paypal_flow.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. """PayPal 流程:从 Stripe 跳转 PayPal 后,识别落地页类型并强制走"创建账号"路径。
  2. 落地页可能是:
  3. A. /checkoutweb/ —— 直接是 Guest 注册+付款合表(最理想)
  4. B. 登录页 with #email 单输入框,下方有"Create Account / Sign Up / 注册"链接
  5. C. 登录页 with #email + #password 双输入框,下方有"Create Account"按钮(你描述的死锁场景:之前点上方 Next 被当成登录)
  6. 对策:
  7. - 不在 B/C 上点上方 Next;先尝试点"创建账号"链接/按钮;
  8. - 点击后等待跳到 /checkoutweb/ 再走 Guest 表单;
  9. - 如果点不动,再降级填邮箱点 Next(保留旧行为兜底)。
  10. """
  11. from __future__ import annotations
  12. import re
  13. import time
  14. from typing import Callable
  15. CREATE_ACCOUNT_PATTERNS = [
  16. r"create\s+(?:an?\s+)?account",
  17. r"sign\s*up",
  18. r"open\s+(?:an?\s+)?account",
  19. r"pay\s+with\s+(?:debit|credit)\s+card",
  20. r"pay\s+with\s+card",
  21. r"continue\s+as\s+guest",
  22. r"guest\s+checkout",
  23. r"创建(?:新)?账[户号]",
  24. r"注册(?:新)?账[户号]",
  25. r"新建账[户号]",
  26. r"以游客身份继续",
  27. ]
  28. CREATE_ACCOUNT_RE = re.compile("|".join(CREATE_ACCOUNT_PATTERNS), re.I)
  29. LOGIN_TEXT_PATTERNS = [r"\blog\s*in\b", r"\bsign\s*in\b", r"登录"]
  30. LOGIN_RE = re.compile("|".join(LOGIN_TEXT_PATTERNS), re.I)
  31. def _now_ms() -> int:
  32. return int(time.time() * 1000)
  33. def detect_landing_state(page, log: Callable[[str], None]) -> dict:
  34. """返回 { kind: 'checkoutweb'|'login_email_only'|'login_email_password'|'unknown', email_count, password_count, url }"""
  35. url = page.url or ""
  36. if "/checkoutweb/" in url:
  37. log(f"[paypal:detect] 已在 /checkoutweb/ url={url}")
  38. return {"kind": "checkoutweb", "email_count": 0, "password_count": 0, "url": url}
  39. try:
  40. info = page.evaluate(
  41. r"""() => {
  42. const visible = (el) => {
  43. if (!el) return false;
  44. const s = window.getComputedStyle(el);
  45. if (s.display === 'none' || s.visibility === 'hidden') return false;
  46. const r = el.getBoundingClientRect();
  47. return r.width > 0 && r.height > 0;
  48. };
  49. const emails = Array.from(document.querySelectorAll(
  50. 'input#email, input[name="email"], input[type="email"], input[autocomplete="username"]'
  51. )).filter(visible);
  52. const passwords = Array.from(document.querySelectorAll(
  53. 'input#password, input[name="password"], input[type="password"], input[autocomplete="current-password"]'
  54. )).filter(visible);
  55. return {
  56. emailCount: emails.length,
  57. passwordCount: passwords.length,
  58. bodyText: (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 600),
  59. };
  60. }"""
  61. ) or {}
  62. except Exception as exc:
  63. log(f"[paypal:detect] page.evaluate 异常: {exc!r}")
  64. info = {}
  65. email_count = int(info.get("emailCount") or 0)
  66. password_count = int(info.get("passwordCount") or 0)
  67. body_preview = info.get("bodyText") or ""
  68. log(f"[paypal:detect] url={url} email_count={email_count} password_count={password_count} body_preview={body_preview[:160]!r}")
  69. if password_count >= 1:
  70. return {"kind": "login_email_password", "email_count": email_count, "password_count": password_count, "url": url}
  71. if email_count >= 1:
  72. return {"kind": "login_email_only", "email_count": email_count, "password_count": password_count, "url": url}
  73. return {"kind": "unknown", "email_count": email_count, "password_count": password_count, "url": url}
  74. def find_create_account_action(page, log: Callable[[str], None]):
  75. """优先找精确 selector,再做文本扫描。返回 Locator 或 None。"""
  76. for sel in (
  77. 'a[data-testid="signUp"]',
  78. 'button[data-testid="signUp"]',
  79. 'a[data-testid="signup"]',
  80. 'button[data-testid="signup"]',
  81. 'a[data-testid="guest-checkout"]',
  82. 'button[data-testid="guest-checkout"]',
  83. 'button[data-testid="signup-button"]',
  84. 'a[href*="signup" i]',
  85. 'a[href*="create" i]',
  86. ):
  87. loc = page.locator(sel)
  88. if loc.count() == 0:
  89. continue
  90. try:
  91. first = loc.first
  92. if first.is_visible() and first.is_enabled():
  93. log(f"[paypal:create] 命中 selector={sel}")
  94. return first
  95. except Exception:
  96. continue
  97. # 文本扫描:只挑 a / button / role=button / role=link
  98. try:
  99. candidates = page.locator('a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]')
  100. n = candidates.count()
  101. except Exception as exc:
  102. log(f"[paypal:create] 扫描候选失败: {exc!r}")
  103. return None
  104. matched = []
  105. for i in range(min(n, 400)):
  106. el = candidates.nth(i)
  107. try:
  108. if not el.is_visible():
  109. continue
  110. except Exception:
  111. continue
  112. try:
  113. txt = (el.inner_text(timeout=400) or "").strip()
  114. except Exception:
  115. txt = ""
  116. if not txt:
  117. try:
  118. txt = (el.get_attribute("aria-label") or "").strip()
  119. except Exception:
  120. txt = ""
  121. if not txt:
  122. continue
  123. if CREATE_ACCOUNT_RE.search(txt) and not LOGIN_RE.search(txt):
  124. try:
  125. rect = el.bounding_box()
  126. except Exception:
  127. rect = None
  128. matched.append((i, txt, rect))
  129. if not matched:
  130. log('[paypal:create] 未找到任何 "创建账号/Sign Up/Pay with Card" 候选')
  131. return None
  132. # 选 y 坐标最大的(页面下方更可能是"创建账号")
  133. matched.sort(key=lambda item: ((item[2] or {}).get("y", 0)), reverse=True)
  134. chosen_idx, chosen_txt, chosen_rect = matched[0]
  135. log(f"[paypal:create] 选中候选 idx={chosen_idx} text={chosen_txt!r} rect={chosen_rect}")
  136. return candidates.nth(chosen_idx)
  137. def click_create_account(page, log: Callable[[str], None]) -> bool:
  138. el = find_create_account_action(page, log)
  139. if not el:
  140. return False
  141. try:
  142. el.scroll_into_view_if_needed(timeout=2000)
  143. except Exception:
  144. pass
  145. try:
  146. el.click(timeout=4000)
  147. log("[paypal:create] 已点击创建账号")
  148. return True
  149. except Exception as exc:
  150. log(f"[paypal:create] click 失败: {exc!r},尝试 force click")
  151. try:
  152. el.click(timeout=4000, force=True)
  153. log("[paypal:create] force click 成功")
  154. return True
  155. except Exception as exc:
  156. log(f"[paypal:create] force click 也失败: {exc!r}")
  157. try:
  158. el.evaluate("el => el.click()")
  159. log("[paypal:create] JS .click() 成功")
  160. return True
  161. except Exception as exc:
  162. log(f"[paypal:create] JS click 也失败: {exc!r}")
  163. return False
  164. def wait_for_checkoutweb(page, log: Callable[[str], None], timeout_sec: int = 25) -> bool:
  165. deadline = time.time() + timeout_sec
  166. while time.time() < deadline:
  167. url = page.url or ""
  168. if "/checkoutweb/" in url:
  169. log(f"[paypal:create] 已进入 /checkoutweb/ url={url}")
  170. return True
  171. time.sleep(0.4)
  172. log(f"[paypal:create] 等待 /checkoutweb/ 超时 url={page.url}")
  173. return False
  174. def find_login_next_button(page, log: Callable[[str], None]):
  175. """优先精确 selector,再 fallback 文本扫描。返回 Locator 或 None。"""
  176. for sel in (
  177. 'button[data-testid="submit-button"]',
  178. 'button[type="submit"]',
  179. 'button[id*="btnNext" i]',
  180. 'button#btnNext',
  181. ):
  182. loc = page.locator(sel)
  183. if loc.count() == 0:
  184. continue
  185. try:
  186. first = loc.first
  187. if first.is_visible() and first.is_enabled():
  188. log(f"[paypal:login] Next 候选命中 selector={sel}")
  189. return first
  190. except Exception:
  191. continue
  192. # 文本扫描:找 Next/Log In/继续 等位于 PayPal 顶部表单的按钮
  193. try:
  194. candidates = page.locator('button, input[type="submit"], [role="button"]')
  195. n = candidates.count()
  196. except Exception as exc:
  197. log(f"[paypal:login] 扫描候选失败: {exc!r}")
  198. return None
  199. LOGIN_NEXT_RE = re.compile(r"\bnext\b|\blog\s*in\b|登录|登入|继续|下一步", re.I)
  200. for i in range(min(n, 200)):
  201. el = candidates.nth(i)
  202. try:
  203. if not el.is_visible() or not el.is_enabled():
  204. continue
  205. txt = (el.inner_text(timeout=400) or "").strip()
  206. aria = el.get_attribute("aria-label") or ""
  207. blob = f"{txt} {aria}"
  208. except Exception:
  209. continue
  210. if LOGIN_NEXT_RE.search(blob) and not CREATE_ACCOUNT_RE.search(blob):
  211. log(f"[paypal:login] Next 文本候选 idx={i} text={txt!r}")
  212. return el
  213. return None
  214. def click_login_next(page, log: Callable[[str], None]) -> bool:
  215. el = find_login_next_button(page, log)
  216. if not el:
  217. return False
  218. try:
  219. el.scroll_into_view_if_needed(timeout=2000)
  220. except Exception:
  221. pass
  222. for action in ("click", "force_click", "js_click"):
  223. try:
  224. if action == "click":
  225. el.click(timeout=4000)
  226. elif action == "force_click":
  227. el.click(timeout=4000, force=True)
  228. else:
  229. el.evaluate("el => el.click()")
  230. log(f"[paypal:login] {action} 成功")
  231. return True
  232. except Exception as exc:
  233. log(f"[paypal:login] {action} 失败: {exc!r}")
  234. continue
  235. return False
  236. def find_continue_to_payment_button(page, log: Callable[[str], None]):
  237. """Create Account 后的中间页:输入邮箱 + "Continue to Payment" 按钮。"""
  238. # 精确 selector
  239. for sel in (
  240. 'button[data-testid="continueToPayment"]',
  241. 'button[data-testid="continue-to-payment"]',
  242. 'button[name="continueToPayment"]',
  243. 'button[id*="continueToPayment" i]',
  244. 'button[type="submit"]',
  245. ):
  246. loc = page.locator(sel)
  247. if loc.count() == 0:
  248. continue
  249. try:
  250. first = loc.first
  251. if first.is_visible() and first.is_enabled():
  252. # 文本必须像 Continue to Payment / 继续 / Next / Pay
  253. txt = (first.inner_text(timeout=400) or "").strip()
  254. if not txt or re.search(r"continue|payment|继续|前往|pay\b", txt, re.I):
  255. log(f"[paypal:c2p] 命中 selector={sel} text={txt!r}")
  256. return first
  257. except Exception:
  258. continue
  259. # 文本扫描
  260. try:
  261. candidates = page.locator('button, input[type="submit"], [role="button"]')
  262. n = candidates.count()
  263. except Exception as exc:
  264. log(f"[paypal:c2p] 扫描失败: {exc!r}")
  265. return None
  266. pat = re.compile(r"continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)", re.I)
  267. for i in range(min(n, 200)):
  268. el = candidates.nth(i)
  269. try:
  270. if not el.is_visible() or not el.is_enabled():
  271. continue
  272. txt = (el.inner_text(timeout=400) or "").strip()
  273. aria = el.get_attribute("aria-label") or ""
  274. blob = f"{txt} {aria}"
  275. except Exception:
  276. continue
  277. if pat.search(blob):
  278. log(f"[paypal:c2p] 文本候选 idx={i} text={txt!r}")
  279. return el
  280. return None
  281. def click_continue_to_payment(page, log: Callable[[str], None]) -> bool:
  282. el = find_continue_to_payment_button(page, log)
  283. if not el:
  284. return False
  285. try:
  286. el.scroll_into_view_if_needed(timeout=2000)
  287. except Exception:
  288. pass
  289. for action in ("click", "force_click", "js_click"):
  290. try:
  291. if action == "click":
  292. el.click(timeout=4000)
  293. elif action == "force_click":
  294. el.click(timeout=4000, force=True)
  295. else:
  296. el.evaluate("el => el.click()")
  297. log(f"[paypal:c2p] {action} 成功")
  298. return True
  299. except Exception as exc:
  300. log(f"[paypal:c2p] {action} 失败: {exc!r}")
  301. continue
  302. return False
  303. def handle_create_account_intermediate(
  304. page, *, fallback_email: str, log: Callable[[str], None]
  305. ) -> bool:
  306. """Create Account 之后的中间页(邮箱 + Continue to Payment)。
  307. 如果识别到这个页,填邮箱并点 Continue to Payment,等到进入 /checkoutweb/。
  308. """
  309. page.wait_for_timeout(1500)
  310. deadline = time.time() + 12
  311. while time.time() < deadline:
  312. try:
  313. info = page.evaluate(
  314. r"""() => {
  315. const visible = (el) => {
  316. if (!el) return false;
  317. const s = window.getComputedStyle(el);
  318. if (s.display === 'none' || s.visibility === 'hidden') return false;
  319. const r = el.getBoundingClientRect();
  320. return r.width > 0 && r.height > 0;
  321. };
  322. const emails = Array.from(document.querySelectorAll(
  323. 'input#email, input[name="email"], input[type="email"]'
  324. )).filter(visible);
  325. const passwords = Array.from(document.querySelectorAll(
  326. 'input[type="password"]'
  327. )).filter(visible);
  328. const c2p = Array.from(document.querySelectorAll(
  329. 'button, input[type="submit"], [role="button"]'
  330. )).filter(visible).some((el) => {
  331. const t = (el.innerText || el.getAttribute('aria-label') || '').trim();
  332. return /continue\s+to\s+payment|continue\s+to\s+pay|继续(?:到)?(?:支付|付款)|前往(?:支付|付款)/i.test(t);
  333. });
  334. return {
  335. url: location.href,
  336. emailCount: emails.length,
  337. passwordCount: passwords.length,
  338. hasContinueToPayment: c2p,
  339. };
  340. }"""
  341. ) or {}
  342. except Exception as exc:
  343. log(f"[paypal:c2p] 探测异常: {exc!r}")
  344. info = {}
  345. url = info.get("url") or page.url or ""
  346. if "/checkoutweb/" in url:
  347. log(f"[paypal:c2p] 已进入 /checkoutweb/ url={url}")
  348. return True
  349. # 中间页特征:有 email 框 + Continue to Payment 按钮(且没有 password 框)
  350. if info.get("emailCount", 0) >= 1 and info.get("hasContinueToPayment") and info.get("passwordCount", 0) == 0:
  351. log(f'[paypal:c2p] 命中 "输入邮箱 + Continue to Payment" 中间页 url={url}')
  352. try:
  353. page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email)
  354. log(f"[paypal:c2p] 填邮箱 {fallback_email}")
  355. except Exception as exc:
  356. log(f"[paypal:c2p] 填邮箱失败: {exc!r}")
  357. page.wait_for_timeout(400)
  358. click_continue_to_payment(page, log)
  359. # 等待进 /checkoutweb/
  360. inner_deadline = time.time() + 15
  361. while time.time() < inner_deadline:
  362. if "/checkoutweb/" in (page.url or ""):
  363. log(f"[paypal:c2p] Continue to Payment 后已进 /checkoutweb/ url={page.url}")
  364. return True
  365. page.wait_for_timeout(400)
  366. log(f"[paypal:c2p] 点了 Continue to Payment 但未进 /checkoutweb/ url={page.url}")
  367. break
  368. time.sleep(0.5)
  369. return False
  370. def ensure_checkoutweb(
  371. page,
  372. *,
  373. fallback_email: str,
  374. log: Callable[[str], None],
  375. on_stage: Callable[[str], None] | None = None,
  376. ) -> str:
  377. """到 /checkoutweb/ 表单页。返回 'login' / 'create' / 'create_c2p' / 'already' / 'unknown'。
  378. 策略:
  379. 1. 已在 /checkoutweb/ → done
  380. 2. email-only 登录页 → 先填 email + 点 Login Next
  381. - 进 /checkoutweb/ → 'login' 成功
  382. - 仍卡 / 冒出 password 框 → 走 Create Account
  383. 3. 双输入框(已要求密码)→ 直接 Create Account
  384. 4. Create Account 后若进入"邮箱 + Continue to Payment"中间页,再走一次 → 'create_c2p'
  385. """
  386. def stage(name: str):
  387. log(f"[stage:paypal] {name}")
  388. if on_stage:
  389. try:
  390. on_stage(name)
  391. except Exception:
  392. pass
  393. def should_try_login_next_first(state: dict) -> bool:
  394. kind = state.get("kind") or ""
  395. url = (state.get("url") or "").lower()
  396. if kind == "login_email_only":
  397. return True
  398. if "paypal.com/pay" in url and int(state.get("email_count") or 0) >= 1:
  399. return True
  400. return False
  401. page.wait_for_load_state("domcontentloaded", timeout=30000)
  402. page.wait_for_timeout(1500)
  403. state = detect_landing_state(page, log)
  404. if state["kind"] == "checkoutweb":
  405. stage("PayPal 已直达 /checkoutweb/")
  406. return "already"
  407. # 路径 A:/pay 或 email-only 落地页 → 先尝试邮箱 + Next
  408. if should_try_login_next_first(state):
  409. stage("PayPal /pay 登录页:先尝试 Login Next 路径")
  410. try:
  411. page.locator('input#email, input[name="email"], input[type="email"]').first.fill(fallback_email)
  412. log(f"[paypal:login] 填邮箱 {fallback_email}")
  413. except Exception as exc:
  414. log(f"[paypal:login] 填邮箱失败: {exc!r}")
  415. if click_login_next(page, log):
  416. deadline = time.time() + 12
  417. while time.time() < deadline:
  418. page.wait_for_timeout(500)
  419. cur = detect_landing_state(page, log)
  420. if cur["kind"] == "checkoutweb":
  421. stage("通过 Login Next 进入 /checkoutweb/")
  422. return "login"
  423. if cur["kind"] == "login_email_password":
  424. log("[paypal:login] 检测到 password 输入框,登录路径不可行,将切到 Create Account")
  425. break
  426. else:
  427. log("[paypal:login] 12s 内未进 /checkoutweb/,将切到 Create Account 兜底")
  428. else:
  429. log("[paypal:login] 没找到可点的 Login Next,将切到 Create Account")
  430. state2 = detect_landing_state(page, log)
  431. if state2["kind"] == "checkoutweb":
  432. stage("Login Next 之后已进 /checkoutweb/")
  433. return "login"
  434. # 路径 B:Create Account(之后可能还有"邮箱 + Continue to Payment"中间页)
  435. stage(f"PayPal 落地为 {state2['kind']},尝试点击 Create Account")
  436. if click_create_account(page, log):
  437. # 直接进 /checkoutweb/?
  438. if wait_for_checkoutweb(page, log, timeout_sec=10):
  439. stage("通过 Create Account 直接进入 /checkoutweb/")
  440. return "create"
  441. # 否则尝试处理 Continue to Payment 中间页
  442. stage("Create Account 后未直接进 /checkoutweb/,处理 Continue to Payment 中间页")
  443. if handle_create_account_intermediate(page, fallback_email=fallback_email, log=log):
  444. stage("通过 Create Account → Continue to Payment 进入 /checkoutweb/")
  445. return "create_c2p"
  446. # 还有一种情况:URL 没改但表单已切到 Guest 模式
  447. page.wait_for_timeout(1500)
  448. state3 = detect_landing_state(page, log)
  449. if state3["kind"] == "checkoutweb":
  450. return "create"
  451. log(f"[paypal] 路径都未进 /checkoutweb/ url={page.url}")
  452. return "unknown"