瀏覽代碼

Initial commit: ChatGPT Plus 全自动注册 + PayPal 付款 + CPA 上传

chendeben 2 月之前
當前提交
196c594404
共有 13 個文件被更改,包括 4646 次插入0 次删除
  1. 40 0
      .gitignore
  2. 1387 0
      automation.py
  3. 309 0
      chatgpt_flow.py
  4. 714 0
      chatgpt_signup.py
  5. 70 0
      config.py
  6. 398 0
      cpa_uploader.py
  7. 120 0
      mail_provider.py
  8. 494 0
      paypal_flow.py
  9. 232 0
      providers.py
  10. 2 0
      requirements.txt
  11. 23 0
      run.sh
  12. 644 0
      server.py
  13. 213 0
      storage.py

+ 40 - 0
.gitignore

@@ -0,0 +1,40 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+*.egg
+*.egg-info/
+.eggs/
+
+# venv
+.venv/
+venv/
+env/
+
+# 本地配置(含 CPA 密钥、邮件助手地址等敏感信息)
+config.local.json
+
+# 数据库与运行产物
+data/
+logs/
+*.db
+*.db-journal
+*.sqlite
+*.sqlite3
+
+# IDE / OS
+.vscode/
+.idea/
+.DS_Store
+*.swp
+
+# Playwright 缓存
+.playwright/
+
+# 项目其他
+.claude/
+
+# 旧文件 / 测试目录(按用户要求不入库)
+auto.js
+tests/

+ 1387 - 0
automation.py

@@ -0,0 +1,1387 @@
+"""端到端自动化:拿 ChatGPT 长链 → Stripe 选 PayPal → PayPal 注册绑卡 → 等短信。"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import time
+import traceback
+from dataclasses import dataclass, field
+from typing import Callable, Optional
+
+from providers import fetch_us_address, fetch_visa_card, fetch_sms_code
+
+try:
+    from curl_cffi import requests as curl_requests
+except Exception:
+    curl_requests = None
+
+
+CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
+PAYURL_CHECKOUT_URL = "https://payurl.ark2.cn/api/checkout"
+PROXY_FOR_LONGLINK = "http://127.0.0.1:7890"
+PHONE_E164 = "+15822201173"
+PHONE_NUMBER = PHONE_E164.removeprefix("+1")
+PHONE_COUNTRY = "US"
+POST_PAYMENT_WAIT_TIMEOUT = 600
+POST_PAYMENT_WAIT_INTERVAL = 2
+POST_PAYMENT_MANUAL_INTERVAL = 5
+POST_SMS_PAYPAL_ACTION_TEXTS = (
+    "Agree & Create Account",
+    "Agree and Create Account",
+    "Agree & Continue",
+    "Agree and Continue",
+    "同意并继续",
+)
+POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
+
+LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
+
+_PAYMENT_COMPLETE_RE = re.compile(
+    r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
+    r"thank\s+you|you['’]?re\s+all\s+set|subscription\s+(active|started|complete)|welcome\s+to\s+chatgpt\s+plus|"
+    r"支付成功|付款成功|订阅成功|已完成)",
+    re.I,
+)
+_PAYMENT_BLOCKING_RE = re.compile(
+    r"(validation|error|declined|failed|couldn['’]?t|cannot|try\s+again|invalid|"
+    r"验证|错误|失败|无效|无法|重试)",
+    re.I,
+)
+_PAYMENT_FAILED_URL_RE = re.compile(r"redirect_status=failed|payment[_-]?status=failed", re.I)
+_PAYMENT_FAILED_TEXT_RE = re.compile(
+    r"an\s+error\s+occurred\s+while\s+processing\s+your\s+payment|"
+    r"payment\s+failed|"
+    r"try\s+again\s+later\s+or\s+with\s+a\s+different\s+payment\s+method|"
+    r"card\s+(?:was\s+)?declined|"
+    r"payment\s+method\s+(?:is\s+)?not\s+supported|"
+    r"we\s+weren['’]?t\s+able\s+to\s+add\s+this\s+card|"
+    r"unable\s+to\s+add\s+this\s+card|"
+    r"check\s+all\s+the\s+details\s+are\s+correct\s+and\s+try\s+again|"
+    r"try\s+a\s+different\s+card|"
+    r"sorry,?\s+something\s+went\s+wrong\.?\s*please\s+try\s+again|"
+    r"we\s+couldn['’]?t\s+process\s+(?:your\s+)?(?:payment|request)|"
+    r"we\s+can'?t\s+continue\s+with\s+this\s+payment|"
+    r"this\s+card\s+has\s+already\s+been\s+added\s+to\s+another\s+paypal\s+account|"
+    r"create_card_account_candidate_validation_error|"
+    r"unmapped[_\s-]?oas[_\s-]?error|"
+    r"oas[_\s-]?error|"
+    r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
+    r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
+    r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
+    r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
+    re.I,
+)
+
+
+class PayPalPaymentFailed(Exception):
+    """PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
+
+
+MAX_PAYPAL_RETRIES = 3
+
+
+def _detect_payment_failure(page) -> str:
+    """检测明确的"支付失败"信号。返回非空字符串表示已失败。"""
+    url = getattr(page, "url", "") or ""
+    if _PAYMENT_FAILED_URL_RE.search(url):
+        return f"URL 标记 redirect_status=failed: {url}"
+    snapshot = _payment_page_snapshot(page)
+    text = snapshot.get("text", "") or ""
+    alerts = snapshot.get("alerts", "") or ""
+    blob = f"{text}\n{alerts}"
+    m = _PAYMENT_FAILED_TEXT_RE.search(blob)
+    if m:
+        # 截上下文方便日志
+        start = max(0, (m.start() if hasattr(m, "start") else 0) - 60)
+        end = min(len(blob), (m.end() if hasattr(m, "end") else 0) + 200)
+        return f"页面失败提示:{blob[start:end].strip()[:400]}"
+    return ""
+
+
+def _replace_browser_context(ctx, page):
+    """彻底重新生成一个浏览器 context(PerimeterX 等基于 fingerprint 的反爬最有效的对策)。
+    注意:调用方持有的 page 引用不会被替换;这里只是把当前 context 关掉重开+加载空白页,
+    并复用同一个 browser。如果调用方传入的 page 已经死,会自然在下次 page.goto 时报错。
+    """
+    ctx.log("[playwright] 重建 browser context(清 PerimeterX 等指纹)")
+    try:
+        old_ctx = page.context
+        browser = old_ctx.browser
+    except Exception as exc:
+        ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退普通清缓存")
+        _clear_browser_state(ctx, page)
+        return
+
+    try:
+        for p in list(old_ctx.pages):
+            try:
+                p.close()
+            except Exception:
+                pass
+        old_ctx.close()
+        ctx.log("[playwright] 旧 context 已关闭")
+    except Exception as exc:
+        ctx.log(f"[playwright] 关旧 context 失败: {exc!r}")
+
+    # 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
+    # 兜底:用 _clear_browser_state 当作降级
+    try:
+        new_ctx = browser.new_context(
+            locale="en-US",
+            timezone_id="America/New_York",
+            viewport={"width": 1280, "height": 900},
+        )
+        new_page = new_ctx.new_page()
+        new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
+        new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
+        new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
+        new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
+        new_page.goto("about:blank", wait_until="domcontentloaded", timeout=10000)
+        # 替换 ctx 上的 page 引用(chatgpt_flow 调 run_paypal_flow 时也是从 ctx._next_page 取)
+        ctx._next_page = new_page  # type: ignore
+        ctx.log("[playwright] 新 context + 新 page 已就绪 (ctx._next_page)")
+    except Exception as exc:
+        ctx.log(f"[playwright] 新 context 创建失败: {exc!r}")
+
+
+def _clear_browser_state(ctx, page):
+    """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。"""
+    ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
+    try:
+        bctx = page.context
+        bctx.clear_cookies()
+        ctx.log("[playwright] context.clear_cookies() 完成")
+    except Exception as exc:
+        ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
+    try:
+        bctx = page.context
+        # 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
+        page.evaluate(r"""() => {
+            try { localStorage.clear(); } catch (_) {}
+            try { sessionStorage.clear(); } catch (_) {}
+            try {
+                if (window.indexedDB && indexedDB.databases) {
+                    indexedDB.databases().then((dbs) => {
+                        (dbs || []).forEach((db) => {
+                            if (db && db.name) {
+                                try { indexedDB.deleteDatabase(db.name); } catch (_) {}
+                            }
+                        });
+                    });
+                }
+            } catch (_) {}
+            try {
+                if (window.caches && caches.keys) {
+                    caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
+                }
+            } catch (_) {}
+        }""")
+        ctx.log("[playwright] localStorage/sessionStorage/IndexedDB/caches 清理完成(当前页)")
+    except Exception as exc:
+        ctx.log(f"[playwright] 当前页 storage 清理失败: {exc!r}")
+    # 关掉所有 PayPal/Stripe 旧标签,最后回到一个空白页
+    try:
+        for p in list(page.context.pages):
+            try:
+                if p is page:
+                    continue
+                p.close()
+            except Exception:
+                pass
+        page.goto("about:blank", wait_until="domcontentloaded", timeout=15000)
+    except Exception as exc:
+        ctx.log(f"[playwright] 关旧 tab/转空白页 失败: {exc!r}")
+
+
+@dataclass
+class RunContext:
+    token: str
+    plan: str = "plus"
+    country: str = "US"
+    currency: str = "USD"
+    use_promo: bool = True
+    headless: bool = False
+    log: Callable[[str], None] = print
+    state: str = "running"
+    stage: str = ""
+    on_stage: Optional[Callable[[str], None]] = None
+    long_link: str = ""
+    email: str = ""           # ChatGPT 注册邮箱(@edu.a4sky.com)
+    paypal_email: str = ""    # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
+    password: str = ""
+    card: dict = field(default_factory=dict)
+    address: dict = field(default_factory=dict)
+    run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
+
+    @property
+    def artifact_dir(self) -> str:
+        d = os.path.join(LOG_DIR, self.run_id)
+        os.makedirs(d, exist_ok=True)
+        return d
+
+    def set_stage(self, name: str):
+        self.stage = name
+        self.log(f"[stage] {name}")
+        if self.on_stage:
+            try:
+                self.on_stage(name)
+            except Exception:
+                pass
+
+
+def _request_headers(token: str) -> dict:
+    return {
+        "Authorization": f"Bearer {token}",
+        "Content-Type": "application/json",
+        "Accept": "application/json",
+        "Origin": "https://chatgpt.com",
+        "Referer": "https://chatgpt.com/",
+        "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+        "User-Agent": (
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+            "AppleWebKit/537.36 (KHTML, like Gecko) "
+            "Chrome/136.0.0.0 Safari/537.36"
+        ),
+    }
+
+
+def _checkout_payload(plan: str, country: str, currency: str, use_promo: bool) -> dict:
+    payload = {
+        "plan_name": "chatgptplusplan" if plan == "plus" else "chatgptteamplan",
+        "billing_details": {"country": country.upper(), "currency": currency.upper()},
+        "checkout_ui_mode": "hosted",
+        "cancel_url": "https://chatgpt.com/#pricing",
+    }
+    if use_promo and plan == "plus":
+        payload["promo_campaign"] = {
+            "promo_campaign_id": "plus-1-month-free",
+            "is_coupon_from_query_param": True,
+        }
+    return payload
+
+
+def generate_long_link(ctx: RunContext) -> str:
+    ctx.log(f"[longlink] === 开始生成长链 ===")
+    ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
+    ctx.log(f"[longlink] 代理={PROXY_FOR_LONGLINK}")
+    payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
+    ctx.log(f"[longlink] payload={json.dumps(payload, ensure_ascii=False)}")
+
+    if curl_requests is None:
+        raise RuntimeError("缺少 curl_cffi,请先 pip install curl_cffi")
+
+    proxies = {"http": PROXY_FOR_LONGLINK, "https": PROXY_FOR_LONGLINK}
+    started = time.time()
+    try:
+        response = curl_requests.post(
+            CHECKOUT_URL,
+            json=payload,
+            headers=_request_headers(ctx.token),
+            impersonate="chrome136",
+            proxies=proxies,
+            timeout=30,
+        )
+    except Exception as exc:
+        ctx.log(f"[longlink] 请求异常({int((time.time()-started)*1000)}ms): {exc!r}")
+        raise
+
+    text = response.text
+    ctx.log(f"[longlink] HTTP {response.status_code} 耗时 {int((time.time()-started)*1000)}ms 返回长度={len(text)}")
+    ctx.log(f"[longlink] 响应预览: {text[:500]}")
+
+    if response.status_code >= 400:
+        raise RuntimeError(f"创建 checkout 失败: HTTP {response.status_code} {text[:300]}")
+
+    try:
+        data = json.loads(text or "{}")
+    except json.JSONDecodeError as exc:
+        raise RuntimeError(f"长链响应不是 JSON: {exc!r} 原文={text[:300]}")
+
+    link = data.get("url") or data.get("stripe_hosted_url") or data.get("checkout_url")
+    session_id = data.get("checkout_session_id")
+    processor = data.get("processor_entity")
+    ctx.log(f"[longlink] checkout_session_id={session_id} processor={processor}")
+    if not link:
+        raise RuntimeError(f"未在响应中解析到长链: {text[:300]}")
+    ctx.log(f"[longlink] 成功取到 long_link={link}")
+    return link
+
+
+def generate_long_link_payurl(ctx: RunContext) -> str:
+    """走 payurl.ark2.cn(与 Chrome 扩展 get-plus-link.js 一致),不走本地代理。"""
+    ctx.log("[longlink] === 通过 payurl.ark2.cn 获取 Plus 长链 ===")
+    ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
+
+    payload = {
+        "token": ctx.token,
+        "plan": ctx.plan or "plus",
+        "checkout_ui_mode": "hosted",
+        "ui_language": "en",
+        "country": (ctx.country or "US").upper(),
+        "currency": (ctx.currency or "USD").upper(),
+        "proxy": "",
+        "use_promo": bool(ctx.use_promo),
+        "promo_code": "STRIPEATLASGPT4BIZ050126",
+        "workspace_name": "linux-do",
+        "seat_quantity": 2,
+    }
+    headers = {
+        "Accept": "*/*",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+        "Content-Type": "application/json",
+        "DNT": "1",
+        "Origin": "https://payurl.ark2.cn",
+        "Referer": "https://payurl.ark2.cn/",
+    }
+    masked = dict(payload)
+    masked["token"] = (ctx.token or "")[:24] + "..."
+    ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
+
+    max_attempts = 5
+    last_err = ""
+    for attempt in range(1, max_attempts + 1):
+        ctx.log(f"[longlink] 第 {attempt}/{max_attempts} 次请求 {PAYURL_CHECKOUT_URL}")
+        started = time.time()
+        try:
+            if curl_requests is not None:
+                r = curl_requests.post(
+                    PAYURL_CHECKOUT_URL,
+                    json=payload,
+                    headers=headers,
+                    impersonate="chrome136",
+                    timeout=30,
+                )
+                text = r.text
+                status = r.status_code
+            else:
+                import urllib.request
+                data = json.dumps(payload).encode("utf-8")
+                req = urllib.request.Request(PAYURL_CHECKOUT_URL, data=data, method="POST")
+                for k, v in headers.items():
+                    req.add_header(k, v)
+                with urllib.request.urlopen(req, timeout=30) as resp:
+                    text = resp.read().decode("utf-8", errors="replace")
+                    status = resp.status
+        except Exception as exc:
+            last_err = repr(exc)
+            ctx.log(f"[longlink] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
+            time.sleep(1.5)
+            continue
+
+        ctx.log(f"[longlink] HTTP {status} 耗时 {int((time.time()-started)*1000)}ms 长度={len(text)} 预览={text[:300]}")
+        if status >= 400:
+            last_err = f"HTTP {status}: {text[:300]}"
+            time.sleep(1.5)
+            continue
+        try:
+            data = json.loads(text or "{}")
+        except Exception as exc:
+            last_err = f"非 JSON: {exc!r}"
+            time.sleep(1.5)
+            continue
+
+        link = data.get("url") or data.get("openai_payurl") or data.get("chatgpt_checkout_url")
+        if link:
+            ctx.log(f"[longlink] 成功取到 long_link={link} sessionId={data.get('checkout_session_id', '?')}")
+            return link
+        last_err = f"响应缺 url 字段: {text[:300]}"
+        time.sleep(1.5)
+
+    raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
+
+
+def _rand_email() -> str:
+    import random
+    import string
+    name = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
+    return f"{name}@gmail.com"
+
+
+def _rand_paypal_email() -> str:
+    """PayPal 注册用的独立邮箱(与 ChatGPT 注册邮箱解耦)。
+    用 gmail 域,一来 PayPal 对 gmail 的接受度高,二来不与 a4sky 邮件助手挂钩。
+    """
+    import random
+    import string
+    # 8-12 位随机字母数字 + 点号风格更像真人
+    body_len = random.randint(8, 12)
+    body = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(body_len))
+    # 加一个随机数字尾巴
+    tail = "".join(random.choice(string.digits) for _ in range(random.randint(2, 4)))
+    return f"{body}{tail}@gmail.com"
+
+
+def _rand_password() -> str:
+    import random
+    import string
+    pools = [
+        random.choice(string.ascii_uppercase),
+        random.choice(string.ascii_lowercase),
+        random.choice(string.digits),
+        random.choice("!@#$%^"),
+    ]
+    pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^") for _ in range(10)]
+    random.shuffle(pools)
+    return "".join(pools)
+
+
+def _check_stop(ctx: RunContext):
+    if ctx.state == "stopped":
+        raise RuntimeError("STOPPED_BY_USER")
+
+
+def _dump_page(ctx: RunContext, page, tag: str):
+    """失败/检查点时把当前页 URL/截图/HTML 都落盘。"""
+    try:
+        url = page.url
+    except Exception:
+        url = "<unknown>"
+    ctx.log(f"[dump:{tag}] URL={url}")
+    try:
+        title = page.title()
+        ctx.log(f"[dump:{tag}] title={title!r}")
+    except Exception as exc:
+        ctx.log(f"[dump:{tag}] 取标题失败: {exc!r}")
+
+    base = os.path.join(ctx.artifact_dir, f"{int(time.time()*1000)}-{tag}")
+    try:
+        page.screenshot(path=base + ".png", full_page=True)
+        ctx.log(f"[dump:{tag}] 截图 -> {base}.png")
+    except Exception as exc:
+        ctx.log(f"[dump:{tag}] 截图失败: {exc!r}")
+    try:
+        html = page.content()
+        with open(base + ".html", "w", encoding="utf-8") as f:
+            f.write(html)
+        ctx.log(f"[dump:{tag}] HTML -> {base}.html ({len(html)} bytes)")
+    except Exception as exc:
+        ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
+
+
+def run_paypal_flow(ctx: RunContext, page=None):
+    """跑 Stripe → PayPal。这一段不走代理。
+    page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
+    支付失败会清缓存+重开长链最多重试 MAX_PAYPAL_RETRIES 次。
+    """
+    if page is None:
+        return _run_paypal_flow_self_browser(ctx)
+
+    ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
+    ctx.set_stage("准备地址/卡/账号")
+    ctx.address = fetch_us_address(log=ctx.log)
+    ctx.card = fetch_visa_card(log=ctx.log)
+    reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
+    if reuse and ctx.email and ctx.password:
+        ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
+    else:
+        ctx.email = _rand_email()
+        ctx.password = _rand_password()
+    ctx.log(f"[playwright] email={ctx.email}")
+    ctx.log(f"[playwright] password={ctx.password}")
+    ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
+    ctx.log(f"[playwright] address={ctx.address}")
+    ctx.log(f"[playwright] phone={PHONE_E164}")
+
+    ctx.log("[playwright] 复用外部浏览器 page 跑 Stripe/PayPal")
+    last_err: Exception | None = None
+    for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
+        # PayPal 字段用独立的 gmail 邮箱,每次重试都换(避开 PayPal 把上次失败的邮箱标黑)
+        ctx.paypal_email = _rand_paypal_email()
+        ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
+        try:
+            ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
+            ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
+            page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
+            _dump_page(ctx, page, f"01-stripe-loaded-attempt{attempt}")
+
+            _stripe_select_paypal_and_submit(ctx, page)
+            _paypal_signup_and_pay(ctx, page)
+            ctx.log("[playwright] 流程完成")
+            _dump_page(ctx, page, "99-final")
+            return
+        except PayPalPaymentFailed as exc:
+            last_err = exc
+            ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
+            if attempt >= MAX_PAYPAL_RETRIES:
+                ctx.log(f"[playwright] 已达最大重试次数 {MAX_PAYPAL_RETRIES},放弃")
+                raise
+            ctx.set_stage(f"支付失败,准备第 {attempt + 1} 次重试(清缓存+换 context)")
+            try:
+                _replace_browser_context(ctx, page)
+                # _replace_browser_context 返回新的 page,但因为这里的 page 是参数无法重新赋值
+                # 改为修改 ctx 上的引用,让上层重新拿
+                # 但上层 chatgpt_flow 是直接传 page 进来的,没法接到新 page
+                # 简单粗暴:在原 context 上彻底清干净
+            except Exception as exc2:
+                ctx.log(f"[playwright] 换 context 异常,回退到清 cookie: {exc2!r}")
+                try:
+                    _clear_browser_state(ctx, page)
+                except Exception as exc3:
+                    ctx.log(f"[playwright] 清缓存异常(继续): {exc3!r}")
+            try:
+                ctx.address = fetch_us_address(log=ctx.log)
+                ctx.card = fetch_visa_card(log=ctx.log)
+                ctx.log(f"[playwright] 重试用新地址={ctx.address} 新卡尾号=***{ctx.card['number'][-4:]}")
+            except Exception as exc2:
+                ctx.log(f"[playwright] 重新拉取地址/卡片异常(继续): {exc2!r}")
+            # 让 PayPal 反爬规则稍微衰减
+            ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
+            time.sleep(30)
+        except Exception as exc:
+            ctx.log(f"[playwright] 流程异常: {exc!r}")
+            ctx.log(traceback.format_exc())
+            try:
+                _dump_page(ctx, page, f"99-error-attempt{attempt}")
+            except Exception:
+                pass
+            raise
+    if last_err is not None:
+        raise last_err
+
+
+def _run_paypal_flow_self_browser(ctx: RunContext):
+    """旧入口:自己开浏览器(保留兼容性)。"""
+    from playwright.sync_api import sync_playwright
+
+    ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
+    ctx.set_stage("准备地址/卡/账号")
+    ctx.address = fetch_us_address(log=ctx.log)
+    ctx.card = fetch_visa_card(log=ctx.log)
+    reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
+    if reuse and ctx.email and ctx.password:
+        ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
+    else:
+        ctx.email = _rand_email()
+        ctx.password = _rand_password()
+    ctx.log(f"[playwright] email={ctx.email}")
+    ctx.log(f"[playwright] password={ctx.password}")
+    ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
+    ctx.log(f"[playwright] address={ctx.address}")
+    ctx.log(f"[playwright] phone={PHONE_E164}")
+
+    with sync_playwright() as p:
+        ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
+        browser = p.chromium.launch(
+            headless=ctx.headless,
+            args=["--disable-blink-features=AutomationControlled"],
+        )
+        context = browser.new_context(
+            locale="en-US",
+            timezone_id="America/New_York",
+            viewport={"width": 1280, "height": 900},
+        )
+        new_page = context.new_page()
+
+        new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
+        new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
+        new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
+        new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
+
+        try:
+            for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
+                ctx.paypal_email = _rand_paypal_email()
+                ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}")
+                try:
+                    ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
+                    new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
+                    _dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
+                    _stripe_select_paypal_and_submit(ctx, new_page)
+                    _paypal_signup_and_pay(ctx, new_page)
+                    ctx.log("[playwright] 流程完成")
+                    _dump_page(ctx, new_page, "99-final")
+                    return
+                except PayPalPaymentFailed as exc:
+                    ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
+                    if attempt >= MAX_PAYPAL_RETRIES:
+                        raise
+                    _clear_browser_state(ctx, new_page)
+                    try:
+                        ctx.address = fetch_us_address(log=ctx.log)
+                        ctx.card = fetch_visa_card(log=ctx.log)
+                    except Exception as exc2:
+                        ctx.log(f"[playwright] 重抽地址/卡异常(继续): {exc2!r}")
+                    ctx.log("[playwright] 重试前等待 30s")
+                    time.sleep(30)
+        except Exception as exc:
+            ctx.log(f"[playwright] 流程异常: {exc!r}")
+            ctx.log(traceback.format_exc())
+            try:
+                _dump_page(ctx, new_page, "99-error")
+            except Exception:
+                pass
+            raise
+        finally:
+            try:
+                browser.close()
+            except Exception:
+                pass
+
+
+def _stripe_select_paypal(ctx, page) -> bool:
+    """多策略尝试选中 PayPal 支付方式。命中返回 True。"""
+    # 1) 先等待至少一种 PayPal 候选出现,避免 timing 问题
+    try:
+        page.wait_for_function(
+            r"""() => {
+                const sels = [
+                    '#payment-method-accordion-item-title-paypal',
+                    'input[type="radio"][value="paypal"]',
+                    'input[name="paymentMethod"][value="paypal"]',
+                    '[data-testid*="paypal" i]',
+                    'label[for*="paypal" i]',
+                    'button[id*="paypal" i]',
+                ];
+                for (const s of sels) {
+                    if (document.querySelector(s)) return true;
+                }
+                const labels = Array.from(document.querySelectorAll('label, button, [role="radio"], [role="button"]'));
+                return labels.some((el) => /paypal/i.test((el.innerText || el.getAttribute('aria-label') || '')));
+            }""",
+            timeout=15000,
+        )
+    except Exception as exc:
+        ctx.log(f"[stripe] 等待 PayPal 候选出现超时: {exc!r}")
+
+    # 2) 候选 selector 列表,按可靠度排序
+    candidates = [
+        '#payment-method-accordion-item-title-paypal',
+        'input[type="radio"][value="paypal"]',
+        'input[name="paymentMethod"][value="paypal"]',
+        '[data-testid="payment-method-paypal"]',
+        '[data-testid*="paypal" i]',
+        'label[for*="paypal" i]',
+        'button[id*="paypal" i]',
+        '[role="radio"][aria-label*="paypal" i]',
+    ]
+    for sel in candidates:
+        loc = page.locator(sel).first
+        if loc.count() == 0:
+            continue
+        ctx.log(f"[stripe] PayPal 候选命中 {sel}")
+        # 多种点法兜底
+        for action in ("check", "click", "force_click", "js_click"):
+            try:
+                if action == "check":
+                    loc.check(force=True, timeout=4000)
+                elif action == "click":
+                    loc.click(timeout=4000)
+                elif action == "force_click":
+                    loc.click(timeout=4000, force=True)
+                else:
+                    loc.evaluate("el => { el.click(); }")
+                ctx.log(f"[stripe] {action}({sel}) 成功")
+                page.wait_for_timeout(800)
+                if _stripe_paypal_is_selected(page):
+                    return True
+            except Exception as exc:
+                ctx.log(f"[stripe] {action}({sel}) 失败: {exc!r}")
+                continue
+        # 没确认选中也继续下一种
+        if _stripe_paypal_is_selected(page):
+            return True
+
+    # 3) 文本扫描:找包含 "PayPal" 文本的可点击元素,逐个尝试
+    ctx.log("[stripe] 进入文本扫描兜底")
+    try:
+        n = page.evaluate(
+            r"""() => {
+                const all = Array.from(document.querySelectorAll(
+                    'label, button, [role="radio"], [role="button"], [role="tab"], div[tabindex], a'
+                ));
+                const matches = all.filter((el) => {
+                    const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
+                    return /paypal/i.test(t);
+                });
+                window.__paypalCandidates = matches;
+                return matches.length;
+            }"""
+        )
+        ctx.log(f"[stripe] 文本扫描候选数={n}")
+        for i in range(int(n or 0)):
+            try:
+                page.evaluate(
+                    r"""(i) => {
+                        const el = (window.__paypalCandidates || [])[i];
+                        if (el) { el.scrollIntoView({block:'center'}); el.click(); }
+                    }""",
+                    i,
+                )
+                page.wait_for_timeout(700)
+                if _stripe_paypal_is_selected(page):
+                    ctx.log(f"[stripe] 文本扫描候选 idx={i} 命中")
+                    return True
+            except Exception as exc:
+                ctx.log(f"[stripe] 文本扫描候选 idx={i} 失败: {exc!r}")
+    except Exception as exc:
+        ctx.log(f"[stripe] 文本扫描兜底异常: {exc!r}")
+
+    return False
+
+
+def _stripe_paypal_is_selected(page) -> bool:
+    """通过多种特征判断 PayPal 已被选中。"""
+    try:
+        return bool(page.evaluate(
+            r"""() => {
+                // 1) 单选框 checked
+                const r = document.querySelector('input[type="radio"][value="paypal"]:checked')
+                       || document.querySelector('input[name="paymentMethod"][value="paypal"]:checked');
+                if (r) return true;
+                // 2) accordion item with aria-selected/expanded=true 含 paypal
+                const items = Array.from(document.querySelectorAll('[role="radio"], [role="tab"], button, label, div'));
+                const sel = items.find((el) => {
+                    const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
+                    if (!/paypal/i.test(t)) return false;
+                    const checked = el.getAttribute('aria-checked') === 'true'
+                                 || el.getAttribute('aria-selected') === 'true'
+                                 || el.getAttribute('aria-expanded') === 'true'
+                                 || /selected|active|checked/i.test(el.className || '');
+                    return checked;
+                });
+                if (sel) return true;
+                // 3) 提交按钮文本包含 PayPal(Stripe 选 PayPal 后按钮会变 "Pay with PayPal")
+                const btn = document.querySelector('button[data-testid="hosted-payment-submit-button"]');
+                if (btn && /paypal/i.test(btn.innerText || '')) return true;
+                return false;
+            }"""
+        ))
+    except Exception:
+        return False
+
+
+def _stripe_select_paypal_and_submit(ctx, page):
+    ctx.log("[stripe] === 在 Stripe 选择 PayPal 并提交 ===")
+    page.wait_for_timeout(2000)
+    _check_stop(ctx)
+
+    selected = _stripe_select_paypal(ctx, page)
+    if not selected:
+        _dump_page(ctx, page, "02-stripe-paypal-not-found")
+        raise RuntimeError("无法在 Stripe 上选中 PayPal 支付方式")
+    ctx.log("[stripe] PayPal 已选中")
+    page.wait_for_timeout(1500)
+
+    ctx.log("[stripe] 切换 billingCountry=US")
+    _select_by_text(ctx, page, '#billingCountry', 'US', "billingCountry")
+    page.wait_for_timeout(500)
+
+    ctx.log("[stripe] 填写账单地址")
+    addr = ctx.address
+    _safe_fill(ctx, page, '#billingAddressLine1', addr["street"], "billingAddressLine1")
+    page.keyboard.press('Escape')
+    page.wait_for_timeout(200)
+    _safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
+    _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
+    _select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
+
+    if page.locator('#phoneNumber').count() > 0:
+        _safe_fill(ctx, page, '#phoneNumber', PHONE_NUMBER, "phoneNumber")
+
+    page.wait_for_timeout(400)
+    cb = page.locator('#termsOfServiceConsentCheckbox')
+    if cb.count() > 0:
+        try:
+            checked = cb.is_checked()
+            ctx.log(f"[stripe] 协议复选框 checked={checked}")
+            if not checked:
+                cb.check(force=True, timeout=2000)
+                ctx.log("[stripe] 已勾选协议")
+        except Exception as exc:
+            ctx.log(f"[stripe] 协议复选框处理失败: {exc!r}")
+    else:
+        ctx.log("[stripe] 未发现协议复选框")
+
+    _dump_page(ctx, page, "03-stripe-filled")
+
+    submit = page.locator('button[data-testid="hosted-payment-submit-button"]').first
+    if submit.count() == 0:
+        ctx.log("[stripe] 找不到 hosted-payment-submit-button")
+        _dump_page(ctx, page, "04-stripe-no-submit")
+        raise RuntimeError("hosted-payment-submit-button 不存在")
+
+    klass = submit.get_attribute("class") or ""
+    ctx.log(f"[stripe] 提交按钮 class={klass}")
+    if "incomplete" in klass:
+        ctx.log("[stripe] 警告:按钮仍处于 incomplete 状态,将再等 2s 重试")
+        page.wait_for_timeout(2000)
+        klass = submit.get_attribute("class") or ""
+        ctx.log(f"[stripe] 再次检查 class={klass}")
+
+    submit.click(timeout=8000)
+    ctx.log("[stripe] 已点击提交,等待跳转 PayPal")
+
+    try:
+        page.wait_for_url(re.compile(r"paypal\.com"), timeout=60000)
+    except Exception as exc:
+        ctx.log(f"[stripe] 等待跳转到 PayPal 超时: {exc!r}")
+        _dump_page(ctx, page, "04-stripe-no-redirect")
+        raise
+    ctx.log(f"[stripe] 已跳转: {page.url}")
+    _dump_page(ctx, page, "05-paypal-arrived")
+
+
+def _paypal_signup_and_pay(ctx, page):
+    ctx.log("[paypal] === 进入 PayPal 流程 ===")
+    page.wait_for_load_state("domcontentloaded", timeout=30000)
+    page.wait_for_timeout(1500)
+    _check_stop(ctx)
+
+    _paypal_clear_session(ctx, page)
+
+    # 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
+    from paypal_flow import ensure_checkoutweb
+    on_stage = getattr(ctx, "on_stage", None)
+    path_taken = ensure_checkoutweb(
+        page,
+        fallback_email=ctx.paypal_email or ctx.email,
+        log=ctx.log,
+        on_stage=on_stage,
+    )
+    ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
+    _dump_page(ctx, page, "06-paypal-checkoutweb-entered")
+
+    ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
+    for sel in ('#cardNumber', '#phone', '#billingLine1'):
+        try:
+            page.locator(sel).first.wait_for(state="visible", timeout=20000)
+            ctx.log(f"[paypal] {sel} 已可见")
+        except Exception as exc:
+            ctx.log(f"[paypal] 等待 {sel} 超时: {exc!r}")
+
+    _dump_page(ctx, page, "07-paypal-checkoutweb")
+
+    ctx.log("[paypal] 检查国家选择器")
+    country = page.locator('#country')
+    if country.count() > 0:
+        try:
+            current = country.input_value()
+            ctx.log(f"[paypal] 当前国家={current}")
+            if current != "US":
+                ctx.log("[paypal] 切换国家为 US")
+                country.select_option("US")
+                page.wait_for_timeout(2500)
+                ctx.log(f"[paypal] 切换后国家={country.input_value()}, URL={page.url}")
+                _dump_page(ctx, page, "08-paypal-country-switched")
+        except Exception as exc:
+            ctx.log(f"[paypal] 切换国家失败: {exc!r}")
+    else:
+        ctx.log("[paypal] 未发现 #country 选择器")
+
+    addr = ctx.address
+    card = ctx.card
+
+    ctx.log("[paypal] 第一轮填表")
+    _paypal_fill_form(ctx, page, addr, card)
+    page.wait_for_timeout(800)
+
+    invalid = _paypal_collect_invalid(page)
+    if invalid:
+        ctx.log(f"[paypal] 第一轮后仍有 aria-invalid='true' 的字段: {invalid},再补一次")
+        _paypal_fill_form(ctx, page, addr, card, retry_only=invalid)
+        page.wait_for_timeout(600)
+
+    _dump_page(ctx, page, "09-paypal-filled")
+
+    ctx.log("[paypal] 提交付款")
+    if not _click_next(ctx, page, "PayPal 提交"):
+        ctx.log("[paypal] 警告:未能找到可点击的提交按钮")
+        _dump_page(ctx, page, "10-paypal-no-submit")
+
+    page.wait_for_timeout(2500)
+    invalid_after = _paypal_collect_invalid(page)
+    if invalid_after:
+        ctx.log(f"[paypal] 提交后页面报错的字段: {invalid_after},再补再提交一次")
+        _paypal_fill_form(ctx, page, addr, card, retry_only=invalid_after)
+        page.wait_for_timeout(500)
+        _click_next(ctx, page, "PayPal 提交-2")
+
+    _paypal_handle_sms(ctx, page)
+
+
+_PAYPAL_FIELDS = [
+    ("email", "value"),
+    ("phone", "value"),
+    ("cardNumber", "card-number"),
+    ("cardExpiry", "card-expiry"),
+    ("cardCvv", "card-cvv"),
+    ("firstName", "value"),
+    ("lastName", "value"),
+    ("billingLine1", "value"),
+    ("billingCity", "value"),
+    ("billingPostalCode", "value"),
+    ("password", "value"),
+]
+
+
+def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
+    targets = retry_only or [k for k, _ in _PAYPAL_FIELDS]
+
+    if "email" in targets:
+        _safe_fill(ctx, page, '#email', ctx.paypal_email or ctx.email, "email")
+    if "phone" in targets:
+        _paypal_type_into(ctx, page, '#phone', PHONE_NUMBER, "phone")
+    if "cardNumber" in targets:
+        _paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
+    if "cardExpiry" in targets:
+        _paypal_type_into(ctx, page, '#cardExpiry', card["expiry"], "cardExpiry")
+    if "cardCvv" in targets:
+        _paypal_type_into(ctx, page, '#cardCvv', card["cvv"], "cardCvv")
+    if "firstName" in targets:
+        _safe_fill(ctx, page, '#firstName', "James", "firstName")
+    if "lastName" in targets:
+        _safe_fill(ctx, page, '#lastName', "Smith", "lastName")
+    if "billingLine1" in targets:
+        _safe_fill(ctx, page, '#billingLine1', addr["street"], "billingLine1")
+        try:
+            page.keyboard.press('Escape')
+        except Exception:
+            pass
+    if "billingCity" in targets:
+        _safe_fill(ctx, page, '#billingCity', addr["city"], "billingCity")
+    if "billingPostalCode" in targets:
+        _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
+    if any(k in targets for k in ("billingLine1", "billingCity", "billingPostalCode")):
+        _select_by_text(ctx, page, '#billingState', addr["state"], "billingState")
+    if "password" in targets:
+        _safe_fill(ctx, page, '#password', ctx.password, "password", mask=True)
+
+
+def _paypal_type_into(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
+    label = label or selector
+    shown = "***" if mask else value
+    try:
+        loc = page.locator(selector).first
+        if loc.count() == 0:
+            ctx.log(f"[type] {label}({selector}) 不存在,跳过")
+            return
+        loc.wait_for(state="visible", timeout=8000)
+        loc.click()
+        loc.fill("")
+        loc.type(value, delay=20)
+        ctx.log(f"[type] {label}({selector}) <- {shown}")
+    except Exception as exc:
+        ctx.log(f"[type] {label}({selector}) 失败: {exc!r}")
+
+
+def _paypal_collect_invalid(page):
+    try:
+        return page.evaluate("""() => {
+            const out = [];
+            document.querySelectorAll('[aria-invalid="true"]').forEach(el => {
+                if (el.id) out.push(el.id);
+                else if (el.name) out.push(el.name);
+            });
+            return out;
+        }""")
+    except Exception:
+        return []
+
+
+def _paypal_clear_session(ctx, page):
+    ctx.log("[paypal] 清理 cookie/storage")
+    try:
+        before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
+        page.evaluate("""() => {
+            try { localStorage.clear(); } catch (e) {}
+            try { sessionStorage.clear(); } catch (e) {}
+            const host = location.hostname;
+            const parts = host.split('.');
+            const domains = [host, '.' + host];
+            for (let i = 1; i < parts.length - 1; i++) domains.push('.' + parts.slice(i).join('.'));
+            const cookies = document.cookie ? document.cookie.split(';') : [];
+            cookies.forEach(c => {
+                const name = c.split('=')[0].trim();
+                if (!name) return;
+                ['/', location.pathname].forEach(p => {
+                    domains.forEach(d => {
+                        document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
+                    });
+                    document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p;
+                });
+            });
+        }""")
+        after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
+        ctx.log(f"[paypal] cookie 数量 {before} -> {after}(httpOnly 项 JS 删不掉)")
+    except Exception as exc:
+        ctx.log(f"[paypal] 清理会话异常: {exc!r}")
+
+
+def _paypal_remove_captcha_overlay(ctx, page):
+    try:
+        removed = page.evaluate("""() => {
+            const ids = ['captchaComponent', 'recaptcha', 'recaptcha-overlay', 'reCAPTCHAOverlay'];
+            let n = 0;
+            for (const id of ids) {
+                const el = document.getElementById(id);
+                if (el) { el.remove(); n++; }
+            }
+            document.querySelectorAll(
+                'iframe[name="recaptcha"], iframe[title*="recaptcha" i], div[class*="captcha" i][class*="overlay" i]'
+            ).forEach(el => { el.remove(); n++; });
+            return n;
+        }""")
+        if removed:
+            ctx.log(f"[paypal] 已删除 captcha 相关蒙层节点 {removed} 个")
+    except Exception as exc:
+        ctx.log(f"[paypal] 删除 captcha 蒙层失败: {exc!r}")
+
+
+def _paypal_handle_sms(ctx, page):
+    ctx.log("[paypal] === 检查是否需要短信验证码 ===")
+    page.wait_for_timeout(3000)
+    _check_stop(ctx)
+
+    _paypal_remove_captcha_overlay(ctx, page)
+
+    digit_inputs = page.locator('input[id^="ci-ciBasic-"]')
+    digit_count = 0
+    try:
+        digit_count = digit_inputs.count()
+    except Exception as exc:
+        ctx.log(f"[paypal] 探测分立 OTP 输入框失败: {exc!r}")
+    ctx.log(f"[paypal] 分立 OTP 输入框 ci-ciBasic-* count={digit_count}")
+
+    single_input = None
+    if digit_count == 0:
+        sms_inputs = [
+            'input[name="otp"]',
+            'input[autocomplete="one-time-code"]',
+            'input#otp',
+            'input[name="smsOtp"]',
+            'input[id*="otp" i]',
+        ]
+        for sel in sms_inputs:
+            loc = page.locator(sel).first
+            try:
+                count = loc.count()
+                ctx.log(f"[paypal] 探测 SMS 输入框 selector={sel} count={count}")
+                if count == 0:
+                    continue
+                loc.wait_for(state="visible", timeout=4000)
+                single_input = (loc, sel)
+                ctx.log(f"[paypal] 命中验证码输入框: {sel}")
+                break
+            except Exception as exc:
+                ctx.log(f"[paypal] selector={sel} 等待可见失败: {exc!r}")
+                continue
+
+    if digit_count == 0 and single_input is None:
+        ctx.log("[paypal] 未检测到验证码输入框,跳过 SMS 步骤")
+        _dump_page(ctx, page, "11-paypal-no-sms")
+        _paypal_wait_for_payment_completion(ctx, page)
+        return
+
+    _dump_page(ctx, page, "12-paypal-sms-prompt")
+    ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
+    code = fetch_sms_code(timeout=180, interval=5, log=ctx.log)
+
+    if digit_count > 0:
+        ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
+        if len(code) < digit_count:
+            ctx.log(f"[paypal] 警告:验证码位数 {len(code)} < 输入框数 {digit_count}")
+        try:
+            first = digit_inputs.nth(0)
+            first.wait_for(state="visible", timeout=4000)
+            first.click()
+        except Exception as exc:
+            ctx.log(f"[paypal] 聚焦第 1 格失败: {exc!r}")
+        for idx in range(min(digit_count, len(code))):
+            try:
+                box = digit_inputs.nth(idx)
+                box.fill("")
+                box.type(code[idx], delay=30)
+                ctx.log(f"[paypal] OTP[{idx}] <- {code[idx]}")
+            except Exception as exc:
+                ctx.log(f"[paypal] OTP[{idx}] 填入失败: {exc!r}")
+    else:
+        loc, sel = single_input
+        loc.fill(code)
+        ctx.log(f"[paypal] 已填入验证码到 {sel}")
+
+    page.wait_for_timeout(800)
+    _click_next(ctx, page, "SMS 提交")
+    ctx.log("[paypal] 已提交验证码")
+    _dump_page(ctx, page, "13-paypal-sms-submitted")
+    _paypal_wait_for_payment_completion(ctx, page)
+
+
+def _paypal_wait_for_payment_completion(
+    ctx,
+    page,
+    timeout: int = POST_PAYMENT_WAIT_TIMEOUT,
+    interval: int = POST_PAYMENT_WAIT_INTERVAL,
+) -> bool:
+    ctx.log(f"[paypal] 等待支付完成,自动等待 {timeout}s,间隔 {interval}s")
+    deadline = time.time() + timeout
+    last_status = ""
+    manual_mode = False
+    clicked_actions = set()
+
+    while True:
+        _check_stop(ctx)
+        if _page_is_closed(page):
+            ctx.log("[paypal] 页面已被关闭,停止等待支付完成")
+            return False
+
+        completion_reason = _detect_payment_completion(page)
+        if completion_reason:
+            ctx.log(f"[paypal] 支付完成确认: {completion_reason}")
+            _dump_page(ctx, page, "14-paypal-payment-complete")
+            return True
+
+        failure_reason = _detect_payment_failure(page)
+        if failure_reason:
+            ctx.log(f"[paypal] 支付失败信号: {failure_reason}")
+            _dump_page(ctx, page, "14-paypal-payment-failed")
+            raise PayPalPaymentFailed(failure_reason)
+
+        if _click_post_sms_action_if_available(ctx, page, clicked_actions):
+            last_status = ""
+            page.wait_for_timeout(2500)
+            continue
+
+        status = _summarize_payment_wait_status(page)
+        if status and status != last_status:
+            ctx.log(f"[paypal] 仍在等待支付完成: {status}")
+            last_status = status
+
+        if time.time() >= deadline:
+            if ctx.headless:
+                _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
+                raise TimeoutError(f"等待支付完成超时 {timeout}s")
+            if not manual_mode:
+                ctx.log("[paypal] 自动等待已超时,当前为可视浏览器,保持窗口打开;完成后会继续检测,或点击停止结束任务")
+                _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
+                manual_mode = True
+            interval = POST_PAYMENT_MANUAL_INTERVAL
+
+        page.wait_for_timeout(interval * 1000)
+
+
+def _click_post_sms_action_if_available(ctx, page, clicked_actions: set) -> bool:
+    url = getattr(page, "url", "") or ""
+    candidates = []
+
+    if "paypal.com" in url:
+        candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_PAYPAL_ACTION_TEXTS)
+    if "pay.openai.com" in url or "checkout.stripe.com" in url:
+        candidates.append(('button[data-testid="hosted-payment-submit-button"]', "Stripe hosted submit"))
+        candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_STRIPE_ACTION_TEXTS)
+
+    for selector, label in candidates:
+        key = (url.split("?", 1)[0], label)
+        if key in clicked_actions:
+            continue
+        try:
+            loc = page.locator(selector).first
+            if loc.count() == 0:
+                continue
+            enabled = loc.is_enabled()
+            ctx.log(f"[paypal] 后续确认候选 {label} selector={selector} enabled={enabled}")
+            if not enabled:
+                continue
+            loc.click(timeout=4000)
+            clicked_actions.add(key)
+            ctx.log(f"[paypal] 已点击后续确认按钮: {label}")
+            return True
+        except Exception as exc:
+            ctx.log(f"[paypal] 后续确认按钮 {label} 处理失败: {exc!r}")
+            continue
+    return False
+
+
+def _page_is_closed(page) -> bool:
+    try:
+        is_closed = getattr(page, "is_closed", None)
+        return bool(is_closed and is_closed())
+    except Exception:
+        return False
+
+
+def _detect_payment_completion(page) -> str:
+    url = getattr(page, "url", "") or ""
+    snapshot = _payment_page_snapshot(page)
+    text = snapshot.get("text", "")
+
+    if "chatgpt.com" in url:
+        return f"已跳回 ChatGPT: {url}"
+    if _PAYMENT_COMPLETE_RE.search(text):
+        return "页面出现完成提示"
+    if (
+        ("pay.openai.com" in url or "checkout.stripe.com" in url)
+        and re.search(r"(complete|success|return|receipt)", url, re.I)
+    ):
+        return f"支付页进入完成 URL: {url}"
+    return ""
+
+
+def _summarize_payment_wait_status(page) -> str:
+    url = getattr(page, "url", "") or ""
+    snapshot = _payment_page_snapshot(page)
+    alerts = snapshot.get("alerts", "")
+    text = snapshot.get("text", "")
+    invalid_count = snapshot.get("invalid_count", 0)
+
+    parts = [f"URL={url}"]
+    if invalid_count:
+        parts.append(f"invalid_fields={invalid_count}")
+    if alerts:
+        parts.append(f"alert={alerts[:300]}")
+    else:
+        blocking = _PAYMENT_BLOCKING_RE.search(text)
+        if blocking:
+            start = max(0, blocking.start() - 80)
+            end = min(len(text), blocking.end() + 160)
+            parts.append(f"page_hint={text[start:end].strip()[:300]}")
+    return " | ".join(parts)
+
+
+def _payment_page_snapshot(page) -> dict:
+    try:
+        data = page.evaluate(r"""() => {
+            const bodyText = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').trim();
+            const alerts = Array.from(document.querySelectorAll(
+                '[role="alert"], [data-testid*="error" i], [class*="error" i], [aria-invalid="true"]'
+            )).map(el => (el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim())
+              .filter(Boolean)
+              .slice(0, 5)
+              .join(' | ');
+            return {
+                title: document.title || '',
+                text: bodyText.slice(0, 5000),
+                alerts,
+                invalid_count: document.querySelectorAll('[aria-invalid="true"]').length
+            };
+        }""")
+        return data if isinstance(data, dict) else {}
+    except Exception:
+        return {}
+
+
+def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
+    label = label or selector
+    shown = "***" if mask else value
+    try:
+        loc = page.locator(selector).first
+        count = loc.count()
+        if count == 0:
+            ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
+            return
+        loc.wait_for(state="visible", timeout=8000)
+        loc.fill(value)
+        ctx.log(f"[fill] {label}({selector}) <- {shown}")
+    except Exception as exc:
+        ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
+
+
+def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
+    label = label or selector
+    try:
+        loc = page.locator(selector).first
+        if loc.count() == 0:
+            ctx.log(f"[select] {label}({selector}) 不存在,跳过")
+            return
+        loc.wait_for(state="visible", timeout=4000)
+        for kind in ("value", "label", "raw"):
+            try:
+                if kind == "value":
+                    loc.select_option(value=text)
+                elif kind == "label":
+                    loc.select_option(label=text)
+                else:
+                    loc.select_option(text)
+                ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
+                return
+            except Exception as exc:
+                ctx.log(f"[select] {label} 尝试 {kind} 失败: {exc!r}")
+        ctx.log(f"[select] {label}({selector}) 三种方式都失败")
+    except Exception as exc:
+        ctx.log(f"[select] {label}({selector}) 异常: {exc!r}")
+
+
+def _click_next(ctx, page, tag: str = ""):
+    candidates = [
+        'button[data-testid="submit-button"]',
+        'button[data-testid="hosted-payment-submit-button"]',
+        'button[data-atomic-wait-intent="Submit_Email"]',
+        'button.SubmitButton--complete',
+    ]
+    for sel in candidates:
+        loc = page.locator(sel).first
+        if loc.count() == 0:
+            continue
+        try:
+            enabled = loc.is_enabled()
+            ctx.log(f"[click:{tag}] 候选 {sel} count={loc.count()} enabled={enabled}")
+            if not enabled:
+                continue
+            try:
+                loc.click(timeout=4000)
+                ctx.log(f"[click:{tag}] 已点击 {sel}")
+                return True
+            except Exception as exc:
+                msg = str(exc)
+                if "intercepts pointer events" in msg or "captchaComponent" in msg:
+                    ctx.log(f"[click:{tag}] {sel} 被蒙层挡住,尝试删 captcha 后重试")
+                    _paypal_remove_captcha_overlay(ctx, page)
+                    try:
+                        loc.click(timeout=4000, force=True)
+                        ctx.log(f"[click:{tag}] 重试已点击 {sel}")
+                        return True
+                    except Exception as exc2:
+                        ctx.log(f"[click:{tag}] {sel} 重试仍失败: {exc2!r}")
+                        continue
+                ctx.log(f"[click:{tag}] {sel} 点击失败: {exc!r}")
+        except Exception as exc:
+            ctx.log(f"[click:{tag}] {sel} 处理失败: {exc!r}")
+            continue
+
+    for text in ["Next", "Subscribe", "Pay", "Continue", "Agree", "下一步", "下一页", "訂閱"]:
+        loc = page.locator(f'button:has-text("{text}")').first
+        if loc.count() == 0:
+            continue
+        try:
+            enabled = loc.is_enabled()
+            ctx.log(f"[click:{tag}] 文本候选 has-text={text!r} enabled={enabled}")
+            if not enabled:
+                continue
+            try:
+                loc.click(timeout=4000)
+                ctx.log(f"[click:{tag}] 已点击 has-text={text!r}")
+                return True
+            except Exception as exc:
+                msg = str(exc)
+                if "intercepts pointer events" in msg or "captchaComponent" in msg:
+                    ctx.log(f"[click:{tag}] has-text={text!r} 被蒙层挡住,删 captcha 重试")
+                    _paypal_remove_captcha_overlay(ctx, page)
+                    try:
+                        loc.click(timeout=4000, force=True)
+                        ctx.log(f"[click:{tag}] 重试已点击 has-text={text!r}")
+                        return True
+                    except Exception as exc2:
+                        ctx.log(f"[click:{tag}] has-text={text!r} 重试仍失败: {exc2!r}")
+                        continue
+                ctx.log(f"[click:{tag}] has-text={text!r} 点击失败: {exc!r}")
+        except Exception as exc:
+            ctx.log(f"[click:{tag}] has-text={text!r} 处理失败: {exc!r}")
+            continue
+    ctx.log(f"[click:{tag}] 未找到任何可点击的下一步按钮")
+    return False
+
+
+def run(ctx: RunContext):
+    ctx.log(f"[run] === 任务开始 run_id={ctx.run_id} artifact_dir={ctx.artifact_dir} ===")
+    try:
+        ctx.long_link = generate_long_link(ctx)
+        run_paypal_flow(ctx)
+        ctx.state = "done"
+        ctx.log("[run] === 全部步骤已尝试完成 ===")
+    except Exception as exc:
+        if str(exc) == "STOPPED_BY_USER":
+            ctx.log("[run] === 用户中止 ===")
+            ctx.state = "stopped"
+        else:
+            ctx.log(f"[run] === 异常退出: {exc!r} ===")
+            ctx.log(traceback.format_exc())
+            ctx.state = "error"

