| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- """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
- def should_try_login_next_first(state: dict) -> bool:
- kind = state.get("kind") or ""
- url = (state.get("url") or "").lower()
- if kind == "login_email_only":
- return True
- if "paypal.com/pay" in url and int(state.get("email_count") or 0) >= 1:
- 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"
- # 路径 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() + 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"
|