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:
@@ -373,6 +373,20 @@ docker compose --profile gateway up -d
|
|||||||
|
|
||||||
搜索结果中的 `shop.shop_code` + `item_code` 可直接用作本接口入参。
|
搜索结果中的 `shop.shop_code` + `item_code` 可直接用作本接口入参。
|
||||||
|
|
||||||
|
**下单前要决定的参数全在响应里**(规格与选项是两套东西,下单时走不同字段):
|
||||||
|
|
||||||
|
| 字段 | 下单时对应 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sku.variants[].variant_id` | `intent.variant_id` | 规格组合(颜色 × 尺码等) |
|
||||||
|
| `options[]` | `intent.choice` | 店铺自定义选项,格式「选项名:取值名」 |
|
||||||
|
| `has_required_options` | — | `true` 时不给 `choice` 会被站点拒绝加购 |
|
||||||
|
| `unfillable_required_options` | — | 必填但无法自动选值的选项名,**必须**由调用方给值 |
|
||||||
|
|
||||||
|
`options[].values[].is_placeholder` 标出「選択してください」这类占位项——它们不是
|
||||||
|
合法取值,拼 `choice` 时要跳过(`selectable_value_count` 已是剔除占位项后的数量)。
|
||||||
|
`type="text"` 的选项是自由文本(如「【お名前】4文字まで」),站点不给候选值,
|
||||||
|
必填时只能由调用方给值。
|
||||||
|
|
||||||
部分官方店的商品页会跳转到独立子站,响应里的 `source` / `source_url` 会标明数据来源,
|
部分官方店的商品页会跳转到独立子站,响应里的 `source` / `source_url` 会标明数据来源,
|
||||||
字段覆盖差异见下一节。
|
字段覆盖差异见下一节。
|
||||||
|
|
||||||
@@ -432,6 +446,7 @@ docker compose --profile gateway up -d
|
|||||||
| `review` | ✅ | ✅ | ❌ 异步加载 | ❌ 异步加载 |
|
| `review` | ✅ | ✅ | ❌ 异步加载 | ❌ 异步加载 |
|
||||||
| `sku.variants`(规格组合) | ✅ | — 图书无规格 | ✅ 颜色 × 尺码 | — 单一规格 |
|
| `sku.variants`(规格组合) | ✅ | — 图书无规格 | ✅ 颜色 × 尺码 | — 单一规格 |
|
||||||
| `sku.attributes`(规格表) | ✅ | ✅ 出版社/ISBN 等 | 挂在各 variant 上 | ❌ |
|
| `sku.attributes`(规格表) | ✅ | ✅ 出版社/ISBN 等 | 挂在各 variant 上 | ❌ |
|
||||||
|
| `options`(店铺自定义选项) | ✅ | ❌ 未解析 | ❌ 未解析 | ❌ 未解析 |
|
||||||
| `shipping` 运费明细 | ✅ | ❌ 仅库存措辞 | ❌ | 仅「是否含运费」 |
|
| `shipping` 运费明细 | ✅ | ❌ 仅库存措辞 | ❌ | 仅「是否含运费」 |
|
||||||
| `shop.shop_id` | ✅ | ❌ | ✅ | ✅ |
|
| `shop.shop_id` | ✅ | ❌ | ✅ | ✅ |
|
||||||
| `breadcrumbs[].url` | ✅ | ✅ | ❌ 站内分类编码,拼不出链接 | ✅ |
|
| `breadcrumbs[].url` | ✅ | ✅ | ❌ 站内分类编码,拼不出链接 | ✅ |
|
||||||
@@ -602,6 +617,8 @@ docker compose --profile gateway up -d
|
|||||||
| `purchase_unit` | 起订单位 |
|
| `purchase_unit` | 起订单位 |
|
||||||
| `sku.inventory_type` | `multiple` 表示多规格,要看 `sku.variants[]` |
|
| `sku.inventory_type` | `multiple` 表示多规格,要看 `sku.variants[]` |
|
||||||
| `sku.variants[].variant_id` | 多规格商品的规格 ID(trading 加购多规格时按此选择) |
|
| `sku.variants[].variant_id` | 多规格商品的规格 ID(trading 加购多规格时按此选择) |
|
||||||
|
| `options[]` | 店铺自定义选项(必填项不给值站点拒绝加购),加购时走 `choice` 字段 |
|
||||||
|
| `unfillable_required_options` | 必填但无法自动选值的选项名,必须由调用方显式给值 |
|
||||||
|
|
||||||
交易服务对外接口(:31108,全部需要 Bearer token):
|
交易服务对外接口(:31108,全部需要 Bearer token):
|
||||||
|
|
||||||
@@ -611,8 +628,13 @@ docker compose --profile gateway up -d
|
|||||||
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`)
|
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`)
|
||||||
- `POST /api/cart/remove` — 删除指定 `item_id`
|
- `POST /api/cart/remove` — 删除指定 `item_id`
|
||||||
|
|
||||||
底层共用 `app/shared/purchase_contract.py` 的常量与字段构造(与 scraping 模型解耦)。
|
底层共用 `app/shared/purchase_contract.py` 的常量、字段构造与**选项解析**(与 scraping
|
||||||
trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」。
|
模型解耦)——`/api/item_detail` 对外暴露的 `options[]` 和 trading 自动填 `choice` 用的
|
||||||
|
是同一份解析与占位项判定,避免「接口说能选的值」与「下单实际填的值」不一致。
|
||||||
|
trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」,
|
||||||
|
取第一个**非占位**候选值(`values[0]` 往往是「選択してください」,填它等于没选)。
|
||||||
|
必填项自动填不出来(自由文本项、候选值只剩占位项)且调用方没给 `choice` 时当场报错
|
||||||
|
并点名是哪几项,不拿占位值凑数去撞站点。
|
||||||
站点端点 `basketDomain` 逐商品不同(实测有 `sp.basket…` 与 `ts.sp.basket…`),不能写死。
|
站点端点 `basketDomain` 逐商品不同(实测有 `sp.basket…` 与 `ts.sp.basket…`),不能写死。
|
||||||
|
|
||||||
几点子站差异(仅信息,trading 不覆盖子站加购):
|
几点子站差异(仅信息,trading 不覆盖子站加购):
|
||||||
|
|||||||
@@ -338,6 +338,43 @@ class SkuInfo(BaseModel):
|
|||||||
variant_count: int = Field(default=0, description="SKU 组合总数;不受 include_sku_variants 影响,始终为真实组合数")
|
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):
|
class ShippingInfo(BaseModel):
|
||||||
"""配送与运费信息"""
|
"""配送与运费信息"""
|
||||||
|
|
||||||
@@ -365,7 +402,8 @@ class ItemDetailData(BaseModel):
|
|||||||
|
|
||||||
加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话,
|
加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话,
|
||||||
归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买
|
归 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")
|
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="分类面包屑")
|
breadcrumbs: list[Breadcrumb] = Field(default_factory=list, description="分类面包屑")
|
||||||
shipping: ShippingInfo = Field(default_factory=ShippingInfo, description="该商品的配送与运费信息")
|
shipping: ShippingInfo = Field(default_factory=ShippingInfo, description="该商品的配送与运费信息")
|
||||||
sku: SkuInfo = Field(default_factory=SkuInfo, description="SKU 信息")
|
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):
|
class HealthData(BaseModel):
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.shared.errors import ScrapeParseError
|
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 (
|
from app.scraping.models.scrape import (
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
ItemDetailData,
|
ItemDetailData,
|
||||||
|
ItemOption,
|
||||||
|
ItemOptionValue,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
ShippingInfo,
|
ShippingInfo,
|
||||||
ShopSummary,
|
ShopSummary,
|
||||||
@@ -31,6 +35,33 @@ _PURCHASABLE_CONDITION = "enabled"
|
|||||||
# 库存类型 → 加购表单里的 inventory_flag(常量与基础字段构造在 app.shared.purchase_contract)
|
# 库存类型 → 加购表单里的 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]:
|
def _parse_attributes(raw: Any) -> list[SkuAttribute]:
|
||||||
return [
|
return [
|
||||||
SkuAttribute(title=as_str(attr.get("title")), value=as_str(attr.get("value")))
|
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")))
|
sell_type = _pick_sell_type(as_dict(purchase.get("sellType")))
|
||||||
purchase_condition = as_str(sell_type.get("purchaseCondition"))
|
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"))
|
raw_sku = as_dict(purchase.get("sku"))
|
||||||
variants = _parse_variants(raw_sku.get("variants"))
|
variants = _parse_variants(raw_sku.get("variants"))
|
||||||
sku = SkuInfo(
|
sku = SkuInfo(
|
||||||
@@ -188,7 +226,7 @@ def parse_item_detail(
|
|||||||
purchase_condition=purchase_condition,
|
purchase_condition=purchase_condition,
|
||||||
# purchaseCondition 是站点判定能否下单的直接依据;缺失时不臆断为售罄
|
# purchaseCondition 是站点判定能否下单的直接依据;缺失时不臆断为售罄
|
||||||
is_sold_out=bool(purchase_condition) and purchase_condition != _PURCHASABLE_CONDITION,
|
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,
|
images=images,
|
||||||
shop=shop,
|
shop=shop,
|
||||||
review=review,
|
review=review,
|
||||||
@@ -196,4 +234,7 @@ def parse_item_detail(
|
|||||||
breadcrumbs=breadcrumbs,
|
breadcrumbs=breadcrumbs,
|
||||||
shipping=shipping,
|
shipping=shipping,
|
||||||
sku=sku,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
- `inventory_flag_for(inventory_type)` — 多规格判定
|
- `inventory_flag_for(inventory_type)` — 多规格判定
|
||||||
- `basket_domain_of(sell_type)` — 抽 basketDomain + 反转义 `\\u002F`
|
- `basket_domain_of(sell_type)` — 抽 basketDomain + 反转义 `\\u002F`
|
||||||
- `base_form_fields(shop_id, item_id, inventory_flag)` — 加购表单四件套
|
- `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
|
- `PurchaseInfo` pydantic 模型 — 是 scraping 的对外契约,4 个子站共用,留在 scraping/models
|
||||||
- variant_id / choice 自动选择策略 — trading 独有(scraping 把决策权留给上游)
|
- variant_id 自动选择策略 — trading 独有(scraping 把决策权留给上游)
|
||||||
- options 结构化解析 — scraping 独有(trading 用原始 dict)
|
|
||||||
- 子站(books/biccamera/brandavenue)的加购契约 — 各自独立,不走这里
|
- 子站(books/biccamera/brandavenue)的加购契约 — 各自独立,不走这里
|
||||||
|
|
||||||
依赖约束:仅 `typing.Any`(标准库),不 import scraping / trading / gateway,
|
依赖约束:仅 `typing.Any`(标准库),不 import scraping / trading / gateway,
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# 站点前端构造加购表单时固定带的事件标识(普通购买 normalPurchase)
|
# 站点前端构造加购表单时固定带的事件标识(普通购买 normalPurchase)
|
||||||
@@ -76,3 +79,172 @@ def base_form_fields(
|
|||||||
"inventory_flag": inventory_flag,
|
"inventory_flag": inventory_flag,
|
||||||
"__event": NORMAL_PURCHASE_EVENT,
|
"__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
|
||||||
|
|||||||
@@ -109,9 +109,12 @@ from app.shared.errors import (
|
|||||||
from app.shared.purchase_contract import (
|
from app.shared.purchase_contract import (
|
||||||
INVENTORY_FLAG_DEFAULT,
|
INVENTORY_FLAG_DEFAULT,
|
||||||
INVENTORY_FLAG_MULTIPLE,
|
INVENTORY_FLAG_MULTIPLE,
|
||||||
|
auto_choice_for,
|
||||||
base_form_fields,
|
base_form_fields,
|
||||||
basket_domain_of,
|
basket_domain_of,
|
||||||
|
format_choice,
|
||||||
inventory_flag_for,
|
inventory_flag_for,
|
||||||
|
parse_options,
|
||||||
)
|
)
|
||||||
from app.shared.proxy import playwright_launch_proxy
|
from app.shared.proxy import playwright_launch_proxy
|
||||||
from app.shared.task_state import OrderState
|
from app.shared.task_state import OrderState
|
||||||
@@ -1102,10 +1105,18 @@ class SiteInteractor:
|
|||||||
raise CartOperationError(
|
raise CartOperationError(
|
||||||
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
|
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
|
||||||
)
|
)
|
||||||
# 必填选项要求填了 choice
|
# 必填选项要求填了 choice。调用方没给 choice 时,只要存在「自动填不了」
|
||||||
if fields["has_required_options"] and not fields["form_fields"].get(fields["options_field"]):
|
# 的必填项(自由文本项,或候选值只剩占位项)就当场失败并点名是哪几项——
|
||||||
|
# 这些项非人工给值不可能成功,继续 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(
|
raise CartOperationError(
|
||||||
"商品有必填选项但未提供 choice,且选项无候选值"
|
"商品有必填选项但未提供 choice,且这些必填项无法自动选值"
|
||||||
|
f"(需在 intent.choice 里按「选项名:取值名」显式给出){detail}"
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = dict(fields["form_fields"])
|
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"):
|
elif inventory_flag == INVENTORY_FLAG_DEFAULT and item.get("variantId"):
|
||||||
form_fields["variant_id"] = str(item.get("variantId"))
|
form_fields["variant_id"] = str(item.get("variantId"))
|
||||||
|
|
||||||
# 必填选项:调用方覆盖 > 自动填第一个候选值
|
# 必填选项:调用方覆盖 > 自动填第一个**非占位**候选值
|
||||||
options = information.get("options") or []
|
# 解析与占位项判定走 app.shared.purchase_contract(与 scraping 的
|
||||||
required_options = [o for o in options if o.get("isRequired")]
|
# /api/item_detail 同源),旧实现直接取 values[0],而必填 select 的 values[0]
|
||||||
has_required = bool(required_options)
|
# 恰恰是「選択してください」占位项,等于把「请选择」当答案提交上去。
|
||||||
|
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"):
|
if intent_override.get("choice"):
|
||||||
# 调用方给的可能是 list 或 str
|
# 调用方给的可能是 list 或 str,两种都交给共用的格式化
|
||||||
c = intent_override["choice"]
|
form_fields["choice"] = format_choice(intent_override["choice"])
|
||||||
form_fields["choice"] = ",".join(c) if isinstance(c, list) else str(c)
|
elif auto_choice:
|
||||||
elif has_required:
|
form_fields["choice"] = auto_choice
|
||||||
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)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"basket_domain": basket_domain,
|
"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_field": "choice" if options else "",
|
||||||
"options": options,
|
"options": options,
|
||||||
"has_required_options": has_required,
|
"has_required_options": has_required,
|
||||||
|
# 必填但自动填不了的选项名(自由文本项,或候选值只有占位项)。调用方没给
|
||||||
|
# choice 时这就是「非人工介入不可能成功」的直接依据,见 _add_to_cart_with_fields
|
||||||
|
"unfillable_required_options": unfillable_required,
|
||||||
"inventory_flag": inventory_flag,
|
"inventory_flag": inventory_flag,
|
||||||
"purchase_condition": sell_type.get("purchaseCondition"),
|
"purchase_condition": sell_type.get("purchaseCondition"),
|
||||||
"min_price": sell_type.get("minPrice"),
|
"min_price": sell_type.get("minPrice"),
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ CREATE TABLE workers (
|
|||||||
"intent": { // gateway 原样透传,结构由 trading 侧定义
|
"intent": { // gateway 原样透传,结构由 trading 侧定义
|
||||||
"item_url": "https://item.rakuten.co.jp/shop/code/",
|
"item_url": "https://item.rakuten.co.jp/shop/code/",
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"variant_id": "...", // 多规格商品必填
|
"variant_id": "...", // 多规格商品必填,取自 /api/item_detail 的 sku.variants[]
|
||||||
"options": {},
|
"choice": ["名入れ:希望する"], // 店铺自定义必填选项,格式「选项名:取值名」,取自 /api/item_detail 的 options[]
|
||||||
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
|
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
|
||||||
},
|
},
|
||||||
"callback_url": "https://upstream.example.com/hooks/rakuten-order" // 可选,终结类事件通知,见 §4.8
|
"callback_url": "https://upstream.example.com/hooks/rakuten-order" // 可选,终结类事件通知,见 §4.8
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""必填选项 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()))
|
||||||
@@ -142,6 +142,79 @@ def test_item_detail_falls_back_to_request_shop_code_when_state_lacks_it(item_st
|
|||||||
assert detail.shop.shop_code == "fallback"
|
assert detail.shop.shop_code == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 店铺自定义选项(下单必填项)----
|
||||||
|
|
||||||
|
def test_item_detail_exposes_shop_options_for_ordering(item_with_options_state):
|
||||||
|
"""下单要用的选项必须能从接口拿到:名字、类型、是否必填、候选取值"""
|
||||||
|
detail = parse_item_detail(
|
||||||
|
item_with_options_state, item_url="https://x", shop_code="kizamu",
|
||||||
|
include_sku_variants=True,
|
||||||
|
)
|
||||||
|
assert detail.has_required_options is True
|
||||||
|
# 真实样本:1 个必填 select(名入れ)+ 1 个非必填 select + 2 个自由文本
|
||||||
|
assert [option.name for option in detail.options] == [
|
||||||
|
"名入れ",
|
||||||
|
" 【お名前】※漢字・かな文字:4文字まで",
|
||||||
|
"【定型】",
|
||||||
|
"【定型】※〇寿・〇婚式・〇長を選択の場合のみ(1文字まで)",
|
||||||
|
]
|
||||||
|
required = detail.options[0]
|
||||||
|
assert required.is_required is True
|
||||||
|
assert required.type == "select"
|
||||||
|
assert required.option_id == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_marks_placeholder_option_values_as_unselectable(item_with_options_state):
|
||||||
|
"""「選択してください」是占位项,不能作为下单取值——这是 choice 填错的根因"""
|
||||||
|
detail = parse_item_detail(
|
||||||
|
item_with_options_state, item_url="https://x", shop_code="kizamu",
|
||||||
|
include_sku_variants=True,
|
||||||
|
)
|
||||||
|
required = detail.options[0]
|
||||||
|
placeholder, first_real = required.values[0], required.values[1]
|
||||||
|
assert placeholder.name == "選択してください"
|
||||||
|
assert placeholder.is_placeholder is True
|
||||||
|
assert first_real.name == "希望する【次の項目で入力】"
|
||||||
|
assert first_real.is_placeholder is False
|
||||||
|
# 3 个候选里只有 2 个真正可提交
|
||||||
|
assert len(required.values) == 3
|
||||||
|
assert required.selectable_value_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_free_text_option_has_no_values(item_with_options_state):
|
||||||
|
"""type=text 是自由文本,站点不给候选值,无法自动选"""
|
||||||
|
detail = parse_item_detail(
|
||||||
|
item_with_options_state, item_url="https://x", shop_code="kizamu",
|
||||||
|
include_sku_variants=True,
|
||||||
|
)
|
||||||
|
free_text = detail.options[1]
|
||||||
|
assert free_text.type == "text"
|
||||||
|
assert free_text.values == []
|
||||||
|
assert free_text.selectable_value_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_reports_required_options_that_cannot_be_auto_filled(item_with_options_state):
|
||||||
|
"""必填自由文本项无法自动选值,必须点名交给调用方,不能静默蒙一个值"""
|
||||||
|
options = item_with_options_state["purchase"]["information"]["options"]
|
||||||
|
# 把那个自由文本项改成必填:店铺要求填「お名前」时就是这种形态
|
||||||
|
options[1]["isRequired"] = True
|
||||||
|
detail = parse_item_detail(
|
||||||
|
item_with_options_state, item_url="https://x", shop_code="kizamu",
|
||||||
|
include_sku_variants=True,
|
||||||
|
)
|
||||||
|
assert detail.unfillable_required_options == [" 【お名前】※漢字・かな文字:4文字まで"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_without_options_reports_empty(item_state):
|
||||||
|
"""没有选项的商品(真实样本 purchase.information 只有 unit)不应凭空造出选项"""
|
||||||
|
detail = parse_item_detail(
|
||||||
|
item_state, item_url="https://x", shop_code="s", include_sku_variants=False,
|
||||||
|
)
|
||||||
|
assert detail.options == []
|
||||||
|
assert detail.has_required_options is False
|
||||||
|
assert detail.unfillable_required_options == []
|
||||||
|
|
||||||
|
|
||||||
def test_item_detail_rejects_state_without_item_node():
|
def test_item_detail_rejects_state_without_item_node():
|
||||||
with pytest.raises(ScrapeParseError):
|
with pytest.raises(ScrapeParseError):
|
||||||
parse_item_detail({}, item_url="https://x", shop_code="s", include_sku_variants=True)
|
parse_item_detail({}, item_url="https://x", shop_code="s", include_sku_variants=True)
|
||||||
|
|||||||
@@ -180,6 +180,95 @@ def test_extract_fields_required_options_auto_picks_first_value():
|
|||||||
assert fields["form_fields"]["choice"] == "サイズ:S"
|
assert fields["form_fields"]["choice"] == "サイズ:S"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_fields_required_options_skips_placeholder_value():
|
||||||
|
"""回归:必填 select 的 values[0] 是「選択してください」占位项,不能当答案提交
|
||||||
|
|
||||||
|
真实商品页(.probe/checkout/03-add-fail-2.html 等 3 份样本)里必填 select 的
|
||||||
|
第一个候选恒为 id=0 的占位项,真实取值从 id=200 起。旧实现直接取 values[0],
|
||||||
|
等于把「請選擇」填进 choice 提交上去。
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"item": {"itemId": 1, "variantId": "v"},
|
||||||
|
"purchase": {
|
||||||
|
"sku": {"inventoryType": "single"},
|
||||||
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
||||||
|
"information": {
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"name": "この商品は「トライタン製/プラスチック」です",
|
||||||
|
"type": "select",
|
||||||
|
"isRequired": True,
|
||||||
|
"values": [
|
||||||
|
{"id": 0, "name": "選択してください"},
|
||||||
|
{"id": 200, "name": "確認した"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"shop": {"information": {"shopId": 1}},
|
||||||
|
}
|
||||||
|
fields = _extract_purchase_fields(state, intent_override={})
|
||||||
|
assert fields["has_required_options"] is True
|
||||||
|
assert fields["form_fields"]["choice"] == "この商品は「トライタン製/プラスチック」です:確認した"
|
||||||
|
assert fields["unfillable_required_options"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_fields_required_free_text_option_cannot_be_auto_filled():
|
||||||
|
"""必填自由文本项(type=text,无候选值)自动填不了,要点名报出来"""
|
||||||
|
state = {
|
||||||
|
"item": {"itemId": 1, "variantId": "v"},
|
||||||
|
"purchase": {
|
||||||
|
"sku": {"inventoryType": "single"},
|
||||||
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
||||||
|
"information": {
|
||||||
|
"options": [
|
||||||
|
{"id": 101, "name": "【お名前】", "type": "text", "isRequired": True},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"shop": {"information": {"shopId": 1}},
|
||||||
|
}
|
||||||
|
fields = _extract_purchase_fields(state, intent_override={})
|
||||||
|
assert fields["has_required_options"] is True
|
||||||
|
# 填不出来就不填,也不拿占位/空值凑数
|
||||||
|
assert "choice" not in fields["form_fields"]
|
||||||
|
assert fields["unfillable_required_options"] == ["【お名前】"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_fields_skips_optional_options_when_auto_filling():
|
||||||
|
"""非必填项不替上游做业务决定(如「置き配を希望する」),只自动填必填项"""
|
||||||
|
state = {
|
||||||
|
"item": {"itemId": 1, "variantId": "v"},
|
||||||
|
"purchase": {
|
||||||
|
"sku": {"inventoryType": "single"},
|
||||||
|
"sellType": {"normalPurchase": {"basketDomain": "https://x/add", "purchaseCondition": "enabled"}},
|
||||||
|
"information": {
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"name": "必須確認",
|
||||||
|
"type": "select",
|
||||||
|
"isRequired": True,
|
||||||
|
"values": [{"id": 0, "name": "選択してください"}, {"id": 200, "name": "了解"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "「置き配」希望について",
|
||||||
|
"type": "select",
|
||||||
|
"isRequired": False,
|
||||||
|
"values": [{"id": 200, "name": "置き配を希望しない"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"shop": {"information": {"shopId": 1}},
|
||||||
|
}
|
||||||
|
fields = _extract_purchase_fields(state, intent_override={})
|
||||||
|
assert fields["form_fields"]["choice"] == "必須確認:了解"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_fields_intent_choice_override_accepts_list_and_str():
|
def test_extract_fields_intent_choice_override_accepts_list_and_str():
|
||||||
state = {
|
state = {
|
||||||
"item": {"itemId": 1, "variantId": "v"},
|
"item": {"itemId": 1, "variantId": "v"},
|
||||||
|
|||||||
Reference in New Issue
Block a user