"""端到端自动化:拿 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 = "" 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"