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:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user