paypal_flow.py 23 KB

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