automation.py 56 KB

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