fix(trading): 必填选项自动填值跳过「選択してください」占位项,并把选项开放给接口

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>
This commit is contained in:
2026-08-28 16:07:33 +08:00
co-authored by Claude Opus 5
parent 8381896eeb
commit b577d3ac8d
9 changed files with 731 additions and 26 deletions
+54 -1
View File
@@ -338,6 +338,43 @@ class SkuInfo(BaseModel):
variant_count: int = Field(default=0, description="SKU 组合总数;不受 include_sku_variants 影响,始终为真实组合数")
class ItemOptionValue(BaseModel):
"""店铺自定义选项的一个候选取值"""
value_id: int | None = Field(default=None, description="站点 values[].id 原值;给不出数字时为 null")
name: str = Field(default="", description="取值展示名,下单时 choice 里要用这个原文")
is_placeholder: bool = Field(
default=False,
description="是否为「選択してください」这类占位项。占位项不是合法取值,"
"提交它等于没选,构造 choice 时必须跳过",
)
class ItemOption(BaseModel):
"""一个店铺自定义选项(下单必填项的来源)
与 SKU 规格不是一回事:规格走 `sku.variants[].variant_id`,选项走下单接口的
`choice` 字段。店铺用它承载「名入れ文字」「配送方式确认」「レビュー依頼」等,
`is_required=true` 的选项不给值时站点会直接拒绝加购。
"""
option_id: int | None = Field(default=None, description="站点 options[].id 原值")
name: str = Field(default="", description="选项名,下单时 choice 的「名」部分要用这个原文")
type: str = Field(
default="",
description="站点原值:select(下拉,看 values)/ text(自由文本,values 为空)",
)
is_required: bool = Field(default=False, description="是否必填;必填项不给值时站点拒绝加购")
values: list[ItemOptionValue] = Field(
default_factory=list, description="候选取值;type=text 时为空"
)
selectable_value_count: int = Field(
default=0,
description="剔除占位项后真正可提交的候选数。为 0 且 is_required=true 时"
"无法自动选值,必须由调用方在 choice 里显式给出",
)
class ShippingInfo(BaseModel):
"""配送与运费信息"""
@@ -365,7 +402,8 @@ class ItemDetailData(BaseModel):
加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话,
归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买
(purchase_condition / is_sold_out)、规格(sku.variants)、起订单位等。
(purchase_condition / is_sold_out)、规格(sku.variants)、起订单位
店铺自定义选项(options)等,即「下单前需要先决定哪些参数」。
"""
source: str = Field(default="ichiba", description="数据来源站点:ichiba / books / brandavenue / biccamera")
@@ -390,6 +428,21 @@ class ItemDetailData(BaseModel):
breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑")
shipping: ShippingInfo = Field(default_factory=ShippingInfo, description="该商品的配送与运费信息")
sku: SkuInfo = Field(default_factory=SkuInfo, description="SKU 信息")
options: list[ItemOption] = Field(
default_factory=list,
description="店铺自定义选项(站点 purchase.information.options)。与 SKU 规格不同:"
"规格选 sku.variants[].variant_id,选项走下单接口的 choice 字段。"
"空列表表示该商品页没有选项;子站(books / brandavenue / biccamera)暂不解析",
)
has_required_options: bool = Field(
default=False,
description="是否存在必填选项。为 true 时下单必须给 choice,否则站点拒绝加购",
)
unfillable_required_options: list[str] = Field(
default_factory=list,
description="必填但无法自动选值的选项名(自由文本项,或候选值只有占位项)。"
"非空时**必须**由调用方在下单 intent.choice 里显式给出这些项的取值",
)
class HealthData(BaseModel):
+42 -1
View File
@@ -11,9 +11,13 @@ from __future__ import annotations
from typing import Any
from app.shared.errors import ScrapeParseError
from app.shared.purchase_contract import ItemOption as SharedItemOption
from app.shared.purchase_contract import auto_choice_for, parse_options
from app.scraping.models.scrape import (
Breadcrumb,
ItemDetailData,
ItemOption,
ItemOptionValue,
ReviewSummary,
ShippingInfo,
ShopSummary,
@@ -31,6 +35,33 @@ _PURCHASABLE_CONDITION = "enabled"
# 库存类型 → 加购表单里的 inventory_flag(常量与基础字段构造在 app.shared.purchase_contract)
def _to_item_options(options: list[SharedItemOption]) -> list[ItemOption]:
"""把共用契约的 ItemOption dataclass 搬成对外的 pydantic 模型
解析与占位项判定都在 `app.shared.purchase_contract.parse_options`——trading
下单时用同一份逻辑挑 choice 取值,两侧对「哪个取值是合法的」必须完全一致,
否则本接口告诉上游能选的值、下单时却填了别的。本函数只做搬运,不加判断。
"""
return [
ItemOption(
option_id=option.option_id,
name=option.name,
type=option.type,
is_required=option.is_required,
values=[
ItemOptionValue(
value_id=value.value_id,
name=value.name,
is_placeholder=value.is_placeholder,
)
for value in option.values
],
selectable_value_count=len(option.selectable_values),
)
for option in options
]
def _parse_attributes(raw: Any) -> list[SkuAttribute]:
return [
SkuAttribute(title=as_str(attr.get("title")), value=as_str(attr.get("value")))
@@ -115,6 +146,13 @@ def parse_item_detail(
sell_type = _pick_sell_type(as_dict(purchase.get("sellType")))
purchase_condition = as_str(sell_type.get("purchaseCondition"))
purchase_information = as_dict(purchase.get("information"))
# 只解析一次,两个派生结果都从这份结果来
shared_options = parse_options(purchase_information)
# 无法自动选值的必填项:与 trading 自动填 choice 时的判定同源,上游据此知道
# 「哪些项必须自己给值」,而不是等下单时才被站点拒绝
_, unfillable_required = auto_choice_for(shared_options)
raw_sku = as_dict(purchase.get("sku"))
variants = _parse_variants(raw_sku.get("variants"))
sku = SkuInfo(
@@ -188,7 +226,7 @@ def parse_item_detail(
purchase_condition=purchase_condition,
# purchaseCondition 是站点判定能否下单的直接依据;缺失时不臆断为售罄
is_sold_out=bool(purchase_condition) and purchase_condition != _PURCHASABLE_CONDITION,
purchase_unit=as_int(as_dict(purchase.get("information")).get("unit")),
purchase_unit=as_int(purchase_information.get("unit")),
images=images,
shop=shop,
review=review,
@@ -196,4 +234,7 @@ def parse_item_detail(
breadcrumbs=breadcrumbs,
shipping=shipping,
sku=sku,
options=_to_item_options(shared_options),
has_required_options=any(option.is_required for option in shared_options),
unfillable_required_options=unfillable_required,
)
+174 -2
View File
@@ -9,11 +9,13 @@
- `inventory_flag_for(inventory_type)` — 多规格判定
- `basket_domain_of(sell_type)` — 抽 basketDomain + 反转义 `\\u002F`
- `base_form_fields(shop_id, item_id, inventory_flag)` — 加购表单四件套
- `parse_options(information)` — 店铺自定义选项(`purchase.information.options[]`)
的结构化解析,含占位值识别;scraping 用它对外暴露选项,trading 用它自动填 choice
- `auto_choice_for(options)` / `format_choice(...)` — choice 表单值的构造
**不**放这里:
- `PurchaseInfo` pydantic 模型 — 是 scraping 的对外契约,4 个子站共用,留在 scraping/models
- variant_id / choice 自动选择策略 — trading 独有(scraping 把决策权留给上游)
- options 结构化解析 — scraping 独有(trading 用原始 dict)
- variant_id 自动选择策略 — trading 独有(scraping 把决策权留给上游)
- 子站(books/biccamera/brandavenue)的加购契约 — 各自独立,不走这里
依赖约束:仅 `typing.Any`(标准库),不 import scraping / trading / gateway,
@@ -21,6 +23,7 @@
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# 站点前端构造加购表单时固定带的事件标识(普通购买 normalPurchase)
@@ -76,3 +79,172 @@ def base_form_fields(
"inventory_flag": inventory_flag,
"__event": NORMAL_PURCHASE_EVENT,
}
# ---- 店铺自定义选项(purchase.information.options[])----
#
# 「規格(SKU)」与「選項(option)」是两套完全不同的东西,加购表单里也走不同字段:
# - 規格:sku.variants[].variantId → 表单 variant_id
# - 選項:店铺自己配的下拉/文本框(名入れ文字、配送方式确认、レビュー依頼 等)
# → 表单 choice,格式「名:值」,多项用「,」连接
#
# 2026-08-28 用 .probe/ 下 3 份真实商品页样本 + tests/fixtures/item_with_options_state.json
# 核对过结构,得到两条**结构性**判据(比按日文文案猜稳):
#
# 1. `values[].id == 0` 是「請選擇」占位项,不是可提交的取值。三份真实样本里所有
# 必填 select 的 values[0] 都是 id=0 的「選択してください」,真实可选值从 id=200
# 起编号。此前 trading 侧自动填 choice 时取的就是 values[0],等于把「選択して
# ください」当答案提交上去(.probe/checkout/probe-v2.txt 里两条 "成功" 记录的
# choice 值就是它)——站点当时收下了,但那是店铺没做校验,不代表填对了。
# 2. `type == "text"` 的选项没有 values[](自由文本,如「【お名前】4文字まで」),
# **无法**自动填。必填且为 text 时只能交由调用方给值,不猜。
#
# 占位项文案本身(「選択してください」)作为辅助判据一起保留:id 编号规则是从 4 份
# 样本归纳的,万一某店铺不按 200 起编号,文案还能兜一层。两条判据命中任一即占位。
_PLACEHOLDER_VALUE_ID: int = 0
_PLACEHOLDER_VALUE_NAMES: frozenset[str] = frozenset({"選択してください", "選択して下さい"})
# 选项类型:下拉(有候选值)与自由文本(无候选值)
OPTION_TYPE_SELECT: str = "select"
OPTION_TYPE_TEXT: str = "text"
# choice 表单值的分隔符:项间用「,」,名与值之间用「:」
_CHOICE_PAIR_SEPARATOR: str = ","
_CHOICE_NAME_VALUE_SEPARATOR: str = ":"
@dataclass(slots=True)
class ItemOptionValue:
"""选项的一个候选取值
`is_placeholder=True` 表示这是「選択してください」这类占位项,提交它等于没选。
"""
value_id: int | None
name: str
is_placeholder: bool = False
@dataclass(slots=True)
class ItemOption:
"""一个店铺自定义选项
`is_free_text` 为 True 时 values 必然为空(站点 type="text"),取值只能由
调用方给;`selectable_values` 是剔掉占位项后真正可提交的候选。
"""
option_id: int | None
name: str
type: str
is_required: bool
values: list[ItemOptionValue] = field(default_factory=list)
@property
def is_free_text(self) -> bool:
return self.type == OPTION_TYPE_TEXT
@property
def selectable_values(self) -> list[ItemOptionValue]:
return [value for value in self.values if not value.is_placeholder]
@property
def can_auto_fill(self) -> bool:
"""能否在不问调用方的情况下自动给出一个合法取值"""
return bool(self.selectable_values)
def _coerce_option_id(raw: Any) -> int | None:
"""选项/取值的 id 收敛为 int;给不出数字时返回 None(不编造 0——0 有含义)"""
if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _parse_option_value(raw: Any) -> ItemOptionValue | None:
"""解析单个候选取值;不是 dict 或没有名字的直接丢掉"""
if not isinstance(raw, dict):
return None
name = raw.get("name")
if not isinstance(name, str) or not name:
return None
value_id = _coerce_option_id(raw.get("id"))
is_placeholder = value_id == _PLACEHOLDER_VALUE_ID or name.strip() in _PLACEHOLDER_VALUE_NAMES
return ItemOptionValue(value_id=value_id, name=name, is_placeholder=is_placeholder)
def parse_options(information: Any) -> list[ItemOption]:
"""从 `purchase.information` 解析店铺自定义选项列表
`information` 传站点原始 dict(不是 options 数组本身);结构不符合预期时返回
空列表——**没有选项与解析不出选项在站点数据上长得一样**(缺 options 键),
这一层区分不出来,也不该假装能区分。
"""
if not isinstance(information, dict):
return []
raw_options = information.get("options")
if not isinstance(raw_options, list):
return []
options: list[ItemOption] = []
for raw in raw_options:
if not isinstance(raw, dict):
continue
name = raw.get("name")
if not isinstance(name, str) or not name:
continue
raw_type = raw.get("type")
option_type = raw_type if isinstance(raw_type, str) else ""
values = [
value
for value in (_parse_option_value(item) for item in raw.get("values") or [])
if value is not None
]
options.append(
ItemOption(
option_id=_coerce_option_id(raw.get("id")),
name=name,
type=option_type,
is_required=bool(raw.get("isRequired")),
values=values,
)
)
return options
def format_choice(pairs: Any) -> str:
"""把「名:值」序列拼成站点 choice 表单值
接受 str(原样透传,调用方自己拼好的)或可迭代的字符串序列。
"""
if isinstance(pairs, str):
return pairs
return _CHOICE_PAIR_SEPARATOR.join(str(pair) for pair in pairs)
def auto_choice_for(options: list[ItemOption]) -> tuple[str, list[str]]:
"""为必填选项自动挑取值,返回 (choice 表单值, 无法自动填的必填项名)
策略:每个必填选项取**第一个非占位**候选值,拼成「名:值」。自由文本必填项
(type="text",没有候选值)与候选值全是占位项的选项都自动填不了,其名字进
第二个返回值——调用方据此决定报错还是要求上游显式给 choice,**不拿占位值
凑数**(那正是本次修复的问题)。
只处理必填项:非必填项站点不强制,替上游擅自选(比如「置き配を希望する」)
等于替人做了业务决定。
"""
pairs: list[str] = []
unfillable: list[str] = []
for option in options:
if not option.is_required:
continue
selectable = option.selectable_values
if not selectable:
unfillable.append(option.name)
continue
pairs.append(
f"{option.name}{_CHOICE_NAME_VALUE_SEPARATOR}{selectable[0].name}"
)
return format_choice(pairs), unfillable
+28 -18
View File
@@ -109,9 +109,12 @@ from app.shared.errors import (
from app.shared.purchase_contract import (
INVENTORY_FLAG_DEFAULT,
INVENTORY_FLAG_MULTIPLE,
auto_choice_for,
base_form_fields,
basket_domain_of,
format_choice,
inventory_flag_for,
parse_options,
)
from app.shared.proxy import playwright_launch_proxy
from app.shared.task_state import OrderState
@@ -1102,10 +1105,18 @@ class SiteInteractor:
raise CartOperationError(
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
)
# 必填选项要求填了 choice
if fields["has_required_options"] and not fields["form_fields"].get(fields["options_field"]):
# 必填选项要求填了 choice。调用方没给 choice 时,只要存在「自动填不了」
# 的必填项(自由文本项,或候选值只剩占位项)就当场失败并点名是哪几项——
# 这些项非人工给值不可能成功,继续 POST 只会拿站点的
# 「未選択の項目からどれか1つ選んでください。」错误页,排查成本更高
if fields["has_required_options"] and not fields["form_fields"].get(
fields["options_field"]
):
unfillable = fields["unfillable_required_options"]
detail = f"{unfillable}" if unfillable else ""
raise CartOperationError(
"商品有必填选项但未提供 choice,且选项无候选值"
"商品有必填选项但未提供 choice,且这些必填项无法自动选值"
f"(需在 intent.choice 里按「选项名:取值名」显式给出){detail}"
)
payload = dict(fields["form_fields"])
@@ -2421,22 +2432,18 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di
elif inventory_flag == INVENTORY_FLAG_DEFAULT and item.get("variantId"):
form_fields["variant_id"] = str(item.get("variantId"))
# 必填选项:调用方覆盖 > 自动填第一个候选值
options = information.get("options") or []
required_options = [o for o in options if o.get("isRequired")]
has_required = bool(required_options)
# 必填选项:调用方覆盖 > 自动填第一个**非占位**候选值
# 解析与占位项判定走 app.shared.purchase_contract(与 scraping 的
# /api/item_detail 同源),旧实现直接取 values[0],而必填 select 的 values[0]
# 恰恰是「選択してください」占位项,等于把「请选择」当答案提交上去。
options = parse_options(information)
has_required = any(option.is_required for option in options)
auto_choice, unfillable_required = auto_choice_for(options)
if intent_override.get("choice"):
# 调用方给的可能是 list 或 str
c = intent_override["choice"]
form_fields["choice"] = ",".join(c) if isinstance(c, list) else str(c)
elif has_required:
pairs: list[str] = []
for opt in required_options:
values = opt.get("values") or []
if values:
pairs.append(f"{opt.get('name')}:{values[0].get('name')}")
if pairs:
form_fields["choice"] = ",".join(pairs)
# 调用方给的可能是 list 或 str,两种都交给共用的格式化
form_fields["choice"] = format_choice(intent_override["choice"])
elif auto_choice:
form_fields["choice"] = auto_choice
return {
"basket_domain": basket_domain,
@@ -2446,6 +2453,9 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di
"options_field": "choice" if options else "",
"options": options,
"has_required_options": has_required,
# 必填但自动填不了的选项名(自由文本项,或候选值只有占位项)。调用方没给
# choice 时这就是「非人工介入不可能成功」的直接依据,见 _add_to_cart_with_fields
"unfillable_required_options": unfillable_required,
"inventory_flag": inventory_flag,
"purchase_condition": sell_type.get("purchaseCondition"),
"min_price": sell_type.get("minPrice"),