加 /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:
@@ -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` 编号映射得到**。
|
||||
|
||||
## 错误码
|
||||
|
||||
|
||||
Reference in New Issue
Block a user