"""Account management services for the PySide6 accounts tab.""" from __future__ import annotations import os import time from datetime import datetime from typing import Optional from urllib.parse import urlparse from . import appconfig, cdp, chrome, db, editor from . import config as account_config DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST INITIAL_LOGIN_TAB_WAIT_SECONDS = 2.0 INITIAL_LOGIN_TAB_POLL_SECONDS = 0.1 SAFE_STARTUP_PAGE_PREFIXES = ( "about:blank", "chrome://newtab", "chrome://new-tab-page", "chrome://welcome", "chrome://intro", ) class AccountError(RuntimeError): """Raised when account management cannot complete an operation.""" def _now() -> str: return datetime.now().isoformat(timespec="seconds") def _db_path(path=None, config=None) -> str: return path or appconfig.db_path(config) def _trim(value) -> str: return str(value or "").strip() def _optional_text(value) -> Optional[str]: text = _trim(value) return text or None def _required_text(value, field_name: str) -> str: text = _trim(value) if not text: raise AccountError(f"{field_name}不能为空") return text def _account_value(account, field, default=None): if isinstance(account, dict): return account.get(field, default) return getattr(account, field, default) def normalize_region_host(value=None) -> str: text = _trim(value) or DEFAULT_REGION_HOST if "://" in text: text = urlparse(text).netloc text = text.strip("/") if "/" in text: text = text.split("/", 1)[0] return text or DEFAULT_REGION_HOST def normalize_debug_port(value) -> int: try: port = int(value) except (TypeError, ValueError) as exc: raise AccountError("调试端口必须是数字") from exc if port <= 0 or port > 65535: raise AccountError("调试端口必须在 1-65535 范围内") return port def preview_user_data_dir(alias, config=None) -> str: slug = account_config.make_slug(alias) root = appconfig.user_data_root(config) return os.path.abspath(os.path.join(str(root), slug)) def mask_password(password) -> str: return "******" if password else "" def next_debug_port(config=None, path=None) -> int: cfg = appconfig.load_config() if config is None else config start, end = appconfig.debug_port_range(cfg) database_path = _db_path(path, cfg) db.init_db(database_path) used = {int(account.debug_port) for account in db.list_accounts(path=database_path)} for port in range(start, end + 1): if port not in used: return port return end + 1 def _assert_debug_port_available(port, database_path, ignore_alias=None) -> None: used_by = [ account.alias for account in db.list_accounts(path=database_path) if int(account.debug_port) == int(port) and account.alias != ignore_alias ] if used_by: raise AccountError(f"调试端口 {port} 已被账号 {used_by[0]} 使用") def list_accounts(path=None, config=None): database_path = _db_path(path, config) db.init_db(database_path) return db.list_accounts(path=database_path) def get_account(alias, path=None, config=None): database_path = _db_path(path, config) db.init_db(database_path) account = db.get_account_by_alias(alias, path=database_path) if account is None: raise AccountError(f"账号不存在: {alias}") return account def create_account( account_name, alias, region_host=None, debug_port=None, password=None, note=None, path=None, config=None, ): cfg = appconfig.load_config() if config is None else config database_path = _db_path(path, cfg) db.init_db(database_path) account_name = _required_text(account_name, "账号名") alias = _required_text(alias, "别名") region_host = normalize_region_host(region_host) port = normalize_debug_port( next_debug_port(config=cfg, path=database_path) if debug_port is None else debug_port ) _assert_debug_port_available(port, database_path) slug = account_config.make_slug(alias) user_data_dir = account_config.ensure_user_data_dir(slug, config=cfg) try: return db.add_account( account_name, alias, region_host, port, password=_optional_text(password), note=_optional_text(note), slug=slug, user_data_dir=user_data_dir, path=database_path, ) except db.DbError as exc: raise AccountError(str(exc)) from exc def update_account( original_alias, account_name, alias, region_host=None, debug_port=None, password=None, note=None, path=None, config=None, ): cfg = appconfig.load_config() if config is None else config database_path = _db_path(path, cfg) existing = get_account(original_alias, path=database_path, config=cfg) account_name = _required_text(account_name, "账号名") alias = _required_text(alias, "别名") region_host = normalize_region_host(region_host) port = normalize_debug_port(debug_port) _assert_debug_port_available(port, database_path, ignore_alias=existing.alias) if alias != existing.alias: slug = account_config.make_slug(alias) user_data_dir = account_config.ensure_user_data_dir(slug, config=cfg) else: slug = existing.slug user_data_dir = os.path.abspath(existing.user_data_dir) os.makedirs(user_data_dir, exist_ok=True) try: db.update_account( original_alias, path=database_path, account_name=account_name, alias=alias, region_host=region_host, slug=slug, user_data_dir=user_data_dir, debug_port=port, password=_optional_text(password), note=_optional_text(note), ) return get_account(alias, path=database_path, config=cfg) except db.DbError as exc: raise AccountError(str(exc)) from exc def delete_account(alias, path=None, config=None) -> None: database_path = _db_path(path, config) db.init_db(database_path) db.delete_account(alias, path=database_path) def resolve_account(account_or_alias, path=None, config=None): if isinstance(account_or_alias, str): return get_account(account_or_alias, path=path, config=config) return account_or_alias def _debug_host(account) -> str: return f"{chrome.CDP_HOST}:{normalize_debug_port(_account_value(account, 'debug_port'))}" def _seller_portal_url(account) -> str: return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/" def _page_tabs(host): return [ tab for tab in cdp.http_get("/json", host=host) if tab.get("type") == "page" ] def _find_login_tab_in_tabs(account, tabs): region_host = normalize_region_host(_account_value(account, 'region_host')).lower() seller_tabs = [] login_tabs = [] for tab in tabs: url = str(tab.get("url") or "") lower_url = url.lower() if region_host in lower_url: seller_tabs.append(tab) elif "accounts.shopee." in lower_url and "/seller/login" in lower_url: login_tabs.append(tab) return (seller_tabs or login_tabs or [None])[0] def _find_login_tab(account, host): return _find_login_tab_in_tabs(account, _page_tabs(host)) def _wait_for_initial_login_tab(account, host, timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS): timeout = max(0.0, float(timeout)) deadline = time.monotonic() + timeout last_tabs = [] probe_error = None first_probe = True while first_probe or time.monotonic() < deadline: first_probe = False try: last_tabs = _page_tabs(host) probe_error = None except Exception as exc: probe_error = exc.__class__.__name__ tab = _find_login_tab_in_tabs(account, last_tabs) if tab: return tab, last_tabs, probe_error remaining = deadline - time.monotonic() if remaining <= 0: break time.sleep(min(INITIAL_LOGIN_TAB_POLL_SECONDS, remaining)) return None, last_tabs, probe_error def _safe_startup_page(tabs): if len(tabs) != 1: return None tab = tabs[0] url = str(tab.get("url") or "").strip().lower() if not url or any(url.startswith(prefix) for prefix in SAFE_STARTUP_PAGE_PREFIXES): return tab return None def _navigate_page_target(tab, url): websocket_url = tab.get("webSocketDebuggerUrl") if not websocket_url: raise AccountError("Chrome 初始页面缺少 CDP 地址,无法复用") client = cdp.CDP(websocket_url) try: result = client.send("Page.navigate", {"url": url}) finally: client.close() if result.get("errorText"): raise AccountError("Chrome 初始页面导航到蝦皮卖家中心失败") def _activate_login_tab_result( tab, host, portal_url, *, created_tab=False, startup_target_reused=False, startup_page_navigated=False, page_target_count=0, fallback_reason=None, target_probe_error=None, ): target_id = tab.get("id") if target_id: cdp.activate_tab(target_id, host=host) return { "target_id": target_id, "url": tab.get("url") or portal_url, "created_tab": bool(created_tab), "startup_target_reused": bool(startup_target_reused), "startup_page_navigated": bool(startup_page_navigated), "page_target_count": int(page_target_count), "fallback_reason": fallback_reason, "target_probe_error": target_probe_error, } def _open_or_activate_login_tab( account, *, newly_launched=False, startup_wait_timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS, ): host = _debug_host(account) portal_url = _seller_portal_url(account) if newly_launched: tab, tabs, probe_error = _wait_for_initial_login_tab( account, host, timeout=startup_wait_timeout, ) else: tabs = _page_tabs(host) tab = _find_login_tab_in_tabs(account, tabs) probe_error = None if tab: return _activate_login_tab_result( tab, host, portal_url, startup_target_reused=newly_launched, page_target_count=len(tabs), target_probe_error=probe_error, ) fallback_reason = "existing_chrome_no_login_tab" if newly_launched: startup_tab = _safe_startup_page(tabs) if startup_tab: try: _navigate_page_target(startup_tab, portal_url) except Exception as exc: fallback_reason = "startup_navigation_failed" probe_error = probe_error or exc.__class__.__name__ else: navigated_tab = dict(startup_tab) navigated_tab["url"] = portal_url return _activate_login_tab_result( navigated_tab, host, portal_url, startup_target_reused=True, startup_page_navigated=True, page_target_count=len(tabs), target_probe_error=probe_error, ) elif tabs: fallback_reason = "startup_page_not_safe" else: fallback_reason = "startup_page_missing" tab = cdp.create_tab_info(portal_url, host=host) return _activate_login_tab_result( tab, host, portal_url, created_tab=True, page_target_count=len(tabs) + 1, fallback_reason=fallback_reason, target_probe_error=probe_error, ) def launch_for_login(account_or_alias, path=None, config=None): account = resolve_account(account_or_alias, path=path, config=config) port = normalize_debug_port(_account_value(account, "debug_port")) alias = _account_value(account, "alias") portal_url = _seller_portal_url(account) if chrome.is_running(port): tab = _open_or_activate_login_tab(account, newly_launched=False) return { "ok": True, "action": "reused", "reused": True, "launched": False, "alias": alias, "debug_port": port, "pid": None, "initial_url": portal_url, **tab, } process = chrome.launch_chrome(account, config=config, initial_url=portal_url) timeout = appconfig.cdp_ready_timeout(config) if not chrome.wait_debug_ready(port, timeout=timeout): raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪") tab = _open_or_activate_login_tab(account, newly_launched=True) return { "ok": True, "action": "launched", "reused": False, "launched": True, "alias": alias, "debug_port": port, "pid": getattr(process, "pid", None), "initial_url": portal_url, **tab, } def create_shortcut(account_or_alias, shortcut_path=None, desktop_dir=None, path=None, config=None) -> str: account = resolve_account(account_or_alias, path=path, config=config) return chrome.create_shortcut( account, shortcut_path=shortcut_path, desktop_dir=desktop_dir, config=config, ) def detect_login(account_or_alias, timeout=8, path=None, config=None) -> dict: database_path = _db_path(path, config) account = resolve_account(account_or_alias, path=database_path, config=config) status = editor.login_status(account, timeout=timeout) if status.get("logged_in"): db.update_account(account.alias, path=database_path, last_login_at=_now()) return status def login_status_text(status) -> str: if not status: return "未知" if status.get("logged_in"): return "已登录" reason = status.get("reason") if reason == "LOGIN_PAGE": return "未登录" if reason == "NO_SESSION_COOKIE": return "未登录" if reason == "LOGIN_CHECK_TARGET_UNAVAILABLE" or str(reason).startswith( "LOGIN_CHECK_FAILED" ): return "登录状态暂不可确认" if reason: return f"未登录({reason})" return "未登录"