"""Rakuten 下单流程只读探针:摸清 cart / 加购响应 / 确认页结构 只读模式:不发任何写请求。只 GET 现有购物车页(用户可能在历史会话里已有商品), 分析页面结构、找出 item_id 与"レジに進む"链接的位置。 要跑通完整流程(加购 → 确认页),加 --mutate 参数;加购后尝试自动清理。 用法: .venv/Scripts/python.exe scripts/probe_checkout.py # 只读 .venv/Scripts/python.exe scripts/probe_checkout.py --mutate # 含加购与清理 .venv/Scripts/python.exe scripts/probe_checkout.py --mutate --item-url https://item.rakuten.co.jp/... """ from __future__ import annotations import argparse import asyncio import json import re import sys from pathlib import Path # 允许 `python scripts/probe_checkout.py` 直接运行 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import httpx from app.shared.config import get_settings from app.trading.core import auth_site PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "checkout" PROBE_DIR.mkdir(parents=True, exist_ok=True) # 默认测试商品:一个低单价、稳定的乐天市场直营商品(用户可覆盖) # 留空则只跑只读流程;--mutate 必须显式传 --item-url DEFAULT_ITEM_URL = "" MOBILE_UA = auth_site.RAKUTEN_USER_AGENT def load_cookies(state_path: Path) -> list[dict]: state = json.loads(state_path.read_text(encoding="utf-8")) return state.get("cookies", []) def build_client(cookies: list[dict]) -> httpx.AsyncClient: client = httpx.AsyncClient( headers=auth_site.PROFILES["rakuten"].headers(), timeout=30.0, follow_redirects=True, http2=True, ) for cookie in cookies: name = cookie.get("name") value = cookie.get("value") if not name or value is None: continue client.cookies.set( name, value, domain=cookie.get("domain") or "", path=cookie.get("path") or "/" ) return client def save(name: str, content: str | bytes, *, is_bytes: bool = False) -> Path: path = PROBE_DIR / name if is_bytes: path.write_bytes(content) # type: ignore[arg-type] else: path.write_text(content, encoding="utf-8") # type: ignore[arg-type] print(f" saved → {path}") return path async def probe_readonly(client: httpx.AsyncClient) -> None: print("\n=== 步骤 1:GET 购物车页 ===") resp = await client.get(auth_site.RAKUTEN_CART_URL) print(f" status={resp.status_code} final_url={resp.url}") save("01-cart.html", resp.text) body = resp.text logged_out = auth_site.RAKUTEN_LOGGED_OUT_MARKER in body print(f" logged_in={not logged_out}") # 找页面里出现的 item_id(购物车里所有商品) # 常见模式:data-item-id="..."、name="item_id" value="..."、/item/.../ 等 item_ids = set(re.findall(r'"item_id"\s*[:=]\s*"?(\d+)', body)) print(f" cart item_ids={sorted(item_ids)}") # 找 "レジに進む" 链接 checkout_links = re.findall( r'href=["\']([^"\']*checkout[^"\']*)["\']', body, re.IGNORECASE ) checkout_links += re.findall( r'href=["\']([^"\']*step\.rakuten[^"\']*)["\']', body, re.IGNORECASE ) print(f" checkout links={sorted(set(checkout_links))[:5]}") # 如果有 checkout 链接,尝试 GET if checkout_links: target = checkout_links[0] if not target.startswith("http"): target = "https://sp.cart.step.rakuten.co.jp" + target print(f"\n=== 步骤 2:GET checkout 链接 {target} ===") resp2 = await client.get(target) print(f" status={resp2.status_code} final_url={resp2.url}") save("02-checkout.html", resp2.text) else: print("\n 购物车里没有可结账商品(或链接结构变化),跳过 checkout 探测") async def probe_with_mutation(client: httpx.AsyncClient, item_url: str) -> None: """完整跑一遍:加购 → 校验 → 进确认页。完成后尝试清理""" from app.scraping.parsers.item import parse_item_detail from app.scraping.parsers.state import require_state_marker import app.scraping.parsers.state as state_mod print(f"\n=== 加载商品 purchase 块:{item_url} ===") resp = await client.get(item_url) print(f" item page status={resp.status_code}") save("00-item-page.html", resp.text) # 抽 __INITIAL_STATE__ m = re.search( r']*>window\.\_\_INITIAL_STATE\_\_\s*=\s*(\{.*?\});?\s*', resp.text, re.DOTALL, ) if not m: # 备用:脚本不一定有 window. 前缀 m = re.search( r'\_\_INITIAL\_STATE\_\_\s*[:=]\s*(\{.*?\})\s*[;<]', resp.text, re.DOTALL, ) if not m: print(" ✗ 抽不出 __INITIAL_STATE__,可能不是手机版模板或反爬被触发") return state = json.loads(m.group(1)) save("00-item-state.json", json.dumps(state, ensure_ascii=False, indent=2)) shop_code_match = re.search(r"/([^/]+)/([^/]+)/?$", item_url.rstrip("/")) shop_code = shop_code_match.group(1) if shop_code_match else "" detail = parse_item_detail(state, item_url=item_url, shop_code=shop_code, include_sku_variants=True) pu = detail.purchase print(f" cart_url={pu.cart_url}") print(f" form_fields={pu.form_fields}") print(f" quantity_field={pu.quantity_field} variant_field={pu.variant_field} options_field={pu.options_field}") if not pu.cart_url: print(" ✗ basketDomain 为空,不能加购") return # 组装加购表单 payload = dict(pu.form_fields) payload[pu.quantity_field or "units"] = "1" # 单一库存商品 variant_id 已在 form_fields 里;多规格需调用方传 print(f"\n=== POST 加购 → {pu.cart_url} ===") print(f" payload={payload}") resp = await client.post(pu.cart_url, data=payload) print(f" status={resp.status_code} final_url={resp.url}") save("03-cart-add-response.html", resp.text) print("\n=== GET 购物车页(校验)===") resp = await client.get(auth_site.RAKUTEN_CART_URL) print(f" status={resp.status_code}") save("04-cart-after-add.html", resp.text) item_id = pu.form_fields.get("item_id", "") found = item_id and item_id in resp.text print(f" item_id={item_id} in_cart={found}") # 找 レジに進む 链接 checkout_links = re.findall( r'href=["\']([^"\']*checkout[^"\']*)["\']', resp.text, re.IGNORECASE ) if checkout_links: target = checkout_links[0] if not target.startswith("http"): target = "https://sp.cart.step.rakuten.co.jp" + target print(f"\n=== GET 确认页 {target} ===") resp = await client.get(target) print(f" status={resp.status_code} final_url={resp.url}") save("05-checkout-confirm.html", resp.text) # 在确认页里找金额 amounts = re.findall(r"[¥¥]?\s*([\d,]+)\s*円", resp.text) print(f" 金额候选(前 5)={amounts[:5]}") print("\n=== 清理:尝试从购物车删除 ===") # 找删除链接 del_matches = re.findall( r'href=["\']([^"\']*(?:delete|del|remove)[^"\']*)["\']', resp.text, re.IGNORECASE, ) if del_matches: print(f" delete 链接={del_matches[:3]}") # 真实删除需 POST + 各种 token,留给用户手动清 async def main() -> int: parser = argparse.ArgumentParser(description="Rakuten 下单流程探针") parser.add_argument("--mutate", action="store_true", help="包含加购写操作(默认只读)") parser.add_argument("--item-url", default=DEFAULT_ITEM_URL, help="测试商品 URL") args = parser.parse_args() settings = get_settings() state_path = settings.auth_state_path / "rakuten_state.json" if not state_path.exists(): print(f"✗ 找不到登录态文件:{state_path}") return 1 cookies = load_cookies(state_path) print(f"加载 {len(cookies)} 条 cookie 自 {state_path}") async with build_client(cookies) as client: await probe_readonly(client) if args.mutate: if not args.item_url: print("\n✗ --mutate 需要搭配 --item-url") return 1 await probe_with_mutation(client, args.item_url) print(f"\n探针输出目录:{PROBE_DIR}") return 0 if __name__ == "__main__": raise SystemExit(asyncio.run(main()))