实现 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,250 @@
|
||||
"""Rakuten 加购探针 v2:补 variant_id 与必填选项
|
||||
|
||||
修复点:
|
||||
- 多规格商品(inventory_flag=2):从 sku.variants[] 选第一个非售罄的,把 variant_id 加进 form
|
||||
- 必填选项:从 purchase.information.options[] 选第一个 value,拼成 "名:值" 加进 choice 字段
|
||||
- 加 Books 测试(普通书籍应该 inventory_flag=1,单一库存)
|
||||
|
||||
目的:验证抓取端 PurchaseInfo 契约 + 调用方选 variant/option 后,能否成功加购。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def save(name: str, content: str) -> Path:
|
||||
path = PROBE_DIR / name
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def parse_state(html: str) -> dict | None:
|
||||
m = re.search(r"window\.__INITIAL_STATE__\s*=\s*(.+?);\s*window\.", html, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def extract_full(state: dict) -> dict:
|
||||
purchase = state.get("purchase") or {}
|
||||
sell_type = (purchase.get("sellType") or {}).get("normalPurchase") or {}
|
||||
raw_sku = purchase.get("sku") or {}
|
||||
item = state.get("item") or {}
|
||||
shop = (state.get("shop") or {}).get("information") or {}
|
||||
information = purchase.get("information") or {}
|
||||
|
||||
basket_domain = (sell_type.get("basketDomain") or "").replace("\\u002F", "/")
|
||||
inventory_type = raw_sku.get("inventoryType")
|
||||
inventory_flag = "2" if inventory_type == "multiple" else "1"
|
||||
shop_id = shop.get("shopId")
|
||||
item_id = item.get("itemId")
|
||||
|
||||
form_fields: dict[str, str] = {
|
||||
"shop_bid": str(shop_id) if shop_id else "",
|
||||
"item_id": str(item_id) if item_id else "",
|
||||
"inventory_flag": inventory_flag,
|
||||
"__event": "ES01_003_001",
|
||||
}
|
||||
|
||||
# 多规格:挑第一个非售罄 variant
|
||||
chosen_variant = None
|
||||
variants = raw_sku.get("variants") or []
|
||||
if inventory_flag == "2" and variants:
|
||||
for v in variants:
|
||||
if not v.get("isSoldOut"):
|
||||
chosen_variant = v
|
||||
break
|
||||
if chosen_variant is None and variants:
|
||||
chosen_variant = variants[0]
|
||||
if chosen_variant:
|
||||
form_fields["variant_id"] = str(chosen_variant.get("variantId") or chosen_variant.get("id") or "")
|
||||
elif inventory_flag == "1" and item.get("variantId"):
|
||||
form_fields["variant_id"] = str(item.get("variantId"))
|
||||
|
||||
# 必填选项:拼成 "名:値"
|
||||
choice_pairs: list[str] = []
|
||||
options = information.get("options") or []
|
||||
required_options = []
|
||||
for opt in options:
|
||||
if opt.get("isRequired"):
|
||||
required_options.append(opt)
|
||||
values = opt.get("values") or []
|
||||
if values:
|
||||
first = values[0]
|
||||
choice_pairs.append(f"{opt.get('name')}:{first.get('name')}")
|
||||
if choice_pairs:
|
||||
form_fields["choice"] = ",".join(choice_pairs)
|
||||
|
||||
return {
|
||||
"basket_domain": basket_domain,
|
||||
"form_fields": form_fields,
|
||||
"quantity_field": "units",
|
||||
"shop_name": shop.get("shopName"),
|
||||
"item_name": (item.get("itemName") or "")[:80],
|
||||
"min_price": sell_type.get("minPrice"),
|
||||
"purchase_condition": sell_type.get("purchaseCondition"),
|
||||
"inventory_type": inventory_type,
|
||||
"variant_count": len(variants),
|
||||
"chosen_variant": chosen_variant.get("variantId") if chosen_variant else None,
|
||||
"required_options": [o.get("name") for o in required_options],
|
||||
"choice_value": form_fields.get("choice"),
|
||||
}
|
||||
|
||||
|
||||
async def probe_one(page, context, item_url: str, log: list[str]) -> bool:
|
||||
log.append(f"\n--- {item_url} ---")
|
||||
try:
|
||||
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_function(
|
||||
"() => window.__INITIAL_STATE__ && window.__INITIAL_STATE__.purchase",
|
||||
timeout=10_000,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.append(f" load fail: {exc}")
|
||||
return False
|
||||
|
||||
state = parse_state(await page.content())
|
||||
if not state:
|
||||
log.append(" state parse fail")
|
||||
return False
|
||||
|
||||
info = extract_full(state)
|
||||
log.append(f" shop: {info['shop_name']}")
|
||||
log.append(f" item: {info['item_name']}")
|
||||
log.append(f" price: {info['min_price']} condition: {info['purchase_condition']} inv: {info['inventory_type']} variants: {info['variant_count']} chosen_variant: {info['chosen_variant']}")
|
||||
log.append(f" required_options: {info['required_options']} choice: {info['choice_value']}")
|
||||
log.append(f" basket: {info['basket_domain']}")
|
||||
log.append(f" form: {info['form_fields']}")
|
||||
|
||||
if info["purchase_condition"] != "enabled":
|
||||
log.append(f" SKIP condition={info['purchase_condition']}")
|
||||
return False
|
||||
if info["inventory_type"] == "multiple" and not info["chosen_variant"]:
|
||||
log.append(" SKIP: multi-inventory but no variant chosen")
|
||||
return False
|
||||
if info["required_options"] and not info["choice_value"]:
|
||||
log.append(" SKIP: required options but no value")
|
||||
return False
|
||||
|
||||
payload = dict(info["form_fields"])
|
||||
payload[info["quantity_field"]] = "1"
|
||||
|
||||
resp = await context.request.post(
|
||||
info["basket_domain"],
|
||||
form=payload,
|
||||
max_redirects=5,
|
||||
headers={"Referer": item_url, "Origin": "https://item.rakuten.co.jp"},
|
||||
)
|
||||
body = await resp.text()
|
||||
log.append(f" POST status={resp.status} final_url={resp.url}")
|
||||
|
||||
# 错误页判断
|
||||
if "/error" in str(resp.url):
|
||||
# 抓错误页里的具体提示
|
||||
m_msgs = re.findall(r'>([^<>]{20,200})<', body)
|
||||
msgs = [s.strip() for s in m_msgs if s.strip() and not any(c in s for c in ['(', ')', '=', '{', '.', ':'])][:3]
|
||||
log.append(f" ERROR page messages: {msgs}")
|
||||
save(f"err-{urlparse(item_url).path.replace('/', '_')}.html", body)
|
||||
return False
|
||||
|
||||
# 成功标志
|
||||
success_markers = ["レジに進む", "買い物かごに追加", "数量", "cart-item", "orderStep"]
|
||||
is_success = any(m in body for m in success_markers)
|
||||
log.append(f" success_markers_hit={is_success}")
|
||||
save(f"ok-{urlparse(item_url).path.replace('/', '_')}.html", body)
|
||||
return is_success
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
settings = get_settings()
|
||||
state_path = settings.auth_state_path / "rakuten_state.json"
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
log: list[str] = []
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True, args=["--no-first-run"])
|
||||
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()
|
||||
|
||||
# 先去 cart 建立 session
|
||||
await page.goto(auth_site.RAKUTEN_CART_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# 搜普通市场商品
|
||||
log.append("=== 搜 NORMAL ===")
|
||||
await page.goto(
|
||||
"https://search.rakuten.co.jp/search/mall/?max=300&min=100&s=1&p=1&v=2",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30_000,
|
||||
)
|
||||
await page.wait_for_timeout(2000)
|
||||
normal_urls = list(dict.fromkeys(
|
||||
re.findall(r"https://item\.rakuten\.co\.jp/[\w-]+/[\w-]+/?", await page.content())
|
||||
))[:3]
|
||||
log.append(f"normal: {normal_urls}")
|
||||
|
||||
# 搜 Books
|
||||
log.append("\n=== 搜 BOOKS ===")
|
||||
await page.goto(
|
||||
"https://search.rakuten.co.jp/search/books/?max=500&min=100&s=1&p=1&v=2",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30_000,
|
||||
)
|
||||
await page.wait_for_timeout(2000)
|
||||
books_html = await page.content()
|
||||
books_urls = list(dict.fromkeys(
|
||||
re.findall(r"https://books\.rakuten\.co\.jp/rb/\d+/?", books_html)
|
||||
))[:3]
|
||||
log.append(f"books: {books_urls}")
|
||||
|
||||
# 加购矩阵
|
||||
log.append("\n========== 测试 ==========")
|
||||
results: dict[str, list[tuple[str, bool]]] = {"NORMAL": [], "BOOKS": []}
|
||||
for url in normal_urls:
|
||||
ok = await probe_one(page, context, url, log)
|
||||
results["NORMAL"].append((url, ok))
|
||||
for url in books_urls:
|
||||
ok = await probe_one(page, context, url, log)
|
||||
results["BOOKS"].append((url, ok))
|
||||
|
||||
# 汇总
|
||||
log.append("\n========== 汇总 ==========")
|
||||
for kind, lst in results.items():
|
||||
ok = sum(1 for _, s in lst if s)
|
||||
log.append(f"{kind}: {ok}/{len(lst)} 成功")
|
||||
for u, s in lst:
|
||||
log.append(f" {'OK' if s else 'FAIL'} {u}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
save("probe-v2.txt", "\n".join(log))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user