加 /api/cart/* 接口;加购链路完全归 trading

trading 新增 4 条购物车接口(POST /api/cart/{add,status,clear,remove}),
全部 Bearer 鉴权、走 SiteInteractor(Playwright + storage_state)。同步把
SiteInteractor 从 gateway URL 解耦——lifespan 总是构造与启停,container
字段 worker_site → site,加 asyncio.Lock 让 HTTP 与 worker 共用同一把锁
(同账号串行硬约束)。clear/remove 用 UI 点击 button[aria-label="削除"],
探针回报这是稳定 selector;真账号实测前先用此路径。

抽 ichiba 加购字段解析到 app/shared/purchase_contract.py(常量 +
inventory_flag_for + basket_domain_of + base_form_fields),原本 scraping
与 trading 重复实现同一段 __INITIAL_STATE__.purchase 解析。进一步发现
README 写的「purchase 块是两服务契约」实际未落地——trading 必须 Playwright
开页(httpx 被 TLS 指纹拦死),本地抽比再调 /api/item_detail 更快更新鲜。
删除 scraping 端 PurchaseInfo/PurchaseOption/PurchaseOptionValue 模型、
各站 _purchase_info 函数、tests/test_purchase.py。ItemDetailData 保留
purchase_condition / is_sold_out / purchase_unit / sku 等商品状态字段。
README「加购与下单」段重写。

328 测试全绿(含架构测试守住三方互不 import)。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:18:00 +08:00
co-authored by Claude Opus 4.6
parent 65e3ed31f8
commit 55c01ae4f5
14 changed files with 934 additions and 536 deletions
+31 -35
View File
@@ -518,50 +518,46 @@ cookie 落在 `.auth/`(已 gitignore,内含可直接冒充账号的凭据,
| 规格 | `sku.axis` + `sku.variants` | 无(单件商品,仅一个 `size` 字段) |
| 每页条数 | 45(搜索) | 40(搜索)/ 36(店铺页) |
| 翻页上限 | `reachable_count`,随查询变化(405~6750) | 固定 100 页 |
| 加购标识 | `purchase` | 无(未实现) |
| 加购 | 由 trading 服务完成(见下) | 无(未实现) |
## 加购与下单(仅乐天市场)
详情响应里的 `purchase` 块给出构造「加入购物车」请求所需的标识。
**抓取服务只提供数据,不执行加购**——加购需要账号登录态,属于交易服务
`app/trading/`,尚在建设中)。这个 `purchase` 块正是两个服务之间的契约:
交易服务调抓取服务的 `/api/item_detail` 取它,而不是直接 import 解析器
加购与下单**完全在交易服务(`app/trading/`,:31108)内部完成**——交易服务用 Playwright
打开商品页(带账号 cookie),从 `__INITIAL_STATE__.purchase``basketDomain` 与字段,
构造表单 POST 到加购端点。抓取服务**不参与**这条链路,`/api/item_detail`
不再返回 `purchase`
```jsonc
"purchase": {
"cart_url": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
"cart_method": "POST",
"form_fields": { // 原样提交的固定字段
"shop_bid": "231431",
"item_id": "10008065",
"inventory_flag": "2", // 1=单一库存,2=多规格
"__event": "ES01_003_001"
},
"quantity_field": "units", // 购买件数填这里
"variant_field": "variant_id", // 选中的 SKU 填这里
"options_field": "choice", // 商品选项填这里
"has_required_options": true, // 有必填选项,缺失会被站点拒绝
"options": [
{ "id": 1, "name": "名入れ", "type": "select", "is_required": true,
"values": [{ "name": "希望する【次の項目で入力】" }] },
{ "id": 2, "name": "【お名前】", "type": "text", "is_required": false, "values": [] }
]
}
```
理由:httpx 在带账号的写操作上被 Rakuten TLS 指纹拦死(探针实测),必须用 Playwright;
既然 Playwright 已经打开商品页(为了发加购 POST 拿到 cookie 上下文),本地抽
`__INITIAL_STATE__.purchase` 比再发 HTTP 到抓取服务更快、数据更新鲜。
拼装规则
抓取服务只暴露**商品状态字段**,不暴露加购指令
- `form_fields` 原样带上,再按 `quantity_field` 填件数。
- `inventory_flag=1``variant_id` 已在 `form_fields` 里预填好;`inventory_flag=2` 时**必须**由调用方从 `sku.variants[].variant_id` 选一个,填到 `variant_field`
- 字段名为空字符串表示该站不支持该项(如 `books` 不能指定件数与规格)。
- `options``type=select` `values` 中选,`type=text` 由买家填写;`is_required=true` 的项不能省。
| 字段 | 含义 |
| --- | --- |
| `purchase_condition` | 站点原值,`enabled` 表示可购买 |
| `is_sold_out` | `purchase_condition != "enabled"` 即售罄 |
| `purchase_unit` | 起订单位 |
| `sku.inventory_type` | `multiple` 表示多规格,要看 `sku.variants[]` |
| `sku.variants[].variant_id` | 多规格商品的规格 ID(trading 加购多规格时按此选择) |
几点差异值得留意
交易服务对外接口(:31108,全部需要 Bearer token)
- **`cart_url` 逐商品不同**,不要写死。不同店铺落在不同 basket 集群(实测有 `sp.basket…``ts.sp.basket…`),`brandavenue` 的端点还要再经一层编号映射。
- **`books``item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。下单请用 `purchase.form_fields.item_id``item_code` 只是市场侧编号。
- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;它的选项取值结构未取到样本验证,因此只用 `has_required_options` 如实回报「有无选项」而不给出选项定义,这类商品需另行处理。
- `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}`
- `POST /api/cart/status` — 调 cart count API,返回购物车商品件数
- `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`
- `POST /api/cart/remove` — 删除指定 `item_id`
底层共用 `app/shared/purchase_contract.py` 的常量与字段构造(与 scraping 模型解耦)。
trading 加购时的字段选择策略:多规格挑第一个非售罄的 variant;必填选项拼「名:值」。
站点端点 `basketDomain` 逐商品不同(实测有 `sp.basket…``ts.sp.basket…`),不能写死。
几点子站差异(仅信息,trading 不覆盖子站加购):
- **`books``item_id` 与 URL 上的商品编号不是同一个值**(如 URL `17065211` 对应 `item_id` `20600328`)。`/api/item_detail` 已经把表单里的 `item_id` 抽到顶层,调用方直接用即可。
- **`biccamera` 没有 `shop_bid`**,走自己的 JSON 接口;选项取值结构未取到样本验证。
- **`brandavenue` 的 cart 端点由 `cart_url_type` 编号映射得到**。
## 错误码