feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
@@ -0,0 +1,860 @@
|
||||
"""Account-level browser login states shared by many script bindings.
|
||||
|
||||
One account owns a vault under ``state/accounts/<account_id>/`` 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
|
||||
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:
|
||||
parsed = urlparse(str(url))
|
||||
hostname = (parsed.hostname or "").casefold()
|
||||
path = parsed.path.casefold()
|
||||
markers = ("login", "passport", "signin", "sign-in")
|
||||
return any(marker in hostname or marker in path for marker in markers)
|
||||
|
||||
|
||||
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],
|
||||
}
|
||||
Reference in New Issue
Block a user