trading 自动填 choice 时取 values[0],而必填 select 的 values[0] 恒为 id=0 的
「選択してください」——等于把「请选择」当答案提交。4 份真实样本一致(真值从
id=200 起)。同时 /api/item_detail 完全不返回 options,调用方即使想显式指定
choice 也无从知道合法取值。
- purchase_contract.py:新增 ItemOption / ItemOptionValue 与 parse_options /
auto_choice_for / format_choice。占位判定以结构为主(value_id == 0),日文
文案仅作兜底。放 shared 是因为「接口声明的合法取值」与「下单实际提交的值」
必须同源,否则两边各判一次迟早再次分叉
- item.py / scrape.py:ItemDetailData 增 options、has_required_options、
unfillable_required_options;只解析一次,两个派生结果都取自同一份结果
- site_interact.py:auto_choice_for 取第一个非占位候选;必填项填不出值时
报错点名是哪些选项,让调用方知道该在 intent.choice 里补什么
- auto_choice_for 只自动填必填项:非必填项要不要选是业务决定,不是我们该替
调用方做的选择
- README / docs:补 options[] → intent.choice、variants[] → intent.variant_id
的对照,修掉 order-gateway 示例里已不存在的 "options": {} 字段
真账号验证(scripts/probe_option_choice.py,仅加购不结算不支付):两个商品
提交 確認した / 了解致しました。均被站点接受,购物车 count=2,跑完清空恢复
原状。探针刻意走生产的 add_to_cart_payload 并从其日志截获实际 payload——
probe_purchase_block_v2.py 自己抄了一遍字段构造,与生产代码同错,正是这个
bug 当初藏住的原因。
未覆盖:这两家店铺本身不校验该选项(旧的占位值当年也被收下),所以只证明新值
走得通、语义上才是真答案,证明不了旧值会被拒;必填自由文本项(
unfillable_required_options)无真实样本,仅离线测试覆盖。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
10 KiB
Python
246 lines
10 KiB
Python
"""必填选项 choice 取值的真账号验证:占位项修复是否真的被站点接受
|
|
|
|
背景:2026-08-28 发现 trading 自动填 choice 时取 `values[0]`,而必填 select 的
|
|
values[0] 恒为 id=0 的「選択してください」占位项,等于把「请选择」当答案提交。
|
|
修复后取第一个非占位候选(见 app/shared/purchase_contract.py::auto_choice_for)。
|
|
|
|
离线测试只能证明「我们填的值变了」,证明不了「站点接受这个值」——后者必须真账号
|
|
实测。本探针就为这一件事。
|
|
|
|
**刻意走生产代码路径**(SiteInteractor.add_to_cart_payload),不重写字段构造逻辑:
|
|
上一版探针 scripts/probe_purchase_block_v2.py 自己抄了一遍 form 构造,结果那份
|
|
抄写与生产代码一起用了 values[0],两边同错就测不出问题。实际提交的 payload 从
|
|
生产代码自己的 INFO 日志里抓(「加购请求:basket=... payload=...」),确保记录的
|
|
就是真正发出去的东西。
|
|
|
|
安全边界:
|
|
- 只做「清空购物车 → 加购 → 校验 → 清空购物车」,**绝不进入结算/支付**
|
|
- 加购完立即清空,账号购物车恢复原状(加购本身可逆,不产生订单、不扣款)
|
|
- 不触碰 enter_checkout / submit_order / pay
|
|
|
|
用法:
|
|
.venv/Scripts/python.exe scripts/probe_option_choice.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
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.shared.errors import AppError # noqa: E402
|
|
from app.shared.purchase_contract import auto_choice_for, parse_options # noqa: E402
|
|
from app.trading.services.auth_session import AuthSession # noqa: E402
|
|
from app.trading.worker.site_interact import ( # noqa: E402
|
|
SiteInteractor,
|
|
_parse_initial_state,
|
|
)
|
|
|
|
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "options_choice"
|
|
PROBE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 目标商品:从 .probe/checkout/ 的历史样本里挑「有必填选项且占位项在 values[0]」的两个。
|
|
# 这两个正是旧实现填「選択してください」也被站点收下的商品——它们不能证明旧值是对的
|
|
# (店铺没做校验),但可以验证新值同样被接受,且是站点自己给的唯一非占位候选。
|
|
TARGETS = (
|
|
"https://item.rakuten.co.jp/waabbit/088-4p",
|
|
"https://item.rakuten.co.jp/aoimorinomise/121",
|
|
)
|
|
|
|
|
|
class _PayloadCapture(logging.Handler):
|
|
"""截获生产代码 `加购请求:basket=%s payload=%s` 这条 INFO 日志
|
|
|
|
目的是记录**真正发出去的 payload**,而不是探针自己再算一遍。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(level=logging.INFO)
|
|
self.payloads: list[dict] = []
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
if record.msg == "加购请求:basket=%s payload=%s":
|
|
basket, payload = record.args
|
|
self.payloads.append({"basket_domain": basket, "payload": dict(payload)})
|
|
|
|
|
|
def save(name: str, content: str) -> Path:
|
|
path = PROBE_DIR / name
|
|
path.write_text(content, encoding="utf-8")
|
|
print(f" saved -> {path.name} ({len(content)} bytes)")
|
|
return path
|
|
|
|
|
|
async def inspect_options(site: SiteInteractor, item_url: str) -> list[dict]:
|
|
"""只读打开商品页,把解析出的选项结构落盘,供人工核对占位项判定是否符合真实页面"""
|
|
page = await site._context.new_page()
|
|
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,
|
|
)
|
|
html = await page.content()
|
|
finally:
|
|
await page.close()
|
|
|
|
state = _parse_initial_state(html) or {}
|
|
information = (state.get("purchase") or {}).get("information") or {}
|
|
options = parse_options(information)
|
|
auto_choice, unfillable = auto_choice_for(options)
|
|
|
|
slug = item_url.rstrip("/").replace("https://item.rakuten.co.jp/", "").replace("/", "_")
|
|
save(
|
|
f"options-{slug}.json",
|
|
json.dumps(
|
|
{
|
|
"item_url": item_url,
|
|
"raw_options": information.get("options"),
|
|
"parsed": [
|
|
{
|
|
"name": option.name,
|
|
"type": option.type,
|
|
"is_required": option.is_required,
|
|
"values": [
|
|
{
|
|
"value_id": value.value_id,
|
|
"name": value.name,
|
|
"is_placeholder": value.is_placeholder,
|
|
}
|
|
for value in option.values
|
|
],
|
|
}
|
|
for option in options
|
|
],
|
|
"auto_choice": auto_choice,
|
|
"unfillable_required_options": unfillable,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=1,
|
|
),
|
|
)
|
|
|
|
for option in options:
|
|
if not option.is_required:
|
|
continue
|
|
names = [
|
|
f"{value.name}{'(占位)' if value.is_placeholder else ''}" for value in option.values
|
|
]
|
|
print(f" 必填项「{option.name[:30]}」候选={names}")
|
|
print(f" auto_choice = {auto_choice!r}")
|
|
if unfillable:
|
|
print(f" !! 自动填不了的必填项:{unfillable}")
|
|
return [{"name": o.name, "is_required": o.is_required} for o in options]
|
|
|
|
|
|
async def run() -> int:
|
|
settings = get_settings()
|
|
capture = _PayloadCapture()
|
|
logging.basicConfig(level=logging.WARNING)
|
|
# 必须显式把这个 logger 抬到 INFO:basicConfig 把 root 设成 WARNING,而目标
|
|
# logger 自身是 NOTSET,有效级别继承 root——INFO 记录会在到达 handler 之前
|
|
# 就被丢掉,capture 全程收不到东西(第一次跑就是这样,choice 打印成 None)
|
|
target_logger = logging.getLogger("app.trading.worker.site_interact")
|
|
target_logger.setLevel(logging.INFO)
|
|
target_logger.addHandler(capture)
|
|
|
|
auth = AuthSession(settings)
|
|
await auth.start()
|
|
|
|
print("=== 0. 登录态探测(storage_state 落盘于 2026-08-11,可能已过期)===")
|
|
status = await auth.check("rakuten")
|
|
print(f" logged_in={status.logged_in} detail={status.detail}")
|
|
if not status.logged_in:
|
|
print(" 登录态已失效——需要先跑 scripts/login.py 重新登录(可能要人工过验证码)")
|
|
await auth.close()
|
|
return 2
|
|
|
|
site = SiteInteractor(auth_session=auth, settings=settings)
|
|
await site.start()
|
|
|
|
results: list[dict] = []
|
|
try:
|
|
print("\n=== 1. 清空购物车(排除历史残留污染校验)===")
|
|
cleared = await site.clear_cart()
|
|
print(f" removed={cleared['removed_count']} cart_count={cleared['cart_count']}")
|
|
|
|
for index, item_url in enumerate(TARGETS, start=1):
|
|
print(f"\n=== 2.{index} {item_url} ===")
|
|
print(" -- 只读核对选项结构 --")
|
|
try:
|
|
await inspect_options(site, item_url)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" 选项结构读取失败(不影响加购验证):{type(exc).__name__}: {exc}")
|
|
|
|
print(" -- 走生产路径加购(add_to_cart_payload)--")
|
|
before = len(capture.payloads)
|
|
entry: dict = {"item_url": item_url}
|
|
try:
|
|
added = await site.add_to_cart_payload(item_url=item_url, quantity=1)
|
|
entry["ok"] = True
|
|
entry["item_id"] = added["item_id"]
|
|
entry["cart_count"] = added["cart_count"]
|
|
print(
|
|
f" 加购成功 item_id={added['item_id']} cart_count={added['cart_count']}"
|
|
)
|
|
save(f"add-response-{index}.html", added.get("response_html") or "")
|
|
except AppError as exc:
|
|
entry["ok"] = False
|
|
entry["error"] = exc.message
|
|
print(f" 加购失败:{exc.message}")
|
|
except Exception as exc: # noqa: BLE001
|
|
entry["ok"] = False
|
|
entry["error"] = f"{type(exc).__name__}: {exc}"
|
|
print(f" 加购异常:{type(exc).__name__}: {exc}")
|
|
|
|
# 真正发出去的 payload(从生产代码日志截获)
|
|
sent = capture.payloads[before:]
|
|
if not sent:
|
|
# 抓不到就等于这次验证什么也没证明——必须显眼报出来,不能让
|
|
# 「加购成功」把它盖过去(第一次跑就是 logger 级别没抬导致静默为 None)
|
|
entry["capture_failed"] = True
|
|
print(" !! 未截获到 payload 日志,本次无法确认实际提交的 choice")
|
|
else:
|
|
entry["sent_payload"] = sent[-1]["payload"]
|
|
choice = sent[-1]["payload"].get("choice")
|
|
print(f" 实际提交 choice = {choice!r}")
|
|
if choice is None:
|
|
print(" !! payload 里没有 choice 字段")
|
|
elif "選択してください" in choice:
|
|
print(" !! 提交值里仍含占位项——修复未生效")
|
|
results.append(entry)
|
|
|
|
print("\n=== 3. 购物车状态(确认商品真的进车了)===")
|
|
try:
|
|
status_after = await site.cart_status()
|
|
print(f" {status_after}")
|
|
except AppError as exc:
|
|
print(f" 查询失败:{exc.message}")
|
|
|
|
print("\n=== 4. 清空购物车(恢复账号原状,不进入结算)===")
|
|
final = await site.clear_cart()
|
|
print(f" removed={final['removed_count']} cart_count={final['cart_count']}")
|
|
finally:
|
|
await site.close()
|
|
await auth.close()
|
|
|
|
save("summary.json", json.dumps(results, ensure_ascii=False, indent=1))
|
|
print("\n========== 汇总 ==========")
|
|
for entry in results:
|
|
mark = "OK " if entry.get("ok") else "FAIL"
|
|
choice = (entry.get("sent_payload") or {}).get("choice")
|
|
print(f" {mark} {entry['item_url']}")
|
|
print(f" choice={choice!r}")
|
|
if not entry.get("ok"):
|
|
print(f" error={entry.get('error')}")
|
|
print(f"\n探针输出目录:{PROBE_DIR}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(run()))
|