From 472672949a84878e82e254c27f6aaa251aa51164 Mon Sep 17 00:00:00 2001 From: Hoshino-Yumetsuki Date: Fri, 3 Apr 2026 23:14:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81docker=E5=86=85?= =?UTF-8?q?=E6=9C=89=E5=A4=B4=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- core/browser_runtime.py | 64 ++++++++++++++++ core/executors/playwright.py | 70 ++++++++++++----- docker-compose.yml | 1 + docker/entrypoint.sh | 3 +- platforms/chatgpt/payment.py | 10 ++- platforms/chatgpt/sentinel_browser.py | 16 +++- platforms/grok/core.py | 99 +++++++++++++++++++----- platforms/grok/plugin.py | 51 ++++++++++--- platforms/kiro/core.py | 72 +++++++++++------ platforms/kiro/plugin.py | 76 +++++++++++------- platforms/tavily/plugin.py | 106 +++++++++++++++++++------- 12 files changed, 436 insertions(+), 134 deletions(-) create mode 100644 core/browser_runtime.py diff --git a/Dockerfile b/Dockerfile index 672a1b3..3553b76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ COPY scripts/install_camoufox.py /tmp/install_camoufox.py RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates \ - libgtk-3-0 libx11-xcb1 libasound2 \ + libgtk-3-0 libx11-xcb1 libasound2 xvfb xauth \ && curl -fsSL https://go.dev/dl/go1.24.2.linux-amd64.tar.gz | tar -C /usr/local -xz \ && curl -LsSf https://astral.sh/uv/install.sh | sh \ && rm -rf /var/lib/apt/lists/* diff --git a/core/browser_runtime.py b/core/browser_runtime.py new file mode 100644 index 0000000..3be9bec --- /dev/null +++ b/core/browser_runtime.py @@ -0,0 +1,64 @@ +"""Browser runtime helpers for headless/headed resolution.""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Iterable + + +logger = logging.getLogger(__name__) + +_TRUE_VALUES = {"1", "true", "yes", "on"} +_FALSE_VALUES = {"0", "false", "no", "off"} + + +def parse_env_bool(name: str) -> bool | None: + raw = os.getenv(name) + if raw is None: + return None + + value = str(raw).strip().lower() + if not value: + return None + if value in _TRUE_VALUES: + return True + if value in _FALSE_VALUES: + return False + + logger.warning("忽略无效布尔环境变量 %s=%r", name, raw) + return None + + +def resolve_browser_headless( + requested_headless: bool | None, + *, + default_headless: bool = True, + override_env_names: Iterable[str] = ("PLAYWRIGHT_HEADLESS", "REGISTER_HEADLESS"), +) -> tuple[bool, str]: + for env_name in override_env_names: + override = parse_env_bool(env_name) + if override is not None: + return override, f"env:{env_name}={str(override).lower()}" + + if requested_headless is not None: + return bool( + requested_headless + ), f"requested:{str(bool(requested_headless)).lower()}" + + return bool(default_headless), f"default:{str(bool(default_headless)).lower()}" + + +def ensure_browser_display_available(headless: bool) -> None: + if headless: + return + if not sys.platform.startswith("linux"): + return + if os.getenv("DISPLAY"): + return + + raise RuntimeError( + "当前为 Linux 有头浏览器模式,但未检测到 DISPLAY。" + "Docker 内请启用 Xvfb;本地 Linux 请先启动图形环境或改用无头模式。" + ) diff --git a/core/executors/playwright.py b/core/executors/playwright.py index 39fd41d..9694cf4 100644 --- a/core/executors/playwright.py +++ b/core/executors/playwright.py @@ -1,47 +1,80 @@ """Playwright 执行器 - 支持 headless/headed 模式""" +import logging +from typing import Any + from ..base_executor import BaseExecutor, Response +from ..browser_runtime import ensure_browser_display_available, resolve_browser_headless from ..proxy_utils import build_playwright_proxy_config +logger = logging.getLogger(__name__) + + class PlaywrightExecutor(BaseExecutor): - def __init__(self, proxy: str = None, headless: bool = True): - super().__init__(proxy) + def __init__(self, proxy: str | None = None, headless: bool = True): + super().__init__(proxy or "") self.headless = headless - self._browser = None - self._context = None - self._page = None + self._pw: Any | None = None + self._browser: Any | None = None + self._context: Any | None = None + self._page: Any | None = None self._init() - def _init(self): + def _init(self) -> None: from playwright.sync_api import sync_playwright self._pw = sync_playwright().start() - launch_opts = {"headless": self.headless} + headless, reason = resolve_browser_headless(self.headless) + ensure_browser_display_available(headless) + logger.info( + "PlaywrightExecutor 浏览器模式: %s (%s)", + "headless" if headless else "headed", + reason, + ) + + launch_opts: dict[str, Any] = {"headless": headless} if self.proxy: - launch_opts["proxy"] = build_playwright_proxy_config(self.proxy) + proxy_cfg = build_playwright_proxy_config(self.proxy) + if proxy_cfg: + launch_opts["proxy"] = proxy_cfg self._browser = self._pw.chromium.launch(**launch_opts) self._context = self._browser.new_context() self._page = self._context.new_page() + def _require_page(self) -> Any: + if self._page is None: + raise RuntimeError("Playwright page 未初始化") + return self._page + + def _require_context(self) -> Any: + if self._context is None: + raise RuntimeError("Playwright context 未初始化") + return self._context + def get(self, url, *, headers=None, params=None) -> Response: import urllib.parse + page = self._require_page() if params: url = url + "?" + urllib.parse.urlencode(params) if headers: - self._page.set_extra_http_headers(headers) - resp = self._page.goto(url) + page.set_extra_http_headers(headers) + resp = page.goto(url) + if resp is None: + raise RuntimeError(f"Playwright 导航失败: {url}") return Response( status_code=resp.status, - text=self._page.content(), + text=page.content(), headers=dict(resp.headers), cookies=self.get_cookies(), ) def post(self, url, *, headers=None, params=None, data=None, json=None) -> Response: - import urllib.parse, json as _json + import json as _json + import urllib.parse + page = self._require_page() if params: url = url + "?" + urllib.parse.urlencode(params) post_data = None @@ -54,7 +87,7 @@ class PlaywrightExecutor(BaseExecutor): h = {"Content-Type": content_type} if headers: h.update(headers) - resp = self._page.request.post(url, headers=h, data=post_data) + resp = page.request.post(url, headers=h, data=post_data) return Response( status_code=resp.status, text=resp.text(), @@ -63,16 +96,19 @@ class PlaywrightExecutor(BaseExecutor): ) def get_cookies(self) -> dict: - return {c["name"]: c["value"] for c in self._context.cookies()} + context = self._require_context() + return {c["name"]: c["value"] for c in context.cookies()} def set_cookies(self, cookies: dict, domain: str = ".example.com") -> None: - page_url = self._page.url if self._page else None + context = self._require_context() + page = self._require_page() + page_url = page.url if page_url and page_url.startswith("http"): - self._context.add_cookies( + context.add_cookies( [{"name": k, "value": v, "url": page_url} for k, v in cookies.items()] ) else: - self._context.add_cookies( + context.add_cookies( [ {"name": k, "value": v, "domain": domain, "path": "/"} for k, v in cookies.items() diff --git a/docker-compose.yml b/docker-compose.yml index a2db7d6..504f685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: SOLVER_BIND_HOST: 0.0.0.0 LOCAL_SOLVER_URL: http://127.0.0.1:8889 SOLVER_BROWSER_TYPE: ${SOLVER_BROWSER_TYPE:-camoufox} + PLAYWRIGHT_HEADLESS: ${PLAYWRIGHT_HEADLESS:-} ports: - "8000:8000" - "127.0.0.1:8889:8889" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 06a37b0..c08387c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,4 +17,5 @@ ln -sfn "${RUNTIME_DIR}/smstome_all_numbers.txt" "${APP_DIR}/smstome_all_numbers ln -sfn "${RUNTIME_DIR}/smstome_uk_deep_numbers.txt" "${APP_DIR}/smstome_uk_deep_numbers.txt" ln -sfn "${RUNTIME_DIR}/logs/solver.log" "${APP_DIR}/services/turnstile_solver/solver.log" -exec python main.py +echo "[entrypoint] Starting backend under Xvfb so Docker can handle both headed and headless browser tasks" +exec xvfb-run -a --server-args="-screen 0 1920x1080x24" python main.py diff --git a/platforms/chatgpt/payment.py b/platforms/chatgpt/payment.py index 670de8d..d3cf3a1 100644 --- a/platforms/chatgpt/payment.py +++ b/platforms/chatgpt/payment.py @@ -7,9 +7,10 @@ from __future__ import annotations import logging import subprocess import sys -from typing import Optional +from typing import Any, Optional from curl_cffi import requests as cffi_requests +from core.browser_runtime import ensure_browser_display_available from core.proxy_utils import build_requests_proxy_config # from ..database.models import Account # removed: external dep @@ -97,7 +98,7 @@ def _open_url_system_browser(url: str) -> bool: def generate_plus_link( - account: Account, + account: Any, proxy: Optional[str] = None, country: str = "SG", ) -> str: @@ -143,7 +144,7 @@ def generate_plus_link( def generate_team_link( - account: Account, + account: Any, workspace_name: str = "MyTeam", price_interval: str = "month", seat_quantity: int = 5, @@ -210,6 +211,7 @@ def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: def _launch(): try: with sync_playwright() as p: + ensure_browser_display_available(False) browser = p.chromium.launch(headless=False, args=["--incognito"]) ctx = browser.new_context() if cookies_str: @@ -225,7 +227,7 @@ def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: return True -def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: +def check_subscription_status(account: Any, proxy: Optional[str] = None) -> str: """ 检测账号当前订阅状态。 diff --git a/platforms/chatgpt/sentinel_browser.py b/platforms/chatgpt/sentinel_browser.py index d7c42c6..6a79fef 100644 --- a/platforms/chatgpt/sentinel_browser.py +++ b/platforms/chatgpt/sentinel_browser.py @@ -3,8 +3,12 @@ from __future__ import annotations import json -from typing import Callable, Optional +from typing import Any, Callable, Optional +from core.browser_runtime import ( + ensure_browser_display_available, + resolve_browser_headless, +) from core.proxy_utils import build_playwright_proxy_config @@ -40,8 +44,14 @@ def get_sentinel_token_via_browser( return None target_url = str(page_url or _flow_page_url(flow)).strip() or _flow_page_url(flow) - launch_args = { - "headless": bool(headless), + effective_headless, reason = resolve_browser_headless(headless) + ensure_browser_display_available(effective_headless) + logger( + f"Sentinel Browser 模式: {'headless' if effective_headless else 'headed'} ({reason})" + ) + + launch_args: dict[str, Any] = { + "headless": effective_headless, "args": [ "--no-sandbox", "--disable-blink-features=AutomationControlled", diff --git a/platforms/grok/core.py b/platforms/grok/core.py index a0dbe93..bcac452 100644 --- a/platforms/grok/core.py +++ b/platforms/grok/core.py @@ -8,11 +8,17 @@ Grok (x.ai) 自动注册 4. 完成注册并接受 ToS 5. 提取 sso / sso-rw cookie """ + import ctypes import random import string import time -from typing import Callable, Optional, Tuple +from typing import Any, Callable, Optional, Tuple + +from core.browser_runtime import ( + ensure_browser_display_available, + resolve_browser_headless, +) UA = ( @@ -30,13 +36,27 @@ def _rand_password(n: int = 12) -> str: class GrokRegister: - def __init__(self, captcha_solver=None, yescaptcha_key: str = "", proxy=None, log_fn=print): + def __init__( + self, + captcha_solver=None, + yescaptcha_key: str = "", + proxy=None, + log_fn=print, + headless: bool = False, + ): self.captcha_solver = captcha_solver self.key = yescaptcha_key self.proxy = proxy self.log = log_fn + self.headless = headless - def _wait_until(self, fn: Callable[[], bool], timeout: float = 30.0, interval: float = 0.5, desc: str = ""): + def _wait_until( + self, + fn: Callable[[], bool], + timeout: float = 30.0, + interval: float = 0.5, + desc: str = "", + ): start = time.time() while time.time() - start < timeout: if fn(): @@ -52,8 +72,13 @@ class GrokRegister: from patchright.sync_api import sync_playwright playwright = sync_playwright().start() - launch_kwargs = { - "headless": False, + headless, reason = resolve_browser_headless( + self.headless, default_headless=False + ) + ensure_browser_display_available(headless) + self.log(f"浏览器模式: {'headless' if headless else 'headed'} ({reason})") + launch_kwargs: dict[str, Any] = { + "headless": headless, "channel": "msedge", } if self.proxy: @@ -98,10 +123,15 @@ class GrokRegister: return page.locator("input[name=code]").count() > 0 try: - self._wait_until(_email_verify_ready, timeout=15, desc="等待邮箱验证码页超时") + self._wait_until( + _email_verify_ready, timeout=15, desc="等待邮箱验证码页超时" + ) except Exception: body = page.locator("body").inner_text() - if any(x in body for x in ["域名", "已被拒绝", "其他邮箱地址", "disposable", "rejected"]): + if any( + x in body + for x in ["域名", "已被拒绝", "其他邮箱地址", "disposable", "rejected"] + ): raise RuntimeError(f"邮箱域名被拒绝: {body[:200]}") raise RuntimeError(f"邮箱提交失败: {body[:200]}") @@ -129,14 +159,18 @@ class GrokRegister: self._wait_until(_user_form_ready, timeout=20, desc="等待完成注册页超时") self.log(" 已进入完成注册页") - def _fill_user_form(self, page, given_name: str, family_name: str, password: str) -> None: + def _fill_user_form( + self, page, given_name: str, family_name: str, password: str + ) -> None: self.log(f"Step4: 填写用户信息 {given_name} {family_name} ...") page.locator("input[name=givenName]").fill(given_name) page.locator("input[name=familyName]").fill(family_name) page.locator("input[name=password]").fill(password) @staticmethod - def _find_turnstile_widget(page) -> Tuple[object, Optional[dict]]: + def _find_turnstile_widget( + page, + ) -> Tuple[Optional[Any], Optional[dict[str, Any]]]: for frame in page.frames: if "challenges.cloudflare.com" not in frame.url: continue @@ -181,7 +215,13 @@ class GrokRegister: @staticmethod def _has_turnstile_error(page) -> bool: - keywords = ["验证失败", "故障排除", "verification failed", "troubleshoot", "try again"] + keywords = [ + "验证失败", + "故障排除", + "verification failed", + "troubleshoot", + "try again", + ] texts = [] try: texts.append(page.locator("body").inner_text(timeout=800)) @@ -233,7 +273,9 @@ class GrokRegister: ) ) - def _wait_turnstile_token(self, page, wait_rounds: int = 25, wait_ms: int = 500) -> str: + def _wait_turnstile_token( + self, page, wait_rounds: int = 25, wait_ms: int = 500 + ) -> str: for _ in range(wait_rounds): token = self._read_turnstile_token(page) if token and len(token) > 20: @@ -323,11 +365,16 @@ class GrokRegister: click_x = box["x"] + min(28, max(18, box["width"] * 0.08)) click_y = box["y"] + box["height"] / 2 - self.log(f" Turnstile click #{attempt + 1}: ({click_x:.1f}, {click_y:.1f})") + self.log( + f" Turnstile click #{attempt + 1}: ({click_x:.1f}, {click_y:.1f})" + ) try: if frame: frame.locator("body").click( - position={"x": min(28, max(18, box["width"] * 0.08)), "y": box["height"] / 2}, + position={ + "x": min(28, max(18, box["width"] * 0.08)), + "y": box["height"] / 2, + }, timeout=2500, ) page.wait_for_timeout(120) @@ -343,7 +390,9 @@ class GrokRegister: last_error = str(e) try: - token = self._native_click_turnstile(page, box, min(28, max(18, box["width"] * 0.08))) + token = self._native_click_turnstile( + page, box, min(28, max(18, box["width"] * 0.08)) + ) if token: self.log(f" Turnstile token: {token[:40]}...") return token @@ -366,6 +415,7 @@ class GrokRegister: def _submit_register(self, page) -> None: self.log("Step6: 提交完成注册...") + def _tos_or_account_ready() -> bool: url = page.url body = page.locator("body").inner_text() @@ -441,14 +491,24 @@ class GrokRegister: def _account_ready() -> bool: url = page.url body = page.locator("body").inner_text() - return "/account" in url or "您的账户" in body or self._has_auth_cookies(page.context.cookies()) + return ( + "/account" in url + or "您的账户" in body + or self._has_auth_cookies(page.context.cookies()) + ) self._wait_until(_account_ready, timeout=20, desc="等待账户页超时") page.wait_for_timeout(1500) @staticmethod def _pick_cookie(cookies: list, name: str) -> str: - domains = [".x.ai", "accounts.x.ai", ".grok.com", ".grokusercontent.com", ".grokipedia.com"] + domains = [ + ".x.ai", + "accounts.x.ai", + ".grok.com", + ".grokusercontent.com", + ".grokipedia.com", + ] for domain in domains: for cookie in cookies: if cookie.get("name") == name and cookie.get("domain") == domain: @@ -458,7 +518,12 @@ class GrokRegister: return cookie.get("value", "") return "" - def register(self, email: str, password: str = None, otp_callback: Optional[Callable[[], str]] = None) -> dict: + def register( + self, + email: str, + password: Optional[str] = None, + otp_callback: Optional[Callable[[], str]] = None, + ) -> dict: if not password: password = _rand_password() given_name = _rand_name() diff --git a/platforms/grok/plugin.py b/platforms/grok/plugin.py index 258dd7a..9b49e40 100644 --- a/platforms/grok/plugin.py +++ b/platforms/grok/plugin.py @@ -1,4 +1,7 @@ """Grok (x.ai) 平台插件""" + +from typing import Optional + from core.base_platform import BasePlatform, Account, AccountStatus, RegisterConfig from core.base_mailbox import BaseMailbox from core.registry import register @@ -10,20 +13,36 @@ class GrokPlatform(BasePlatform): display_name = "Grok" version = "1.0.0" - def __init__(self, config: RegisterConfig = None, mailbox: BaseMailbox = None): - super().__init__(config) + def __init__( + self, + config: Optional[RegisterConfig] = None, + mailbox: Optional[BaseMailbox] = None, + ): + super().__init__(config or RegisterConfig()) self.mailbox = mailbox - def register(self, email: str, password: str = None) -> Account: + def register(self, email: str, password: Optional[str] = None) -> Account: from platforms.grok.core import GrokRegister from core.config_store import config_store - log = getattr(self, '_log_fn', print) + + log = getattr(self, "_log_fn", print) # 优先从任务配置读取,兜底从全局配置读取 - yescaptcha_key = self.config.extra.get("yescaptcha_key") or config_store.get("yescaptcha_key", "") + yescaptcha_key = self.config.extra.get("yescaptcha_key") or config_store.get( + "yescaptcha_key", "" + ) captcha_solver = self._make_captcha(key=yescaptcha_key) - reg = GrokRegister(captcha_solver=captcha_solver, yescaptcha_key=yescaptcha_key, proxy=self.config.proxy, log_fn=log) - mailbox_attempts = 1 if email else int(self.config.extra.get("grok_mailbox_attempts", 8)) + requested_headless = (self.config.executor_type or "protocol") != "headed" + reg = GrokRegister( + captcha_solver=captcha_solver, + yescaptcha_key=yescaptcha_key, + proxy=self.config.proxy, + log_fn=log, + headless=requested_headless, + ) + mailbox_attempts = ( + 1 if email else int(self.config.extra.get("grok_mailbox_attempts", 8)) + ) otp_timeout = self.get_mailbox_otp_timeout() last_error = None @@ -34,23 +53,31 @@ class GrokPlatform(BasePlatform): mail_acct = self.mailbox.get_email() current_email = mail_acct.email if mail_acct else None log(f"邮箱: {current_email}") - before_ids = self.mailbox.get_current_ids(mail_acct) if (self.mailbox and mail_acct) else set() + before_ids = ( + self.mailbox.get_current_ids(mail_acct) + if (self.mailbox and mail_acct) + else set() + ) def otp_cb(): log("等待验证码...") + if not self.mailbox or not mail_acct: + return "" code = self.mailbox.wait_for_code( mail_acct, keyword="", timeout=otp_timeout, before_ids=before_ids, - code_pattern=r'[A-Z0-9]{3}-[A-Z0-9]{3}', + code_pattern=r"[A-Z0-9]{3}-[A-Z0-9]{3}", ) if code: - code = code.replace('-', '').replace(' ', '') + code = code.replace("-", "").replace(" ", "") log(f"验证码: {code}") return code try: + if not current_email: + raise RuntimeError("未获取到可用邮箱") result = reg.register( email=current_email, password=password, @@ -61,7 +88,9 @@ class GrokPlatform(BasePlatform): last_error = e msg = str(e) if attempt < mailbox_attempts and "邮箱域名被拒绝" in msg: - log(f"Grok 邮箱域名被拒绝,切换新邮箱重试 {attempt + 1}/{mailbox_attempts}") + log( + f"Grok 邮箱域名被拒绝,切换新邮箱重试 {attempt + 1}/{mailbox_attempts}" + ) continue raise else: diff --git a/platforms/kiro/core.py b/platforms/kiro/core.py index a1dc690..572b739 100644 --- a/platforms/kiro/core.py +++ b/platforms/kiro/core.py @@ -13,22 +13,27 @@ import hashlib import threading import base64 import os +import importlib from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Tuple, Union, Optional +from typing import Any, Optional, Tuple, Union from urllib.parse import urlencode, urlparse, parse_qs +from core.browser_runtime import ( + ensure_browser_display_available, + resolve_browser_headless, +) from urllib.request import Request, build_opener from core.proxy_utils import build_requests_proxy_config try: from curl_cffi import requests as cffi_requests except ImportError: - cffi_requests = None + cffi_requests: Any = None from playwright.sync_api import sync_playwright, TimeoutError, Page, Locator try: - from playwright_stealth import stealth_sync -except ImportError: + stealth_sync = importlib.import_module("playwright_stealth").stealth_sync +except Exception: stealth_sync = None # 如果有全局的 turnstile_strategy,可借用,这里留个 stub @@ -177,6 +182,11 @@ class KiroRegister: self._captured_tokens = {} self._network_debug = [] + def _require_context(self): + if self.context is None: + raise RuntimeError("浏览器上下文未初始化") + return self.context + def log(self, msg): self.log_fn(f"[{self.tag}] {msg}") @@ -223,8 +233,12 @@ class KiroRegister: def _init_browser(self): self.pw = sync_playwright().start() - launch_opts = { - "headless": self.headless, + headless, reason = resolve_browser_headless( + self.headless, default_headless=False + ) + ensure_browser_display_available(headless) + launch_opts: dict[str, Any] = { + "headless": headless, "args": [ "--disable-blink-features=AutomationControlled", "--no-sandbox", @@ -240,20 +254,22 @@ class KiroRegister: env_timezone = os.getenv("KIRO_TIMEZONE", "").strip() locale = env_locale or profile["locale"] timezone_id = env_timezone or profile["timezone_id"] - viewport = dict(profile["viewport"]) + viewport: dict[str, int] = dict(profile["viewport"]) + self.log(f"浏览器模式: {'headless' if headless else 'headed'} ({reason})") self.log( f"浏览器画像: {profile['name']} / {locale} / {timezone_id} / " f"{viewport['width']}x{viewport['height']}" ) - self.context = self.browser.new_context( - user_agent=profile["user_agent"], - locale=locale, - timezone_id=timezone_id, - viewport=viewport, - color_scheme=random.choice(["light", "dark"]), - reduced_motion=random.choice(["reduce", "no-preference"]), - ) + context_opts: dict[str, Any] = { + "user_agent": profile["user_agent"], + "locale": locale, + "timezone_id": timezone_id, + "viewport": viewport, + "color_scheme": random.choice(["light", "dark"]), + "reduced_motion": random.choice(["reduce", "no-preference"]), + } + self.context = self.browser.new_context(**context_opts) self.context.set_extra_http_headers({"Accept-Language": f"{locale},en;q=0.9"}) # 拦截 Kiro 登录成功相关的请求/响应,提取 Token @@ -653,14 +669,16 @@ class KiroRegister: "user-agent": "KiroIDE", } if cffi_requests is not None: - kwargs = { + kwargs: dict[str, Any] = { "json": payload, "headers": headers, "timeout": 30, "impersonate": "chrome131", } if self.proxy: - kwargs["proxies"] = build_requests_proxy_config(self.proxy) + proxies = build_requests_proxy_config(self.proxy) + if proxies: + kwargs["proxies"] = proxies response = cffi_requests.post(url, **kwargs) if response.status_code != 200: raise RuntimeError( @@ -691,7 +709,7 @@ class KiroRegister: try: cookie_map = { c.get("name", ""): c.get("value", "") - for c in self.context.cookies() + for c in self._require_context().cookies() if c.get("domain", "").endswith("app.kiro.dev") } if cookie_map.get("AccessToken"): @@ -864,7 +882,7 @@ class KiroRegister: ) self.log("开始桌面端授权跳转 ...") - auth_page = self.context.new_page() + auth_page = self._require_context().new_page() auth_page.goto(authorize_url, wait_until="domcontentloaded", timeout=60000) started = time.time() @@ -928,7 +946,7 @@ class KiroRegister: if not self.context: self._init_browser() created_browser = True - page = self.context.new_page() + page = self._require_context().new_page() page.goto(KIRO_SIGNIN_URL, wait_until="domcontentloaded") tokens = self._complete_desktop_idc_flow( email=email, pwd=pwd, otp_callback=otp_callback @@ -948,9 +966,9 @@ class KiroRegister: def register( self, email: str, - pwd: str = None, + pwd: Optional[str] = None, name: str = "Kiro User", - mail_token: str = None, + mail_token: Optional[str] = None, otp_timeout: int = 120, otp_callback=None, ) -> Tuple[bool, dict]: @@ -962,7 +980,7 @@ class KiroRegister: page = None try: self._init_browser() - page = self.context.new_page() + page = self._require_context().new_page() if stealth_sync: stealth_sync(page) @@ -1031,6 +1049,8 @@ class KiroRegister: otp_input = stage_input if stage == "otp" else None if stage == "name": self.log("2. 填写名字 (Your name)...") + if stage_input is None: + return False, {"error": "未找到姓名输入框"} self._type_like_human(page, stage_input, name) self._click_primary_button(page) self._human_sleep(1.1, 2.4) @@ -1054,6 +1074,8 @@ class KiroRegister: return False, {"error": "未获取到邮箱验证码(OTP Timeout)"} self.log(f"获取到验证码: {otp_code},正在填入...") + if otp_input is None: + return False, {"error": "未找到 OTP 输入框"} self._type_like_human(page, otp_input, otp_code) self._click_primary_button(page) self._human_sleep(1.0, 2.2) @@ -1149,7 +1171,7 @@ class KiroRegister: "domain": c.get("domain", ""), "path": c.get("path", ""), } - for c in self.context.cookies() + for c in self._require_context().cookies() if "kiro.dev" in c.get("domain", "") or "aws" in c.get("domain", "") ] @@ -1205,7 +1227,7 @@ class KiroRegister: with open("kiro_error.html", "w", encoding="utf-8") as f: f.write(page.content()) self.log("HTML 已保存为 kiro_error.html") - except: + except Exception: pass return False, {"error": str(e)} finally: diff --git a/platforms/kiro/plugin.py b/platforms/kiro/plugin.py index 0139432..fbd0925 100644 --- a/platforms/kiro/plugin.py +++ b/platforms/kiro/plugin.py @@ -1,4 +1,7 @@ """Kiro 平台插件 - 基于 AWS Builder ID 注册""" + +from typing import Optional + from core.base_platform import BasePlatform, Account, AccountStatus, RegisterConfig from core.base_mailbox import BaseMailbox from core.registry import register @@ -10,37 +13,47 @@ class KiroPlatform(BasePlatform): display_name = "Kiro (AWS Builder ID)" version = "1.0.0" - def __init__(self, config: RegisterConfig = None, mailbox: BaseMailbox = None): - super().__init__(config) + def __init__( + self, + config: Optional[RegisterConfig] = None, + mailbox: Optional[BaseMailbox] = None, + ): + super().__init__(config or RegisterConfig()) self.mailbox = mailbox - def register(self, email: str, password: str = None) -> Account: + def register(self, email: str, password: Optional[str] = None) -> Account: from platforms.kiro.core import KiroRegister proxy = self.config.proxy laoudo_account_id = self.config.extra.get("laoudo_account_id", "") + requested_headless = (self.config.executor_type or "protocol") != "headed" - reg = KiroRegister(proxy=proxy, tag="KIRO") - log_fn = getattr(self, '_log_fn', print) - reg.log = lambda msg: log_fn(msg) + reg = KiroRegister(proxy=proxy, tag="KIRO", headless=requested_headless) + log_fn = getattr(self, "_log_fn", print) + reg.log_fn = log_fn otp_timeout = int(self.config.extra.get("otp_timeout", 120)) if self.mailbox: - mail_acct = self.mailbox.get_email() + mailbox = self.mailbox + mail_acct = mailbox.get_email() + if not mail_acct: + raise RuntimeError("未获取到可用邮箱账号") email = email or mail_acct.email log_fn(f"邮箱: {mail_acct.email}") - _before = self.mailbox.get_current_ids(mail_acct) + _before = mailbox.get_current_ids(mail_acct) + def otp_cb(): log_fn("等待验证码...") - code = self.mailbox.wait_for_code( + code = mailbox.wait_for_code( mail_acct, keyword="builder id", timeout=otp_timeout, before_ids=_before, - code_pattern=r'(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})', + code_pattern=r"(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})", ) - if code: log_fn(f"验证码: {code}") + if code: + log_fn(f"验证码: {code}") return code else: otp_cb = None @@ -85,6 +98,7 @@ class KiroPlatform(BasePlatform): return False try: from platforms.kiro.switch import refresh_kiro_token + ok, _ = refresh_kiro_token( refresh_token, extra.get("clientId", ""), @@ -106,7 +120,9 @@ class KiroPlatform(BasePlatform): if action_id == "switch_account": from platforms.kiro.switch import ( - refresh_kiro_token, switch_kiro_account, restart_kiro_ide, + refresh_kiro_token, + switch_kiro_account, + restart_kiro_ide, ) from platforms.kiro.core import KiroRegister from core.base_mailbox import create_mailbox, MailboxAccount @@ -119,11 +135,14 @@ class KiroPlatform(BasePlatform): # Kiro 桌面端需要完整的 Builder ID SSO 缓存。 # 只有 accessToken/sessionToken 的网页态账号无法稳定切到桌面应用。 if not access_token: - return {"ok": False, "error": "当前账号缺少 accessToken,无法切换到桌面应用"} + return { + "ok": False, + "error": "当前账号缺少 accessToken,无法切换到桌面应用", + } if not refresh_token or not client_id or not client_secret: if account.email and account.password: reg = KiroRegister(proxy=self.config.proxy, tag="KIRO-SWITCH") - reg.log = getattr(self, "_log_fn", print) + reg.log_fn = getattr(self, "_log_fn", print) otp_callback = None mailbox_extra = dict(self.config.extra or {}) for key in ( @@ -142,7 +161,7 @@ class KiroPlatform(BasePlatform): mailbox = create_mailbox( provider=mail_provider, extra=mailbox_extra, - proxy=self.config.proxy, + proxy=self.config.proxy or "", ) mail_account = MailboxAccount( email=account.email, @@ -158,16 +177,18 @@ class KiroPlatform(BasePlatform): keyword="", timeout=45, before_ids=before_ids, - code_pattern=r'(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})', + code_pattern=r"(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})", ) except Exception: - reg.log("未等到新验证码,回退读取最近一封身份验证邮件 ...") + reg.log( + "未等到新验证码,回退读取最近一封身份验证邮件 ..." + ) code = mailbox.wait_for_code( mail_account, keyword="", timeout=15, - before_ids=None, - code_pattern=r'(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})', + before_ids=set(), + code_pattern=r"(?is)(?:verification\s+code|验证码)[^0-9]{0,20}(\d{6})", ) if code: reg.log(f"桌面授权验证码: {code}") @@ -219,13 +240,16 @@ class KiroPlatform(BasePlatform): return {"ok": False, "error": msg} restart_ok, restart_msg = restart_kiro_ide() - return {"ok": True, "data": { - "accessToken": access_token, - "refreshToken": refresh_token, - "clientId": client_id, - "clientSecret": client_secret, - "message": f"{msg}。{restart_msg}" if restart_ok else msg, - }} + return { + "ok": True, + "data": { + "accessToken": access_token, + "refreshToken": refresh_token, + "clientId": client_id, + "clientSecret": client_secret, + "message": f"{msg}。{restart_msg}" if restart_ok else msg, + }, + } elif action_id == "refresh_token": from platforms.kiro.switch import refresh_kiro_token diff --git a/platforms/tavily/plugin.py b/platforms/tavily/plugin.py index 45dd333..60d3858 100644 --- a/platforms/tavily/plugin.py +++ b/platforms/tavily/plugin.py @@ -1,5 +1,9 @@ """Tavily 平台插件""" -import random, string + +import random +import string +from typing import Optional + from core.base_platform import BasePlatform, Account, AccountStatus, RegisterConfig from core.base_mailbox import BaseMailbox from core.registry import register @@ -12,77 +16,121 @@ class TavilyPlatform(BasePlatform): version = "1.0.0" supported_executors = ["protocol", "headless", "headed"] - def __init__(self, config: RegisterConfig = None, mailbox: BaseMailbox = None): - super().__init__(config) + def __init__( + self, + config: Optional[RegisterConfig] = None, + mailbox: Optional[BaseMailbox] = None, + ): + super().__init__(config or RegisterConfig()) self.mailbox = mailbox def _register_browser(self, email: str, password: str) -> Account: - import sys, os, importlib, pathlib + import importlib + import os + import pathlib + import sys + extra = self.config.extra or {} os.environ["LOCAL_SOLVER_URL"] = "http://127.0.0.1:8889" os.environ["SOLVER_PORT"] = "8889" - os.environ["REGISTER_HEADLESS"] = "true" - if extra.get("duckmail_api_key"): os.environ["DUCKMAIL_API_KEY"] = extra["duckmail_api_key"] - if extra.get("duckmail_api_url"): os.environ["DUCKMAIL_API_URL"] = extra["duckmail_api_url"] - if extra.get("duckmail_domain"): os.environ["DUCKMAIL_DOMAIN"] = extra["duckmail_domain"] - tavily_gen_path = str(pathlib.Path(__file__).resolve().parents[3] / "tavily-key-generator") + os.environ["REGISTER_HEADLESS"] = ( + "false" if (self.config.executor_type or "") == "headed" else "true" + ) + if extra.get("duckmail_api_key"): + os.environ["DUCKMAIL_API_KEY"] = extra["duckmail_api_key"] + if extra.get("duckmail_api_url"): + os.environ["DUCKMAIL_API_URL"] = extra["duckmail_api_url"] + if extra.get("duckmail_domain"): + os.environ["DUCKMAIL_DOMAIN"] = extra["duckmail_domain"] + tavily_gen_path = str( + pathlib.Path(__file__).resolve().parents[3] / "tavily-key-generator" + ) if tavily_gen_path not in sys.path: sys.path.insert(0, tavily_gen_path) - if "config" in sys.modules: importlib.reload(sys.modules["config"]) - if "tavily_browser_solver" in sys.modules: importlib.reload(sys.modules["tavily_browser_solver"]) - from tavily_browser_solver import register_with_browser_solver + if "config" in sys.modules: + importlib.reload(sys.modules["config"]) + if "tavily_browser_solver" in sys.modules: + importlib.reload(sys.modules["tavily_browser_solver"]) + solver_mod = importlib.import_module("tavily_browser_solver") + register_with_browser_solver = solver_mod.register_with_browser_solver + api_key = register_with_browser_solver(email, password) if not api_key: raise RuntimeError("浏览器注册失败") - return Account(platform="tavily", email=email, password=password, - status=AccountStatus.REGISTERED, extra={"api_key": api_key}) + return Account( + platform="tavily", + email=email, + password=password, + status=AccountStatus.REGISTERED, + extra={"api_key": api_key}, + ) - def register(self, email: str, password: str = None) -> Account: + def register(self, email: str, password: Optional[str] = None) -> Account: if not password: - password = "".join(random.choices(string.ascii_letters + string.digits + "!@#", k=14)) - log = getattr(self, '_log_fn', print) + password = "".join( + random.choices(string.ascii_letters + string.digits + "!@#", k=14) + ) + log = getattr(self, "_log_fn", print) if (self.config.executor_type or "") in ("headless", "headed"): log(f"使用浏览器模式注册: {email}") return self._register_browser(email, password) - mail_acct = self.mailbox.get_email() if self.mailbox else None - email = email or (mail_acct.email if mail_acct else None) + mailbox = self.mailbox + mail_acct = mailbox.get_email() if mailbox else None + email = email or (mail_acct.email if mail_acct else "") + if not email: + raise RuntimeError("未获取到可用邮箱") log(f"邮箱: {email}") - before_ids = self.mailbox.get_current_ids(mail_acct) if mail_acct else set() + before_ids = mailbox.get_current_ids(mail_acct) if (mailbox and mail_acct) else set() otp_timeout = self.get_mailbox_otp_timeout() def otp_cb(): log("等待验证码邮件...") - code = self.mailbox.wait_for_code( + if not mailbox or not mail_acct: + return "" + code = mailbox.wait_for_code( mail_acct, keyword="", timeout=otp_timeout, before_ids=before_ids, ) - if code: log(f"验证码: {code}") + if code: + log(f"验证码: {code}") return code captcha = self._make_captcha(key=self.config.extra.get("yescaptcha_key", "")) from platforms.tavily.core import TavilyRegister + with self._make_executor() as ex: reg = TavilyRegister(executor=ex, captcha=captcha, log_fn=log) - result = reg.register(email=email, password=password, - otp_callback=otp_cb if self.mailbox else None) + result = reg.register( + email=email, + password=password, + otp_callback=otp_cb if self.mailbox else None, + ) - return Account(platform="tavily", email=result["email"], password=result["password"], - status=AccountStatus.REGISTERED, extra={"api_key": result["api_key"]}) + return Account( + platform="tavily", + email=result["email"], + password=result["password"], + status=AccountStatus.REGISTERED, + extra={"api_key": result["api_key"]}, + ) def check_valid(self, account: Account) -> bool: api_key = account.extra.get("api_key", "") if not api_key: return False import requests + try: - r = requests.post("https://api.tavily.com/search", - json={"api_key": api_key, "query": "test", "max_results": 1}, - timeout=10) + r = requests.post( + "https://api.tavily.com/search", + json={"api_key": api_key, "query": "test", "max_results": 1}, + timeout=10, + ) return r.status_code != 401 except Exception: return False