Переглянути джерело

Refactor automation: payurl long-link, PayPal retry & DataDome wait

- 新增 generate_long_link_payurl 调 payurl.ark2.cn(与扩展一致)
- run_paypal_flow 接受外部 page,支持 retry 3 次(清缓存+刷新长链+换卡换地址)
- _detect_payment_failure / PayPalPaymentFailed 识别 20+ 类 PayPal/Stripe 失败信号
- _detect_datadome_captcha + _wait_for_datadome_solved 半自动滑块(业务元素出现即视为通过)
- _open_paypal_context 支持 PayPal 阶段独立代理
- _stripe_select_paypal 多策略兜底;safe_fill/select_by_text 收紧 timeout
- RunContext 加 paypal_email/phone_e164/sms_api_url/paypal_proxy/_stop_hook 字段
chendeben 2 місяців тому
батько
коміт
c06586c2ee
1 змінених файлів з 330 додано та 62 видалено
  1. 330 62
      automation.py

+ 330 - 62
automation.py

@@ -20,7 +20,9 @@ except Exception:
 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"
+DEFAULT_PHONE_E164 = "+15822201173"
+DEFAULT_SMS_API_URL = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
+PHONE_E164 = DEFAULT_PHONE_E164
 PHONE_NUMBER = PHONE_E164.removeprefix("+1")
 PHONE_COUNTRY = "US"
 POST_PAYMENT_WAIT_TIMEOUT = 600
