"""Account-level browser login states shared by many script bindings. One account owns a vault under ``state/accounts//`` holding the authoritative cookies.json / storage_state.json and a dedicated login profile. Member scripts keep their own isolated browser profiles (required for parallel execution) but receive a merged copy of the vault cookies on every run, so a single manual login refreshes the whole account group. """ from __future__ import annotations import json import os import subprocess import sys import time from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, TextIO from urllib.parse import urlparse from gyxx_flow.adapters.browser import BrowserCookieStore, looks_like_login_url from gyxx_flow.adapters.integration import ( RuntimeIntegrationCatalog, _account_cookie_valid, ) from gyxx_flow.adapters.scrapling import ScraplingBrowser, is_browser_timeout_error from gyxx_flow.core.artifacts import atomic_write_json from gyxx_flow.core.config import Settings from gyxx_flow.core.locks import LockManager, ResourceBusyError DEFAULT_LOGIN_TIMEOUT_SECONDS = 600 _DEFAULT_TARGET_URL = ( "https://fxg.jinritemai.com/ffa/merchant/campaign-square" "?list_tab=access&f_tab=0" ) _POLL_INTERVAL_SECONDS = 5 _PROGRESS_INTERVAL_SECONDS = 30 _KEEPALIVE_RETRY_SECONDS = 60 _KEEPALIVE_SETTLE_MILLISECONDS = 3_000 _KEEPALIVE_RESOURCE_PREFIX = "browser-account" def _load_catalog(settings: Settings) -> RuntimeIntegrationCatalog: return RuntimeIntegrationCatalog.load_default( project_root=settings.project_root, data_root=settings.data_root, ) def account_status( catalog: RuntimeIntegrationCatalog, account_id: str, ) -> dict[str, object]: account = catalog.account_for(account_id) store = BrowserCookieStore(account.cookie_file, account.storage_state_file) valid = False if account.cookie_file.exists(): try: valid = _account_cookie_valid(account, store.load_cookies()) except (OSError, ValueError): valid = False keepalive_status = _read_keepalive_status(account) return { "account_id": account.account_id, "cdp_port": account.cdp_port, "login_mode": account.login_mode, "login_command_id": account.login_command_id, "required_cookie_domains": list(account.required_cookie_domains), "required_cookie_names": list(account.required_cookie_names), "members": [ { "script_id": binding.script_id, "command_id": binding.command_id, "cookie_file": str(binding.cookie_file), } for binding in catalog.bindings_for_account(account_id) ], "vault_cookie_file": str(account.cookie_file), "vault_exists": account.cookie_file.exists(), "vault_valid": valid, "keepalive": { "enabled": account.keepalive_enabled, "url": account.keepalive_url, "initial_delay_seconds": account.keepalive_initial_delay_seconds, "interval_seconds": account.keepalive_interval_seconds, "timeout_ms": account.keepalive_timeout_ms, "headless": account.keepalive_headless, "real_chrome": account.keepalive_real_chrome, "status": keepalive_status, }, } def list_accounts(settings: Settings) -> dict[str, object]: catalog = _load_catalog(settings) return { "accounts": [ account_status(catalog, account.account_id) for account in catalog.accounts ] } def sync_account( settings: Settings, account_id: str | None, output: TextIO, ) -> dict[str, object]: catalog = _load_catalog(settings) targets = ( [account_id] if account_id is not None else [account.account_id for account in catalog.accounts] ) if not targets: raise ValueError("no runtime accounts are configured") results: list[dict[str, object]] = [] for target in targets: output.write(f"[sync] account={target}\n") for item in catalog.sync_account_state(target): flag = "updated" if item["synced"] else "up-to-date" output.write(f" [{flag}] {item['script_id']}\n") results.append(item) return {"synced": results} def seed_account( settings: Settings, account_id: str, source_binding_id: str, output: TextIO, *, force: bool = False, ) -> dict[str, object]: """Import one existing binding's authenticated state into an account vault. Only cookies whose domains belong to the target account are copied. This prevents a legacy browser profile containing several platforms from leaking unrelated sessions into a new account vault. """ catalog = _load_catalog(settings) account = catalog.account_for(account_id) source = catalog.binding_for(source_binding_id) if source.account and source.account != account_id: raise ValueError( f"source binding {source_binding_id} already belongs to account " f"{source.account}" ) target_store = BrowserCookieStore(account.cookie_file, account.storage_state_file) if account.cookie_file.exists() and not force: if _vault_cookie_valid(account, target_store): output.write( f"[seed] account={account_id} already has a valid vault; " "use --force to replace it\n" ) return {"account_id": account_id, "status": "already_valid"} raise ValueError( f"account {account_id} has an existing invalid vault; " "use --force to replace it" ) source_store = BrowserCookieStore(source.cookie_file, source.storage_state_file) source_cookies = source_store.load_cookies() cookies = [ item for item in source_cookies if _cookie_matches_account(item, account.required_cookie_domains) ] if not cookies: raise ValueError( f"source binding {source_binding_id} has no cookies for account " f"{account_id}" ) if not _account_cookie_valid(account, cookies): raise ValueError( f"source binding {source_binding_id} does not contain a valid " f"login cookie for account {account_id}" ) source_state = source_store.load_storage_state() or {} state = _filter_storage_state(source_state, account.required_cookie_domains) state["cookies"] = cookies target_store.save_cookies(cookies) target_store.save_storage_state(state) pushed = catalog.sync_account_state(account_id) synced_count = sum(1 for item in pushed if item["synced"]) output.write( f"[seed] account={account_id} imported {len(cookies)} cookies from " f"{source_binding_id}; synced {synced_count} members\n" ) return { "account_id": account_id, "status": "seeded", "cookie_count": len(cookies), "source_binding": source_binding_id, "synced_members": [item["script_id"] for item in pushed], } class AccountKeepaliveManager: """Refresh configured account sessions from the resident scheduler. A refresh is deliberately published only after the safe URL remains on an authenticated page and the required cookies are still live. This keeps a redirect to login, a hard server-side expiry, or a challenge page from replacing the last known-good vault state. """ def __init__( self, settings: Settings, *, catalog: RuntimeIntegrationCatalog | None = None, clock: Callable[[], float] = time.monotonic, login_runner: Callable[[Any], dict[str, object]] | None = None, ) -> None: self.settings = settings self.catalog = catalog or _load_catalog(settings) self._clock = clock self._next_due: dict[str, float] = {} self._locks = LockManager(settings.data_root / "state" / "locks") self._login_runner = login_runner or self._run_configured_login def tick(self) -> list[dict[str, object]]: """Run due account refreshes once; never starts work in dry-run mode.""" now = self._clock() outcomes: list[dict[str, object]] = [] for account in self.catalog.accounts: if not account.keepalive_enabled: continue if account.account_id not in self._next_due: self._next_due[account.account_id] = ( now + account.keepalive_initial_delay_seconds ) if now < self._next_due.get(account.account_id, 0.0): continue outcome = self.refresh_account(account.account_id) outcomes.append(outcome) interval = account.keepalive_interval_seconds if outcome["status"] in {"not_logged_in", "busy"}: interval = min(interval, _KEEPALIVE_RETRY_SECONDS) self._next_due[account.account_id] = now + interval return outcomes def refresh_account(self, account_id: str) -> dict[str, object]: """Refresh one configured account and return redacted operational evidence.""" account = self.catalog.account_for(account_id) if not account.keepalive_enabled: return self._record_keepalive_status( account, {"status": "disabled"}, ) store = BrowserCookieStore(account.cookie_file, account.storage_state_file) resource = f"{_KEEPALIVE_RESOURCE_PREFIX}:{account.account_id}" try: with self._locks.acquire( resource, owner=f"keepalive:{account.account_id}", timeout_seconds=0, ): # Login can happen between the first check and lock acquisition. # A prior authenticated-page failure also requires recovery even # when the browser still reports a structurally live cookie. vault_valid = _vault_cookie_valid(account, store) needs_relogin = not vault_valid or _keepalive_requires_relogin( _read_keepalive_status(account) ) if needs_relogin and account.login_mode == "C": return self._recover_locked(account, store) if not vault_valid: return self._record_keepalive_status( account, {"status": "not_logged_in"}, ) return self._refresh_locked(account) except ResourceBusyError: return self._record_keepalive_status(account, {"status": "busy"}) def _recover_locked( self, account: Any, store: BrowserCookieStore, ) -> dict[str, object]: """Run the configured credential login and publish only verified state.""" try: result = self._login_runner(account) except Exception as exc: # pragma: no cover - runtime dependent result = { "status": "not_logged_in", "login_attempted": True, "login_error_type": type(exc).__name__, } if isinstance(result, dict) and result.get("status") in { "success", "logged_in", } and _vault_cookie_valid(account, store): try: cookie_count = len(store.load_cookies()) except (OSError, ValueError): cookie_count = 0 try: pushed = self.catalog.sync_account_state(account.account_id) except Exception as exc: # pragma: no cover - runtime dependent return self._record_keepalive_status( account, { "status": "error", "error_type": type(exc).__name__, "login_recovered": True, }, ) return self._record_keepalive_status( account, { "status": "success", "login_recovered": True, "cookie_count": cookie_count, "synced_members": sum( 1 for item in pushed if item.get("synced") ), }, ) evidence: dict[str, object] = { "status": "not_logged_in", "login_attempted": True, } if isinstance(result, dict): for key in ("login_reason", "login_error_type", "login_exit_code"): value = result.get(key) if isinstance(value, (str, int)) and not isinstance(value, bool): evidence[key] = value attempted = result.get("login_attempted") if isinstance(attempted, bool): evidence["login_attempted"] = attempted return self._record_keepalive_status(account, evidence) def _run_configured_login(self, account: Any) -> dict[str, object]: """Invoke a binding-owned login script without putting secrets in argv.""" command_id = str(getattr(account, "login_command_id", "") or "").strip() if not command_id: return { "status": "not_logged_in", "login_attempted": False, "login_reason": "no_login_command", } try: binding = self.catalog.binding_for(command_id) except Exception: # pragma: no cover - invalid deployment configuration return { "status": "not_logged_in", "login_attempted": False, "login_reason": "login_command_unavailable", } try: environment = self.catalog.environment_for( binding.command_id, os.environ, ) except Exception as exc: # pragma: no cover - runtime configuration dependent return { "status": "not_logged_in", "login_attempted": False, "login_error_type": type(exc).__name__, } config_path = Path( environment.get("GYXX_PRODUCT_CONFIG", "").strip() or ( self.settings.data_root / "state" / "product_commerce" / "auto-flow-config.json" ) ).expanduser() environment["GYXX_PRODUCT_CONFIG"] = str(config_path) credential_names = tuple(getattr(account, "credential_env_names", ()) or ()) username = environment.get(credential_names[0], "") if credential_names else "" password = "" for name in credential_names[1:]: candidate = environment.get(name, "") if candidate.strip(): password = candidate break # Product ERP login historically keeps its login block in the ignored # runtime config. Let the child script read that file when the # conventional credential env vars are absent; secrets still never # enter argv or this manager's diagnostic result. if (not username.strip() or not password) and not config_path.is_file(): return { "status": "not_logged_in", "login_attempted": False, "login_reason": "credentials_unavailable", } # The ERP login script reads these conventional names. Values stay in # the child environment and never appear in the process argument list. environment["ERP_USERNAME"] = username environment["ERP_PASSWORD"] = password try: script_path = self._login_script_path(binding) except (OSError, ValueError): return { "status": "not_logged_in", "login_attempted": False, "login_reason": "login_script_unavailable", } timeout_seconds = max( DEFAULT_LOGIN_TIMEOUT_SECONDS, (int(account.keepalive_timeout_ms) // 1000) * 3, ) try: completed = subprocess.run( [sys.executable, str(script_path), "--login-only"], cwd=str(self.settings.project_root), env=environment, check=False, timeout=timeout_seconds, ) except subprocess.TimeoutExpired: return { "status": "not_logged_in", "login_attempted": True, "login_error_type": "TimeoutExpired", } except OSError as exc: # pragma: no cover - runtime dependent return { "status": "not_logged_in", "login_attempted": True, "login_error_type": type(exc).__name__, } try: exit_code = int(getattr(completed, "returncode", 1)) except (TypeError, ValueError): exit_code = 1 return { "status": "success" if exit_code == 0 else "not_logged_in", "login_attempted": True, "login_exit_code": exit_code, } def _login_script_path(self, binding: Any) -> Path: project_root = Path(self.settings.project_root).expanduser().resolve() module_root = ( project_root / "src" / "gyxx_flow" / "modules" / str(binding.module) ).resolve() entry = PurePosixPath(str(binding.entry)) if entry.is_absolute() or ".." in entry.parts: raise ValueError("login script entry must stay inside its module") script_path = module_root.joinpath(*entry.parts).resolve() if not script_path.is_relative_to(module_root) or not script_path.is_file(): raise ValueError("configured login script is unavailable") return script_path def _refresh_locked( self, account: Any, ) -> dict[str, object]: browser = ScraplingBrowser( user_data_dir=str(account.profile_dir), cookie_file=account.cookie_file, storage_state_file=account.storage_state_file, headless=account.keepalive_headless, real_chrome=account.keepalive_real_chrome, retries=1, persist_state_on_close=False, ) try: browser.start() response, page_state = browser.run_fetch_action( account.keepalive_url, lambda page: _probe_account_page(page, account), wait=0, network_idle=False, timeout=account.keepalive_timeout_ms, ) final_url = str(page_state.get("url", "")) identity_matches = bool(page_state.get("identity_matches", False)) status = getattr(response, "status", None) if isinstance(status, int) and status >= 400: return self._record_keepalive_status( account, {"status": "http_error", "http_status": status}, ) context = browser.context if context is None: raise RuntimeError("browser context was not created") cookies = context.cookies() if ( _looks_like_login_url(final_url) or not identity_matches or not _account_cookie_valid(account, cookies) ): return self._record_keepalive_status( account, { "status": "expired", "final_url_login": _looks_like_login_url(final_url), "identity_mismatch": not identity_matches, }, ) # Scrapling may have received Set-Cookie headers while navigating. # Publish only this verified authenticated snapshot. browser.persist_bound_state() pushed = self.catalog.sync_account_state(account.account_id) return self._record_keepalive_status( account, { "status": "success", "cookie_count": len(cookies), "synced_members": sum( 1 for item in pushed if item.get("synced") ), }, ) except Exception as exc: # pragma: no cover - browser/runtime dependent return self._record_keepalive_status( account, { "status": "timeout" if is_browser_timeout_error(exc) else "error", "error_type": type(exc).__name__, }, ) finally: try: browser.close() except Exception: # pragma: no cover - browser/runtime dependent pass @staticmethod def _status_path(account: Any): return account.cookie_file.with_name("keepalive_status.json") def _record_keepalive_status( self, account: Any, result: dict[str, object], ) -> dict[str, object]: path = self._status_path(account) previous: dict[str, object] = {} if path.exists(): try: payload = json.loads(path.read_text(encoding="utf-8")) if isinstance(payload, dict): previous = payload except (OSError, json.JSONDecodeError): previous = {} if result.get("status") == "success": # A prior failed probe may have recorded these diagnostic fields. # They must not survive a later verified successful refresh. previous.pop("final_url_login", None) previous.pop("identity_mismatch", None) previous.pop("error_type", None) previous.pop("http_status", None) previous.pop("login_attempted", None) previous.pop("login_recovered", None) previous.pop("login_reason", None) previous.pop("login_error_type", None) previous.pop("login_exit_code", None) timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds") payload = { **previous, "account_id": account.account_id, "updated_at": timestamp, **result, } if result.get("status") == "success": payload["last_success_at"] = timestamp atomic_write_json(path, payload) return dict(result) def _vault_cookie_valid(account: Any, store: BrowserCookieStore) -> bool: if not account.cookie_file.exists(): return False try: return _account_cookie_valid(account, store.load_cookies()) except (OSError, ValueError): return False def _cookie_matches_account( cookie: dict[str, object], required_domains: tuple[str, ...] ) -> bool: domain = str(cookie.get("domain", "")).lstrip(".").casefold() return bool(domain) and any( domain == required.casefold() or domain.endswith("." + required.casefold()) for required in required_domains ) def _filter_storage_state( state: dict[str, object], required_domains: tuple[str, ...] ) -> dict[str, object]: filtered = dict(state) origins = state.get("origins") if isinstance(origins, list): filtered["origins"] = [ origin for origin in origins if isinstance(origin, dict) and _origin_matches_account(origin, required_domains) ] else: filtered["origins"] = [] return filtered def _origin_matches_account( origin: dict[str, object], required_domains: tuple[str, ...] ) -> bool: hostname = urlparse(str(origin.get("origin", ""))).hostname or "" hostname = hostname.casefold() return bool(hostname) and any( hostname == required.casefold() or hostname.endswith("." + required.casefold()) for required in required_domains ) def _looks_like_login_url(url: str) -> bool: return looks_like_login_url(url) def _probe_account_page(page: Any, account: Any) -> dict[str, object]: """Read account state only after client-side redirects have settled.""" wait_for_timeout = getattr(page, "wait_for_timeout", None) if callable(wait_for_timeout): wait_for_timeout(_KEEPALIVE_SETTLE_MILLISECONDS) return { "url": str(getattr(page, "url", "")), "identity_matches": _page_matches_account_identity(page, account), } def _page_matches_account_identity(page: Any, account: Any) -> bool: """Require the authenticated page to identify the configured account.""" if _looks_like_login_url(str(getattr(page, "url", ""))): return False markers = tuple(getattr(account, "login_identity_markers", ()) or ()) if not markers: return True evaluate = getattr(page, "evaluate", None) if not callable(evaluate): return False result = evaluate( """ (markers) => { const text = (document.body && document.body.innerText) || document.documentElement.outerHTML || ""; const normalized = text.toLocaleLowerCase(); return markers.some((marker) => normalized.includes( String(marker).toLocaleLowerCase() )); } """, list(markers), ) return bool(result) def _read_keepalive_status(account: Any) -> dict[str, object] | None: path = account.cookie_file.with_name("keepalive_status.json") if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None return payload if isinstance(payload, dict) else None def _keepalive_requires_relogin(status: dict[str, object] | None) -> bool: """Do not let a structurally valid cookie bypass a failed identity check.""" if not status: return False return bool(status.get("identity_mismatch")) or status.get("status") in { "expired", "not_logged_in", } def login_account( settings: Settings, account_id: str, *, output: TextIO, timeout_seconds: int = DEFAULT_LOGIN_TIMEOUT_SECONDS, target_url: str | None = None, force: bool = False, ) -> dict[str, object]: """Open the account login browser, wait for a human login, and save the vault.""" catalog = _load_catalog(settings) account = catalog.account_for(account_id) target_url = target_url or account.keepalive_url or _DEFAULT_TARGET_URL store = BrowserCookieStore(account.cookie_file, account.storage_state_file) current_status = account_status(catalog, account_id) existing_valid = current_status["vault_valid"] keepalive_status = current_status["keepalive"]["status"] needs_relogin = _keepalive_requires_relogin(keepalive_status) if existing_valid and not needs_relogin and not force: output.write( f"[login] 账号 {account_id} 的登录态已有效,跳过(--force 强制重登)\n" ) return {"account_id": account_id, "status": "already_valid"} output.write( f"[login] 账号 {account_id}(CDP 端口 {account.cdp_port})\n" f"[login] 正在打开浏览器: {account.profile_dir}\n" f"[login] 请在浏览器窗口中完成登录(扫码/验证码),最长等待 {timeout_seconds}s\n" ) started = time.monotonic() deadline = started + timeout_seconds last_progress = started vault_saved = False cookie_count = 0 locks = LockManager(settings.data_root / "state" / "locks") try: browser_lock = locks.acquire( f"{_KEEPALIVE_RESOURCE_PREFIX}:{account.account_id}", owner=f"login:{account.account_id}", timeout_seconds=0, ) browser_lock.__enter__() except ResourceBusyError as exc: raise ValueError( f"账号 {account_id} 的浏览器正在被 keepalive 使用,请稍后重试" ) from exc browser = ScraplingBrowser( stealthy=True, user_data_dir=str(account.profile_dir), headless=False, real_chrome=True, retries=1, persist_state_on_close=False, extra_flags=[ f"--remote-debugging-port={account.cdp_port}", "--remote-debugging-address=127.0.0.1", "--disable-session-crashed-bubble", "--disable-features=InfiniteSessionRestore", ], ) try: try: browser.start() except Exception as exc: # pragma: no cover - browser dependent raise ValueError( "无法启动账号登录浏览器(可能已被占用)。" "请先关闭该账号的浏览器进程后重试" ) from exc context = browser.context if context is None: raise ValueError("账号登录浏览器未创建上下文") page = context.new_page() if force: # ``--force`` is used for account switching as well as expiry # recovery. Clear only this account's live browser context so a # still-valid old cookie cannot make the loop accept the old # identity before the operator enters the new one. The # authoritative vault is not overwritten until verification # succeeds below. clear_cookies = getattr(context, "clear_cookies", None) if not callable(clear_cookies): raise ValueError("强制切换账号需要浏览器支持清理 cookie") clear_cookies() try: page.goto(target_url, wait_until="domcontentloaded", timeout=60_000) page.evaluate( "() => { localStorage.clear(); sessionStorage.clear(); }" ) except Exception: # Some login pages deny storage access before their first # navigation; cookie clearing is still sufficient and the # official page is opened again below. pass page.goto(target_url, wait_until="domcontentloaded", timeout=60_000) while True: cookies = context.cookies() page_state = _probe_account_page(page, account) if _account_cookie_valid(account, cookies) and bool( page_state["identity_matches"] ): break now = time.monotonic() if now - last_progress >= _PROGRESS_INTERVAL_SECONDS: elapsed = int(now - started) output.write( f"[login] 等待登录中... {elapsed}s / {timeout_seconds}s " f"(共 {len(cookies)} 个 cookie)\n" ) last_progress = now if now >= deadline: raise TimeoutError( f"等待登录超时({timeout_seconds}s),未检测到登录态;" "浏览器状态保留在账号 profile 中,可再次运行本命令继续" ) time.sleep(_POLL_INTERVAL_SECONDS) cookies = context.cookies() storage_state = context.storage_state() store.save_cookies(cookies) store.save_storage_state(storage_state) vault_saved = True cookie_count = len(cookies) output.write( f"[login] 登录成功,已保存 {cookie_count} 个 cookie " f"到 {account.cookie_file}\n" ) except KeyboardInterrupt: output.write( "[login] 已中断;未保存账号登录态,profile 保留以便继续\n" ) return {"account_id": account_id, "status": "interrupted"} finally: try: browser.close() except Exception: # pragma: no cover - browser dependent pass browser_lock.__exit__(None, None, None) if not vault_saved: return {"account_id": account_id, "status": "not_logged_in"} pushed = catalog.sync_account_state(account_id) for item in pushed: output.write( f" [sync] {'updated' if item['synced'] else 'up-to-date'} " f"{item['script_id']}\n" ) return { "account_id": account_id, "status": "logged_in", "cookie_count": cookie_count, "synced_members": [item["script_id"] for item in pushed], }