"""PayPal 流程:从 Stripe 跳转 PayPal 后,识别落地页类型并强制走"创建账号"路径。 落地页可能是: A. /checkoutweb/ —— 直接是 Guest 注册+付款合表(最理想) B. 登录页 with #email 单输入框,下方有"Create Account / Sign Up / 注册"链接 C. 登录页 with #email + #password 双输入框,下方有"Create Account"按钮(你描述的死锁场景:之前点上方 Next 被当成登录) 对策: - 不在 B/C 上点上方 Next;先尝试点"创建账号"链接/按钮; - 点击后等待跳到 /checkoutweb/ 再走 Guest 表单; - 如果点不动,再降级填邮箱点 Next(保留旧行为兜底)。 """ from __future__ import annotations import re import time from typing import Callable CREATE_ACCOUNT_PATTERNS = [ r"create\s+(?:an?\s+)?account", r"sign\s*up", r"open\s+(?:an?\s+)?account", r"pay\s+with\s+(?:debit|credit)\s+card", r"pay\s+with\s+card", r"continue\s+as\s+guest", r"guest\s+checkout", r"创建(?:新)?账[户号]", r"注册(?:新)?账[户号]", r"新建账[户号]", r"以游客身份继续", ] CREATE_ACCOUNT_RE = re.compile("|".join(CREATE_ACCOUNT_PATTERNS), re.I) LOGIN_TEXT_PATTERNS = [r"\blog\s*in\b", r"\bsign\s*in\b", r"登录"] LOGIN_RE = re.compile("|".join(LOGIN_TEXT_PATTERNS), re.I) def _now_ms() -> int: return int(time.time() * 1000) def detect_landing_state(page, log: Callable[[str], None]) -> dict: """返回 { kind: 'checkoutweb'|'create_with_c2p'|'login_email_only'|'login_email_password'|'unknown', email_count, password_count, has_c2p, url }""" url = page.url or "" if "/checkoutweb/" in url: log(f"[paypal:detect] 已在 /checkoutweb/ url={url}") return {"kind": "checkoutweb", "email_count": 0, "password_count": 0, "has_c2p": False, "url": url} 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 emails = Array.from(document.querySelectorAll( 'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]' )).filter(visible); const passwords = Array.from(document.querySelectorAll( 'input#password, input[name="password"], input[type="password"], input[autocomplete="current-password"]' )).filter(visible); const bodyText = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 800); // 检测 "Continue to Payment" 中间页特征 const hasC2P = Array.from(document.querySelectorAll( 'button, input[type="submit"], [role="button"]' )).filter(visible).some((el) => { const t = (el.innerText || el.getAttribute('aria-label') || '').trim(); return /continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)/i.test(t); }); const hasCreateAccount = /create\s+(?:a\s+)?paypal\s+account|create\s+(?:an?\s+)?account/i.test(bodyText); return { emailCount: emails.length, passwordCount: passwords.length, hasC2P: hasC2P, hasCreateAccount: hasCreateAccount, bodyText: bodyText, }; }""" ) or {} except Exception as exc: log(f"[paypal:detect] page.evaluate 异常: {exc!r}") info = {} email_count = int(info.get("emailCount") or 0) password_count = int(info.get("passwordCount") or 0) has_c2p = bool(info.get("hasC2P")) has_create = bool(info.get("hasCreateAccount")) body_preview = info.get("bodyText") or "" log(f"[paypal:detect] url={url} email_count={email_count} password_count={password_count} has_c2p={has_c2p} has_create={has_create} body_preview={body_preview[:160]!r}") # 优先识别 "Create Account + Continue to Payment" 中间页 if has_c2p and has_create and password_count == 0: return {"kind": "create_with_c2p", "email_count": email_count, "password_count": password_count, "has_c2p": True, "url": url} if has_c2p and password_count == 0 and email_count >= 1: return {"kind": "create_with_c2p", "email_count": email_count, "password_count": password_count, "has_c2p": True, "url": url} if password_count >= 1: return {"kind": "login_email_password", "email_count": email_count, "password_count": password_count, "has_c2p": has_c2p, "url": url} if email_count >= 1: return {"kind": "login_email_only", "email_count": email_count, "password_count": password_count, "has_c2p": has_c2p, "url": url} return {"kind": "unknown", "email_count": email_count, "password_count": password_count, "has_c2p": has_c2p, "url": url} def find_create_account_action(page, log: Callable[[str], None]): """优先找精确 selector,再做文本扫描。返回 Locator 或 None。""" for sel in ( 'a[data-testid="signUp"]', 'button[data-testid="signUp"]', 'a[data-testid="signup"]', 'button[data-testid="signup"]', 'a[data-testid="guest-checkout"]', 'button[data-testid="guest-checkout"]', 'button[data-testid="signup-button"]', 'a[href*="signup" i]', 'a[href*="create" i]', ): loc = page.locator(sel) if loc.count() == 0: continue try: first = loc.first if first.is_visible() and first.is_enabled(): log(f"[paypal:create] 命中 selector={sel}") return first except Exception: continue # 文本扫描:只挑 a / button / role=button / role=link try: candidates = page.locator('a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]') n = candidates.count() except Exception as exc: log(f"[paypal:create] 扫描候选失败: {exc!r}") return None matched = [] for i in range(min(n, 400)): el = candidates.nth(i) try: if not el.is_visible(): continue except Exception: continue try: txt = (el.inner_text(timeout=400) or "").strip() except Exception: txt = "" if not txt: try: txt = (el.get_attribute("aria-label") or "").strip() except Exception: txt = "" if not txt: continue if CREATE_ACCOUNT_RE.search(txt) and not LOGIN_RE.search(txt): try: rect = el.bounding_box() except Exception: rect = None matched.append((i, txt, rect)) if not matched: log('[paypal:create] 未找到任何 "创建账号/Sign Up/Pay with Card" 候选') return None # 选 y 坐标最大的(页面下方更可能是"创建账号") matched.sort(key=lambda item: ((item[2] or {}).get("y", 0)), reverse=True) chosen_idx, chosen_txt, chosen_rect = matched[0] log(f"[paypal:create] 选中候选 idx={chosen_idx} text={chosen_txt!r} rect={chosen_rect}") return candidates.nth(chosen_idx) def click_create_account(page, log: Callable[[str], None]) -> bool: el = find_create_account_action(page, log) if not el: return False try: el.scroll_into_view_if_needed(timeout=2000) except Exception: pass try: el.click(timeout=4000) log("[paypal:create] 已点击创建账号") return True except Exception as exc: log(f"[paypal:create] click 失败: {exc!r},尝试 force click") try: el.click(timeout=4000, force=True) log("[paypal:create] force click 成功") return True except Exception as exc: log(f"[paypal:create] force click 也失败: {exc!r}") try: el.evaluate("el => el.click()") log("[paypal:create] JS .click() 成功") return True except Exception as exc: log(f"[paypal:create] JS click 也失败: {exc!r}") return False def wait_for_checkoutweb(page, log: Callable[[str], None], timeout_sec: int = 25) -> bool: deadline = time.time() + timeout_sec while time.time() < deadline: url = page.url or "" if "/checkoutweb/" in url: log(f"[paypal:create] 已进入 /checkoutweb/ url={url}") return True time.sleep(0.4) log(f"[paypal:create] 等待 /checkoutweb/ 超时 url={page.url}") return False def find_login_next_button(page, log: Callable[[str], None]): """优先精确 selector,再 fallback 文本扫描。返回 Locator 或 None。""" for sel in ( 'button[data-testid="submit-button"]', 'button[type="submit"]', 'button[id*="btnNext" i]', 'button#btnNext', ): loc = page.locator(sel) if loc.count() == 0: continue try: first = loc.first if first.is_visible() and first.is_enabled(): log(f"[paypal:login] Next 候选命中 selector={sel}") return first except Exception: continue # 文本扫描:找 Next/Log In/继续 等位于 PayPal 顶部表单的按钮 try: candidates = page.locator('button, input[type="submit"], [role="button"]') n = candidates.count() except Exception as exc: log(f"[paypal:login] 扫描候选失败: {exc!r}") return None LOGIN_NEXT_RE = re.compile(r"\bnext\b|\blog\s*in\b|登录|登入|继续|下一步", re.I) for i in range(min(n, 200)): el = candidates.nth(i) try: if not el.is_visible() or not el.is_enabled(): continue txt = (el.inner_text(timeout=400) or "").strip() aria = el.get_attribute("aria-label") or "" blob = f"{txt} {aria}" except Exception: continue if LOGIN_NEXT_RE.search(blob) and not CREATE_ACCOUNT_RE.search(blob): log(f"[paypal:login] Next 文本候选 idx={i} text={txt!r}") return el return None def click_login_next(page, log: Callable[[str], None]) -> bool: el = find_login_next_button(page, log) if not el: return False try: el.scroll_into_view_if_needed(timeout=2000) except Exception: pass for action in ("click", "force_click", "js_click"): try: if action == "click": el.click(timeout=4000) elif action == "force_click": el.click(timeout=4000, force=True) else: el.evaluate("el => el.click()") log(f"[paypal:login] {action} 成功") return True except Exception as exc: log(f"[paypal:login] {action} 失败: {exc!r}") continue return False def find_continue_to_payment_button(page, log: Callable[[str], None]): """Create Account 后的中间页:输入邮箱 + "Continue to Payment" 按钮。""" # 精确 selector for sel in ( 'button[data-testid="continueToPayment"]', 'button[data-testid="continue-to-payment"]', 'button[name="continueToPayment"]', 'button[id*="continueToPayment" i]', 'button[type="submit"]', ): loc = page.locator(sel) if loc.count() == 0: continue try: first = loc.first if first.is_visible() and first.is_enabled(): # 文本必须像 Continue to Payment / 继续 / Next / Pay txt = (first.inner_text(timeout=400) or "").strip() if not txt or re.search(r"continue|payment|继续|前往|pay\b", txt, re.I): log(f"[paypal:c2p] 命中 selector={sel} text={txt!r}") return first except Exception: continue # 文本扫描 try: candidates = page.locator('button, input[type="submit"], [role="button"]') n = candidates.count() except Exception as exc: log(f"[paypal:c2p] 扫描失败: {exc!r}") return None pat = re.compile(r"continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)", re.I) for i in range(min(n, 200)): el = candidates.nth(i) try: if not el.is_visible() or not el.is_enabled(): continue txt = (el.inner_text(timeout=400) or "").strip() aria = el.get_attribute("aria-label") or "" blob = f"{txt} {aria}" except Exception: continue if pat.search(blob): log(f"[paypal:c2p] 文本候选 idx={i} text={txt!r}") return el return None def click_continue_to_payment(page, log: Callable[[str], None]) -> bool: el = find_continue_to_payment_button(page, log) if not el: return False try: el.scroll_into_view_if_needed(timeout=2000) except Exception: pass for action in ("click", "force_click", "js_click"): try: if action == "click": el.click(timeout=4000) elif action == "force_click": el.click(timeout=4000, force=True) else: el.evaluate("el => el.click()") log(f"[paypal:c2p] {action} 成功") return True except Exception as exc: log(f"[paypal:c2p] {action} 失败: {exc!r}") continue return False def handle_create_account_intermediate( page, *, fallback_email: str, log: Callable[[str], None] ) -> bool: """Create Account 之后的中间页(邮箱 + Continue to Payment)。 如果识别到这个页,填邮箱并点 Continue to Payment,等到进入 /checkoutweb/。 """ page.wait_for_timeout(1500) deadline = time.time() + 12 while time.time() < deadline: 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 emails = Array.from(document.querySelectorAll( 'input#email, input[name="email"], input[type="email"]' )).filter(visible); const passwords = Array.from(document.querySelectorAll( 'input[type="password"]' )).filter(visible); const c2p = Array.from(document.querySelectorAll( 'button, input[type="submit"], [role="button"]' )).filter(visible).some((el) => { const t = (el.innerText || el.getAttribute('aria-label') || '').trim(); return /continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)/i.test(t); }); return { url: location.href, emailCount: emails.length, passwordCount: passwords.length, hasContinueToPayment: c2p, }; }""" ) or {} except Exception as exc: log(f"[paypal:c2p] 探测异常: {exc!r}") info = {} url = info.get("url") or page.url or "" if "/checkoutweb/" in url: log(f"[paypal:c2p] 已进入 /checkoutweb/ url={url}") return True # 中间页特征:有 email 框 + Continue to Payment 按钮(且没有 password 框) if info.get("emailCount", 0) >= 1 and info.get("hasContinueToPayment") and info.get("passwordCount", 0) == 0: log(f'[paypal:c2p] 命中 "输入邮箱 + Continue to Payment" 中间页 url={url}') try: page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email) log(f"[paypal:c2p] 填邮箱 {fallback_email}") except Exception as exc: log(f"[paypal:c2p] 填邮箱失败: {exc!r}") page.wait_for_timeout(400) click_continue_to_payment(page, log) # 等待进 /checkoutweb/ inner_deadline = time.time() + 15 while time.time() < inner_deadline: if "/checkoutweb/" in (page.url or ""): log(f"[paypal:c2p] Continue to Payment 后已进 /checkoutweb/ url={page.url}") return True page.wait_for_timeout(400) log(f"[paypal:c2p] 点了 Continue to Payment 但未进 /checkoutweb/ url={page.url}") break time.sleep(0.5) return False def ensure_checkoutweb( page, *, fallback_email: str, log: Callable[[str], None], on_stage: Callable[[str], None] | None = None, ) -> str: """到 /checkoutweb/ 表单页。返回 'login' / 'create' / 'create_c2p' / 'already' / 'unknown'。 策略: 1. 已在 /checkoutweb/ → done 2. email-only 登录页 → 先填 email + 点 Login Next - 进 /checkoutweb/ → 'login' 成功 - 仍卡 / 冒出 password 框 → 走 Create Account 3. 双输入框(已要求密码)→ 直接 Create Account 4. Create Account 后若进入"邮箱 + Continue to Payment"中间页,再走一次 → 'create_c2p' """ def stage(name: str): log(f"[stage:paypal] {name}") if on_stage: try: on_stage(name) except Exception: pass def should_try_login_next_first(state: dict) -> bool: kind = state.get("kind") or "" url = (state.get("url") or "").lower() # 有 Continue to Payment 特征的页面不走 Login Next,走中间页处理 if state.get("has_c2p"): return False # /pay 页面无论单/双输入框,都先尝试填 email + Next if "paypal.com/pay" in url and int(state.get("email_count") or 0) >= 1: return True if kind == "login_email_only": return True return False page.wait_for_load_state("domcontentloaded", timeout=30000) page.wait_for_timeout(1500) state = detect_landing_state(page, log) if state["kind"] == "checkoutweb": stage("PayPal 已直达 /checkoutweb/") return "already" # 优先处理 "Create Account + Continue to Payment" 中间页 if state.get("kind") == "create_with_c2p" or state.get("has_c2p"): stage("PayPal 落地为 Create Account + Continue to Payment 中间页") if handle_create_account_intermediate(page, fallback_email=fallback_email, log=log): stage("通过 Continue to Payment 进入 /checkoutweb/") return "create_c2p" # 中间页处理失败,继续尝试其他路径 log("[paypal] Continue to Payment 中间页处理失败,尝试其他路径") # 路径 A:/pay 或 email-only 落地页 → 先尝试邮箱 + Next if should_try_login_next_first(state): stage("PayPal /pay 登录页:先尝试 Login Next 路径") try: page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email) log(f"[paypal:login] 填邮箱 {fallback_email}") except Exception as exc: log(f"[paypal:login] 填邮箱失败: {exc!r}") if click_login_next(page, log): deadline = time.time() + 15 password_seen_count = 0 while time.time() < deadline: page.wait_for_timeout(800) cur = detect_landing_state(page, log) if cur["kind"] == "checkoutweb": stage("通过 Login Next 进入 /checkoutweb/") return "login" # 检测到 C2P 中间页,立刻跳出去处理 if cur.get("has_c2p") or cur["kind"] == "create_with_c2p": log("[paypal:login] Login Next 后检测到 Continue to Payment 中间页,切到 C2P 处理") break if cur["kind"] == "login_email_password": password_seen_count += 1 if password_seen_count >= 3: log("[paypal:login] 多次检测到 password 输入框,登录路径不可行,将切到 Create Account") break else: password_seen_count = 0 else: log("[paypal:login] 15s 内未进 /checkoutweb/,将切到 Create Account 兜底") else: log("[paypal:login] 没找到可点的 Login Next,将切到 Create Account") state2 = detect_landing_state(page, log) if state2["kind"] == "checkoutweb": stage("Login Next 之后已进 /checkoutweb/") return "login" # Login Next 后页面变成了 C2P 中间页,直接处理 if state2.get("has_c2p") or state2["kind"] == "create_with_c2p": stage("Login Next 后进入 Create Account + Continue to Payment 中间页") if handle_create_account_intermediate(page, fallback_email=fallback_email, log=log): stage("通过 Continue to Payment 进入 /checkoutweb/") return "create_c2p" log("[paypal] Continue to Payment 中间页处理失败") # 路径 B:Create Account(之后可能还有"邮箱 + Continue to Payment"中间页) stage(f"PayPal 落地为 {state2['kind']},尝试点击 Create Account") if click_create_account(page, log): # 直接进 /checkoutweb/? if wait_for_checkoutweb(page, log, timeout_sec=10): stage("通过 Create Account 直接进入 /checkoutweb/") return "create" # 否则尝试处理 Continue to Payment 中间页 stage("Create Account 后未直接进 /checkoutweb/,处理 Continue to Payment 中间页") if handle_create_account_intermediate(page, fallback_email=fallback_email, log=log): stage("通过 Create Account → Continue to Payment 进入 /checkoutweb/") return "create_c2p" # 还有一种情况:URL 没改但表单已切到 Guest 模式 page.wait_for_timeout(1500) state3 = detect_landing_state(page, log) if state3["kind"] == "checkoutweb": return "create" log(f"[paypal] 路径都未进 /checkoutweb/ url={page.url}") return "unknown"