All checks were successful
Build and Push Docker Image / build (push) Successful in 34s
154 lines
5.0 KiB
Python
154 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Optional
|
|
|
|
from ...core import AccountSummary, Envelope, ErrorType, fail, ok
|
|
from ...browser.client import connect, new_context, new_page
|
|
from ...browser.navigation import goto_with_auth_check
|
|
from ...browser.auth import ensure_cookies
|
|
from ...core.config import get_playwright_url, load_config
|
|
|
|
# Use the same URL as transactions feature for consistency and reliability
|
|
TRANSACTION_HISTORY_URL = "https://client.schwab.com/app/accounts/history/#/"
|
|
|
|
|
|
def _normalize_account_option(text: str, value: str) -> Optional[AccountSummary]:
|
|
text = text.strip()
|
|
if not text:
|
|
return None
|
|
|
|
normalized_text = re.sub(r"\s+", " ", text)
|
|
|
|
last4_match = re.search(r"(\d{3,4})", normalized_text.replace(" ", ""))
|
|
last4 = last4_match.group(1)[-4:] if last4_match else None
|
|
|
|
type_match = re.search(r"^([A-Za-z&'\- ]+)", normalized_text)
|
|
account_type = (type_match.group(1).strip() if type_match else "Account").replace(" ", "_")
|
|
|
|
account_id_candidates = [candidate for candidate in (value.strip(), last4, normalized_text) if candidate]
|
|
account_id = account_id_candidates[0] if account_id_candidates else normalized_text
|
|
|
|
|
|
label = normalized_text
|
|
is_margin = "margin" in normalized_text.lower()
|
|
|
|
return AccountSummary(
|
|
id=account_id,
|
|
label=label,
|
|
type=account_type,
|
|
last4=last4,
|
|
is_margin=is_margin,
|
|
)
|
|
|
|
|
|
async def list_accounts(debug: bool = False) -> Envelope[list[AccountSummary]]:
|
|
"""
|
|
Discover accounts from Schwab transaction history page.
|
|
|
|
Uses the robust account discovery logic from the transactions feature
|
|
which handles multiple selector patterns and has enhanced reliability.
|
|
"""
|
|
cookies = await ensure_cookies()
|
|
if not cookies:
|
|
return fail("Unable to establish Schwab session.", ErrorType.AUTHENTICATION, retryable=False)
|
|
|
|
config = load_config()
|
|
playwright_url = get_playwright_url(config)
|
|
|
|
playwright = browser = context = page = None
|
|
try:
|
|
playwright, browser = await connect(playwright_url)
|
|
context = await new_context(browser, cookies=cookies)
|
|
page = await new_page(context)
|
|
|
|
if not await goto_with_auth_check(page, context, TRANSACTION_HISTORY_URL, debug=debug):
|
|
return fail("Failed to load transaction history for account discovery.", ErrorType.AUTHENTICATION, retryable=True)
|
|
|
|
# Allow page to fully load
|
|
await asyncio.sleep(2)
|
|
|
|
# Use the robust account discovery from transactions feature
|
|
from ..transactions.scraper import discover_accounts_from_page
|
|
|
|
discovered_accounts = await discover_accounts_from_page(page, debug=debug)
|
|
|
|
if not discovered_accounts:
|
|
return fail("Account dropdown not found on transaction history page.", ErrorType.PARSING, retryable=True)
|
|
|
|
# Convert discovered accounts to AccountSummary objects
|
|
accounts: list[AccountSummary] = []
|
|
seen_ids: set[str] = set()
|
|
|
|
for acc in discovered_accounts:
|
|
# Create AccountSummary from discovered account info
|
|
account_id = acc.get('ending', acc.get('label', ''))
|
|
|
|
if account_id and account_id not in seen_ids:
|
|
summary = AccountSummary(
|
|
id=account_id,
|
|
label=acc.get('label', ''),
|
|
type=acc.get('type', 'Account'),
|
|
last4=acc.get('ending', ''),
|
|
is_margin=False, # Will be enhanced in future if needed
|
|
)
|
|
accounts.append(summary)
|
|
seen_ids.add(account_id)
|
|
|
|
if not accounts:
|
|
return fail("No accounts discovered from Schwab transaction history.", ErrorType.PARSING, retryable=True)
|
|
|
|
if debug:
|
|
print(f"DEBUG: Successfully discovered {len(accounts)} accounts:")
|
|
for acc in accounts:
|
|
print(f"DEBUG: - {acc.label} (type: {acc.type}, last4: {acc.last4})")
|
|
|
|
return ok(accounts)
|
|
except Exception as exc:
|
|
if debug:
|
|
print(f"DEBUG: Account discovery error: {exc}")
|
|
return fail(str(exc), ErrorType.UNKNOWN, retryable=True)
|
|
finally:
|
|
await _safe_close_page(page)
|
|
await _safe_close_context(context)
|
|
await _safe_close_browser(browser)
|
|
await _safe_stop_playwright(playwright)
|
|
|
|
|
|
async def _safe_close_page(page) -> None:
|
|
if page is None:
|
|
return
|
|
try:
|
|
await page.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _safe_close_context(context) -> None:
|
|
if context is None:
|
|
return
|
|
try:
|
|
await context.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _safe_close_browser(browser) -> None:
|
|
if browser is None:
|
|
return
|
|
try:
|
|
await browser.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _safe_stop_playwright(playwright) -> None:
|
|
if playwright is None:
|
|
return
|
|
try:
|
|
await playwright.stop()
|
|
except Exception:
|
|
pass
|
|
|