| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387 |
- """端到端自动化:拿 ChatGPT 长链 → Stripe 选 PayPal → PayPal 注册绑卡 → 等短信。"""
- from __future__ import annotations
- import json
- import os
- import re
- import time
- import traceback
- from dataclasses import dataclass, field
- from typing import Callable, Optional
- from providers import fetch_us_address, fetch_visa_card, fetch_sms_code
- try:
- from curl_cffi import requests as curl_requests
- except Exception:
- curl_requests = None
- CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
- PAYURL_CHECKOUT_URL = "https://payurl.ark2.cn/api/checkout"
- PROXY_FOR_LONGLINK = "http://127.0.0.1:7890"
- PHONE_E164 = "+15822201173"
- PHONE_NUMBER = PHONE_E164.removeprefix("+1")
- PHONE_COUNTRY = "US"
- POST_PAYMENT_WAIT_TIMEOUT = 600
- POST_PAYMENT_WAIT_INTERVAL = 2
- POST_PAYMENT_MANUAL_INTERVAL = 5
- POST_SMS_PAYPAL_ACTION_TEXTS = (
- "Agree & Create Account",
- "Agree and Create Account",
- "Agree & Continue",
- "Agree and Continue",
- "同意并继续",
- )
- POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
- LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
- _PAYMENT_COMPLETE_RE = re.compile(
- r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
- r"thank\s+you|you['’]?re\s+all\s+set|subscription\s+(active|started|complete)|welcome\s+to\s+chatgpt\s+plus|"
- r"支付成功|付款成功|订阅成功|已完成)",
- re.I,
- )
- _PAYMENT_BLOCKING_RE = re.compile(
- r"(validation|error|declined|failed|couldn['’]?t|cannot|try\s+again|invalid|"
- r"验证|错误|失败|无效|无法|重试)",
- re.I,
- )
- _PAYMENT_FAILED_URL_RE = re.compile(r"redirect_status=failed|payment[_-]?status=failed", re.I)
- _PAYMENT_FAILED_TEXT_RE = re.compile(
- r"an\s+error\s+occurred\s+while\s+processing\s+your\s+payment|"
- r"payment\s+failed|"
- r"try\s+again\s+later\s+or\s+with\s+a\s+different\s+payment\s+method|"
- r"card\s+(?:was\s+)?declined|"
- r"payment\s+method\s+(?:is\s+)?not\s+supported|"
- r"we\s+weren['’]?t\s+able\s+to\s+add\s+this\s+card|"
- r"unable\s+to\s+add\s+this\s+card|"
- r"check\s+all\s+the\s+details\s+are\s+correct\s+and\s+try\s+again|"
- r"try\s+a\s+different\s+card|"
- r"sorry,?\s+something\s+went\s+wrong\.?\s*please\s+try\s+again|"
- r"we\s+couldn['’]?t\s+process\s+(?:your\s+)?(?:payment|request)|"
- r"we\s+can'?t\s+continue\s+with\s+this\s+payment|"
- r"this\s+card\s+has\s+already\s+been\s+added\s+to\s+another\s+paypal\s+account|"
- r"create_card_account_candidate_validation_error|"
- r"unmapped[_\s-]?oas[_\s-]?error|"
- r"oas[_\s-]?error|"
- r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
- r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
- r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
- r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
- re.I,
- )
- class PayPalPaymentFailed(Exception):
- """PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
- MAX_PAYPAL_RETRIES = 3
- def _detect_payment_failure(page) -> str:
- """检测明确的"支付失败"信号。返回非空字符串表示已失败。"""
- url = getattr(page, "url", "") or ""
- if _PAYMENT_FAILED_URL_RE.search(url):
- return f"URL 标记 redirect_status=failed: {url}"
- snapshot = _payment_page_snapshot(page)
- text = snapshot.get("text", "") or ""
- alerts = snapshot.get("alerts", "") or ""
- blob = f"{text}\n{alerts}"
- m = _PAYMENT_FAILED_TEXT_RE.search(blob)
- if m:
- # 截上下文方便日志
- start = max(0, (m.start() if hasattr(m, "start") else 0) - 60)
- end = min(len(blob), (m.end() if hasattr(m, "end") else 0) + 200)
- return f"页面失败提示:{blob[start:end].strip()[:400]}"
- return ""
- def _replace_browser_context(ctx, page):
- """彻底重新生成一个浏览器 context(PerimeterX 等基于 fingerprint 的反爬最有效的对策)。
- 注意:调用方持有的 page 引用不会被替换;这里只是把当前 context 关掉重开+加载空白页,
- 并复用同一个 browser。如果调用方传入的 page 已经死,会自然在下次 page.goto 时报错。
- """
- ctx.log("[playwright] 重建 browser context(清 PerimeterX 等指纹)")
- try:
- old_ctx = page.context
- browser = old_ctx.browser
- except Exception as exc:
- ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退普通清缓存")
- _clear_browser_state(ctx, page)
- return
- try:
- for p in list(old_ctx.pages):
- try:
- p.close()
- except Exception:
- pass
- old_ctx.close()
- ctx.log("[playwright] 旧 context 已关闭")
- except Exception as exc:
- ctx.log(f"[playwright] 关旧 context 失败: {exc!r}")
- # 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
- # 兜底:用 _clear_browser_state 当作降级
- try:
- new_ctx = browser.new_context(
- locale="en-US",
- timezone_id="America/New_York",
- viewport={"width": 1280, "height": 900},
- )
- new_page = new_ctx.new_page()
- new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
- new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
- new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
- new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
- new_page.goto("about:blank", wait_until="domcontentloaded", timeout=10000)
- # 替换 ctx 上的 page 引用(chatgpt_flow 调 run_paypal_flow 时也是从 ctx._next_page 取)
- ctx._next_page = new_page # type: ignore
- ctx.log("[playwright] 新 context + 新 page 已就绪 (ctx._next_page)")
- except Exception as exc:
- ctx.log(f"[playwright] 新 context 创建失败: {exc!r}")
- def _clear_browser_state(ctx, page):
- """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。"""
- ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
- try:
- bctx = page.context
- bctx.clear_cookies()
- ctx.log("[playwright] context.clear_cookies() 完成")
- except Exception as exc:
- ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
- try:
- bctx = page.context
- # 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
- page.evaluate(r"""() => {
- try { localStorage.clear(); } catch (_) {}
- try { sessionStorage.clear(); } catch (_) {}
- try {
- if (window.indexedDB && indexedDB.databases) {
- indexedDB.databases().then((dbs) => {
- (dbs || []).forEach((db) => {
- if (db && db.name) {
- try { indexedDB.deleteDatabase(db.name); } catch (_) {}
- }
- });
- });
- }
- } catch (_) {}
- try {
- if (window.caches && caches.keys) {
- caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
- }
- } catch (_) {}
- }""")
- ctx.log("[playwright] localStorage/sessionStorage/IndexedDB/caches 清理完成(当前页)")
- except Exception as exc:
- ctx.log(f"[playwright] 当前页 storage 清理失败: {exc!r}")
- # 关掉所有 PayPal/Stripe 旧标签,最后回到一个空白页
- try:
- for p in list(page.context.pages):
- try:
- if p is page:
- continue
- p.close()
- except Exception:
- pass
- page.goto("about:blank", wait_until="domcontentloaded", timeout=15000)
- except Exception as exc:
- ctx.log(f"[playwright] 关旧 tab/转空白页 失败: {exc!r}")
- @dataclass
- class RunContext:
- token: str
- plan: str = "plus"
- country: str = "US"
- currency: str = "USD"
- use_promo: bool = True
- headless: bool = False
- log: Callable[[str], None] = print
- state: str = "running"
- stage: str = ""
- on_stage: Optional[Callable[[str], None]] = None
- long_link: str = ""
- email: str = "" # ChatGPT 注册邮箱(@edu.a4sky.com)
- paypal_email: str = "" # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
- password: str = ""
- card: dict = field(default_factory=dict)
- address: dict = field(default_factory=dict)
- run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
- @property
- def artifact_dir(self) -> str:
- d = os.path.join(LOG_DIR, self.run_id)
- os.makedirs(d, exist_ok=True)
- return d
- def set_stage(self, name: str):
- self.stage = name
- self.log(f"[stage] {name}")
- if self.on_stage:
- try:
- self.on_stage(name)
- except Exception:
- pass
- def _request_headers(token: str) -> dict:
- return {
- "Authorization": f"Bearer {token}",
- "Content-Type": "application/json",
- "Accept": "application/json",
- "Origin": "https://chatgpt.com",
- "Referer": "https://chatgpt.com/",
- "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
- "User-Agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/136.0.0.0 Safari/537.36"
- ),
- }
- def _checkout_payload(plan: str, country: str, currency: str, use_promo: bool) -> dict:
- payload = {
- "plan_name": "chatgptplusplan" if plan == "plus" else "chatgptteamplan",
- "billing_details": {"country": country.upper(), "currency": currency.upper()},
- "checkout_ui_mode": "hosted",
- "cancel_url": "https://chatgpt.com/#pricing",
- }
- if use_promo and plan == "plus":
- payload["promo_campaign"] = {
- "promo_campaign_id": "plus-1-month-free",
- "is_coupon_from_query_param": True,
- }
- return payload
- def generate_long_link(ctx: RunContext) -> str:
- ctx.log(f"[longlink] === 开始生成长链 ===")
- ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
- ctx.log(f"[longlink] 代理={PROXY_FOR_LONGLINK}")
- payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
- ctx.log(f"[longlink] payload={json.dumps(payload, ensure_ascii=False)}")
- if curl_requests is None:
- raise RuntimeError("缺少 curl_cffi,请先 pip install curl_cffi")
- proxies = {"http": PROXY_FOR_LONGLINK, "https": PROXY_FOR_LONGLINK}
- started = time.time()
- try:
- response = curl_requests.post(
- CHECKOUT_URL,
- json=payload,
- headers=_request_headers(ctx.token),
- impersonate="chrome136",
- proxies=proxies,
- timeout=30,
- )
- except Exception as exc:
- ctx.log(f"[longlink] 请求异常({int((time.time()-started)*1000)}ms): {exc!r}")
- raise
- text = response.text
- ctx.log(f"[longlink] HTTP {response.status_code} 耗时 {int((time.time()-started)*1000)}ms 返回长度={len(text)}")
- ctx.log(f"[longlink] 响应预览: {text[:500]}")
- if response.status_code >= 400:
- raise RuntimeError(f"创建 checkout 失败: HTTP {response.status_code} {text[:300]}")
- try:
- data = json.loads(text or "{}")
- except json.JSONDecodeError as exc:
- raise RuntimeError(f"长链响应不是 JSON: {exc!r} 原文={text[:300]}")
- link = data.get("url") or data.get("stripe_hosted_url") or data.get("checkout_url")
- session_id = data.get("checkout_session_id")
- processor = data.get("processor_entity")
- ctx.log(f"[longlink] checkout_session_id={session_id} processor={processor}")
- if not link:
- raise RuntimeError(f"未在响应中解析到长链: {text[:300]}")
- ctx.log(f"[longlink] 成功取到 long_link={link}")
- return link
- def generate_long_link_payurl(ctx: RunContext) -> str:
- """走 payurl.ark2.cn(与 Chrome 扩展 get-plus-link.js 一致),不走本地代理。"""
- ctx.log("[longlink] === 通过 payurl.ark2.cn 获取 Plus 长链 ===")
- ctx.log(f"[longlink] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
- payload = {
- "token": ctx.token,
- "plan": ctx.plan or "plus",
- "checkout_ui_mode": "hosted",
- "ui_language": "en",
- "country": (ctx.country or "US").upper(),
- "currency": (ctx.currency or "USD").upper(),
- "proxy": "",
- "use_promo": bool(ctx.use_promo),
- "promo_code": "STRIPEATLASGPT4BIZ050126",
- "workspace_name": "linux-do",
- "seat_quantity": 2,
- }
- headers = {
- "Accept": "*/*",
- "Accept-Language": "zh-CN,zh;q=0.9",
- "Content-Type": "application/json",
- "DNT": "1",
- "Origin": "https://payurl.ark2.cn",
- "Referer": "https://payurl.ark2.cn/",
- }
- masked = dict(payload)
- masked["token"] = (ctx.token or "")[:24] + "..."
- ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
- max_attempts = 5
- last_err = ""
- for attempt in range(1, max_attempts + 1):
- ctx.log(f"[longlink] 第 {attempt}/{max_attempts} 次请求 {PAYURL_CHECKOUT_URL}")
- started = time.time()
- try:
- if curl_requests is not None:
- r = curl_requests.post(
- PAYURL_CHECKOUT_URL,
- json=payload,
- headers=headers,
- impersonate="chrome136",
- timeout=30,
- )
- text = r.text
- status = r.status_code
- else:
- import urllib.request
- data = json.dumps(payload).encode("utf-8")
- req = urllib.request.Request(PAYURL_CHECKOUT_URL, data=data, method="POST")
- for k, v in headers.items():
- req.add_header(k, v)
- with urllib.request.urlopen(req, timeout=30) as resp:
- text = resp.read().decode("utf-8", errors="replace")
- status = resp.status
- except Exception as exc:
- last_err = repr(exc)
- ctx.log(f"[longlink] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
- time.sleep(1.5)
- continue
- ctx.log(f"[longlink] HTTP {status} 耗时 {int((time.time()-started)*1000)}ms 长度={len(text)} 预览={text[:300]}")
- if status >= 400:
- last_err = f"HTTP {status}: {text[:300]}"
- time.sleep(1.5)
- continue
- try:
- data = json.loads(text or "{}")
- except Exception as exc:
- last_err = f"非 JSON: {exc!r}"
- time.sleep(1.5)
- continue
- link = data.get("url") or data.get("openai_payurl") or data.get("chatgpt_checkout_url")
- if link:
- ctx.log(f"[longlink] 成功取到 long_link={link} sessionId={data.get('checkout_session_id', '?')}")
- return link
- last_err = f"响应缺 url 字段: {text[:300]}"
- time.sleep(1.5)
- raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
- def _rand_email() -> str:
- import random
- import string
- name = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
- return f"{name}@gmail.com"
- def _rand_paypal_email() -> str:
- """PayPal 注册用的独立邮箱(与 ChatGPT 注册邮箱解耦)。
- 用 gmail 域,一来 PayPal 对 gmail 的接受度高,二来不与 a4sky 邮件助手挂钩。
- """
- import random
- import string
- # 8-12 位随机字母数字 + 点号风格更像真人
- body_len = random.randint(8, 12)
- body = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(body_len))
- # 加一个随机数字尾巴
- tail = "".join(random.choice(string.digits) for _ in range(random.randint(2, 4)))
- return f"{body}{tail}@gmail.com"
- def _rand_password() -> str:
- import random
- import string
- pools = [
- random.choice(string.ascii_uppercase),
- random.choice(string.ascii_lowercase),
- random.choice(string.digits),
- random.choice("!@#$%^"),
- ]
- pools += [random.choice(string.ascii_letters + string.digits + "!@#$%^") for _ in range(10)]
- random.shuffle(pools)
- return "".join(pools)
- def _check_stop(ctx: RunContext):
- if ctx.state == "stopped":
- raise RuntimeError("STOPPED_BY_USER")
- def _dump_page(ctx: RunContext, page, tag: str):
- """失败/检查点时把当前页 URL/截图/HTML 都落盘。"""
- try:
- url = page.url
- except Exception:
- url = "<unknown>"
- ctx.log(f"[dump:{tag}] URL={url}")
- try:
- title = page.title()
- ctx.log(f"[dump:{tag}] title={title!r}")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] 取标题失败: {exc!r}")
- base = os.path.join(ctx.artifact_dir, f"{int(time.time()*1000)}-{tag}")
- try:
- page.screenshot(path=base + ".png", full_page=True)
- ctx.log(f"[dump:{tag}] 截图 -> {base}.png")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] 截图失败: {exc!r}")
- try:
- html = page.content()
- with open(base + ".html", "w", encoding="utf-8") as f:
- f.write(html)
- ctx.log(f"[dump:{tag}] HTML -> {base}.html ({len(html)} bytes)")
- except Exception as exc:
- ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
- def run_paypal_flow(ctx: RunContext, page=None):
- """跑 Stripe → PayPal。这一段不走代理。
- page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
- 支付失败会清缓存+重开长链最多重试 MAX_PAYPAL_RETRIES 次。
- """
- if page is None:
- return _run_paypal_flow_self_browser(ctx)
- ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
- ctx.set_stage("准备地址/卡/账号")
- ctx.address = fetch_us_address(log=ctx.log)
- ctx.card = fetch_visa_card(log=ctx.log)
- reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
- if reuse and ctx.email and ctx.password:
- ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
- else:
- ctx.email = _rand_email()
- ctx.password = _rand_password()
- ctx.log(f"[playwright] email={ctx.email}")
- ctx.log(f"[playwright] password={ctx.password}")
- ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
- ctx.log(f"[playwright] address={ctx.address}")
- ctx.log(f"[playwright] phone={PHONE_E164}")
- ctx.log("[playwright] 复用外部浏览器 page 跑 Stripe/PayPal")
- last_err: Exception | None = None
- for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
- # PayPal 字段用独立的 gmail 邮箱,每次重试都换(避开 PayPal 把上次失败的邮箱标黑)
- ctx.paypal_email = _rand_paypal_email()
- ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
- try:
- ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
- ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
- page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
- _dump_page(ctx, page, f"01-stripe-loaded-attempt{attempt}")
- _stripe_select_paypal_and_submit(ctx, page)
- _paypal_signup_and_pay(ctx, page)
- ctx.log("[playwright] 流程完成")
- _dump_page(ctx, page, "99-final")
- return
- except PayPalPaymentFailed as exc:
- last_err = exc
- ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
- if attempt >= MAX_PAYPAL_RETRIES:
- ctx.log(f"[playwright] 已达最大重试次数 {MAX_PAYPAL_RETRIES},放弃")
- raise
- ctx.set_stage(f"支付失败,准备第 {attempt + 1} 次重试(清缓存+换 context)")
- try:
- _replace_browser_context(ctx, page)
- # _replace_browser_context 返回新的 page,但因为这里的 page 是参数无法重新赋值
- # 改为修改 ctx 上的引用,让上层重新拿
- # 但上层 chatgpt_flow 是直接传 page 进来的,没法接到新 page
- # 简单粗暴:在原 context 上彻底清干净
- except Exception as exc2:
- ctx.log(f"[playwright] 换 context 异常,回退到清 cookie: {exc2!r}")
- try:
- _clear_browser_state(ctx, page)
- except Exception as exc3:
- ctx.log(f"[playwright] 清缓存异常(继续): {exc3!r}")
- try:
- ctx.address = fetch_us_address(log=ctx.log)
- ctx.card = fetch_visa_card(log=ctx.log)
- ctx.log(f"[playwright] 重试用新地址={ctx.address} 新卡尾号=***{ctx.card['number'][-4:]}")
- except Exception as exc2:
- ctx.log(f"[playwright] 重新拉取地址/卡片异常(继续): {exc2!r}")
- # 让 PayPal 反爬规则稍微衰减
- ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
- time.sleep(30)
- except Exception as exc:
- ctx.log(f"[playwright] 流程异常: {exc!r}")
- ctx.log(traceback.format_exc())
- try:
- _dump_page(ctx, page, f"99-error-attempt{attempt}")
- except Exception:
- pass
- raise
- if last_err is not None:
- raise last_err
- def _run_paypal_flow_self_browser(ctx: RunContext):
- """旧入口:自己开浏览器(保留兼容性)。"""
- from playwright.sync_api import sync_playwright
- ctx.log("[playwright] === 准备外部资源(地址/卡/邮箱/密码) ===")
- ctx.set_stage("准备地址/卡/账号")
- ctx.address = fetch_us_address(log=ctx.log)
- ctx.card = fetch_visa_card(log=ctx.log)
- reuse = bool(getattr(ctx, "_reuse_account_for_paypal", False))
- if reuse and ctx.email and ctx.password:
- ctx.log(f"[playwright] 复用已有账号 email={ctx.email}(来自注册阶段)")
- else:
- ctx.email = _rand_email()
- ctx.password = _rand_password()
- ctx.log(f"[playwright] email={ctx.email}")
- ctx.log(f"[playwright] password={ctx.password}")
- ctx.log(f"[playwright] card={{number=***{ctx.card['number'][-4:]}, expiry={ctx.card['expiry']}, cvv={ctx.card['cvv']}}}")
- ctx.log(f"[playwright] address={ctx.address}")
- ctx.log(f"[playwright] phone={PHONE_E164}")
- with sync_playwright() as p:
- ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
- browser = p.chromium.launch(
- headless=ctx.headless,
- args=["--disable-blink-features=AutomationControlled"],
- )
- context = browser.new_context(
- locale="en-US",
- timezone_id="America/New_York",
- viewport={"width": 1280, "height": 900},
- )
- new_page = context.new_page()
- new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
- new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
- new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
- new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
- try:
- for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
- ctx.paypal_email = _rand_paypal_email()
- ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}")
- try:
- ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
- new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
- _dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
- _stripe_select_paypal_and_submit(ctx, new_page)
- _paypal_signup_and_pay(ctx, new_page)
- ctx.log("[playwright] 流程完成")
- _dump_page(ctx, new_page, "99-final")
- return
- except PayPalPaymentFailed as exc:
- ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
- if attempt >= MAX_PAYPAL_RETRIES:
- raise
- _clear_browser_state(ctx, new_page)
- try:
- ctx.address = fetch_us_address(log=ctx.log)
- ctx.card = fetch_visa_card(log=ctx.log)
- except Exception as exc2:
- ctx.log(f"[playwright] 重抽地址/卡异常(继续): {exc2!r}")
- ctx.log("[playwright] 重试前等待 30s")
- time.sleep(30)
- except Exception as exc:
- ctx.log(f"[playwright] 流程异常: {exc!r}")
- ctx.log(traceback.format_exc())
- try:
- _dump_page(ctx, new_page, "99-error")
- except Exception:
- pass
- raise
- finally:
- try:
- browser.close()
- except Exception:
- pass
- def _stripe_select_paypal(ctx, page) -> bool:
- """多策略尝试选中 PayPal 支付方式。命中返回 True。"""
- # 1) 先等待至少一种 PayPal 候选出现,避免 timing 问题
- try:
- page.wait_for_function(
- r"""() => {
- const sels = [
- '#payment-method-accordion-item-title-paypal',
- 'input[type="radio"][value="paypal"]',
- 'input[name="paymentMethod"][value="paypal"]',
- '[data-testid*="paypal" i]',
- 'label[for*="paypal" i]',
- 'button[id*="paypal" i]',
- ];
- for (const s of sels) {
- if (document.querySelector(s)) return true;
- }
- const labels = Array.from(document.querySelectorAll('label, button, [role="radio"], [role="button"]'));
- return labels.some((el) => /paypal/i.test((el.innerText || el.getAttribute('aria-label') || '')));
- }""",
- timeout=15000,
- )
- except Exception as exc:
- ctx.log(f"[stripe] 等待 PayPal 候选出现超时: {exc!r}")
- # 2) 候选 selector 列表,按可靠度排序
- candidates = [
- '#payment-method-accordion-item-title-paypal',
- 'input[type="radio"][value="paypal"]',
- 'input[name="paymentMethod"][value="paypal"]',
- '[data-testid="payment-method-paypal"]',
- '[data-testid*="paypal" i]',
- 'label[for*="paypal" i]',
- 'button[id*="paypal" i]',
- '[role="radio"][aria-label*="paypal" i]',
- ]
- for sel in candidates:
- loc = page.locator(sel).first
- if loc.count() == 0:
- continue
- ctx.log(f"[stripe] PayPal 候选命中 {sel}")
- # 多种点法兜底
- for action in ("check", "click", "force_click", "js_click"):
- try:
- if action == "check":
- loc.check(force=True, timeout=4000)
- elif action == "click":
- loc.click(timeout=4000)
- elif action == "force_click":
- loc.click(timeout=4000, force=True)
- else:
- loc.evaluate("el => { el.click(); }")
- ctx.log(f"[stripe] {action}({sel}) 成功")
- page.wait_for_timeout(800)
- if _stripe_paypal_is_selected(page):
- return True
- except Exception as exc:
- ctx.log(f"[stripe] {action}({sel}) 失败: {exc!r}")
- continue
- # 没确认选中也继续下一种
- if _stripe_paypal_is_selected(page):
- return True
- # 3) 文本扫描:找包含 "PayPal" 文本的可点击元素,逐个尝试
- ctx.log("[stripe] 进入文本扫描兜底")
- try:
- n = page.evaluate(
- r"""() => {
- const all = Array.from(document.querySelectorAll(
- 'label, button, [role="radio"], [role="button"], [role="tab"], div[tabindex], a'
- ));
- const matches = all.filter((el) => {
- const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
- return /paypal/i.test(t);
- });
- window.__paypalCandidates = matches;
- return matches.length;
- }"""
- )
- ctx.log(f"[stripe] 文本扫描候选数={n}")
- for i in range(int(n or 0)):
- try:
- page.evaluate(
- r"""(i) => {
- const el = (window.__paypalCandidates || [])[i];
- if (el) { el.scrollIntoView({block:'center'}); el.click(); }
- }""",
- i,
- )
- page.wait_for_timeout(700)
- if _stripe_paypal_is_selected(page):
- ctx.log(f"[stripe] 文本扫描候选 idx={i} 命中")
- return True
- except Exception as exc:
- ctx.log(f"[stripe] 文本扫描候选 idx={i} 失败: {exc!r}")
- except Exception as exc:
- ctx.log(f"[stripe] 文本扫描兜底异常: {exc!r}")
- return False
- def _stripe_paypal_is_selected(page) -> bool:
- """通过多种特征判断 PayPal 已被选中。"""
- try:
- return bool(page.evaluate(
- r"""() => {
- // 1) 单选框 checked
- const r = document.querySelector('input[type="radio"][value="paypal"]:checked')
- || document.querySelector('input[name="paymentMethod"][value="paypal"]:checked');
- if (r) return true;
- // 2) accordion item with aria-selected/expanded=true 含 paypal
- const items = Array.from(document.querySelectorAll('[role="radio"], [role="tab"], button, label, div'));
- const sel = items.find((el) => {
- const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
- if (!/paypal/i.test(t)) return false;
- const checked = el.getAttribute('aria-checked') === 'true'
- || el.getAttribute('aria-selected') === 'true'
- || el.getAttribute('aria-expanded') === 'true'
- || /selected|active|checked/i.test(el.className || '');
- return checked;
- });
- if (sel) return true;
- // 3) 提交按钮文本包含 PayPal(Stripe 选 PayPal 后按钮会变 "Pay with PayPal")
- const btn = document.querySelector('button[data-testid="hosted-payment-submit-button"]');
- if (btn && /paypal/i.test(btn.innerText || '')) return true;
- return false;
- }"""
- ))
- except Exception:
- return False
- def _stripe_select_paypal_and_submit(ctx, page):
- ctx.log("[stripe] === 在 Stripe 选择 PayPal 并提交 ===")
- page.wait_for_timeout(2000)
- _check_stop(ctx)
- selected = _stripe_select_paypal(ctx, page)
- if not selected:
- _dump_page(ctx, page, "02-stripe-paypal-not-found")
- raise RuntimeError("无法在 Stripe 上选中 PayPal 支付方式")
- ctx.log("[stripe] PayPal 已选中")
- page.wait_for_timeout(1500)
- ctx.log("[stripe] 切换 billingCountry=US")
- _select_by_text(ctx, page, '#billingCountry', 'US', "billingCountry")
- page.wait_for_timeout(500)
- ctx.log("[stripe] 填写账单地址")
- addr = ctx.address
- _safe_fill(ctx, page, '#billingAddressLine1', addr["street"], "billingAddressLine1")
- page.keyboard.press('Escape')
- page.wait_for_timeout(200)
- _safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
- _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
- _select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
- if page.locator('#phoneNumber').count() > 0:
- _safe_fill(ctx, page, '#phoneNumber', PHONE_NUMBER, "phoneNumber")
- page.wait_for_timeout(400)
- cb = page.locator('#termsOfServiceConsentCheckbox')
- if cb.count() > 0:
- try:
- checked = cb.is_checked()
- ctx.log(f"[stripe] 协议复选框 checked={checked}")
- if not checked:
- cb.check(force=True, timeout=2000)
- ctx.log("[stripe] 已勾选协议")
- except Exception as exc:
- ctx.log(f"[stripe] 协议复选框处理失败: {exc!r}")
- else:
- ctx.log("[stripe] 未发现协议复选框")
- _dump_page(ctx, page, "03-stripe-filled")
- submit = page.locator('button[data-testid="hosted-payment-submit-button"]').first
- if submit.count() == 0:
- ctx.log("[stripe] 找不到 hosted-payment-submit-button")
- _dump_page(ctx, page, "04-stripe-no-submit")
- raise RuntimeError("hosted-payment-submit-button 不存在")
- klass = submit.get_attribute("class") or ""
- ctx.log(f"[stripe] 提交按钮 class={klass}")
- if "incomplete" in klass:
- ctx.log("[stripe] 警告:按钮仍处于 incomplete 状态,将再等 2s 重试")
- page.wait_for_timeout(2000)
- klass = submit.get_attribute("class") or ""
- ctx.log(f"[stripe] 再次检查 class={klass}")
- submit.click(timeout=8000)
- ctx.log("[stripe] 已点击提交,等待跳转 PayPal")
- try:
- page.wait_for_url(re.compile(r"paypal\.com"), timeout=60000)
- except Exception as exc:
- ctx.log(f"[stripe] 等待跳转到 PayPal 超时: {exc!r}")
- _dump_page(ctx, page, "04-stripe-no-redirect")
- raise
- ctx.log(f"[stripe] 已跳转: {page.url}")
- _dump_page(ctx, page, "05-paypal-arrived")
- def _paypal_signup_and_pay(ctx, page):
- ctx.log("[paypal] === 进入 PayPal 流程 ===")
- page.wait_for_load_state("domcontentloaded", timeout=30000)
- page.wait_for_timeout(1500)
- _check_stop(ctx)
- _paypal_clear_session(ctx, page)
- # 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
- from paypal_flow import ensure_checkoutweb
- on_stage = getattr(ctx, "on_stage", None)
- path_taken = ensure_checkoutweb(
- page,
- fallback_email=ctx.paypal_email or ctx.email,
- log=ctx.log,
- on_stage=on_stage,
- )
- ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
- _dump_page(ctx, page, "06-paypal-checkoutweb-entered")
- ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
- for sel in ('#cardNumber', '#phone', '#billingLine1'):
- try:
- page.locator(sel).first.wait_for(state="visible", timeout=20000)
- ctx.log(f"[paypal] {sel} 已可见")
- except Exception as exc:
- ctx.log(f"[paypal] 等待 {sel} 超时: {exc!r}")
- _dump_page(ctx, page, "07-paypal-checkoutweb")
- ctx.log("[paypal] 检查国家选择器")
- country = page.locator('#country')
- if country.count() > 0:
- try:
- current = country.input_value()
- ctx.log(f"[paypal] 当前国家={current}")
- if current != "US":
- ctx.log("[paypal] 切换国家为 US")
- country.select_option("US")
- page.wait_for_timeout(2500)
- ctx.log(f"[paypal] 切换后国家={country.input_value()}, URL={page.url}")
- _dump_page(ctx, page, "08-paypal-country-switched")
- except Exception as exc:
- ctx.log(f"[paypal] 切换国家失败: {exc!r}")
- else:
- ctx.log("[paypal] 未发现 #country 选择器")
- addr = ctx.address
- card = ctx.card
- ctx.log("[paypal] 第一轮填表")
- _paypal_fill_form(ctx, page, addr, card)
- page.wait_for_timeout(800)
- invalid = _paypal_collect_invalid(page)
- if invalid:
- ctx.log(f"[paypal] 第一轮后仍有 aria-invalid='true' 的字段: {invalid},再补一次")
- _paypal_fill_form(ctx, page, addr, card, retry_only=invalid)
- page.wait_for_timeout(600)
- _dump_page(ctx, page, "09-paypal-filled")
- ctx.log("[paypal] 提交付款")
- if not _click_next(ctx, page, "PayPal 提交"):
- ctx.log("[paypal] 警告:未能找到可点击的提交按钮")
- _dump_page(ctx, page, "10-paypal-no-submit")
- page.wait_for_timeout(2500)
- invalid_after = _paypal_collect_invalid(page)
- if invalid_after:
- ctx.log(f"[paypal] 提交后页面报错的字段: {invalid_after},再补再提交一次")
- _paypal_fill_form(ctx, page, addr, card, retry_only=invalid_after)
- page.wait_for_timeout(500)
- _click_next(ctx, page, "PayPal 提交-2")
- _paypal_handle_sms(ctx, page)
- _PAYPAL_FIELDS = [
- ("email", "value"),
- ("phone", "value"),
- ("cardNumber", "card-number"),
- ("cardExpiry", "card-expiry"),
- ("cardCvv", "card-cvv"),
- ("firstName", "value"),
- ("lastName", "value"),
- ("billingLine1", "value"),
- ("billingCity", "value"),
- ("billingPostalCode", "value"),
- ("password", "value"),
- ]
- def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
- targets = retry_only or [k for k, _ in _PAYPAL_FIELDS]
- if "email" in targets:
- _safe_fill(ctx, page, '#email', ctx.paypal_email or ctx.email, "email")
- if "phone" in targets:
- _paypal_type_into(ctx, page, '#phone', PHONE_NUMBER, "phone")
- if "cardNumber" in targets:
- _paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
- if "cardExpiry" in targets:
- _paypal_type_into(ctx, page, '#cardExpiry', card["expiry"], "cardExpiry")
- if "cardCvv" in targets:
- _paypal_type_into(ctx, page, '#cardCvv', card["cvv"], "cardCvv")
- if "firstName" in targets:
- _safe_fill(ctx, page, '#firstName', "James", "firstName")
- if "lastName" in targets:
- _safe_fill(ctx, page, '#lastName', "Smith", "lastName")
- if "billingLine1" in targets:
- _safe_fill(ctx, page, '#billingLine1', addr["street"], "billingLine1")
- try:
- page.keyboard.press('Escape')
- except Exception:
- pass
- if "billingCity" in targets:
- _safe_fill(ctx, page, '#billingCity', addr["city"], "billingCity")
- if "billingPostalCode" in targets:
- _safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
- if any(k in targets for k in ("billingLine1", "billingCity", "billingPostalCode")):
- _select_by_text(ctx, page, '#billingState', addr["state"], "billingState")
- if "password" in targets:
- _safe_fill(ctx, page, '#password', ctx.password, "password", mask=True)
- def _paypal_type_into(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
- label = label or selector
- shown = "***" if mask else value
- try:
- loc = page.locator(selector).first
- if loc.count() == 0:
- ctx.log(f"[type] {label}({selector}) 不存在,跳过")
- return
- loc.wait_for(state="visible", timeout=8000)
- loc.click()
- loc.fill("")
- loc.type(value, delay=20)
- ctx.log(f"[type] {label}({selector}) <- {shown}")
- except Exception as exc:
- ctx.log(f"[type] {label}({selector}) 失败: {exc!r}")
- def _paypal_collect_invalid(page):
- try:
- return page.evaluate("""() => {
- const out = [];
- document.querySelectorAll('[aria-invalid="true"]').forEach(el => {
- if (el.id) out.push(el.id);
- else if (el.name) out.push(el.name);
- });
- return out;
- }""")
- except Exception:
- return []
- def _paypal_clear_session(ctx, page):
- ctx.log("[paypal] 清理 cookie/storage")
- try:
- before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
- page.evaluate("""() => {
- try { localStorage.clear(); } catch (e) {}
- try { sessionStorage.clear(); } catch (e) {}
- const host = location.hostname;
- const parts = host.split('.');
- const domains = [host, '.' + host];
- for (let i = 1; i < parts.length - 1; i++) domains.push('.' + parts.slice(i).join('.'));
- const cookies = document.cookie ? document.cookie.split(';') : [];
- cookies.forEach(c => {
- const name = c.split('=')[0].trim();
- if (!name) return;
- ['/', location.pathname].forEach(p => {
- domains.forEach(d => {
- document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
- });
- document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p;
- });
- });
- }""")
- after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
- ctx.log(f"[paypal] cookie 数量 {before} -> {after}(httpOnly 项 JS 删不掉)")
- except Exception as exc:
- ctx.log(f"[paypal] 清理会话异常: {exc!r}")
- def _paypal_remove_captcha_overlay(ctx, page):
- try:
- removed = page.evaluate("""() => {
- const ids = ['captchaComponent', 'recaptcha', 'recaptcha-overlay', 'reCAPTCHAOverlay'];
- let n = 0;
- for (const id of ids) {
- const el = document.getElementById(id);
- if (el) { el.remove(); n++; }
- }
- document.querySelectorAll(
- 'iframe[name="recaptcha"], iframe[title*="recaptcha" i], div[class*="captcha" i][class*="overlay" i]'
- ).forEach(el => { el.remove(); n++; });
- return n;
- }""")
- if removed:
- ctx.log(f"[paypal] 已删除 captcha 相关蒙层节点 {removed} 个")
- except Exception as exc:
- ctx.log(f"[paypal] 删除 captcha 蒙层失败: {exc!r}")
- def _paypal_handle_sms(ctx, page):
- ctx.log("[paypal] === 检查是否需要短信验证码 ===")
- page.wait_for_timeout(3000)
- _check_stop(ctx)
- _paypal_remove_captcha_overlay(ctx, page)
- digit_inputs = page.locator('input[id^="ci-ciBasic-"]')
- digit_count = 0
- try:
- digit_count = digit_inputs.count()
- except Exception as exc:
- ctx.log(f"[paypal] 探测分立 OTP 输入框失败: {exc!r}")
- ctx.log(f"[paypal] 分立 OTP 输入框 ci-ciBasic-* count={digit_count}")
- single_input = None
- if digit_count == 0:
- sms_inputs = [
- 'input[name="otp"]',
- 'input[autocomplete="one-time-code"]',
- 'input#otp',
- 'input[name="smsOtp"]',
- 'input[id*="otp" i]',
- ]
- for sel in sms_inputs:
- loc = page.locator(sel).first
- try:
- count = loc.count()
- ctx.log(f"[paypal] 探测 SMS 输入框 selector={sel} count={count}")
- if count == 0:
- continue
- loc.wait_for(state="visible", timeout=4000)
- single_input = (loc, sel)
- ctx.log(f"[paypal] 命中验证码输入框: {sel}")
- break
- except Exception as exc:
- ctx.log(f"[paypal] selector={sel} 等待可见失败: {exc!r}")
- continue
- if digit_count == 0 and single_input is None:
- ctx.log("[paypal] 未检测到验证码输入框,跳过 SMS 步骤")
- _dump_page(ctx, page, "11-paypal-no-sms")
- _paypal_wait_for_payment_completion(ctx, page)
- return
- _dump_page(ctx, page, "12-paypal-sms-prompt")
- ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
- code = fetch_sms_code(timeout=180, interval=5, log=ctx.log)
- if digit_count > 0:
- ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
- if len(code) < digit_count:
- ctx.log(f"[paypal] 警告:验证码位数 {len(code)} < 输入框数 {digit_count}")
- try:
- first = digit_inputs.nth(0)
- first.wait_for(state="visible", timeout=4000)
- first.click()
- except Exception as exc:
- ctx.log(f"[paypal] 聚焦第 1 格失败: {exc!r}")
- for idx in range(min(digit_count, len(code))):
- try:
- box = digit_inputs.nth(idx)
- box.fill("")
- box.type(code[idx], delay=30)
- ctx.log(f"[paypal] OTP[{idx}] <- {code[idx]}")
- except Exception as exc:
- ctx.log(f"[paypal] OTP[{idx}] 填入失败: {exc!r}")
- else:
- loc, sel = single_input
- loc.fill(code)
- ctx.log(f"[paypal] 已填入验证码到 {sel}")
- page.wait_for_timeout(800)
- _click_next(ctx, page, "SMS 提交")
- ctx.log("[paypal] 已提交验证码")
- _dump_page(ctx, page, "13-paypal-sms-submitted")
- _paypal_wait_for_payment_completion(ctx, page)
- def _paypal_wait_for_payment_completion(
- ctx,
- page,
- timeout: int = POST_PAYMENT_WAIT_TIMEOUT,
- interval: int = POST_PAYMENT_WAIT_INTERVAL,
- ) -> bool:
- ctx.log(f"[paypal] 等待支付完成,自动等待 {timeout}s,间隔 {interval}s")
- deadline = time.time() + timeout
- last_status = ""
- manual_mode = False
- clicked_actions = set()
- while True:
- _check_stop(ctx)
- if _page_is_closed(page):
- ctx.log("[paypal] 页面已被关闭,停止等待支付完成")
- return False
- completion_reason = _detect_payment_completion(page)
- if completion_reason:
- ctx.log(f"[paypal] 支付完成确认: {completion_reason}")
- _dump_page(ctx, page, "14-paypal-payment-complete")
- return True
- failure_reason = _detect_payment_failure(page)
- if failure_reason:
- ctx.log(f"[paypal] 支付失败信号: {failure_reason}")
- _dump_page(ctx, page, "14-paypal-payment-failed")
- raise PayPalPaymentFailed(failure_reason)
- if _click_post_sms_action_if_available(ctx, page, clicked_actions):
- last_status = ""
- page.wait_for_timeout(2500)
- continue
- status = _summarize_payment_wait_status(page)
- if status and status != last_status:
- ctx.log(f"[paypal] 仍在等待支付完成: {status}")
- last_status = status
- if time.time() >= deadline:
- if ctx.headless:
- _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
- raise TimeoutError(f"等待支付完成超时 {timeout}s")
- if not manual_mode:
- ctx.log("[paypal] 自动等待已超时,当前为可视浏览器,保持窗口打开;完成后会继续检测,或点击停止结束任务")
- _dump_page(ctx, page, "14-paypal-payment-wait-timeout")
- manual_mode = True
- interval = POST_PAYMENT_MANUAL_INTERVAL
- page.wait_for_timeout(interval * 1000)
- def _click_post_sms_action_if_available(ctx, page, clicked_actions: set) -> bool:
- url = getattr(page, "url", "") or ""
- candidates = []
- if "paypal.com" in url:
- candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_PAYPAL_ACTION_TEXTS)
- if "pay.openai.com" in url or "checkout.stripe.com" in url:
- candidates.append(('button[data-testid="hosted-payment-submit-button"]', "Stripe hosted submit"))
- candidates.extend((f'button:has-text("{text}")', text) for text in POST_SMS_STRIPE_ACTION_TEXTS)
- for selector, label in candidates:
- key = (url.split("?", 1)[0], label)
- if key in clicked_actions:
- continue
- try:
- loc = page.locator(selector).first
- if loc.count() == 0:
- continue
- enabled = loc.is_enabled()
- ctx.log(f"[paypal] 后续确认候选 {label} selector={selector} enabled={enabled}")
- if not enabled:
- continue
- loc.click(timeout=4000)
- clicked_actions.add(key)
- ctx.log(f"[paypal] 已点击后续确认按钮: {label}")
- return True
- except Exception as exc:
- ctx.log(f"[paypal] 后续确认按钮 {label} 处理失败: {exc!r}")
- continue
- return False
- def _page_is_closed(page) -> bool:
- try:
- is_closed = getattr(page, "is_closed", None)
- return bool(is_closed and is_closed())
- except Exception:
- return False
- def _detect_payment_completion(page) -> str:
- url = getattr(page, "url", "") or ""
- snapshot = _payment_page_snapshot(page)
- text = snapshot.get("text", "")
- if "chatgpt.com" in url:
- return f"已跳回 ChatGPT: {url}"
- if _PAYMENT_COMPLETE_RE.search(text):
- return "页面出现完成提示"
- if (
- ("pay.openai.com" in url or "checkout.stripe.com" in url)
- and re.search(r"(complete|success|return|receipt)", url, re.I)
- ):
- return f"支付页进入完成 URL: {url}"
- return ""
- def _summarize_payment_wait_status(page) -> str:
- url = getattr(page, "url", "") or ""
- snapshot = _payment_page_snapshot(page)
- alerts = snapshot.get("alerts", "")
- text = snapshot.get("text", "")
- invalid_count = snapshot.get("invalid_count", 0)
- parts = [f"URL={url}"]
- if invalid_count:
- parts.append(f"invalid_fields={invalid_count}")
- if alerts:
- parts.append(f"alert={alerts[:300]}")
- else:
- blocking = _PAYMENT_BLOCKING_RE.search(text)
- if blocking:
- start = max(0, blocking.start() - 80)
- end = min(len(text), blocking.end() + 160)
- parts.append(f"page_hint={text[start:end].strip()[:300]}")
- return " | ".join(parts)
- def _payment_page_snapshot(page) -> dict:
- try:
- data = page.evaluate(r"""() => {
- const bodyText = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').trim();
- const alerts = Array.from(document.querySelectorAll(
- '[role="alert"], [data-testid*="error" i], [class*="error" i], [aria-invalid="true"]'
- )).map(el => (el.innerText || el.value || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim())
- .filter(Boolean)
- .slice(0, 5)
- .join(' | ');
- return {
- title: document.title || '',
- text: bodyText.slice(0, 5000),
- alerts,
- invalid_count: document.querySelectorAll('[aria-invalid="true"]').length
- };
- }""")
- return data if isinstance(data, dict) else {}
- except Exception:
- return {}
- def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool = False):
- label = label or selector
- shown = "***" if mask else value
- try:
- loc = page.locator(selector).first
- count = loc.count()
- if count == 0:
- ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
- return
- loc.wait_for(state="visible", timeout=8000)
- loc.fill(value)
- ctx.log(f"[fill] {label}({selector}) <- {shown}")
- except Exception as exc:
- ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
- def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
- label = label or selector
- try:
- loc = page.locator(selector).first
- if loc.count() == 0:
- ctx.log(f"[select] {label}({selector}) 不存在,跳过")
- return
- loc.wait_for(state="visible", timeout=4000)
- for kind in ("value", "label", "raw"):
- try:
- if kind == "value":
- loc.select_option(value=text)
- elif kind == "label":
- loc.select_option(label=text)
- else:
- loc.select_option(text)
- ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
- return
- except Exception as exc:
- ctx.log(f"[select] {label} 尝试 {kind} 失败: {exc!r}")
- ctx.log(f"[select] {label}({selector}) 三种方式都失败")
- except Exception as exc:
- ctx.log(f"[select] {label}({selector}) 异常: {exc!r}")
- def _click_next(ctx, page, tag: str = ""):
- candidates = [
- 'button[data-testid="submit-button"]',
- 'button[data-testid="hosted-payment-submit-button"]',
- 'button[data-atomic-wait-intent="Submit_Email"]',
- 'button.SubmitButton--complete',
- ]
- for sel in candidates:
- loc = page.locator(sel).first
- if loc.count() == 0:
- continue
- try:
- enabled = loc.is_enabled()
- ctx.log(f"[click:{tag}] 候选 {sel} count={loc.count()} enabled={enabled}")
- if not enabled:
- continue
- try:
- loc.click(timeout=4000)
- ctx.log(f"[click:{tag}] 已点击 {sel}")
- return True
- except Exception as exc:
- msg = str(exc)
- if "intercepts pointer events" in msg or "captchaComponent" in msg:
- ctx.log(f"[click:{tag}] {sel} 被蒙层挡住,尝试删 captcha 后重试")
- _paypal_remove_captcha_overlay(ctx, page)
- try:
- loc.click(timeout=4000, force=True)
- ctx.log(f"[click:{tag}] 重试已点击 {sel}")
- return True
- except Exception as exc2:
- ctx.log(f"[click:{tag}] {sel} 重试仍失败: {exc2!r}")
- continue
- ctx.log(f"[click:{tag}] {sel} 点击失败: {exc!r}")
- except Exception as exc:
- ctx.log(f"[click:{tag}] {sel} 处理失败: {exc!r}")
- continue
- for text in ["Next", "Subscribe", "Pay", "Continue", "Agree", "下一步", "下一页", "訂閱"]:
- loc = page.locator(f'button:has-text("{text}")').first
- if loc.count() == 0:
- continue
- try:
- enabled = loc.is_enabled()
- ctx.log(f"[click:{tag}] 文本候选 has-text={text!r} enabled={enabled}")
- if not enabled:
- continue
- try:
- loc.click(timeout=4000)
- ctx.log(f"[click:{tag}] 已点击 has-text={text!r}")
- return True
- except Exception as exc:
- msg = str(exc)
- if "intercepts pointer events" in msg or "captchaComponent" in msg:
- ctx.log(f"[click:{tag}] has-text={text!r} 被蒙层挡住,删 captcha 重试")
- _paypal_remove_captcha_overlay(ctx, page)
- try:
- loc.click(timeout=4000, force=True)
- ctx.log(f"[click:{tag}] 重试已点击 has-text={text!r}")
- return True
- except Exception as exc2:
- ctx.log(f"[click:{tag}] has-text={text!r} 重试仍失败: {exc2!r}")
- continue
- ctx.log(f"[click:{tag}] has-text={text!r} 点击失败: {exc!r}")
- except Exception as exc:
- ctx.log(f"[click:{tag}] has-text={text!r} 处理失败: {exc!r}")
- continue
- ctx.log(f"[click:{tag}] 未找到任何可点击的下一步按钮")
- return False
- def run(ctx: RunContext):
- ctx.log(f"[run] === 任务开始 run_id={ctx.run_id} artifact_dir={ctx.artifact_dir} ===")
- try:
- ctx.long_link = generate_long_link(ctx)
- run_paypal_flow(ctx)
- ctx.state = "done"
- ctx.log("[run] === 全部步骤已尝试完成 ===")
- except Exception as exc:
- if str(exc) == "STOPPED_BY_USER":
- ctx.log("[run] === 用户中止 ===")
- ctx.state = "stopped"
- else:
- ctx.log(f"[run] === 异常退出: {exc!r} ===")
- ctx.log(traceback.format_exc())
- ctx.state = "error"
|