| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352 |
- """端到端自动化:拿 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 = "<unknown>"
- ctx.log(f"[dump:{tag}] URL={url}")
- try:
- title = page.title()
- ctx.log(f"[dump:{tag}] title={title!r}")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] 取标题失败: {exc!r}")
- base = os.path.join(ctx.artifact_dir, f"{int(time.time()*1000)}-{tag}")
- try:
- page.screenshot(path=base + ".png", full_page=True)
- ctx.log(f"[dump:{tag}] 截图 -> {base}.png")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] 截图失败: {exc!r}")
- try:
- html = page.content()
- with open(base + ".html", "w", encoding="utf-8") as f:
- f.write(html)
- ctx.log(f"[dump:{tag}] HTML -> {base}.html ({len(html)} bytes)")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
- def _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"
|