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:
1 parent
187792d6be
commit
55f56d27c9
19 files changed
+1225
-85
No files matched your search
+252
-17
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user