Add Gmail+Email Worker hybrid mail path and private runtime config

Prefer Cloudflare Worker KV for verification codes with optional short IMAP
fallback disabled for private deploy; track config.json/.env for private repo.
Also add worker project, protocol mint backoff, and fail-fast Turnstile/mail
timeouts from batch runs.
This commit is contained in:
chaos committed 2026-07-11 23:21:23 +08:00
1 parent 187792d6be
commit 55f56d27c9
19 files changed
+1225 -85

No files matched your search

+252 -17
View File
@@ -621,6 +621,34 @@ CHROMIUM_SLIM_FLAGS = [
"--no-first-run",
]
# Prefer Google Chrome for registration reliability (email/CF path).
# Chromium is fallback; turnstilePatch only loads on Chromium (Chrome blocks --load-extension).
BROWSER_CANDIDATES = (
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
)
def _is_google_chrome_path(path: str | None) -> bool:
if not path:
return False
base = os.path.basename(path)
return "chrome" in base and "chromium" not in path
def resolve_browser_path():
# Optional override from config.json "browser_path"
override = str((config.get("browser_path") if isinstance(config, dict) else "") or "").strip()
if override and (os.path.isfile(override) or os.path.islink(override)):
return override
for cand in BROWSER_CANDIDATES:
if os.path.isfile(cand) or os.path.islink(cand):
return cand
return None
def create_browser_options():
options = ChromiumOptions()
@@ -628,8 +656,22 @@ def create_browser_options():
options.set_timeouts(base=1)
for flag in CHROMIUM_SLIM_FLAGS:
options.set_argument(flag)
browser_path = resolve_browser_path()
if browser_path:
try:
options.set_browser_path(browser_path)
print(f" [browser] path={browser_path}", flush=True)
except Exception:
pass
if os.path.exists(EXTENSION_PATH):
options.add_extension(EXTENSION_PATH)
# Google Chrome blocks CLI unpacked extension loading; Chromium still allows it.
if _is_google_chrome_path(browser_path):
print(
" [ext] skip turnstilePatch on Google Chrome (--load-extension blocked)",
flush=True,
)
else:
options.add_extension(EXTENSION_PATH)
# Apply config.json "proxy" to Chromium. Without this, only HTTP helpers
# used get_proxies(); the browser itself fell through to system/env proxy.
proxy = (config.get("proxy") or "").strip()
@@ -1833,6 +1875,112 @@ def get_email_provider():
# ──────────────────────── Gmail + Cloudflare catch-all ────────────────────────
def _gmail_worker_enabled() -> bool:
if not _config_bool(config.get("gmail_worker_enabled", False), default=False):
return False
url = str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip()
key = str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip()
return bool(url and key)
def _gmail_worker_base_url() -> str:
return str(config.get("gmail_worker_url", "") or os.getenv("GMAIL_WORKER_URL", "") or "").strip().rstrip("/")
def _gmail_worker_api_key() -> str:
return str(config.get("gmail_worker_api_key", "") or os.getenv("GMAIL_WORKER_API_KEY", "") or "").strip()
def gmail_worker_fetch_code(
email: str,
*,
timeout: float = 25.0,
poll_interval: float | None = None,
log_callback=None,
cancel_callback=None,
consume: bool = True,
) -> str | None:
"""Poll Cloudflare Email Worker KV HTTP API for a code.
Returns code string or None on timeout / not configured.
Does NOT raise on 404; raises only on hard misconfiguration after retries optional.
"""
base = _gmail_worker_base_url()
key = _gmail_worker_api_key()
if not base or not key:
return None
if poll_interval is None:
try:
poll_interval = float(config.get("gmail_worker_poll_interval", 0.4) or 0.4)
except Exception:
poll_interval = 0.4
poll_interval = max(0.15, min(float(poll_interval), 3.0))
try:
timeout = float(timeout)
except Exception:
timeout = 25.0
deadline = time.time() + max(1.0, timeout)
target = (email or "").strip().lower()
if not target:
return None
t0 = time.time()
polls = 0
last_err = ""
url = f"{base}/code"
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
polls += 1
try:
# Prefer no proxy for workers.dev / own CF endpoint (local latency)
resp = http_get(
url,
params={
"to": target,
"key": key,
"consume": "1" if consume else "0",
},
headers={"x-api-key": key},
timeout=8,
proxies={},
)
if resp.status_code == 200:
data = resp.json() if hasattr(resp, "json") else {}
if not isinstance(data, dict):
data = {}
code = str(data.get("code") or "").strip()
if code:
if log_callback:
log_callback(
f"[*] Gmail Worker 取到验证码: {code} "
f"(elapsed={time.time() - t0:.1f}s polls={polls} source={data.get('source') or 'worker'})"
)
return code
elif resp.status_code == 404:
last_err = "not found"
elif resp.status_code in (401, 403):
last_err = f"auth {resp.status_code}"
if log_callback and polls <= 2:
log_callback(f"[Debug] Gmail Worker 鉴权失败 HTTP {resp.status_code}(检查 gmail_worker_api_key)")
# auth errors won't recover quickly
if polls >= 3:
return None
else:
last_err = f"HTTP {resp.status_code}"
if log_callback and polls <= 3:
log_callback(f"[Debug] Gmail Worker 响应 {resp.status_code}: {str(getattr(resp, 'text', '') or '')[:120]}")
except Exception as exc:
last_err = str(exc)
if log_callback and polls <= 3:
log_callback(f"[Debug] Gmail Worker 请求失败: {exc}")
sleep_with_cancel(poll_interval, cancel_callback)
if log_callback:
log_callback(
f"[*] Gmail Worker {timeout:.0f}s 内未取到验证码 ({last_err or 'timeout'}),"
f"{'将回退 IMAP' if _config_bool(config.get('gmail_imap_fallback', True), default=True) else '结束'}"
)
return None
def gmail_get_email_and_token():
raw = str(config.get("defaultDomains", "") or "")
domains = [x.strip() for x in re.split(r"[,,\s]+", raw) if x.strip()]
@@ -1853,11 +2001,57 @@ def gmail_get_oai_code(
cancel_callback=None,
resend_callback=None,
):
"""Poll Gmail IMAP for xAI code. Optimized for catch-all + subject codes.
"""Fetch xAI code: Cloudflare Email Worker first (optional), then Gmail IMAP.
xAI subjects look like: "ABC-DEF xAI confirmation code".
We fetch HEADER only first (no full RFC822 body) and extract from Subject/To.
Hybrid path (recommended):
xAI → CF Email Routing → Worker(KV) → GET /code (seconds)
fallback → Gmail IMAP (if Worker miss / disabled)
Worker subjects still look like: "ABC-DEF xAI confirmation code".
IMAP path fetches HEADER only first (subject often has the code).
"""
# ── 1) Email Worker (KV HTTP) — fast path ──
# Lesson from batch10: hits are ~1.5s; misses used to burn 25s worker + ~155s IMAP
# even when Catch-all only delivers to Worker (IMAP never sees the mail).
worker_on = _gmail_worker_enabled()
imap_fallback = _config_bool(config.get("gmail_imap_fallback", True), default=True)
if worker_on:
try:
# Prefer short wait: real hits arrive in 1–3s; long waits only delay retry.
worker_timeout = float(config.get("gmail_worker_timeout_sec", 12) or 12)
except Exception:
worker_timeout = 12.0
worker_timeout = max(4.0, min(worker_timeout, float(timeout)))
if log_callback:
log_callback(
f"[*] Gmail 取码:优先 Email Worker(timeout={worker_timeout:.0f}s)"
+ (",未命中再短等 IMAP" if imap_fallback else ",未命中即换号")
)
code = gmail_worker_fetch_code(
email,
timeout=worker_timeout,
log_callback=log_callback,
cancel_callback=cancel_callback,
consume=True,
)
if code:
return code
if not imap_fallback:
raise Exception(
f"Gmail Worker 在 {worker_timeout:.0f}s 内未收到验证码(gmail_imap_fallback=false): {email}"
)
# Short IMAP fallback only — Catch-all→Worker often never reaches Gmail.
try:
imap_fb = float(config.get("gmail_imap_fallback_timeout_sec", 35) or 35)
except Exception:
imap_fb = 35.0
timeout = max(15.0, min(imap_fb, max(0.0, float(timeout) - worker_timeout)))
if log_callback:
log_callback(
f"[*] Gmail Worker 未命中,短回退 IMAP(timeout={timeout:.0f}s;"
f"若邮件只进 Worker 请设 GMAIL_FORWARD_TO 或 gmail_imap_fallback=false)"
)
if poll_interval is None:
# Gmail catch-all benefits from denser polling; default 1s unless config forces higher.
poll_interval = float(config.get("mail_poll_interval", 1) or 1)
@@ -1865,13 +2059,24 @@ def gmail_get_oai_code(
imap_user = str(config.get("gmail_imap_user", "") or os.getenv("GMAIL_USER", "") or "").strip()
imap_password = str(config.get("gmail_imap_password", "") or os.getenv("GMAIL_PASSWORD", "") or "").strip()
if not imap_user or not imap_password:
if worker_on:
raise Exception(
f"Gmail Worker 未取到验证码,且 IMAP 未配置(gmail_imap_user/password): {email}"
)
raise Exception("Gmail IMAP 未配置:需要在 config.json 设置 gmail_imap_user/gmail_imap_password,或环境变量 GMAIL_USER/GMAIL_PASSWORD")
imap_host = str(config.get("gmail_imap_host", "imap.gmail.com") or "imap.gmail.com").strip()
imap_port = int(config.get("gmail_imap_port", 993) or 993)
delete_after = _config_bool(config.get("gmail_delete_after_code", True), default=True)
deadline = time.time() + timeout
# Resend earlier: first mail often arrives in 5-40s; resend at 45s as safety net.
next_resend_at = time.time() + float(config.get("gmail_resend_after_sec", 45) or 45)
# Resend: default 45s for pure IMAP; after worker-miss fallback use sooner resend.
try:
resend_after = float(config.get("gmail_resend_after_sec", 45) or 45)
except Exception:
resend_after = 45.0
if worker_on:
# fallback window is short; resend once around 12s into IMAP
resend_after = min(resend_after, max(8.0, float(config.get("gmail_imap_fallback_resend_sec", 12) or 12)))
next_resend_at = time.time() + resend_after
imap = None
target_lower = email.lower().strip()
seen_ids: set[bytes] = set()
@@ -2965,7 +3170,11 @@ return 'clicked';
raise Exception("验证码已获取,但自动填写/提交失败")
def getTurnstileToken(log_callback=None, cancel_callback=None):
def getTurnstileToken(log_callback=None, cancel_callback=None, max_rounds: int = 4):
"""Try to obtain a Turnstile token quickly.
max_rounds default 4 (~3-5s) — fail fast and restart browser/account.
"""
page = _get_page()
if page is None:
raise Exception("页面未就绪,无法执行 Turnstile")
@@ -2977,7 +3186,8 @@ def getTurnstileToken(log_callback=None, cancel_callback=None):
except Exception:
pass
for _ in range(0, 20):
rounds = max(2, int(max_rounds or 4))
for _ in range(0, rounds):
raise_if_cancelled(cancel_callback)
try:
token = page.run_js(
@@ -3040,7 +3250,7 @@ if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click();
)
except Exception:
pass
human_sleep(1, cancel_callback)
human_sleep(0.7, cancel_callback)
raise Exception("Turnstile 获取 token 失败")
@@ -3086,6 +3296,23 @@ def fill_profile_and_submit(timeout=120, log_callback=None, cancel_callback=None
wait_cf_since = None
last_cf_retry_at = 0.0
last_cf_log_at = 0.0
cf_token_fail_streak = 0
try:
max_cf_token_fails = max(1, int(config.get("turnstile_fast_fail_count", 2) or 2))
except Exception:
max_cf_token_fails = 2
try:
cf_retry_after = float(config.get("turnstile_retry_after_sec", 8) or 8)
except Exception:
cf_retry_after = 8.0
try:
cf_retry_gap = float(config.get("turnstile_retry_gap_sec", 5) or 5)
except Exception:
cf_retry_gap = 5.0
try:
turnstile_rounds = max(3, int(config.get("turnstile_max_rounds", 4) or 4))
except Exception:
turnstile_rounds = 4
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
@@ -3177,12 +3404,13 @@ return 'filled-no-submit';
log_callback(f"[*] 资料已填写,等待 Cloudflare... token长度={token_len} waited={waited:.0f}s")
last_cf_log_at = now
# 卡住后自动二次复用 Turnstile 组件
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
if log_callback:
log_callback("[*] Cloudflare 验证卡住,开始二次复用 Turnstile...")
try:
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback, max_rounds=turnstile_rounds)
if token:
cf_token_fail_streak = 0
synced = page.run_js(
"""
const token = String(arguments[0] || '').trim();
@@ -3200,8 +3428,11 @@ return String(cfInput.value || '').trim().length;
if log_callback:
log_callback(f"[*] Turnstile 二次复用完成,回填长度={synced}")
except Exception as cf_exc:
cf_token_fail_streak += 1
if log_callback:
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
log_callback(f"[Debug] Turnstile 二次复用失败({cf_token_fail_streak}/{max_cf_token_fails}): {cf_exc}")
if cf_token_fail_streak >= max_cf_token_fails:
raise Exception(f"Turnstile 连续失败,快速放弃资料页: {cf_exc}")
last_cf_retry_at = now
human_sleep(0.8, cancel_callback)
continue
@@ -3258,12 +3489,13 @@ return 'submitted';
waited = now - wait_cf_since if wait_cf_since else 0
log_callback(f"[*] 等待 Cloudflare 后再提交... token长度={token_len} waited={waited:.0f}s")
last_cf_log_at = now
if now - wait_cf_since >= 12 and now - last_cf_retry_at >= 8:
if now - wait_cf_since >= cf_retry_after and now - last_cf_retry_at >= cf_retry_gap:
if log_callback:
log_callback("[*] 提交前仍卡住,自动再次复用 Turnstile...")
try:
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback)
token = getTurnstileToken(log_callback=log_callback, cancel_callback=cancel_callback, max_rounds=turnstile_rounds)
if token:
cf_token_fail_streak = 0
synced = page.run_js(
"""
const token = String(arguments[0] || '').trim();
@@ -3281,8 +3513,11 @@ return String(cfInput.value || '').trim().length;
if log_callback:
log_callback(f"[*] Turnstile 二次复用完成,回填长度={synced}")
except Exception as cf_exc:
cf_token_fail_streak += 1
if log_callback:
log_callback(f"[Debug] Turnstile 二次复用失败: {cf_exc}")
log_callback(f"[Debug] Turnstile 二次复用失败({cf_token_fail_streak}/{max_cf_token_fails}): {cf_exc}")
if cf_token_fail_streak >= max_cf_token_fails:
raise Exception(f"Turnstile 连续失败,快速放弃资料页: {cf_exc}")
last_cf_retry_at = now
human_sleep(0.8, cancel_callback)
continue
@@ -3525,9 +3760,9 @@ return String(cfInput.value || '').trim().length;
if name:
last_seen_names.add(name)
if name == "sso" and value:
if name in ("sso", "sso-rw") and value:
if log_callback:
log_callback("[*] 已获取到 sso cookie")
log_callback(f"[*] 已获取到 {name} cookie")
return value
except PageDisconnectedError:
refresh_active_page()