@@ -68,6 +70,13 @@ _PAYMENT_FAILED_TEXT_RE = re.compile(
     r"oas[_\s-]?error|"
     r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
     r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
+    r"your\s+account\s+is\s+limited|"
+    r"check\s+your\s+paypal\s+account\s+overview\s+page|"
+    r"how\s+to\s+resolve\s+this\s+problem|"
+    r"account\s+(?:has\s+been\s+)?(?:temporarily\s+)?(?:limited|restricted|suspended|locked)|"
+    r"weasley_fallback_to_hermes|"
+    r"r_error\b|\br[_\s-]?error\b|"
+    r"账[户号]?(?:已)?受限|账[户号]?(?:已)?(?:被)?(?:冻结|限制|暂停)|"
     r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
     r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
     re.I,
@@ -210,10 +219,19 @@ class RunContext:
     email: str = ""           # ChatGPT 注册邮箱(@edu.a4sky.com)
     paypal_email: str = ""    # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
     password: str = ""
+    phone_e164: str = DEFAULT_PHONE_E164
+    sms_api_url: str = DEFAULT_SMS_API_URL
+    paypal_proxy: str = ""    # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
     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 phone_number(self) -> str:
+        """无国家码的手机号(只去掉前导 +1,对应 PayPal #phone 字段格式)。"""
+        e164 = (self.phone_e164 or DEFAULT_PHONE_E164).strip()
+        return e164.removeprefix("+1") if e164.startswith("+1") else e164.lstrip("+")
+
     @property
     def artifact_dir(self) -> str:
         d = os.path.join(LOG_DIR, self.run_id)
@@ -338,6 +356,13 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
     masked["token"] = (ctx.token or "")[:24] + "..."
     ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
 
+    # 长链生成走全局代理(payurl.ark2.cn 自身不一定需要,但保持与浏览器同源更稳)
+    proxies = None
+    proxy_url = getattr(ctx, "paypal_proxy", "") or ""
+    if proxy_url:
+        proxies = {"http": proxy_url, "https": proxy_url}
+        ctx.log(f"[longlink] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
+
     max_attempts = 5
     last_err = ""
     for attempt in range(1, max_attempts + 1):
@@ -351,6 +376,7 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
                     headers=headers,
                     impersonate="chrome136",
                     timeout=30,
+                    proxies=proxies,
                 )
                 text = r.text
                 status = r.status_code
@@ -360,7 +386,11 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
                 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:
+                opener = urllib.request.build_opener()
+                if proxy_url:
+                    proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
+                    opener = urllib.request.build_opener(proxy_handler)
+                with opener.open(req, timeout=30) as resp:
                     text = resp.read().decode("utf-8", errors="replace")
                     status = resp.status
         except Exception as exc:
@@ -429,6 +459,13 @@ def _rand_password() -> str:
 def _check_stop(ctx: RunContext):
     if ctx.state == "stopped":
         raise RuntimeError("STOPPED_BY_USER")
+    # 让上层(chatgpt_flow.FullRunContext)注入的 hook 也能短路停止
+    hook = getattr(ctx, "_stop_hook", None)
+    if callable(hook):
+        try:
+            hook()
+        except Exception:
+            raise
 
 
 def _dump_page(ctx: RunContext, page, tag: str):
@@ -459,6 +496,98 @@ def _dump_page(ctx: RunContext, page, tag: str):
         ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
 
 
+def _parse_proxy_url(proxy_url: str) -> dict | None:
+    """把 http://user:pass@host:port 解析成 Playwright proxy 配置。空串返回 None。"""
+    if not proxy_url or not proxy_url.strip():
+        return None
+    from urllib.parse import urlparse
+    p = urlparse(proxy_url.strip())
+    if not p.hostname or not p.port:
+        return None
+    server = f"{p.scheme or 'http'}://{p.hostname}:{p.port}"
+    out: dict = {"server": server}
+    if p.username:
+        out["username"] = p.username
+    if p.password:
+        out["password"] = p.password
+    return out
+
+
+def _open_paypal_context(ctx, page):
+    """为 PayPal 阶段开一个带代理的新 context,返回 (new_page, old_context)。
+    若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
+    就返回 (page, None) 表示沿用当前 page。
+    """
+    proxy_cfg = _parse_proxy_url(getattr(ctx, "paypal_proxy", "") or "")
+    if not proxy_cfg:
+        ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
+        return page, None
+
+    # 若当前 context 已带相同代理,无需再切
+    try:
+        current_proxy = page.context._impl_obj._initializer.get("proxy") if hasattr(page.context, "_impl_obj") else None
+    except Exception:
+        current_proxy = None
+    if current_proxy and isinstance(current_proxy, dict) and current_proxy.get("server") == proxy_cfg.get("server"):
+        ctx.log(f"[playwright] 当前 context 已使用代理 {proxy_cfg.get('server')},PayPal 阶段沿用")
+        return page, None
+
+    masked = dict(proxy_cfg)
+    if masked.get("password"):
+        masked["password"] = "***"
+    ctx.log(f"[playwright] PayPal 阶段使用代理: {masked}")
+    try:
+        browser = page.context.browser
+    except Exception as exc:
+        ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退直连")
+        return page, None
+
+    try:
+        old_ctx = page.context
+        new_ctx = browser.new_context(
+            locale="en-US",
+            timezone_id="America/New_York",
+            viewport={"width": 1280, "height": 900},
+            proxy=proxy_cfg,
+        )
+        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}"))
+        ctx.log("[playwright] PayPal 代理 context 已就绪")
+        return new_page, old_ctx
+    except Exception as exc:
+        ctx.log(f"[playwright] 新建代理 context 失败: {exc!r},回退直连")
+        return page, None
+
+
+def _close_paypal_context(ctx, paypal_page, old_ctx):
+    """关掉 PayPal 阶段的代理 context(如果有),返回原始 page 用于后续拉 session。"""
+    if old_ctx is None:
+        return paypal_page  # 没切代理,直接返回
+    try:
+        new_ctx = paypal_page.context
+        for p in list(new_ctx.pages):
+            try:
+                p.close()
+            except Exception:
+                pass
+        new_ctx.close()
+        ctx.log("[playwright] PayPal 代理 context 已关闭")
+    except Exception as exc:
+        ctx.log(f"[playwright] 关闭代理 context 异常: {exc!r}")
+    # 从老 context 找一个还活着的 page,没有就新开一个
+    try:
+        live_pages = [p for p in old_ctx.pages if not p.is_closed()]
+        if live_pages:
+            return live_pages[0]
+        return old_ctx.new_page()
+    except Exception as exc:
+        ctx.log(f"[playwright] 切回老 context 异常: {exc!r}")
+        return paypal_page
+
+
 def run_paypal_flow(ctx: RunContext, page=None):
     """跑 Stripe → PayPal。这一段不走代理。
     page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
@@ -481,63 +610,72 @@ def run_paypal_flow(ctx: RunContext, page=None):
     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(f"[playwright] phone={ctx.phone_e164}")
+
+    # 切到 PayPal 阶段的代理 context(如果配置了 paypal_proxy)
+    paypal_page, old_ctx = _open_paypal_context(ctx, page)
 
     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:
+        for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
+            ctx.paypal_email = _rand_paypal_email()
+            ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
             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}")
+                ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
+                ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
+                paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
+                _dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
+
+                _stripe_select_paypal_and_submit(ctx, paypal_page)
+                _paypal_signup_and_pay(ctx, paypal_page)
+                ctx.log("[playwright] 流程完成")
+                _dump_page(ctx, paypal_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} 次重试(清缓存+刷新长链)")
                 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)
+                    _clear_browser_state(ctx, paypal_page)
+                except Exception as exc2:
+                    ctx.log(f"[playwright] 清缓存异常(继续): {exc2!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}")
+                # 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
+                # 表单会变 disabled。必须重新生成一个新的长链。
+                try:
+                    new_link = generate_long_link_payurl(ctx)
+                    ctx.long_link = new_link
+                    ctx.log(f"[playwright] 重试用新长链 sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
+                except Exception as exc2:
+                    ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
+                ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
+                time.sleep(30)
+                continue
+            except Exception as exc:
+                ctx.log(f"[playwright] 流程异常: {exc!r}")
+                ctx.log(traceback.format_exc())
+                try:
+                    _dump_page(ctx, paypal_page, f"99-error-attempt{attempt}")
+                except Exception:
+                    pass
+                raise
+        if last_err is not None:
+            raise last_err
+    finally:
+        # 不论成功失败,都尝试关掉 PayPal 代理 context、切回原 page,便于上层继续拉 session
+        try:
+            ctx._post_paypal_page = _close_paypal_context(ctx, paypal_page, old_ctx)  # type: ignore
         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
+            ctx.log(f"[playwright] 关闭 PayPal context 异常: {exc!r}")
 
 
 def _run_paypal_flow_self_browser(ctx: RunContext):
@@ -558,7 +696,7 @@ def _run_paypal_flow_self_browser(ctx: RunContext):
     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(f"[playwright] phone={ctx.phone_e164}")
 
     with sync_playwright() as p:
         ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
@@ -776,7 +914,7 @@ def _stripe_select_paypal_and_submit(ctx, page):
     _select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
 
     if page.locator('#phoneNumber').count() > 0:
-        _safe_fill(ctx, page, '#phoneNumber', PHONE_NUMBER, "phoneNumber")
+        _safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
 
     page.wait_for_timeout(400)
     cb = page.locator('#termsOfServiceConsentCheckbox')
@@ -821,12 +959,129 @@ def _stripe_select_paypal_and_submit(ctx, page):
     _dump_page(ctx, page, "05-paypal-arrived")
 
 
+def _detect_datadome_captcha(page) -> str:
+    """检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
+
+    判定逻辑:
+    1) 如果页面已出现 PayPal 业务元素(邮箱输入框 / Create an Account / Continue / checkoutweb URL 等)
+       → 直接判定"已通过",不再看 DataDome 残留
+    2) 否则看 DataDome iframe / 滑块 / 拦截文本
+    """
+    url = getattr(page, "url", "") or ""
+    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 out = { hasIframe: false, hasSlider: false, snippet: '', hasBusiness: false, businessHint: '' };
+
+                // 正向:PayPal 业务页面元素(出现即代表已过 DataDome)
+                const emailInput = document.querySelector(
+                    'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
+                );
+                if (emailInput && visible(emailInput)) {
+                    out.hasBusiness = true;
+                    out.businessHint = 'email-input-visible';
+                }
+                if (!out.hasBusiness) {
+                    const c2pBtns = Array.from(document.querySelectorAll(
+                        'button, a, [role="button"]'
+                    )).filter(visible);
+                    const matched = c2pBtns.find((el) => {
+                        const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
+                        return /create\s+an?\s+account|continue\s+to\s+payment|pay\s+with\s+(?:debit|credit)\s+card/i.test(t);
+                    });
+                    if (matched) {
+                        out.hasBusiness = true;
+                        out.businessHint = 'business-button:' + (matched.innerText || '').slice(0, 40);
+                    }
+                }
+
+                // 反向:DataDome 拦截特征(仅当业务元素未出现才看)
+                if (!out.hasBusiness) {
+                    const ifr = document.querySelector(
+                        'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
+                    );
+                    if (ifr && visible(ifr)) out.hasIframe = true;
+                    const slider = document.querySelector(
+                        '[id*="datadome" i] [class*="slider" i], [class*="datadome" i] [class*="slider" i], '
+                        + '[id^="ddc-"], [class*="captcha-container" i], [aria-label*="slider" i][role="slider"]'
+                    );
+                    if (slider && visible(slider)) out.hasSlider = true;
+                    const t = (document.body && document.body.innerText || '').replace(/\s+/g, ' ');
+                    if (/please\s+slide\s+to\s+verify|slide\s+to\s+complete|slide\s+to\s+confirm|拖动滑块|拖拽滑块|滑动验证|完成拼图|complete\s+the\s+puzzle/i.test(t)) {
+                        out.hasSlider = true;
+                        out.snippet = t.slice(0, 200);
+                    }
+                }
+                return out;
+            }"""
+        ) or {}
+    except Exception:
+        info = {}
+
+    # 已经在 PayPal 业务页面 → 一定通过
+    if info.get("hasBusiness"):
+        return ""
+
+    # URL 已经进入 /checkoutweb/ 或 /pay/ 带业务参数 → 也算通过
+    if "/checkoutweb/" in url:
+        return ""
+    if ("paypal_client_cfci=" in url) or ("paypal_client_metadata_id=" in url):
+        return ""
+
+    if info.get("hasIframe") or info.get("hasSlider"):
+        return f"DataDome captcha 检测到 iframe={info.get('hasIframe')} slider={info.get('hasSlider')} url={url} snippet={info.get('snippet', '')[:120]!r}"
+    return ""
+
+
+def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
+    """提示用户手动过滑块,轮询直到 DataDome 元素消失或超时。"""
+    ctx.set_stage("⚠️ 检测到 DataDome 滑块校验,请在浏览器中手动滑动完成(最长等 5 分钟)")
+    deadline = time.time() + max_wait_sec
+    last_log = 0.0
+    poll_interval = 0.5  # 高频检测,滑过后 ≤500ms 就能继续
+    stable_needed = 2    # 连续 2 次检测不到才算真的通过(避免 DataDome 中间状态误判)
+    stable_count = 0
+    while time.time() < deadline:
+        _check_stop(ctx)
+        if not _detect_datadome_captcha(page):
+            stable_count += 1
+            if stable_count >= stable_needed:
+                ctx.log("[paypal] DataDome 已通过,继续流程")
+                # 给页面 1s 完成跳转,但不再傻等
+                page.wait_for_timeout(1000)
+                return True
+        else:
+            stable_count = 0
+        if time.time() - last_log > 10:
+            remaining = int(deadline - time.time())
+            ctx.log(f"[paypal] 仍在等待人工通过 DataDome 滑块... 剩余 {remaining}s")
+            last_log = time.time()
+        time.sleep(poll_interval)
+    ctx.log("[paypal] DataDome 等待超时")
+    return False
+
+
 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 着陆即可能弹 DataDome 滑块(agreements/approve、checkoutweb 都见过)