+ 309 - 0
chatgpt_flow.py

@@ -0,0 +1,309 @@
+"""端到端编排:注册 ChatGPT → 生成 Plus 长链 → PayPal 付款 → 校验 Plus → 上传 CPA。"""
+from __future__ import annotations
+
+import json
+import time
+import traceback
+from dataclasses import dataclass, field
+from typing import Callable, Optional
+
+from automation import (
+    RunContext,
+    generate_long_link_payurl,
+    run_paypal_flow,
+    _dump_page,
+)
+from chatgpt_signup import fetch_current_session, signup_chatgpt
+from config import AppConfig
+from cpa_uploader import (
+    get_session_plan_type,
+    is_plus_session,
+    upload_session_to_cpa,
+)
+from mail_provider import build_a4sky_email  # noqa: F401  (re-exported for tests)
+from storage import add_event, init_db, upsert_account
+
+
+@dataclass
+class FullRunContext:
+    cfg: AppConfig
+    log: Callable[[str], None] = print
+    on_stage: Optional[Callable[[str], None]] = None
+    on_account_finished: Optional[Callable[[dict], None]] = None
+    state: str = "running"
+    stage: str = ""
+    accounts: list[dict] = field(default_factory=list)
+    run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
+
+    def set_stage(self, name: str):
+        self.stage = name
+        self.log(f"[stage:full] {name}")
+        if self.on_stage:
+            try:
+                self.on_stage(name)
+            except Exception:
+                pass
+
+    def check_stop(self):
+        if self.state == "stopped":
+            raise RuntimeError("STOPPED_BY_USER")
+
+
+def _refresh_session(page, log: Callable[[str], None]) -> dict:
+    """支付完成后重新拉一次 /api/auth/session 看 planType。"""
+    return fetch_current_session(page, log)
+
+
+def _run_one_account(full_ctx: FullRunContext, page, idx: int, total: int) -> dict:
+    cfg = full_ctx.cfg
+    full_ctx.check_stop()
+    full_ctx.set_stage(f"账号 {idx}/{total}:开始注册")
+
+    sub_log = lambda msg: full_ctx.log(f"[acc{idx}] {msg}")
+
+    signup_result = signup_chatgpt(
+        page,
+        helper_url=cfg.mail_helper_url,
+        mail_domain=cfg.mail_domain,
+        mail_poll_interval_sec=cfg.mail_poll_interval_sec,
+        mail_poll_max_attempts=cfg.mail_poll_max_attempts,
+        log=sub_log,
+        on_stage=lambda name: full_ctx.set_stage(f"账号 {idx}/{total}:注册-{name}"),
+    )
+    email = signup_result["email"]
+    password = signup_result["password"]
+    session = signup_result["session"]
+    access_token = session.get("accessToken") or ""
+    if not access_token:
+        upsert_account(email, password, fields={
+            "final_status": "failed",
+            "last_error": "注册成功但未拿到 accessToken",
+            "initial_session": session,
+        })
+        add_event(email, "register", "error", "未拿到 accessToken")
+        raise RuntimeError("注册成功但未拿到 accessToken")
+
+    initial_plan = get_session_plan_type(session)
+    upsert_account(email, password, fields={
+        "final_status": "registered",
+        "plan_type": initial_plan,
+        "initial_session": session,
+    })
+    add_event(email, "register", "ok", f"plan={initial_plan}")
+
+    record = {
+        "email": email,
+        "password": password,
+        "stage": "registered",
+        "planType": initial_plan,
+        "cpa": None,
+        "error": None,
+    }
+    full_ctx.set_stage(f"账号 {idx}/{total}:注册成功 email={email}")
+
+    # 1) 生成 Plus 长链 — 走 payurl.ark2.cn(与 Chrome 扩展一致)
+    full_ctx.check_stop()
+    full_ctx.set_stage(f"账号 {idx}/{total}:生成 Plus 长链")
+    sub_ctx = RunContext(
+        token=access_token,
+        plan="plus",
+        country="US",
+        currency="USD",
+        use_promo=cfg.use_promo,
+        headless=cfg.headless,
+        log=sub_log,
+        on_stage=lambda name: full_ctx.set_stage(f"账号 {idx}/{total}:长链-{name}"),
+    )
+    sub_ctx.email = email
+    sub_ctx.password = password
+    long_link = generate_long_link_payurl(sub_ctx)
+    sub_ctx.long_link = long_link
+    record["longLink"] = long_link
+    upsert_account(email, password, fields={"long_link": long_link})
+    add_event(email, "long_link", "ok", long_link[:200])
+
+    # 2) PayPal 付款 — 复用同一个浏览器 page,避免嵌套 sync_playwright
+    full_ctx.check_stop()
+    full_ctx.set_stage(f"账号 {idx}/{total}:进入 PayPal 付款流")
+    sub_ctx._reuse_account_for_paypal = True  # type: ignore
+    try:
+        run_paypal_flow(sub_ctx, page=page)
+    except Exception as exc:
+        upsert_account(email, password, fields={
+            "final_status": "failed",
+            "last_error": f"PayPal 流异常: {exc!r}",
+        })
+        add_event(email, "paypal", "error", repr(exc))
+        raise
+    add_event(email, "paypal", "ok")
+    upsert_account(email, password, fields={"final_status": "paid"})
+
+    # 3) 重新拉 session,看 planType
+    full_ctx.check_stop()
+    full_ctx.set_stage(f"账号 {idx}/{total}:付款完成,重新拉 session")
+    new_session = _refresh_session_from_page(page, access_token, log=sub_log)
+    new_plan = get_session_plan_type(new_session)
+    record["planType"] = new_plan
+    record["sessionRefreshed"] = True
+    upsert_account(email, password, fields={
+        "plan_type": new_plan,
+        "plus_session": new_session,
+    })
+
+    if not is_plus_session(new_session):
+        record["stage"] = "plus_check_failed"
+        record["error"] = f"planType 不是 plus,实际为 {record['planType']!r}"
+        full_ctx.log(f"[acc{idx}] 失败:{record['error']}")
+        upsert_account(email, password, fields={
+            "final_status": "plus_check_failed",
+            "last_error": record["error"],
+        })
+        add_event(email, "plus_check", "error", record["error"])
+        return record
+
+    full_ctx.set_stage(f"账号 {idx}/{total}:Plus 校验通过")
+    upsert_account(email, password, fields={"final_status": "plus"})
+    add_event(email, "plus_check", "ok", f"plan={new_plan}")
+
+    # 4) 上传 CPA
+    full_ctx.check_stop()
+    full_ctx.set_stage(f"账号 {idx}/{total}:上传 CPA")
+    if not (cfg.cpa_url and cfg.cpa_management_key):
+        full_ctx.log(f"[acc{idx}] 跳过 CPA 上传(未配置 cpa_url / cpa_management_key)")
+        record["stage"] = "cpa_skipped"
+        upsert_account(email, password, fields={"final_status": "cpa_skipped"})
+        add_event(email, "cpa", "warn", "未配置 CPA")
+        return record
+
+    try:
+        cpa_result = upload_session_to_cpa(
+            new_session,
+            cpa_url=cfg.cpa_url,
+            management_key=cfg.cpa_management_key,
+            email_hint=email,
+            log=sub_log,
+        )
+    except Exception as exc:
+        upsert_account(email, password, fields={
+            "final_status": "cpa_failed",
+            "last_error": f"CPA 上传异常: {exc!r}",
+        })
+        add_event(email, "cpa", "error", repr(exc))
+        raise
+    record["cpa"] = cpa_result
+    record["stage"] = "cpa_uploaded"
+    upsert_account(email, password, fields={
+        "final_status": "cpa_uploaded",
+        "cpa_file_name": cpa_result.get("fileName"),
+        "cpa_uploaded_at": int(time.time() * 1000),
+    })
+    add_event(email, "cpa", "ok", cpa_result.get("fileName"), payload=cpa_result)
+    full_ctx.set_stage(f"账号 {idx}/{total}:完成 file={cpa_result.get('fileName')}")
+    return record
+
+
+def _refresh_session_from_page(page, access_token: str, *, log: Callable[[str], None]) -> dict:
+    """付款完成后用同一个浏览器 page 拉 session,避免嵌套 sync_playwright。"""
+    log("[session] 浏览器内拉 /api/auth/session ...")
+    try:
+        page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
+        page.wait_for_timeout(2000)
+    except Exception as exc:
+        log(f"[session] 跳回 chatgpt.com 异常: {exc!r}")
+
+    deadline = time.time() + 60
+    last = ""
+    while time.time() < deadline:
+        try:
+            data = page.evaluate(
+                """async (token) => {
+                    try {
+                        const r = await fetch('/api/auth/session', {
+                            credentials: 'include',
+                            headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/json' },
+                        });
+                        const t = await r.text();
+                        try { return { ok: true, data: JSON.parse(t), status: r.status }; }
+                        catch (_) { return { ok: false, raw: t, status: r.status }; }
+                    } catch (e) {
+                        return { ok: false, error: String(e) };
+                    }
+                }""",
+                access_token,
+            )
+            if isinstance(data, dict) and data.get("ok") and isinstance(data.get("data"), dict):
+                sess = data["data"]
+                if sess.get("accessToken"):
+                    plan = (sess.get("account") or {}).get("planType")
+                    log(f"[session] 拉到 session planType={plan} status={data.get('status')}")
+                    return sess
+            preview = json.dumps(data, ensure_ascii=False)[:200] if isinstance(data, dict) else str(data)[:200]
+            if preview != last:
+                log(f"[session] 暂无可用 session,预览={preview}")
+                last = preview
+        except Exception as exc:
+            log(f"[session] page.evaluate 异常: {exc!r}")
+        time.sleep(2)
+    raise TimeoutError("拉取 session 超时(60s 内未取到 accessToken)")
+
+
+def _open_and_fetch_session_with_token(access_token: str, *, log: Callable[[str], None]) -> dict:
+    """[已废弃] 旧实现会嵌套 sync_playwright 导致流程静默退出。
+    保留空壳避免外部 import 报错;新流程请用 _refresh_session_from_page。
+    """
+    raise RuntimeError("_open_and_fetch_session_with_token 已废弃,请使用 _refresh_session_from_page(page, access_token)")
+
+
+def run_full(cfg: AppConfig, *, log: Callable[[str], None] = print,
+             on_stage: Optional[Callable[[str], None]] = None) -> FullRunContext:
+    full_ctx = FullRunContext(cfg=cfg, log=log, on_stage=on_stage)
+    full_ctx.set_stage(f"开始全自动流程 共 {cfg.account_count} 个账号")
+
+    if not cfg.cpa_url or not cfg.cpa_management_key:
+        full_ctx.log("[full] 警告:未配置 CPA 地址/密钥,仍会注册并付款,但跳过 CPA 上传")
+
+    from playwright.sync_api import sync_playwright
+
+    total = max(1, int(cfg.account_count))
+    for idx in range(1, total + 1):
+        full_ctx.check_stop()
+        full_ctx.set_stage(f"启动账号 {idx}/{total} 的浏览器")
+        with sync_playwright() as p:
+            browser = p.chromium.launch(
+                headless=cfg.headless,
+                args=["--disable-blink-features=AutomationControlled"],
+            )
+            ctx_browser = browser.new_context(
+                locale="en-US",
+                timezone_id="America/New_York",
+                viewport={"width": 1280, "height": 900},
+            )
+            page = ctx_browser.new_page()
+            page.on("console", lambda m: full_ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
+            page.on("pageerror", lambda e: full_ctx.log(f"[browser-pageerror] {e}"))
+            try:
+                record = _run_one_account(full_ctx, page, idx, total)
+            except Exception as exc:
+                if str(exc) == "STOPPED_BY_USER":
+                    full_ctx.state = "stopped"
+                    full_ctx.set_stage("用户停止")
+                    full_ctx.accounts.append({"stage": "stopped", "error": "user stopped"})
+                    return full_ctx
+                full_ctx.log(f"[full] 账号 {idx}/{total} 异常: {exc!r}")
+                full_ctx.log(traceback.format_exc())
+                record = {"stage": "error", "error": repr(exc)}
+            finally:
+                try:
+                    browser.close()
+                except Exception:
+                    pass
+            full_ctx.accounts.append(record)
+            if full_ctx.on_account_finished:
+                try:
+                    full_ctx.on_account_finished(record)
+                except Exception:
+                    pass
+
+    full_ctx.set_stage("全部账号已处理完毕")
+    full_ctx.state = "done"
+    return full_ctx

+ 714 - 0
chatgpt_signup.py

@@ -0,0 +1,714 @@
+"""ChatGPT 注册流:用 Playwright 完成"打开 chatgpt.com → 输邮箱 → 填密码 → 邮箱验证码 → 姓名生日 → 拿 session"。
+
+参考 /Users/chendeben/code/chrome_extension/codex-oauth-automation-extension/content/signup-page.js。
+"""
+from __future__ import annotations
+
+import random
+import re
+import string
+import time
+from datetime import datetime
+from typing import Callable
+
+from mail_provider import build_a4sky_email, poll_signup_code
+
+
+SIGNUP_ENTRY_URL = "https://chatgpt.com/"
+SESSION_URL = "https://chatgpt.com/api/auth/session"
+
+EMAIL_INPUT_SELECTORS = [
+    'input[type="email"]',
+    'input[name="email"]',
+    'input[name="username"]',
+    'input[id*="email" i]',
+    'input[placeholder*="email" i]',
+]
+
+PASSWORD_INPUT_SELECTORS = [
+    'input[type="password"]',
+    'input[name="password"]',
+    'input[id*="password" i]',
+]
+
+CONTINUE_BUTTON_TEXTS = ("Continue", "Next", "Sign up", "Create account", "继续", "下一步", "注册", "创建")
+SIGNUP_TRIGGER_PATTERN = re.compile(
+    r"(免费注册|立即注册|注册|sign\s*up|register|create\s*account)", re.I
+)
+
+
+def _rand_password(length: int = 16) -> str:
+    pools = [
+        random.choice(string.ascii_uppercase),
+        random.choice(string.ascii_lowercase),
+        random.choice(string.digits),
+        random.choice("!@#$%^*"),
+    ]
+    pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^*") for _ in range(length - 4)]
+    random.shuffle(pools)
+    return "".join(pools)
+
+
+def _rand_name() -> tuple[str, str]:
+    firsts = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
+              "William", "Elizabeth", "David", "Susan", "Daniel", "Sarah", "Thomas", "Karen"]
+    lasts = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
+             "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson"]
+    return random.choice(firsts), random.choice(lasts)
+
+
+def _rand_birthday() -> tuple[int, int, int]:
+    """随机 1985~2000 年的生日,避开 28 号以后避免月份冲突。"""
+    year = random.randint(1985, 2000)
+    month = random.randint(1, 12)
+    day = random.randint(1, 28)
+    return year, month, day
+
+
+def _try_click_first_visible(page, selector: str, log, *, label: str = "") -> bool:
+    loc = page.locator(selector)
+    count = loc.count()
+    if count == 0:
+        return False
+    for i in range(count):
+        item = loc.nth(i)
+        try:
+            if item.is_visible() and item.is_enabled():
+                item.click(timeout=4000)
+                log(f"[signup] 点击 {label or selector} (idx={i}) 成功")
+                return True
+        except Exception as exc:
+            log(f"[signup] 点击 {label or selector} (idx={i}) 失败: {exc!r}")
+    return False
+
+
+def _click_signup_entry(page, log) -> bool:
+    """ChatGPT 首页右上角"注册"按钮。"""
+    candidates = [
+        'a[data-testid="signup-button"]',
+        'button[data-testid="signup-button"]',
+        'a:has-text("Sign up")',
+        'button:has-text("Sign up")',
+        'a:has-text("注册")',
+        'button:has-text("注册")',
+    ]
+    for sel in candidates:
+        if _try_click_first_visible(page, sel, log, label=f"signup entry({sel})"):
+            return True
+
+    # 文本兜底
+    try:
+        all_btns = page.locator('a, button, [role="button"], [role="link"]')
+        n = all_btns.count()
+        for i in range(min(n, 200)):
+            el = all_btns.nth(i)
+            try:
+                txt = (el.inner_text(timeout=500) or "").strip()
+            except Exception:
+                continue
+            if txt and SIGNUP_TRIGGER_PATTERN.search(txt) and el.is_visible() and el.is_enabled():
+                el.click(timeout=3000)
+                log(f"[signup] 点击文本注册入口 {txt!r}")
+                return True
+    except Exception as exc:
+        log(f"[signup] 兜底查找注册入口异常: {exc!r}")
+    return False
+
+
+def _find_visible(page, selectors: list[str]):
+    for sel in selectors:
+        loc = page.locator(sel)
+        count = loc.count()
+        for i in range(count):
+            try:
+                item = loc.nth(i)
+                if item.is_visible():
+                    return item, sel
+            except Exception:
+                continue
+    return None, None
+
+
+def _click_continue(page, log, *, label: str = "continue") -> bool:
+    """通用:点 type=submit / 文本 Continue / Next 等。"""
+    direct = page.locator('button[type="submit"], input[type="submit"]')
+    cnt = direct.count()
+    for i in range(cnt):
+        try:
+            it = direct.nth(i)
+            if it.is_visible() and it.is_enabled():
+                it.click(timeout=4000)
+                log(f"[signup] {label} 点击 button[type=submit] (idx={i}) 成功")
+                return True
+        except Exception as exc:
+            log(f"[signup] {label} button[type=submit] (idx={i}) 失败: {exc!r}")
+
+    for txt in CONTINUE_BUTTON_TEXTS:
+        loc = page.locator(f'button:has-text("{txt}")').first
+        if loc.count() == 0:
+            continue
+        try:
+            if loc.is_visible() and loc.is_enabled():
+                loc.click(timeout=4000)
+                log(f"[signup] {label} 点击 has-text={txt!r} 成功")
+                return True
+        except Exception as exc:
+            log(f"[signup] {label} has-text={txt!r} 失败: {exc!r}")
+    return False
+
+
+def _is_password_page(page) -> bool:
+    try:
+        loc = page.locator(", ".join(PASSWORD_INPUT_SELECTORS))
+        if loc.count() == 0:
+            return False
+        for i in range(loc.count()):
+            if loc.nth(i).is_visible():
+                return True
+    except Exception:
+        pass
+    return False
+
+
+def _is_email_verification_page(page) -> bool:
+    """6 位验证码输入页。"""
+    url = (page.url or "").lower()
+    if "/email-verification" in url or "/verify" in url:
+        return True
+    try:
+        sel = ('input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], '
+               'input[maxlength="6"], input[maxlength="1"]')
+        loc = page.locator(sel)
+        if loc.count() >= 1:
+            return True
+    except Exception:
+        pass
+    return False
+
+
+def _wait_until(predicate: Callable[[], bool], timeout_sec: int, interval_ms: int = 250) -> bool:
+    deadline = time.time() + timeout_sec
+    while time.time() < deadline:
+        try:
+            if predicate():
+                return True
+        except Exception:
+            pass
+        time.sleep(interval_ms / 1000)
+    return False
+
+
+def _fill_signup_email(page, email: str, log):
+    log(f"[signup] === 提交注册邮箱 {email} ===")
+    inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
+    if not inp:
+        # 可能仍在首页,先点注册入口
+        if _click_signup_entry(page, log):
+            page.wait_for_load_state("domcontentloaded", timeout=20000)
+            page.wait_for_timeout(1500)
+        inp, used_sel = _find_visible(page, EMAIL_INPUT_SELECTORS)
+    if not inp:
+        raise RuntimeError(f"未找到邮箱输入框 URL={page.url}")
+
+    log(f"[signup] 命中邮箱输入框 selector={used_sel}")
+    inp.click()
+    inp.fill("")
+    inp.type(email, delay=20)
+    page.wait_for_timeout(300)
+
+    if not _click_continue(page, log, label="email-continue"):
+        raise RuntimeError("未找到邮箱页的继续按钮")
+
+    # 等待跳到密码页
+    ok = _wait_until(lambda: _is_password_page(page) or _is_email_verification_page(page), 25)
+    if not ok:
+        raise RuntimeError(f"提交邮箱后未进入密码/验证码页 URL={page.url}")
+    log(f"[signup] 邮箱已提交 URL={page.url} is_password_page={_is_password_page(page)}")
+
+
+def _fill_password(page, password: str, log):
+    log("[signup] === 填密码 ===")
+    inp, used_sel = _find_visible(page, PASSWORD_INPUT_SELECTORS)
+    if not inp:
+        raise RuntimeError(f"未找到密码输入框 URL={page.url}")
+    log(f"[signup] 命中密码输入框 selector={used_sel}")
+    inp.click()
+    inp.fill("")
+    inp.type(password, delay=20)
+    page.wait_for_timeout(300)
+    submitted_at_ms = int(time.time() * 1000)
+    if not _click_continue(page, log, label="password-continue"):
+        raise RuntimeError("未找到密码页的继续按钮")
+    return submitted_at_ms
+
+
+def _fill_verification_code(page, code: str, log):
+    log(f"[signup] === 填入验证码 {code} ===")
+    # 优先 6 位拆分输入框
+    split = page.locator('input[maxlength="1"]')
+    n_split = split.count()
+    if n_split >= 6:
+        log(f"[signup] 检测到拆分输入框 count={n_split}")
+        try:
+            split.nth(0).click()
+        except Exception:
+            pass
+        for idx, ch in enumerate(code[:n_split]):
+            try:
+                box = split.nth(idx)
+                box.fill("")
+                box.type(ch, delay=30)
+            except Exception as exc:
+                log(f"[signup] 拆分位 {idx} 输入失败: {exc!r}")
+        return
+
+    sel = 'input[name="code"], input[name="otp"], input[autocomplete="one-time-code"], input[maxlength="6"]'
+    loc = page.locator(sel).first
+    if loc.count() == 0:
+        raise RuntimeError("未找到验证码输入框")
+    loc.fill("")
+    loc.type(code, delay=30)
+    log("[signup] 已填入单格验证码")
+    page.wait_for_timeout(300)
+    _click_continue(page, log, label="code-continue")
+
+
+def _wait_profile_page_ready(page, log, timeout_sec: int = 30) -> dict:
+    """等待 profile 页可见控件出现,并返回它用的是哪种 UI。"""
+    deadline = time.time() + timeout_sec
+    while time.time() < deadline:
+        try:
+            info = page.evaluate(
+                r"""() => {
+                    const visible = (el) => {
+                        if (!el) return false;
+                        const s = window.getComputedStyle(el);
+                        if (s.display === 'none' || s.visibility === 'hidden') return false;
+                        const r = el.getBoundingClientRect();
+                        return r.width > 0 && r.height > 0;
+                    };
+                    const name = document.querySelector('input[name="name"], input[autocomplete="name"], input[placeholder*="全名"]');
+                    const age = document.querySelector('input[name="age"]');
+                    const yearSpin = document.querySelector('[role="spinbutton"][data-type="year"]');
+                    const monthSpin = document.querySelector('[role="spinbutton"][data-type="month"]');
+                    const daySpin = document.querySelector('[role="spinbutton"][data-type="day"]');
+                    const hiddenBday = document.querySelector('input[name="birthday"]');
+
+                    // React Aria 下拉:button + listbox 隐藏 select
+                    const allButtons = Array.from(document.querySelectorAll('button[aria-haspopup="listbox"], [role="combobox"]'));
+                    const matchByLabel = (kw) => allButtons.find((b) => {
+                        const t = (b.innerText || b.getAttribute('aria-label') || '').trim();
+                        return t && new RegExp(kw, 'i').test(t);
+                    }) || null;
+                    const yearBtn = matchByLabel('year|年');
+                    const monthBtn = matchByLabel('month|月');
+                    const dayBtn = matchByLabel('day|天|日');
+
+                    return {
+                        url: location.href,
+                        nameVisible: visible(name),
+                        ageVisible: visible(age),
+                        spinVisible: visible(yearSpin) && visible(monthSpin) && visible(daySpin),
+                        selectVisible: visible(yearBtn) && visible(monthBtn) && visible(dayBtn),
+                        hasHiddenBday: Boolean(hiddenBday),
+                        bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 240),
+                    };
+                }"""
+            ) or {}
+        except Exception as exc:
+            log(f"[signup] profile 页探测异常: {exc!r}")
+            info = {}
+
+        kind = "unknown"
+        if info.get("ageVisible"):
+            kind = "age"
+        elif info.get("selectVisible"):
+            kind = "select"
+        elif info.get("spinVisible"):
+            kind = "spin"
+
+        if info.get("nameVisible") and kind != "unknown":
+            log(f"[signup] profile 页就绪 url={info.get('url')} mode={kind} hidden_bday={info.get('hasHiddenBday')}")
+            return {"kind": kind, **info}
+        time.sleep(0.3)
+
+    log(f"[signup] profile 页等待超时 url={page.url}")
+    return {"kind": "unknown", "url": page.url}
+
+
+def _fill_name_and_birthday(page, first: str, last: str, year: int, month: int, day: int, log):
+    log(f"[signup] === 姓名/生日 {first} {last} {year}-{month:02d}-{day:02d} ===")
+
+    profile_info = _wait_profile_page_ready(page, log, timeout_sec=30)
+    if profile_info["kind"] == "unknown":
+        raise RuntimeError(f"profile 页未识别可见控件 url={page.url}")
+
+    full_name = f"{first} {last}"
+    name_input, name_sel = _find_visible(page, [
+        'input[name="name"]',
+        'input[autocomplete="name"]',
+        'input[placeholder*="全名"]',
+    ])
+    if not name_input:
+        raise RuntimeError("未找到姓名输入框")
+    log(f"[signup] 命中姓名输入框 selector={name_sel}")
+    name_input.click()
+    name_input.fill("")
+    name_input.type(full_name, delay=20)
+    log(f"[signup] 姓名已填写: {full_name}")
+    page.wait_for_timeout(400)
+
+    kind = profile_info["kind"]
+    bday_value = f"{year:04d}-{month:02d}-{day:02d}"
+
+    if kind == "age":
+        age = max(18, datetime.now().year - year)
+        # React Aria 用 <label> 浮在 input 上面拦截了 click,所以走 focus+JS 赋值
+        try:
+            ok = page.evaluate(
+                r"""(ageStr) => {
+                    const el = document.querySelector('input[name="age"]');
+                    if (!el) return false;
+                    el.focus();
+                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+                    setter.call(el, '');
+                    el.dispatchEvent(new Event('input', { bubbles: true }));
+                    setter.call(el, String(ageStr));
+                    el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: String(ageStr), bubbles: true }));
+                    el.dispatchEvent(new Event('input', { bubbles: true }));
+                    el.dispatchEvent(new Event('change', { bubbles: true }));
+                    el.blur();
+                    return el.value;
+                }""",
+                str(age),
+            )
+            log(f"[signup] age 已通过 JS 设置 value={ok!r}")
+        except Exception as exc:
+            log(f"[signup] JS 设 age 失败: {exc!r},回退键盘输入")
+            try:
+                # 用 page.keyboard:先 focus 后 type
+                page.evaluate("() => document.querySelector('input[name=\"age\"]').focus()")
+                page.keyboard.type(str(age), delay=30)
+                log(f"[signup] 键盘输入 age={age} 成功")
+            except Exception as exc2:
+                raise RuntimeError(f"填年龄失败(JS+键盘均失败): {exc!r} / {exc2!r}")
+        # 校验
+        try:
+            real = page.evaluate("() => document.querySelector('input[name=\"age\"]').value")
+            log(f"[signup] age input 实际 value={real!r} (期望 {age})")
+        except Exception:
+            pass
+
+    elif kind == "spin":
+        log("[signup] 使用 spinbutton 三段式生日")
+        for kw, val in (("year", year), ("month", f"{month:02d}"), ("day", f"{day:02d}")):
+            try:
+                ok = page.evaluate(
+                    r"""([sel, valStr]) => {
+                        const el = document.querySelector(sel);
+                        if (!el) return false;
+                        el.focus();
+                        document.execCommand('selectAll', false, null);
+                        for (const ch of String(valStr)) {
+                            el.dispatchEvent(new KeyboardEvent('keydown', { key: ch, code: 'Digit'+ch, bubbles: true }));
+                            el.dispatchEvent(new KeyboardEvent('keypress', { key: ch, code: 'Digit'+ch, bubbles: true }));
+                            el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: ch, bubbles: true }));
+                            el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: ch, bubbles: true }));
+                        }
+                        el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
+                        el.blur();
+                        return true;
+                    }""",
+                    [f'[role="spinbutton"][data-type="{kw}"]', str(val)],
+                )
+                log(f"[signup] spin {kw} <- {val} ok={ok}")
+            except Exception as exc:
+                log(f"[signup] spin {kw} 失败: {exc!r}")
+        # spinbutton 模式有时也会同步 hidden birthday;保险起见显式设置
+        try:
+            page.evaluate(
+                r"""([sel, val]) => {
+                    const el = document.querySelector(sel);
+                    if (!el) return false;
+                    const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+                    setter.call(el, val);
+                    el.dispatchEvent(new Event('input', { bubbles: true }));
+                    el.dispatchEvent(new Event('change', { bubbles: true }));
+                    return true;
+                }""",
+                ['input[name="birthday"]', bday_value],
+            )
+        except Exception:
+            pass
+
+    elif kind == "select":
+        log("[signup] 使用 React Aria 下拉式生日")
+        # year/month/day 各自是 button[aria-haspopup=listbox],点开后选 option
+        # 用 inner_text 含关键字定位三个 button;click 失败则回退 dispatchEvent
+        def click_select_option(label_kw_re: str, option_value: str | int) -> bool:
+            buttons = page.locator('button[aria-haspopup="listbox"], [role="combobox"]')
+            n = buttons.count()
+            target_idx = -1
+            for i in range(min(n, 80)):
+                b = buttons.nth(i)
+                try:
+                    if not b.is_visible():
+                        continue
+                    txt = (b.inner_text(timeout=400) or "").strip()
+                    aria = b.get_attribute("aria-label") or ""
+                    blob = f"{txt} {aria}"
+                except Exception:
+                    continue
+                if re.search(label_kw_re, blob, re.I):
+                    target_idx = i
+                    break
+            if target_idx < 0:
+                log(f"[signup] 未找到下拉按钮 kw={label_kw_re}")
+                return False
+            target = buttons.nth(target_idx)
+            try:
+                target.click(timeout=3000)
+            except Exception:
+                # label/positioner 拦截:force click + JS click 双兜底
+                try:
+                    target.click(timeout=3000, force=True)
+                except Exception:
+                    try:
+                        target.evaluate("el => el.click()")
+                    except Exception as exc:
+                        log(f"[signup] 三种 click 都失败 kw={label_kw_re}: {exc!r}")
+                        return False
+            page.wait_for_timeout(350)
+            opt = page.locator(f'[role="option"]:has-text("{option_value}")').first
+            if opt.count() == 0:
+                opt = page.locator(f'[role="option"]:has-text("{int(option_value)}")').first
+            if opt.count() == 0:
+                log(f"[signup] 未找到 option={option_value}")
+                return False
+            try:
+                opt.click(timeout=3000)
+            except Exception:
+                try:
+                    opt.click(timeout=3000, force=True)
+                except Exception:
+                    try:
+                        opt.evaluate("el => el.click()")
+                    except Exception as exc:
+                        log(f"[signup] 选 option 三种 click 都失败: {exc!r}")
+                        return False
+            log(f"[signup] 下拉 kw={label_kw_re} 已选 {option_value}")
+            return True
+
+        click_select_option(r"year|年", year)
+        page.wait_for_timeout(300)
+        click_select_option(r"month|月", month)
+        page.wait_for_timeout(300)
+        click_select_option(r"day|天|日", day)
+        page.wait_for_timeout(300)
+
+    # 验证 hidden birthday(如果存在)确实被写入
+    try:
+        hidden_val = page.evaluate(
+            r"""() => {
+                const el = document.querySelector('input[name="birthday"]');
+                return el ? el.value || '' : '__no_hidden__';
+            }"""
+        )
+        log(f"[signup] hidden birthday 当前值={hidden_val!r} (期望 {bday_value})")
+    except Exception:
+        pass
+
+    # 同意复选框(如出现)
+    try:
+        page.evaluate(
+            r"""() => {
+                const cbs = document.querySelectorAll('input[name="allCheckboxes"][type="checkbox"], input[type="checkbox"]');
+                let n = 0;
+                cbs.forEach((cb) => {
+                    const lbl = cb.closest('label');
+                    if (cb.checked) return;
+                    const txt = (lbl?.textContent || cb.getAttribute('aria-label') || '').replace(/\s+/g, ' ');
+                    if (/agree|同意|i\s+agree/i.test(txt) || cb.name === 'allCheckboxes') {
+                        try { (lbl || cb).click(); n++; } catch (_) {}
+                    }
+                });
+                return n;
+            }"""
+        )
+    except Exception as exc:
+        log(f"[signup] 勾选同意复选框异常: {exc!r}")
+
+    page.wait_for_timeout(600)
+
+    # 提交"完成帐户创建"
+    submit = page.locator('button[type="submit"]').first
+    if submit.count() == 0:
+        log("[signup] 未找到 button[type=submit],尝试文本兜底")
+        for txt in ("完成", "Create account", "Continue", "Finish", "Done", "Agree"):
+            cand = page.locator(f'button:has-text("{txt}")').first
+            if cand.count() > 0:
+                submit = cand
+                break
+
+    if submit.count() == 0:
+        raise RuntimeError("profile 页未找到提交按钮")
+
+    try:
+        submit.scroll_into_view_if_needed(timeout=2000)
+    except Exception:
+        pass
+    try:
+        submit.click(timeout=5000)
+        log("[signup] 已点击完成帐户创建按钮")
+    except Exception as exc:
+        log(f"[signup] click submit 失败: {exc!r},回退 force click")
+        submit.click(timeout=5000, force=True)
+
+    # 等待页面真正离开 profile 页
+    deadline = time.time() + 25
+    last_url = page.url or ""
+    while time.time() < deadline:
+        try:
+            cur = page.url or ""
+            still = page.locator('input[name="name"]').count() > 0
+            if cur != last_url:
+                log(f"[signup] profile 页跳转 {last_url} -> {cur}")
+                last_url = cur
+            if not still and "chatgpt.com" in cur:
+                log(f"[signup] profile 页已离开,进入 {cur}")
+                return
+            if "chatgpt.com" in cur and "/auth" not in cur:
+                # 已经回到 chatgpt.com 主域
+                log(f"[signup] 已回到 chatgpt.com: {cur}")
+                return
+        except Exception:
+            pass
+        time.sleep(0.5)
+    log(f"[signup] 警告:profile 提交后 25s 未确认离开,url={page.url}")
+
+
+def _fetch_session(page, log) -> dict:
+    """读取 chatgpt.com/api/auth/session。"""
+    log(f"[signup] === 拉取 session: {SESSION_URL} ===")
+    # 确保 cookies 已写入;先回到 chatgpt.com 主域
+    if "chatgpt.com" not in (page.url or ""):
+        try:
+            page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=45000)
+            page.wait_for_timeout(2000)
+        except Exception as exc:
+            log(f"[signup] 回 chatgpt.com 异常: {exc!r}")
+
+    deadline = time.time() + 60
+    last_text = ""
+    while time.time() < deadline:
+        try:
+            resp = page.evaluate(
+                """async () => {
+                    try {
+                        const r = await fetch('/api/auth/session', { credentials: 'include' });
+                        const text = await r.text();
+                        return { status: r.status, text };
+                    } catch (e) {
+                        return { status: 0, text: '', error: String(e) };
+                    }
+                }"""
+            )
+            text = (resp or {}).get("text") or ""
+            status = (resp or {}).get("status") or 0
+            if text and text != last_text:
+                last_text = text
+                log(f"[signup] session HTTP {status} 长度 {len(text)} 预览 {text[:200]}")
+            if status == 200 and text:
+                import json
+                try:
+                    data = json.loads(text)
+                except Exception:
+                    data = None
+                if isinstance(data, dict) and data.get("accessToken"):
+                    return data
+        except Exception as exc:
+            log(f"[signup] fetch session 异常: {exc!r}")
+        time.sleep(2)
+    raise TimeoutError("拉取 session 超时(未取到 accessToken)")
+
+
+def signup_chatgpt(
+    page,
+    *,
+    helper_url: str,
+    mail_domain: str = "edu.a4sky.com",
+    mail_poll_interval_sec: int = 4,
+    mail_poll_max_attempts: int = 60,
+    log: Callable[[str], None] = print,
+    on_stage: Callable[[str], None] | None = None,
+) -> dict:
+    """跑完整注册流,返回 { email, password, session }。"""
+    def stage(name: str):
+        log(f"[stage] {name}")
+        if on_stage:
+            try:
+                on_stage(name)
+            except Exception:
+                pass
+
+    email = build_a4sky_email(mail_domain)
+    password = _rand_password()
+    first, last = _rand_name()
+    year, month, day = _rand_birthday()
+    log(f"[signup] 生成 email={email} password={password} name={first} {last} bday={year}-{month:02d}-{day:02d}")
+
+    stage("打开 chatgpt.com")
+    page.goto(SIGNUP_ENTRY_URL, wait_until="domcontentloaded", timeout=60000)
+    page.wait_for_timeout(2000)
+
+    # 入口:可能在首页 / 已经在邮箱页 / 已经在密码页
+    inp, _ = _find_visible(page, EMAIL_INPUT_SELECTORS)
+    if not inp and not _is_password_page(page):
+        stage("点击注册入口")
+        _click_signup_entry(page, log)
+        page.wait_for_load_state("domcontentloaded", timeout=20000)
+        page.wait_for_timeout(1500)
+
+    stage("填写邮箱")
+    if not _is_password_page(page):
+        _fill_signup_email(page, email, log)
+
+    if _is_password_page(page):
+        stage("填写密码")
+        # 邮件助手仅看邮件接收时间,过滤起点用"现在"-30s 比较稳
+        code_started_ms = int(time.time() * 1000) - 30 * 1000
+        _fill_password(page, password, log)
+    else:
+        code_started_ms = int(time.time() * 1000) - 30 * 1000
+
+    # 等待进入验证码页
+    stage("等待验证码页")
+    if not _wait_until(lambda: _is_email_verification_page(page), 30):
+        log(f"[signup] 警告:未确认进入验证码页 URL={page.url},仍尝试拉验证码")
+
+    stage("轮询邮箱验证码")
+    code = poll_signup_code(
+        helper_url,
+        email,
+        started_at_ms=code_started_ms,
+        interval_sec=mail_poll_interval_sec,
+        max_attempts=mail_poll_max_attempts,
+        log=log,
+    )
+
+    stage("填入验证码")
+    _fill_verification_code(page, code, log)
+    page.wait_for_timeout(2500)
+
+    stage("填姓名/生日")
+    _fill_name_and_birthday(page, first, last, year, month, day, log)
+    page.wait_for_timeout(2500)
+
+    stage("回 chatgpt.com 拉 session")
+    session = _fetch_session(page, log)
+    log(f"[signup] 注册完成 email={email} accessToken={(session.get('accessToken') or '')[:24]}... planType={(session.get('account') or {}).get('planType')}")
+    return {"email": email, "password": password, "session": session}
+
+
+def fetch_current_session(page, log: Callable[[str], None] = print) -> dict:
+    return _fetch_session(page, log)

