主站 ichiba 的「加购→确认页→提交」已经用真账号跑通,但 books / brandavenue / biccamera 三个官方子站只有加购契约的静态记录,checkout 这段的选择器与流程从没在 子站上真实走过。本脚本把这段空白补上:每个子站自动挑一件便宜的在售商品,用真账号 走到**下单确认页为止**,逐步落 .probe/subsite/<site>/ 快照与 report-<site>.json。 两道闸保证不产生订单:_FORBIDDEN_BUTTON_TEXTS 黑名单(候选按钮文案命中就停手), 以及一旦出现确认页特征(「注文を確定する」等)立即停并落证据。会产生的真实副作用 已写在脚本文档里:往真实账号购物车加商品(结束时尝试清空,清不掉的在报告里点名)、 可能触发 session upgrade(走与生产同一条自动复核路径)、站点留下未提交的订单草稿。 探针脚本,不进生产链路,也不被任何测试导入。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
850 lines
40 KiB
Python
850 lines
40 KiB
Python
"""官方子站(books / brandavenue / biccamera)加购→下单链路探针
|
|
|
|
背景:主站 ichiba 的「加购→确认页→提交」已经用真账号跑通(见
|
|
project://jp-rakuten/trading-split),但 [[cart_contract]] 记录的三个官方子站
|
|
(楽天ブックス / Rakuten Fashion / ビックカメラ楽天市場店)只有**加购契约**的
|
|
静态记录,checkout 这一段的选择器与流程从没在子站上真实走过。本探针把这段空白
|
|
补上:每个子站自动挑一件便宜的在售商品,用真账号走到**下单确认页为止**。
|
|
|
|
**绝不提交订单**:全流程有两道闸——
|
|
1. `_FORBIDDEN_BUTTON_TEXTS` 黑名单,任何候选按钮文案命中就不点、直接停;
|
|
2. 一旦页面出现确认页特征(「注文を確定する」等),立即停手并落证据。
|
|
所以本脚本不会产生订单,也不会扣款。它**会**产生的真实副作用:
|
|
- 往真实账号的购物车里加商品(结束时尝试清空,清不掉的会在报告里点名);
|
|
- 可能触发 session upgrade(用 account.yaml 里的密码自动复核,与生产同一条路径);
|
|
- 在站点上留下订单草稿(未提交,站点侧一般随会话失效,不影响账号)。
|
|
|
|
用法:
|
|
.venv/Scripts/python.exe scripts/probe_subsite_checkout.py # 三个子站依次跑
|
|
.venv/Scripts/python.exe scripts/probe_subsite_checkout.py --site books
|
|
.venv/Scripts/python.exe scripts/probe_subsite_checkout.py --site biccamera \
|
|
--item-url https://item.rakuten.co.jp/biccamera/4904530012150/
|
|
|
|
输出:.probe/subsite/<site>/NN-*.html|png 逐步快照 + report-<site>.json 结构化结论。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import traceback
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from app.shared.config import get_settings # noqa: E402
|
|
from app.trading.services.auth_session import AuthSession # noqa: E402
|
|
from app.trading.worker.site_interact import ( # noqa: E402
|
|
SiteInteractor,
|
|
_parse_checkout_summary,
|
|
)
|
|
|
|
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "subsite"
|
|
|
|
|
|
@dataclass
|
|
class SubsiteSpec:
|
|
"""一个官方子站的探测配置"""
|
|
|
|
name: str
|
|
host: str # 商品页最终落地的域名,用来确认确实跳出了 item.rakuten.co.jp
|
|
sid: str # 市场侧 shop_id,用于用搜索页自动挑商品
|
|
max_price: int # 自动挑商品的价格上限(挑不到就抬价重试)
|
|
# item.rakuten.co.jp 的 302 有时只把人送到子站首页而丢掉商品(brandavenue 实测),
|
|
# 这时用商品编号直接拼子站 URL 兜底。upper_code=True 表示子站 URL 用大写编号。
|
|
direct_url: str = ""
|
|
upper_code: bool = False
|
|
|
|
|
|
SUBSITES: dict[str, SubsiteSpec] = {
|
|
"books": SubsiteSpec(
|
|
name="books",
|
|
host="books.rakuten.co.jp",
|
|
sid="213310",
|
|
max_price=1200,
|
|
direct_url="https://books.rakuten.co.jp/rb/{code}/",
|
|
),
|
|
"brandavenue": SubsiteSpec(
|
|
name="brandavenue",
|
|
host="brandavenue.rakuten.co.jp",
|
|
sid="279405",
|
|
max_price=6000,
|
|
direct_url="https://brandavenue.rakuten.co.jp/item/{code}/",
|
|
upper_code=True,
|
|
),
|
|
"biccamera": SubsiteSpec(
|
|
name="biccamera",
|
|
host="biccamera.rakuten.co.jp",
|
|
sid="269553",
|
|
max_price=1200,
|
|
direct_url="https://biccamera.rakuten.co.jp/item/{code}/",
|
|
),
|
|
}
|
|
|
|
_SEARCH_URL = "https://search.rakuten.co.jp/search/mall/-/?sid={sid}&max={max_price}&min=100"
|
|
|
|
# ---- 加购按钮候选(子站各自的文案未知,列一串按顺序试,命中哪条记在报告里)----
|
|
_ADD_CART_TEXTS = (
|
|
"カートに入れる",
|
|
"カートに追加",
|
|
"かごに追加",
|
|
"買い物かごに入れる",
|
|
"カートへ入れる",
|
|
"レジに進む",
|
|
)
|
|
|
|
# ---- 「继续下一步」候选按钮文案:从加购落地页一路点到确认页 ----
|
|
_NEXT_STEP_TEXTS = (
|
|
"購入手続き",
|
|
"ご購入手続き",
|
|
"購入手続きへ",
|
|
"レジに進む",
|
|
"注文手続きへ",
|
|
"ご注文手続き",
|
|
"次へ",
|
|
"進む",
|
|
)
|
|
|
|
# ---- 绝对不点的按钮:命中即停手。宁可少走一步,也不能提交订单 ----
|
|
_FORBIDDEN_BUTTON_TEXTS = (
|
|
"注文を確定",
|
|
"ご注文を確定",
|
|
"この内容で注文",
|
|
"購入を確定",
|
|
"注文する",
|
|
"決済する",
|
|
"支払う",
|
|
"購入する",
|
|
)
|
|
|
|
# ---- 已经走到下单确认页的判据 ----
|
|
# **不能按 HTML 全文匹配文案**:楽天ブックス 的购物车页正文里就写着「注文画面の
|
|
# 「注文を確定する」ボタンを押した時点で…」这样的注意事项,全文匹配会把购物车页
|
|
# 误判成确认页(2026-08-14 books 探测实测踩到)。改成「页面上真实存在一个可见的
|
|
# 确认按钮」或 URL 命中确认页路径两个判据。
|
|
_CONFIRM_PAGE_URL_MARKERS = ("order-confirmation", "/step/confirm", "ConfirmOrder")
|
|
|
|
# 确认页判定专用的按钮文案:比 _FORBIDDEN_BUTTON_TEXTS 窄。黑名单里的「購入する」
|
|
# 会命中商品页上的「Rakuten Fashionアプリで購入する」(装 App 引导),拿它判定
|
|
# 确认页会把商品页误判成确认页(2026-08-14 brandavenue 实测)。判定要窄、
|
|
# 点击闸门要宽,两者不能共用一份列表。
|
|
_CONFIRM_BUTTON_TEXTS = ("注文を確定", "ご注文を確定", "この内容で注文", "購入を確定")
|
|
|
|
# session upgrade / 中间步骤判据:与生产 site_interact 保持一致的口径
|
|
_SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password")
|
|
|
|
# 2026-08-14 biccamera 探测新发现的一步:密码复核通过后,SSO 会再要求同意
|
|
# **该子站自己的**利用規約/プライバシーポリシー(URL 还是 session/upgrade,
|
|
# 只是 hash 变成 #/agree/service)。这一步是账号级的协议同意,不是下单动作,
|
|
# 默认**不自动点**,要显式加 --accept-subsite-terms 才代用户同意。
|
|
_AGREE_STEP_URL_MARKER = "#/agree/"
|
|
_AGREE_STEP_TEXT_MARKER = "ご利用いただくサービスに関するご確認"
|
|
_PHONE_REGISTRATION_MARKER = "会員情報の追加登録"
|
|
# 同一个「会員情報の追加登録」标题下还有另一种变体(2026-08-14 biccamera 实测,
|
|
# hash #/profiling/1):必填项是**誕生日 + 性別**,页面明写「登録した情報は、
|
|
# 変更できません」。这类不可撤销的个人信息不能由脚本代填,探针识别到就停手。
|
|
_PROFILE_REGISTRATION_MARKERS = ("誕生日", "性別")
|
|
_PROFILE_STEP_URL_MARKER = "#/profiling"
|
|
_ADDRESS_STEP_URL_MARKER = "/ship"
|
|
_PAYMENT_STEP_URL_MARKER = "/pay"
|
|
|
|
_MAX_HOPS = 8
|
|
# 一个子站最多试几个候选商品(多规格商品可能整件都缺货,见 discover_candidates)
|
|
_MAX_CANDIDATES = 4
|
|
|
|
|
|
@dataclass
|
|
class SiteReport:
|
|
"""单个子站的探测结论(最终写成 report-<site>.json)"""
|
|
|
|
site: str
|
|
item_url: str = ""
|
|
item_price: int | None = None
|
|
item_name: str = ""
|
|
landing_host: str = ""
|
|
add_cart_contract: dict[str, Any] = field(default_factory=dict)
|
|
add_cart_button: str = ""
|
|
add_cart_landing_url: str = ""
|
|
add_cart_ok: bool = False
|
|
cart_signals: dict[str, Any] = field(default_factory=dict)
|
|
ichiba_cart_count: int | None = None
|
|
hops: list[dict[str, str]] = field(default_factory=list)
|
|
reached_confirm: bool = False
|
|
confirm_buttons: list[str] = field(default_factory=list)
|
|
payable_yen: int | None = None
|
|
stopped_reason: str = ""
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
class Probe:
|
|
"""把一个子站从挑商品走到确认页;每一步都落快照,任何异常只记录不中断整轮"""
|
|
|
|
def __init__(self, site: SiteInteractor, spec: SubsiteSpec, *, accept_terms: bool):
|
|
self._site = site
|
|
self._spec = spec
|
|
self._accept_terms = accept_terms
|
|
self._dir = PROBE_DIR / spec.name
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
self._step = 0
|
|
self.report = SiteReport(site=spec.name)
|
|
|
|
# ---- 通用工具 ----
|
|
|
|
def log(self, message: str) -> None:
|
|
print(f"[{self._spec.name}] {message}", flush=True)
|
|
|
|
async def snapshot(self, page, label: str) -> str:
|
|
"""落一份 HTML + 截图,返回 HTML;截图失败不影响主流程"""
|
|
self._step += 1
|
|
stem = f"{self._step:02d}-{label}"
|
|
html = await page.content()
|
|
(self._dir / f"{stem}.html").write_text(html, encoding="utf-8")
|
|
try:
|
|
await page.screenshot(path=str(self._dir / f"{stem}.png"), full_page=False)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log(f"截图失败(忽略):{type(exc).__name__}: {exc}")
|
|
self.log(f"快照 {stem} url={page.url}")
|
|
return html
|
|
|
|
async def _visible_texts(self, page) -> list[str]:
|
|
"""页面上所有可点控件的文案,供报告里留证据(子站文案基本靠这个发现)"""
|
|
return await page.evaluate(
|
|
"""() => Array.from(
|
|
document.querySelectorAll('button, a[role="button"], input[type="submit"], div[role="button"]')
|
|
).map(el => (el.innerText || el.value || '').trim().replace(/\\s+/g, ' '))
|
|
.filter(t => t && t.length <= 30).slice(0, 60)"""
|
|
)
|
|
|
|
# ---- 步骤 1:挑一件便宜的在售商品 ----
|
|
|
|
async def discover_candidates(self, page) -> list[dict[str, Any]]:
|
|
"""用市场搜索页按 shop_id 过滤,按价格升序给出候选商品
|
|
|
|
走浏览器而不是 httpx:search.rakuten.co.jp 对 curl/httpx 直接 503,
|
|
浏览器带完整指纹能正常拿到 __INITIAL_STATE__。
|
|
返回列表而不是单个:搜索页的 isSoldOut 是**商品级**的,多规格商品可能
|
|
「商品在售但每个颜色尺码都缺货」(brandavenue 实测第一个候选就是这样),
|
|
调用方要能换下一个候选试。
|
|
"""
|
|
url = _SEARCH_URL.format(sid=self._spec.sid, max_price=self._spec.max_price)
|
|
await self._goto_with_retry(page, url)
|
|
items = await page.evaluate(
|
|
"""() => {
|
|
const s = window.__INITIAL_STATE__;
|
|
const list = s && s.state && s.state.data && s.state.data.ichibaSearch
|
|
? s.state.data.ichibaSearch.items : null;
|
|
if (!list) return null;
|
|
return list.filter(i => !i.isSoldOut)
|
|
.map(i => ({price: i.price, url: i.url, name: i.name}));
|
|
}"""
|
|
)
|
|
if not items:
|
|
raise RuntimeError(f"搜索页没拿到商品列表(sid={self._spec.sid})")
|
|
items.sort(key=lambda i: i["price"])
|
|
self.log(f"候选 {len(items)} 件,最低 {items[0]['price']}円")
|
|
return items
|
|
|
|
def record_item(self, candidate: dict[str, Any]) -> None:
|
|
self.report.item_url = candidate["url"]
|
|
self.report.item_price = candidate["price"]
|
|
self.report.item_name = candidate["name"]
|
|
self.log(f"试 {candidate['price']}円 {candidate['url']}")
|
|
|
|
# ---- 步骤 2:打开商品页,抽这个子站的加购契约 ----
|
|
|
|
async def open_item(self, page, item_url: str) -> None:
|
|
await self._goto_with_retry(page, item_url)
|
|
await page.wait_for_timeout(3_000)
|
|
# 302 落到子站首页(路径只剩 /)时,用商品编号直接拼子站商品页兜底
|
|
if page.url.rstrip("/").endswith(self._spec.host) and self._spec.direct_url:
|
|
code = [part for part in item_url.split("?")[0].split("/") if part][-1]
|
|
direct = self._spec.direct_url.format(
|
|
code=code.upper() if self._spec.upper_code else code
|
|
)
|
|
self.log(f"302 只落到子站首页,改用直连商品页:{direct}")
|
|
self.report.errors.append(
|
|
f"item.rakuten.co.jp 的 302 丢掉了商品(落到 {page.url}),改用 {direct}"
|
|
)
|
|
await self._goto_with_retry(page, direct)
|
|
await page.wait_for_timeout(3_000)
|
|
self.report.landing_host = page.url.split("/")[2] if "//" in page.url else ""
|
|
await self.snapshot(page, "item-page")
|
|
if self._spec.host not in page.url:
|
|
self.report.errors.append(
|
|
f"商品页没有落到 {self._spec.host},实际 url={page.url}"
|
|
)
|
|
self.report.add_cart_contract = await self._extract_contract(page)
|
|
self.log(f"加购契约:{json.dumps(self.report.add_cart_contract, ensure_ascii=False)[:400]}")
|
|
|
|
async def _goto_with_retry(self, page, url: str, attempts: int = 3) -> None:
|
|
"""item.rakuten.co.jp → 子站的 302 跳转偶发 ERR_HTTP2_PROTOCOL_ERROR
|
|
|
|
2026-08-14 brandavenue 探测实测:同一个 URL 第一次 goto 直接 HTTP2 协议
|
|
报错,重试就过。这是站点/网络侧的偶发问题,不是选择器或登录态问题,所以
|
|
重试而不是当失败——但重试次数用尽仍失败就如实抛出,不静默跳过这个子站。
|
|
"""
|
|
last: Exception | None = None
|
|
for attempt in range(attempts):
|
|
try:
|
|
await page.goto(url, wait_until="domcontentloaded", timeout=45_000)
|
|
return
|
|
except Exception as exc: # noqa: BLE001
|
|
last = exc
|
|
self.log(f"goto 第 {attempt + 1} 次失败:{type(exc).__name__},2s 后重试")
|
|
await page.wait_for_timeout(2_000)
|
|
raise RuntimeError(f"打开 {url} 连续 {attempts} 次失败:{last}")
|
|
|
|
async def _extract_contract(self, page) -> dict[str, Any]:
|
|
"""按子站各自的数据源抽加购要素,核对 [[cart_contract]] 的记录是否还成立"""
|
|
if self._spec.name == "biccamera":
|
|
return await page.evaluate(
|
|
"""() => {
|
|
const it = window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.item;
|
|
if (!it) return {error: 'no __NUXT__.state.item'};
|
|
return {
|
|
source: '__NUXT__.state.item',
|
|
item_id: it.item_id, item_number: it.item_number, shop_id: it.shop_id,
|
|
add_cart_api_url: it.add_cart_api_url,
|
|
buying_procedure_url: it.buying_procedure_url,
|
|
cart_addable: it.cart_addable, price_with_tax: it.price_with_tax,
|
|
};
|
|
}"""
|
|
)
|
|
if self._spec.name == "books":
|
|
return await page.evaluate(
|
|
"""() => {
|
|
const forms = Array.from(document.querySelectorAll('form'))
|
|
.filter(f => (f.action || '').includes('Cart'));
|
|
if (!forms.length) return {error: 'no cart form'};
|
|
const f = forms[0];
|
|
const fields = {};
|
|
f.querySelectorAll('input, select').forEach(i => {
|
|
if (i.name) fields[i.name] = i.value;
|
|
});
|
|
return {source: 'form[action*=Cart]', action: f.action, method: f.method, fields};
|
|
}"""
|
|
)
|
|
return await page.evaluate(
|
|
"""() => {
|
|
const s = window.__INITIAL_STATE__;
|
|
const p = s && s.itemDetail && s.itemDetail.data && s.itemDetail.data.product;
|
|
if (!p) return {error: 'no itemDetail.data.product'};
|
|
const rms = p.rms_info || {};
|
|
return {
|
|
source: '__INITIAL_STATE__.itemDetail.data.product',
|
|
rms_item_id: rms.rms_item_id, shop_id: (s.env || {}).shop_id,
|
|
cart_url_type: rms.cart_url_type ?? p.cart_url_type ?? null,
|
|
sku_count: (p.product_sku || []).length,
|
|
inventory_count: (rms.inventory_list || []).length,
|
|
first_variant: (rms.inventory_list || [])[0] || null,
|
|
};
|
|
}"""
|
|
)
|
|
|
|
# ---- 步骤 3:点站点自己的加购按钮(不重放表单,看真实交互长什么样)----
|
|
|
|
async def add_to_cart(self, page) -> None:
|
|
if self._spec.name == "brandavenue":
|
|
await self._add_to_cart_brandavenue(page)
|
|
await self._assert_in_cart(page, "かごに追加(SKU モーダル)")
|
|
self.report.add_cart_ok = True
|
|
return
|
|
await self._select_required_options(page)
|
|
button = await self._find_clickable(page, _ADD_CART_TEXTS)
|
|
if button is None:
|
|
texts = await self._visible_texts(page)
|
|
self.report.errors.append(f"商品页没找到加购按钮,页面控件文案:{texts}")
|
|
await self.snapshot(page, "add-cart-notfound")
|
|
raise RuntimeError("找不到加购按钮")
|
|
|
|
locator, text = button
|
|
self.report.add_cart_button = text
|
|
self.log(f"点击加购按钮「{text}」")
|
|
await locator.click(timeout=15_000)
|
|
await page.wait_for_timeout(4_000)
|
|
self.report.add_cart_landing_url = page.url
|
|
await self.snapshot(page, "after-add-cart")
|
|
|
|
await self._assert_in_cart(page, text)
|
|
self.report.add_cart_ok = True
|
|
|
|
async def _assert_in_cart(self, page, button_text: str) -> None:
|
|
"""点了不等于进车:两个独立信号,任一成立就算进车
|
|
|
|
1. 落地 URL 带 `added_item=`——市场侧加购成功的标准落地(brandavenue 走这条)
|
|
2. cart count JSONP API 的 count > 0(books / biccamera 走这条)
|
|
两个信号都要记进报告:**count API 并不总可靠**——brandavenue 明明已经
|
|
进车并跳到 sp.cart.step 的 cart 页,count API 仍返回 status=101
|
|
「value not found」(2026-08-14 实测,末尾 clear_cart 真删掉了 4 件,
|
|
证明东西确实在车里)。只信 count API 会把成功判成失败。
|
|
"""
|
|
added_signal = "added_item=" in page.url
|
|
count: int | None = None
|
|
try:
|
|
_, count = await self._site._query_cart_count() # noqa: SLF001
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log(f"cart count 查询失败:{type(exc).__name__}: {exc}")
|
|
self.report.cart_signals = {
|
|
"landing_url_has_added_item": added_signal,
|
|
"cart_count_api": count,
|
|
}
|
|
if added_signal or (count or 0) > 0:
|
|
return
|
|
texts = await self._visible_texts(page)
|
|
self.report.errors.append(
|
|
f"点了「{button_text}」但两个进车信号都不成立,可能还有规格/数量选择层。"
|
|
f"当时控件文案:{texts}"
|
|
)
|
|
raise RuntimeError("加购未生效(URL 无 added_item 且 cart count 为 0)")
|
|
|
|
async def _add_to_cart_brandavenue(self, page) -> None:
|
|
"""Rakuten Fashion 的加购是三步,不是一步
|
|
|
|
实测(2026-08-14):页面上的「かごに追加」只负责**弹出**「カラー・サイズを選ぶ」
|
|
模态层;真正入车的按钮在模态层里(同样叫「かごに追加」,但带
|
|
data-tracking="sku-modal-size-cart")。中间必须先选颜色再选尺码。
|
|
另外搜索页的 isSoldOut 只表示「整件商品还挂着」,颜色/尺码级缺货要看
|
|
product_sku[].inventory_exist_flg——最便宜的候选商品全尺码「なし」,
|
|
点加购只会弹「再入荷リクエスト」。
|
|
"""
|
|
variants = await page.evaluate(
|
|
"""() => {
|
|
const s = window.__INITIAL_STATE__;
|
|
const p = s && s.itemDetail && s.itemDetail.data && s.itemDetail.data.product;
|
|
if (!p) return [];
|
|
return (p.product_sku || [])
|
|
.filter(e => String(e.inventory_exist_flg) === '1')
|
|
.map(e => ({color: e.product_color_name, size: e.product_size_name}));
|
|
}"""
|
|
)
|
|
if not variants:
|
|
raise RuntimeError("该商品所有颜色/尺码都缺货(product_sku 无 inventory_exist_flg=1)")
|
|
target = variants[0]
|
|
self.log(f"选中规格:颜色={target['color']} 尺码={target['size']}(共 {len(variants)} 个有货组合)")
|
|
self.report.add_cart_contract["picked_variant"] = target
|
|
|
|
opener = await self._find_clickable(page, ("かごに追加",))
|
|
if opener is None:
|
|
raise RuntimeError("商品页没找到「かごに追加」(打开规格模态层的按钮)")
|
|
self.report.add_cart_button = "かごに追加 → SKU モーダル → かごに追加"
|
|
await opener[0].click(timeout=15_000)
|
|
await page.wait_for_timeout(2_500)
|
|
await self.snapshot(page, "sku-modal")
|
|
|
|
for label, value in (("颜色", target["color"]), ("尺码", target["size"])):
|
|
if not value:
|
|
continue
|
|
if await self._click_variant_chip(page, value):
|
|
self.log(f"{label}「{value}」已点选")
|
|
await page.wait_for_timeout(1_500)
|
|
else:
|
|
self.report.errors.append(f"{label}「{value}」在模态层里没找到可点的选项")
|
|
await self.snapshot(page, "variant-selected")
|
|
|
|
# 点尺码这一下**本身就入车**并整页跳到市场侧 cart(实测落地
|
|
# sp.cart.step.rakuten.co.jp/cart?shop_bid=279405&added_item=<rms_item_id>),
|
|
# 模态层里那个带 data-tracking="sku-modal-size-cart" 的「かごに追加」根本
|
|
# 等不到——页面已经走了。所以只在没跳走时才去点它兜底。
|
|
if "added_item=" not in page.url:
|
|
modal_add = page.locator('button:has([data-tracking="sku-modal-size-cart"])').first
|
|
try:
|
|
await modal_add.click(timeout=10_000)
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
f"选完尺码没自动入车,模态层里的「かごに追加」也点不到:"
|
|
f"{type(exc).__name__}: {exc}"
|
|
) from exc
|
|
await page.wait_for_timeout(4_000)
|
|
self.report.add_cart_landing_url = page.url
|
|
await self.snapshot(page, "after-add-cart")
|
|
|
|
async def _click_variant_chip(self, page, value: str) -> bool:
|
|
"""点一个规格选项:颜色是带 img[alt] 的按钮,尺码是文案就等于尺码的按钮"""
|
|
for selector in (
|
|
f'button:has(img[alt="{value}"])',
|
|
f'button[aria-label="{value}"]',
|
|
f'li:has-text("{value}") button',
|
|
):
|
|
candidates = page.locator(selector)
|
|
try:
|
|
total = await candidates.count()
|
|
except Exception:
|
|
continue
|
|
for index in range(min(total, 20)):
|
|
locator = candidates.nth(index)
|
|
try:
|
|
if not await locator.is_visible():
|
|
continue
|
|
await locator.click(timeout=5_000)
|
|
return True
|
|
except Exception:
|
|
continue
|
|
# 尺码这类纯文本 chip:按「可见控件文案完全等于目标值」精确点,避免撞到别的数字
|
|
clicked = await page.evaluate(
|
|
"""(value) => {
|
|
const els = Array.from(document.querySelectorAll('button, div[role="button"], label, li'));
|
|
for (const el of els) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width <= 0 || r.height <= 0) continue;
|
|
if ((el.innerText || '').trim() === value) { el.click(); return true; }
|
|
}
|
|
return false;
|
|
}""",
|
|
value,
|
|
)
|
|
return bool(clicked)
|
|
|
|
async def _select_required_options(self, page) -> None:
|
|
"""加购前尽力选一个可售规格(Rakuten Fashion 的尺码/颜色是必选)
|
|
|
|
只做「有明显的下拉框就选第一个非空项」这一档:真实规格控件长什么样正是
|
|
本探针要探的东西,猜太多反而会掩盖真实结构,选不出来就继续往下走,
|
|
让站点自己报错、把错误文案记进报告。
|
|
"""
|
|
selects = page.locator("select")
|
|
for index in range(min(await selects.count(), 3)):
|
|
select = selects.nth(index)
|
|
try:
|
|
values = await select.evaluate(
|
|
"el => Array.from(el.options).filter(o => o.value && !o.disabled).map(o => o.value)"
|
|
)
|
|
if values:
|
|
await select.select_option(values[0])
|
|
self.log(f"下拉框 #{index} 选中 {values[0]}")
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log(f"下拉框 #{index} 选择失败(忽略):{type(exc).__name__}")
|
|
|
|
# ---- 步骤 4:购物车归属确认 ----
|
|
|
|
async def check_cart(self, page) -> None:
|
|
"""加购后查市场侧 cart count:子站是否共用 ichiba 购物车,这一步能直接看出来"""
|
|
try:
|
|
_, count = await self._site._query_cart_count() # noqa: SLF001
|
|
self.report.ichiba_cart_count = count
|
|
self.log(f"ichiba cart count = {count}")
|
|
except Exception as exc: # noqa: BLE001
|
|
self.report.errors.append(f"cart count 查询失败:{type(exc).__name__}: {exc}")
|
|
|
|
# ---- 步骤 5:一路点到确认页为止 ----
|
|
|
|
async def drive_checkout(self, page) -> None:
|
|
for hop in range(_MAX_HOPS):
|
|
await page.wait_for_timeout(2_000)
|
|
url = page.url
|
|
html = await self.snapshot(page, f"hop{hop}")
|
|
hop_record = {"hop": str(hop), "url": url}
|
|
|
|
confirm_buttons = await self._confirm_buttons(page)
|
|
if confirm_buttons or any(m in url for m in _CONFIRM_PAGE_URL_MARKERS):
|
|
hop_record["action"] = f"确认页命中(确认按钮={confirm_buttons}),停手(不提交)"
|
|
self.report.hops.append(hop_record)
|
|
self.report.reached_confirm = True
|
|
self.report.confirm_buttons = confirm_buttons
|
|
self.report.stopped_reason = "已到下单确认页,按要求不提交"
|
|
await self._parse_amount(html)
|
|
return
|
|
|
|
# 同样必须排在 session upgrade 判据前面(URL 仍带 session/upgrade)。
|
|
# 生年月日/性別 这种「注册后不可变更」的个人信息,脚本一律不代填。
|
|
if _PROFILE_STEP_URL_MARKER in url or (
|
|
_PHONE_REGISTRATION_MARKER in html
|
|
and all(m in html for m in _PROFILE_REGISTRATION_MARKERS)
|
|
):
|
|
hop_record["action"] = "会員情報の追加登録(誕生日+性別)页,脚本不代填,停手"
|
|
self.report.hops.append(hop_record)
|
|
self.report.stopped_reason = (
|
|
"卡在「会員情報の追加登録」的誕生日+性別 变体(hash #/profiling/1):"
|
|
"两项都是必填且页面明写「登録した情報は、変更できません」,"
|
|
"属于不可撤销的个人信息,脚本不代填,必须人工填一次"
|
|
)
|
|
return
|
|
|
|
# 必须排在 session upgrade 判据前面:同意页的 URL 里仍然带
|
|
# session/upgrade,只有 hash 不同,顺序反了会拿密码框选择器去撞同意页
|
|
if _AGREE_STEP_URL_MARKER in url or _AGREE_STEP_TEXT_MARKER in html:
|
|
if not self._accept_terms:
|
|
hop_record["action"] = "子站利用規約同意页,未授权自动同意,停手"
|
|
self.report.hops.append(hop_record)
|
|
self.report.stopped_reason = (
|
|
"卡在子站利用規約/プライバシーポリシー同意页("
|
|
"#/agree/service):这是账号级协议同意,需显式授权,"
|
|
"加 --accept-subsite-terms 才会自动点「次へ」"
|
|
)
|
|
return
|
|
hop_record["action"] = "子站利用規約同意页:点「次へ」(已显式授权)"
|
|
self.report.hops.append(hop_record)
|
|
agree = await self._find_clickable(page, ("次へ",))
|
|
if agree is None:
|
|
self.report.stopped_reason = "同意页没找到「次へ」"
|
|
return
|
|
await agree[0].click(timeout=15_000)
|
|
continue
|
|
|
|
if any(marker in url for marker in _SESSION_UPGRADE_URL_MARKERS):
|
|
hop_record["action"] = "session upgrade:调生产 _complete_session_upgrade"
|
|
self.report.hops.append(hop_record)
|
|
await self._site._complete_session_upgrade( # noqa: SLF001
|
|
page, task_id=f"probe-{self._spec.name}"
|
|
)
|
|
continue
|
|
|
|
if _PHONE_REGISTRATION_MARKER in html:
|
|
hop_record["action"] = "电话补录:调生产 _complete_phone_registration"
|
|
self.report.hops.append(hop_record)
|
|
await self._site._complete_phone_registration( # noqa: SLF001
|
|
page, task_id=f"probe-{self._spec.name}"
|
|
)
|
|
continue
|
|
|
|
if _ADDRESS_STEP_URL_MARKER in url:
|
|
hop_record["action"] = "地址确认:调生产 _confirm_default_address"
|
|
self.report.hops.append(hop_record)
|
|
await self._site._confirm_default_address( # noqa: SLF001
|
|
page, task_id=f"probe-{self._spec.name}"
|
|
)
|
|
continue
|
|
|
|
if _PAYMENT_STEP_URL_MARKER in url:
|
|
hop_record["action"] = "支付方式:调生产 _select_payment_method"
|
|
self.report.hops.append(hop_record)
|
|
await self._site._select_payment_method( # noqa: SLF001
|
|
page, task_id=f"probe-{self._spec.name}"
|
|
)
|
|
continue
|
|
|
|
found = await self._find_clickable(page, _NEXT_STEP_TEXTS)
|
|
if found is None:
|
|
texts = await self._visible_texts(page)
|
|
hop_record["action"] = f"没有可继续的按钮,停。页面控件文案:{texts}"
|
|
self.report.hops.append(hop_record)
|
|
self.report.stopped_reason = "找不到下一步按钮"
|
|
return
|
|
|
|
locator, text = found
|
|
hop_record["action"] = f"点击「{text}」"
|
|
self.report.hops.append(hop_record)
|
|
self.log(f"hop{hop} 点击「{text}」")
|
|
await locator.click(timeout=15_000)
|
|
|
|
self.report.stopped_reason = f"跳转超过 {_MAX_HOPS} 跳仍未到确认页"
|
|
|
|
async def _parse_amount(self, html: str) -> None:
|
|
"""确认页金额解析:直接用生产的 _parse_checkout_summary,验证它在子站是否也成立"""
|
|
try:
|
|
summary = _parse_checkout_summary(html)
|
|
self.report.payable_yen = summary.payable_yen
|
|
self.log(f"确认页金额解析成功:{summary.payable_yen} 円")
|
|
except Exception as exc: # noqa: BLE001
|
|
self.report.errors.append(
|
|
f"确认页金额解析失败(生产 _parse_checkout_summary):{type(exc).__name__}: {exc}"
|
|
)
|
|
|
|
async def _confirm_buttons(self, page) -> list[str]:
|
|
"""页面上**可见的**下单确认按钮文案;空列表表示还没到确认页
|
|
|
|
只看真实控件(button / role=button / submit)且尺寸非零,不看正文文案——
|
|
正文里出现「注文を確定する」这五个字的说明性文字并不代表这是确认页。
|
|
"""
|
|
return await page.evaluate(
|
|
"""(texts) => Array.from(document.querySelectorAll(
|
|
'button, div[role="button"], a[role="button"], input[type="submit"]'
|
|
))
|
|
.filter(el => {
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
})
|
|
.map(el => (el.innerText || el.value || '').trim().replace(/\\s+/g, ' '))
|
|
.filter(t => t && texts.some(x => t.includes(x)))""",
|
|
list(_CONFIRM_BUTTON_TEXTS),
|
|
)
|
|
|
|
# ---- 按钮定位:文案匹配 + 黑名单闸门 ----
|
|
|
|
async def _find_clickable(self, page, texts: tuple[str, ...]):
|
|
"""按候选文案找第一个可见可点的控件;命中黑名单立即抛错,绝不返回
|
|
|
|
`button:has-text()` 是子串匹配,「購入手続き」会同时命中促销按钮
|
|
「カード入会&購入手続き」——主站踩过这个坑(见 site_interact
|
|
_CHECKOUT_BUTTON_SELECTOR 注释),所以这里按 aria-label 精确匹配优先,
|
|
再退到文案子串匹配,并且每个候选都要过一遍黑名单。
|
|
"""
|
|
for text in texts:
|
|
for selector in (
|
|
f'button[aria-label="{text}"]',
|
|
f'button:has-text("{text}")',
|
|
f'a:has-text("{text}")',
|
|
f'div[role="button"]:has-text("{text}")',
|
|
f'input[type="submit"][value*="{text}"]',
|
|
):
|
|
candidates = page.locator(selector)
|
|
# 子站页面同一个按钮常有 PC/SP 两套 DOM,只有一套可见;.first 会选中
|
|
# 隐藏的那个然后死等 visible 超时(biccamera 实测踩过),必须逐个筛可见的
|
|
try:
|
|
total = await candidates.count()
|
|
except Exception:
|
|
continue
|
|
for index in range(min(total, 10)):
|
|
locator = candidates.nth(index)
|
|
try:
|
|
if not await locator.is_visible():
|
|
continue
|
|
except Exception:
|
|
continue
|
|
actual = text
|
|
if "input" not in selector:
|
|
try:
|
|
actual = ((await locator.inner_text()) or "").strip() or text
|
|
except Exception:
|
|
actual = text
|
|
if any(bad in actual for bad in _FORBIDDEN_BUTTON_TEXTS):
|
|
raise RuntimeError(
|
|
f"候选按钮「{actual}」命中提交订单黑名单,停手(selector={selector})"
|
|
)
|
|
return locator, actual
|
|
return None
|
|
|
|
|
|
async def _start_visible(site: SiteInteractor, settings) -> None:
|
|
"""headless=False 启动(与 probe_payment_method.py 同一写法)
|
|
|
|
无头模式下结算 SPA 表现异常,见 site_interact.start() 的注释。
|
|
"""
|
|
from playwright.async_api import async_playwright
|
|
|
|
from app.trading.core import auth_site
|
|
|
|
state_path = settings.auth_state_path / auth_site.profile("rakuten").state_filename
|
|
storage_state = str(state_path) if state_path.exists() else None
|
|
|
|
site._playwright = await async_playwright().start() # noqa: SLF001
|
|
site._browser = await site._playwright.chromium.launch( # noqa: SLF001
|
|
headless=False,
|
|
channel=settings.browser_channel or None,
|
|
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
|
)
|
|
site._context = await site._browser.new_context( # noqa: SLF001
|
|
storage_state=storage_state,
|
|
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,
|
|
)
|
|
print(f"[visible] 浏览器已启动,storage_state={storage_state or '(none)'}", flush=True)
|
|
|
|
|
|
async def probe_one(
|
|
site: SiteInteractor, spec: SubsiteSpec, item_url: str | None, *, accept_terms: bool
|
|
) -> SiteReport:
|
|
probe = Probe(site, spec, accept_terms=accept_terms)
|
|
page = await site._context.new_page() # noqa: SLF001
|
|
try:
|
|
if item_url:
|
|
candidates = [{"url": item_url, "price": None, "name": "(指定)"}]
|
|
else:
|
|
candidates = (await probe.discover_candidates(page))[:_MAX_CANDIDATES]
|
|
for index, candidate in enumerate(candidates):
|
|
probe.record_item(candidate)
|
|
try:
|
|
await probe.open_item(page, candidate["url"])
|
|
await probe.add_to_cart(page)
|
|
break
|
|
except Exception as exc: # noqa: BLE001
|
|
probe.report.errors.append(
|
|
f"候选 {index + 1}/{len(candidates)}({candidate['url']})加购失败:"
|
|
f"{type(exc).__name__}: {exc}"
|
|
)
|
|
probe.log(f"候选 {index + 1} 加购失败,换下一个:{type(exc).__name__}: {exc}")
|
|
if not probe.report.add_cart_ok:
|
|
raise RuntimeError(f"{len(candidates)} 个候选商品都没能加购成功")
|
|
await probe.check_cart(page)
|
|
await probe.drive_checkout(page)
|
|
except Exception as exc: # noqa: BLE001
|
|
probe.report.errors.append(f"{type(exc).__name__}: {exc}")
|
|
probe.report.stopped_reason = probe.report.stopped_reason or f"异常中断:{type(exc).__name__}"
|
|
probe.log(f"异常:{type(exc).__name__}: {exc}")
|
|
traceback.print_exc()
|
|
try:
|
|
await probe.snapshot(page, "error")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
finally:
|
|
await page.close()
|
|
# 每个子站跑完都清一次购物车:下一个子站要从空车开始,否则确认页会混单
|
|
try:
|
|
result = await site.clear_cart()
|
|
probe.log(f"清空购物车:{result}")
|
|
if result.get("cart_count") not in (0, -1):
|
|
probe.report.errors.append(f"购物车未清干净:{result}")
|
|
except Exception as exc: # noqa: BLE001
|
|
probe.report.errors.append(f"清空购物车失败:{type(exc).__name__}: {exc}")
|
|
|
|
(PROBE_DIR / f"report-{spec.name}.json").write_text(
|
|
json.dumps(probe.report.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
return probe.report
|
|
|
|
|
|
async def run(sites: list[str], item_url: str | None, *, accept_terms: bool) -> int:
|
|
PROBE_DIR.mkdir(parents=True, exist_ok=True)
|
|
settings = get_settings()
|
|
auth = AuthSession(settings)
|
|
await auth.start()
|
|
site = SiteInteractor(auth_session=auth, settings=settings)
|
|
await _start_visible(site, settings)
|
|
|
|
reports: list[SiteReport] = []
|
|
try:
|
|
try:
|
|
first = await site.clear_cart()
|
|
print(f"[setup] 起始清空购物车:{first}", flush=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"[setup] 起始清空失败(继续):{type(exc).__name__}: {exc}", flush=True)
|
|
|
|
for name in sites:
|
|
print(f"\n{'=' * 20} {name} {'=' * 20}", flush=True)
|
|
reports.append(
|
|
await probe_one(site, SUBSITES[name], item_url, accept_terms=accept_terms)
|
|
)
|
|
finally:
|
|
await site.close()
|
|
await auth.close()
|
|
|
|
print("\n==== 汇总 ====", flush=True)
|
|
for report in reports:
|
|
print(
|
|
f"{report.site}: 加购={'OK' if report.add_cart_ok else 'NG'} "
|
|
f"落地域名={report.landing_host} cart_count={report.ichiba_cart_count} "
|
|
f"到确认页={'是' if report.reached_confirm else '否'} "
|
|
f"金额={report.payable_yen} 停在={report.stopped_reason}",
|
|
flush=True,
|
|
)
|
|
for err in report.errors:
|
|
print(f" ! {err}", flush=True)
|
|
print(f"\n证据目录:{PROBE_DIR}", flush=True)
|
|
return 0
|
|
|
|
|
|
async def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--site", choices=[*SUBSITES, "all"], default="all")
|
|
parser.add_argument("--item-url", default=None, help="跳过自动选品,直接用这个商品 URL")
|
|
parser.add_argument(
|
|
"--accept-subsite-terms",
|
|
action="store_true",
|
|
help="遇到子站利用規約同意页时代用户点「次へ」(账号级协议同意,默认不做)",
|
|
)
|
|
args = parser.parse_args()
|
|
sites = list(SUBSITES) if args.site == "all" else [args.site]
|
|
if args.item_url and len(sites) > 1:
|
|
parser.error("--item-url 只能配合单个 --site 使用")
|
|
return await run(sites, args.item_url, accept_terms=args.accept_subsite_terms)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(main()))
|