automation.py 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655
  1. """端到端自动化:拿 ChatGPT 长链 → Stripe 选 PayPal → PayPal 注册绑卡 → 等短信。"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import re
  6. import time
  7. import traceback
  8. from dataclasses import dataclass, field
  9. from typing import Callable, Optional
  10. from providers import fetch_us_address, fetch_visa_card, fetch_sms_code
  11. try:
  12. from curl_cffi import requests as curl_requests
  13. except Exception:
  14. curl_requests = None
  15. CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
  16. PAYURL_CHECKOUT_URL = "https://payurl.ark2.cn/api/checkout"
  17. PROXY_FOR_LONGLINK = "http://127.0.0.1:7890"
  18. DEFAULT_PHONE_E164 = "+15822201173"
  19. DEFAULT_SMS_API_URL = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
  20. PHONE_E164 = DEFAULT_PHONE_E164
  21. PHONE_NUMBER = PHONE_E164.removeprefix("+1")
  22. PHONE_COUNTRY = "US"
  23. POST_PAYMENT_WAIT_TIMEOUT = 600
  24. POST_PAYMENT_WAIT_INTERVAL = 2
  25. POST_PAYMENT_MANUAL_INTERVAL = 5
  26. POST_SMS_PAYPAL_ACTION_TEXTS = (
  27. "Agree & Create Account",
  28. "Agree and Create Account",
  29. "Agree & Continue",
  30. "Agree and Continue",
  31. "同意并继续",
  32. )
  33. POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
  34. LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
  35. _PAYMENT_COMPLETE_RE = re.compile(
  36. r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
  37. r"thank\s+you|you['’]?re\s+all\s+set|subscription\s+(active|started|complete)|welcome\s+to\s+chatgpt\s+plus|"
  38. r"支付成功|付款成功|订阅成功|已完成)",
  39. re.I,
  40. )
  41. _PAYMENT_BLOCKING_RE = re.compile(
  42. r"(validation|error|declined|failed|couldn['’]?t|cannot|try\s+again|invalid|"
  43. r"验证|错误|失败|无效|无法|重试)",
  44. re.I,
  45. )
  46. _PAYMENT_FAILED_URL_RE = re.compile(r"redirect_status=failed|payment[_-]?status=failed", re.I)
  47. _PAYMENT_FAILED_TEXT_RE = re.compile(
  48. r"an\s+error\s+occurred\s+while\s+processing\s+your\s+payment|"
  49. r"payment\s+failed|"
  50. r"try\s+again\s+later\s+or\s+with\s+a\s+different\s+payment\s+method|"
  51. r"card\s+(?:was\s+)?declined|"
  52. r"payment\s+method\s+(?:is\s+)?not\s+supported|"
  53. r"we\s+weren['’]?t\s+able\s+to\s+add\s+this\s+card|"
  54. r"unable\s+to\s+add\s+this\s+card|"
  55. r"check\s+all\s+the\s+details\s+are\s+correct\s+and\s+try\s+again|"
  56. r"try\s+a\s+different\s+card|"
  57. r"sorry,?\s+something\s+went\s+wrong\.?\s*please\s+try\s+again|"
  58. r"we\s+couldn['’]?t\s+process\s+(?:your\s+)?(?:payment|request)|"
  59. r"we\s+can'?t\s+continue\s+with\s+this\s+payment|"
  60. r"this\s+card\s+has\s+already\s+been\s+added\s+to\s+another\s+paypal\s+account|"
  61. r"create_card_account_candidate_validation_error|"
  62. r"unmapped[_\s-]?oas[_\s-]?error|"
  63. r"oas[_\s-]?error|"
  64. r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
  65. r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
  66. r"your\s+account\s+is\s+limited|"
  67. r"check\s+your\s+paypal\s+account\s+overview\s+page|"
  68. r"how\s+to\s+resolve\s+this\s+problem|"
  69. r"account\s+(?:has\s+been\s+)?(?:temporarily\s+)?(?:limited|restricted|suspended|locked)|"
  70. r"weasley_fallback_to_hermes|"
  71. r"r_error\b|\br[_\s-]?error\b|"
  72. r"账[户号]?(?:已)?受限|账[户号]?(?:已)?(?:被)?(?:冻结|限制|暂停)|"
  73. r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
  74. r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
  75. re.I,
  76. )
  77. class PayPalPaymentFailed(Exception):
  78. """PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
  79. MAX_PAYPAL_RETRIES = 3
  80. def _detect_payment_failure(page) -> str:
  81. """检测明确的"支付失败"信号。返回非空字符串表示已失败。"""
  82. url = getattr(page, "url", "") or ""
  83. if _PAYMENT_FAILED_URL_RE.search(url):
  84. return f"URL 标记 redirect_status=failed: {url}"
  85. snapshot = _payment_page_snapshot(page)
  86. text = snapshot.get("text", "") or ""
  87. alerts = snapshot.get("alerts", "") or ""
  88. blob = f"{text}\n{alerts}"
  89. m = _PAYMENT_FAILED_TEXT_RE.search(blob)
  90. if m:
  91. # 截上下文方便日志
  92. start = max(0, (m.start() if hasattr(m, "start") else 0) - 60)
  93. end = min(len(blob), (m.end() if hasattr(m, "end") else 0) + 200)
  94. return f"页面失败提示:{blob[start:end].strip()[:400]}"
  95. return ""
  96. def _replace_browser_context(ctx, page):
  97. """彻底重新生成一个浏览器 context(PerimeterX 等基于 fingerprint 的反爬最有效的对策)。
  98. 注意:调用方持有的 page 引用不会被替换;这里只是把当前 context 关掉重开+加载空白页,
  99. 并复用同一个 browser。如果调用方传入的 page 已经死,会自然在下次 page.goto 时报错。
  100. """
  101. ctx.log("[playwright] 重建 browser context(清 PerimeterX 等指纹)")
  102. try:
  103. old_ctx = page.context
  104. browser = old_ctx.browser
  105. except Exception as exc:
  106. ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退普通清缓存")
  107. _clear_browser_state(ctx, page)
  108. return
  109. try:
  110. for p in list(old_ctx.pages):
  111. try:
  112. p.close()
  113. except Exception:
  114. pass
  115. old_ctx.close()
  116. ctx.log("[playwright] 旧 context 已关闭")
  117. except Exception as exc:
  118. ctx.log(f"[playwright] 关旧 context 失败: {exc!r}")
  119. # 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
  120. # 兜底:用 _clear_browser_state 当作降级
  121. try:
  122. new_ctx = browser.new_context(
  123. locale="en-US",
  124. timezone_id="America/New_York",
  125. viewport={"width": 1280, "height": 900},
  126. )
  127. new_page = new_ctx.new_page()
  128. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  129. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  130. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  131. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  132. new_page.goto("about:blank", wait_until="domcontentloaded", timeout=10000)
  133. # 替换 ctx 上的 page 引用(chatgpt_flow 调 run_paypal_flow 时也是从 ctx._next_page 取)
  134. ctx._next_page = new_page # type: ignore
  135. ctx.log("[playwright] 新 context + 新 page 已就绪 (ctx._next_page)")
  136. except Exception as exc:
  137. ctx.log(f"[playwright] 新 context 创建失败: {exc!r}")
  138. def _clear_browser_state(ctx, page):
  139. """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。"""
  140. ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
  141. try:
  142. bctx = page.context
  143. bctx.clear_cookies()
  144. ctx.log("[playwright] context.clear_cookies() 完成")
  145. except Exception as exc:
  146. ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
  147. try:
  148. bctx = page.context
  149. # 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
  150. page.evaluate(r"""() => {
  151. try { localStorage.clear(); } catch (_) {}
  152. try { sessionStorage.clear(); } catch (_) {}
  153. try {
  154. if (window.indexedDB && indexedDB.databases) {
  155. indexedDB.databases().then((dbs) => {
  156. (dbs || []).forEach((db) => {
  157. if (db && db.name) {
  158. try { indexedDB.deleteDatabase(db.name); } catch (_) {}
  159. }
  160. });
  161. });
  162. }
  163. } catch (_) {}
  164. try {
  165. if (window.caches && caches.keys) {
  166. caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
  167. }
  168. } catch (_) {}
  169. }""")
  170. ctx.log("[playwright] localStorage/sessionStorage/IndexedDB/caches 清理完成(当前页)")
  171. except Exception as exc:
  172. ctx.log(f"[playwright] 当前页 storage 清理失败: {exc!r}")
  173. # 关掉所有 PayPal/Stripe 旧标签,最后回到一个空白页
  174. try:
  175. for p in list(page.context.pages):
  176. try:
  177. if p is page:
  178. continue
  179. p.close()
  180. except Exception:
  181. pass
  182. page.goto("about:blank", wait_until="domcontentloaded", timeout=15000)
  183. except Exception as exc:
  184. ctx.log(f"[playwright] 关旧 tab/转空白页 失败: {exc!r}")
  185. @dataclass
  186. class RunContext:
  187. token: str
  188. plan: str = "plus"
  189. country: str = "US"
  190. currency: str = "USD"
  191. use_promo: bool = True
  192. headless: bool = False
  193. log: Callable[[str], None] = print
  194. state: str = "running"
  195. stage: str = ""
  196. on_stage: Optional[Callable[[str], None]] = None
  197. long_link: str = ""
  198. email: str = "" # ChatGPT 注册邮箱(@edu.a4sky.com)
  199. paypal_email: str = "" # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
  200. password: str = ""
  201. phone_e164: str = DEFAULT_PHONE_E164
  202. sms_api_url: str = DEFAULT_SMS_API_URL
  203. paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
  204. card: dict = field(default_factory=dict)
  205. address: dict = field(default_factory=dict)
  206. run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
  207. @property
  208. def phone_number(self) -> str:
  209. """无国家码的手机号(只去掉前导 +1,对应 PayPal #phone 字段格式)。"""
  210. e164 = (self.phone_e164 or DEFAULT_PHONE_E164).strip()
  211. return e164.removeprefix("+1") if e164.startswith("+1") else e164.lstrip("+")
  212. @property
  213. def artifact_dir(self) -> str:
  214. d = os.path.join(LOG_DIR, self.run_id)
  215. os.makedirs(d, exist_ok=True)
  216. return d
  217. def set_stage(self, name: str):
  218. self.stage = name
  219. self.log(f"[stage] {name}")
  220. if self.on_stage:
  221. try:
  222. self.on_stage(name)
  223. except Exception:
  224. pass
  225. def _request_headers(token: str) -> dict:
  226. return {
  227. "Authorization": f"Bearer {token}",
  228. "Content-Type": "application/json",
  229. "Accept": "application/json",
  230. "Origin": "https://chatgpt.com",
  231. "Referer": "https://chatgpt.com/",
  232. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  233. "User-Agent": (
  234. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  235. "AppleWebKit/537.36 (KHTML, like Gecko) "
  236. "Chrome/136.0.0.0 Safari/537.36"
  237. ),
  238. }
  239. def _checkout_payload(plan: str, country: str, currency: str, use_promo: bool) -> dict:
  240. payload = {
  241. "plan_name": "chatgptplusplan" if plan == "plus" else "chatgptteamplan",
  242. "billing_details": {"country": country.upper(), "currency": currency.upper()},
  243. "checkout_ui_mode": "hosted",
  244. "cancel_url": "https://chatgpt.com/#pricing",
  245. }
  246. if use_promo and plan == "plus":
  247. payload["promo_campaign"] = {
  248. "promo_campaign_id": "plus-1-month-free",
  249. "is_coupon_from_query_param": True,
  250. }
  251. return payload
  252. def generate_long_link(ctx: RunContext) -> str:
  253. ctx.log(f"[longlink] === 开始生成长链 ===")
  254. ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
  255. ctx.log(f"[longlink] 代理={PROXY_FOR_LONGLINK}")
  256. payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
  257. ctx.log(f"[longlink] payload={json.dumps(payload, ensure_ascii=False)}")
  258. if curl_requests is None:
  259. raise RuntimeError("缺少 curl_cffi,请先 pip install curl_cffi")
  260. proxies = {"http": PROXY_FOR_LONGLINK, "https": PROXY_FOR_LONGLINK}
  261. started = time.time()
  262. try:
  263. response = curl_requests.post(
  264. CHECKOUT_URL,
  265. json=payload,
  266. headers=_request_headers(ctx.token),
  267. impersonate="chrome136",
  268. proxies=proxies,
  269. timeout=30,
  270. )
  271. except Exception as exc:
  272. ctx.log(f"[longlink] 请求异常({int((time.time()-started)*1000)}ms): {exc!r}")
  273. raise
  274. text = response.text
  275. ctx.log(f"[longlink] HTTP {response.status_code} 耗时 {int((time.time()-started)*1000)}ms 返回长度={len(text)}")
  276. ctx.log(f"[longlink] 响应预览: {text[:500]}")
  277. if response.status_code >= 400:
  278. raise RuntimeError(f"创建 checkout 失败: HTTP {response.status_code} {text[:300]}")
  279. try:
  280. data = json.loads(text or "{}")
  281. except json.JSONDecodeError as exc:
  282. raise RuntimeError(f"长链响应不是 JSON: {exc!r} 原文={text[:300]}")
  283. link = data.get("url") or data.get("stripe_hosted_url") or data.get("checkout_url")
  284. session_id = data.get("checkout_session_id")
  285. processor = data.get("processor_entity")
  286. ctx.log(f"[longlink] checkout_session_id={session_id} processor={processor}")
  287. if not link:
  288. raise RuntimeError(f"未在响应中解析到长链: {text[:300]}")
  289. ctx.log(f"[longlink] 成功取到 long_link={link}")
  290. return link
  291. def generate_long_link_payurl(ctx: RunContext) -> str:
  292. """走 payurl.ark2.cn(与 Chrome 扩展 get-plus-link.js 一致),不走本地代理。"""
  293. ctx.log("[longlink] === 通过 payurl.ark2.cn 获取 Plus 长链 ===")
  294. ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
  295. payload = {
  296. "token": ctx.token,
  297. "plan": ctx.plan or "plus",
  298. "checkout_ui_mode": "hosted",
  299. "ui_language": "en",
  300. "country": (ctx.country or "US").upper(),
  301. "currency": (ctx.currency or "USD").upper(),
  302. "proxy": "",
  303. "use_promo": bool(ctx.use_promo),
  304. "promo_code": "STRIPEATLASGPT4BIZ050126",
  305. "workspace_name": "linux-do",
  306. "seat_quantity": 2,
  307. }
  308. headers = {
  309. "Accept": "*/*",
  310. "Accept-Language": "zh-CN,zh;q=0.9",
  311. "Content-Type": "application/json",
  312. "DNT": "1",
  313. "Origin": "https://payurl.ark2.cn",
  314. "Referer": "https://payurl.ark2.cn/",
  315. }
  316. masked = dict(payload)
  317. masked["token"] = (ctx.token or "")[:24] + "..."
  318. ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
  319. # 长链生成走全局代理(payurl.ark2.cn 自身不一定需要,但保持与浏览器同源更稳)
  320. proxies = None
  321. proxy_url = getattr(ctx, "paypal_proxy", "") or ""
  322. if proxy_url:
  323. proxies = {"http": proxy_url, "https": proxy_url}
  324. ctx.log(f"[longlink] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
  325. max_attempts = 5
  326. last_err = ""
  327. for attempt in range(1, max_attempts + 1):
  328. ctx.log(f"[longlink] 第 {attempt}/{max_attempts} 次请求 {PAYURL_CHECKOUT_URL}")
  329. started = time.time()
  330. try:
  331. if curl_requests is not None:
  332. r = curl_requests.post(
  333. PAYURL_CHECKOUT_URL,
  334. json=payload,
  335. headers=headers,
  336. impersonate="chrome136",
  337. timeout=30,
  338. proxies=proxies,
  339. )
  340. text = r.text
  341. status = r.status_code
  342. else:
  343. import urllib.request
  344. data = json.dumps(payload).encode("utf-8")
  345. req = urllib.request.Request(PAYURL_CHECKOUT_URL, data=data, method="POST")
  346. for k, v in headers.items():
  347. req.add_header(k, v)
  348. opener = urllib.request.build_opener()
  349. if proxy_url:
  350. proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
  351. opener = urllib.request.build_opener(proxy_handler)
  352. with opener.open(req, timeout=30) as resp:
  353. text = resp.read().decode("utf-8", errors="replace")
  354. status = resp.status
  355. except Exception as exc:
  356. last_err = repr(exc)
  357. ctx.log(f"[longlink] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
  358. time.sleep(1.5)
  359. continue
  360. ctx.log(f"[longlink] HTTP {status} 耗时 {int((time.time()-started)*1000)}ms 长度={len(text)} 预览={text[:300]}")
  361. if status >= 400:
  362. last_err = f"HTTP {status}: {text[:300]}"
  363. time.sleep(1.5)
  364. continue
  365. try:
  366. data = json.loads(text or "{}")
  367. except Exception as exc:
  368. last_err = f"非 JSON: {exc!r}"
  369. time.sleep(1.5)
  370. continue
  371. link = data.get("url") or data.get("openai_payurl") or data.get("chatgpt_checkout_url")
  372. if link:
  373. ctx.log(f"[longlink] 成功取到 long_link={link} sessionId={data.get('checkout_session_id', '?')}")
  374. return link
  375. last_err = f"响应缺 url 字段: {text[:300]}"
  376. time.sleep(1.5)
  377. raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
  378. def _rand_email() -> str:
  379. import random
  380. import string
  381. name = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
  382. return f"{name}@gmail.com"
  383. def _rand_paypal_email() -> str:
  384. """PayPal 注册用的独立邮箱(与 ChatGPT 注册邮箱解耦)。
  385. 用 gmail 域,一来 PayPal 对 gmail 的接受度高,二来不与 a4sky 邮件助手挂钩。
  386. """
  387. import random
  388. import string
  389. # 8-12 位随机字母数字 + 点号风格更像真人
  390. body_len = random.randint(8, 12)
  391. body = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(body_len))
  392. # 加一个随机数字尾巴
  393. tail = "".join(random.choice(string.digits) for _ in range(random.randint(2, 4)))
  394. return f"{body}{tail}@gmail.com"
  395. def _rand_password() -> str:
  396. import random
  397. import string
  398. pools = [
  399. random.choice(string.ascii_uppercase),
  400. random.choice(string.ascii_lowercase),
  401. random.choice(string.digits),
  402. random.choice("!@#$%^"),
  403. ]
  404. pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^") for _ in range(10)]
  405. random.shuffle(pools)
  406. return "".join(pools)
  407. def _check_stop(ctx: RunContext):
  408. if ctx.state == "stopped":
  409. raise RuntimeError("STOPPED_BY_USER")
  410. # 让上层(chatgpt_flow.FullRunContext)注入的 hook 也能短路停止
  411. hook = getattr(ctx, "_stop_hook", None)
  412. if callable(hook):
  413. try:
  414. hook()
  415. except Exception:
  416. raise
  417. def _dump_page(ctx: RunContext, page, tag: str):
  418. """失败/检查点时把当前页 URL/截图/HTML 都落盘。"""
  419. try:
  420. url = page.url
  421. except Exception:
  422. url = "<unknown>"
  423. ctx.log(f"[dump:{tag}] URL={url}")
  424. try:
  425. title = page.title()
  426. ctx.log(f"[dump:{tag}] title={title!r}")
  427. except Exception as exc:
  428. ctx.log(f"[dump:{tag}] 取标题失败: {exc!r}")
  429. base = os.path.join(ctx.artifact_dir, f"{int(time.time()*1000)}-{tag}")
  430. try:
  431. page.screenshot(path=base + ".png", full_page=True)
  432. ctx.log(f"[dump:{tag}] 截图 -> {base}.png")
  433. except Exception as exc:
  434. ctx.log(f"[dump:{tag}] 截图失败: {exc!r}")
  435. try:
  436. html = page.content()
  437. with open(base + ".html", "w", encoding="utf-8") as f:
  438. f.write(html)
  439. ctx.log(f"[dump:{tag}] HTML -> {base}.html ({len(html)} bytes)")
  440. except Exception as exc:
  441. ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
  442. def _parse_proxy_url(proxy_url: str) -> dict | None:
  443. """把 http://user:pass@host:port 解析成 Playwright proxy 配置。空串返回 None。"""
  444. if not proxy_url or not proxy_url.strip():
  445. return None
  446. from urllib.parse import urlparse
  447. p = urlparse(proxy_url.strip())
  448. if not p.hostname or not p.port:
  449. return None
  450. server = f"{p.scheme or 'http'}://{p.hostname}:{p.port}"
  451. out: dict = {"server": server}
  452. if p.username:
  453. out["username"] = p.username
  454. if p.password:
  455. out["password"] = p.password
  456. return out
  457. def _open_paypal_context(ctx, page):
  458. """为 PayPal 阶段开一个带代理的新 context,返回 (new_page, old_context)。
  459. 若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
  460. 就返回 (page, None) 表示沿用当前 page。
  461. """
  462. proxy_cfg = _parse_proxy_url(getattr(ctx, "paypal_proxy", "") or "")
  463. if not proxy_cfg:
  464. ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
  465. return page, None
  466. # 若当前 context 已带相同代理,无需再切
  467. try:
  468. current_proxy = page.context._impl_obj._initializer.get("proxy") if hasattr(page.context, "_impl_obj") else None
  469. except Exception:
  470. current_proxy = None
  471. if current_proxy and isinstance(current_proxy, dict) and current_proxy.get("server") == proxy_cfg.get("server"):
  472. ctx.log(f"[playwright] 当前 context 已使用代理 {proxy_cfg.get('server')},PayPal 阶段沿用")
  473. return page, None
  474. masked = dict(proxy_cfg)
  475. if masked.get("password"):
  476. masked["password"] = "***"
  477. ctx.log(f"[playwright] PayPal 阶段使用代理: {masked}")
  478. try:
  479. browser = page.context.browser
  480. except Exception as exc:
  481. ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退直连")
  482. return page, None
  483. try:
  484. old_ctx = page.context
  485. new_ctx = browser.new_context(
  486. locale="en-US",
  487. timezone_id="America/New_York",
  488. viewport={"width": 1280, "height": 900},
  489. proxy=proxy_cfg,
  490. )
  491. new_page = new_ctx.new_page()
  492. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  493. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  494. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  495. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  496. ctx.log("[playwright] PayPal 代理 context 已就绪")
  497. return new_page, old_ctx
  498. except Exception as exc:
  499. ctx.log(f"[playwright] 新建代理 context 失败: {exc!r},回退直连")
  500. return page, None
  501. def _close_paypal_context(ctx, paypal_page, old_ctx):
  502. """关掉 PayPal 阶段的代理 context(如果有),返回原始 page 用于后续拉 session。"""
  503. if old_ctx is None:
  504. return paypal_page # 没切代理,直接返回
  505. try:
  506. new_ctx = paypal_page.context
  507. for p in list(new_ctx.pages):
  508. try:
  509. p.close()
  510. except Exception:
  511. pass
  512. new_ctx.close()
  513. ctx.log("[playwright] PayPal 代理 context 已关闭")
  514. except Exception as exc:
  515. ctx.log(f"[playwright] 关闭代理 context 异常: {exc!r}")
  516. # 从老 context 找一个还活着的 page,没有就新开一个
  517. try:
  518. live_pages = [p for p in old_ctx.pages if not p.is_closed()]
  519. if live_pages:
  520. return live_pages[0]
  521. return old_ctx.new_page()
  522. except Exception as exc:
  523. ctx.log(f"[playwright] 切回老 context 异常: {exc!r}")
  524. return paypal_page
  525. def run_paypal_flow(ctx: RunContext, page=None):
  526. """跑 Stripe → PayPal。这一段不走代理。
  527. page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
  528. 支付失败会清缓存+重开长链最多重试 MAX_PAYPAL_RETRIES 次。
  529. """
  530. if page is None:
  531. return _run_paypal_flow_self_browser(ctx)
  532. ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
  533. ctx.set_stage("准备地址/卡/账号")
  534. ctx.address = fetch_us_address(log=ctx.log)
  535. ctx.card = fetch_visa_card(log=ctx.log)
  536. reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
  537. if reuse and ctx.email and ctx.password:
  538. ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
  539. else:
  540. ctx.email = _rand_email()
  541. ctx.password = _rand_password()
  542. ctx.log(f"[playwright] email={ctx.email}")
  543. ctx.log(f"[playwright] password={ctx.password}")
  544. ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
  545. ctx.log(f"[playwright] address={ctx.address}")
  546. ctx.log(f"[playwright] phone={ctx.phone_e164}")
  547. # 切到 PayPal 阶段的代理 context(如果配置了 paypal_proxy)
  548. paypal_page, old_ctx = _open_paypal_context(ctx, page)
  549. ctx.log("[playwright] 复用外部浏览器 page 跑 Stripe/PayPal")
  550. last_err: Exception | None = None
  551. try:
  552. for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
  553. ctx.paypal_email = _rand_paypal_email()
  554. ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
  555. try:
  556. ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
  557. ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
  558. paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
  559. _dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
  560. _stripe_select_paypal_and_submit(ctx, paypal_page)
  561. _paypal_signup_and_pay(ctx, paypal_page)
  562. ctx.log("[playwright] 流程完成")
  563. _dump_page(ctx, paypal_page, "99-final")
  564. return
  565. except PayPalPaymentFailed as exc:
  566. last_err = exc
  567. ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
  568. if attempt >= MAX_PAYPAL_RETRIES:
  569. ctx.log(f"[playwright] 已达最大重试次数 {MAX_PAYPAL_RETRIES},放弃")
  570. raise
  571. ctx.set_stage(f"支付失败,准备第 {attempt + 1} 次重试(清缓存+刷新长链)")
  572. try:
  573. _clear_browser_state(ctx, paypal_page)
  574. except Exception as exc2:
  575. ctx.log(f"[playwright] 清缓存异常(继续): {exc2!r}")
  576. try:
  577. ctx.address = fetch_us_address(log=ctx.log)
  578. ctx.card = fetch_visa_card(log=ctx.log)
  579. ctx.log(f"[playwright] 重试用新地址={ctx.address} 新卡尾号=***{ctx.card['number'][-4:]}")
  580. except Exception as exc2:
  581. ctx.log(f"[playwright] 重新拉取地址/卡片异常(继续): {exc2!r}")
  582. # 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
  583. # 表单会变 disabled。必须重新生成一个新的长链。
  584. try:
  585. new_link = generate_long_link_payurl(ctx)
  586. ctx.long_link = new_link
  587. ctx.log(f"[playwright] 重试用新长链 sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
  588. except Exception as exc2:
  589. ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
  590. ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
  591. time.sleep(30)
  592. continue
  593. except Exception as exc:
  594. ctx.log(f"[playwright] 流程异常: {exc!r}")
  595. ctx.log(traceback.format_exc())
  596. try:
  597. _dump_page(ctx, paypal_page, f"99-error-attempt{attempt}")
  598. except Exception:
  599. pass
  600. raise
  601. if last_err is not None:
  602. raise last_err
  603. finally:
  604. # 不论成功失败,都尝试关掉 PayPal 代理 context、切回原 page,便于上层继续拉 session
  605. try:
  606. ctx._post_paypal_page = _close_paypal_context(ctx, paypal_page, old_ctx) # type: ignore
  607. except Exception as exc:
  608. ctx.log(f"[playwright] 关闭 PayPal context 异常: {exc!r}")
  609. def _run_paypal_flow_self_browser(ctx: RunContext):
  610. """旧入口:自己开浏览器(保留兼容性)。"""
  611. from playwright.sync_api import sync_playwright
  612. ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
  613. ctx.set_stage("准备地址/卡/账号")
  614. ctx.address = fetch_us_address(log=ctx.log)
  615. ctx.card = fetch_visa_card(log=ctx.log)
  616. reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
  617. if reuse and ctx.email and ctx.password:
  618. ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
  619. else:
  620. ctx.email = _rand_email()
  621. ctx.password = _rand_password()
  622. ctx.log(f"[playwright] email={ctx.email}")
  623. ctx.log(f"[playwright] password={ctx.password}")
  624. ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
  625. ctx.log(f"[playwright] address={ctx.address}")
  626. ctx.log(f"[playwright] phone={ctx.phone_e164}")
  627. with sync_playwright() as p:
  628. ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
  629. browser = p.chromium.launch(
  630. headless=ctx.headless,
  631. args=["--disable-blink-features=AutomationControlled"],
  632. )
  633. context = browser.new_context(
  634. locale="en-US",
  635. timezone_id="America/New_York",
  636. viewport={"width": 1280, "height": 900},
  637. )
  638. new_page = context.new_page()
  639. new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
  640. new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
  641. new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
  642. new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
  643. try:
  644. for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
  645. ctx.paypal_email = _rand_paypal_email()
  646. ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}")
  647. try:
  648. ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
  649. new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
  650. _dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
  651. _stripe_select_paypal_and_submit(ctx, new_page)
  652. _paypal_signup_and_pay(ctx, new_page)
  653. ctx.log("[playwright] 流程完成")
  654. _dump_page(ctx, new_page, "99-final")
  655. return
  656. except PayPalPaymentFailed as exc:
  657. ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
  658. if attempt >= MAX_PAYPAL_RETRIES:
  659. raise
  660. _clear_browser_state(ctx, new_page)
  661. try:
  662. ctx.address = fetch_us_address(log=ctx.log)
  663. ctx.card = fetch_visa_card(log=ctx.log)
  664. except Exception as exc2:
  665. ctx.log(f"[playwright] 重抽地址/卡异常(继续): {exc2!r}")
  666. ctx.log("[playwright] 重试前等待 30s")
  667. time.sleep(30)
  668. except Exception as exc:
  669. ctx.log(f"[playwright] 流程异常: {exc!r}")
  670. ctx.log(traceback.format_exc())
  671. try:
  672. _dump_page(ctx, new_page, "99-error")
  673. except Exception:
  674. pass
  675. raise
  676. finally:
  677. try:
  678. browser.close()
  679. except Exception:
  680. pass
  681. def _stripe_select_paypal(ctx, page) -> bool:
  682. """多策略尝试选中 PayPal 支付方式。命中返回 True。"""
  683. # 1) 先等待至少一种 PayPal 候选出现,避免 timing 问题
  684. try:
  685. page.wait_for_function(
  686. r"""() => {
  687. const sels = [
  688. '#payment-method-accordion-item-title-paypal',
  689. 'input[type="radio"][value="paypal"]',
  690. 'input[name="paymentMethod"][value="paypal"]',
  691. '[data-testid*="paypal" i]',
  692. 'label[for*="paypal" i]',
  693. 'button[id*="paypal" i]',
  694. ];
  695. for (const s of sels) {
  696. if (document.querySelector(s)) return true;
  697. }
  698. const labels = Array.from(document.querySelectorAll('label, button, [role="radio"], [role="button"]'));
  699. return labels.some((el) => /paypal/i.test((el.innerText || el.getAttribute('aria-label') || '')));
  700. }""",
  701. timeout=15000,
  702. )
  703. except Exception as exc:
  704. ctx.log(f"[stripe] 等待 PayPal 候选出现超时: {exc!r}")
  705. # 2) 候选 selector 列表,按可靠度排序
  706. candidates = [
  707. '#payment-method-accordion-item-title-paypal',
  708. 'input[type="radio"][value="paypal"]',
  709. 'input[name="paymentMethod"][value="paypal"]',
  710. '[data-testid="payment-method-paypal"]',
  711. '[data-testid*="paypal" i]',
  712. 'label[for*="paypal" i]',
  713. 'button[id*="paypal" i]',
  714. '[role="radio"][aria-label*="paypal" i]',
  715. ]
  716. for sel in candidates:
  717. loc = page.locator(sel).first
  718. if loc.count() == 0:
  719. continue
  720. ctx.log(f"[stripe] PayPal 候选命中 {sel}")
  721. # 多种点法兜底
  722. for action in ("check", "click", "force_click", "js_click"):
  723. try:
  724. if action == "check":
  725. loc.check(force=True, timeout=4000)
  726. elif action == "click":
  727. loc.click(timeout=4000)
  728. elif action == "force_click":
  729. loc.click(timeout=4000, force=True)
  730. else:
  731. loc.evaluate("el => { el.click(); }")
  732. ctx.log(f"[stripe] {action}({sel}) 成功")
  733. page.wait_for_timeout(800)
  734. if _stripe_paypal_is_selected(page):
  735. return True
  736. except Exception as exc:
  737. ctx.log(f"[stripe] {action}({sel}) 失败: {exc!r}")
  738. continue
  739. # 没确认选中也继续下一种
  740. if _stripe_paypal_is_selected(page):
  741. return True
  742. # 3) 文本扫描:找包含 "PayPal" 文本的可点击元素,逐个尝试
  743. ctx.log("[stripe] 进入文本扫描兜底")
  744. try:
  745. n = page.evaluate(
  746. r"""() => {
  747. const all = Array.from(document.querySelectorAll(
  748. 'label, button, [role="radio"], [role="button"], [role="tab"], div[tabindex], a'
  749. ));
  750. const matches = all.filter((el) => {
  751. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  752. return /paypal/i.test(t);
  753. });
  754. window.__paypalCandidates = matches;
  755. return matches.length;
  756. }"""
  757. )
  758. ctx.log(f"[stripe] 文本扫描候选数={n}")
  759. for i in range(int(n or 0)):
  760. try:
  761. page.evaluate(
  762. r"""(i) => {
  763. const el = (window.__paypalCandidates || [])[i];
  764. if (el) { el.scrollIntoView({block:'center'}); el.click(); }
  765. }""",
  766. i,
  767. )
  768. page.wait_for_timeout(700)
  769. if _stripe_paypal_is_selected(page):
  770. ctx.log(f"[stripe] 文本扫描候选 idx={i} 命中")
  771. return True
  772. except Exception as exc:
  773. ctx.log(f"[stripe] 文本扫描候选 idx={i} 失败: {exc!r}")
  774. except Exception as exc:
  775. ctx.log(f"[stripe] 文本扫描兜底异常: {exc!r}")
  776. return False
  777. def _stripe_paypal_is_selected(page) -> bool:
  778. """通过多种特征判断 PayPal 已被选中。"""
  779. try:
  780. return bool(page.evaluate(
  781. r"""() => {
  782. // 1) 单选框 checked
  783. const r = document.querySelector('input[type="radio"][value="paypal"]:checked')
  784. || document.querySelector('input[name="paymentMethod"][value="paypal"]:checked');
  785. if (r) return true;
  786. // 2) accordion item with aria-selected/expanded=true 含 paypal
  787. const items = Array.from(document.querySelectorAll('[role="radio"], [role="tab"], button, label, div'));
  788. const sel = items.find((el) => {
  789. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  790. if (!/paypal/i.test(t)) return false;
  791. const checked = el.getAttribute('aria-checked') === 'true'
  792. || el.getAttribute('aria-selected') === 'true'
  793. || el.getAttribute('aria-expanded') === 'true'
  794. || /selected|active|checked/i.test(el.className || '');
  795. return checked;
  796. });
  797. if (sel) return true;
  798. // 3) 提交按钮文本包含 PayPal(Stripe 选 PayPal 后按钮会变 "Pay with PayPal")
  799. const btn = document.querySelector('button[data-testid="hosted-payment-submit-button"]');
  800. if (btn && /paypal/i.test(btn.innerText || '')) return true;
  801. return false;
  802. }"""
  803. ))
  804. except Exception:
  805. return False
  806. def _stripe_select_paypal_and_submit(ctx, page):
  807. ctx.log("[stripe] === 在 Stripe 选择 PayPal 并提交 ===")
  808. page.wait_for_timeout(2000)
  809. _check_stop(ctx)
  810. selected = _stripe_select_paypal(ctx, page)
  811. if not selected:
  812. _dump_page(ctx, page, "02-stripe-paypal-not-found")
  813. raise RuntimeError("无法在 Stripe 上选中 PayPal 支付方式")
  814. ctx.log("[stripe] PayPal 已选中")
  815. page.wait_for_timeout(1500)
  816. ctx.log("[stripe] 切换 billingCountry=US")
  817. _select_by_text(ctx, page, '#billingCountry', 'US', "billingCountry")
  818. page.wait_for_timeout(500)
  819. ctx.log("[stripe] 填写账单地址")
  820. addr = ctx.address
  821. _safe_fill(ctx, page, '#billingAddressLine1', addr["street"], "billingAddressLine1")
  822. page.keyboard.press('Escape')
  823. page.wait_for_timeout(200)
  824. _safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
  825. _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
  826. _select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
  827. if page.locator('#phoneNumber').count() > 0:
  828. _safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
  829. page.wait_for_timeout(400)
  830. cb = page.locator('#termsOfServiceConsentCheckbox')
  831. if cb.count() > 0:
  832. try:
  833. checked = cb.is_checked()
  834. ctx.log(f"[stripe] 协议复选框 checked={checked}")
  835. if not checked:
  836. cb.check(force=True, timeout=2000)
  837. ctx.log("[stripe] 已勾选协议")
  838. except Exception as exc:
  839. ctx.log(f"[stripe] 协议复选框处理失败: {exc!r}")
  840. else:
  841. ctx.log("[stripe] 未发现协议复选框")
  842. _dump_page(ctx, page, "03-stripe-filled")
  843. submit = page.locator('button[data-testid="hosted-payment-submit-button"]').first
  844. if submit.count() == 0:
  845. ctx.log("[stripe] 找不到 hosted-payment-submit-button")
  846. _dump_page(ctx, page, "04-stripe-no-submit")
  847. raise RuntimeError("hosted-payment-submit-button 不存在")
  848. klass = submit.get_attribute("class") or ""
  849. ctx.log(f"[stripe] 提交按钮 class={klass}")
  850. if "incomplete" in klass:
  851. ctx.log("[stripe] 警告:按钮仍处于 incomplete 状态,将再等 2s 重试")
  852. page.wait_for_timeout(2000)
  853. klass = submit.get_attribute("class") or ""
  854. ctx.log(f"[stripe] 再次检查 class={klass}")
  855. submit.click(timeout=8000)
  856. ctx.log("[stripe] 已点击提交,等待跳转 PayPal")
  857. try:
  858. page.wait_for_url(re.compile(r"paypal\.com"), timeout=60000)
  859. except Exception as exc:
  860. ctx.log(f"[stripe] 等待跳转到 PayPal 超时: {exc!r}")
  861. _dump_page(ctx, page, "04-stripe-no-redirect")
  862. raise
  863. ctx.log(f"[stripe] 已跳转: {page.url}")
  864. _dump_page(ctx, page, "05-paypal-arrived")
  865. def _detect_datadome_captcha(page) -> str:
  866. """检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
  867. 判定逻辑:
  868. 1) 如果页面已出现 PayPal 业务元素(邮箱输入框 / Create an Account / Continue / checkoutweb URL 等)
  869. → 直接判定"已通过",不再看 DataDome 残留
  870. 2) 否则看 DataDome iframe / 滑块 / 拦截文本
  871. """
  872. url = getattr(page, "url", "") or ""
  873. try:
  874. info = page.evaluate(
  875. r"""() => {
  876. const visible = (el) => {
  877. if (!el) return false;
  878. const s = window.getComputedStyle(el);
  879. if (s.display === 'none' || s.visibility === 'hidden') return false;
  880. const r = el.getBoundingClientRect();
  881. return r.width > 0 && r.height > 0;
  882. };
  883. const out = { hasIframe: false, hasSlider: false, snippet: '', hasBusiness: false, businessHint: '' };
  884. // 正向:PayPal 业务页面元素(出现即代表已过 DataDome)
  885. const emailInput = document.querySelector(
  886. 'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
  887. );
  888. if (emailInput && visible(emailInput)) {
  889. out.hasBusiness = true;
  890. out.businessHint = 'email-input-visible';
  891. }
  892. if (!out.hasBusiness) {
  893. const c2pBtns = Array.from(document.querySelectorAll(
  894. 'button, a, [role="button"]'
  895. )).filter(visible);
  896. const matched = c2pBtns.find((el) => {
  897. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  898. return /create\s+an?\s+account|continue\s+to\s+payment|pay\s+with\s+(?:debit|credit)\s+card/i.test(t);
  899. });
  900. if (matched) {
  901. out.hasBusiness = true;
  902. out.businessHint = 'business-button:' + (matched.innerText || '').slice(0, 40);
  903. }
  904. }
  905. // 反向:DataDome 拦截特征(仅当业务元素未出现才看)
  906. if (!out.hasBusiness) {
  907. const ifr = document.querySelector(
  908. 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
  909. );
  910. if (ifr && visible(ifr)) out.hasIframe = true;
  911. const slider = document.querySelector(
  912. '[id*="datadome" i] [class*="slider" i], [class*="datadome" i] [class*="slider" i], '
  913. + '[id^="ddc-"], [class*="captcha-container" i], [aria-label*="slider" i][role="slider"]'
  914. );
  915. if (slider && visible(slider)) out.hasSlider = true;
  916. const t = (document.body && document.body.innerText || '').replace(/\s+/g, ' ');
  917. 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)) {
  918. out.hasSlider = true;
  919. out.snippet = t.slice(0, 200);
  920. }
  921. }
  922. return out;
  923. }"""
  924. ) or {}
  925. except Exception:
  926. info = {}
  927. # 已经在 PayPal 业务页面 → 一定通过
  928. if info.get("hasBusiness"):
  929. return ""
  930. # URL 已经进入 /checkoutweb/ 或 /pay/ 带业务参数 → 也算通过
  931. if "/checkoutweb/" in url:
  932. return ""
  933. if ("paypal_client_cfci=" in url) or ("paypal_client_metadata_id=" in url):
  934. return ""
  935. if info.get("hasIframe") or info.get("hasSlider"):
  936. return f"DataDome captcha 检测到 iframe={info.get('hasIframe')} slider={info.get('hasSlider')} url={url} snippet={info.get('snippet', '')[:120]!r}"
  937. return ""
  938. def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
  939. """提示用户手动过滑块,轮询直到 DataDome 元素消失或超时。"""
  940. ctx.set_stage("⚠️ 检测到 DataDome 滑块校验,请在浏览器中手动滑动完成(最长等 5 分钟)")
  941. deadline = time.time() + max_wait_sec
  942. last_log = 0.0
  943. poll_interval = 0.5 # 高频检测,滑过后 ≤500ms 就能继续
  944. stable_needed = 2 # 连续 2 次检测不到才算真的通过(避免 DataDome 中间状态误判)
  945. stable_count = 0
  946. while time.time() < deadline:
  947. _check_stop(ctx)
  948. if not _detect_datadome_captcha(page):
  949. stable_count += 1
  950. if stable_count >= stable_needed:
  951. ctx.log("[paypal] DataDome 已通过,继续流程")
  952. # 给页面 1s 完成跳转,但不再傻等
  953. page.wait_for_timeout(1000)
  954. return True
  955. else:
  956. stable_count = 0
  957. if time.time() - last_log > 10:
  958. remaining = int(deadline - time.time())
  959. ctx.log(f"[paypal] 仍在等待人工通过 DataDome 滑块... 剩余 {remaining}s")
  960. last_log = time.time()
  961. time.sleep(poll_interval)
  962. ctx.log("[paypal] DataDome 等待超时")
  963. return False
  964. def _paypal_signup_and_pay(ctx, page):
  965. ctx.log("[paypal] === 进入 PayPal 流程 ===")
  966. page.wait_for_load_state("domcontentloaded", timeout=30000)
  967. page.wait_for_timeout(1500)
  968. _check_stop(ctx)
  969. # PayPal 着陆即可能弹 DataDome 滑块(agreements/approve、checkoutweb 都见过)
  970. captcha_reason = _detect_datadome_captcha(page)
  971. if captcha_reason:
  972. ctx.log(f"[paypal] {captcha_reason}")
  973. if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
  974. _dump_page(ctx, page, "06-paypal-datadome-timeout")
  975. raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
  976. _paypal_clear_session(ctx, page)
  977. # 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
  978. from paypal_flow import ensure_checkoutweb
  979. on_stage = getattr(ctx, "on_stage", None)
  980. path_taken = ensure_checkoutweb(
  981. page,
  982. fallback_email=ctx.paypal_email or ctx.email,
  983. log=ctx.log,
  984. on_stage=on_stage,
  985. )
  986. ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
  987. _dump_page(ctx, page, "06-paypal-checkoutweb-entered")
  988. # 进 /checkoutweb/ 之后再次检测 DataDome(PayPal 有时会在表单页弹)
  989. captcha_reason = _detect_datadome_captcha(page)
  990. if captcha_reason:
  991. ctx.log(f"[paypal] {captcha_reason}")
  992. if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
  993. _dump_page(ctx, page, "06b-paypal-datadome-timeout")
  994. raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
  995. if path_taken == "unknown":
  996. # 上层没识别到 /checkoutweb/,强行往下填表只会错位 → 直接抛失败让外层 retry
  997. ctx.log("[paypal] ensure_checkoutweb 返回 unknown,直接判定本次失败以触发重试")
  998. raise PayPalPaymentFailed(f"未能进入 /checkoutweb/ 表单页, url={page.url}")
  999. ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
  1000. for sel in ('#cardNumber', '#phone', '#billingLine1'):
  1001. try:
  1002. page.locator(sel).first.wait_for(state="visible", timeout=20000)
  1003. ctx.log(f"[paypal] {sel} 已可见")
  1004. except Exception as exc:
  1005. ctx.log(f"[paypal] 等待 {sel} 超时: {exc!r}")
  1006. _dump_page(ctx, page, "07-paypal-checkoutweb")
  1007. ctx.log("[paypal] 检查国家选择器")
  1008. country = page.locator('#country')
  1009. if country.count() > 0:
  1010. try:
  1011. current = country.input_value()
  1012. ctx.log(f"[paypal] 当前国家={current}")
  1013. if current != "US":
  1014. ctx.log("[paypal] 切换国家为 US")
  1015. country.select_option("US")
  1016. page.wait_for_timeout(2500)
  1017. ctx.log(f"[paypal] 切换后国家={country.input_value()}, URL={page.url}")
  1018. _dump_page(ctx, page, "08-paypal-country-switched")
  1019. except Exception as exc:
  1020. ctx.log(f"[paypal] 切换国家失败: {exc!r}")
  1021. else:
  1022. ctx.log("[paypal] 未发现 #country 选择器")
  1023. addr = ctx.address
  1024. card = ctx.card
  1025. ctx.log("[paypal] 第一轮填表")
  1026. _paypal_fill_form(ctx, page, addr, card)
  1027. page.wait_for_timeout(800)
  1028. invalid = _paypal_collect_invalid(page)
  1029. if invalid:
  1030. ctx.log(f"[paypal] 第一轮后仍有 aria-invalid='true' 的字段: {invalid},再补一次")
  1031. _paypal_fill_form(ctx, page, addr, card, retry_only=invalid)
  1032. page.wait_for_timeout(600)
  1033. _dump_page(ctx, page, "09-paypal-filled")
  1034. ctx.log("[paypal] 提交付款")
  1035. if not _click_next(ctx, page, "PayPal 提交"):
  1036. ctx.log("[paypal] 警告:未能找到可点击的提交按钮")
  1037. _dump_page(ctx, page, "10-paypal-no-submit")
  1038. page.wait_for_timeout(2500)
  1039. invalid_after = _paypal_collect_invalid(page)
  1040. if invalid_after:
  1041. ctx.log(f"[paypal] 提交后页面报错的字段: {invalid_after},再补再提交一次")
  1042. _paypal_fill_form(ctx, page, addr, card, retry_only=invalid_after)
  1043. page.wait_for_timeout(500)
  1044. _click_next(ctx, page, "PayPal 提交-2")
  1045. _paypal_handle_sms(ctx, page)
  1046. _PAYPAL_FIELDS = [
  1047. ("email", "value"),
  1048. ("phone", "value"),
  1049. ("cardNumber", "card-number"),
  1050. ("cardExpiry", "card-expiry"),
  1051. ("cardCvv", "card-cvv"),
  1052. ("firstName", "value"),
  1053. ("lastName", "value"),
  1054. ("billingLine1", "value"),
  1055. ("billingCity", "value"),
  1056. ("billingPostalCode", "value"),
  1057. ("password", "value"),
  1058. ]
  1059. def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
  1060. targets = retry_only or [k for k, _ in _PAYPAL_FIELDS]
  1061. if "email" in targets:
  1062. _safe_fill(ctx, page, '#email', ctx.paypal_email or ctx.email, "email")
  1063. if "phone" in targets:
  1064. _paypal_type_into(ctx, page, '#phone', ctx.phone_number, "phone")
  1065. if "cardNumber" in targets:
  1066. _paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
  1067. if "cardExpiry" in targets:
  1068. _paypal_type_into(ctx, page, '#cardExpiry', card["expiry"], "cardExpiry")
  1069. if "cardCvv" in targets:
  1070. _paypal_type_into(ctx, page, '#cardCvv', card["cvv"], "cardCvv")
  1071. if "firstName" in targets:
  1072. _safe_fill(ctx, page, '#firstName', "James", "firstName")
  1073. if "lastName" in targets:
  1074. _safe_fill(ctx, page, '#lastName', "Smith", "lastName")
  1075. if "billingLine1" in targets:
  1076. _safe_fill(ctx, page, '#billingLine1', addr["street"], "billingLine1")
  1077. try:
  1078. page.keyboard.press('Escape')
  1079. except Exception:
  1080. pass
  1081. if "billingCity" in targets:
  1082. _safe_fill(ctx, page, '#billingCity', addr["city"], "billingCity")
  1083. if "billingPostalCode" in targets:
  1084. _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
  1085. if any(k in targets for k in ("billingLine1", "billingCity", "billingPostalCode")):
  1086. _select_by_text(ctx, page, '#billingState', addr["state"], "billingState")
  1087. if "password" in targets:
  1088. _safe_fill(ctx, page, '#password', ctx.password, "password", mask=True)
  1089. def _paypal_type_into(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
  1090. label = label or selector
  1091. shown = "***" if mask else value
  1092. try:
  1093. loc = page.locator(selector).first
  1094. if loc.count() == 0:
  1095. ctx.log(f"[type] {label}({selector}) 不存在,跳过")
  1096. return
  1097. loc.wait_for(state="visible", timeout=8000)
  1098. loc.click()
  1099. loc.fill("")
  1100. loc.type(value, delay=20)
  1101. ctx.log(f"[type] {label}({selector}) <- {shown}")
  1102. except Exception as exc:
  1103. ctx.log(f"[type] {label}({selector}) 失败: {exc!r}")
  1104. def _paypal_collect_invalid(page):
  1105. try:
  1106. return page.evaluate("""() => {
  1107. const out = [];
  1108. document.querySelectorAll('[aria-invalid="true"]').forEach(el => {
  1109. if (el.id) out.push(el.id);
  1110. else if (el.name) out.push(el.name);
  1111. });
  1112. return out;
  1113. }""")
  1114. except Exception:
  1115. return []
  1116. def _paypal_clear_session(ctx, page):
  1117. ctx.log("[paypal] 清理 cookie/storage")
  1118. try:
  1119. before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
  1120. page.evaluate("""() => {
  1121. try { localStorage.clear(); } catch (e) {}
  1122. try { sessionStorage.clear(); } catch (e) {}
  1123. const host = location.hostname;
  1124. const parts = host.split('.');
  1125. const domains = [host, '.' + host];
  1126. for (let i = 1; i < parts.length - 1; i++) domains.push('.' + parts.slice(i).join('.'));
  1127. const cookies = document.cookie ? document.cookie.split(';') : [];
  1128. cookies.forEach(c => {
  1129. const name = c.split('=')[0].trim();
  1130. if (!name) return;
  1131. ['/', location.pathname].forEach(p => {
  1132. domains.forEach(d => {
  1133. document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
  1134. });
  1135. document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p;
  1136. });
  1137. });
  1138. }""")
  1139. after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
  1140. ctx.log(f"[paypal] cookie 数量 {before} -> {after}(httpOnly 项 JS 删不掉)")
  1141. except Exception as exc:
  1142. ctx.log(f"[paypal] 清理会话异常: {exc!r}")
  1143. def _paypal_remove_captcha_overlay(ctx, page):
  1144. try:
  1145. removed = page.evaluate("""() => {
  1146. const ids = ['captchaComponent', 'recaptcha', 'recaptcha-overlay', 'reCAPTCHAOverlay'];
  1147. let n = 0;
  1148. for (const id of ids) {
  1149. const el = document.getElementById(id);
  1150. if (el) { el.remove(); n++; }
  1151. }
  1152. document.querySelectorAll(
  1153. 'iframe[name="recaptcha"], iframe[title*="recaptcha" i], div[class*="captcha" i][class*="overlay" i]'
  1154. ).forEach(el => { el.remove(); n++; });
  1155. return n;
  1156. }""")
  1157. if removed:
  1158. ctx.log(f"[paypal] 已删除 captcha 相关蒙层节点 {removed} 个")
  1159. except Exception as exc:
  1160. ctx.log(f"[paypal] 删除 captcha 蒙层失败: {exc!r}")
  1161. def _paypal_handle_sms(ctx, page):
  1162. ctx.log("[paypal] === 检查是否需要短信验证码 ===")
  1163. page.wait_for_timeout(3000)
  1164. _check_stop(ctx)
  1165. _paypal_remove_captcha_overlay(ctx, page)
  1166. digit_inputs = page.locator('input[id^="ci-ciBasic-"]')
  1167. digit_count = 0
  1168. try:
  1169. digit_count = digit_inputs.count()
  1170. except Exception as exc:
  1171. ctx.log(f"[paypal] 探测分立 OTP 输入框失败: {exc!r}")
  1172. ctx.log(f"[paypal] 分立 OTP 输入框 ci-ciBasic-* count={digit_count}")
  1173. single_input = None
  1174. if digit_count == 0:
  1175. sms_inputs = [
  1176. 'input[name="otp"]',
  1177. 'input[autocomplete="one-time-code"]',
  1178. 'input#otp',
  1179. 'input[name="smsOtp"]',
  1180. 'input[id*="otp" i]',
  1181. ]
  1182. for sel in sms_inputs:
  1183. loc = page.locator(sel).first
  1184. try:
  1185. count = loc.count()
  1186. ctx.log(f"[paypal] 探测 SMS 输入框 selector={sel} count={count}")
  1187. if count == 0:
  1188. continue
  1189. loc.wait_for(state="visible", timeout=4000)
  1190. single_input = (loc, sel)
  1191. ctx.log(f"[paypal] 命中验证码输入框: {sel}")
  1192. break
  1193. except Exception as exc:
  1194. ctx.log(f"[paypal] selector={sel} 等待可见失败: {exc!r}")
  1195. continue
  1196. if digit_count == 0 and single_input is None:
  1197. ctx.log("[paypal] 未检测到验证码输入框,跳过 SMS 步骤")
  1198. _dump_page(ctx, page, "11-paypal-no-sms")
  1199. _paypal_wait_for_payment_completion(ctx, page)
  1200. return
  1201. _dump_page(ctx, page, "12-paypal-sms-prompt")
  1202. ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
  1203. code = fetch_sms_code(timeout=180, interval=5, log=ctx.log, sms_api_url=ctx.sms_api_url)
  1204. if digit_count > 0:
  1205. ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
  1206. if len(code) < digit_count:
  1207. ctx.log(f"[paypal] 警告:验证码位数 {len(code)} < 输入框数 {digit_count}")
  1208. try:
  1209. first = digit_inputs.nth(0)
  1210. first.wait_for(state="visible", timeout=4000)
  1211. first.click()
  1212. except Exception as exc:
  1213. ctx.log(f"[paypal] 聚焦第 1 格失败: {exc!r}")
  1214. for idx in range(min(digit_count, len(code))):
  1215. try:
  1216. box = digit_inputs.nth(idx)
  1217. box.fill("")
  1218. box.type(code[idx], delay=30)
  1219. ctx.log(f"[paypal] OTP[{idx}] <- {code[idx]}")
  1220. except Exception as exc:
  1221. ctx.log(f"[paypal] OTP[{idx}] 填入失败: {exc!r}")
  1222. else:
  1223. loc, sel = single_input
  1224. loc.fill(code)
  1225. ctx.log(f"[paypal] 已填入验证码到 {sel}")
  1226. page.wait_for_timeout(800)
  1227. _click_next(ctx, page, "SMS 提交")
  1228. ctx.log("[paypal] 已提交验证码")
  1229. _dump_page(ctx, page, "13-paypal-sms-submitted")
  1230. _paypal_wait_for_payment_completion(ctx, page)
  1231. def _paypal_wait_for_payment_completion(
  1232. ctx,
  1233. page,
  1234. timeout: int = POST_PAYMENT_WAIT_TIMEOUT,
  1235. interval: int = POST_PAYMENT_WAIT_INTERVAL,
  1236. ) -> bool:
  1237. ctx.log(f"[paypal] 等待支付完成,自动等待 {timeout}s,间隔 {interval}s")
  1238. deadline = time.time() + timeout
  1239. last_status = ""
  1240. manual_mode = False
  1241. clicked_actions = set()
  1242. while True:
  1243. _check_stop(ctx)
  1244. if _page_is_closed(page):
  1245. ctx.log("[paypal] 页面已被关闭,停止等待支付完成")
  1246. return False
  1247. completion_reason = _detect_payment_completion(page)
  1248. if completion_reason:
  1249. ctx.log(f"[paypal] 支付完成确认: {completion_reason}")
  1250. _dump_page(ctx, page, "14-paypal-payment-complete")
  1251. return True
  1252. failure_reason = _detect_payment_failure(page)
  1253. if failure_reason:
  1254. ctx.log(f"[paypal] 支付失败信号: {failure_reason}")
  1255. _dump_page(ctx, page, "14-paypal-payment-failed")
  1256. raise PayPalPaymentFailed(failure_reason)
  1257. if _click_post_sms_action_if_available(ctx, page, clicked_actions):
  1258. last_status = ""
  1259. page.wait_for_timeout(2500)
  1260. continue
  1261. status = _summarize_payment_wait_status(page)
  1262. if status and status != last_status:
  1263. ctx.log(f"[paypal] 仍在等待支付完成: {status}")
  1264. last_status = status
  1265. if time.time() >= deadline:
  1266. if ctx.headless:
  1267. _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
  1268. raise TimeoutError(f"等待支付完成超时 {timeout}s")
  1269. if not manual_mode:
  1270. ctx.log("[paypal] 自动等待已超时,当前为可视浏览器,保持窗口打开;完成后会继续检测,或点击停止结束任务")
  1271. _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
  1272. manual_mode = True
  1273. interval = POST_PAYMENT_MANUAL_INTERVAL
  1274. page.wait_for_timeout(interval * 1000)
  1275. def _click_post_sms_action_if_available(ctx, page, clicked_actions: set) -> bool:
  1276. url = getattr(page, "url", "") or ""
  1277. candidates = []
  1278. if "paypal.com" in url:
  1279. candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_PAYPAL_ACTION_TEXTS)
  1280. if "pay.openai.com" in url or "checkout.stripe.com" in url:
  1281. candidates.append(('button[data-testid="hosted-payment-submit-button"]', "Stripe hosted submit"))
  1282. candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_STRIPE_ACTION_TEXTS)
  1283. for selector, label in candidates:
  1284. key = (url.split("?", 1)[0], label)
  1285. if key in clicked_actions:
  1286. continue
  1287. try:
  1288. loc = page.locator(selector).first
  1289. if loc.count() == 0:
  1290. continue
  1291. enabled = loc.is_enabled()
  1292. ctx.log(f"[paypal] 后续确认候选 {label} selector={selector} enabled={enabled}")
  1293. if not enabled:
  1294. continue
  1295. loc.click(timeout=4000)
  1296. clicked_actions.add(key)
  1297. ctx.log(f"[paypal] 已点击后续确认按钮: {label}")
  1298. return True
  1299. except Exception as exc:
  1300. ctx.log(f"[paypal] 后续确认按钮 {label} 处理失败: {exc!r}")
  1301. continue
  1302. return False
  1303. def _page_is_closed(page) -> bool:
  1304. try:
  1305. is_closed = getattr(page, "is_closed", None)
  1306. return bool(is_closed and is_closed())
  1307. except Exception:
  1308. return False
  1309. def _detect_payment_completion(page) -> str:
  1310. url = getattr(page, "url", "") or ""
  1311. snapshot = _payment_page_snapshot(page)
  1312. text = snapshot.get("text", "")
  1313. if "chatgpt.com" in url:
  1314. return f"已跳回 ChatGPT: {url}"
  1315. if _PAYMENT_COMPLETE_RE.search(text):
  1316. return "页面出现完成提示"
  1317. if (
  1318. ("pay.openai.com" in url or "checkout.stripe.com" in url)
  1319. and re.search(r"(complete|success|return|receipt)", url, re.I)
  1320. ):
  1321. return f"支付页进入完成 URL: {url}"
  1322. return ""
  1323. def _summarize_payment_wait_status(page) -> str:
  1324. url = getattr(page, "url", "") or ""
  1325. snapshot = _payment_page_snapshot(page)
  1326. alerts = snapshot.get("alerts", "")
  1327. text = snapshot.get("text", "")
  1328. invalid_count = snapshot.get("invalid_count", 0)
  1329. parts = [f"URL={url}"]
  1330. if invalid_count:
  1331. parts.append(f"invalid_fields={invalid_count}")
  1332. if alerts:
  1333. parts.append(f"alert={alerts[:300]}")
  1334. else:
  1335. blocking = _PAYMENT_BLOCKING_RE.search(text)
  1336. if blocking:
  1337. start = max(0, blocking.start() - 80)
  1338. end = min(len(text), blocking.end() + 160)
  1339. parts.append(f"page_hint={text[start:end].strip()[:300]}")
  1340. return " | ".join(parts)
  1341. def _payment_page_snapshot(page) -> dict:
  1342. try:
  1343. data = page.evaluate(r"""() => {
  1344. const bodyText = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').trim();
  1345. const alerts = Array.from(document.querySelectorAll(
  1346. '[role="alert"], [data-testid*="error" i], [class*="error" i], [aria-invalid="true"]'
  1347. )).map(el => (el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim())
  1348. .filter(Boolean)
  1349. .slice(0, 5)
  1350. .join(' | ');
  1351. return {
  1352. title: document.title || '',
  1353. text: bodyText.slice(0, 5000),
  1354. alerts,
  1355. invalid_count: document.querySelectorAll('[aria-invalid="true"]').length
  1356. };
  1357. }""")
  1358. return data if isinstance(data, dict) else {}
  1359. except Exception:
  1360. return {}
  1361. def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
  1362. label = label or selector
  1363. shown = "***" if mask else value
  1364. try:
  1365. loc = page.locator(selector).first
  1366. count = loc.count()
  1367. if count == 0:
  1368. ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
  1369. return
  1370. loc.wait_for(state="visible", timeout=6000)
  1371. loc.fill(value, timeout=6000)
  1372. ctx.log(f"[fill] {label}({selector}) <- {shown}")
  1373. except Exception as exc:
  1374. ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
  1375. def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
  1376. label = label or selector
  1377. try:
  1378. loc = page.locator(selector).first
  1379. if loc.count() == 0:
  1380. ctx.log(f"[select] {label}({selector}) 不存在,跳过")
  1381. return
  1382. loc.wait_for(state="visible", timeout=4000)
  1383. for kind in ("value", "label", "raw"):
  1384. try:
  1385. if kind == "value":
  1386. loc.select_option(value=text, timeout=4000)
  1387. elif kind == "label":
  1388. loc.select_option(label=text, timeout=4000)
  1389. else:
  1390. loc.select_option(text, timeout=4000)
  1391. ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
  1392. return
  1393. except Exception as exc:
  1394. ctx.log(f"[select] {label} 尝试 {kind} 失败: {exc!r}")
  1395. ctx.log(f"[select] {label}({selector}) 三种方式都失败")
  1396. except Exception as exc:
  1397. ctx.log(f"[select] {label}({selector}) 异常: {exc!r}")
  1398. def _click_next(ctx, page, tag: str = ""):
  1399. candidates = [
  1400. 'button[data-testid="submit-button"]',
  1401. 'button[data-testid="hosted-payment-submit-button"]',
  1402. 'button[data-atomic-wait-intent="Submit_Email"]',
  1403. 'button.SubmitButton--complete',
  1404. ]
  1405. for sel in candidates:
  1406. loc = page.locator(sel).first
  1407. if loc.count() == 0:
  1408. continue
  1409. try:
  1410. enabled = loc.is_enabled()
  1411. ctx.log(f"[click:{tag}] 候选 {sel} count={loc.count()} enabled={enabled}")
  1412. if not enabled:
  1413. continue
  1414. try:
  1415. loc.click(timeout=4000)
  1416. ctx.log(f"[click:{tag}] 已点击 {sel}")
  1417. return True
  1418. except Exception as exc:
  1419. msg = str(exc)
  1420. if "intercepts pointer events" in msg or "captchaComponent" in msg:
  1421. ctx.log(f"[click:{tag}] {sel} 被蒙层挡住,尝试删 captcha 后重试")
  1422. _paypal_remove_captcha_overlay(ctx, page)
  1423. try:
  1424. loc.click(timeout=4000, force=True)
  1425. ctx.log(f"[click:{tag}] 重试已点击 {sel}")
  1426. return True
  1427. except Exception as exc2:
  1428. ctx.log(f"[click:{tag}] {sel} 重试仍失败: {exc2!r}")
  1429. continue
  1430. ctx.log(f"[click:{tag}] {sel} 点击失败: {exc!r}")
  1431. except Exception as exc:
  1432. ctx.log(f"[click:{tag}] {sel} 处理失败: {exc!r}")
  1433. continue
  1434. for text in ["Next", "Subscribe", "Pay", "Continue", "Agree", "下一步", "下一页", "訂閱"]:
  1435. loc = page.locator(f'button:has-text("{text}")').first
  1436. if loc.count() == 0:
  1437. continue
  1438. try:
  1439. enabled = loc.is_enabled()
  1440. ctx.log(f"[click:{tag}] 文本候选 has-text={text!r} enabled={enabled}")
  1441. if not enabled:
  1442. continue
  1443. try:
  1444. loc.click(timeout=4000)
  1445. ctx.log(f"[click:{tag}] 已点击 has-text={text!r}")
  1446. return True
  1447. except Exception as exc:
  1448. msg = str(exc)
  1449. if "intercepts pointer events" in msg or "captchaComponent" in msg:
  1450. ctx.log(f"[click:{tag}] has-text={text!r} 被蒙层挡住,删 captcha 重试")
  1451. _paypal_remove_captcha_overlay(ctx, page)
  1452. try:
  1453. loc.click(timeout=4000, force=True)
  1454. ctx.log(f"[click:{tag}] 重试已点击 has-text={text!r}")
  1455. return True
  1456. except Exception as exc2:
  1457. ctx.log(f"[click:{tag}] has-text={text!r} 重试仍失败: {exc2!r}")
  1458. continue
  1459. ctx.log(f"[click:{tag}] has-text={text!r} 点击失败: {exc!r}")
  1460. except Exception as exc:
  1461. ctx.log(f"[click:{tag}] has-text={text!r} 处理失败: {exc!r}")
  1462. continue
  1463. ctx.log(f"[click:{tag}] 未找到任何可点击的下一步按钮")
  1464. return False
  1465. def run(ctx: RunContext):
  1466. ctx.log(f"[run] === 任务开始 run_id={ctx.run_id} artifact_dir={ctx.artifact_dir} ===")
  1467. try:
  1468. ctx.long_link = generate_long_link(ctx)
  1469. run_paypal_flow(ctx)
  1470. ctx.state = "done"
  1471. ctx.log("[run] === 全部步骤已尝试完成 ===")
  1472. except Exception as exc:
  1473. if str(exc) == "STOPPED_BY_USER":
  1474. ctx.log("[run] === 用户中止 ===")
  1475. ctx.state = "stopped"
  1476. else:
  1477. ctx.log(f"[run] === 异常退出: {exc!r} ===")
  1478. ctx.log(traceback.format_exc())
  1479. ctx.state = "error"