Merge pull request #88 from Hoshino-Yumetsuki/main

feat: 支持docker内运行有头/无头浏览器
This commit is contained in:
zhangchen
2026-04-04 03:13:21 +08:00
committed by GitHub
12 changed files with 436 additions and 134 deletions

View File

@@ -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/*

64
core/browser_runtime.py Normal file
View File

@@ -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 请先启动图形环境或改用无头模式。"
)

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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:
"""
检测账号当前订阅状态。

View File

@@ -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",

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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