01218b2907
- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
(journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
857 lines
32 KiB
Python
857 lines
32 KiB
Python
"""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, 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],
|
||
}
|