+ 70 - 0
config.py

@@ -0,0 +1,70 @@
+"""持久化网页配置:账号数 / 接码 URL / 邮件助手 / CPA 等。"""
+from __future__ import annotations
+
+import json
+import os
+import threading
+from dataclasses import asdict, dataclass, field, fields
+
+CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.local.json")
+_LOCK = threading.Lock()
+
+
+@dataclass
+class AppConfig:
+    # 注册控制
+    account_count: int = 1
+    headless: bool = False
+
+    # 邮件
+    mail_helper_url: str = "http://ali.ss5.xyz:17373"
+    mail_domain: str = "edu.a4sky.com"
+    mail_poll_interval_sec: int = 4
+    mail_poll_max_attempts: int = 60  # 4s * 60 = 4min
+
+    # PayPal / 接码
+    phone_e164: str = "+15822201173"
+    sms_api_url: str = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
+
+    # CPA
+    cpa_url: str = ""
+    cpa_management_key: str = ""
+
+    # 调试
+    use_promo: bool = True
+
+    @classmethod
+    def load(cls) -> "AppConfig":
+        if not os.path.exists(CONFIG_PATH):
+            return cls()
+        try:
+            with open(CONFIG_PATH, "r", encoding="utf-8") as f:
+                raw = json.load(f)
+        except Exception:
+            return cls()
+        valid = {f.name for f in fields(cls)}
+        cleaned = {k: v for k, v in (raw or {}).items() if k in valid}
+        return cls(**cleaned)
+
+    def save(self):
+        with _LOCK:
+            with open(CONFIG_PATH, "w", encoding="utf-8") as f:
+                json.dump(asdict(self), f, ensure_ascii=False, indent=2)
+
+    def update(self, patch: dict) -> "AppConfig":
+        valid = {f.name for f in fields(self)}
+        for k, v in (patch or {}).items():
+            if k not in valid:
+                continue
+            current = getattr(self, k)
+            if isinstance(current, bool):
+                setattr(self, k, bool(v) if not isinstance(v, str) else v.lower() in ("1", "true", "yes", "on"))
+            elif isinstance(current, int):
+                try:
+                    setattr(self, k, int(v))
+                except Exception:
+                    pass
+            else:
+                setattr(self, k, "" if v is None else str(v))
+        self.save()
+        return self

