|
|
@@ -20,7 +20,9 @@ except Exception:
|
|
|
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"
|
|
|
+DEFAULT_PHONE_E164 = "+15822201173"
|
|
|
+DEFAULT_SMS_API_URL = "http://a.62-us.com/api/get_sms?key=a5d3262e05efaba982aba7cfae20b8bc"
|
|
|
+PHONE_E164 = DEFAULT_PHONE_E164
|
|
|
PHONE_NUMBER = PHONE_E164.removeprefix("+1")
|
|
|
PHONE_COUNTRY = "US"
|
|
|
POST_PAYMENT_WAIT_TIMEOUT = 600
|
|
|
@@ -68,6 +70,13 @@ _PAYMENT_FAILED_TEXT_RE = re.compile(
|
|
|
r"oas[_\s-]?error|"
|
|
|
r"cc[_\s-]?linked[_\s-]?to[_\s-]?full[_\s-]?account|"
|
|
|
r"instrument[_\s-]?sharing[_\s-]?limit[_\s-]?exceeded|"
|
|
|
+ r"your\s+account\s+is\s+limited|"
|
|
|
+ r"check\s+your\s+paypal\s+account\s+overview\s+page|"
|
|
|
+ r"how\s+to\s+resolve\s+this\s+problem|"
|
|
|
+ r"account\s+(?:has\s+been\s+)?(?:temporarily\s+)?(?:limited|restricted|suspended|locked)|"
|
|
|
+ r"weasley_fallback_to_hermes|"
|
|
|
+ r"r_error\b|\br[_\s-]?error\b|"
|
|
|
+ r"账[户号]?(?:已)?受限|账[户号]?(?:已)?(?:被)?(?:冻结|限制|暂停)|"
|
|
|
r"支付失败|付款失败|银行卡(?:已)?(?:被)?拒绝|请稍后重试或使用其他支付方式|"
|
|
|
r"无法添加(?:此|这张)?(?:银行)?卡|请检查(?:卡)?信息|请尝试其他(?:银行)?卡",
|
|
|
re.I,
|
|
|
@@ -210,10 +219,19 @@ class RunContext:
|
|
|
email: str = "" # ChatGPT 注册邮箱(@edu.a4sky.com)
|
|
|
paypal_email: str = "" # PayPal 字段用的独立邮箱(@gmail.com,每次重试都换)
|
|
|
password: str = ""
|
|
|
+ phone_e164: str = DEFAULT_PHONE_E164
|
|
|
+ sms_api_url: str = DEFAULT_SMS_API_URL
|
|
|
+ paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
|
|
|
card: dict = field(default_factory=dict)
|
|
|
address: dict = field(default_factory=dict)
|
|
|
run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
|
|
|
|
|
|
+ @property
|
|
|
+ def phone_number(self) -> str:
|
|
|
+ """无国家码的手机号(只去掉前导 +1,对应 PayPal #phone 字段格式)。"""
|
|
|
+ e164 = (self.phone_e164 or DEFAULT_PHONE_E164).strip()
|
|
|
+ return e164.removeprefix("+1") if e164.startswith("+1") else e164.lstrip("+")
|
|
|
+
|
|
|
@property
|
|
|
def artifact_dir(self) -> str:
|
|
|
d = os.path.join(LOG_DIR, self.run_id)
|
|
|
@@ -338,6 +356,13 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
|
|
|
masked["token"] = (ctx.token or "")[:24] + "..."
|
|
|
ctx.log(f"[longlink] payload(masked)={json.dumps(masked, ensure_ascii=False)}")
|
|
|
|
|
|
+ # 长链生成走全局代理(payurl.ark2.cn 自身不一定需要,但保持与浏览器同源更稳)
|
|
|
+ proxies = None
|
|
|
+ proxy_url = getattr(ctx, "paypal_proxy", "") or ""
|
|
|
+ if proxy_url:
|
|
|
+ proxies = {"http": proxy_url, "https": proxy_url}
|
|
|
+ ctx.log(f"[longlink] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
|
|
|
+
|
|
|
max_attempts = 5
|
|
|
last_err = ""
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
|
@@ -351,6 +376,7 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
|
|
|
headers=headers,
|
|
|
impersonate="chrome136",
|
|
|
timeout=30,
|
|
|
+ proxies=proxies,
|
|
|
)
|
|
|
text = r.text
|
|
|
status = r.status_code
|
|
|
@@ -360,7 +386,11 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
|
|
|
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:
|
|
|
+ opener = urllib.request.build_opener()
|
|
|
+ if proxy_url:
|
|
|
+ proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
|
+ opener = urllib.request.build_opener(proxy_handler)
|
|
|
+ with opener.open(req, timeout=30) as resp:
|
|
|
text = resp.read().decode("utf-8", errors="replace")
|
|
|
status = resp.status
|
|
|
except Exception as exc:
|
|
|
@@ -429,6 +459,13 @@ def _rand_password() -> str:
|
|
|
def _check_stop(ctx: RunContext):
|
|
|
if ctx.state == "stopped":
|
|
|
raise RuntimeError("STOPPED_BY_USER")
|
|
|
+ # 让上层(chatgpt_flow.FullRunContext)注入的 hook 也能短路停止
|
|
|
+ hook = getattr(ctx, "_stop_hook", None)
|
|
|
+ if callable(hook):
|
|
|
+ try:
|
|
|
+ hook()
|
|
|
+ except Exception:
|
|
|
+ raise
|
|
|
|
|
|
|
|
|
def _dump_page(ctx: RunContext, page, tag: str):
|
|
|
@@ -459,6 +496,98 @@ def _dump_page(ctx: RunContext, page, tag: str):
|
|
|
ctx.log(f"[dump:{tag}] HTML 落盘失败: {exc!r}")
|
|
|
|
|
|
|
|
|
+def _parse_proxy_url(proxy_url: str) -> dict | None:
|
|
|
+ """把 http://user:pass@host:port 解析成 Playwright proxy 配置。空串返回 None。"""
|
|
|
+ if not proxy_url or not proxy_url.strip():
|
|
|
+ return None
|
|
|
+ from urllib.parse import urlparse
|
|
|
+ p = urlparse(proxy_url.strip())
|
|
|
+ if not p.hostname or not p.port:
|
|
|
+ return None
|
|
|
+ server = f"{p.scheme or 'http'}://{p.hostname}:{p.port}"
|
|
|
+ out: dict = {"server": server}
|
|
|
+ if p.username:
|
|
|
+ out["username"] = p.username
|
|
|
+ if p.password:
|
|
|
+ out["password"] = p.password
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def _open_paypal_context(ctx, page):
|
|
|
+ """为 PayPal 阶段开一个带代理的新 context,返回 (new_page, old_context)。
|
|
|
+ 若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
|
|
|
+ 就返回 (page, None) 表示沿用当前 page。
|
|
|
+ """
|
|
|
+ proxy_cfg = _parse_proxy_url(getattr(ctx, "paypal_proxy", "") or "")
|
|
|
+ if not proxy_cfg:
|
|
|
+ ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
|
|
|
+ return page, None
|
|
|
+
|
|
|
+ # 若当前 context 已带相同代理,无需再切
|
|
|
+ try:
|
|
|
+ current_proxy = page.context._impl_obj._initializer.get("proxy") if hasattr(page.context, "_impl_obj") else None
|
|
|
+ except Exception:
|
|
|
+ current_proxy = None
|
|
|
+ if current_proxy and isinstance(current_proxy, dict) and current_proxy.get("server") == proxy_cfg.get("server"):
|
|
|
+ ctx.log(f"[playwright] 当前 context 已使用代理 {proxy_cfg.get('server')},PayPal 阶段沿用")
|
|
|
+ return page, None
|
|
|
+
|
|
|
+ masked = dict(proxy_cfg)
|
|
|
+ if masked.get("password"):
|
|
|
+ masked["password"] = "***"
|
|
|
+ ctx.log(f"[playwright] PayPal 阶段使用代理: {masked}")
|
|
|
+ try:
|
|
|
+ browser = page.context.browser
|
|
|
+ except Exception as exc:
|
|
|
+ ctx.log(f"[playwright] 取 browser 失败: {exc!r},回退直连")
|
|
|
+ return page, None
|
|
|
+
|
|
|
+ try:
|
|
|
+ old_ctx = page.context
|
|
|
+ new_ctx = browser.new_context(
|
|
|
+ locale="en-US",
|
|
|
+ timezone_id="America/New_York",
|
|
|
+ viewport={"width": 1280, "height": 900},
|
|
|
+ proxy=proxy_cfg,
|
|
|
+ )
|
|
|
+ new_page = new_ctx.new_page()
|
|
|
+ new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
|
|
|
+ new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
|
|
|
+ new_page.on("framenavigated", lambda f: ctx.log(f"[nav] {f.url}") if f == new_page.main_frame else None)
|
|
|
+ new_page.on("requestfailed", lambda r: ctx.log(f"[req-failed] {r.method} {r.url} -> {r.failure}"))
|
|
|
+ ctx.log("[playwright] PayPal 代理 context 已就绪")
|
|
|
+ return new_page, old_ctx
|
|
|
+ except Exception as exc:
|
|
|
+ ctx.log(f"[playwright] 新建代理 context 失败: {exc!r},回退直连")
|
|
|
+ return page, None
|
|
|
+
|
|
|
+
|
|
|
+def _close_paypal_context(ctx, paypal_page, old_ctx):
|
|
|
+ """关掉 PayPal 阶段的代理 context(如果有),返回原始 page 用于后续拉 session。"""
|
|
|
+ if old_ctx is None:
|
|
|
+ return paypal_page # 没切代理,直接返回
|
|
|
+ try:
|
|
|
+ new_ctx = paypal_page.context
|
|
|
+ for p in list(new_ctx.pages):
|
|
|
+ try:
|
|
|
+ p.close()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ new_ctx.close()
|
|
|
+ ctx.log("[playwright] PayPal 代理 context 已关闭")
|
|
|
+ except Exception as exc:
|
|
|
+ ctx.log(f"[playwright] 关闭代理 context 异常: {exc!r}")
|
|
|
+ # 从老 context 找一个还活着的 page,没有就新开一个
|
|
|
+ try:
|
|
|
+ live_pages = [p for p in old_ctx.pages if not p.is_closed()]
|
|
|
+ if live_pages:
|
|
|
+ return live_pages[0]
|
|
|
+ return old_ctx.new_page()
|
|
|
+ except Exception as exc:
|
|
|
+ ctx.log(f"[playwright] 切回老 context 异常: {exc!r}")
|
|
|
+ return paypal_page
|
|
|
+
|
|
|
+
|
|
|
def run_paypal_flow(ctx: RunContext, page=None):
|
|
|
"""跑 Stripe → PayPal。这一段不走代理。
|
|
|
page 不为空时复用外部浏览器(避免嵌套 sync_playwright),否则自己开。
|
|
|
@@ -481,63 +610,72 @@ def run_paypal_flow(ctx: RunContext, page=None):
|
|
|
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(f"[playwright] phone={ctx.phone_e164}")
|
|
|
+
|
|
|
+ # 切到 PayPal 阶段的代理 context(如果配置了 paypal_proxy)
|
|
|
+ paypal_page, old_ctx = _open_paypal_context(ctx, page)
|
|
|
|
|
|
ctx.log("[playwright] 复用外部浏览器 page 跑 Stripe/PayPal")
|
|
|
last_err: Exception | None = None
|
|
|
- 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:
|
|
|
+ for attempt in range(1, MAX_PAYPAL_RETRIES + 1):
|
|
|
+ ctx.paypal_email = _rand_paypal_email()
|
|
|
+ ctx.log(f"[playwright] PayPal 用邮箱={ctx.paypal_email}(与 ChatGPT 注册邮箱 {ctx.email} 解耦)")
|
|
|
try:
|
|
|
- _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}")
|
|
|
+ ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
|
|
|
+ ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
|
|
|
+ paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
|
|
|
+ _dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
|
|
|
+
|
|
|
+ _stripe_select_paypal_and_submit(ctx, paypal_page)
|
|
|
+ _paypal_signup_and_pay(ctx, paypal_page)
|
|
|
+ ctx.log("[playwright] 流程完成")
|
|
|
+ _dump_page(ctx, paypal_page, "99-final")
|
|
|
+ return
|
|
|
+ except PayPalPaymentFailed as exc:
|
|
|
+ last_err = exc
|
|
|
+ ctx.log(f"[playwright] 第 {attempt} 次支付失败: {exc}")
|
|
|
+ if attempt >= MAX_PAYPAL_RETRIES:
|
|
|
+ ctx.log(f"[playwright] 已达最大重试次数 {MAX_PAYPAL_RETRIES},放弃")
|
|
|
+ raise
|
|
|
+ ctx.set_stage(f"支付失败,准备第 {attempt + 1} 次重试(清缓存+刷新长链)")
|
|
|
try:
|
|
|
- _clear_browser_state(ctx, 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)
|
|
|
+ _clear_browser_state(ctx, paypal_page)
|
|
|
+ except Exception as exc2:
|
|
|
+ ctx.log(f"[playwright] 清缓存异常(继续): {exc2!r}")
|
|
|
+ try:
|
|
|
+ ctx.address = fetch_us_address(log=ctx.log)
|
|
|
+ ctx.card = fetch_visa_card(log=ctx.log)
|
|
|
+ ctx.log(f"[playwright] 重试用新地址={ctx.address} 新卡尾号=***{ctx.card['number'][-4:]}")
|
|
|
+ except Exception as exc2:
|
|
|
+ ctx.log(f"[playwright] 重新拉取地址/卡片异常(继续): {exc2!r}")
|
|
|
+ # 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
|
|
|
+ # 表单会变 disabled。必须重新生成一个新的长链。
|
|
|
+ try:
|
|
|
+ new_link = generate_long_link_payurl(ctx)
|
|
|
+ ctx.long_link = new_link
|
|
|
+ ctx.log(f"[playwright] 重试用新长链 sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
|
|
|
+ except Exception as exc2:
|
|
|
+ ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
|
|
|
+ ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
|
|
|
+ time.sleep(30)
|
|
|
+ continue
|
|
|
+ except Exception as exc:
|
|
|
+ ctx.log(f"[playwright] 流程异常: {exc!r}")
|
|
|
+ ctx.log(traceback.format_exc())
|
|
|
+ try:
|
|
|
+ _dump_page(ctx, paypal_page, f"99-error-attempt{attempt}")
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ raise
|
|
|
+ if last_err is not None:
|
|
|
+ raise last_err
|
|
|
+ finally:
|
|
|
+ # 不论成功失败,都尝试关掉 PayPal 代理 context、切回原 page,便于上层继续拉 session
|
|
|
+ try:
|
|
|
+ ctx._post_paypal_page = _close_paypal_context(ctx, paypal_page, old_ctx) # type: ignore
|
|
|
except Exception as exc:
|
|
|
- ctx.log(f"[playwright] 流程异常: {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
|
|
|
+ ctx.log(f"[playwright] 关闭 PayPal context 异常: {exc!r}")
|
|
|
|
|
|
|
|
|
def _run_paypal_flow_self_browser(ctx: RunContext):
|
|
|
@@ -558,7 +696,7 @@ def _run_paypal_flow_self_browser(ctx: RunContext):
|
|
|
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(f"[playwright] phone={ctx.phone_e164}")
|
|
|
|
|
|
with sync_playwright() as p:
|
|
|
ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
|
|
|
@@ -776,7 +914,7 @@ def _stripe_select_paypal_and_submit(ctx, page):
|
|
|
_select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
|
|
|
|
|
|
if page.locator('#phoneNumber').count() > 0:
|
|
|
- _safe_fill(ctx, page, '#phoneNumber', PHONE_NUMBER, "phoneNumber")
|
|
|
+ _safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
|
|
|
|
|
|
page.wait_for_timeout(400)
|
|
|
cb = page.locator('#termsOfServiceConsentCheckbox')
|
|
|
@@ -821,12 +959,129 @@ def _stripe_select_paypal_and_submit(ctx, page):
|
|
|
_dump_page(ctx, page, "05-paypal-arrived")
|
|
|
|
|
|
|
|
|
+def _detect_datadome_captcha(page) -> str:
|
|
|
+ """检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
|
|
|
+
|
|
|
+ 判定逻辑:
|
|
|
+ 1) 如果页面已出现 PayPal 业务元素(邮箱输入框 / Create an Account / Continue / checkoutweb URL 等)
|
|
|
+ → 直接判定"已通过",不再看 DataDome 残留
|
|
|
+ 2) 否则看 DataDome iframe / 滑块 / 拦截文本
|
|
|
+ """
|
|
|
+ url = getattr(page, "url", "") or ""
|
|
|
+ try:
|
|
|
+ info = page.evaluate(
|
|
|
+ r"""() => {
|
|
|
+ const visible = (el) => {
|
|
|
+ if (!el) return false;
|
|
|
+ const s = window.getComputedStyle(el);
|
|
|
+ if (s.display === 'none' || s.visibility === 'hidden') return false;
|
|
|
+ const r = el.getBoundingClientRect();
|
|
|
+ return r.width > 0 && r.height > 0;
|
|
|
+ };
|
|
|
+ const out = { hasIframe: false, hasSlider: false, snippet: '', hasBusiness: false, businessHint: '' };
|
|
|
+
|
|
|
+ // 正向:PayPal 业务页面元素(出现即代表已过 DataDome)
|
|
|
+ const emailInput = document.querySelector(
|
|
|
+ 'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
|
|
|
+ );
|
|
|
+ if (emailInput && visible(emailInput)) {
|
|
|
+ out.hasBusiness = true;
|
|
|
+ out.businessHint = 'email-input-visible';
|
|
|
+ }
|
|
|
+ if (!out.hasBusiness) {
|
|
|
+ const c2pBtns = Array.from(document.querySelectorAll(
|
|
|
+ 'button, a, [role="button"]'
|
|
|
+ )).filter(visible);
|
|
|
+ const matched = c2pBtns.find((el) => {
|
|
|
+ const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
|
|
|
+ return /create\s+an?\s+account|continue\s+to\s+payment|pay\s+with\s+(?:debit|credit)\s+card/i.test(t);
|
|
|
+ });
|
|
|
+ if (matched) {
|
|
|
+ out.hasBusiness = true;
|
|
|
+ out.businessHint = 'business-button:' + (matched.innerText || '').slice(0, 40);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 反向:DataDome 拦截特征(仅当业务元素未出现才看)
|
|
|
+ if (!out.hasBusiness) {
|
|
|
+ const ifr = document.querySelector(
|
|
|
+ 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
|
|
|
+ );
|
|
|
+ if (ifr && visible(ifr)) out.hasIframe = true;
|
|
|
+ const slider = document.querySelector(
|
|
|
+ '[id*="datadome" i] [class*="slider" i], [class*="datadome" i] [class*="slider" i], '
|
|
|
+ + '[id^="ddc-"], [class*="captcha-container" i], [aria-label*="slider" i][role="slider"]'
|
|
|
+ );
|
|
|
+ if (slider && visible(slider)) out.hasSlider = true;
|
|
|
+ const t = (document.body && document.body.innerText || '').replace(/\s+/g, ' ');
|
|
|
+ if (/please\s+slide\s+to\s+verify|slide\s+to\s+complete|slide\s+to\s+confirm|拖动滑块|拖拽滑块|滑动验证|完成拼图|complete\s+the\s+puzzle/i.test(t)) {
|
|
|
+ out.hasSlider = true;
|
|
|
+ out.snippet = t.slice(0, 200);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }"""
|
|
|
+ ) or {}
|
|
|
+ except Exception:
|
|
|
+ info = {}
|
|
|
+
|
|
|
+ # 已经在 PayPal 业务页面 → 一定通过
|
|
|
+ if info.get("hasBusiness"):
|
|
|
+ return ""
|
|
|
+
|
|
|
+ # URL 已经进入 /checkoutweb/ 或 /pay/ 带业务参数 → 也算通过
|
|
|
+ if "/checkoutweb/" in url:
|
|
|
+ return ""
|
|
|
+ if ("paypal_client_cfci=" in url) or ("paypal_client_metadata_id=" in url):
|
|
|
+ return ""
|
|
|
+
|
|
|
+ if info.get("hasIframe") or info.get("hasSlider"):
|
|
|
+ return f"DataDome captcha 检测到 iframe={info.get('hasIframe')} slider={info.get('hasSlider')} url={url} snippet={info.get('snippet', '')[:120]!r}"
|
|
|
+ return ""
|
|
|
+
|
|
|
+
|
|
|
+def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
|
|
|
+ """提示用户手动过滑块,轮询直到 DataDome 元素消失或超时。"""
|
|
|
+ ctx.set_stage("⚠️ 检测到 DataDome 滑块校验,请在浏览器中手动滑动完成(最长等 5 分钟)")
|
|
|
+ deadline = time.time() + max_wait_sec
|
|
|
+ last_log = 0.0
|
|
|
+ poll_interval = 0.5 # 高频检测,滑过后 ≤500ms 就能继续
|
|
|
+ stable_needed = 2 # 连续 2 次检测不到才算真的通过(避免 DataDome 中间状态误判)
|
|
|
+ stable_count = 0
|
|
|
+ while time.time() < deadline:
|
|
|
+ _check_stop(ctx)
|
|
|
+ if not _detect_datadome_captcha(page):
|
|
|
+ stable_count += 1
|
|
|
+ if stable_count >= stable_needed:
|
|
|
+ ctx.log("[paypal] DataDome 已通过,继续流程")
|
|
|
+ # 给页面 1s 完成跳转,但不再傻等
|
|
|
+ page.wait_for_timeout(1000)
|
|
|
+ return True
|
|
|
+ else:
|
|
|
+ stable_count = 0
|
|
|
+ if time.time() - last_log > 10:
|
|
|
+ remaining = int(deadline - time.time())
|
|
|
+ ctx.log(f"[paypal] 仍在等待人工通过 DataDome 滑块... 剩余 {remaining}s")
|
|
|
+ last_log = time.time()
|
|
|
+ time.sleep(poll_interval)
|
|
|
+ ctx.log("[paypal] DataDome 等待超时")
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
def _paypal_signup_and_pay(ctx, page):
|
|
|
ctx.log("[paypal] === 进入 PayPal 流程 ===")
|
|
|
page.wait_for_load_state("domcontentloaded", timeout=30000)
|
|
|
page.wait_for_timeout(1500)
|
|
|
_check_stop(ctx)
|
|
|
|
|
|
+ # PayPal 着陆即可能弹 DataDome 滑块(agreements/approve、checkoutweb 都见过)
|
|
|
+ captcha_reason = _detect_datadome_captcha(page)
|
|
|
+ if captcha_reason:
|
|
|
+ ctx.log(f"[paypal] {captcha_reason}")
|
|
|
+ if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
|
|
|
+ _dump_page(ctx, page, "06-paypal-datadome-timeout")
|
|
|
+ raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
|
|
|
+
|
|
|
_paypal_clear_session(ctx, page)
|
|
|
|
|
|
# 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
|
|
|
@@ -841,6 +1096,19 @@ def _paypal_signup_and_pay(ctx, page):
|
|
|
ctx.log(f"[paypal] checkoutweb 进入路径: {path_taken}")
|
|
|
_dump_page(ctx, page, "06-paypal-checkoutweb-entered")
|
|
|
|
|
|
+ # 进 /checkoutweb/ 之后再次检测 DataDome(PayPal 有时会在表单页弹)
|
|
|
+ captcha_reason = _detect_datadome_captcha(page)
|
|
|
+ if captcha_reason:
|
|
|
+ ctx.log(f"[paypal] {captcha_reason}")
|
|
|
+ if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
|
|
|
+ _dump_page(ctx, page, "06b-paypal-datadome-timeout")
|
|
|
+ raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
|
|
|
+
|
|
|
+ if path_taken == "unknown":
|
|
|
+ # 上层没识别到 /checkoutweb/,强行往下填表只会错位 → 直接抛失败让外层 retry
|
|
|
+ ctx.log("[paypal] ensure_checkoutweb 返回 unknown,直接判定本次失败以触发重试")
|
|
|
+ raise PayPalPaymentFailed(f"未能进入 /checkoutweb/ 表单页, url={page.url}")
|
|
|
+
|
|
|
ctx.log("[paypal] 等待关键字段挂载(cardNumber/phone/billingLine1)")
|
|
|
for sel in ('#cardNumber', '#phone', '#billingLine1'):
|
|
|
try:
|
|
|
@@ -920,7 +1188,7 @@ def _paypal_fill_form(ctx, page, addr, card, retry_only=None):
|
|
|
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")
|
|
|
+ _paypal_type_into(ctx, page, '#phone', ctx.phone_number, "phone")
|
|
|
if "cardNumber" in targets:
|
|
|
_paypal_type_into(ctx, page, '#cardNumber', card["number"], "cardNumber", mask=True)
|
|
|
if "cardExpiry" in targets:
|
|
|
@@ -1074,7 +1342,7 @@ def _paypal_handle_sms(ctx, page):
|
|
|
|
|
|
_dump_page(ctx, page, "12-paypal-sms-prompt")
|
|
|
ctx.log("[paypal] 开始等待短信验证码(最长 180s)")
|
|
|
- code = fetch_sms_code(timeout=180, interval=5, log=ctx.log)
|
|
|
+ code = fetch_sms_code(timeout=180, interval=5, log=ctx.log, sms_api_url=ctx.sms_api_url)
|
|
|
|
|
|
if digit_count > 0:
|
|
|
ctx.log(f"[paypal] 逐位填入 OTP,长度={len(code)} 输入框={digit_count}")
|
|
|
@@ -1268,8 +1536,8 @@ def _safe_fill(ctx, page, selector: str, value: str, label: str = "", mask: bool
|
|
|
if count == 0:
|
|
|
ctx.log(f"[fill] {label}({selector}) 不存在,跳过")
|
|
|
return
|
|
|
- loc.wait_for(state="visible", timeout=8000)
|
|
|
- loc.fill(value)
|
|
|
+ loc.wait_for(state="visible", timeout=6000)
|
|
|
+ loc.fill(value, timeout=6000)
|
|
|
ctx.log(f"[fill] {label}({selector}) <- {shown}")
|
|
|
except Exception as exc:
|
|
|
ctx.log(f"[fill] {label}({selector}) 失败: {exc!r}")
|
|
|
@@ -1286,11 +1554,11 @@ def _select_by_text(ctx, page, selector: str, text: str, label: str = ""):
|
|
|
for kind in ("value", "label", "raw"):
|
|
|
try:
|
|
|
if kind == "value":
|
|
|
- loc.select_option(value=text)
|
|
|
+ loc.select_option(value=text, timeout=4000)
|
|
|
elif kind == "label":
|
|
|
- loc.select_option(label=text)
|
|
|
+ loc.select_option(label=text, timeout=4000)
|
|
|
else:
|
|
|
- loc.select_option(text)
|
|
|
+ loc.select_option(text, timeout=4000)
|
|
|
ctx.log(f"[select] {label}({selector}) <- {text!r} 命中方式={kind}")
|
|
|
return
|
|
|
except Exception as exc:
|