实现 site_interact 加购与购物车校验(Playwright 通道)
探针实测发现 httpx 因 TLS/HTTP2 指纹被 Rakuten 拒认(cookie 有效但 站点不识别 session),site_interact 改为全程 Playwright + storage_state。 add_to_cart 与 verify_cart 已实测可用:从商品页 __INITIAL_STATE__ 抽 purchase 块、调用方补 variant_id 与 choice、POST basketDomain、用 cart count JSONP API 与 SPA 渲染后的 cart 页校验。enter_checkout 及之后仍 NotImplementedError:Rakuten 对 checkout 要求 session upgrade(重输密码), 是自动 checkout 的硬墙,未实测过墙方案前留接口缝。 SiteInteractor 类持有 Playwright BrowserContext,由 WorkerRunner 实例 持有、container/main 接线、lifespan 管理生命周期。测试侧 23 个用例, 全套件 333 passed,架构边界守住。 附 5 个探针脚本(scripts/probe_*.py),记录实测路径与响应结构。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""Rakuten 下单流程探针(Playwright 版)
|
||||
|
||||
httpx 因 TLS/HTTP2 指纹问题被 Rakuten 拒认(cookie 有效但站点不识别 session)。
|
||||
这个探针改用 Playwright(headless Chromium + storage_state)跑 cart → 加购 → 校验
|
||||
→ 确认页,把每步 SPA 渲染后的 HTML 落到 .probe/checkout/,作为 site_interact 实现
|
||||
的事实依据。
|
||||
|
||||
加购是写操作。默认商品是 fixture 里的小额商品(fafachai/10000033,2190 円),
|
||||
跑完会尝试从购物车删除。可用 --item-url 覆盖。
|
||||
|
||||
用法:
|
||||
.venv/Scripts/python.exe scripts/probe_checkout_playwright.py
|
||||
.venv/Scripts/python.exe scripts/probe_checkout_playwright.py --item-url https://item.rakuten.co.jp/...
|
||||
.venv/Scripts/python.exe scripts/probe_checkout_playwright.py --headful # 调试时看浏览器
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.shared.config import get_settings # noqa: E402
|
||||
from app.trading.core import auth_site # noqa: E402
|
||||
|
||||
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "checkout"
|
||||
PROBE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 默认测试商品:来自 tests/fixtures/item_state.json,2190 円小额商品
|
||||
DEFAULT_ITEM_URL = "https://item.rakuten.co.jp/fafachai/10000033/"
|
||||
|
||||
|
||||
def save(name: str, content: str) -> Path:
|
||||
path = PROBE_DIR / name
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f" saved -> {path} ({len(content)} bytes)")
|
||||
return path
|
||||
|
||||
|
||||
async def run(item_url: str, *, headful: bool) -> int:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
settings = get_settings()
|
||||
state_path = settings.auth_state_path / "rakuten_state.json"
|
||||
if not state_path.exists():
|
||||
print(f"找不到登录态文件:{state_path}")
|
||||
return 1
|
||||
|
||||
print(f"加载 storage_state: {state_path}")
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=not headful,
|
||||
channel=settings.browser_channel or None,
|
||||
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
storage_state=state_path,
|
||||
user_agent=auth_site.RAKUTEN_USER_AGENT,
|
||||
locale="ja-JP",
|
||||
timezone_id="Asia/Tokyo",
|
||||
viewport={"width": 390, "height": 844},
|
||||
is_mobile=True,
|
||||
has_touch=True,
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
# 抓所有 XHR,便于看到 SPA 调用了哪些 API
|
||||
api_calls: list[tuple[int, str]] = []
|
||||
|
||||
def on_response(r):
|
||||
url = str(r.url)
|
||||
if any(kw in url for kw in ["cart", "step", "checkout", "basket", "member", "order"]):
|
||||
if "r.r10s.jp" not in url: # 排除静态资源
|
||||
api_calls.append((r.status, url))
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
print("\n=== 步骤 1:先去 myrakuten 触发完整 session 建立 ===")
|
||||
await page.goto("https://www.rakuten.co.jp/", wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
print("\n=== 步骤 2:商品详情页 ===")
|
||||
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(2500)
|
||||
item_html = await page.content()
|
||||
save("00-item-page.html", item_html)
|
||||
|
||||
# 从 __INITIAL_STATE__ 抽 purchase 块
|
||||
m = re.search(r"window\.__INITIAL_STATE__\s*=\s*(.+?);\s*window\.", item_html, re.DOTALL)
|
||||
if not m:
|
||||
print(" ✗ 抽不出 __INITIAL_STATE__(可能 PC 模板或被反爬)")
|
||||
else:
|
||||
try:
|
||||
state = json.loads(m.group(1))
|
||||
pu = state.get("purchase", {}).get("sellType", {}).get("normalPurchase", {})
|
||||
print(f" basketDomain: {pu.get('basketDomain')}")
|
||||
print(f" minPrice: {pu.get('minPrice')}")
|
||||
save("00-item-state.json", json.dumps(state, ensure_ascii=False, indent=2))
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f" state JSON 解析失败:{exc}")
|
||||
|
||||
print("\n=== 步骤 3:点 '買い物かごに入れる' ===")
|
||||
# 商品页直接点按钮;fallback 是直接 POST basketDomain
|
||||
btn = page.locator('input[type="submit"][value*="買い物かご"], button:has-text("買い物かごに入れる")').first
|
||||
try:
|
||||
await btn.wait_for(state="visible", timeout=5000)
|
||||
print(f" 找到加购按钮,点击")
|
||||
await btn.click()
|
||||
await page.wait_for_timeout(3000)
|
||||
after_add_url = page.url
|
||||
print(f" 点击后落地:{after_add_url}")
|
||||
after_add_html = await page.content()
|
||||
save("03-cart-add-landing.html", after_add_html)
|
||||
except Exception as exc:
|
||||
print(f" 没找到加购按钮或点击失败:{type(exc).__name__}: {exc}")
|
||||
# 直接 POST basketDomain
|
||||
print(" fallback:直接构造表单 POST 到 basketDomain")
|
||||
# 这块留给 site_interact 实现,探针只看页面行为
|
||||
|
||||
print("\n=== 步骤 4:访问购物车页 ===")
|
||||
await page.goto(auth_site.RAKUTEN_CART_URL, wait_until="networkidle", timeout=30_000)
|
||||
await page.wait_for_timeout(3000)
|
||||
cart_html = await page.content()
|
||||
save("04-cart-after-add.html", cart_html)
|
||||
print(f" final_url={page.url} body_len={len(cart_html)}")
|
||||
|
||||
# 检查 SPA 状态
|
||||
for kw in ["レジに進む", "注文手続き", "小計", "商品の金額", "itemId", "shopName"]:
|
||||
print(f" contains {kw!r}: {kw in cart_html}")
|
||||
m = re.search(r'"isLoggedIn"\s*:\s*(true|false)', cart_html)
|
||||
if m:
|
||||
print(f" isLoggedIn={m.group(1)}")
|
||||
|
||||
# 抓 レジ / checkout 链接
|
||||
checkout_links = re.findall(r'href=["\']([^"\']*(?:checkout|step1|order)[^"\']*)["\']', cart_html, re.IGNORECASE)
|
||||
print(f" checkout links: {sorted(set(checkout_links))[:3]}")
|
||||
|
||||
print("\n=== 步骤 5:捕获 SPA 调用的 API ===")
|
||||
for status, url in api_calls[-30:]:
|
||||
print(f" {status} {url[:140]}")
|
||||
|
||||
# 不在探针里跑 checkout——避免真的进入付款流程
|
||||
# 真实 site_interact 实现时,需要点 レジに進む 后看跳转
|
||||
|
||||
print("\n=== 步骤 6:尝试清理购物车 ===")
|
||||
# 找删除按钮
|
||||
try:
|
||||
del_btn = page.locator('a:has-text("削除"), button:has-text("削除"), [data-testid*="delete"]').first
|
||||
if await del_btn.count() > 0:
|
||||
print(" 找到削除按钮,点击")
|
||||
await del_btn.click()
|
||||
await page.wait_for_timeout(2000)
|
||||
print(f" 清理后 url={page.url}")
|
||||
else:
|
||||
print(" 没找到削除按钮(购物车可能本就为空或按钮选择器要调)")
|
||||
except Exception as exc:
|
||||
print(f" 清理失败:{type(exc).__name__}: {exc}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
print(f"\n探针输出目录:{PROBE_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--item-url", default=DEFAULT_ITEM_URL)
|
||||
parser.add_argument("--headful", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return await run(args.item_url, headful=args.headful)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user