+ 398 - 0
cpa_uploader.py

@@ -0,0 +1,398 @@
+"""CPA 上传:校验 session.account.planType=='plus'、构造 codex auth JSON、POST 到 CPA。
+
+参考 chrome_extension/codex-oauth-automation-extension/background/cpa-api.js。
+"""
+from __future__ import annotations
+
+import base64
+import json
+import re
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from typing import Callable
+from urllib.parse import urlparse, quote
+
+
+def _normalize_str(value) -> str:
+    return str(value or "").strip()
+
+
+def _is_email(value: str) -> bool:
+    return bool(value and re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", value))
+
+
+def _first_non_empty(*values) -> str:
+    for v in values:
+        s = _normalize_str(v)
+        if s:
+            return s
+    return ""
+
+
+def _b64url_decode(segment: str) -> str:
+    s = _normalize_str(segment).replace("-", "+").replace("_", "/")
+    if not s:
+        return ""
+    pad = (-len(s)) % 4
+    s += "=" * pad
+    try:
+        return base64.b64decode(s).decode("utf-8", errors="replace")
+    except Exception:
+        return ""
+
+
+def _b64url_encode_json(value) -> str:
+    raw = json.dumps(value, separators=(",", ":")).encode("utf-8")
+    enc = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+    return enc
+
+
+def parse_jwt_payload(token: str) -> dict:
+    token = _normalize_str(token)
+    if not token:
+        return {}
+    parts = token.split(".")
+    if len(parts) < 2:
+        return {}
+    decoded = _b64url_decode(parts[1])
+    if not decoded:
+        return {}
+    try:
+        return json.loads(decoded)
+    except Exception:
+        return {}
+
+
+def _normalize_iso_timestamp(value) -> str:
+    if isinstance(value, datetime):
+        return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+    if isinstance(value, (int, float)):
+        ms = value if value > 1e11 else value * 1000
+        try:
+            return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+        except Exception:
+            return ""
+    if isinstance(value, str) and value.strip():
+        try:
+            dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
+            return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+        except Exception:
+            return ""
+    return ""
+
+
+def _ts_from_unix_seconds(value) -> str:
+    try:
+        n = float(value)
+    except Exception:
+        return ""
+    try:
+        return datetime.fromtimestamp(n, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+    except Exception:
+        return ""
+
+
+def _epoch_seconds_from(value) -> int:
+    if value in (None, ""):
+        return 0
+    try:
+        n = float(value)
+        return int(n / 1000) if n > 1e11 else int(n)
+    except Exception:
+        pass
+    iso = _normalize_iso_timestamp(value)
+    if not iso:
+        return 0
+    try:
+        return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp())
+    except Exception:
+        return 0
+
+
+def _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at) -> str:
+    if not _normalize_str(account_id):
+        return ""
+    now = int(time.time())
+    expires = _epoch_seconds_from(expires_at) or (now + 90 * 24 * 60 * 60)
+    auth_info = {"chatgpt_account_id": _normalize_str(account_id)}
+    if plan_type:
+        auth_info["chatgpt_plan_type"] = _normalize_str(plan_type)
+    if user_id:
+        auth_info["chatgpt_user_id"] = _normalize_str(user_id)
+        auth_info["user_id"] = _normalize_str(user_id)
+    payload = {
+        "iat": now,
+        "exp": expires,
+        "https://api.openai.com/auth": auth_info,
+    }
+    if email:
+        payload["email"] = _normalize_str(email)
+    header = _b64url_encode_json({"alg": "none", "typ": "JWT", "cpa_synthetic": True})
+    body = _b64url_encode_json(payload)
+    return f"{header}.{body}.synthetic"
+
+
+def _sanitize_segment(value: str, fallback: str = "chatgpt-session") -> str:
+    s = _normalize_str(value)
+    s = re.sub(r"[\\/:*?\"<>|]+", "-", s)
+    s = re.sub(r"\s+", "-", s)
+    s = re.sub(r"-+", "-", s).strip("-")
+    return s or fallback
+
+
+def _normalize_plan_for_filename(plan_type: str) -> str:
+    parts = re.split(r"[^a-zA-Z0-9]+", _normalize_str(plan_type))
+    return "-".join(p.lower() for p in parts if p)
+
+
+def build_cpa_filename(email: str, plan_type: str, account_id: str) -> str:
+    e = _sanitize_segment(email or "")
+    p = _normalize_plan_for_filename(plan_type or "")
+    a = _sanitize_segment(account_id or "")
+    if e and p:
+        return f"codex-{e}-{p}.json"
+    if e:
+        return f"codex-{e}.json"
+    if a and p:
+        return f"codex-{a}-{p}.json"
+    if a:
+        return f"codex-{a}.json"
+    return f"codex-{int(time.time()*1000)}.json"
+
+
+def get_session_plan_type(session: dict) -> str:
+    """读 session.account.planType(与扩展一致)。"""
+    if not isinstance(session, dict):
+        return ""
+    account = session.get("account") or {}
+    if not isinstance(account, dict):
+        return ""
+    return _first_non_empty(account.get("planType"), account.get("plan_type"))
+
+
+def is_plus_session(session: dict) -> bool:
+    plan = get_session_plan_type(session).lower()
+    return plan == "plus"
+
+
+def build_cpa_auth_payload(session: dict, *, email_hint: str = "") -> dict:
+    """从 ChatGPT /api/auth/session JSON 构造 CPA codex auth JSON。
+    返回 { authJson, accountId, email, fileName, hasRefreshToken }。
+    """
+    if not isinstance(session, dict):
+        raise RuntimeError("session 不是 JSON 对象")
+
+    access_token = _normalize_str(session.get("accessToken"))
+    if not access_token:
+        raise RuntimeError("session 中没有 accessToken")
+
+    input_id_token = _first_non_empty(session.get("idToken"), session.get("id_token"))
+    refresh_token = _first_non_empty(session.get("refreshToken"), session.get("refresh_token"))
+    session_token = _first_non_empty(session.get("sessionToken"), session.get("session_token"))
+
+    access_payload = parse_jwt_payload(access_token)
+    id_payload = parse_jwt_payload(input_id_token)
+    access_auth = access_payload.get("https://api.openai.com/auth") if isinstance(access_payload, dict) else {}
+    id_auth = id_payload.get("https://api.openai.com/auth") if isinstance(id_payload, dict) else {}
+    profile = access_payload.get("https://api.openai.com/profile") if isinstance(access_payload, dict) else {}
+    if not isinstance(access_auth, dict):
+        access_auth = {}
+    if not isinstance(id_auth, dict):
+        id_auth = {}
+    if not isinstance(profile, dict):
+        profile = {}
+
+    expires_at = _first_non_empty(
+        _ts_from_unix_seconds(access_payload.get("exp")) if isinstance(access_payload, dict) else "",
+        _normalize_iso_timestamp(session.get("expires")),
+        _normalize_iso_timestamp(session.get("expiresAt")),
+        _normalize_iso_timestamp(session.get("expired")),
+        _normalize_iso_timestamp(session.get("expires_at")),
+    )
+
+    user = session.get("user") if isinstance(session.get("user"), dict) else {}
+    account = session.get("account") if isinstance(session.get("account"), dict) else {}
+
+    def _email(v):
+        s = _normalize_str(v).lower()
+        return s if _is_email(s) else ""
+
+    email = _first_non_empty(
+        _email(user.get("email")),
+        _email(session.get("email")),
+        _email(email_hint),
+        _email(profile.get("email")) if profile else "",
+        _email(id_payload.get("email")) if isinstance(id_payload, dict) else "",
+        _email(access_payload.get("email")) if isinstance(access_payload, dict) else "",
+    )
+
+    account_id = _first_non_empty(
+        account.get("id") if account else "",
+        session.get("account_id"),
+        access_auth.get("chatgpt_account_id"),
+        id_auth.get("chatgpt_account_id"),
+    )
+
+    user_id = _first_non_empty(
+        user.get("id") if user else "",
+        session.get("user_id"),
+        access_auth.get("chatgpt_user_id"),
+        access_auth.get("user_id"),
+        id_auth.get("chatgpt_user_id"),
+        id_auth.get("user_id"),
+    )
+
+    plan_type = _first_non_empty(
+        account.get("planType") if account else "",
+        account.get("plan_type") if account else "",
+        session.get("planType"),
+        session.get("plan_type"),
+        access_auth.get("chatgpt_plan_type"),
+        id_auth.get("chatgpt_plan_type"),
+    )
+
+    exported_at = _normalize_iso_timestamp(datetime.now(timezone.utc)) or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+    synthetic_id_token = "" if input_id_token else _build_synthetic_id_token(email, account_id, plan_type, user_id, expires_at)
+    id_token = input_id_token or synthetic_id_token
+
+    auth_json_full = {
+        "type": "codex",
+        "account_id": account_id,
+        "chatgpt_account_id": account_id,
+        "email": email,
+        "name": _first_non_empty(email, "ChatGPT Account"),
+        "plan_type": plan_type,
+        "chatgpt_plan_type": plan_type,
+        "id_token": id_token,
+        "id_token_synthetic": True if synthetic_id_token else None,
+        "access_token": access_token,
+        "refresh_token": refresh_token or "",
+        "session_token": session_token,
+        "last_refresh": exported_at,
+        "expired": expires_at,
+        "disabled": True if session.get("disabled") is True else None,
+    }
+    auth_json = {k: v for k, v in auth_json_full.items() if v not in (None, "")}
+
+    return {
+        "authJson": auth_json,
+        "accountId": account_id,
+        "email": email,
+        "expiresAt": expires_at,
+        "fileName": build_cpa_filename(email, plan_type, account_id),
+        "hasRefreshToken": bool(refresh_token),
+        "planType": plan_type,
+    }
+
+
+def _http_post_json(url: str, *, headers: dict, body: dict, timeout: int = 60) -> tuple[int, str, dict]:
+    """POST JSON。优先 curl_cffi 走 chrome 指纹(绕 Cloudflare 等基于 UA 的拦截),失败回退 urllib。"""
+    payload_bytes = json.dumps(body).encode("utf-8")
+
+    try:
+        from curl_cffi import requests as curl_requests
+    except Exception:
+        curl_requests = None
+
+    if curl_requests is not None:
+        try:
+            r = curl_requests.post(
+                url,
+                data=payload_bytes,
+                headers=headers,
+                impersonate="chrome136",
+                timeout=timeout,
+            )
+            text = r.text
+            status = r.status_code
+            try:
+                parsed = r.json() if text else {}
+            except Exception:
+                parsed = {}
+            return status, text, parsed
+        except Exception:
+            # 回退 urllib
+            pass
+
+    req = urllib.request.Request(url, data=payload_bytes, method="POST")
+    for k, v in headers.items():
+        req.add_header(k, v)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            text = resp.read().decode("utf-8", errors="replace")
+            status = resp.status
+    except urllib.error.HTTPError as exc:
+        try:
+            text = exc.read().decode("utf-8", errors="replace")
+        except Exception:
+            text = ""
+        status = exc.code
+    parsed = {}
+    try:
+        parsed = json.loads(text or "{}")
+    except Exception:
+        parsed = {}
+    return status, text, parsed
+
+
+def upload_session_to_cpa(
+    session: dict,
+    *,
+    cpa_url: str,
+    management_key: str,
+    email_hint: str = "",
+    timeout: int = 60,
+    log: Callable[[str], None] = print,
+) -> dict:
+    """把 ChatGPT session 通过 CPA 管理接口上传。
+
+    Returns: { fileName, email, planType, hasRefreshToken, status, response }
+    """
+    cpa_url = _normalize_str(cpa_url)
+    management_key = _normalize_str(management_key)
+    if not cpa_url:
+        raise RuntimeError("CPA 地址未配置")
+    if not management_key:
+        raise RuntimeError("CPA 管理密钥未配置")
+
+    parsed_url = urlparse(cpa_url)
+    if not parsed_url.scheme or not parsed_url.netloc:
+        raise RuntimeError(f"CPA 地址格式无效: {cpa_url}")
+    origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
+
+    payload = build_cpa_auth_payload(session, email_hint=email_hint)
+    log(f"[cpa] 构造 auth JSON 完成 file={payload['fileName']} email={payload['email']!r} plan={payload['planType']!r} hasRefreshToken={payload['hasRefreshToken']}")
+    if not payload["hasRefreshToken"]:
+        log("[cpa] 警告:缺少 refresh_token,access_token 过期后无法续期")
+
+    name_q = quote(payload["fileName"], safe="")
+    url = f"{origin}/v0/management/auth-files?name={name_q}"
+    headers = {
+        "Accept": "application/json",
+        "Content-Type": "application/json",
+        "Authorization": f"Bearer {management_key}",
+        "X-Management-Key": management_key,
+    }
+    log(f"[cpa] POST {url}")
+    status, text, resp = _http_post_json(url, headers=headers, body=payload["authJson"], timeout=timeout)
+    log(f"[cpa] HTTP {status} 返回长度 {len(text)} 预览={text[:300]}")
+
+    if status >= 400:
+        msg = ""
+        if isinstance(resp, dict):
+            for k in ("error", "message", "detail", "reason"):
+                if resp.get(k):
+                    msg = str(resp[k])
+                    break
+        raise RuntimeError(f"CPA 上传失败 HTTP {status}: {msg or text[:300]}")
+
+    return {
+        "fileName": payload["fileName"],
+        "email": payload["email"],
+        "planType": payload["planType"],
+        "hasRefreshToken": payload["hasRefreshToken"],
+        "status": status,
+        "response": resp,
+    }

