实现 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,226 @@
|
||||
"""Rakuten 探针:搜低价商品 → 实测 add-to-cart → 跨 cart/checkout
|
||||
|
||||
策略(顺序):
|
||||
1. 用 Playwright 打开 search.rakuten.co.jp 限定最低价筛选
|
||||
2. 从结果里逐个取商品 URL
|
||||
3. 用 Playwright 实际打开商品页,等 "買い物かごに入れる" 按钮
|
||||
4. 点按钮(不是手工 POST,确保 form 字段都是当前版本)
|
||||
5. 跟到 cart 后让 SPA 渲染够久
|
||||
6. 落证据,尝试清理(删除购物车里这件)
|
||||
|
||||
避免 httpx(已被 TLS 指纹拦)。全程 Playwright。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 collect_item_urls(page, limit: int = 10) -> list[str]:
|
||||
"""从搜索结果抽商品 URL(PC 与 SP 都试)"""
|
||||
html = await page.content()
|
||||
# 候选:/shop-code/item-code/、item.rakuten.co.jp、div[data-itemid]
|
||||
urls: list[str] = []
|
||||
for m in re.findall(r'https://item\.rakuten\.co\.jp/[\w-]+/[\w-]+/?', html):
|
||||
if m not in urls:
|
||||
urls.append(m)
|
||||
if len(urls) >= limit:
|
||||
break
|
||||
return urls
|
||||
|
||||
|
||||
async def try_add_item(page, item_url: str) -> tuple[bool, str]:
|
||||
"""尝试加购一个商品。返回 (是否成功, 落地 URL)。"""
|
||||
print(f" open item page: {item_url}")
|
||||
try:
|
||||
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as exc:
|
||||
return False, f"goto failed: {exc}"
|
||||
|
||||
# 等 __INITIAL_STATE__ 出现
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"() => window.__INITIAL_STATE__ && window.__INITIAL_STATE__.purchase",
|
||||
timeout=10_000,
|
||||
)
|
||||
except Exception:
|
||||
return False, "no __INITIAL_STATE__ (PC 模板/反爬)"
|
||||
|
||||
# 找加购按钮
|
||||
candidates = [
|
||||
'input[type="submit"][value*="買い物かご"]',
|
||||
'input[type="submit"][value*="購入"]',
|
||||
'button:has-text("買い物かご")',
|
||||
'button:has-text("カートに入れる")',
|
||||
'[data-testid*="add-cart"]',
|
||||
'button.add-cart',
|
||||
]
|
||||
btn = None
|
||||
for sel in candidates:
|
||||
loc = page.locator(sel).first
|
||||
try:
|
||||
await loc.wait_for(state="visible", timeout=2000)
|
||||
btn = loc
|
||||
print(f" found button via {sel!r}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if btn is None:
|
||||
return False, "no add-to-cart button visible"
|
||||
|
||||
# 监听导航
|
||||
try:
|
||||
async with page.expect_navigation(timeout=15_000, wait_until="domcontentloaded"):
|
||||
await btn.click()
|
||||
except Exception as exc:
|
||||
return False, f"click/navigation failed: {exc}"
|
||||
|
||||
final_url = page.url
|
||||
body = await page.content()
|
||||
# 成功标志:URL 含 cart/add-item 或 confirm;或正文含 レジに進む
|
||||
success = (
|
||||
"add-item" in final_url
|
||||
or "cart" in final_url
|
||||
or "レジに進む" in body
|
||||
or "買い物かごに追加" in body
|
||||
)
|
||||
# 失败标志:URL 含 /error
|
||||
if "/error" in final_url:
|
||||
return False, f"error page: {final_url}"
|
||||
return success, final_url
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
settings = get_settings()
|
||||
state_path = settings.auth_state_path / "rakuten_state.json"
|
||||
if not state_path.exists():
|
||||
print(f"找不到 {state_path}")
|
||||
return 1
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True,
|
||||
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()
|
||||
|
||||
# 搜索:100~300 円筛选
|
||||
print("=== 步骤 1:搜低价商品 ===")
|
||||
search_url = "https://search.rakuten.co.jp/search/mall/?f=2&fs=1000&max=300&min=100&s=1&p=1"
|
||||
# 备用关键词:邮费便宜 / 免邮的便宜商品
|
||||
await page.goto(search_url, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(2000)
|
||||
save("search-result.html", await page.content())
|
||||
item_urls = await collect_item_urls(page, limit=10)
|
||||
print(f" 候选商品 {len(item_urls)} 个")
|
||||
for u in item_urls[:5]:
|
||||
print(f" {u}")
|
||||
|
||||
# 试加购,最多试 5 个
|
||||
chosen: str | None = None
|
||||
for i, url in enumerate(item_urls[:5]):
|
||||
print(f"\n=== 步骤 2.{i+1}:试加购 {url} ===")
|
||||
ok, info = await try_add_item(page, url)
|
||||
print(f" result: ok={ok} info={info}")
|
||||
if ok:
|
||||
chosen = url
|
||||
save("03-add-success-landing.html", await page.content())
|
||||
break
|
||||
# 失败:保存页面用于复盘
|
||||
save(f"03-add-fail-{i+1}.html", await page.content())
|
||||
|
||||
if not chosen:
|
||||
print("\n✗ 5 个商品全部加购失败。")
|
||||
await browser.close()
|
||||
return 1
|
||||
|
||||
print(f"\n=== 步骤 3:访问 cart,让 SPA 充分渲染 ===")
|
||||
# 先访问 cart
|
||||
await page.goto(auth_site.RAKUTEN_CART_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
# 等 SPA 把 cart 数据渲染出来:尝试等 "レジに進む" 出现,最多 15 秒
|
||||
try:
|
||||
await page.wait_for_selector(
|
||||
'a:has-text("レジに進む"), button:has-text("レジに進む"), [data-testid*="checkout"]',
|
||||
timeout=15_000,
|
||||
)
|
||||
print(" 'レジに進む' 出现了!")
|
||||
except Exception:
|
||||
print(" 'レジに進む' 15s 内没出现,继续抓现状")
|
||||
|
||||
cart_html = await page.content()
|
||||
save("04-cart-after-add.html", cart_html)
|
||||
print(f" cart len={len(cart_html)} url={page.url}")
|
||||
|
||||
# 找 レジに進む href
|
||||
checkout_links = re.findall(r'href=["\']([^"\']*(?:checkout|step1|order/step)[^"\']*)["\']', cart_html, re.IGNORECASE)
|
||||
print(f" checkout links: {sorted(set(checkout_links))[:3]}")
|
||||
|
||||
# 看 cart count API
|
||||
resp = await context.request.get(
|
||||
"https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/?sid=1010",
|
||||
headers={"Referer": "https://sp.cart.step.rakuten.co.jp/cart"},
|
||||
)
|
||||
print(f" count API: status={resp.status} body={(await resp.text())[:200]!r}")
|
||||
|
||||
print("\n=== 步骤 4:尝试清理购物车 ===")
|
||||
# 找削除按钮
|
||||
del_selectors = [
|
||||
'a:has-text("削除")',
|
||||
'button:has-text("削除")',
|
||||
'[data-testid*="delete"]',
|
||||
'[data-testid*="remove"]',
|
||||
]
|
||||
for sel in del_selectors:
|
||||
loc = page.locator(sel).first
|
||||
try:
|
||||
await loc.wait_for(state="visible", timeout=3000)
|
||||
print(f" 找到删除按钮 {sel!r},点击")
|
||||
await loc.click()
|
||||
await page.wait_for_timeout(2000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
print(" 没找到删除按钮,请手动清理购物车")
|
||||
|
||||
save("05-cart-after-cleanup.html", await page.content())
|
||||
|
||||
await browser.close()
|
||||
|
||||
print(f"\n探针输出目录:{PROBE_DIR}")
|
||||
print(f"测试用商品:{chosen}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user