实现 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 下单流程只读探针:摸清 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'<script[^>]*>window\.\_\_INITIAL_STATE\_\_\s*=\s*(\{.*?\});?\s*</script>',
|
||||
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()))
|
||||
@@ -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()))
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Rakuten 加购探针:验证抓取端 purchase 块字段 + 官方旗舰店是否可加购
|
||||
|
||||
要做的事:
|
||||
1. 从搜索结果取若干商品 URL(普通 + 旗舰店)
|
||||
2. 用 Playwright 打开商品页,抽 __INITIAL_STATE__.purchase(与抓取端同样的逻辑)
|
||||
3. 用同一份 cookie POST basketDomain,看响应
|
||||
4. 对比抓取端 contract 是否充分、官方店是否走同一端点
|
||||
|
||||
输出:.probe/checkout/probe-purchase-block.txt
|
||||
"""
|
||||
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"
|
||||
PROBE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 测试矩阵:
|
||||
# - 普通市场商品(来自搜索)
|
||||
# - 楽天ブックス(books.rakuten.co.jp)
|
||||
# - 楽天ブランドアベニュー(brandavenue.rakuten.co.jp)
|
||||
# - ビックカメラ(biccamera.rakuten.co.jp)
|
||||
NORMAL_URLS = [
|
||||
"https://item.rakuten.co.jp/livingut/uni437951",
|
||||
"https://item.rakuten.co.jp/waabbit/088-4p",
|
||||
]
|
||||
BOOKS_URLS = [
|
||||
"https://books.rakuten.co.jp/rb/17427435/", # 占位:实际跑时换当前有库存的
|
||||
]
|
||||
# brandavenue / biccamera 占位(探针跑时若搜索命中会自动用上)
|
||||
|
||||
|
||||
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_purchase_fields(state: dict) -> dict:
|
||||
"""与 scraping/parsers/item.py _purchase_info 相同的契约"""
|
||||
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 {}
|
||||
|
||||
basket_domain = sell_type.get("basketDomain") or ""
|
||||
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")
|
||||
variant_id = item.get("variantId")
|
||||
|
||||
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",
|
||||
}
|
||||
if inventory_flag == "1" and variant_id:
|
||||
form_fields["variant_id"] = str(variant_id)
|
||||
|
||||
return {
|
||||
"basket_domain": basket_domain.replace("\\u002F", "/"),
|
||||
"form_fields": form_fields,
|
||||
"quantity_field": "units",
|
||||
"variant_field": "variant_id",
|
||||
"options_field": "choice" if (purchase.get("information") or {}).get("options") else "",
|
||||
"min_price": sell_type.get("minPrice"),
|
||||
"purchase_condition": sell_type.get("purchaseCondition"),
|
||||
"shop_name": shop.get("shopName"),
|
||||
"item_name": item.get("itemName"),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
except Exception as exc:
|
||||
log.append(f" goto FAIL: {exc}")
|
||||
return False
|
||||
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"() => window.__INITIAL_STATE__ && window.__INITIAL_STATE__.purchase",
|
||||
timeout=10_000,
|
||||
)
|
||||
except Exception:
|
||||
log.append(" no __INITIAL_STATE__.purchase within 10s")
|
||||
return False
|
||||
|
||||
html = await page.content()
|
||||
state = parse_state(html)
|
||||
if not state:
|
||||
log.append(" state parse failed")
|
||||
return False
|
||||
|
||||
fields = extract_purchase_fields(state)
|
||||
log.append(f" basket_domain: {fields['basket_domain']}")
|
||||
log.append(f" form_fields: {fields['form_fields']}")
|
||||
log.append(f" shop: {fields['shop_name']}")
|
||||
log.append(f" item: {(fields['item_name'] or '')[:60]}")
|
||||
log.append(f" price: {fields['min_price']}, condition: {fields['purchase_condition']}")
|
||||
|
||||
# 跳过卖完的
|
||||
if fields["purchase_condition"] != "enabled":
|
||||
log.append(f" SKIP: condition={fields['purchase_condition']}")
|
||||
return False
|
||||
|
||||
payload = dict(fields["form_fields"])
|
||||
payload[fields["quantity_field"]] = "1"
|
||||
|
||||
# POST 加购
|
||||
resp = await context.request.post(
|
||||
fields["basket_domain"],
|
||||
form=payload,
|
||||
max_redirects=5,
|
||||
headers={"Referer": item_url, "Origin": "https://item.rakuten.co.jp"},
|
||||
)
|
||||
log.append(f" POST add: final_status={resp.status} url={resp.url}")
|
||||
body = await resp.text()
|
||||
save(
|
||||
f"add-{urlparse(item_url).path.replace('/', '_')}.html",
|
||||
body,
|
||||
)
|
||||
|
||||
if "/error" in str(resp.url):
|
||||
log.append(f" -> ERROR page (item may be stale or fields wrong)")
|
||||
return False
|
||||
# 成功标志
|
||||
success_markers = ["レジに進む", "買い物かごに追加しました", "数量", "cart"]
|
||||
is_success = any(m in body for m in success_markers)
|
||||
log.append(f" success_markers_hit={is_success}")
|
||||
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()
|
||||
|
||||
# 先去 sp.cart 建立 session
|
||||
await page.goto(auth_site.RAKUTEN_CART_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
# 1. 搜普通市场商品(取前 2 个)
|
||||
log.append("=== 搜索普通市场商品 ===")
|
||||
await page.goto(
|
||||
"https://search.rakuten.co.jp/search/mall/?max=500&min=100&s=1&p=1&v=2",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30_000,
|
||||
)
|
||||
await page.wait_for_timeout(2000)
|
||||
normal_urls = re.findall(
|
||||
r"https://item\.rakuten\.co\.jp/[\w-]+/[\w-]+/?",
|
||||
await page.content(),
|
||||
)
|
||||
seen = set()
|
||||
normal_urls = [u for u in normal_urls if not (u in seen or seen.add(u))][:3]
|
||||
log.append(f"候选: {normal_urls}")
|
||||
|
||||
# 2. 搜 Books
|
||||
log.append("\n=== 搜索楽天ブックス ===")
|
||||
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 = re.findall(
|
||||
r"https://books\.rakuten\.co\.jp/rb/\d+/?",
|
||||
books_html,
|
||||
)
|
||||
books_urls = [u for u in books_urls if not (u in seen or seen.add(u))][:2]
|
||||
log.append(f"候选 books: {books_urls}")
|
||||
|
||||
# 3. 搜 brandavenue
|
||||
log.append("\n=== 搜索楽天ブランドアベニュー ===")
|
||||
await page.goto(
|
||||
"https://search.rakuten.co.jp/search/?sid=brandavenue&max=2000&min=100&s=1&p=1",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30_000,
|
||||
)
|
||||
await page.wait_for_timeout(2000)
|
||||
bv_urls = re.findall(
|
||||
r"https://brandavenue\.rakuten\.co\.jp/[\w-]+/[\w-]+/?",
|
||||
await page.content(),
|
||||
)
|
||||
bv_urls = [u for u in bv_urls if not (u in seen or seen.add(u))][:2]
|
||||
log.append(f"候选 brandavenue: {bv_urls}")
|
||||
|
||||
# 4. 实际加购测试
|
||||
log.append("\n========== 加购矩阵测试 ==========")
|
||||
matrix = [
|
||||
("NORMAL", normal_urls),
|
||||
("BOOKS", books_urls),
|
||||
("BRANDAVENUE", bv_urls),
|
||||
]
|
||||
results: dict[str, list[tuple[str, bool]]] = {}
|
||||
for kind, urls in matrix:
|
||||
results[kind] = []
|
||||
for url in urls:
|
||||
ok = await probe_one(page, context, url, log)
|
||||
results[kind].append((url, ok))
|
||||
|
||||
# 5. 收尾
|
||||
log.append("\n========== 汇总 ==========")
|
||||
for kind, lst in results.items():
|
||||
ok_count = sum(1 for _, ok in lst if ok)
|
||||
log.append(f"{kind}: {ok_count}/{len(lst)} 成功")
|
||||
for url, ok in lst:
|
||||
log.append(f" {'OK' if ok else 'FAIL'} {url}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
save("probe-purchase-block.txt", "\n".join(log))
|
||||
print(f"输出: {PROBE_DIR / 'probe-purchase-block.txt'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -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()))
|
||||
@@ -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