+ 120 - 0
mail_provider.py

@@ -0,0 +1,120 @@
+"""邮件 provider:a4sky 邮箱生成 + 通过 hotmail-helper 协议拉验证码。
+
+helper 协议(Chrome 扩展原版):
+  POST {helper_url}/imap-code
+  body: {
+    targetEmail, mailbox: "INBOX", top: 60,
+    senderFilters, subjectFilters, excludeCodes, filterAfterTimestamp
+  }
+  返回: { ok, code, message: { id, mailbox, subject, receivedTimestamp, ... }, usedTimeFallback, transport }
+helper 自己读 IMAP 配置(data/a4sky-imap.local.json),客户端不传账号密码。
+"""
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime
+from typing import Callable
+
+DEFAULT_SENDER_FILTERS = ["openai", "noreply", "verify", "auth", "duckduckgo", "forward", "chatgpt"]
+DEFAULT_SUBJECT_FILTERS = ["verify", "verification", "code", "验证码", "confirm"]
+
+
+def build_a4sky_email(domain: str = "edu.a4sky.com") -> str:
+    """格式:n<YYYYMMDDHHMMSS>@<domain>,与 Chrome 扩展一致。"""
+    ts = datetime.now().strftime("%Y%m%d%H%M%S")
+    return f"n{ts}@{domain}"
+
+
+def _post_json(url: str, body: dict, timeout: int = 30) -> dict:
+    data = json.dumps(body).encode("utf-8")
+    req = urllib.request.Request(url, data=data, method="POST")
+    req.add_header("Content-Type", "application/json")
+    req.add_header("Accept", "application/json")
+    with urllib.request.urlopen(req, timeout=timeout) as resp:
+        text = resp.read().decode("utf-8", errors="replace")
+    if not text:
+        return {}
+    return json.loads(text)
+
+
+def request_imap_code(
+    helper_url: str,
+    target_email: str,
+    *,
+    filter_after_ts_ms: int = 0,
+    exclude_codes: list[str] | None = None,
+    sender_filters: list[str] | None = None,
+    subject_filters: list[str] | None = None,
+    top: int = 60,
+    timeout: int = 30,
+) -> dict:
+    """单次请求 helper /imap-code。返回原始响应。"""
+    base = (helper_url or "").rstrip("/")
+    if not base:
+        raise RuntimeError("未配置邮件助手地址")
+    url = f"{base}/imap-code"
+    payload = {
+        "targetEmail": (target_email or "").strip().lower(),
+        "mailbox": "INBOX",
+        "top": top,
+        "senderFilters": sender_filters or DEFAULT_SENDER_FILTERS,
+        "subjectFilters": subject_filters or DEFAULT_SUBJECT_FILTERS,
+        "excludeCodes": exclude_codes or [],
+        "filterAfterTimestamp": int(filter_after_ts_ms or 0),
+    }
+    return _post_json(url, payload, timeout=timeout)
+
+
+def poll_signup_code(
+    helper_url: str,
+    target_email: str,
+    *,
+    started_at_ms: int,
+    interval_sec: int = 4,
+    max_attempts: int = 60,
+    exclude_codes: list[str] | None = None,
+    log: Callable[[str], None] = print,
+) -> str:
+    """轮询邮箱直到拿到验证码。返回 6 位 code 字符串。"""
+    if not target_email:
+        raise RuntimeError("未指定目标邮箱")
+
+    log(f"[mail] 开始轮询验证码 helper={helper_url} email={target_email} interval={interval_sec}s max_attempts={max_attempts}")
+    last_err = None
+    for attempt in range(1, max_attempts + 1):
+        try:
+            resp = request_imap_code(
+                helper_url,
+                target_email,
+                filter_after_ts_ms=started_at_ms,
+                exclude_codes=exclude_codes,
+            )
+        except urllib.error.HTTPError as exc:
+            try:
+                body = exc.read().decode("utf-8", errors="replace")
+            except Exception:
+                body = ""
+            last_err = f"HTTP {exc.code} {body[:200]}"
+            log(f"[mail] 第{attempt}次轮询请求 HTTPError: {last_err}")
+        except Exception as exc:
+            last_err = repr(exc)
+            log(f"[mail] 第{attempt}次轮询异常: {last_err}")
+        else:
+            ok = bool(resp.get("ok"))
+            code = str(resp.get("code") or "").strip()
+            msg = resp.get("message") or {}
+            subj = (msg or {}).get("subject", "")
+            ts = (msg or {}).get("receivedTimestamp", 0)
+            transport = resp.get("transport", "")
+            if ok and code:
+                log(f"[mail] 第{attempt}次轮询命中 code={code} subject={subj!r} ts={ts} transport={transport}")
+                return code
+            log(f"[mail] 第{attempt}次轮询未命中 ok={ok} code={code!r} usedTimeFallback={resp.get('usedTimeFallback')} subject={subj!r}")
+
+        if attempt < max_attempts:
+            time.sleep(interval_sec)
+
+    raise TimeoutError(f"轮询验证码超时({max_attempts} 次),最后错误:{last_err}")

+ 494 - 0
paypal_flow.py

@@ -0,0 +1,494 @@
+"""PayPal 流程:从 Stripe 跳转 PayPal 后,识别落地页类型并强制走"创建账号"路径。
+
+落地页可能是:
+  A. /checkoutweb/  —— 直接是 Guest 注册+付款合表(最理想)
+  B. 登录页 with #email 单输入框,下方有"Create Account / Sign Up / 注册"链接
+  C. 登录页 with #email + #password 双输入框,下方有"Create Account"按钮(你描述的死锁场景:之前点上方 Next 被当成登录)
+
+对策:
+  - 不在 B/C 上点上方 Next;先尝试点"创建账号"链接/按钮;
+  - 点击后等待跳到 /checkoutweb/ 再走 Guest 表单;
+  - 如果点不动,再降级填邮箱点 Next(保留旧行为兜底)。
+"""
+from __future__ import annotations
+
+import re
+import time
+from typing import Callable
+
+
+CREATE_ACCOUNT_PATTERNS = [
+    r"create\s+(?:an?\s+)?account",
+    r"sign\s*up",
+    r"open\s+(?:an?\s+)?account",
+    r"pay\s+with\s+(?:debit|credit)\s+card",
+    r"pay\s+with\s+card",
+    r"continue\s+as\s+guest",
+    r"guest\s+checkout",
+    r"创建(?:新)?账[户号]",
+    r"注册(?:新)?账[户号]",
+    r"新建账[户号]",
+    r"以游客身份继续",
+]
+CREATE_ACCOUNT_RE = re.compile("|".join(CREATE_ACCOUNT_PATTERNS), re.I)
+
+LOGIN_TEXT_PATTERNS = [r"\blog\s*in\b", r"\bsign\s*in\b", r"登录"]
+LOGIN_RE = re.compile("|".join(LOGIN_TEXT_PATTERNS), re.I)
+
+
+def _now_ms() -> int:
+    return int(time.time() * 1000)
+
+
+def detect_landing_state(page, log: Callable[[str], None]) -> dict:
+    """返回 { kind: 'checkoutweb'|'login_email_only'|'login_email_password'|'unknown', email_count, password_count, url }"""
+    url = page.url or ""
+    if "/checkoutweb/" in url:
+        log(f"[paypal:detect] 已在 /checkoutweb/ url={url}")
+        return {"kind": "checkoutweb", "email_count": 0, "password_count": 0, "url": url}
+
+    try:
+        info = page.evaluate(
+            r"""() => {
+                const visible = (el) => {
+                    if (!el) return false;
+                    const s = window.getComputedStyle(el);
+                    if (s.display === 'none' || s.visibility === 'hidden') return false;
+                    const r = el.getBoundingClientRect();
+                    return r.width > 0 && r.height > 0;
+                };
+                const emails = Array.from(document.querySelectorAll(
+                    'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
+                )).filter(visible);
+                const passwords = Array.from(document.querySelectorAll(
+                    'input#password, input[name="password"], input[type="password"], input[autocomplete="current-password"]'
+                )).filter(visible);
+                return {
+                    emailCount: emails.length,
+                    passwordCount: passwords.length,
+                    bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 600),
+                };
+            }"""
+        ) or {}
+    except Exception as exc:
+        log(f"[paypal:detect] page.evaluate 异常: {exc!r}")
+        info = {}
+
+    email_count = int(info.get("emailCount") or 0)
+    password_count = int(info.get("passwordCount") or 0)
+    body_preview = info.get("bodyText") or ""
+    log(f"[paypal:detect] url={url} email_count={email_count} password_count={password_count} body_preview={body_preview[:160]!r}")
+
+    if password_count >= 1:
+        return {"kind": "login_email_password", "email_count": email_count, "password_count": password_count, "url": url}
+    if email_count >= 1:
+        return {"kind": "login_email_only", "email_count": email_count, "password_count": password_count, "url": url}
+    return {"kind": "unknown", "email_count": email_count, "password_count": password_count, "url": url}
+
+
+def find_create_account_action(page, log: Callable[[str], None]):
+    """优先找精确 selector,再做文本扫描。返回 Locator 或 None。"""
+    for sel in (
+        'a[data-testid="signUp"]',
+        'button[data-testid="signUp"]',
+        'a[data-testid="signup"]',
+        'button[data-testid="signup"]',
+        'a[data-testid="guest-checkout"]',
+        'button[data-testid="guest-checkout"]',
+        'button[data-testid="signup-button"]',
+        'a[href*="signup" i]',
+        'a[href*="create" i]',
+    ):
+        loc = page.locator(sel)
+        if loc.count() == 0:
+            continue
+        try:
+            first = loc.first
+            if first.is_visible() and first.is_enabled():
+                log(f"[paypal:create] 命中 selector={sel}")
+                return first
+        except Exception:
+            continue
+
+    # 文本扫描:只挑 a / button / role=button / role=link
+    try:
+        candidates = page.locator('a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]')
+        n = candidates.count()
+    except Exception as exc:
+        log(f"[paypal:create] 扫描候选失败: {exc!r}")
+        return None
+
+    matched = []
+    for i in range(min(n, 400)):
+        el = candidates.nth(i)
+        try:
+            if not el.is_visible():
+                continue
+        except Exception:
+            continue
+        try:
+            txt = (el.inner_text(timeout=400) or "").strip()
+        except Exception:
+            txt = ""
+        if not txt:
+            try:
+                txt = (el.get_attribute("aria-label") or "").strip()
+            except Exception:
+                txt = ""
+        if not txt:
+            continue
+        if CREATE_ACCOUNT_RE.search(txt) and not LOGIN_RE.search(txt):
+            try:
+                rect = el.bounding_box()
+            except Exception:
+                rect = None
+            matched.append((i, txt, rect))
+
+    if not matched:
+        log('[paypal:create] 未找到任何 "创建账号/Sign Up/Pay with Card" 候选')
+        return None
+
+    # 选 y 坐标最大的(页面下方更可能是"创建账号")
+    matched.sort(key=lambda item: ((item[2] or {}).get("y", 0)), reverse=True)
+    chosen_idx, chosen_txt, chosen_rect = matched[0]
+    log(f"[paypal:create] 选中候选 idx={chosen_idx} text={chosen_txt!r} rect={chosen_rect}")
+    return candidates.nth(chosen_idx)
+
+
+def click_create_account(page, log: Callable[[str], None]) -> bool:
+    el = find_create_account_action(page, log)
+    if not el:
+        return False
+    try:
+        el.scroll_into_view_if_needed(timeout=2000)
+    except Exception:
+        pass
+    try:
+        el.click(timeout=4000)
+        log("[paypal:create] 已点击创建账号")
+        return True
+    except Exception as exc:
+        log(f"[paypal:create] click 失败: {exc!r},尝试 force click")
+    try:
+        el.click(timeout=4000, force=True)
+        log("[paypal:create] force click 成功")
+        return True
+    except Exception as exc:
+        log(f"[paypal:create] force click 也失败: {exc!r}")
+    try:
+        el.evaluate("el => el.click()")
+        log("[paypal:create] JS .click() 成功")
+        return True
+    except Exception as exc:
+        log(f"[paypal:create] JS click 也失败: {exc!r}")
+    return False
+
+
+def wait_for_checkoutweb(page, log: Callable[[str], None], timeout_sec: int = 25) -> bool:
+    deadline = time.time() + timeout_sec
+    while time.time() < deadline:
+        url = page.url or ""
+        if "/checkoutweb/" in url:
+            log(f"[paypal:create] 已进入 /checkoutweb/ url={url}")
+            return True
+        time.sleep(0.4)
+    log(f"[paypal:create] 等待 /checkoutweb/ 超时 url={page.url}")
+    return False
+
+
+def find_login_next_button(page, log: Callable[[str], None]):
+    """优先精确 selector,再 fallback 文本扫描。返回 Locator 或 None。"""
+    for sel in (
+        'button[data-testid="submit-button"]',
+        'button[type="submit"]',
+        'button[id*="btnNext" i]',
+        'button#btnNext',
+    ):
+        loc = page.locator(sel)
+        if loc.count() == 0:
+            continue
+        try:
+            first = loc.first
+            if first.is_visible() and first.is_enabled():
+                log(f"[paypal:login] Next 候选命中 selector={sel}")
+                return first
+        except Exception:
+            continue
+
+    # 文本扫描:找 Next/Log In/继续 等位于 PayPal 顶部表单的按钮
+    try:
+        candidates = page.locator('button, input[type="submit"], [role="button"]')
+        n = candidates.count()
+    except Exception as exc:
+        log(f"[paypal:login] 扫描候选失败: {exc!r}")
+        return None
+
+    LOGIN_NEXT_RE = re.compile(r"\bnext\b|\blog\s*in\b|登录|登入|继续|下一步", re.I)
+    for i in range(min(n, 200)):
+        el = candidates.nth(i)
+        try:
+            if not el.is_visible() or not el.is_enabled():
+                continue
+            txt = (el.inner_text(timeout=400) or "").strip()
+            aria = el.get_attribute("aria-label") or ""
+            blob = f"{txt} {aria}"
+        except Exception:
+            continue
+        if LOGIN_NEXT_RE.search(blob) and not CREATE_ACCOUNT_RE.search(blob):
+            log(f"[paypal:login] Next 文本候选 idx={i} text={txt!r}")
+            return el
+    return None
+
+
+def click_login_next(page, log: Callable[[str], None]) -> bool:
+    el = find_login_next_button(page, log)
+    if not el:
+        return False
+    try:
+        el.scroll_into_view_if_needed(timeout=2000)
+    except Exception:
+        pass
+    for action in ("click", "force_click", "js_click"):
+        try:
+            if action == "click":
+                el.click(timeout=4000)
+            elif action == "force_click":
+                el.click(timeout=4000, force=True)
+            else:
+                el.evaluate("el => el.click()")
+            log(f"[paypal:login] {action} 成功")
+            return True
+        except Exception as exc:
+            log(f"[paypal:login] {action} 失败: {exc!r}")
+            continue
+    return False
+
+
+def find_continue_to_payment_button(page, log: Callable[[str], None]):
+    """Create Account 后的中间页:输入邮箱 + "Continue to Payment" 按钮。"""
+    # 精确 selector
+    for sel in (
+        'button[data-testid="continueToPayment"]',
+        'button[data-testid="continue-to-payment"]',
+        'button[name="continueToPayment"]',
+        'button[id*="continueToPayment" i]',
+        'button[type="submit"]',
+    ):
+        loc = page.locator(sel)
+        if loc.count() == 0:
+            continue
+        try:
+            first = loc.first
+            if first.is_visible() and first.is_enabled():
+                # 文本必须像 Continue to Payment / 继续 / Next / Pay
+                txt = (first.inner_text(timeout=400) or "").strip()
+                if not txt or re.search(r"continue|payment|继续|前往|pay\b", txt, re.I):
+                    log(f"[paypal:c2p] 命中 selector={sel} text={txt!r}")
+                    return first
+        except Exception:
+            continue
+
+    # 文本扫描
+    try:
+        candidates = page.locator('button, input[type="submit"], [role="button"]')
+        n = candidates.count()
+    except Exception as exc:
+        log(f"[paypal:c2p] 扫描失败: {exc!r}")
+        return None
+
+    pat = re.compile(r"continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)", re.I)
+    for i in range(min(n, 200)):
+        el = candidates.nth(i)
+        try:
+            if not el.is_visible() or not el.is_enabled():
+                continue
+            txt = (el.inner_text(timeout=400) or "").strip()
+            aria = el.get_attribute("aria-label") or ""
+            blob = f"{txt} {aria}"
+        except Exception:
+            continue
+        if pat.search(blob):
+            log(f"[paypal:c2p] 文本候选 idx={i} text={txt!r}")
+            return el
+    return None
+
+
+def click_continue_to_payment(page, log: Callable[[str], None]) -> bool:
+    el = find_continue_to_payment_button(page, log)
+    if not el:
+        return False
+    try:
+        el.scroll_into_view_if_needed(timeout=2000)
+    except Exception:
+        pass
+    for action in ("click", "force_click", "js_click"):
+        try:
+            if action == "click":
+                el.click(timeout=4000)
+            elif action == "force_click":
+                el.click(timeout=4000, force=True)
+            else:
+                el.evaluate("el => el.click()")
+            log(f"[paypal:c2p] {action} 成功")
+            return True
+        except Exception as exc:
+            log(f"[paypal:c2p] {action} 失败: {exc!r}")
+            continue
+    return False
+
+
+def handle_create_account_intermediate(
+    page, *, fallback_email: str, log: Callable[[str], None]
+) -> bool:
+    """Create Account 之后的中间页(邮箱 + Continue to Payment)。
+    如果识别到这个页,填邮箱并点 Continue to Payment,等到进入 /checkoutweb/。
+    """
+    page.wait_for_timeout(1500)
+    deadline = time.time() + 12
+    while time.time() < deadline:
+        try:
+            info = page.evaluate(
+                r"""() => {
+                    const visible = (el) => {
+                        if (!el) return false;
+                        const s = window.getComputedStyle(el);
+                        if (s.display === 'none' || s.visibility === 'hidden') return false;
+                        const r = el.getBoundingClientRect();
+                        return r.width > 0 && r.height > 0;
+                    };
+                    const emails = Array.from(document.querySelectorAll(
+                        'input#email, input[name="email"], input[type="email"]'
+                    )).filter(visible);
+                    const passwords = Array.from(document.querySelectorAll(
+                        'input[type="password"]'
+                    )).filter(visible);
+                    const c2p = Array.from(document.querySelectorAll(
+                        'button, input[type="submit"], [role="button"]'
+                    )).filter(visible).some((el) => {
+                        const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
+                        return /continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)/i.test(t);
+                    });
+                    return {
+                        url: location.href,
+                        emailCount: emails.length,
+                        passwordCount: passwords.length,
+                        hasContinueToPayment: c2p,
+                    };
+                }"""
+            ) or {}
+        except Exception as exc:
+            log(f"[paypal:c2p] 探测异常: {exc!r}")
+            info = {}
+
+        url = info.get("url") or page.url or ""
+        if "/checkoutweb/" in url:
+            log(f"[paypal:c2p] 已进入 /checkoutweb/ url={url}")
+            return True
+
+        # 中间页特征:有 email 框 + Continue to Payment 按钮(且没有 password 框)
+        if info.get("emailCount", 0) >= 1 and info.get("hasContinueToPayment") and info.get("passwordCount", 0) == 0:
+            log(f'[paypal:c2p] 命中 "输入邮箱 + Continue to Payment" 中间页 url={url}')
+            try:
+                page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email)
+                log(f"[paypal:c2p] 填邮箱 {fallback_email}")
+            except Exception as exc:
+                log(f"[paypal:c2p] 填邮箱失败: {exc!r}")
+            page.wait_for_timeout(400)
+            click_continue_to_payment(page, log)
+            # 等待进 /checkoutweb/
+            inner_deadline = time.time() + 15
+            while time.time() < inner_deadline:
+                if "/checkoutweb/" in (page.url or ""):
+                    log(f"[paypal:c2p] Continue to Payment 后已进 /checkoutweb/ url={page.url}")
+                    return True
+                page.wait_for_timeout(400)
+            log(f"[paypal:c2p] 点了 Continue to Payment 但未进 /checkoutweb/ url={page.url}")
+            break
+
+        time.sleep(0.5)
+    return False
+
+
+def ensure_checkoutweb(
+    page,
+    *,
+    fallback_email: str,
+    log: Callable[[str], None],
+    on_stage: Callable[[str], None] | None = None,
+) -> str:
+    """到 /checkoutweb/ 表单页。返回 'login' / 'create' / 'create_c2p' / 'already' / 'unknown'。
+
+    策略:
+      1. 已在 /checkoutweb/ → done
+      2. email-only 登录页 → 先填 email + 点 Login Next
+         - 进 /checkoutweb/ → 'login' 成功
+         - 仍卡 / 冒出 password 框 → 走 Create Account
+      3. 双输入框(已要求密码)→ 直接 Create Account
+      4. Create Account 后若进入"邮箱 + Continue to Payment"中间页,再走一次 → 'create_c2p'
+    """
+    def stage(name: str):
+        log(f"[stage:paypal] {name}")
+        if on_stage:
+            try:
+                on_stage(name)
+            except Exception:
+                pass
+
+    page.wait_for_load_state("domcontentloaded", timeout=30000)
+    page.wait_for_timeout(1500)
+
+    state = detect_landing_state(page, log)
+    if state["kind"] == "checkoutweb":
+        stage("PayPal 已直达 /checkoutweb/")
+        return "already"
+
+    # 路径 A:email-only 登录页 → 先尝试登录 Next
+    if state["kind"] == "login_email_only":
+        stage("PayPal 登录页:先尝试 Login Next 路径")
+        try:
+            page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email)
+            log(f"[paypal:login] 填邮箱 {fallback_email}")
+        except Exception as exc:
+            log(f"[paypal:login] 填邮箱失败: {exc!r}")
+
+        if click_login_next(page, log):
+            deadline = time.time() + 12
+            while time.time() < deadline:
+                page.wait_for_timeout(500)
+                cur = detect_landing_state(page, log)
+                if cur["kind"] == "checkoutweb":
+                    stage("通过 Login Next 进入 /checkoutweb/")
+                    return "login"
+                if cur["kind"] == "login_email_password":
+                    log("[paypal:login] 检测到 password 输入框,登录路径不可行,将切到 Create Account")
+                    break
+            else:
+                log("[paypal:login] 12s 内未进 /checkoutweb/,将切到 Create Account 兜底")
+        else:
+            log("[paypal:login] 没找到可点的 Login Next,将切到 Create Account")
+
+    state2 = detect_landing_state(page, log)
+    if state2["kind"] == "checkoutweb":
+        stage("Login Next 之后已进 /checkoutweb/")
+        return "login"
+
+    # 路径 B:Create Account(之后可能还有"邮箱 + Continue to Payment"中间页)
+    stage(f"PayPal 落地为 {state2['kind']},尝试点击 Create Account")
+    if click_create_account(page, log):
+        # 直接进 /checkoutweb/?
+        if wait_for_checkoutweb(page, log, timeout_sec=10):
+            stage("通过 Create Account 直接进入 /checkoutweb/")
+            return "create"
+        # 否则尝试处理 Continue to Payment 中间页
+        stage("Create Account 后未直接进 /checkoutweb/,处理 Continue to Payment 中间页")
+        if handle_create_account_intermediate(page, fallback_email=fallback_email, log=log):
+            stage("通过 Create Account → Continue to Payment 进入 /checkoutweb/")
+            return "create_c2p"
+        # 还有一种情况:URL 没改但表单已切到 Guest 模式
+        page.wait_for_timeout(1500)
+        state3 = detect_landing_state(page, log)
+        if state3["kind"] == "checkoutweb":
+            return "create"
+
+    log(f"[paypal] 路径都未进 /checkoutweb/ url={page.url}")
+    return "unknown"