+    captcha_reason = _detect_datadome_captcha(page)
+    if captcha_reason:
+        ctx.log(f"[paypal] {captcha_reason}")
+        if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
+            _dump_page(ctx, page, "06-paypal-datadome-timeout")
+            raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
+
     _paypal_clear_session(ctx, page)
 
     # 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
@@ -841,6 +1096,19 @@ def _paypal_signup_and_pay(ctx, page):
     ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
     _dump_page(ctx, page, "06-paypal-checkoutweb-entered")
 
+    # 进 /checkoutweb/ 之后再次检测 DataDome(PayPal 有时会在表单页弹)
+    captcha_reason = _detect_datadome_captcha(page)
+    if captcha_reason:
+        ctx.log(f"[paypal] {captcha_reason}")
+        if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
+            _dump_page(ctx, page, "06b-paypal-datadome-timeout")
+            raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
+
+    if path_taken == "unknown":
+        # 上层没识别到 /checkoutweb/,强行往下填表只会错位 → 直接抛失败让外层 retry
+        ctx.log("[paypal] ensure_checkoutweb 返回 unknown,直接判定本次失败以触发重试")
+        raise PayPalPaymentFailed(f"未能进入 /checkoutweb/ 表单页, url={page.url}")
+
     ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
     for sel in ('#cardNumber', '#phone', '#billingLine1'):
         try:
@@ -920,7 +1188,7 @@ def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
     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")
+        _paypal_type_into(ctx, page, '#phone', ctx.phone_number, "phone")
     if "cardNumber" in targets:
         _paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
     if "cardExpiry" in targets:
@@ -1074,7 +1342,7 @@ def _paypal_handle_sms(ctx, page):
 
     _dump_page(ctx, page, "12-paypal-sms-prompt")
     ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
-    code = fetch_sms_code(timeout=180, interval=5, log=ctx.log)
+    code = fetch_sms_code(timeout=180, interval=5, log=ctx.log, sms_api_url=ctx.sms_api_url)
 
     if digit_count > 0:
         ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
@@ -1268,8 +1536,8 @@ def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool
         if count == 0:
             ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
             return
-        loc.wait_for(state="visible", timeout=8000)
-        loc.fill(value)
+        loc.wait_for(state="visible", timeout=6000)
+        loc.fill(value, timeout=6000)
         ctx.log(f"[fill] {label}({selector}) <- {shown}")
     except Exception as exc:
         ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
@@ -1286,11 +1554,11 @@ def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
         for kind in ("value", "label", "raw"):
             try:
                 if kind == "value":
-                    loc.select_option(value=text)
+                    loc.select_option(value=text, timeout=4000)
                 elif kind == "label":
-                    loc.select_option(label=text)
+                    loc.select_option(label=text, timeout=4000)
                 else:
-                    loc.select_option(text)
+                    loc.select_option(text, timeout=4000)
                 ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
                 return
             except Exception as exc: