"""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'|'login_email_only'|'login_email_password'|'unknown', email_count, password_count, 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, "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); return { emailCount: emails.length, passwordCount: passwords.length, bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 600), }; }""" ) 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) body_preview = info.get("bodyText") or "" log(f"[paypal:detect] url={url} email_count={email_count} password_count={password_count} body_preview={body_preview[:160]!r}") if password_count >= 1: return {"kind": "login_email_password", "email_count": email_count, "password_count": password_count, "url": url} if email_count >= 1: return {"kind": "login_email_only", "email_count": email_count, "password_count": password_count, "url": url} return {"kind": "unknown", "email_count": email_count, "password_count": password_count, "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 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" # 路径 A:email-only 登录页 → 先尝试登录 Next if state["kind"] == "login_email_only": stage("PayPal 登录页:先尝试 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() + 12 while time.time() < deadline: page.wait_for_timeout(500) cur = detect_landing_state(page, log) if cur["kind"] == "checkoutweb": stage("通过 Login Next 进入 /checkoutweb/") return "login" if cur["kind"] == "login_email_password": log("[paypal:login] 检测到 password 输入框,登录路径不可行,将切到 Create Account") break else: log("[paypal:login] 12s 内未进 /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" # 路径 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"