"""端到端自动化:拿 ChatGPT 长链 → Stripe 选 PayPal → PayPal 注册绑卡 → 等短信。""" from __future__ import annotations import json import math import os import random 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" 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 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") DATADOME_COOKIE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "datadome_cookie.json") _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"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, ) class PayPalPaymentFailed(Exception): """PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。""" class StripeNonFreeDetected(Exception): """Stripe 页面检测到金额非 $0,保留用于未来扩展。""" MAX_PAYPAL_RETRIES = 3 def detect_stripe_amount(page, log: Callable[[str], None] = print) -> str: """在 Stripe checkout 页面检测最终付款金额,返回金额文本(如 "$0.00"、"$20.00")。 空字符串表示无法检测到。 """ try: amount = page.evaluate(r"""() => { // Stripe checkout 页面的金额通常在多个位置出现 const selectors = [ '[data-testid="hosted-payment-submit-button"]', '.SubmitButton-TextContainer', '[class*="OrderTotal"]', '[class*="total" i]', '[data-testid*="total" i]', '[class*="amount" i]', '[data-testid*="amount" i]', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (!el) continue; const text = (el.innerText || el.textContent || '').trim(); // 匹配 $0.00 / $0 / ¥0 / €0 等货币金额 const m = text.match(/[\$€£¥]\s*[\d,.]+/); if (m) return m[0]; } // 兜底:扫全页面找金额 const bodyText = (document.body && document.body.innerText || ''); // 找 "Total" / "Amount due" / "Due today" 后面跟的金额 const patterns = [ /(?:total|amount\s+due|due\s+today|order\s+total)[:\s]*?[\$€£¥]\s*([\d,.]+)/i, /(?:pay|subscribe)[:\s]*?[\$€£¥]\s*([\d,.]+)/i, ]; for (const re of patterns) { const m = bodyText.match(re); if (m) return '$' + m[1]; } return ''; }""") if amount: log(f"[stripe] 检测到付款金额: {amount}") return amount or "" except Exception as exc: log(f"[stripe] 金额检测失败: {exc!r}") return "" def is_zero_amount(amount_str: str) -> bool: """判断金额字符串是否为 0($0.00 / $0 / ¥0.00 等)。""" if not amount_str: return False import re m = re.search(r'[\d,.]+', amount_str) if not m: return False num_str = m.group().replace(',', '') try: return float(num_str) == 0.0 except ValueError: return False def decide_paypal_flow_for_amount(amount_str: str, *, trial_eligible: bool | None) -> dict: """基于 Stripe 金额和试用资格,决定是否继续进入 PayPal 付款。""" if amount_str and is_zero_amount(amount_str): return { "mode": "free_trial", "continue_payment": True, "is_free_trial": True, } if amount_str: if trial_eligible is True: return { "mode": "paid_retry", "continue_payment": True, "is_free_trial": False, } return { "mode": "manual_payment_required", "continue_payment": False, "is_free_trial": False, } return { "mode": "unknown", "continue_payment": True, "is_free_trial": False, } def _set_trial_eligibility(ctx: "RunContext", eligible: bool): prev = getattr(ctx, "_trial_eligible", None) if prev is True and not eligible: ctx.log("[stripe] 已认定支持试用,忽略后续不支持试用的覆盖结果") return if prev is eligible: return ctx._trial_eligible = eligible ctx.log(f"[stripe] 试用资格已判定为: {'支持试用' if eligible else '不支持试用'}") hook = getattr(ctx, "_on_trial_eligibility_detected", None) if callable(hook): try: hook(eligible) except Exception as exc: ctx.log(f"[stripe] 持久化试用资格失败: {exc!r}") 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: from geo_fingerprint import detect_paypal_geo_fingerprint geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log) new_ctx = browser.new_context( locale=geo.locale, timezone_id=geo.timezone_id, viewport={"width": 1280, "height": 900}, ) # 重建 context 后也注入 datadome cookie _inject_datadome_cookie_at_startup(ctx, new_ctx) 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。 保留 datadome cookie 以便下次复用。 """ ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)") # 先提取 datadome cookie,清除后恢复 saved_dd = [] try: bctx = page.context all_cookies = bctx.cookies() saved_dd = [c for c in all_cookies if "datadome" in c.get("name", "").lower()] except Exception: pass 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}") # 恢复 datadome cookie if saved_dd: try: page.context.add_cookies(saved_dd) ctx.log(f"[playwright] 恢复了 {len(saved_dd)} 个 datadome cookie") except Exception as exc: ctx.log(f"[playwright] 恢复 datadome cookie 失败: {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 = "" phone_e164: str = DEFAULT_PHONE_E164 sms_api_url: str = DEFAULT_SMS_API_URL paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理 long_link_mode: str = "payurl" # "payurl" 或 "local" long_link_proxy: str = "" # local 模式的代理 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) 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)}") # 长链生成走全局代理(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): 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, proxies=proxies, ) 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) 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: 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 generate_long_link_local(ctx: RunContext, proxy: str = "") -> str: """本地直连 chatgpt.com/backend-api/payments/checkout 生成长链,不走第三方中转。""" ctx.log("[longlink-local] === 本地直连 ChatGPT checkout API ===") ctx.log(f"[longlink-local] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}") payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo) headers = _request_headers(ctx.token) ctx.log(f"[longlink-local] payload={json.dumps(payload, ensure_ascii=False)}") proxy_url = (proxy or "").strip() if proxy_url and "://" not in proxy_url: proxy_url = "http://" + proxy_url if proxy_url: masked = proxy_url.split("@")[-1] if "@" in proxy_url else proxy_url ctx.log(f"[longlink-local] 使用代理: {masked}") max_attempts = 5 last_err = "" for attempt in range(1, max_attempts + 1): ctx.log(f"[longlink-local] 第 {attempt}/{max_attempts} 次请求 {CHECKOUT_URL}") started = time.time() try: if curl_requests is not None: proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None r = curl_requests.post( CHECKOUT_URL, json=payload, headers=headers, impersonate="chrome136", proxies=proxies, timeout=30, ) text = r.text status = r.status_code else: import urllib.request data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(CHECKOUT_URL, data=data, method="POST") for k, v in headers.items(): req.add_header(k, v) 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: last_err = repr(exc) ctx.log(f"[longlink-local] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}") time.sleep(1.5) continue elapsed = int((time.time() - started) * 1000) ctx.log(f"[longlink-local] HTTP {status} 耗时 {elapsed}ms 长度={len(text)} 预览={text[:300]}") if status >= 400: last_err = f"HTTP {status}: {text[:300]}" time.sleep(1.5) continue try: result = json.loads(text or "{}") except Exception as exc: last_err = f"非 JSON: {exc!r}" time.sleep(1.5) continue link = result.get("url") or result.get("stripe_hosted_url") or result.get("checkout_url") session_id = result.get("checkout_session_id") processor = result.get("processor_entity") if not link and session_id and processor: link = f"https://chatgpt.com/checkout/{processor}/{session_id}" ctx.log(f"[longlink-local] 无直接 url 字段,由 session_id 构造: {link}") if link: ctx.log(f"[longlink-local] 成功 long_link={link} session_id={session_id}") return link last_err = f"响应缺 url 字段: {text[:300]}" time.sleep(1.5) raise RuntimeError(f"本地长链获取连续失败 {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") # 让上层(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): """失败/检查点时把当前页 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 _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。 """ paypal_proxy_url = (getattr(ctx, "paypal_proxy", "") or "").strip() proxy_cfg = _parse_proxy_url(paypal_proxy_url) 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 from geo_fingerprint import detect_paypal_geo_fingerprint geo = detect_paypal_geo_fingerprint(paypal_proxy_url, log=ctx.log) new_ctx = browser.new_context( locale=geo.locale, timezone_id=geo.timezone_id, 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),否则自己开。 支付失败会清缓存+重开长链最多重试 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={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 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: 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) paypal_page.wait_for_timeout(2000) _dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}") amount = detect_stripe_amount(paypal_page, log=ctx.log) decision = decide_paypal_flow_for_amount( amount, trial_eligible=getattr(ctx, "_trial_eligible", None), ) if amount and decision["mode"] == "free_trial": ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款") _set_trial_eligibility(ctx, True) ctx._is_free_trial = True elif amount and decision["mode"] == "paid_retry": ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款") elif amount and decision["mode"] == "manual_payment_required": ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理") _set_trial_eligibility(ctx, False) raise StripeNonFreeDetected(f"金额为 {amount}") _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, 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: if ctx.long_link_mode == "local": new_link = generate_long_link_local(ctx, proxy=ctx.long_link_proxy) else: new_link = generate_long_link_payurl(ctx) ctx.long_link = new_link ctx.log(f"[playwright] 重试用新长链(mode={ctx.long_link_mode}) 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] 关闭 PayPal context 异常: {exc!r}") def _run_paypal_flow_self_browser(ctx: RunContext): """旧入口:自己开浏览器(保留兼容性)。""" from patchright.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={ctx.phone_e164}") with sync_playwright() as p: ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}") from geo_fingerprint import detect_paypal_geo_fingerprint geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log) browser = p.chromium.launch( headless=ctx.headless, args=["--disable-blink-features=AutomationControlled"], ) context = browser.new_context( locale=geo.locale, timezone_id=geo.timezone_id, viewport={"width": 1280, "height": 900}, ) # 启动时注入已保存的 datadome cookie,减少后续触发验证码的概率 _inject_datadome_cookie_at_startup(ctx, context) 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) new_page.wait_for_timeout(2000) _dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}") amount = detect_stripe_amount(new_page, log=ctx.log) decision = decide_paypal_flow_for_amount( amount, trial_eligible=getattr(ctx, "_trial_eligible", None), ) if amount and decision["mode"] == "free_trial": ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款") _set_trial_eligibility(ctx, True) ctx._is_free_trial = True elif amount and decision["mode"] == "paid_retry": ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款") elif amount and decision["mode"] == "manual_payment_required": ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理") _set_trial_eligibility(ctx, False) raise StripeNonFreeDetected(f"金额为 {amount}") elif amount: ctx.log(f"[stripe] 金额为 {amount}(正常付费)") _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") # Stripe 的州下拉框 option value 使用缩写(如 "IL"),label 使用全名(如 "Illinois") # 先尝试缩写,再尝试全名 state_abbrev = addr.get("state_abbrev", "") state_full = addr.get("state", "") if state_abbrev: _select_by_text(ctx, page, '#billingAdministrativeArea', state_abbrev, "billingAdministrativeArea") # 验证是否选中成功,若没有则尝试全名 sel_loc = page.locator('#billingAdministrativeArea').first if sel_loc.count() > 0: cur = sel_loc.input_value() if state_abbrev else "" if not cur and state_full: ctx.log(f"[stripe] 州下拉框未选中,尝试全名: {state_full}") _select_by_text(ctx, page, '#billingAdministrativeArea', state_full, "billingAdministrativeArea") if page.locator('#phoneNumber').count() > 0: _safe_fill(ctx, page, '#phoneNumber', ctx.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") # --------------------------------------------------------------------------- # DataDome 滑块自动模拟 + cookie 复用 # --------------------------------------------------------------------------- def _human_bezier_points(start_x: float, start_y: float, end_x: float, end_y: float, steps: int = 50) -> list[tuple[float, float]]: """用三阶贝塞尔曲线生成从 start 到 end 的人类鼠标轨迹,带随机抖动和加减速。""" dx = end_x - start_x dy = end_y - start_y dist = math.hypot(dx, dy) # 随机偏移控制点,制造弧度 offset_y = random.uniform(-dist * 0.15, dist * 0.15) cp1x = start_x + dx * random.uniform(0.2, 0.4) cp1y = start_y + dy * random.uniform(0.1, 0.3) + offset_y cp2x = start_x + dx * random.uniform(0.6, 0.8) cp2y = start_y + dy * random.uniform(0.7, 0.9) + offset_y points = [] for i in range(steps): t = i / max(steps - 1, 1) # 加速→匀速→减速的时间映射 t_ease = t * t * (3 - 2 * t) # smoothstep x = ((1 - t_ease) ** 3 * start_x + 3 * (1 - t_ease) ** 2 * t_ease * cp1x + 3 * (1 - t_ease) * t_ease ** 2 * cp2x + t_ease ** 3 * end_x) y = ((1 - t_ease) ** 3 * start_y + 3 * (1 - t_ease) ** 2 * t_ease * cp1y + 3 * (1 - t_ease) * t_ease ** 2 * cp2y + t_ease ** 3 * end_y) # 手抖:在中间段加随机微偏移 if 0.15 < t < 0.85: x += random.gauss(0, dist * 0.008) y += random.gauss(0, dist * 0.008) points.append((round(x, 1), round(y, 1))) points.append((round(end_x, 1), round(end_y, 1))) return points def _find_datadome_slider(page) -> dict | None: """在页面中查找 DataDome iframe 的位置信息。 DataDome 滑块在跨域 iframe 内,JS 无法直接访问内部 DOM。 返回 iframe 的 bounding box,供 page.mouse 在主页面坐标系操作。 """ try: return page.evaluate(r"""() => { const ifr = document.querySelector( 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]' ); if (!ifr) return null; const r = ifr.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return null; return { iframe: { x: r.left, y: r.top, width: r.width, height: r.height }, iframeSrc: ifr.src || '' }; }""") except Exception: return None def _datadome_drag_via_frame(ctx, page) -> bool: """用 Playwright frame_locator 定位 DataDome iframe 内的滑块并拖拽。 DataDome 的 iframe 是跨域的,JS 无法访问内部 DOM, 但 Playwright 的 frame_locator 可以操作跨域 iframe 内的元素。 滑块 UI 结构:iframe 内有一个蓝色按钮,需从左拖到右。 """ try: # 方法一:用 page.frame_locator() 进入 iframe frame = page.frame_locator( 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]' ) # 尝试多种滑块选择器 slider_selectors = [ # DataDome 常见滑块选择器 '[class*="slider"] [class*="btn"]', '[class*="slider"] button', '[class*="slider-track"] > div', '[class*="slider"] > div', 'button[class*="slider"]', '[role="slider"]', 'div[class*="challenge"] [class*="slider"]', 'div[class*="challenge"] button', # 更宽泛 'button', ] slider_loc = None used_sel = "" for sel in slider_selectors: try: loc = frame.locator(sel).first if loc.count() > 0 and loc.is_visible(): slider_loc = loc used_sel = sel break except Exception: continue if not slider_loc: ctx.log("[datadome] frame_locator: iframe 内未找到可拖拽的滑块元素") return False ctx.log(f"[datadome] frame_locator: 找到滑块 selector={used_sel}") # 获取滑块的 bounding box(在主页面坐标系) box = slider_loc.bounding_box() if not box: ctx.log("[datadome] frame_locator: 滑块无 bounding box") return False # 获取 iframe 的 bounding box 计算拖拽距离 iframe_loc = page.locator( 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]' ).first iframe_box = iframe_loc.bounding_box() if not iframe_box: ctx.log("[datadome] frame_locator: iframe 无 bounding box") return False ctx.log(f"[datadome] 滑块 x={box['x']:.0f} y={box['y']:.0f} w={box['width']:.0f} h={box['height']:.0f}") ctx.log(f"[datadome] iframe x={iframe_box['x']:.0f} y={iframe_box['y']:.0f} w={iframe_box['width']:.0f} h={iframe_box['height']:.0f}") # 滑块起始中心 start_x = box["x"] + box["width"] / 2 start_y = box["y"] + box["height"] / 2 # 计算拖拽距离:从滑块当前位置到 iframe 右边界的距离 # DataDome track 宽度 ≈ iframe 宽度减去两侧内边距 available_width = iframe_box["x"] + iframe_box["width"] - box["x"] - box["width"] * 0.3 drag_dist = available_width * random.uniform(0.88, 0.97) end_x = start_x + drag_dist end_y = start_y + random.uniform(-3, 3) ctx.log(f"[datadome] 拖拽 ({start_x:.0f},{start_y:.0f}) → ({end_x:.0f},{end_y:.0f}) dist={drag_dist:.0f}") # === 人类化拖拽 === # 阶段1:鼠标从远处自然移到滑块上方 approach_x = start_x - random.uniform(120, 250) approach_y = start_y + random.uniform(-60, 60) approach_pts = _human_bezier_points( approach_x, approach_y, start_x - random.uniform(2, 8), start_y + random.uniform(-1, 1), steps=random.randint(10, 18), ) for px, py in approach_pts: page.mouse.move(px, py) page.wait_for_timeout(random.randint(10, 25)) # 到达滑块后短暂停顿(人类瞄准) page.wait_for_timeout(random.randint(300, 700)) # 阶段2:按下鼠标 page.mouse.down() page.wait_for_timeout(random.randint(80, 200)) # 阶段3:主拖拽轨迹(变速:慢→快→慢,带 overshot) # 先拖到目标稍微偏右的位置(overshot),再微调回来 overshot_x = end_x + random.uniform(8, 25) overshot_y = end_y + random.uniform(-4, 4) steps_main = random.randint(40, 65) points_main = _human_bezier_points(start_x, start_y, overshot_x, overshot_y, steps=steps_main) for i, (px, py) in enumerate(points_main): page.mouse.move(px, py) # 速度曲线:起步慢→中间快→结尾慢 progress = i / max(steps_main - 1, 1) if progress < 0.15: delay = random.randint(18, 35) # 起步慢 elif progress < 0.7: delay = random.randint(4, 12) # 中间快 else: delay = random.randint(12, 28) # 结尾减速 page.wait_for_timeout(delay) # 阶段4:overshot 回弹(拖过头再回来一点) page.wait_for_timeout(random.randint(60, 150)) correction_steps = random.randint(3, 6) for i in range(correction_steps): t = (i + 1) / correction_steps cx = overshot_x + (end_x - overshot_x) * t cy = overshot_y + (end_y - overshot_y) * t + random.gauss(0, 0.5) page.mouse.move(cx, cy) page.wait_for_timeout(random.randint(15, 30)) # 阶段5:松手前的微小停顿 page.wait_for_timeout(random.randint(150, 400)) page.mouse.up() ctx.log("[datadome] frame_locator 拖拽完成") return True except Exception as exc: ctx.log(f"[datadome] frame_locator 拖拽异常: {exc!r}") return False def _auto_solve_datadome(ctx, page, max_attempts: int = 3) -> bool: """尝试自动通过 DataDome 滑块验证。 策略: 1) 注入已保存的 cookie → 如已通过则直接返回 2) 用 frame_locator 进入跨域 iframe → 定位滑块 → 人类轨迹拖拽 3) 每次 attempt 之间等待随机时间 """ ctx.set_stage("🤖 尝试自动通过 DataDome 滑块...") # 1) 注入已有 cookie _inject_datadome_cookie(ctx, page) page.wait_for_timeout(1500) if not _detect_datadome_captcha(page): ctx.log("[datadome] 注入已有 cookie 后验证已通过") return True for attempt in range(1, max_attempts + 1): ctx.log(f"[datadome] 自动解决第 {attempt}/{max_attempts} 次尝试") # 2) 模拟人类预行为:随机鼠标移动 + 微滚动 try: vp = page.viewport_size or {"width": 1280, "height": 900} for _ in range(random.randint(3, 6)): rx = random.uniform(100, vp["width"] - 100) ry = random.uniform(100, vp["height"] - 100) page.mouse.move(rx, ry, steps=random.randint(8, 20)) page.wait_for_timeout(random.randint(80, 250)) page.mouse.wheel(0, random.randint(-80, 80)) page.wait_for_timeout(random.randint(800, 1500)) except Exception as exc: ctx.log(f"[datadome] 预行为异常: {exc!r}") # 3) 用 frame_locator 拖拽(核心方法,能操作跨域 iframe) dragged = _datadome_drag_via_frame(ctx, page) if not dragged: # 4) 备用方案:基于 iframe bounding box 直接推算滑块位置拖拽 ctx.log("[datadome] frame_locator 失败,尝试基于 iframe 位置推算拖拽") info = _find_datadome_slider(page) if not info or not info.get("iframe"): ctx.log("[datadome] 未找到 DataDome iframe,降级等待手动") return False iframe = info["iframe"] # DataDome 滑块 UI:iframe 中部偏下,滑块按钮从左端拖到右端 # 典型布局:图标栏(~30px) → 文字(~20px) → slider track(中间区域) # 滑块按钮起始在 track 左端 slider_y = iframe["y"] + iframe["height"] * random.uniform(0.55, 0.65) start_x = iframe["x"] + random.uniform(20, 40) end_x = iframe["x"] + iframe["width"] - random.uniform(15, 30) ctx.log(f"[datadome] 推算拖拽 ({start_x:.0f},{slider_y:.0f}) → ({end_x:.0f},{slider_y:.0f})") # 生成人类轨迹 steps = random.randint(35, 55) points = _human_bezier_points(start_x, slider_y, end_x, slider_y + random.uniform(-2, 2), steps=steps) # 移到附近 try: approach_pts = _human_bezier_points( start_x - random.uniform(100, 200), slider_y + random.uniform(-50, 50), start_x, slider_y, steps=random.randint(10, 18), ) for px, py in approach_pts: page.mouse.move(px, py) page.wait_for_timeout(random.randint(10, 25)) page.wait_for_timeout(random.randint(300, 700)) page.mouse.down() page.wait_for_timeout(random.randint(80, 200)) # 变速拖拽:慢→快→慢 overshot_x = end_x + random.uniform(5, 20) points_os = _human_bezier_points(start_x, slider_y, overshot_x, slider_y + random.uniform(-3, 3), steps=random.randint(40, 60)) for i, (px, py) in enumerate(points_os): page.mouse.move(px, py) progress = i / max(len(points_os) - 1, 1) if progress < 0.15: delay = random.randint(18, 35) elif progress < 0.7: delay = random.randint(4, 12) else: delay = random.randint(12, 28) page.wait_for_timeout(delay) # overshot 回弹 page.wait_for_timeout(random.randint(60, 150)) for i in range(random.randint(2, 5)): t = (i + 1) / 5 cx = overshot_x + (end_x - overshot_x) * t page.mouse.move(cx, slider_y + random.gauss(0, 0.5)) page.wait_for_timeout(random.randint(15, 30)) page.wait_for_timeout(random.randint(150, 400)) page.mouse.up() ctx.log("[datadome] 推算拖拽完成") except Exception as exc: ctx.log(f"[datadome] 推算拖拽异常: {exc!r}") # 5) 等待验证结果 page.wait_for_timeout(5000) if not _detect_datadome_captcha(page): ctx.log("[datadome] 自动拖拽通过验证!") _save_datadome_cookie(ctx, page) return True ctx.log(f"[datadome] 第 {attempt} 次拖拽未通过") # 重试前等待更久,让 DataDome 状态重置 page.wait_for_timeout(random.randint(2000, 4000)) ctx.log(f"[datadome] {max_attempts} 次自动拖拽均未通过,降级为手动") return False def _save_datadome_cookie(ctx, page): """滑块通过后提取 datadome cookie 并保存到文件。""" try: cookies = page.context.cookies() dd_cookies = [c for c in cookies if "datadome" in c.get("name", "").lower()] if not dd_cookies: # 也从 document.cookie 中提取 raw = page.evaluate("() => document.cookie") or "" for part in raw.split(";"): kv = part.strip() if kv.lower().startswith("datadome="): dd_cookies.append({ "name": "datadome", "value": kv.split("=", 1)[1], "domain": ".paypal.com", "path": "/", }) break if not dd_cookies: ctx.log("[datadome] 未找到 datadome cookie 可保存") return payload = { "cookies": dd_cookies, "saved_at": time.time(), "url": page.url, } with open(DATADOME_COOKIE_FILE, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) ctx.log(f"[datadome] 已保存 {len(dd_cookies)} 个 cookie 到 {DATADOME_COOKIE_FILE}") except Exception as exc: ctx.log(f"[datadome] 保存 cookie 异常: {exc!r}") def _inject_datadome_cookie(ctx, page): """从文件读取之前保存的 datadome cookie 并注入到当前浏览器 context。""" if not os.path.exists(DATADOME_COOKIE_FILE): ctx.log("[datadome] 无已保存的 cookie 文件") return try: with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f: payload = json.load(f) cookies = payload.get("cookies", []) if not cookies: ctx.log("[datadome] cookie 文件为空") return saved_at = payload.get("saved_at", 0) age_hours = (time.time() - saved_at) / 3600 if age_hours > 24: ctx.log(f"[datadome] cookie 已过期 {age_hours:.1f}h(>24h),跳过注入") return ctx.log(f"[datadome] 注入已保存的 cookie({age_hours:.1f}h 前,{len(cookies)} 个)") for c in cookies: c.setdefault("path", "/") if "domain" not in c: c["domain"] = ".paypal.com" page.context.add_cookies(cookies) ctx.log("[datadome] cookie 注入完成") except Exception as exc: ctx.log(f"[datadome] 注入 cookie 异常: {exc!r}") def _inject_datadome_cookie_at_startup(ctx, browser_context): """浏览器 context 创建后立即注入 datadome cookie(无需 page 对象)。 使后续所有页面访问(包括首次打开 PayPal)都携带 datadome cookie, 实现跨进程、跨浏览器实例的 cookie 复用。 """ if not os.path.exists(DATADOME_COOKIE_FILE): return try: with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f: payload = json.load(f) cookies = payload.get("cookies", []) if not cookies: return saved_at = payload.get("saved_at", 0) age_hours = (time.time() - saved_at) / 3600 if age_hours > 24: ctx.log(f"[datadome] 启动注入跳过:cookie 已过期 {age_hours:.1f}h") return for c in cookies: c.setdefault("path", "/") if "domain" not in c: c["domain"] = ".paypal.com" browser_context.add_cookies(cookies) ctx.log(f"[datadome] 启动注入 {len(cookies)} 个 cookie({age_hours:.1f}h 前保存)") except Exception as exc: ctx.log(f"[datadome] 启动注入异常: {exc!r}") 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 滑块,失败后降级为手动等待。 策略: 1) 先注入已保存的 datadome cookie → 如已通过则直接返回 2) 自动模拟人类拖拽滑块(最多 3 次) 3) 均失败 → 降级为手动等待(原有逻辑) """ # 阶段一:自动尝试 if _auto_solve_datadome(ctx, page, max_attempts=3): return True # 阶段二:降级为手动等待 ctx.set_stage("⚠️ 自动过滑块失败,请在浏览器中手动滑动完成") deadline = time.time() + max_wait_sec last_log = 0.0 poll_interval = 0.5 stable_needed = 2 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 已通过(手动),保存 cookie 并继续") page.wait_for_timeout(1000) _save_datadome_cookie(ctx, page) 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 滑块校验超时未通过") # DataDome 检查点通过后,清理残留的 captcha 容器,避免挡住 /pay 邮箱和 Next。 _paypal_remove_captcha_overlay(ctx, page) ctx.log("[paypal] 保留当前 PayPal 会话,继续后续流程") # 强制走"创建账号"路径,避免点上方 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") # 进 /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 滑块校验超时未通过") # PayPal 可能在跳转业务页后留下空 captcha 容器,填表前再清一次。 _paypal_remove_captcha_overlay(ctx, page) 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: 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', ctx.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(保留 datadome)") try: before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length") page.evaluate(r"""() => { 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; // 保留 datadome cookie if (name.toLowerCase() === 'datadome') 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}(保留 datadome)") 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, sms_api_url=ctx.sms_api_url) 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 _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 "" combined = f"{alerts} {text}" m = _PAYMENT_FAILED_TEXT_RE.search(combined) if m: start = max(0, m.start() - 40) end = min(len(combined), m.end() + 80) return combined[start:end].strip()[:300] 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=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}") 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, timeout=4000) elif kind == "label": loc.select_option(label=text, timeout=4000) else: loc.select_option(text, timeout=4000) 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"