加 /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:
@@ -0,0 +1,78 @@
|
||||
"""乐天市场(ichiba)加购字段抽取的纯函数与常量
|
||||
|
||||
`__INITIAL_STATE__.purchase` 的字段映射在两处使用:
|
||||
- `app/scraping/parsers/item.py::_purchase_info` — `/api/item_detail` 返回的 PurchaseInfo
|
||||
- `app/trading/worker/site_interact.py::_extract_purchase_fields` — trading 加购链路
|
||||
|
||||
两者都解析同一份站点 JSON,本模块抽出**双方都要用**的核心逻辑:
|
||||
- 常量:普通购买事件标识、库存类型 → inventory_flag 映射
|
||||
- `inventory_flag_for(inventory_type)` — 多规格判定
|
||||
- `basket_domain_of(sell_type)` — 抽 basketDomain + 反转义 `\\u002F`
|
||||
- `base_form_fields(shop_id, item_id, inventory_flag)` — 加购表单四件套
|
||||
|
||||
**不**放这里:
|
||||
- `PurchaseInfo` pydantic 模型 — 是 scraping 的对外契约,4 个子站共用,留在 scraping/models
|
||||
- variant_id / choice 自动选择策略 — trading 独有(scraping 把决策权留给上游)
|
||||
- options 结构化解析 — scraping 独有(trading 用原始 dict)
|
||||
- 子站(books/biccamera/brandavenue)的加购契约 — 各自独立,不走这里
|
||||
|
||||
依赖约束:仅 `typing.Any`(标准库),不 import scraping / trading / gateway,
|
||||
符合架构测试 tests/test_architecture.py 的依赖方向规则。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 站点前端构造加购表单时固定带的事件标识(普通购买 normalPurchase)
|
||||
NORMAL_PURCHASE_EVENT: str = "ES01_003_001"
|
||||
|
||||
# 库存类型 → inventory_flag
|
||||
# 多规格商品(sku.variants[] 非空)inventoryType="multiple" → flag "2"
|
||||
# 单一库存商品 → flag "1"
|
||||
# 注意:scraping 早期版本写过 int 2/1,但表单最终要 str,统一在这里就转成 str
|
||||
INVENTORY_FLAG_MULTIPLE: str = "2"
|
||||
INVENTORY_FLAG_DEFAULT: str = "1"
|
||||
_INVENTORY_FLAG_MAP: dict[str, str] = {"multiple": INVENTORY_FLAG_MULTIPLE}
|
||||
|
||||
|
||||
def inventory_flag_for(inventory_type: Any) -> str:
|
||||
"""从 __INITIAL_STATE__.purchase.sku.inventoryType 取 inventory_flag
|
||||
|
||||
inventory_type="multiple" → "2"(多规格,调用方需选 variant_id)
|
||||
其他(含 None / 未知值)→ "1"(单一库存)
|
||||
"""
|
||||
key = inventory_type if isinstance(inventory_type, str) else ""
|
||||
return _INVENTORY_FLAG_MAP.get(key, INVENTORY_FLAG_DEFAULT)
|
||||
|
||||
|
||||
def basket_domain_of(sell_type: dict[str, Any]) -> str:
|
||||
"""从 purchase.sellType.normalPurchase 抽 basketDomain 并反转义
|
||||
|
||||
站点 JSON 里 `/` 被编码为 `\\u002F`,POST 前要还原。
|
||||
sell_type 不是 dict 或 basketDomain 缺失时返回空串。
|
||||
"""
|
||||
if not isinstance(sell_type, dict):
|
||||
return ""
|
||||
raw = sell_type.get("basketDomain")
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
return raw.replace("\\u002F", "/")
|
||||
|
||||
|
||||
def base_form_fields(
|
||||
*,
|
||||
shop_id: Any,
|
||||
item_id: Any,
|
||||
inventory_flag: str,
|
||||
) -> dict[str, str]:
|
||||
"""构造加购表单的基础四件套
|
||||
|
||||
shop_bid / item_id / inventory_flag / __event 是 rakuten 主站加购的固定字段。
|
||||
variant_id / choice / units 由调用方按业务策略再加(trading 自动选,scraping 留给上游)。
|
||||
"""
|
||||
return {
|
||||
"shop_bid": str(shop_id) if shop_id else "",
|
||||
"item_id": str(item_id) if item_id else "",
|
||||
"inventory_flag": inventory_flag,
|
||||
"__event": NORMAL_PURCHASE_EVENT,
|
||||
}
|
||||
Reference in New Issue
Block a user