加 /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:
@@ -17,7 +17,6 @@ from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
ReviewSummary,
|
||||
ShippingInfo,
|
||||
ShopSummary,
|
||||
@@ -120,27 +119,21 @@ def _parse_genres(html: str) -> tuple[str, list[Breadcrumb]]:
|
||||
return genre_id, crumbs
|
||||
|
||||
|
||||
def _parse_purchase(tree: HTMLParser) -> PurchaseInfo:
|
||||
"""加购信息直接取页面上的购物车表单
|
||||
def _extract_cart_form_item_id(tree: HTMLParser) -> str:
|
||||
"""从购物车表单里抽 item_id
|
||||
|
||||
注意表单里的 item_id 与商品 URL 上的编号不是一回事,加购必须用表单里的值。
|
||||
该表单没有数量字段,无法在加购时指定件数。
|
||||
楽天ブックス 的站内 item_id 与 URL 上的商品编号不是一回事(URL 17065211 →
|
||||
item_id 20600328)。下单要用表单里的 item_id,所以这里专门抽它。
|
||||
加购表单本身不再解析成 PurchaseInfo——加购由 trading 内部完成。
|
||||
"""
|
||||
for form in tree.css("form"):
|
||||
action = form.attributes.get("action") or ""
|
||||
if "/bs/Cart" not in action:
|
||||
continue
|
||||
fields = {
|
||||
name: inp.attributes.get("value") or ""
|
||||
for inp in form.css("input")
|
||||
if (name := inp.attributes.get("name"))
|
||||
}
|
||||
return PurchaseInfo(
|
||||
cart_url=action,
|
||||
cart_method=(form.attributes.get("method") or "POST").upper(),
|
||||
form_fields=fields,
|
||||
)
|
||||
return PurchaseInfo()
|
||||
for inp in form.css("input"):
|
||||
if inp.attributes.get("name") == "item_id":
|
||||
return inp.attributes.get("value") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def parse(page: SubsitePage) -> ItemDetailData:
|
||||
@@ -158,13 +151,13 @@ def parse(page: SubsitePage) -> ItemDetailData:
|
||||
status = _text(tree, ".status")
|
||||
genre_id, breadcrumbs = _parse_genres(page.html)
|
||||
review_count = _text(tree, '[itemprop="reviewCount"]')
|
||||
purchase = _parse_purchase(tree)
|
||||
# 站内 item_id 与 URL 编号不同,下单要用表单里的——保留抽取,但不构 PurchaseInfo
|
||||
item_id = _extract_cart_form_item_id(tree) or page.item_code
|
||||
|
||||
return ItemDetailData(
|
||||
source=SOURCE,
|
||||
source_url=page.final_url,
|
||||
# 站内商品 ID 与 URL 上的编号不同,以购物车表单里的为准(下单要用它)
|
||||
item_id=purchase.form_fields.get("item_id", "") or page.item_code,
|
||||
item_id=item_id,
|
||||
item_code=page.item_code,
|
||||
item_name=name,
|
||||
description=_parse_description(tree),
|
||||
@@ -183,5 +176,4 @@ def parse(page: SubsitePage) -> ItemDetailData:
|
||||
# 图书统一由楽天ブックス发货,页面只给库存措辞,不给运费明细
|
||||
shipping=ShippingInfo(delivery_message=status),
|
||||
sku=SkuInfo(inventory_type="single", attributes=_parse_spec(tree), delivery_message=status),
|
||||
purchase=purchase,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user