automation.py 98 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352
  1. """端到端自动化:拿 ChatGPT 长链 → Stripe 选 PayPal → PayPal 注册绑卡 → 等短信。"""
  2. from __future__ import annotations
  3. import json
  4. import math
  5. import os
  6. import random
  7. import re
  8. import time
  9. import traceback
  10. from dataclasses import dataclass, field
  11. from typing import Callable, Optional
  12. from providers import fetch_us_address, fetch_visa_card, fetch_sms_code
  13. try:
  14. from curl_cffi import requests as curl_requests
  15. except Exception:
  16. curl_requests = None
  17. CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
  18. PAYURL_CHECKOUT_URL = "https://payurl.ark2.cn/api/checkout"
  19. PROXY_FOR_LONGLINK = "http://127.0.0.1:7890"
  20. DEFAULT_PHONE_E164 = "+15822201173"
  21. DEFAULT_SMS_API_URL = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
  22. PHONE_E164 = DEFAULT_PHONE_E164
  23. PHONE_NUMBER = PHONE_E164.removeprefix("+1")
  24. PHONE_COUNTRY = "US"
  25. POST_PAYMENT_WAIT_TIMEOUT = 600
  26. POST_PAYMENT_WAIT_INTERVAL = 2
  27. POST_PAYMENT_MANUAL_INTERVAL = 5
  28. POST_SMS_PAYPAL_ACTION_TEXTS = (
  29. "Agree & Create Account",
  30. "Agree and Create Account",
  31. "Agree & Continue",
  32. "Agree and Continue",
  33. "同意并继续",
  34. )
  35. POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
  36. LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
  37. DATADOME_COOKIE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "datadome_cookie.json")
  38. _PAYMENT_COMPLETE_RE = re.compile(
  39. r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
  40. r"thank\s+you|you['’]?re\s+all\s+set|subscription\s+(active|started|complete)|welcome\s+to\s+chatgpt\s+plus|"
  41. r"支付成功|付款成功|订阅成功|已完成)",
  42. re.I,
  43. )
  44. _PAYMENT_BLOCKING_RE = re.compile(
  45. r"(validation|error|declined|failed|couldn['’]?t|cannot|try\s+again|invalid|"
  46. r"验证|错误|失败|无效|无法|重试)",
  47. re.I,
  48. )
  49. _PAYMENT_FAILED_URL_RE = re.compile(r"redirect_status=failed|payment[_-]?status=failed", re.I)
  50. _PAYMENT_FAILED_TEXT_RE = re.compile(
  51. r"an\s+error\s+occurred\s+while\s+processing\s+your\s+payment|"
  52. r"payment\s+failed|"
  53. r"try\s+again\s+later\s+or\s+with\s+a\s+different\s+payment\s+method|"
  54. r"card\s+(?:was\s+)?declined|"
  55. r"payment\s+method\s+(?:is\s+)?not\s+supported|"
  56. r"we\s+weren['’]?t\s+able\s+to\s+add\s+this\s+card|"
  57. r"unable\s+to\s+add\s+this\s+card|"
  58. r"check\s+all\s+the\s+details\s+are\s+correct\s+and\s+try\s+again|"
  59. r"try\s+a\s+different\s+card|"
  60. r"sorry,?\s+something\s+went\s+wrong\.?\s*please\s+try\s+again|"
  61. r"we\s+couldn['’]?t\s+process\s+(?:your\s+)?(?:payment|request)|"
  62. r"we\s+can'?t\s+continue\s+with\s+this\s+payment|"
  63. r"this\s+card\s+has\s+already\s+been\s+added\s+to\s+another\s+paypal\s+account|"
  64. r"create_card_account_candidate_validation_error|"
  65. r"unmapped[_\s-]?oas[_\s-]?error|"
  66. r"oas[_\s-]?error|"
  67. r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
  68. r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
  69. r"your\s+account\s+is\s+limited|"
  70. r"check\s+your\s+paypal\s+account\s+overview\s+page|"
  71. r"how\s+to\s+resolve\s+this\s+problem|"
  72. r"account\s+(?:has\s+been\s+)?(?:temporarily\s+)?(?:limited|restricted|suspended|locked)|"
  73. r"weasley_fallback_to_hermes|"
  74. r"r_error\b|\br[_\s-]?error\b|"
  75. r"账[户号]?(?:已)?受限|账[户号]?(?:已)?(?:被)?(?:冻结|限制|暂停)|"
  76. r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
  77. r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
  78. re.I,
  79. )
  80. class PayPalPaymentFailed(Exception):
  81. """PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
  82. class StripeNonFreeDetected(Exception):
  83. """Stripe 页面检测到金额非 $0,保留用于未来扩展。"""
  84. MAX_PAYPAL_RETRIES = 3
  85. def detect_stripe_amount(page, log: Callable[[str], None] = print) -> str:
  86. """在 Stripe checkout 页面检测最终付款金额,返回金额文本(如 "$0.00"、"$20.00")。
  87. 空字符串表示无法检测到。
  88. """
  89. try:
  90. amount = page.evaluate(r"""() => {
  91. // Stripe checkout 页面的金额通常在多个位置出现
  92. const selectors = [
  93. '[data-testid="hosted-payment-submit-button"]',
  94. '.SubmitButton-TextContainer',
  95. '[class*="OrderTotal"]',
  96. '[class*="total" i]',
  97. '[data-testid*="total" i]',
  98. '[class*="amount" i]',
  99. '[data-testid*="amount" i]',
  100. ];
  101. for (const sel of selectors) {
  102. const el = document.querySelector(sel);
  103. if (!el) continue;
  104. const text = (el.innerText || el.textContent || '').trim();
  105. // 匹配 $0.00 / $0 / ¥0 / €0 等货币金额
  106. const m = text.match(/[\$€£¥]\s*[\d,.]+/);
  107. if (m) return m[0];
  108. }
  109. // 兜底:扫全页面找金额
  110. const bodyText = (document.body && document.body.innerText || '');
  111. // 找 "Total" / "Amount due" / "Due today" 后面跟的金额
  112. const patterns = [
  113. /(?:total|amount\s+due|due\s+today|order\s+total)[:\s]*?[\$€£¥]\s*([\d,.]+)/i,
  114. /(?:pay|subscribe)[:\s]*?[\$€£¥]\s*([\d,.]+)/i,
  115. ];
  116. for (const re of patterns) {
  117. const m = bodyText.match(re);
  118. if (m) return '$' + m[1];
  119. }
  120. return '';
  121. }""")
  122. if amount:
  123. log(f"[stripe] 检测到付款金额: {amount}")
  124. return amount or ""
  125. except Exception as exc:
  126. log(f"[stripe] 金额检测失败: {exc!r}")
  127. return ""
  128. def is_zero_amount(amount_str: str) -> bool:
  129. """判断金额字符串是否为 0($0.00 / $0 / ¥0.00 等)。"""
  130. if not amount_str:
  131. return False
  132. import re
  133. m = re.search(r'[\d,.]+', amount_str)
  134. if not m:
  135. return False
  136. num_str = m.group().replace(',', '')
  137. try:
  138. return float(num_str) == 0.0
  139. except ValueError:
  140. return False
  141. def decide_paypal_flow_for_amount(amount_str: str, *, trial_eligible: bool | None) -> dict:
  142. """基于 Stripe 金额和试用资格,决定是否继续进入 PayPal 付款。"""
  143. if amount_str and is_zero_amount(amount_str):
  144. return {
  145. "mode": "free_trial",
  146. "continue_payment": True,
  147. "is_free_trial": True,
  148. }
  149. if amount_str:
  150. if trial_eligible is True:
  151. return {
  152. "mode": "paid_retry",
  153. "continue_payment": True,
  154. "is_free_trial": False,
  155. }
  156. return {
  157. "mode": "manual_payment_required",
  158. "continue_payment": False,
  159. "is_free_trial": False,
  160. }
  161. return {
  162. "mode": "unknown",
  163. "continue_payment": True,
  164. "is_free_trial": False,
  165. }
  166. def _set_trial_eligibility(ctx: "RunContext", eligible: bool):
  167. prev = getattr(ctx, "_trial_eligible", None)
  168. if prev is True and not eligible:
  169. ctx.log("[stripe] 已认定支持试用,忽略后续不支持试用的覆盖结果")
  170. return
  171. if prev is eligible:
  172. return
  173. ctx._trial_eligible = eligible
  174. ctx.log(f"[stripe] 试用资格已判定为: {'支持试用' if eligible else '不支持试用'}")
  175. hook = getattr(ctx, "_on_trial_eligibility_detected", None)
  176. if callable(hook):
  177. try:
  178. hook(eligible)
  179. except Exception as exc:
  180. ctx.log(f"[stripe] 持久化试用资格失败: {exc!r}")
  181. def _replace_browser_context(ctx, page):
  182. """彻底重新生成一个浏览器 context(PerimeterX 等基于 fingerprint 的反爬最有效的对策)。
  183. 注意:调用方持有的 page 引用不会被替换;这里只是把当前 context 关掉重开+加载空白页,
  184. 并复用同一个 browser。如果调用方传入的 page 已经死,会自然在下次 page.goto 时报错。
  185. """
  186. ctx.log("[playwright] 重建 browser context(清 PerimeterX 等指纹)")
  187. try:
  188. old_ctx = page.context
  189. browser = old_ctx.browser
  190. except Exception as exc:
  191. ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退普通清缓存")
  192. _clear_browser_state(ctx, page)
  193. return
  194. try:
  195. for p in list(old_ctx.pages):
  196. try:
  197. p.close()
  198. except Exception:
  199. pass
  200. old_ctx.close()
  201. ctx.log("[playwright] 旧 context 已关闭")
  202. except Exception as exc:
  203. ctx.log(f"[playwright] 关旧 context 失败: {exc!r}")
  204. # 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
  205. # 兜底:用 _clear_browser_state 当作降级
  206. try:
  207. from geo_fingerprint import detect_paypal_geo_fingerprint
  208. geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log)
  209. new_ctx = browser.new_context(
  210. locale=geo.locale,
  211. timezone_id=geo.timezone_id,
  212. viewport={"width": 1280, "height": 900},
  213. )
  214. # 重建 context 后也注入 datadome cookie
  215. _inject_datadome_cookie_at_startup(ctx, new_ctx)
  216. new_page = new_ctx.new_page()
  217. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  218. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  219. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  220. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  221. new_page.goto("about:blank", wait_until="domcontentloaded", timeout=10000)
  222. # 替换 ctx 上的 page 引用(chatgpt_flow 调 run_paypal_flow 时也是从 ctx._next_page 取)
  223. ctx._next_page = new_page # type: ignore
  224. ctx.log("[playwright] 新 context + 新 page 已就绪 (ctx._next_page)")
  225. except Exception as exc:
  226. ctx.log(f"[playwright] 新 context 创建失败: {exc!r}")
  227. def _clear_browser_state(ctx, page):
  228. """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。
  229. 保留 datadome cookie 以便下次复用。
  230. """
  231. ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
  232. # 先提取 datadome cookie,清除后恢复
  233. saved_dd = []
  234. try:
  235. bctx = page.context
  236. all_cookies = bctx.cookies()
  237. saved_dd = [c for c in all_cookies if "datadome" in c.get("name", "").lower()]
  238. except Exception:
  239. pass
  240. try:
  241. bctx = page.context
  242. bctx.clear_cookies()
  243. ctx.log("[playwright] context.clear_cookies() 完成")
  244. except Exception as exc:
  245. ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
  246. # 恢复 datadome cookie
  247. if saved_dd:
  248. try:
  249. page.context.add_cookies(saved_dd)
  250. ctx.log(f"[playwright] 恢复了 {len(saved_dd)} 个 datadome cookie")
  251. except Exception as exc:
  252. ctx.log(f"[playwright] 恢复 datadome cookie 失败: {exc!r}")
  253. try:
  254. bctx = page.context
  255. # 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
  256. page.evaluate(r"""() => {
  257. try { localStorage.clear(); } catch (_) {}
  258. try { sessionStorage.clear(); } catch (_) {}
  259. try {
  260. if (window.indexedDB && indexedDB.databases) {
  261. indexedDB.databases().then((dbs) => {
  262. (dbs || []).forEach((db) => {
  263. if (db && db.name) {
  264. try { indexedDB.deleteDatabase(db.name); } catch (_) {}
  265. }
  266. });
  267. });
  268. }
  269. } catch (_) {}
  270. try {
  271. if (window.caches && caches.keys) {
  272. caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
  273. }
  274. } catch (_) {}
  275. }""")
  276. ctx.log("[playwright] localStorage/sessionStorage/IndexedDB/caches 清理完成(当前页)")
  277. except Exception as exc:
  278. ctx.log(f"[playwright] 当前页 storage 清理失败: {exc!r}")
  279. # 关掉所有 PayPal/Stripe 旧标签,最后回到一个空白页
  280. try:
  281. for p in list(page.context.pages):
  282. try:
  283. if p is page:
  284. continue
  285. p.close()
  286. except Exception:
  287. pass
  288. page.goto("about:blank", wait_until="domcontentloaded", timeout=15000)
  289. except Exception as exc:
  290. ctx.log(f"[playwright] 关旧 tab/转空白页 失败: {exc!r}")
  291. @dataclass
  292. class RunContext:
  293. token: str
  294. plan: str = "plus"
  295. country: str = "US"
  296. currency: str = "USD"
  297. use_promo: bool = True
  298. headless: bool = False
  299. log: Callable[[str], None] = print
  300. state: str = "running"
  301. stage: str = ""
  302. on_stage: Optional[Callable[[str], None]] = None
  303. long_link: str = ""
  304. email: str = "" # ChatGPT 注册邮箱(@edu.a4sky.com)
  305. paypal_email: str = "" # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
  306. password: str = ""
  307. phone_e164: str = DEFAULT_PHONE_E164
  308. sms_api_url: str = DEFAULT_SMS_API_URL
  309. paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
  310. long_link_mode: str = "payurl" # "payurl" 或 "local"
  311. long_link_proxy: str = "" # local 模式的代理
  312. card: dict = field(default_factory=dict)
  313. address: dict = field(default_factory=dict)
  314. run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
  315. @property
  316. def phone_number(self) -> str:
  317. """无国家码的手机号(只去掉前导 +1,对应 PayPal #phone 字段格式)。"""
  318. e164 = (self.phone_e164 or DEFAULT_PHONE_E164).strip()
  319. return e164.removeprefix("+1") if e164.startswith("+1") else e164.lstrip("+")
  320. @property
  321. def artifact_dir(self) -> str:
  322. d = os.path.join(LOG_DIR, self.run_id)
  323. os.makedirs(d, exist_ok=True)
  324. return d
  325. def set_stage(self, name: str):
  326. self.stage = name
  327. self.log(f"[stage] {name}")
  328. if self.on_stage:
  329. try:
  330. self.on_stage(name)
  331. except Exception:
  332. pass
  333. def _request_headers(token: str) -> dict:
  334. return {
  335. "Authorization": f"Bearer {token}",
  336. "Content-Type": "application/json",
  337. "Accept": "application/json",
  338. "Origin": "https://chatgpt.com",
  339. "Referer": "https://chatgpt.com/",
  340. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  341. "User-Agent": (
  342. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  343. "AppleWebKit/537.36 (KHTML, like Gecko) "
  344. "Chrome/136.0.0.0 Safari/537.36"
  345. ),
  346. }
  347. def _checkout_payload(plan: str, country: str, currency: str, use_promo: bool) -> dict:
  348. payload = {
  349. "plan_name": "chatgptplusplan" if plan == "plus" else "chatgptteamplan",
  350. "billing_details": {"country": country.upper(), "currency": currency.upper()},
  351. "checkout_ui_mode": "hosted",
  352. "cancel_url": "https://chatgpt.com/#pricing",
  353. }
  354. if use_promo and plan == "plus":
  355. payload["promo_campaign"] = {
  356. "promo_campaign_id": "plus-1-month-free",
  357. "is_coupon_from_query_param": True,
  358. }
  359. return payload
  360. def generate_long_link(ctx: RunContext) -> str:
  361. ctx.log(f"[longlink] === 开始生成长链 ===")
  362. ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
  363. ctx.log(f"[longlink] 代理={PROXY_FOR_LONGLINK}")
  364. payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
  365. ctx.log(f"[longlink] payload={json.dumps(payload, ensure_ascii=False)}")
  366. if curl_requests is None:
  367. raise RuntimeError("缺少 curl_cffi,请先 pip install curl_cffi")
  368. proxies = {"http": PROXY_FOR_LONGLINK, "https": PROXY_FOR_LONGLINK}
  369. started = time.time()
  370. try:
  371. response = curl_requests.post(
  372. CHECKOUT_URL,
  373. json=payload,
  374. headers=_request_headers(ctx.token),
  375. impersonate="chrome136",
  376. proxies=proxies,
  377. timeout=30,
  378. )
  379. except Exception as exc:
  380. ctx.log(f"[longlink] 请求异常({int((time.time()-started)*1000)}ms): {exc!r}")
  381. raise
  382. text = response.text
  383. ctx.log(f"[longlink] HTTP {response.status_code} 耗时 {int((time.time()-started)*1000)}ms 返回长度={len(text)}")
  384. ctx.log(f"[longlink] 响应预览: {text[:500]}")
  385. if response.status_code >= 400:
  386. raise RuntimeError(f"创建 checkout 失败: HTTP {response.status_code} {text[:300]}")
  387. try:
  388. data = json.loads(text or "{}")
  389. except json.JSONDecodeError as exc:
  390. raise RuntimeError(f"长链响应不是 JSON: {exc!r} 原文={text[:300]}")
  391. link = data.get("url") or data.get("stripe_hosted_url") or data.get("checkout_url")
  392. session_id = data.get("checkout_session_id")
  393. processor = data.get("processor_entity")
  394. ctx.log(f"[longlink] checkout_session_id={session_id} processor={processor}")
  395. if not link:
  396. raise RuntimeError(f"未在响应中解析到长链: {text[:300]}")
  397. ctx.log(f"[longlink] 成功取到 long_link={link}")
  398. return link
  399. def generate_long_link_payurl(ctx: RunContext) -> str:
  400. """走 payurl.ark2.cn(与 Chrome 扩展 get-plus-link.js 一致),不走本地代理。"""
  401. ctx.log("[longlink] === 通过 payurl.ark2.cn 获取 Plus 长链 ===")
  402. ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
  403. payload = {
  404. "token": ctx.token,
  405. "plan": ctx.plan or "plus",
  406. "checkout_ui_mode": "hosted",
  407. "ui_language": "en",
  408. "country": (ctx.country or "US").upper(),
  409. "currency": (ctx.currency or "USD").upper(),
  410. "proxy": "",
  411. "use_promo": bool(ctx.use_promo),
  412. "promo_code": "STRIPEATLASGPT4BIZ050126",
  413. "workspace_name": "linux-do",
  414. "seat_quantity": 2,
  415. }
  416. headers = {
  417. "Accept": "*/*",
  418. "Accept-Language": "zh-CN,zh;q=0.9",
  419. "Content-Type": "application/json",
  420. "DNT": "1",
  421. "Origin": "https://payurl.ark2.cn",
  422. "Referer": "https://payurl.ark2.cn/",
  423. }
  424. masked = dict(payload)
  425. masked["token"] = (ctx.token or "")[:24] + "..."
  426. ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
  427. # 长链生成走全局代理(payurl.ark2.cn 自身不一定需要,但保持与浏览器同源更稳)
  428. proxies = None
  429. proxy_url = getattr(ctx, "paypal_proxy", "") or ""
  430. if proxy_url:
  431. proxies = {"http": proxy_url, "https": proxy_url}
  432. ctx.log(f"[longlink] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
  433. max_attempts = 5
  434. last_err = ""
  435. for attempt in range(1, max_attempts + 1):
  436. ctx.log(f"[longlink] 第 {attempt}/{max_attempts} 次请求 {PAYURL_CHECKOUT_URL}")
  437. started = time.time()
  438. try:
  439. if curl_requests is not None:
  440. r = curl_requests.post(
  441. PAYURL_CHECKOUT_URL,
  442. json=payload,
  443. headers=headers,
  444. impersonate="chrome136",
  445. timeout=30,
  446. proxies=proxies,
  447. )
  448. text = r.text
  449. status = r.status_code
  450. else:
  451. import urllib.request
  452. data = json.dumps(payload).encode("utf-8")
  453. req = urllib.request.Request(PAYURL_CHECKOUT_URL, data=data, method="POST")
  454. for k, v in headers.items():
  455. req.add_header(k, v)
  456. opener = urllib.request.build_opener()
  457. if proxy_url:
  458. proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
  459. opener = urllib.request.build_opener(proxy_handler)
  460. with opener.open(req, timeout=30) as resp:
  461. text = resp.read().decode("utf-8", errors="replace")
  462. status = resp.status
  463. except Exception as exc:
  464. last_err = repr(exc)
  465. ctx.log(f"[longlink] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
  466. time.sleep(1.5)
  467. continue
  468. ctx.log(f"[longlink] HTTP {status} 耗时 {int((time.time()-started)*1000)}ms 长度={len(text)} 预览={text[:300]}")
  469. if status >= 400:
  470. last_err = f"HTTP {status}: {text[:300]}"
  471. time.sleep(1.5)
  472. continue
  473. try:
  474. data = json.loads(text or "{}")
  475. except Exception as exc:
  476. last_err = f"非 JSON: {exc!r}"
  477. time.sleep(1.5)
  478. continue
  479. link = data.get("url") or data.get("openai_payurl") or data.get("chatgpt_checkout_url")
  480. if link:
  481. ctx.log(f"[longlink] 成功取到 long_link={link} sessionId={data.get('checkout_session_id', '?')}")
  482. return link
  483. last_err = f"响应缺 url 字段: {text[:300]}"
  484. time.sleep(1.5)
  485. raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
  486. def generate_long_link_local(ctx: RunContext, proxy: str = "") -> str:
  487. """本地直连 chatgpt.com/backend-api/payments/checkout 生成长链,不走第三方中转。"""
  488. ctx.log("[longlink-local] === 本地直连 ChatGPT checkout API ===")
  489. ctx.log(f"[longlink-local] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
  490. payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
  491. headers = _request_headers(ctx.token)
  492. ctx.log(f"[longlink-local] payload={json.dumps(payload, ensure_ascii=False)}")
  493. proxy_url = (proxy or "").strip()
  494. if proxy_url and "://" not in proxy_url:
  495. proxy_url = "http://" + proxy_url
  496. if proxy_url:
  497. masked = proxy_url.split("@")[-1] if "@" in proxy_url else proxy_url
  498. ctx.log(f"[longlink-local] 使用代理: {masked}")
  499. max_attempts = 5
  500. last_err = ""
  501. for attempt in range(1, max_attempts + 1):
  502. ctx.log(f"[longlink-local] 第 {attempt}/{max_attempts} 次请求 {CHECKOUT_URL}")
  503. started = time.time()
  504. try:
  505. if curl_requests is not None:
  506. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  507. r = curl_requests.post(
  508. CHECKOUT_URL,
  509. json=payload,
  510. headers=headers,
  511. impersonate="chrome136",
  512. proxies=proxies,
  513. timeout=30,
  514. )
  515. text = r.text
  516. status = r.status_code
  517. else:
  518. import urllib.request
  519. data = json.dumps(payload).encode("utf-8")
  520. req = urllib.request.Request(CHECKOUT_URL, data=data, method="POST")
  521. for k, v in headers.items():
  522. req.add_header(k, v)
  523. opener = urllib.request.build_opener()
  524. if proxy_url:
  525. proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
  526. opener = urllib.request.build_opener(proxy_handler)
  527. with opener.open(req, timeout=30) as resp:
  528. text = resp.read().decode("utf-8", errors="replace")
  529. status = resp.status
  530. except Exception as exc:
  531. last_err = repr(exc)
  532. ctx.log(f"[longlink-local] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
  533. time.sleep(1.5)
  534. continue
  535. elapsed = int((time.time() - started) * 1000)
  536. ctx.log(f"[longlink-local] HTTP {status} 耗时 {elapsed}ms 长度={len(text)} 预览={text[:300]}")
  537. if status >= 400:
  538. last_err = f"HTTP {status}: {text[:300]}"
  539. time.sleep(1.5)
  540. continue
  541. try:
  542. result = json.loads(text or "{}")
  543. except Exception as exc:
  544. last_err = f"非 JSON: {exc!r}"
  545. time.sleep(1.5)
  546. continue
  547. link = result.get("url") or result.get("stripe_hosted_url") or result.get("checkout_url")
  548. session_id = result.get("checkout_session_id")
  549. processor = result.get("processor_entity")
  550. if not link and session_id and processor:
  551. link = f"https://chatgpt.com/checkout/{processor}/{session_id}"
  552. ctx.log(f"[longlink-local] 无直接 url 字段,由 session_id 构造: {link}")
  553. if link:
  554. ctx.log(f"[longlink-local] 成功 long_link={link} session_id={session_id}")
  555. return link
  556. last_err = f"响应缺 url 字段: {text[:300]}"
  557. time.sleep(1.5)
  558. raise RuntimeError(f"本地长链获取连续失败 {max_attempts} 次:{last_err}")
  559. def _rand_email() -> str:
  560. import random
  561. import string
  562. name = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
  563. return f"{name}@gmail.com"
  564. def _rand_paypal_email() -> str:
  565. """PayPal 注册用的独立邮箱(与 ChatGPT 注册邮箱解耦)。
  566. 用 gmail 域,一来 PayPal 对 gmail 的接受度高,二来不与 a4sky 邮件助手挂钩。
  567. """
  568. import random
  569. import string
  570. # 8-12 位随机字母数字 + 点号风格更像真人
  571. body_len = random.randint(8, 12)
  572. body = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(body_len))
  573. # 加一个随机数字尾巴
  574. tail = "".join(random.choice(string.digits) for _ in range(random.randint(2, 4)))
  575. return f"{body}{tail}@gmail.com"
  576. def _rand_password() -> str:
  577. import random
  578. import string
  579. pools = [
  580. random.choice(string.ascii_uppercase),
  581. random.choice(string.ascii_lowercase),
  582. random.choice(string.digits),
  583. random.choice("!@#$%^"),
  584. ]
  585. pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^") for _ in range(10)]
  586. random.shuffle(pools)
  587. return "".join(pools)
  588. def _check_stop(ctx: RunContext):
  589. if ctx.state == "stopped":
  590. raise RuntimeError("STOPPED_BY_USER")
  591. # 让上层(chatgpt_flow.FullRunContext)注入的 hook 也能短路停止
  592. hook = getattr(ctx, "_stop_hook", None)
  593. if callable(hook):
  594. try:
  595. hook()
  596. except Exception:
  597. raise
  598. def _dump_page(ctx: RunContext, page, tag: str):
  599. """失败/检查点时把当前页 URL/截图/HTML 都落盘。"""
  600. try:
  601. url = page.url
  602. except Exception:
  603. url = "<unknown>"
  604. ctx.log(f"[dump:{tag}] URL={url}")
  605. try:
  606. title = page.title()
  607. ctx.log(f"[dump:{tag}] title={title!r}")
  608. except Exception as exc:
  609. ctx.log(f"[dump:{tag}] 取标题失败: {exc!r}")
  610. base = os.path.join(ctx.artifact_dir, f"{int(time.time()*1000)}-{tag}")
  611. try:
  612. page.screenshot(path=base + ".png", full_page=True)
  613. ctx.log(f"[dump:{tag}] 截图 -> {base}.png")
  614. except Exception as exc:
  615. ctx.log(f"[dump:{tag}] 截图失败: {exc!r}")
  616. try:
  617. html = page.content()
  618. with open(base + ".html", "w", encoding="utf-8") as f:
  619. f.write(html)
  620. ctx.log(f"[dump:{tag}] HTML -> {base}.html ({len(html)} bytes)")
  621. except Exception as exc:
  622. ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
  623. def _parse_proxy_url(proxy_url: str) -> dict | None:
  624. """把 http://user:pass@host:port 解析成 Playwright proxy 配置。空串返回 None。"""
  625. if not proxy_url or not proxy_url.strip():
  626. return None
  627. from urllib.parse import urlparse
  628. p = urlparse(proxy_url.strip())
  629. if not p.hostname or not p.port:
  630. return None
  631. server = f"{p.scheme or 'http'}://{p.hostname}:{p.port}"
  632. out: dict = {"server": server}
  633. if p.username:
  634. out["username"] = p.username
  635. if p.password:
  636. out["password"] = p.password
  637. return out
  638. def _open_paypal_context(ctx, page):
  639. """为 PayPal 阶段开一个带代理的新 context,返回 (new_page, old_context)。
  640. 若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
  641. 就返回 (page, None) 表示沿用当前 page。
  642. """
  643. paypal_proxy_url = (getattr(ctx, "paypal_proxy", "") or "").strip()
  644. proxy_cfg = _parse_proxy_url(paypal_proxy_url)
  645. if not proxy_cfg:
  646. ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
  647. return page, None
  648. # 若当前 context 已带相同代理,无需再切
  649. try:
  650. current_proxy = page.context._impl_obj._initializer.get("proxy") if hasattr(page.context, "_impl_obj") else None
  651. except Exception:
  652. current_proxy = None
  653. if current_proxy and isinstance(current_proxy, dict) and current_proxy.get("server") == proxy_cfg.get("server"):
  654. ctx.log(f"[playwright] 当前 context 已使用代理 {proxy_cfg.get('server')},PayPal 阶段沿用")
  655. return page, None
  656. masked = dict(proxy_cfg)
  657. if masked.get("password"):
  658. masked["password"] = "***"
  659. ctx.log(f"[playwright] PayPal 阶段使用代理: {masked}")
  660. try:
  661. browser = page.context.browser
  662. except Exception as exc:
  663. ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退直连")
  664. return page, None
  665. try:
  666. old_ctx = page.context
  667. from geo_fingerprint import detect_paypal_geo_fingerprint
  668. geo = detect_paypal_geo_fingerprint(paypal_proxy_url, log=ctx.log)
  669. new_ctx = browser.new_context(
  670. locale=geo.locale,
  671. timezone_id=geo.timezone_id,
  672. viewport={"width": 1280, "height": 900},
  673. proxy=proxy_cfg,
  674. )
  675. new_page = new_ctx.new_page()
  676. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  677. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  678. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  679. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  680. ctx.log("[playwright] PayPal 代理 context 已就绪")
  681. return new_page, old_ctx
  682. except Exception as exc:
  683. ctx.log(f"[playwright] 新建代理 context 失败: {exc!r},回退直连")
  684. return page, None
  685. def _close_paypal_context(ctx, paypal_page, old_ctx):
  686. """关掉 PayPal 阶段的代理 context(如果有),返回原始 page 用于后续拉 session。"""
  687. if old_ctx is None:
  688. return paypal_page # 没切代理,直接返回
  689. try:
  690. new_ctx = paypal_page.context
  691. for p in list(new_ctx.pages):
  692. try:
  693. p.close()
  694. except Exception:
  695. pass
  696. new_ctx.close()
  697. ctx.log("[playwright] PayPal 代理 context 已关闭")
  698. except Exception as exc:
  699. ctx.log(f"[playwright] 关闭代理 context 异常: {exc!r}")
  700. # 从老 context 找一个还活着的 page,没有就新开一个
  701. try:
  702. live_pages = [p for p in old_ctx.pages if not p.is_closed()]
  703. if live_pages:
  704. return live_pages[0]
  705. return old_ctx.new_page()
  706. except Exception as exc:
  707. ctx.log(f"[playwright] 切回老 context 异常: {exc!r}")
  708. return paypal_page
  709. def run_paypal_flow(ctx: RunContext, page=None):
  710. """跑 Stripe → PayPal。这一段不走代理。
  711. page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
  712. 支付失败会清缓存+重开长链最多重试 MAX_PAYPAL_RETRIES 次。
  713. """
  714. if page is None:
  715. return _run_paypal_flow_self_browser(ctx)
  716. ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
  717. ctx.set_stage("准备地址/卡/账号")
  718. ctx.address = fetch_us_address(log=ctx.log)
  719. ctx.card = fetch_visa_card(log=ctx.log)
  720. reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
  721. if reuse and ctx.email and ctx.password:
  722. ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
  723. else:
  724. ctx.email = _rand_email()
  725. ctx.password = _rand_password()
  726. ctx.log(f"[playwright] email={ctx.email}")
  727. ctx.log(f"[playwright] password={ctx.password}")
  728. ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
  729. ctx.log(f"[playwright] address={ctx.address}")
  730. ctx.log(f"[playwright] phone={ctx.phone_e164}")
  731. # 切到 PayPal 阶段的代理 context(如果配置了 paypal_proxy)
  732. paypal_page, old_ctx = _open_paypal_context(ctx, page)
  733. ctx.log("[playwright] 复用外部浏览器 page 跑 Stripe/PayPal")
  734. last_err: Exception | None = None
  735. try:
  736. for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
  737. ctx.paypal_email = _rand_paypal_email()
  738. ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
  739. try:
  740. ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
  741. ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
  742. paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
  743. paypal_page.wait_for_timeout(2000)
  744. _dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
  745. amount = detect_stripe_amount(paypal_page, log=ctx.log)
  746. decision = decide_paypal_flow_for_amount(
  747. amount,
  748. trial_eligible=getattr(ctx, "_trial_eligible", None),
  749. )
  750. if amount and decision["mode"] == "free_trial":
  751. ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款")
  752. _set_trial_eligibility(ctx, True)
  753. ctx._is_free_trial = True
  754. elif amount and decision["mode"] == "paid_retry":
  755. ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款")
  756. elif amount and decision["mode"] == "manual_payment_required":
  757. ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理")
  758. _set_trial_eligibility(ctx, False)
  759. raise StripeNonFreeDetected(f"金额为 {amount}")
  760. _stripe_select_paypal_and_submit(ctx, paypal_page)
  761. _paypal_signup_and_pay(ctx, paypal_page)
  762. ctx.log("[playwright] 流程完成")
  763. _dump_page(ctx, paypal_page, "99-final")
  764. return
  765. except PayPalPaymentFailed as exc:
  766. last_err = exc
  767. ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
  768. if attempt >= MAX_PAYPAL_RETRIES:
  769. ctx.log(f"[playwright] 已达最大重试次数 {MAX_PAYPAL_RETRIES},放弃")
  770. raise
  771. ctx.set_stage(f"支付失败,准备第 {attempt + 1} 次重试(清缓存+刷新长链)")
  772. try:
  773. _clear_browser_state(ctx, paypal_page)
  774. except Exception as exc2:
  775. ctx.log(f"[playwright] 清缓存异常(继续): {exc2!r}")
  776. try:
  777. ctx.address = fetch_us_address(log=ctx.log)
  778. ctx.card = fetch_visa_card(log=ctx.log)
  779. ctx.log(f"[playwright] 重试用新地址={ctx.address} 新卡尾号=***{ctx.card['number'][-4:]}")
  780. except Exception as exc2:
  781. ctx.log(f"[playwright] 重新拉取地址/卡片异常(继续): {exc2!r}")
  782. # 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
  783. # 表单会变 disabled。必须重新生成一个新的长链。
  784. try:
  785. if ctx.long_link_mode == "local":
  786. new_link = generate_long_link_local(ctx, proxy=ctx.long_link_proxy)
  787. else:
  788. new_link = generate_long_link_payurl(ctx)
  789. ctx.long_link = new_link
  790. ctx.log(f"[playwright] 重试用新长链(mode={ctx.long_link_mode}) sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
  791. except Exception as exc2:
  792. ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
  793. ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
  794. time.sleep(30)
  795. continue
  796. except Exception as exc:
  797. ctx.log(f"[playwright] 流程异常: {exc!r}")
  798. ctx.log(traceback.format_exc())
  799. try:
  800. _dump_page(ctx, paypal_page, f"99-error-attempt{attempt}")
  801. except Exception:
  802. pass
  803. raise
  804. if last_err is not None:
  805. raise last_err
  806. finally:
  807. # 不论成功失败,都尝试关掉 PayPal 代理 context、切回原 page,便于上层继续拉 session
  808. try:
  809. ctx._post_paypal_page = _close_paypal_context(ctx, paypal_page, old_ctx) # type: ignore
  810. except Exception as exc:
  811. ctx.log(f"[playwright] 关闭 PayPal context 异常: {exc!r}")
  812. def _run_paypal_flow_self_browser(ctx: RunContext):
  813. """旧入口:自己开浏览器(保留兼容性)。"""
  814. from patchright.sync_api import sync_playwright
  815. ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
  816. ctx.set_stage("准备地址/卡/账号")
  817. ctx.address = fetch_us_address(log=ctx.log)
  818. ctx.card = fetch_visa_card(log=ctx.log)
  819. reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
  820. if reuse and ctx.email and ctx.password:
  821. ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
  822. else:
  823. ctx.email = _rand_email()
  824. ctx.password = _rand_password()
  825. ctx.log(f"[playwright] email={ctx.email}")
  826. ctx.log(f"[playwright] password={ctx.password}")
  827. ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
  828. ctx.log(f"[playwright] address={ctx.address}")
  829. ctx.log(f"[playwright] phone={ctx.phone_e164}")
  830. with sync_playwright() as p:
  831. ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
  832. from geo_fingerprint import detect_paypal_geo_fingerprint
  833. geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log)
  834. browser = p.chromium.launch(
  835. headless=ctx.headless,
  836. args=["--disable-blink-features=AutomationControlled"],
  837. )
  838. context = browser.new_context(
  839. locale=geo.locale,
  840. timezone_id=geo.timezone_id,
  841. viewport={"width": 1280, "height": 900},
  842. )
  843. # 启动时注入已保存的 datadome cookie,减少后续触发验证码的概率
  844. _inject_datadome_cookie_at_startup(ctx, context)
  845. new_page = context.new_page()
  846. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  847. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  848. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  849. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  850. try:
  851. for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
  852. ctx.paypal_email = _rand_paypal_email()
  853. ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}")
  854. try:
  855. ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
  856. new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
  857. new_page.wait_for_timeout(2000)
  858. _dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
  859. amount = detect_stripe_amount(new_page, log=ctx.log)
  860. decision = decide_paypal_flow_for_amount(
  861. amount,
  862. trial_eligible=getattr(ctx, "_trial_eligible", None),
  863. )
  864. if amount and decision["mode"] == "free_trial":
  865. ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款")
  866. _set_trial_eligibility(ctx, True)
  867. ctx._is_free_trial = True
  868. elif amount and decision["mode"] == "paid_retry":
  869. ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款")
  870. elif amount and decision["mode"] == "manual_payment_required":
  871. ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理")
  872. _set_trial_eligibility(ctx, False)
  873. raise StripeNonFreeDetected(f"金额为 {amount}")
  874. elif amount:
  875. ctx.log(f"[stripe] 金额为 {amount}(正常付费)")
  876. _stripe_select_paypal_and_submit(ctx, new_page)
  877. _paypal_signup_and_pay(ctx, new_page)
  878. ctx.log("[playwright] 流程完成")
  879. _dump_page(ctx, new_page, "99-final")
  880. return
  881. except PayPalPaymentFailed as exc:
  882. ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
  883. if attempt >= MAX_PAYPAL_RETRIES:
  884. raise
  885. _clear_browser_state(ctx, new_page)
  886. try:
  887. ctx.address = fetch_us_address(log=ctx.log)
  888. ctx.card = fetch_visa_card(log=ctx.log)
  889. except Exception as exc2:
  890. ctx.log(f"[playwright] 重抽地址/卡异常(继续): {exc2!r}")
  891. ctx.log("[playwright] 重试前等待 30s")
  892. time.sleep(30)
  893. except Exception as exc:
  894. ctx.log(f"[playwright] 流程异常: {exc!r}")
  895. ctx.log(traceback.format_exc())
  896. try:
  897. _dump_page(ctx, new_page, "99-error")
  898. except Exception:
  899. pass
  900. raise
  901. finally:
  902. try:
  903. browser.close()
  904. except Exception:
  905. pass
  906. def _stripe_select_paypal(ctx, page) -> bool:
  907. """多策略尝试选中 PayPal 支付方式。命中返回 True。"""
  908. # 1) 先等待至少一种 PayPal 候选出现,避免 timing 问题
  909. try:
  910. page.wait_for_function(
  911. r"""() => {
  912. const sels = [
  913. '#payment-method-accordion-item-title-paypal',
  914. 'input[type="radio"][value="paypal"]',
  915. 'input[name="paymentMethod"][value="paypal"]',
  916. '[data-testid*="paypal" i]',
  917. 'label[for*="paypal" i]',
  918. 'button[id*="paypal" i]',
  919. ];
  920. for (const s of sels) {
  921. if (document.querySelector(s)) return true;
  922. }
  923. const labels = Array.from(document.querySelectorAll('label, button, [role="radio"], [role="button"]'));
  924. return labels.some((el) => /paypal/i.test((el.innerText || el.getAttribute('aria-label') || '')));
  925. }""",
  926. timeout=15000,
  927. )
  928. except Exception as exc:
  929. ctx.log(f"[stripe] 等待 PayPal 候选出现超时: {exc!r}")
  930. # 2) 候选 selector 列表,按可靠度排序
  931. candidates = [
  932. '#payment-method-accordion-item-title-paypal',
  933. 'input[type="radio"][value="paypal"]',
  934. 'input[name="paymentMethod"][value="paypal"]',
  935. '[data-testid="payment-method-paypal"]',
  936. '[data-testid*="paypal" i]',
  937. 'label[for*="paypal" i]',
  938. 'button[id*="paypal" i]',
  939. '[role="radio"][aria-label*="paypal" i]',
  940. ]
  941. for sel in candidates:
  942. loc = page.locator(sel).first
  943. if loc.count() == 0:
  944. continue
  945. ctx.log(f"[stripe] PayPal 候选命中 {sel}")
  946. # 多种点法兜底
  947. for action in ("check", "click", "force_click", "js_click"):
  948. try:
  949. if action == "check":
  950. loc.check(force=True, timeout=4000)
  951. elif action == "click":
  952. loc.click(timeout=4000)
  953. elif action == "force_click":
  954. loc.click(timeout=4000, force=True)
  955. else:
  956. loc.evaluate("el => { el.click(); }")
  957. ctx.log(f"[stripe] {action}({sel}) 成功")
  958. page.wait_for_timeout(800)
  959. if _stripe_paypal_is_selected(page):
  960. return True
  961. except Exception as exc:
  962. ctx.log(f"[stripe] {action}({sel}) 失败: {exc!r}")
  963. continue
  964. # 没确认选中也继续下一种
  965. if _stripe_paypal_is_selected(page):
  966. return True
  967. # 3) 文本扫描:找包含 "PayPal" 文本的可点击元素,逐个尝试
  968. ctx.log("[stripe] 进入文本扫描兜底")
  969. try:
  970. n = page.evaluate(
  971. r"""() => {
  972. const all = Array.from(document.querySelectorAll(
  973. 'label, button, [role="radio"], [role="button"], [role="tab"], div[tabindex], a'
  974. ));
  975. const matches = all.filter((el) => {
  976. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  977. return /paypal/i.test(t);
  978. });
  979. window.__paypalCandidates = matches;
  980. return matches.length;
  981. }"""
  982. )
  983. ctx.log(f"[stripe] 文本扫描候选数={n}")
  984. for i in range(int(n or 0)):
  985. try:
  986. page.evaluate(
  987. r"""(i) => {
  988. const el = (window.__paypalCandidates || [])[i];
  989. if (el) { el.scrollIntoView({block:'center'}); el.click(); }
  990. }""",
  991. i,
  992. )
  993. page.wait_for_timeout(700)
  994. if _stripe_paypal_is_selected(page):
  995. ctx.log(f"[stripe] 文本扫描候选 idx={i} 命中")
  996. return True
  997. except Exception as exc:
  998. ctx.log(f"[stripe] 文本扫描候选 idx={i} 失败: {exc!r}")
  999. except Exception as exc:
  1000. ctx.log(f"[stripe] 文本扫描兜底异常: {exc!r}")
  1001. return False
  1002. def _stripe_paypal_is_selected(page) -> bool:
  1003. """通过多种特征判断 PayPal 已被选中。"""
  1004. try:
  1005. return bool(page.evaluate(
  1006. r"""() => {
  1007. // 1) 单选框 checked
  1008. const r = document.querySelector('input[type="radio"][value="paypal"]:checked')
  1009. || document.querySelector('input[name="paymentMethod"][value="paypal"]:checked');
  1010. if (r) return true;
  1011. // 2) accordion item with aria-selected/expanded=true 含 paypal
  1012. const items = Array.from(document.querySelectorAll('[role="radio"], [role="tab"], button, label, div'));
  1013. const sel = items.find((el) => {
  1014. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  1015. if (!/paypal/i.test(t)) return false;
  1016. const checked = el.getAttribute('aria-checked') === 'true'
  1017. || el.getAttribute('aria-selected') === 'true'
  1018. || el.getAttribute('aria-expanded') === 'true'
  1019. || /selected|active|checked/i.test(el.className || '');
  1020. return checked;
  1021. });
  1022. if (sel) return true;
  1023. // 3) 提交按钮文本包含 PayPal(Stripe 选 PayPal 后按钮会变 "Pay with PayPal")
  1024. const btn = document.querySelector('button[data-testid="hosted-payment-submit-button"]');
  1025. if (btn && /paypal/i.test(btn.innerText || '')) return true;
  1026. return false;
  1027. }"""
  1028. ))
  1029. except Exception:
  1030. return False
  1031. def _stripe_select_paypal_and_submit(ctx, page):
  1032. ctx.log("[stripe] === 在 Stripe 选择 PayPal 并提交 ===")
  1033. page.wait_for_timeout(2000)
  1034. _check_stop(ctx)
  1035. selected = _stripe_select_paypal(ctx, page)
  1036. if not selected:
  1037. _dump_page(ctx, page, "02-stripe-paypal-not-found")
  1038. raise RuntimeError("无法在 Stripe 上选中 PayPal 支付方式")
  1039. ctx.log("[stripe] PayPal 已选中")
  1040. page.wait_for_timeout(1500)
  1041. ctx.log("[stripe] 切换 billingCountry=US")
  1042. _select_by_text(ctx, page, '#billingCountry', 'US', "billingCountry")
  1043. page.wait_for_timeout(500)
  1044. ctx.log("[stripe] 填写账单地址")
  1045. addr = ctx.address
  1046. _safe_fill(ctx, page, '#billingAddressLine1', addr["street"], "billingAddressLine1")
  1047. page.keyboard.press('Escape')
  1048. page.wait_for_timeout(200)
  1049. _safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
  1050. _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
  1051. # Stripe 的州下拉框 option value 使用缩写(如 "IL"),label 使用全名(如 "Illinois")
  1052. # 先尝试缩写,再尝试全名
  1053. state_abbrev = addr.get("state_abbrev", "")
  1054. state_full = addr.get("state", "")
  1055. if state_abbrev:
  1056. _select_by_text(ctx, page, '#billingAdministrativeArea', state_abbrev, "billingAdministrativeArea")
  1057. # 验证是否选中成功,若没有则尝试全名
  1058. sel_loc = page.locator('#billingAdministrativeArea').first
  1059. if sel_loc.count() > 0:
  1060. cur = sel_loc.input_value() if state_abbrev else ""
  1061. if not cur and state_full:
  1062. ctx.log(f"[stripe] 州下拉框未选中,尝试全名: {state_full}")
  1063. _select_by_text(ctx, page, '#billingAdministrativeArea', state_full, "billingAdministrativeArea")
  1064. if page.locator('#phoneNumber').count() > 0:
  1065. _safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
  1066. page.wait_for_timeout(400)
  1067. cb = page.locator('#termsOfServiceConsentCheckbox')
  1068. if cb.count() > 0:
  1069. try:
  1070. checked = cb.is_checked()
  1071. ctx.log(f"[stripe] 协议复选框 checked={checked}")
  1072. if not checked:
  1073. cb.check(force=True, timeout=2000)
  1074. ctx.log("[stripe] 已勾选协议")
  1075. except Exception as exc:
  1076. ctx.log(f"[stripe] 协议复选框处理失败: {exc!r}")
  1077. else:
  1078. ctx.log("[stripe] 未发现协议复选框")
  1079. _dump_page(ctx, page, "03-stripe-filled")
  1080. submit = page.locator('button[data-testid="hosted-payment-submit-button"]').first
  1081. if submit.count() == 0:
  1082. ctx.log("[stripe] 找不到 hosted-payment-submit-button")
  1083. _dump_page(ctx, page, "04-stripe-no-submit")
  1084. raise RuntimeError("hosted-payment-submit-button 不存在")
  1085. klass = submit.get_attribute("class") or ""
  1086. ctx.log(f"[stripe] 提交按钮 class={klass}")
  1087. if "incomplete" in klass:
  1088. ctx.log("[stripe] 警告:按钮仍处于 incomplete 状态,将再等 2s 重试")
  1089. page.wait_for_timeout(2000)
  1090. klass = submit.get_attribute("class") or ""
  1091. ctx.log(f"[stripe] 再次检查 class={klass}")
  1092. submit.click(timeout=8000)
  1093. ctx.log("[stripe] 已点击提交,等待跳转 PayPal")
  1094. try:
  1095. page.wait_for_url(re.compile(r"paypal\.com"), timeout=60000)
  1096. except Exception as exc:
  1097. ctx.log(f"[stripe] 等待跳转到 PayPal 超时: {exc!r}")
  1098. _dump_page(ctx, page, "04-stripe-no-redirect")
  1099. raise
  1100. ctx.log(f"[stripe] 已跳转: {page.url}")
  1101. _dump_page(ctx, page, "05-paypal-arrived")
  1102. # ---------------------------------------------------------------------------
  1103. # DataDome 滑块自动模拟 + cookie 复用
  1104. # ---------------------------------------------------------------------------
  1105. def _human_bezier_points(start_x: float, start_y: float,
  1106. end_x: float, end_y: float,
  1107. steps: int = 50) -> list[tuple[float, float]]:
  1108. """用三阶贝塞尔曲线生成从 start 到 end 的人类鼠标轨迹,带随机抖动和加减速。"""
  1109. dx = end_x - start_x
  1110. dy = end_y - start_y
  1111. dist = math.hypot(dx, dy)
  1112. # 随机偏移控制点,制造弧度
  1113. offset_y = random.uniform(-dist * 0.15, dist * 0.15)
  1114. cp1x = start_x + dx * random.uniform(0.2, 0.4)
  1115. cp1y = start_y + dy * random.uniform(0.1, 0.3) + offset_y
  1116. cp2x = start_x + dx * random.uniform(0.6, 0.8)
  1117. cp2y = start_y + dy * random.uniform(0.7, 0.9) + offset_y
  1118. points = []
  1119. for i in range(steps):
  1120. t = i / max(steps - 1, 1)
  1121. # 加速→匀速→减速的时间映射
  1122. t_ease = t * t * (3 - 2 * t) # smoothstep
  1123. x = ((1 - t_ease) ** 3 * start_x
  1124. + 3 * (1 - t_ease) ** 2 * t_ease * cp1x
  1125. + 3 * (1 - t_ease) * t_ease ** 2 * cp2x
  1126. + t_ease ** 3 * end_x)
  1127. y = ((1 - t_ease) ** 3 * start_y
  1128. + 3 * (1 - t_ease) ** 2 * t_ease * cp1y
  1129. + 3 * (1 - t_ease) * t_ease ** 2 * cp2y
  1130. + t_ease ** 3 * end_y)
  1131. # 手抖:在中间段加随机微偏移
  1132. if 0.15 < t < 0.85:
  1133. x += random.gauss(0, dist * 0.008)
  1134. y += random.gauss(0, dist * 0.008)
  1135. points.append((round(x, 1), round(y, 1)))
  1136. points.append((round(end_x, 1), round(end_y, 1)))
  1137. return points
  1138. def _find_datadome_slider(page) -> dict | None:
  1139. """在页面中查找 DataDome iframe 的位置信息。
  1140. DataDome 滑块在跨域 iframe 内,JS 无法直接访问内部 DOM。
  1141. 返回 iframe 的 bounding box,供 page.mouse 在主页面坐标系操作。
  1142. """
  1143. try:
  1144. return page.evaluate(r"""() => {
  1145. const ifr = document.querySelector(
  1146. 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
  1147. );
  1148. if (!ifr) return null;
  1149. const r = ifr.getBoundingClientRect();
  1150. if (r.width === 0 || r.height === 0) return null;
  1151. return {
  1152. iframe: { x: r.left, y: r.top, width: r.width, height: r.height },
  1153. iframeSrc: ifr.src || ''
  1154. };
  1155. }""")
  1156. except Exception:
  1157. return None
  1158. def _datadome_drag_via_frame(ctx, page) -> bool:
  1159. """用 Playwright frame_locator 定位 DataDome iframe 内的滑块并拖拽。
  1160. DataDome 的 iframe 是跨域的,JS 无法访问内部 DOM,
  1161. 但 Playwright 的 frame_locator 可以操作跨域 iframe 内的元素。
  1162. 滑块 UI 结构:iframe 内有一个蓝色按钮,需从左拖到右。
  1163. """
  1164. try:
  1165. # 方法一:用 page.frame_locator() 进入 iframe
  1166. frame = page.frame_locator(
  1167. 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
  1168. )
  1169. # 尝试多种滑块选择器
  1170. slider_selectors = [
  1171. # DataDome 常见滑块选择器
  1172. '[class*="slider"] [class*="btn"]',
  1173. '[class*="slider"] button',
  1174. '[class*="slider-track"] > div',
  1175. '[class*="slider"] > div',
  1176. 'button[class*="slider"]',
  1177. '[role="slider"]',
  1178. 'div[class*="challenge"] [class*="slider"]',
  1179. 'div[class*="challenge"] button',
  1180. # 更宽泛
  1181. 'button',
  1182. ]
  1183. slider_loc = None
  1184. used_sel = ""
  1185. for sel in slider_selectors:
  1186. try:
  1187. loc = frame.locator(sel).first
  1188. if loc.count() > 0 and loc.is_visible():
  1189. slider_loc = loc
  1190. used_sel = sel
  1191. break
  1192. except Exception:
  1193. continue
  1194. if not slider_loc:
  1195. ctx.log("[datadome] frame_locator: iframe 内未找到可拖拽的滑块元素")
  1196. return False
  1197. ctx.log(f"[datadome] frame_locator: 找到滑块 selector={used_sel}")
  1198. # 获取滑块的 bounding box(在主页面坐标系)
  1199. box = slider_loc.bounding_box()
  1200. if not box:
  1201. ctx.log("[datadome] frame_locator: 滑块无 bounding box")
  1202. return False
  1203. # 获取 iframe 的 bounding box 计算拖拽距离
  1204. iframe_loc = page.locator(
  1205. 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
  1206. ).first
  1207. iframe_box = iframe_loc.bounding_box()
  1208. if not iframe_box:
  1209. ctx.log("[datadome] frame_locator: iframe 无 bounding box")
  1210. return False
  1211. ctx.log(f"[datadome] 滑块 x={box['x']:.0f} y={box['y']:.0f} w={box['width']:.0f} h={box['height']:.0f}")
  1212. 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}")
  1213. # 滑块起始中心
  1214. start_x = box["x"] + box["width"] / 2
  1215. start_y = box["y"] + box["height"] / 2
  1216. # 计算拖拽距离:从滑块当前位置到 iframe 右边界的距离
  1217. # DataDome track 宽度 ≈ iframe 宽度减去两侧内边距
  1218. available_width = iframe_box["x"] + iframe_box["width"] - box["x"] - box["width"] * 0.3
  1219. drag_dist = available_width * random.uniform(0.88, 0.97)
  1220. end_x = start_x + drag_dist
  1221. end_y = start_y + random.uniform(-3, 3)
  1222. ctx.log(f"[datadome] 拖拽 ({start_x:.0f},{start_y:.0f}) → ({end_x:.0f},{end_y:.0f}) dist={drag_dist:.0f}")
  1223. # === 人类化拖拽 ===
  1224. # 阶段1:鼠标从远处自然移到滑块上方
  1225. approach_x = start_x - random.uniform(120, 250)
  1226. approach_y = start_y + random.uniform(-60, 60)
  1227. approach_pts = _human_bezier_points(
  1228. approach_x, approach_y,
  1229. start_x - random.uniform(2, 8), start_y + random.uniform(-1, 1),
  1230. steps=random.randint(10, 18),
  1231. )
  1232. for px, py in approach_pts:
  1233. page.mouse.move(px, py)
  1234. page.wait_for_timeout(random.randint(10, 25))
  1235. # 到达滑块后短暂停顿(人类瞄准)
  1236. page.wait_for_timeout(random.randint(300, 700))
  1237. # 阶段2:按下鼠标
  1238. page.mouse.down()
  1239. page.wait_for_timeout(random.randint(80, 200))
  1240. # 阶段3:主拖拽轨迹(变速:慢→快→慢,带 overshot)
  1241. # 先拖到目标稍微偏右的位置(overshot),再微调回来
  1242. overshot_x = end_x + random.uniform(8, 25)
  1243. overshot_y = end_y + random.uniform(-4, 4)
  1244. steps_main = random.randint(40, 65)
  1245. points_main = _human_bezier_points(start_x, start_y, overshot_x, overshot_y, steps=steps_main)
  1246. for i, (px, py) in enumerate(points_main):
  1247. page.mouse.move(px, py)
  1248. # 速度曲线:起步慢→中间快→结尾慢
  1249. progress = i / max(steps_main - 1, 1)
  1250. if progress < 0.15:
  1251. delay = random.randint(18, 35) # 起步慢
  1252. elif progress < 0.7:
  1253. delay = random.randint(4, 12) # 中间快
  1254. else:
  1255. delay = random.randint(12, 28) # 结尾减速
  1256. page.wait_for_timeout(delay)
  1257. # 阶段4:overshot 回弹(拖过头再回来一点)
  1258. page.wait_for_timeout(random.randint(60, 150))
  1259. correction_steps = random.randint(3, 6)
  1260. for i in range(correction_steps):
  1261. t = (i + 1) / correction_steps
  1262. cx = overshot_x + (end_x - overshot_x) * t
  1263. cy = overshot_y + (end_y - overshot_y) * t + random.gauss(0, 0.5)
  1264. page.mouse.move(cx, cy)
  1265. page.wait_for_timeout(random.randint(15, 30))
  1266. # 阶段5:松手前的微小停顿
  1267. page.wait_for_timeout(random.randint(150, 400))
  1268. page.mouse.up()
  1269. ctx.log("[datadome] frame_locator 拖拽完成")
  1270. return True
  1271. except Exception as exc:
  1272. ctx.log(f"[datadome] frame_locator 拖拽异常: {exc!r}")
  1273. return False
  1274. def _auto_solve_datadome(ctx, page, max_attempts: int = 3) -> bool:
  1275. """尝试自动通过 DataDome 滑块验证。
  1276. 策略:
  1277. 1) 注入已保存的 cookie → 如已通过则直接返回
  1278. 2) 用 frame_locator 进入跨域 iframe → 定位滑块 → 人类轨迹拖拽
  1279. 3) 每次 attempt 之间等待随机时间
  1280. """
  1281. ctx.set_stage("🤖 尝试自动通过 DataDome 滑块...")
  1282. # 1) 注入已有 cookie
  1283. _inject_datadome_cookie(ctx, page)
  1284. page.wait_for_timeout(1500)
  1285. if not _detect_datadome_captcha(page):
  1286. ctx.log("[datadome] 注入已有 cookie 后验证已通过")
  1287. return True
  1288. for attempt in range(1, max_attempts + 1):
  1289. ctx.log(f"[datadome] 自动解决第 {attempt}/{max_attempts} 次尝试")
  1290. # 2) 模拟人类预行为:随机鼠标移动 + 微滚动
  1291. try:
  1292. vp = page.viewport_size or {"width": 1280, "height": 900}
  1293. for _ in range(random.randint(3, 6)):
  1294. rx = random.uniform(100, vp["width"] - 100)
  1295. ry = random.uniform(100, vp["height"] - 100)
  1296. page.mouse.move(rx, ry, steps=random.randint(8, 20))
  1297. page.wait_for_timeout(random.randint(80, 250))
  1298. page.mouse.wheel(0, random.randint(-80, 80))
  1299. page.wait_for_timeout(random.randint(800, 1500))
  1300. except Exception as exc:
  1301. ctx.log(f"[datadome] 预行为异常: {exc!r}")
  1302. # 3) 用 frame_locator 拖拽(核心方法,能操作跨域 iframe)
  1303. dragged = _datadome_drag_via_frame(ctx, page)
  1304. if not dragged:
  1305. # 4) 备用方案:基于 iframe bounding box 直接推算滑块位置拖拽
  1306. ctx.log("[datadome] frame_locator 失败,尝试基于 iframe 位置推算拖拽")
  1307. info = _find_datadome_slider(page)
  1308. if not info or not info.get("iframe"):
  1309. ctx.log("[datadome] 未找到 DataDome iframe,降级等待手动")
  1310. return False
  1311. iframe = info["iframe"]
  1312. # DataDome 滑块 UI:iframe 中部偏下,滑块按钮从左端拖到右端
  1313. # 典型布局:图标栏(~30px) → 文字(~20px) → slider track(中间区域)
  1314. # 滑块按钮起始在 track 左端
  1315. slider_y = iframe["y"] + iframe["height"] * random.uniform(0.55, 0.65)
  1316. start_x = iframe["x"] + random.uniform(20, 40)
  1317. end_x = iframe["x"] + iframe["width"] - random.uniform(15, 30)
  1318. ctx.log(f"[datadome] 推算拖拽 ({start_x:.0f},{slider_y:.0f}) → ({end_x:.0f},{slider_y:.0f})")
  1319. # 生成人类轨迹
  1320. steps = random.randint(35, 55)
  1321. points = _human_bezier_points(start_x, slider_y, end_x, slider_y + random.uniform(-2, 2), steps=steps)
  1322. # 移到附近
  1323. try:
  1324. approach_pts = _human_bezier_points(
  1325. start_x - random.uniform(100, 200),
  1326. slider_y + random.uniform(-50, 50),
  1327. start_x, slider_y, steps=random.randint(10, 18),
  1328. )
  1329. for px, py in approach_pts:
  1330. page.mouse.move(px, py)
  1331. page.wait_for_timeout(random.randint(10, 25))
  1332. page.wait_for_timeout(random.randint(300, 700))
  1333. page.mouse.down()
  1334. page.wait_for_timeout(random.randint(80, 200))
  1335. # 变速拖拽:慢→快→慢
  1336. overshot_x = end_x + random.uniform(5, 20)
  1337. points_os = _human_bezier_points(start_x, slider_y, overshot_x, slider_y + random.uniform(-3, 3), steps=random.randint(40, 60))
  1338. for i, (px, py) in enumerate(points_os):
  1339. page.mouse.move(px, py)
  1340. progress = i / max(len(points_os) - 1, 1)
  1341. if progress < 0.15:
  1342. delay = random.randint(18, 35)
  1343. elif progress < 0.7:
  1344. delay = random.randint(4, 12)
  1345. else:
  1346. delay = random.randint(12, 28)
  1347. page.wait_for_timeout(delay)
  1348. # overshot 回弹
  1349. page.wait_for_timeout(random.randint(60, 150))
  1350. for i in range(random.randint(2, 5)):
  1351. t = (i + 1) / 5
  1352. cx = overshot_x + (end_x - overshot_x) * t
  1353. page.mouse.move(cx, slider_y + random.gauss(0, 0.5))
  1354. page.wait_for_timeout(random.randint(15, 30))
  1355. page.wait_for_timeout(random.randint(150, 400))
  1356. page.mouse.up()
  1357. ctx.log("[datadome] 推算拖拽完成")
  1358. except Exception as exc:
  1359. ctx.log(f"[datadome] 推算拖拽异常: {exc!r}")
  1360. # 5) 等待验证结果
  1361. page.wait_for_timeout(5000)
  1362. if not _detect_datadome_captcha(page):
  1363. ctx.log("[datadome] 自动拖拽通过验证!")
  1364. _save_datadome_cookie(ctx, page)
  1365. return True
  1366. ctx.log(f"[datadome] 第 {attempt} 次拖拽未通过")
  1367. # 重试前等待更久,让 DataDome 状态重置
  1368. page.wait_for_timeout(random.randint(2000, 4000))
  1369. ctx.log(f"[datadome] {max_attempts} 次自动拖拽均未通过,降级为手动")
  1370. return False
  1371. def _save_datadome_cookie(ctx, page):
  1372. """滑块通过后提取 datadome cookie 并保存到文件。"""
  1373. try:
  1374. cookies = page.context.cookies()
  1375. dd_cookies = [c for c in cookies if "datadome" in c.get("name", "").lower()]
  1376. if not dd_cookies:
  1377. # 也从 document.cookie 中提取
  1378. raw = page.evaluate("() => document.cookie") or ""
  1379. for part in raw.split(";"):
  1380. kv = part.strip()
  1381. if kv.lower().startswith("datadome="):
  1382. dd_cookies.append({
  1383. "name": "datadome",
  1384. "value": kv.split("=", 1)[1],
  1385. "domain": ".paypal.com",
  1386. "path": "/",
  1387. })
  1388. break
  1389. if not dd_cookies:
  1390. ctx.log("[datadome] 未找到 datadome cookie 可保存")
  1391. return
  1392. payload = {
  1393. "cookies": dd_cookies,
  1394. "saved_at": time.time(),
  1395. "url": page.url,
  1396. }
  1397. with open(DATADOME_COOKIE_FILE, "w", encoding="utf-8") as f:
  1398. json.dump(payload, f, ensure_ascii=False, indent=2)
  1399. ctx.log(f"[datadome] 已保存 {len(dd_cookies)} 个 cookie 到 {DATADOME_COOKIE_FILE}")
  1400. except Exception as exc:
  1401. ctx.log(f"[datadome] 保存 cookie 异常: {exc!r}")
  1402. def _inject_datadome_cookie(ctx, page):
  1403. """从文件读取之前保存的 datadome cookie 并注入到当前浏览器 context。"""
  1404. if not os.path.exists(DATADOME_COOKIE_FILE):
  1405. ctx.log("[datadome] 无已保存的 cookie 文件")
  1406. return
  1407. try:
  1408. with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f:
  1409. payload = json.load(f)
  1410. cookies = payload.get("cookies", [])
  1411. if not cookies:
  1412. ctx.log("[datadome] cookie 文件为空")
  1413. return
  1414. saved_at = payload.get("saved_at", 0)
  1415. age_hours = (time.time() - saved_at) / 3600
  1416. if age_hours > 24:
  1417. ctx.log(f"[datadome] cookie 已过期 {age_hours:.1f}h(>24h),跳过注入")
  1418. return
  1419. ctx.log(f"[datadome] 注入已保存的 cookie({age_hours:.1f}h 前,{len(cookies)} 个)")
  1420. for c in cookies:
  1421. c.setdefault("path", "/")
  1422. if "domain" not in c:
  1423. c["domain"] = ".paypal.com"
  1424. page.context.add_cookies(cookies)
  1425. ctx.log("[datadome] cookie 注入完成")
  1426. except Exception as exc:
  1427. ctx.log(f"[datadome] 注入 cookie 异常: {exc!r}")
  1428. def _inject_datadome_cookie_at_startup(ctx, browser_context):
  1429. """浏览器 context 创建后立即注入 datadome cookie(无需 page 对象)。
  1430. 使后续所有页面访问(包括首次打开 PayPal)都携带 datadome cookie,
  1431. 实现跨进程、跨浏览器实例的 cookie 复用。
  1432. """
  1433. if not os.path.exists(DATADOME_COOKIE_FILE):
  1434. return
  1435. try:
  1436. with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f:
  1437. payload = json.load(f)
  1438. cookies = payload.get("cookies", [])
  1439. if not cookies:
  1440. return
  1441. saved_at = payload.get("saved_at", 0)
  1442. age_hours = (time.time() - saved_at) / 3600
  1443. if age_hours > 24:
  1444. ctx.log(f"[datadome] 启动注入跳过:cookie 已过期 {age_hours:.1f}h")
  1445. return
  1446. for c in cookies:
  1447. c.setdefault("path", "/")
  1448. if "domain" not in c:
  1449. c["domain"] = ".paypal.com"
  1450. browser_context.add_cookies(cookies)
  1451. ctx.log(f"[datadome] 启动注入 {len(cookies)} 个 cookie({age_hours:.1f}h 前保存)")
  1452. except Exception as exc:
  1453. ctx.log(f"[datadome] 启动注入异常: {exc!r}")
  1454. def _detect_datadome_captcha(page) -> str:
  1455. """检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
  1456. 判定逻辑:
  1457. 1) 如果页面已出现 PayPal 业务元素(邮箱输入框 / Create an Account / Continue / checkoutweb URL 等)
  1458. → 直接判定"已通过",不再看 DataDome 残留
  1459. 2) 否则看 DataDome iframe / 滑块 / 拦截文本
  1460. """
  1461. url = getattr(page, "url", "") or ""
  1462. try:
  1463. info = page.evaluate(
  1464. r"""() => {
  1465. const visible = (el) => {
  1466. if (!el) return false;
  1467. const s = window.getComputedStyle(el);
  1468. if (s.display === 'none' || s.visibility === 'hidden') return false;
  1469. const r = el.getBoundingClientRect();
  1470. return r.width > 0 && r.height > 0;
  1471. };
  1472. const out = { hasIframe: false, hasSlider: false, snippet: '', hasBusiness: false, businessHint: '' };
  1473. // 正向:PayPal 业务页面元素(出现即代表已过 DataDome)
  1474. const emailInput = document.querySelector(
  1475. 'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
  1476. );
  1477. if (emailInput && visible(emailInput)) {
  1478. out.hasBusiness = true;
  1479. out.businessHint = 'email-input-visible';
  1480. }
  1481. if (!out.hasBusiness) {
  1482. const c2pBtns = Array.from(document.querySelectorAll(
  1483. 'button, a, [role="button"]'
  1484. )).filter(visible);
  1485. const matched = c2pBtns.find((el) => {
  1486. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  1487. return /create\s+an?\s+account|continue\s+to\s+payment|pay\s+with\s+(?:debit|credit)\s+card/i.test(t);
  1488. });
  1489. if (matched) {
  1490. out.hasBusiness = true;
  1491. out.businessHint = 'business-button:' + (matched.innerText || '').slice(0, 40);
  1492. }
  1493. }
  1494. // 反向:DataDome 拦截特征(仅当业务元素未出现才看)
  1495. if (!out.hasBusiness) {
  1496. const ifr = document.querySelector(
  1497. 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
  1498. );
  1499. if (ifr && visible(ifr)) out.hasIframe = true;
  1500. const slider = document.querySelector(
  1501. '[id*="datadome" i] [class*="slider" i], [class*="datadome" i] [class*="slider" i], '
  1502. + '[id^="ddc-"], [class*="captcha-container" i], [aria-label*="slider" i][role="slider"]'
  1503. );
  1504. if (slider && visible(slider)) out.hasSlider = true;
  1505. const t = (document.body && document.body.innerText || '').replace(/\s+/g, ' ');
  1506. 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)) {
  1507. out.hasSlider = true;
  1508. out.snippet = t.slice(0, 200);
  1509. }
  1510. }
  1511. return out;
  1512. }"""
  1513. ) or {}
  1514. except Exception:
  1515. info = {}
  1516. # 已经在 PayPal 业务页面 → 一定通过
  1517. if info.get("hasBusiness"):
  1518. return ""
  1519. # URL 已经进入 /checkoutweb/ 或 /pay/ 带业务参数 → 也算通过
  1520. if "/checkoutweb/" in url:
  1521. return ""
  1522. if ("paypal_client_cfci=" in url) or ("paypal_client_metadata_id=" in url):
  1523. return ""
  1524. if info.get("hasIframe") or info.get("hasSlider"):
  1525. return f"DataDome captcha 检测到 iframe={info.get('hasIframe')} slider={info.get('hasSlider')} url={url} snippet={info.get('snippet', '')[:120]!r}"
  1526. return ""
  1527. def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
  1528. """尝试自动过 DataDome 滑块,失败后降级为手动等待。
  1529. 策略:
  1530. 1) 先注入已保存的 datadome cookie → 如已通过则直接返回
  1531. 2) 自动模拟人类拖拽滑块(最多 3 次)
  1532. 3) 均失败 → 降级为手动等待(原有逻辑)
  1533. """
  1534. # 阶段一:自动尝试
  1535. if _auto_solve_datadome(ctx, page, max_attempts=3):
  1536. return True
  1537. # 阶段二:降级为手动等待
  1538. ctx.set_stage("⚠️ 自动过滑块失败,请在浏览器中手动滑动完成")
  1539. deadline = time.time() + max_wait_sec
  1540. last_log = 0.0
  1541. poll_interval = 0.5
  1542. stable_needed = 2
  1543. stable_count = 0
  1544. while time.time() < deadline:
  1545. _check_stop(ctx)
  1546. if not _detect_datadome_captcha(page):
  1547. stable_count += 1
  1548. if stable_count >= stable_needed:
  1549. ctx.log("[paypal] DataDome 已通过(手动),保存 cookie 并继续")
  1550. page.wait_for_timeout(1000)
  1551. _save_datadome_cookie(ctx, page)
  1552. return True
  1553. else:
  1554. stable_count = 0
  1555. if time.time() - last_log > 10:
  1556. remaining = int(deadline - time.time())
  1557. ctx.log(f"[paypal] 仍在等待人工通过 DataDome 滑块... 剩余 {remaining}s")
  1558. last_log = time.time()
  1559. time.sleep(poll_interval)
  1560. ctx.log("[paypal] DataDome 等待超时")
  1561. return False
  1562. def _paypal_signup_and_pay(ctx, page):
  1563. ctx.log("[paypal] === 进入 PayPal 流程 ===")
  1564. page.wait_for_load_state("domcontentloaded", timeout=30000)
  1565. page.wait_for_timeout(1500)
  1566. _check_stop(ctx)
  1567. # PayPal 着陆即可能弹 DataDome 滑块(agreements/approve、checkoutweb 都见过)
  1568. captcha_reason = _detect_datadome_captcha(page)
  1569. if captcha_reason:
  1570. ctx.log(f"[paypal] {captcha_reason}")
  1571. if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
  1572. _dump_page(ctx, page, "06-paypal-datadome-timeout")
  1573. raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
  1574. # DataDome 检查点通过后,清理残留的 captcha 容器,避免挡住 /pay 邮箱和 Next。
  1575. _paypal_remove_captcha_overlay(ctx, page)
  1576. ctx.log("[paypal] 保留当前 PayPal 会话,继续后续流程")
  1577. # 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
  1578. from paypal_flow import ensure_checkoutweb
  1579. on_stage = getattr(ctx, "on_stage", None)
  1580. path_taken = ensure_checkoutweb(
  1581. page,
  1582. fallback_email=ctx.paypal_email or ctx.email,
  1583. log=ctx.log,
  1584. on_stage=on_stage,
  1585. )
  1586. ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
  1587. _dump_page(ctx, page, "06-paypal-checkoutweb-entered")
  1588. # 进 /checkoutweb/ 之后再次检测 DataDome(PayPal 有时会在表单页弹)
  1589. captcha_reason = _detect_datadome_captcha(page)
  1590. if captcha_reason:
  1591. ctx.log(f"[paypal] {captcha_reason}")
  1592. if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
  1593. _dump_page(ctx, page, "06b-paypal-datadome-timeout")
  1594. raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
  1595. # PayPal 可能在跳转业务页后留下空 captcha 容器,填表前再清一次。
  1596. _paypal_remove_captcha_overlay(ctx, page)
  1597. if path_taken == "unknown":
  1598. # 上层没识别到 /checkoutweb/,强行往下填表只会错位 → 直接抛失败让外层 retry
  1599. ctx.log("[paypal] ensure_checkoutweb 返回 unknown,直接判定本次失败以触发重试")
  1600. raise PayPalPaymentFailed(f"未能进入 /checkoutweb/ 表单页, url={page.url}")
  1601. ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
  1602. for sel in ('#cardNumber', '#phone', '#billingLine1'):
  1603. try:
  1604. page.locator(sel).first.wait_for(state="visible", timeout=20000)
  1605. ctx.log(f"[paypal] {sel} 已可见")
  1606. except Exception as exc:
  1607. ctx.log(f"[paypal] 等待 {sel} 超时: {exc!r}")
  1608. _dump_page(ctx, page, "07-paypal-checkoutweb")
  1609. ctx.log("[paypal] 检查国家选择器")
  1610. country = page.locator('#country')
  1611. if country.count() > 0:
  1612. try:
  1613. current = country.input_value()
  1614. ctx.log(f"[paypal] 当前国家={current}")
  1615. if current != "US":
  1616. ctx.log("[paypal] 切换国家为 US")
  1617. country.select_option("US")
  1618. page.wait_for_timeout(2500)
  1619. ctx.log(f"[paypal] 切换后国家={country.input_value()}, URL={page.url}")
  1620. _dump_page(ctx, page, "08-paypal-country-switched")
  1621. except Exception as exc:
  1622. ctx.log(f"[paypal] 切换国家失败: {exc!r}")
  1623. else:
  1624. ctx.log("[paypal] 未发现 #country 选择器")
  1625. addr = ctx.address
  1626. card = ctx.card
  1627. ctx.log("[paypal] 第一轮填表")
  1628. _paypal_fill_form(ctx, page, addr, card)
  1629. page.wait_for_timeout(800)
  1630. invalid = _paypal_collect_invalid(page)
  1631. if invalid:
  1632. ctx.log(f"[paypal] 第一轮后仍有 aria-invalid='true' 的字段: {invalid},再补一次")
  1633. _paypal_fill_form(ctx, page, addr, card, retry_only=invalid)
  1634. page.wait_for_timeout(600)
  1635. _dump_page(ctx, page, "09-paypal-filled")
  1636. ctx.log("[paypal] 提交付款")
  1637. if not _click_next(ctx, page, "PayPal 提交"):
  1638. ctx.log("[paypal] 警告:未能找到可点击的提交按钮")
  1639. _dump_page(ctx, page, "10-paypal-no-submit")
  1640. page.wait_for_timeout(2500)
  1641. invalid_after = _paypal_collect_invalid(page)
  1642. if invalid_after:
  1643. ctx.log(f"[paypal] 提交后页面报错的字段: {invalid_after},再补再提交一次")
  1644. _paypal_fill_form(ctx, page, addr, card, retry_only=invalid_after)
  1645. page.wait_for_timeout(500)
  1646. _click_next(ctx, page, "PayPal 提交-2")
  1647. _paypal_handle_sms(ctx, page)
  1648. _PAYPAL_FIELDS = [
  1649. ("email", "value"),
  1650. ("phone", "value"),
  1651. ("cardNumber", "card-number"),
  1652. ("cardExpiry", "card-expiry"),
  1653. ("cardCvv", "card-cvv"),
  1654. ("firstName", "value"),
  1655. ("lastName", "value"),
  1656. ("billingLine1", "value"),
  1657. ("billingCity", "value"),
  1658. ("billingPostalCode", "value"),
  1659. ("password", "value"),
  1660. ]
  1661. def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
  1662. targets = retry_only or [k for k, _ in _PAYPAL_FIELDS]
  1663. if "email" in targets:
  1664. _safe_fill(ctx, page, '#email', ctx.paypal_email or ctx.email, "email")
  1665. if "phone" in targets:
  1666. _paypal_type_into(ctx, page, '#phone', ctx.phone_number, "phone")
  1667. if "cardNumber" in targets:
  1668. _paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
  1669. if "cardExpiry" in targets:
  1670. _paypal_type_into(ctx, page, '#cardExpiry', card["expiry"], "cardExpiry")
  1671. if "cardCvv" in targets:
  1672. _paypal_type_into(ctx, page, '#cardCvv', card["cvv"], "cardCvv")
  1673. if "firstName" in targets:
  1674. _safe_fill(ctx, page, '#firstName', "James", "firstName")
  1675. if "lastName" in targets:
  1676. _safe_fill(ctx, page, '#lastName', "Smith", "lastName")
  1677. if "billingLine1" in targets:
  1678. _safe_fill(ctx, page, '#billingLine1', addr["street"], "billingLine1")
  1679. try:
  1680. page.keyboard.press('Escape')
  1681. except Exception:
  1682. pass
  1683. if "billingCity" in targets:
  1684. _safe_fill(ctx, page, '#billingCity', addr["city"], "billingCity")
  1685. if "billingPostalCode" in targets:
  1686. _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
  1687. if any(k in targets for k in ("billingLine1", "billingCity", "billingPostalCode")):
  1688. _select_by_text(ctx, page, '#billingState', addr["state"], "billingState")
  1689. if "password" in targets:
  1690. _safe_fill(ctx, page, '#password', ctx.password, "password", mask=True)
  1691. def _paypal_type_into(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
  1692. label = label or selector
  1693. shown = "***" if mask else value
  1694. try:
  1695. loc = page.locator(selector).first
  1696. if loc.count() == 0:
  1697. ctx.log(f"[type] {label}({selector}) 不存在,跳过")
  1698. return
  1699. loc.wait_for(state="visible", timeout=8000)
  1700. loc.click()
  1701. loc.fill("")
  1702. loc.type(value, delay=20)
  1703. ctx.log(f"[type] {label}({selector}) <- {shown}")
  1704. except Exception as exc:
  1705. ctx.log(f"[type] {label}({selector}) 失败: {exc!r}")
  1706. def _paypal_collect_invalid(page):
  1707. try:
  1708. return page.evaluate("""() => {
  1709. const out = [];
  1710. document.querySelectorAll('[aria-invalid="true"]').forEach(el => {
  1711. if (el.id) out.push(el.id);
  1712. else if (el.name) out.push(el.name);
  1713. });
  1714. return out;
  1715. }""")
  1716. except Exception:
  1717. return []
  1718. def _paypal_clear_session(ctx, page):
  1719. ctx.log("[paypal] 清理 cookie/storage(保留 datadome)")
  1720. try:
  1721. before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
  1722. page.evaluate(r"""() => {
  1723. try { localStorage.clear(); } catch (e) {}
  1724. try { sessionStorage.clear(); } catch (e) {}
  1725. const host = location.hostname;
  1726. const parts = host.split('.');
  1727. const domains = [host, '.' + host];
  1728. for (let i = 1; i < parts.length - 1; i++) domains.push('.' + parts.slice(i).join('.'));
  1729. const cookies = document.cookie ? document.cookie.split(';') : [];
  1730. cookies.forEach(c => {
  1731. const name = c.split('=')[0].trim();
  1732. if (!name) return;
  1733. // 保留 datadome cookie
  1734. if (name.toLowerCase() === 'datadome') return;
  1735. ['/', location.pathname].forEach(p => {
  1736. domains.forEach(d => {
  1737. document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
  1738. });
  1739. document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p;
  1740. });
  1741. });
  1742. }""")
  1743. after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
  1744. ctx.log(f"[paypal] cookie 数量 {before} -> {after}(保留 datadome)")
  1745. except Exception as exc:
  1746. ctx.log(f"[paypal] 清理会话异常: {exc!r}")
  1747. def _paypal_remove_captcha_overlay(ctx, page):
  1748. try:
  1749. removed = page.evaluate("""() => {
  1750. const ids = ['captchaComponent', 'recaptcha', 'recaptcha-overlay', 'reCAPTCHAOverlay'];
  1751. let n = 0;
  1752. for (const id of ids) {
  1753. const el = document.getElementById(id);
  1754. if (el) { el.remove(); n++; }
  1755. }
  1756. document.querySelectorAll(
  1757. 'iframe[name="recaptcha"], iframe[title*="recaptcha" i], div[class*="captcha" i][class*="overlay" i]'
  1758. ).forEach(el => { el.remove(); n++; });
  1759. return n;
  1760. }""")
  1761. if removed:
  1762. ctx.log(f"[paypal] 已删除 captcha 相关蒙层节点 {removed} 个")
  1763. except Exception as exc:
  1764. ctx.log(f"[paypal] 删除 captcha 蒙层失败: {exc!r}")
  1765. def _paypal_handle_sms(ctx, page):
  1766. ctx.log("[paypal] === 检查是否需要短信验证码 ===")
  1767. page.wait_for_timeout(3000)
  1768. _check_stop(ctx)
  1769. _paypal_remove_captcha_overlay(ctx, page)
  1770. digit_inputs = page.locator('input[id^="ci-ciBasic-"]')
  1771. digit_count = 0
  1772. try:
  1773. digit_count = digit_inputs.count()
  1774. except Exception as exc:
  1775. ctx.log(f"[paypal] 探测分立 OTP 输入框失败: {exc!r}")
  1776. ctx.log(f"[paypal] 分立 OTP 输入框 ci-ciBasic-* count={digit_count}")
  1777. single_input = None
  1778. if digit_count == 0:
  1779. sms_inputs = [
  1780. 'input[name="otp"]',
  1781. 'input[autocomplete="one-time-code"]',
  1782. 'input#otp',
  1783. 'input[name="smsOtp"]',
  1784. 'input[id*="otp" i]',
  1785. ]
  1786. for sel in sms_inputs:
  1787. loc = page.locator(sel).first
  1788. try:
  1789. count = loc.count()
  1790. ctx.log(f"[paypal] 探测 SMS 输入框 selector={sel} count={count}")
  1791. if count == 0:
  1792. continue
  1793. loc.wait_for(state="visible", timeout=4000)
  1794. single_input = (loc, sel)
  1795. ctx.log(f"[paypal] 命中验证码输入框: {sel}")
  1796. break
  1797. except Exception as exc:
  1798. ctx.log(f"[paypal] selector={sel} 等待可见失败: {exc!r}")
  1799. continue
  1800. if digit_count == 0 and single_input is None:
  1801. ctx.log("[paypal] 未检测到验证码输入框,跳过 SMS 步骤")
  1802. _dump_page(ctx, page, "11-paypal-no-sms")
  1803. _paypal_wait_for_payment_completion(ctx, page)
  1804. return
  1805. _dump_page(ctx, page, "12-paypal-sms-prompt")
  1806. ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
  1807. code = fetch_sms_code(timeout=180, interval=5, log=ctx.log, sms_api_url=ctx.sms_api_url)
  1808. if digit_count > 0:
  1809. ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
  1810. if len(code) < digit_count:
  1811. ctx.log(f"[paypal] 警告:验证码位数 {len(code)} < 输入框数 {digit_count}")
  1812. try:
  1813. first = digit_inputs.nth(0)
  1814. first.wait_for(state="visible", timeout=4000)
  1815. first.click()
  1816. except Exception as exc:
  1817. ctx.log(f"[paypal] 聚焦第 1 格失败: {exc!r}")
  1818. for idx in range(min(digit_count, len(code))):
  1819. try:
  1820. box = digit_inputs.nth(idx)
  1821. box.fill("")
  1822. box.type(code[idx], delay=30)
  1823. ctx.log(f"[paypal] OTP[{idx}] <- {code[idx]}")
  1824. except Exception as exc:
  1825. ctx.log(f"[paypal] OTP[{idx}] 填入失败: {exc!r}")
  1826. else:
  1827. loc, sel = single_input
  1828. loc.fill(code)
  1829. ctx.log(f"[paypal] 已填入验证码到 {sel}")
  1830. page.wait_for_timeout(800)
  1831. _click_next(ctx, page, "SMS 提交")
  1832. ctx.log("[paypal] 已提交验证码")
  1833. _dump_page(ctx, page, "13-paypal-sms-submitted")
  1834. _paypal_wait_for_payment_completion(ctx, page)
  1835. def _paypal_wait_for_payment_completion(
  1836. ctx,
  1837. page,
  1838. timeout: int = POST_PAYMENT_WAIT_TIMEOUT,
  1839. interval: int = POST_PAYMENT_WAIT_INTERVAL,
  1840. ) -> bool:
  1841. ctx.log(f"[paypal] 等待支付完成,自动等待 {timeout}s,间隔 {interval}s")
  1842. deadline = time.time() + timeout
  1843. last_status = ""
  1844. manual_mode = False
  1845. clicked_actions = set()
  1846. while True:
  1847. _check_stop(ctx)
  1848. if _page_is_closed(page):
  1849. ctx.log("[paypal] 页面已被关闭,停止等待支付完成")
  1850. return False
  1851. completion_reason = _detect_payment_completion(page)
  1852. if completion_reason:
  1853. ctx.log(f"[paypal] 支付完成确认: {completion_reason}")
  1854. _dump_page(ctx, page, "14-paypal-payment-complete")
  1855. return True
  1856. failure_reason = _detect_payment_failure(page)
  1857. if failure_reason:
  1858. ctx.log(f"[paypal] 支付失败信号: {failure_reason}")
  1859. _dump_page(ctx, page, "14-paypal-payment-failed")
  1860. raise PayPalPaymentFailed(failure_reason)
  1861. if _click_post_sms_action_if_available(ctx, page, clicked_actions):
  1862. last_status = ""
  1863. page.wait_for_timeout(2500)
  1864. continue
  1865. status = _summarize_payment_wait_status(page)
  1866. if status and status != last_status:
  1867. ctx.log(f"[paypal] 仍在等待支付完成: {status}")
  1868. last_status = status
  1869. if time.time() >= deadline:
  1870. if ctx.headless:
  1871. _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
  1872. raise TimeoutError(f"等待支付完成超时 {timeout}s")
  1873. if not manual_mode:
  1874. ctx.log("[paypal] 自动等待已超时,当前为可视浏览器,保持窗口打开;完成后会继续检测,或点击停止结束任务")
  1875. _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
  1876. manual_mode = True
  1877. interval = POST_PAYMENT_MANUAL_INTERVAL
  1878. page.wait_for_timeout(interval * 1000)
  1879. def _click_post_sms_action_if_available(ctx, page, clicked_actions: set) -> bool:
  1880. url = getattr(page, "url", "") or ""
  1881. candidates = []
  1882. if "paypal.com" in url:
  1883. candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_PAYPAL_ACTION_TEXTS)
  1884. if "pay.openai.com" in url or "checkout.stripe.com" in url:
  1885. candidates.append(('button[data-testid="hosted-payment-submit-button"]', "Stripe hosted submit"))
  1886. candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_STRIPE_ACTION_TEXTS)
  1887. for selector, label in candidates:
  1888. key = (url.split("?", 1)[0], label)
  1889. if key in clicked_actions:
  1890. continue
  1891. try:
  1892. loc = page.locator(selector).first
  1893. if loc.count() == 0:
  1894. continue
  1895. enabled = loc.is_enabled()
  1896. ctx.log(f"[paypal] 后续确认候选 {label} selector={selector} enabled={enabled}")
  1897. if not enabled:
  1898. continue
  1899. loc.click(timeout=4000)
  1900. clicked_actions.add(key)
  1901. ctx.log(f"[paypal] 已点击后续确认按钮: {label}")
  1902. return True
  1903. except Exception as exc:
  1904. ctx.log(f"[paypal] 后续确认按钮 {label} 处理失败: {exc!r}")
  1905. continue
  1906. return False
  1907. def _page_is_closed(page) -> bool:
  1908. try:
  1909. is_closed = getattr(page, "is_closed", None)
  1910. return bool(is_closed and is_closed())
  1911. except Exception:
  1912. return False
  1913. def _detect_payment_completion(page) -> str:
  1914. url = getattr(page, "url", "") or ""
  1915. snapshot = _payment_page_snapshot(page)
  1916. text = snapshot.get("text", "")
  1917. if "chatgpt.com" in url:
  1918. return f"已跳回 ChatGPT: {url}"
  1919. if _PAYMENT_COMPLETE_RE.search(text):
  1920. return "页面出现完成提示"
  1921. if (
  1922. ("pay.openai.com" in url or "checkout.stripe.com" in url)
  1923. and re.search(r"(complete|success|return|receipt)", url, re.I)
  1924. ):
  1925. return f"支付页进入完成 URL: {url}"
  1926. return ""
  1927. def _detect_payment_failure(page) -> str:
  1928. url = getattr(page, "url", "") or ""
  1929. if _PAYMENT_FAILED_URL_RE.search(url):
  1930. return f"URL 标记 redirect_status=failed: {url}"
  1931. snapshot = _payment_page_snapshot(page)
  1932. text = snapshot.get("text", "") or ""
  1933. alerts = snapshot.get("alerts", "") or ""
  1934. combined = f"{alerts} {text}"
  1935. m = _PAYMENT_FAILED_TEXT_RE.search(combined)
  1936. if m:
  1937. start = max(0, m.start() - 40)
  1938. end = min(len(combined), m.end() + 80)
  1939. return combined[start:end].strip()[:300]
  1940. return ""
  1941. def _summarize_payment_wait_status(page) -> str:
  1942. url = getattr(page, "url", "") or ""
  1943. snapshot = _payment_page_snapshot(page)
  1944. alerts = snapshot.get("alerts", "")
  1945. text = snapshot.get("text", "")
  1946. invalid_count = snapshot.get("invalid_count", 0)
  1947. parts = [f"URL={url}"]
  1948. if invalid_count:
  1949. parts.append(f"invalid_fields={invalid_count}")
  1950. if alerts:
  1951. parts.append(f"alert={alerts[:300]}")
  1952. else:
  1953. blocking = _PAYMENT_BLOCKING_RE.search(text)
  1954. if blocking:
  1955. start = max(0, blocking.start() - 80)
  1956. end = min(len(text), blocking.end() + 160)
  1957. parts.append(f"page_hint={text[start:end].strip()[:300]}")
  1958. return " | ".join(parts)
  1959. def _payment_page_snapshot(page) -> dict:
  1960. try:
  1961. data = page.evaluate(r"""() => {
  1962. const bodyText = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').trim();
  1963. const alerts = Array.from(document.querySelectorAll(
  1964. '[role="alert"], [data-testid*="error" i], [class*="error" i], [aria-invalid="true"]'
  1965. )).map(el => (el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim())
  1966. .filter(Boolean)
  1967. .slice(0, 5)
  1968. .join(' | ');
  1969. return {
  1970. title: document.title || '',
  1971. text: bodyText.slice(0, 5000),
  1972. alerts,
  1973. invalid_count: document.querySelectorAll('[aria-invalid="true"]').length
  1974. };
  1975. }""")
  1976. return data if isinstance(data, dict) else {}
  1977. except Exception:
  1978. return {}
  1979. def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
  1980. label = label or selector
  1981. shown = "***" if mask else value
  1982. try:
  1983. loc = page.locator(selector).first
  1984. count = loc.count()
  1985. if count == 0:
  1986. ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
  1987. return
  1988. loc.wait_for(state="visible", timeout=6000)
  1989. loc.fill(value, timeout=6000)
  1990. ctx.log(f"[fill] {label}({selector}) <- {shown}")
  1991. except Exception as exc:
  1992. ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
  1993. def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
  1994. label = label or selector
  1995. try:
  1996. loc = page.locator(selector).first
  1997. if loc.count() == 0:
  1998. ctx.log(f"[select] {label}({selector}) 不存在,跳过")
  1999. return
  2000. loc.wait_for(state="visible", timeout=4000)
  2001. for kind in ("value", "label", "raw"):
  2002. try:
  2003. if kind == "value":
  2004. loc.select_option(value=text, timeout=4000)
  2005. elif kind == "label":
  2006. loc.select_option(label=text, timeout=4000)
  2007. else:
  2008. loc.select_option(text, timeout=4000)
  2009. ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
  2010. return
  2011. except Exception as exc:
  2012. ctx.log(f"[select] {label} 尝试 {kind} 失败: {exc!r}")
  2013. ctx.log(f"[select] {label}({selector}) 三种方式都失败")
  2014. except Exception as exc:
  2015. ctx.log(f"[select] {label}({selector}) 异常: {exc!r}")
  2016. def _click_next(ctx, page, tag: str = ""):
  2017. candidates = [
  2018. 'button[data-testid="submit-button"]',
  2019. 'button[data-testid="hosted-payment-submit-button"]',
  2020. 'button[data-atomic-wait-intent="Submit_Email"]',
  2021. 'button.SubmitButton--complete',
  2022. ]
  2023. for sel in candidates:
  2024. loc = page.locator(sel).first
  2025. if loc.count() == 0:
  2026. continue
  2027. try:
  2028. enabled = loc.is_enabled()
  2029. ctx.log(f"[click:{tag}] 候选 {sel} count={loc.count()} enabled={enabled}")
  2030. if not enabled:
  2031. continue
  2032. try:
  2033. loc.click(timeout=4000)
  2034. ctx.log(f"[click:{tag}] 已点击 {sel}")
  2035. return True
  2036. except Exception as exc:
  2037. msg = str(exc)
  2038. if "intercepts pointer events" in msg or "captchaComponent" in msg:
  2039. ctx.log(f"[click:{tag}] {sel} 被蒙层挡住,尝试删 captcha 后重试")
  2040. _paypal_remove_captcha_overlay(ctx, page)
  2041. try:
  2042. loc.click(timeout=4000, force=True)
  2043. ctx.log(f"[click:{tag}] 重试已点击 {sel}")
  2044. return True
  2045. except Exception as exc2:
  2046. ctx.log(f"[click:{tag}] {sel} 重试仍失败: {exc2!r}")
  2047. continue
  2048. ctx.log(f"[click:{tag}] {sel} 点击失败: {exc!r}")
  2049. except Exception as exc:
  2050. ctx.log(f"[click:{tag}] {sel} 处理失败: {exc!r}")
  2051. continue
  2052. for text in ["Next", "Subscribe", "Pay", "Continue", "Agree", "下一步", "下一页", "訂閱"]:
  2053. loc = page.locator(f'button:has-text("{text}")').first
  2054. if loc.count() == 0:
  2055. continue
  2056. try:
  2057. enabled = loc.is_enabled()
  2058. ctx.log(f"[click:{tag}] 文本候选 has-text={text!r} enabled={enabled}")
  2059. if not enabled:
  2060. continue
  2061. try:
  2062. loc.click(timeout=4000)
  2063. ctx.log(f"[click:{tag}] 已点击 has-text={text!r}")
  2064. return True
  2065. except Exception as exc:
  2066. msg = str(exc)
  2067. if "intercepts pointer events" in msg or "captchaComponent" in msg:
  2068. ctx.log(f"[click:{tag}] has-text={text!r} 被蒙层挡住,删 captcha 重试")
  2069. _paypal_remove_captcha_overlay(ctx, page)
  2070. try:
  2071. loc.click(timeout=4000, force=True)
  2072. ctx.log(f"[click:{tag}] 重试已点击 has-text={text!r}")
  2073. return True
  2074. except Exception as exc2:
  2075. ctx.log(f"[click:{tag}] has-text={text!r} 重试仍失败: {exc2!r}")
  2076. continue
  2077. ctx.log(f"[click:{tag}] has-text={text!r} 点击失败: {exc!r}")
  2078. except Exception as exc:
  2079. ctx.log(f"[click:{tag}] has-text={text!r} 处理失败: {exc!r}")
  2080. continue
  2081. ctx.log(f"[click:{tag}] 未找到任何可点击的下一步按钮")
  2082. return False
  2083. def run(ctx: RunContext):
  2084. ctx.log(f"[run] === 任务开始 run_id={ctx.run_id} artifact_dir={ctx.artifact_dir} ===")
  2085. try:
  2086. ctx.long_link = generate_long_link(ctx)
  2087. run_paypal_flow(ctx)
  2088. ctx.state = "done"
  2089. ctx.log("[run] === 全部步骤已尝试完成 ===")
  2090. except Exception as exc:
  2091. if str(exc) == "STOPPED_BY_USER":
  2092. ctx.log("[run] === 用户中止 ===")
  2093. ctx.state = "stopped"
  2094. else:
  2095. ctx.log(f"[run] === 异常退出: {exc!r} ===")
  2096. ctx.log(traceback.format_exc())
  2097. ctx.state = "error"