+ 232 - 0
providers.py

@@ -0,0 +1,232 @@
+"""卡号、地址、短信三个外部数据源。"""
+from __future__ import annotations
+
+import json
+import random
+import re
+import time
+import urllib.error
+import urllib.request
+
+
+CARD_API = "https://api2.suijidaquan.com/api/v2/random-credit-card"
+ADDR_API = "https://www.meiguodizhi.com/api/v1/dz"
+SMS_API = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
+
+
+# VISA 起 4,16 位;Mastercard 起 51-55 或 2221-2720,16 位
+CARD_BIN_POOLS = {
+    "visa": [str(random.randint(4, 4)) + "".join(str(random.randint(0, 9)) for _ in range(5)) for _ in range(0)],
+}
+
+
+def _luhn_check_digit(number_without_check: str) -> str:
+    digits = [int(c) for c in number_without_check]
+    # 从右往左、每隔一位(即偶数索引位)×2
+    parity = (len(digits) + 1) % 2  # 让最后一位 parity=0 才需要×2
+    total = 0
+    for i, d in enumerate(digits):
+        if i % 2 == parity:
+            d *= 2
+            if d > 9:
+                d -= 9
+        total += d
+    return str((10 - total % 10) % 10)
+
+
+def _gen_visa_pan() -> str:
+    # 起 4,再补 14 位随机,最后 1 位 Luhn
+    body = "4" + "".join(str(random.randint(0, 9)) for _ in range(14))
+    return body + _luhn_check_digit(body)
+
+
+def _gen_mastercard_pan() -> str:
+    # 起 51-55,再补 13 位随机,最后 1 位 Luhn
+    prefix = str(random.randint(51, 55))
+    body = prefix + "".join(str(random.randint(0, 9)) for _ in range(13))
+    return body + _luhn_check_digit(body)
+
+
+def generate_local_card(brand: str = "visa") -> dict:
+    """本地随机生成一张 Luhn 合规的 VISA / Mastercard 测试卡。
+    expiry 取未来 1-4 年的随机月份,CVV 三位随机。
+    """
+    brand = (brand or "visa").lower()
+    if brand == "mastercard":
+        pan = _gen_mastercard_pan()
+    else:
+        pan = _gen_visa_pan()
+        brand = "visa"
+    now = time.localtime()
+    exp_year = (now.tm_year + random.randint(1, 4)) % 100
+    exp_month = random.randint(1, 12)
+    expiry = f"{exp_month:02d} / {exp_year:02d}"
+    cvv = "".join(str(random.randint(0, 9)) for _ in range(3))
+    return {
+        "number": pan,
+        "expiry": expiry,
+        "cvv": cvv,
+        "brand": brand,
+    }
+
+
+DEFAULT_BROWSER_HEADERS = {
+    "Accept": "application/json, text/plain, */*",
+    "Accept-Language": "zh-CN,zh;q=0.9",
+    "Cache-Control": "no-cache",
+    "Pragma": "no-cache",
+    "DNT": "1",
+    "Priority": "u=1, i",
+    "Sec-Ch-Ua": '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"',
+    "Sec-Ch-Ua-Mobile": "?0",
+    "Sec-Ch-Ua-Platform": '"macOS"',
+    "Sec-Fetch-Dest": "empty",
+    "Sec-Fetch-Mode": "cors",
+    "Sec-Fetch-Site": "same-site",
+    "User-Agent": (
+        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+        "AppleWebKit/537.36 (KHTML, like Gecko) "
+        "Chrome/148.0.0.0 Safari/537.36"
+    ),
+}
+
+
+def _post_json(url: str, body: dict, headers: dict | None = None, timeout: int = 15, log=print) -> tuple[int, str, dict]:
+    data = json.dumps(body).encode("utf-8")
+    req = urllib.request.Request(url, data=data, method="POST")
+    merged = {**DEFAULT_BROWSER_HEADERS, "Content-Type": "application/json;charset=UTF-8"}
+    merged.update(headers or {})
+    for k, v in merged.items():
+        req.add_header(k, v)
+    started = time.time()
+    log(f"[http] POST {url} body={json.dumps(body, ensure_ascii=False)}")
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            status = resp.status
+            text = resp.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        text = exc.read().decode("utf-8", errors="replace")
+        log(f"[http] {url} HTTP {exc.code} 耗时{int((time.time()-started)*1000)}ms 返回={text[:300]}")
+        raise
+    log(f"[http] {url} HTTP {status} 耗时{int((time.time()-started)*1000)}ms 返回={text[:300]}")
+    return status, text, json.loads(text or "{}")
+
+
+def _normalize_expiry(expires: str) -> str:
+    m = re.match(r"^\s*(\d{1,2})\s*/\s*(\d{2,4})\s*$", expires or "")
+    if not m:
+        return expires
+    mm = m.group(1).zfill(2)
+    yy = m.group(2)
+    if len(yy) == 4:
+        yy = yy[2:]
+    return f"{mm} / {yy}"
+
+
+def fetch_visa_card(max_attempts: int = 8, log=print, *, prefer_local: bool = True) -> dict:
+    """获取一张可用的卡。默认本地随机生成(避免接口返回重复卡导致 PayPal CC_LINKED_TO_FULL_ACCOUNT),
+    若 prefer_local=False 则走旧的接口逻辑。
+    """
+    if prefer_local:
+        brand = random.choice(["visa", "mastercard"])
+        card = generate_local_card(brand=brand)
+        log(f"[card] 本地生成 {card['brand'].upper()} 卡 尾号 {card['number'][-4:]} 有效期 {card['expiry']} CVV {card['cvv']}")
+        return card
+
+    log(f"[card] 开始通过接口获取 VISA 卡,最多重试 {max_attempts} 次")
+    for attempt in range(1, max_attempts + 1):
+        log(f"[card] 第 {attempt}/{max_attempts} 次请求 {CARD_API}")
+        try:
+            _, _, data = _post_json(
+                CARD_API,
+                {"count": 4, "method": "random_credit_card"},
+                headers={
+                    "Origin": "https://www.suijidaquan.com",
+                    "Referer": "https://www.suijidaquan.com/",
+                },
+                log=log,
+            )
+        except (urllib.error.URLError, json.JSONDecodeError) as exc:
+            log(f"[card] 请求异常: {exc!r}")
+            time.sleep(0.6)
+            continue
+
+        cards = data.get("data") or []
+        types = [c.get("Credit_Card_Type") for c in cards]
+        log(f"[card] 本次返回 {len(cards)} 张卡,类型 = {types}")
+        for c in cards:
+            if (c.get("Credit_Card_Type") or "").lower() == "visa":
+                card = {
+                    "number": c["Credit_Card_Number"],
+                    "expiry": _normalize_expiry(c["Expires"]),
+                    "cvv": c["CVV2"],
+                }
+                log(f"[card] 命中 VISA 尾号 {card['number'][-4:]} 有效期 {card['expiry']} CVV {card['cvv']}")
+                return card
+        log("[card] 本次未拿到 VISA,准备重试")
+        time.sleep(0.4)
+
+    raise RuntimeError(f"已重试 {max_attempts} 次,仍未取到 VISA 卡")
+
+
+def fetch_us_address(log=print) -> dict:
+    log(f"[addr] 请求随机美国地址 {ADDR_API}")
+    try:
+        _, _, data = _post_json(ADDR_API, {"path": "/", "method": "address"}, log=log)
+        a = data.get("address") or data
+        addr = {
+            "street": a.get("Address") or a.get("street") or "123 Main St",
+            "city": a.get("City") or a.get("city") or "New York",
+            "state": a.get("State_Full") or a.get("State") or a.get("state") or "New York",
+            "zip": (a.get("Zip_Code") or a.get("zip") or "10001")[:5],
+        }
+    except Exception as exc:
+        log(f"[addr] 取地址失败,使用兜底: {exc!r}")
+        addr = {"street": "123 Main St", "city": "New York", "state": "New York", "zip": "10001"}
+    log(f"[addr] 解析结果: {addr}")
+    return addr
+
+
+def fetch_sms_code(timeout: int = 180, interval: int = 5, log=print) -> str:
+    log(f"[sms] 开始轮询验证码,超时 {timeout}s,间隔 {interval}s")
+    deadline = time.time() + timeout
+    last_text = ""
+    polls = 0
+    while time.time() < deadline:
+        polls += 1
+        try:
+            req = urllib.request.Request(SMS_API)
+            req.add_header("Accept", "*/*")
+            req.add_header("Accept-Language", "zh-CN,zh;q=0.9")
+            req.add_header(
+                "User-Agent",
+                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
+            )
+            with urllib.request.urlopen(req, timeout=10) as resp:
+                text = resp.read().decode("utf-8", errors="replace").strip()
+        except Exception as exc:
+            log(f"[sms] 第{polls}次轮询请求异常: {exc!r}")
+            time.sleep(interval)
+            continue
+
+        if text != last_text:
+            log(f"[sms] 第{polls}次轮询新返回: {text}")
+            last_text = text
+        else:
+            log(f"[sms] 第{polls}次轮询无变化")
+
+        parts = text.split("|")
+        status = parts[0].lower() if parts else ""
+        content = parts[1] if len(parts) > 1 else ""
+
+        if status == "yes":
+            m = re.search(r"\b(\d{4,8})\b", content)
+            if m:
+                code = m.group(1)
+                log(f"[sms] 命中验证码: {code}")
+                return code
+            log(f"[sms] status=yes 但未能从内容中匹配到数字: {content!r}")
+        time.sleep(interval)
+
+    raise TimeoutError(f"等待短信验证码超时 {timeout}s(共轮询 {polls} 次,最后返回={last_text!r})")

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+curl_cffi>=0.14.0
+playwright>=1.49.0

+ 23 - 0
run.sh

@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -e
+cd "$(dirname "$0")"
+
+PY="${PYTHON:-python3}"
+
+if [ ! -d .venv ]; then
+  echo "Creating .venv ..."
+  "$PY" -m venv .venv
+fi
+
+source .venv/bin/activate
+
+echo "Installing python deps ..."
+pip install -q --upgrade pip
+pip install -q -r requirements.txt
+
+if ! ls "$HOME/Library/Caches/ms-playwright"/chromium-* >/dev/null 2>&1; then
+  echo "Installing Playwright Chromium ..."
+  python -m playwright install chromium
+fi
+
+exec python server.py

+ 644 - 0
server.py

