Files
rakuten-api/scripts/probe_subsite_checkout.py
T
q792602257andClaude Opus 5 3dc2aeb3a2 加官方子站结算链路探针:走到确认页为止,绝不提交订单
主站 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>
2026-08-14 14:28:15 +08:00

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()))