@@ -0,0 +1,644 @@
+"""本地 Web 控制台:网页配置 + 一键全自动注册→付款→上传 CPA。"""
+from __future__ import annotations
+
+import json
+import queue
+import threading
+import time
+from dataclasses import asdict
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+from chatgpt_flow import FullRunContext, run_full
+from config import AppConfig
+from cpa_uploader import build_cpa_auth_payload
+from storage import get_account, init_db, list_accounts, list_events
+
+
+HOST = "127.0.0.1"
+PORT = 7791
+
+
+class JobManager:
+    def __init__(self):
+        self.lock = threading.Lock()
+        self.full_ctx: FullRunContext | None = None
+        self.thread: threading.Thread | None = None
+        self.log_queue: queue.Queue[str] = queue.Queue()
+        self.history: list[str] = []
+        self.stage: str = ""
+
+    def _log(self, msg: str):
+        line = f"[{time.strftime('%H:%M:%S')}] {msg}"
+        self.history.append(line)
+        if len(self.history) > 4000:
+            self.history = self.history[-3000:]
+        self.log_queue.put(line)
+
+    def _on_stage(self, name: str):
+        self.stage = name
+        # stage 也写到日志,便于复盘
+        self._log(f"[STAGE] {name}")
+
+    def start(self, cfg: AppConfig) -> str:
+        with self.lock:
+            if self.thread and self.thread.is_alive():
+                return "已有任务在运行"
+            self.history.clear()
+            while not self.log_queue.empty():
+                self.log_queue.get_nowait()
+            self.stage = ""
+
+            def runner():
+                try:
+                    self.full_ctx = run_full(cfg, log=self._log, on_stage=self._on_stage)
+                except Exception as exc:
+                    import traceback
+                    self._log(f"[server] 任务异常: {exc!r}")
+                    self._log(traceback.format_exc())
+
+            self.thread = threading.Thread(target=runner, daemon=True)
+            self.thread.start()
+            return ""
+
+    def stop(self):
+        if self.full_ctx:
+            self.full_ctx.state = "stopped"
+            self._log("[user] 已请求停止")
+
+    def status(self) -> dict:
+        running = bool(self.thread and self.thread.is_alive())
+        ctx = self.full_ctx
+        accounts = []
+        state = "idle"
+        if ctx:
+            state = ctx.state
+            for a in ctx.accounts:
+                accounts.append({
+                    "email": a.get("email"),
+                    "stage": a.get("stage"),
+                    "planType": a.get("planType"),
+                    "error": a.get("error"),
+                    "cpaFile": (a.get("cpa") or {}).get("fileName") if a.get("cpa") else None,
+                })
+        return {
+            "running": running,
+            "state": state,
+            "stage": self.stage,
+            "accounts": accounts,
+        }
+
+
+JOB = JobManager()
+
+
+INDEX_HTML = r"""<!doctype html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>ChatGPT Plus 全自动注册</title>
+<style>
+  :root { color-scheme: light; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; }
+  body { margin:0; background:#f5f5f7; color:#111; }
+  .wrap { max-width: 980px; margin: 24px auto; padding: 0 16px; }
+  .card { background:#fff; border:1px solid #ddd; border-radius:18px; padding:22px; box-shadow:0 14px 40px rgba(0,0,0,.06); margin-bottom:18px; }
+  h1 { margin: 0 0 6px; font-size: 22px; }
+  h2 { margin: 0 0 10px; font-size: 16px; }
+  p, li { color:#666; line-height:1.6; }
+  label { display:block; margin:14px 0 6px; font-weight:600; }
+  input, select { width:100%; box-sizing:border-box; border:1px solid #ccc; border-radius:10px; padding:9px 10px; font:inherit; background:#fff; }
+  .grid { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+  .grid-3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
+  .row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:14px; }
+  button { border:0; border-radius:12px; background:#111; color:#fff; padding:10px 16px; font-weight:700; cursor:pointer; }
+  button.secondary { background:#e9e9ec; color:#111; }
+  button:disabled { opacity:.55; cursor:not-allowed; }
+  .muted { color:#777; font-size:13px; }
+  .chip { display:inline-block; padding:3px 10px; border-radius:999px; font-size:12px; background:#eef; color:#225; }
+  .chip.green { background:#e6f7ec; color:#0f5f22; }
+  .chip.red { background:#fde7e7; color:#a40000; }
+  .chip.gray { background:#eee; color:#444; }
+  .chip.blue { background:#e7f0ff; color:#1d4ed8; }
+  pre.log { height:340px; overflow:auto; background:#0b0b10; color:#d6d6dc; padding:12px; border-radius:12px; font-size:12px; line-height:1.5; white-space:pre-wrap; word-break:break-all; }
+  table { width:100%; border-collapse: collapse; font-size:13px; }
+  th, td { padding:8px 10px; border-bottom:1px solid #eee; text-align:left; vertical-align:top; }
+  th { background:#fafafa; font-weight:600; color:#333; }
+  .stage-box { padding:10px 14px; border-radius:12px; background:#fffaf0; border:1px solid #ffe2a8; color:#7a4f00; font-size:13px; min-height: 22px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+
+  <div class="card">
+    <h1>ChatGPT Plus 全自动注册 + CPA 上传</h1>
+    <div class="muted">流程:a4sky 邮箱注册 → 拿 Plus 长链 → PayPal 创建账号付款 → 校验 plan=plus → 上传 CPA。手机号统一 +15822201173。</div>
+  </div>
+
+  <div class="card">
+    <h2>配置</h2>
+
+    <div class="grid">
+      <div>
+        <label>账号数量</label>
+        <input id="cfg_account_count" type="number" min="1" value="1" />
+      </div>
+      <div>
+        <label>浏览器模式</label>
+        <select id="cfg_headless">
+          <option value="false" selected>有头(推荐,方便干预)</option>
+          <option value="true">无头</option>
+        </select>
+      </div>
+    </div>
+
+    <div class="grid">
+      <div>
+        <label>邮件助手 URL</label>
+        <input id="cfg_mail_helper_url" placeholder="http://ali.ss5.xyz:17373" />
+      </div>
+      <div>
+        <label>邮箱域名</label>
+        <input id="cfg_mail_domain" placeholder="edu.a4sky.com" />
+      </div>
+    </div>
+
+    <div class="grid-3">
+      <div>
+        <label>邮箱轮询间隔(秒)</label>
+        <input id="cfg_mail_poll_interval_sec" type="number" min="1" value="4" />
+      </div>
+      <div>
+        <label>邮箱轮询次数</label>
+        <input id="cfg_mail_poll_max_attempts" type="number" min="5" value="60" />
+      </div>
+      <div>
+        <label>使用 1 个月免费 promo</label>
+        <select id="cfg_use_promo">
+          <option value="true" selected>是</option>
+          <option value="false">否</option>
+        </select>
+      </div>
+    </div>
+
+    <div class="grid">
+      <div>
+        <label>PayPal 短信手机号 (E164)</label>
+        <input id="cfg_phone_e164" placeholder="+15822201173" />
+      </div>
+      <div>
+        <label>接码 API URL</label>
+        <input id="cfg_sms_api_url" placeholder="http://a.62-us.com/api/get_sms?key=..." />
+      </div>
+    </div>
+
+    <div class="grid">
+      <div>
+        <label>CPA 地址</label>
+        <input id="cfg_cpa_url" placeholder="http://your-cpa-host:port" />
+      </div>
+      <div>
+        <label>CPA 管理密钥</label>
+        <input id="cfg_cpa_management_key" placeholder="管理 token" />
+      </div>
+    </div>
+
+    <div class="row">
+      <button id="save">保存配置</button>
+      <button id="go">开始全自动</button>
+      <button id="stop" class="secondary" disabled>停止</button>
+      <span id="state" class="chip gray">空闲</span>
+    </div>
+  </div>
+
+  <div class="card">
+    <h2>当前阶段</h2>
+    <div id="stageBox" class="stage-box">空闲</div>
+  </div>
+
+  <div class="card">
+    <h2>账号进度</h2>
+    <table id="accTable">
+      <thead><tr><th>#</th><th>邮箱</th><th>阶段</th><th>planType</th><th>CPA 文件</th><th>错误</th></tr></thead>
+      <tbody></tbody>
+    </table>
+  </div>
+
+  <div class="card">
+    <h2>已注册账号库</h2>
+    <div class="row" style="margin-top:0">
+      <button id="refreshAccounts" class="secondary">刷新</button>
+      <select id="accFilter" style="max-width:200px">
+        <option value="">全部状态</option>
+        <option value="registered">已注册</option>
+        <option value="paid">已付款</option>
+        <option value="plus">已 Plus</option>
+        <option value="cpa_uploaded">已上传 CPA</option>
+        <option value="cpa_skipped">CPA 跳过</option>
+        <option value="failed">失败</option>
+        <option value="plus_check_failed">Plus 校验失败</option>
+        <option value="cpa_failed">CPA 上传失败</option>
+      </select>
+      <span class="muted">数据库:<code>data/accounts.db</code></span>
+    </div>
+    <table id="dbTable">
+      <thead><tr><th>邮箱</th><th>plan</th><th>状态</th><th>CPA 文件</th><th>注册时间</th><th>更新时间</th><th>错误</th><th>动作</th></tr></thead>
+      <tbody></tbody>
+    </table>
+    <details style="margin-top:10px">
+      <summary class="muted">点击查看选中账号详情</summary>
+      <pre id="accDetail" class="log" style="height:240px"></pre>
+    </details>
+  </div>
+
+  <div class="card">
+    <h2>实时日志</h2>
+    <pre id="log" class="log"></pre>
+  </div>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+
+const FIELDS = [
+  ['cfg_account_count','account_count','int'],
+  ['cfg_headless','headless','bool'],
+  ['cfg_mail_helper_url','mail_helper_url','str'],
+  ['cfg_mail_domain','mail_domain','str'],
+  ['cfg_mail_poll_interval_sec','mail_poll_interval_sec','int'],
+  ['cfg_mail_poll_max_attempts','mail_poll_max_attempts','int'],
+  ['cfg_use_promo','use_promo','bool'],
+  ['cfg_phone_e164','phone_e164','str'],
+  ['cfg_sms_api_url','sms_api_url','str'],
+  ['cfg_cpa_url','cpa_url','str'],
+  ['cfg_cpa_management_key','cpa_management_key','str'],
+];
+
+function fillForm(cfg) {
+  for (const [domId, key, kind] of FIELDS) {
+    const el = $(domId);
+    if (!el || cfg[key] === undefined) continue;
+    if (kind === 'bool') {
+      el.value = cfg[key] ? 'true' : 'false';
+    } else {
+      el.value = cfg[key];
+    }
+  }
+}
+
+function readForm() {
+  const out = {};
+  for (const [domId, key, kind] of FIELDS) {
+    const el = $(domId);
+    if (!el) continue;
+    let v = el.value;
+    if (kind === 'int') v = Number(v) || 0;
+    else if (kind === 'bool') v = (v === 'true' || v === true);
+    out[key] = v;
+  }
+  return out;
+}
+
+async function loadConfig() {
+  const r = await fetch('/api/config');
+  const data = await r.json();
+  fillForm(data);
+}
+
+async function saveConfig() {
+  const body = readForm();
+  const r = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
+  if (!r.ok) { alert('保存失败 HTTP ' + r.status); return; }
+  const data = await r.json();
+  fillForm(data);
+}
+
+function setState(text, cls) {
+  const el = $('state');
+  el.textContent = text;
+  el.className = 'chip ' + (cls || 'gray');
+}
+
+let evtSource = null;
+function startLogStream() {
+  if (evtSource) evtSource.close();
+  evtSource = new EventSource('/api/log');
+  evtSource.onmessage = e => {
+    if (!e.data) return;
+    const log = $('log');
+    log.textContent += e.data + '\n';
+    log.scrollTop = log.scrollHeight;
+  };
+}
+
+function renderAccounts(accounts) {
+  const tbody = $('accTable').querySelector('tbody');
+  tbody.innerHTML = '';
+  accounts.forEach((a, idx) => {
+    const tr = document.createElement('tr');
+    tr.innerHTML = `<td>${idx+1}</td><td>${a.email||''}</td><td>${a.stage||''}</td><td>${a.planType||''}</td><td>${a.cpaFile||''}</td><td style="color:#a40000">${a.error||''}</td>`;
+    tbody.appendChild(tr);
+  });
+}
+
+async function refreshStatus() {
+  try {
+    const r = await fetch('/api/status');
+    const data = await r.json();
+    $('stageBox').textContent = data.stage || '空闲';
+    renderAccounts(data.accounts || []);
+    if (data.running) {
+      setState('执行中', 'green');
+      $('go').disabled = true;
+      $('stop').disabled = false;
+    } else {
+      $('go').disabled = false;
+      $('stop').disabled = true;
+      if (data.state === 'done') setState('完成', 'green');
+      else if (data.state === 'error') setState('异常', 'red');
+      else if (data.state === 'stopped') setState('已停止', 'red');
+      else setState('空闲', 'gray');
+    }
+  } catch (_) {}
+}
+setInterval(refreshStatus, 1500);
+refreshStatus();
+startLogStream();
+loadConfig();
+loadAccounts();
+
+function fmtTime(ms) {
+  if (!ms) return '';
+  const d = new Date(Number(ms));
+  if (isNaN(d.getTime())) return '';
+  const pad = n => String(n).padStart(2,'0');
+  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+async function loadAccounts() {
+  const status = $('accFilter').value || '';
+  const url = status ? '/api/accounts?status=' + encodeURIComponent(status) : '/api/accounts';
+  try {
+    const r = await fetch(url);
+    const data = await r.json();
+    const tbody = $('dbTable').querySelector('tbody');
+    tbody.innerHTML = '';
+    (data.accounts || []).forEach(a => {
+      const tr = document.createElement('tr');
+      const detailBtn = `<button class="secondary" data-email="${a.email}" data-action="detail" style="padding:4px 10px">详情</button>`;
+      const dlBtn = a.cpa_file_name
+        ? ` <a href="/api/account/${encodeURIComponent(a.email)}/cpa.json" target="_blank" rel="noopener" style="padding:4px 10px;border-radius:8px;background:#e6f7ec;color:#0f5f22;text-decoration:none;font-weight:600;font-size:12px">下载 CPA</a>`
+        : '';
+      tr.innerHTML = `<td><code>${a.email||''}</code></td><td>${a.plan_type||''}</td><td>${a.final_status||''}</td><td>${a.cpa_file_name||''}</td><td>${fmtTime(a.created_at)}</td><td>${fmtTime(a.updated_at)}</td><td style="color:#a40000">${a.last_error||''}</td><td>${detailBtn}${dlBtn}</td>`;
+      tbody.appendChild(tr);
+    });
+    tbody.querySelectorAll('button[data-action="detail"]').forEach(btn => {
+      btn.addEventListener('click', () => loadAccountDetail(btn.dataset.email));
+    });
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+async function loadAccountDetail(email) {
+  try {
+    const r = await fetch('/api/account/' + encodeURIComponent(email));
+    const data = await r.json();
+    $('accDetail').textContent = JSON.stringify(data, null, 2);
+  } catch (e) {
+    $('accDetail').textContent = String(e);
+  }
+}
+
+$('refreshAccounts').addEventListener('click', loadAccounts);
+$('accFilter').addEventListener('change', loadAccounts);
+
+// 全自动跑完后自动刷一次账号库
+const _origRefreshStatus = refreshStatus;
+let _wasRunning = false;
+async function refreshStatusWithDb() {
+  await _origRefreshStatus();
+  try {
+    const r = await fetch('/api/status');
+    const s = await r.json();
+    if (_wasRunning && !s.running) loadAccounts();
+    _wasRunning = !!s.running;
+  } catch (_) {}
+}
+clearInterval(window.__statusTimer);
+window.__statusTimer = setInterval(refreshStatusWithDb, 1500);
+
+$('save').addEventListener('click', saveConfig);
+
+$('go').addEventListener('click', async () => {
+  // 自动先保存一次配置
+  await saveConfig();
+  $('log').textContent = '';
+  $('go').disabled = true;
+  setState('启动中', 'gray');
+  try {
+    const r = await fetch('/api/start', {method:'POST'});
+    const data = await r.json();
+    if (data.error) {
+      alert(data.error);
+      $('go').disabled = false;
+      setState('空闲', 'gray');
+    }
+  } catch (e) {
+    alert(e.message || String(e));
+    $('go').disabled = false;
+  }
+});
+
+$('stop').addEventListener('click', async () => {
+  await fetch('/api/stop', {method: 'POST'});
+});
+</script>
+</body>
+</html>"""
+
+
+def _read_json(handler) -> dict:
+    length = int(handler.headers.get("content-length") or "0")
+    if length <= 0:
+        return {}
+    raw = handler.rfile.read(length).decode("utf-8", errors="replace")
+    return json.loads(raw or "{}")
+
+
+class Handler(BaseHTTPRequestHandler):
+    def do_GET(self):
+        path = self.path.split("?", 1)[0]
+        query = self.path.split("?", 1)[1] if "?" in self.path else ""
+        if path in ("/", "/index.html"):
+            self._send(200, INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8")
+            return
+        if path == "/api/status":
+            self._send_json(200, JOB.status())
+            return
+        if path == "/api/config":
+            self._send_json(200, asdict(AppConfig.load()))
+            return
+        if path == "/api/log":
+            self._stream_log()
+            return
+        if path == "/api/accounts":
+            try:
+                from urllib.parse import parse_qs
+                q = parse_qs(query)
+                status = (q.get("status") or [""])[0] or None
+                limit = int((q.get("limit") or ["200"])[0])
+                accounts = list_accounts(limit=limit, status=status)
+                # 别把 session 全文 dump 给列表,太大;列表只回主要字段
+                slim = []
+                for a in accounts:
+                    slim.append({k: a.get(k) for k in (
+                        "email", "plan_type", "final_status", "cpa_file_name",
+                        "long_link", "last_error", "created_at", "updated_at",
+                        "cpa_uploaded_at"
+                    )})
+                self._send_json(200, {"accounts": slim})
+            except Exception as exc:
+                self._send_json(500, {"error": str(exc)})
+            return
+        if path.startswith("/api/account/") and path.endswith("/cpa.json"):
+            from urllib.parse import unquote
+            email = unquote(path[len("/api/account/"):-len("/cpa.json")])
+            acc = get_account(email)
+            if not acc:
+                self._send_json(404, {"error": "account not found"})
+                return
+            session = acc.get("plus_session") or acc.get("initial_session")
+            if not session:
+                self._send_json(404, {"error": "该账号没有可下载的 session"})
+                return
+            try:
+                payload = build_cpa_auth_payload(session, email_hint=email)
+            except Exception as exc:
+                self._send_json(500, {"error": f"构造 CPA auth JSON 失败: {exc}"})
+                return
+            file_name = acc.get("cpa_file_name") or payload["fileName"]
+            content = json.dumps(payload["authJson"], ensure_ascii=False, indent=2).encode("utf-8")
+            self.send_response(200)
+            self.send_header("Content-Type", "application/json; charset=utf-8")
+            self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
+            self.send_header("Cache-Control", "no-store")
+            self.send_header("Content-Length", str(len(content)))
+            self.end_headers()
+            self.wfile.write(content)
+            return
+        if path.startswith("/api/account/"):
+            from urllib.parse import unquote
+            email = unquote(path[len("/api/account/"):])
+            acc = get_account(email)
+            if not acc:
+                self._send_json(404, {"error": "account not found"})
+                return
+            events = list_events(email, limit=200)
+            self._send_json(200, {"account": acc, "events": events})
+            return
+        self._send_json(404, {"error": "not found"})
+
+    def do_POST(self):
+        path = self.path.split("?", 1)[0]
+        if path == "/api/config":
+            try:
+                body = _read_json(self)
+                cfg = AppConfig.load().update(body or {})
+                self._send_json(200, asdict(cfg))
+            except Exception as exc:
+                self._send_json(500, {"error": str(exc)})
+            return
+
+        if path == "/api/start":
+            try:
+                cfg = AppConfig.load()
+                err = JOB.start(cfg)
+                if err:
+                    self._send_json(409, {"error": err})
+                else:
+                    self._send_json(200, {"ok": True})
+            except Exception as exc:
+                self._send_json(500, {"error": str(exc)})
+            return
+
+        if path == "/api/stop":
+            JOB.stop()
+            self._send_json(200, {"ok": True})
+            return
+
+        self._send_json(404, {"error": "not found"})
+
+    def _stream_log(self):
+        self.send_response(200)
+        self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+        self.send_header("Cache-Control", "no-cache")
+        self.send_header("Connection", "keep-alive")
+        self.end_headers()
+        try:
+            for line in JOB.history[-300:]:
+                self._sse_send(line)
+            while True:
+                try:
+                    line = JOB.log_queue.get(timeout=15)
+                    self._sse_send(line)
+                except queue.Empty:
+                    self.wfile.write(b": ping\n\n")
+                    self.wfile.flush()
+        except (BrokenPipeError, ConnectionResetError):
+            return
+
+    def _sse_send(self, line: str):
+        for piece in line.splitlines() or [""]:
+            self.wfile.write(b"data: " + piece.encode("utf-8") + b"\n")
+        self.wfile.write(b"\n")
+        self.wfile.flush()
+
+    def _send_json(self, status: int, payload: dict):
+        self._send(status, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
+
+    def _send(self, status: int, content: bytes, content_type: str):
+        self.send_response(status)
+        self.send_header("Content-Type", content_type)
+        self.send_header("Cache-Control", "no-store")
+        self.send_header("Content-Length", str(len(content)))
+        self.end_headers()
+        self.wfile.write(content)
+
+    def log_message(self, fmt, *args):
+        return
+
+    def handle_one_request(self):
+        try:
+            return super().handle_one_request()
+        except (ConnectionResetError, BrokenPipeError):
+            # 浏览器主动断开 SSE / fetch 时打印栈很碍眼,直接静音
+            self.close_connection = True
+
+
+def _silence_threading_excepthook():
+    """ThreadingHTTPServer 在 worker 线程里仍可能抛 ConnectionResetError;接住它。"""
+    import threading
+
+    prev = threading.excepthook
+
+    def hook(args):
+        if isinstance(args.exc_value, (ConnectionResetError, BrokenPipeError)):
+            return
+        prev(args)
+
+    threading.excepthook = hook
+
+
+def main():
+    init_db()
+    _silence_threading_excepthook()
+    server = ThreadingHTTPServer((HOST, PORT), Handler)
+    print(f"ChatGPT Plus Auto Console: http://{HOST}:{PORT}/")
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        print("\nStopped.")
+
+
+if __name__ == "__main__":
+    main()

+ 213 - 0
storage.py

@@ -0,0 +1,213 @@
+"""SQLite 持久化:注册成功的账号、session 快照、CPA 上传记录。"""
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+import threading
+import time
+from contextlib import contextmanager
+from typing import Any, Iterable
+
+DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
+DB_PATH = os.path.join(DATA_DIR, "accounts.db")
+_LOCK = threading.Lock()
+
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS accounts (
+    email TEXT PRIMARY KEY,
+    password TEXT NOT NULL,
+    created_at INTEGER NOT NULL,
+    updated_at INTEGER NOT NULL,
+    plan_type TEXT,
+    final_status TEXT,         -- registered/paid/plus/cpa_uploaded/failed/stopped
+    last_error TEXT,
+    long_link TEXT,
+    cpa_file_name TEXT,
+    cpa_uploaded_at INTEGER,
+    initial_session_json TEXT, -- 注册成功时拉到的 /api/auth/session
+    plus_session_json TEXT,    -- 付款后拉到的 plus session
+    notes TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(final_status);
+CREATE INDEX IF NOT EXISTS idx_accounts_created ON accounts(created_at);
+
+CREATE TABLE IF NOT EXISTS account_events (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    email TEXT NOT NULL,
+    ts INTEGER NOT NULL,
+    stage TEXT NOT NULL,
+    status TEXT NOT NULL,        -- info/ok/warn/error
+    detail TEXT,
+    payload_json TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_events_email ON account_events(email);
+CREATE INDEX IF NOT EXISTS idx_events_ts ON account_events(ts);
+"""
+
+
+def _now_ms() -> int:
+    return int(time.time() * 1000)
+
+
+def _ensure_dir():
+    os.makedirs(DATA_DIR, exist_ok=True)
+
+
+def init_db():
+    _ensure_dir()
+    with _LOCK, sqlite3.connect(DB_PATH) as conn:
+        conn.executescript(SCHEMA)
+        conn.commit()
+
+
+@contextmanager
+def _conn():
+    _ensure_dir()
+    with _LOCK:
+        c = sqlite3.connect(DB_PATH)
+        c.row_factory = sqlite3.Row
+        try:
+            yield c
+            c.commit()
+        finally:
+            c.close()
+
+
+def _dump(value: Any) -> str | None:
+    if value is None:
+        return None
+    try:
+        return json.dumps(value, ensure_ascii=False)
+    except Exception:
+        return str(value)
+
+
+def upsert_account(email: str, password: str, *, fields: dict | None = None) -> dict:
+    """Insert or update by email. fields 中只更新非 None 字段。"""
+    init_db()
+    fields = dict(fields or {})
+    now = _now_ms()
+    with _conn() as c:
+        row = c.execute("SELECT email FROM accounts WHERE email = ?", (email,)).fetchone()
+        if row is None:
+            c.execute(
+                """
+                INSERT INTO accounts (email, password, created_at, updated_at, plan_type,
+                  final_status, last_error, long_link, cpa_file_name, cpa_uploaded_at,
+                  initial_session_json, plus_session_json, notes)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    email, password, now, now,
+                    fields.get("plan_type"),
+                    fields.get("final_status") or "registered",
+                    fields.get("last_error"),
+                    fields.get("long_link"),
+                    fields.get("cpa_file_name"),
+                    fields.get("cpa_uploaded_at"),
+                    _dump(fields.get("initial_session")) if "initial_session" in fields else fields.get("initial_session_json"),
+                    _dump(fields.get("plus_session")) if "plus_session" in fields else fields.get("plus_session_json"),
+                    fields.get("notes"),
+                ),
+            )
+        else:
+            sets = ["updated_at = ?"]
+            args: list[Any] = [now]
+            for col in ("plan_type", "final_status", "last_error", "long_link",
+                        "cpa_file_name", "cpa_uploaded_at", "notes"):
+                if col in fields and fields[col] is not None:
+                    sets.append(f"{col} = ?")
+                    args.append(fields[col])
+            if "initial_session" in fields:
+                sets.append("initial_session_json = ?")
+                args.append(_dump(fields["initial_session"]))
+            elif "initial_session_json" in fields and fields["initial_session_json"] is not None:
+                sets.append("initial_session_json = ?")
+                args.append(fields["initial_session_json"])
+            if "plus_session" in fields:
+                sets.append("plus_session_json = ?")
+                args.append(_dump(fields["plus_session"]))
+            elif "plus_session_json" in fields and fields["plus_session_json"] is not None:
+                sets.append("plus_session_json = ?")
+                args.append(fields["plus_session_json"])
+            if password:
+                sets.append("password = ?")
+                args.append(password)
+            args.append(email)
+            c.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE email = ?", args)
+
+        return _row_to_dict(c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone())
+
+
+def add_event(email: str, stage: str, status: str = "info",
+              detail: str | None = None, payload: Any = None) -> int:
+    init_db()
+    with _conn() as c:
+        cur = c.execute(
+            "INSERT INTO account_events (email, ts, stage, status, detail, payload_json) VALUES (?, ?, ?, ?, ?, ?)",
+            (email or "", _now_ms(), stage, status, detail, _dump(payload)),
+        )
+        return cur.lastrowid
+
+
+def list_accounts(limit: int = 200, status: str | None = None) -> list[dict]:
+    init_db()
+    with _conn() as c:
+        if status:
+            rows = c.execute(
+                "SELECT * FROM accounts WHERE final_status = ? ORDER BY created_at DESC LIMIT ?",
+                (status, limit),
+            ).fetchall()
+        else:
+            rows = c.execute(
+                "SELECT * FROM accounts ORDER BY created_at DESC LIMIT ?",
+                (limit,),
+            ).fetchall()
+        return [_row_to_dict(r) for r in rows]
+
+
+def get_account(email: str) -> dict | None:
+    init_db()
+    with _conn() as c:
+        r = c.execute("SELECT * FROM accounts WHERE email = ?", (email,)).fetchone()
+        return _row_to_dict(r) if r else None
+
+
+def list_events(email: str, limit: int = 100) -> list[dict]:
+    init_db()
+    with _conn() as c:
+        rows = c.execute(
+            "SELECT * FROM account_events WHERE email = ? ORDER BY ts DESC LIMIT ?",
+            (email, limit),
+        ).fetchall()
+        return [_event_row(r) for r in rows]
+
+
+def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
+    if row is None:
+        return None
+    d = {k: row[k] for k in row.keys()}
+    # session JSON 反序列化但保留 raw 副本
+    for k in ("initial_session_json", "plus_session_json"):
+        raw = d.get(k)
+        if raw:
+            try:
+                d[k.replace("_json", "")] = json.loads(raw)
+            except Exception:
+                d[k.replace("_json", "")] = None
+    return d
+
+
+def _event_row(row: sqlite3.Row) -> dict:
+    d = {k: row[k] for k in row.keys()}
+    raw = d.get("payload_json")
+    if raw:
+        try:
+            d["payload"] = json.loads(raw)
+        except Exception:
+            d["payload"] = None
+    return d