加 /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:
@@ -347,56 +347,15 @@ class Breadcrumb(BaseModel):
|
||||
url: str = ""
|
||||
|
||||
|
||||
class PurchaseOptionValue(BaseModel):
|
||||
"""商品选项的一个可选值"""
|
||||
|
||||
value_id: str = ""
|
||||
name: str = ""
|
||||
|
||||
|
||||
class PurchaseOption(BaseModel):
|
||||
"""商品选项(選択肢),如「名入れ」「ラッピング」
|
||||
|
||||
加购时需要按 `名称:取值` 的形式拼进 options_field 指定的字段。
|
||||
"""
|
||||
|
||||
option_id: str = ""
|
||||
name: str = ""
|
||||
type: str = "" # select 单选 / check 多选 / text 自由文本
|
||||
is_required: bool = False
|
||||
values: list[PurchaseOptionValue] = Field(default_factory=list) # type=text 时为空
|
||||
|
||||
|
||||
class PurchaseInfo(BaseModel):
|
||||
"""构造加购请求所需的信息
|
||||
|
||||
本服务只提供数据、不执行加购——加购需要已登录的乐天账号会话,由持有登录态的
|
||||
下游负责。四个来源的加购端点与字段名各不相同,因此这里不写死字段,而是把
|
||||
「提交到哪里、固定字段是什么、数量/规格/选项各该用哪个字段名」显式描述出来:
|
||||
|
||||
payload = {**form_fields}
|
||||
payload[quantity_field] = 数量 # quantity_field 为空表示不支持指定数量
|
||||
payload[variant_field] = 选中的 sku.variants[].variant_id # variant_field 为空表示无规格
|
||||
payload[options_field] = ["选项名:取值", ...] # options_field 为空表示无选项
|
||||
|
||||
然后以 cart_method 提交到 cart_url。
|
||||
"""
|
||||
|
||||
cart_url: str = ""
|
||||
cart_method: str = "POST"
|
||||
form_fields: dict[str, str] = Field(default_factory=dict)
|
||||
quantity_field: str = ""
|
||||
variant_field: str = ""
|
||||
options_field: str = ""
|
||||
options: list[PurchaseOption] = Field(default_factory=list)
|
||||
has_required_options: bool = False
|
||||
|
||||
|
||||
class ItemDetailData(BaseModel):
|
||||
"""商品详情数据
|
||||
|
||||
部分乐天官方店的商品页会跳转到独立子站,各子站页面结构不同、可提供的字段也
|
||||
不同。source 标明这条数据由哪个站点解析而来,字段覆盖差异见 README。
|
||||
|
||||
加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话,
|
||||
归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买
|
||||
(purchase_condition / is_sold_out)、规格(sku.variants)、起订单位等。
|
||||
"""
|
||||
|
||||
source: str = "ichiba" # ichiba / books / brandavenue / biccamera
|
||||
@@ -421,7 +380,6 @@ class ItemDetailData(BaseModel):
|
||||
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
|
||||
shipping: ShippingInfo = Field(default_factory=ShippingInfo)
|
||||
sku: SkuInfo = Field(default_factory=SkuInfo)
|
||||
purchase: PurchaseInfo = Field(default_factory=PurchaseInfo)
|
||||
|
||||
|
||||
class HealthData(BaseModel):
|
||||
|
||||
@@ -14,9 +14,6 @@ from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
PurchaseOption,
|
||||
PurchaseOptionValue,
|
||||
ReviewSummary,
|
||||
ShippingInfo,
|
||||
ShopSummary,
|
||||
@@ -31,12 +28,7 @@ from app.scraping.utils.coerce import as_dict, as_float, as_int, as_list, as_str
|
||||
# purchase.sellType 下表示「可正常购买」的状态值
|
||||
_PURCHASABLE_CONDITION = "enabled"
|
||||
|
||||
# 普通购买的事件标识,站点前端构造加购表单时固定带上
|
||||
_NORMAL_PURCHASE_EVENT = "ES01_003_001"
|
||||
|
||||
# 库存类型 → 加购表单里的 inventory_flag
|
||||
_INVENTORY_FLAG = {"multiple": 2}
|
||||
_DEFAULT_INVENTORY_FLAG = 1
|
||||
# 库存类型 → 加购表单里的 inventory_flag(常量与基础字段构造在 app.shared.purchase_contract)
|
||||
|
||||
|
||||
def _parse_attributes(raw: Any) -> list[SkuAttribute]:
|
||||
@@ -91,72 +83,6 @@ def _parse_variants(raw: Any) -> list[SkuVariant]:
|
||||
return variants
|
||||
|
||||
|
||||
def parse_purchase_options(raw: Any) -> list[PurchaseOption]:
|
||||
"""解析商品选项(選択肢)
|
||||
|
||||
结构为 {id, name, type: select|check|text, isRequired, values:[{id, name}]};
|
||||
type=text 的选项没有候选值,由买家自由填写(如刻字内容)。
|
||||
"""
|
||||
options: list[PurchaseOption] = []
|
||||
for option in as_list(raw):
|
||||
if not isinstance(option, dict):
|
||||
continue
|
||||
options.append(
|
||||
PurchaseOption(
|
||||
option_id=str(as_int(option.get("id"))) if option.get("id") is not None else "",
|
||||
name=as_str(option.get("name")),
|
||||
type=as_str(option.get("type")),
|
||||
is_required=bool(option.get("isRequired")),
|
||||
values=[
|
||||
PurchaseOptionValue(
|
||||
value_id=str(as_int(value.get("id"))) if value.get("id") is not None else "",
|
||||
name=as_str(value.get("name")),
|
||||
)
|
||||
for value in as_list(option.get("values"))
|
||||
if isinstance(value, dict)
|
||||
],
|
||||
)
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _purchase_info(
|
||||
*,
|
||||
sell_type: dict[str, Any],
|
||||
raw_sku: dict[str, Any],
|
||||
information: dict[str, Any],
|
||||
shop_id: int,
|
||||
item_id: str,
|
||||
item_variant_id: str,
|
||||
) -> PurchaseInfo:
|
||||
"""组装加购所需的端点与字段
|
||||
|
||||
字段名与取值来自站点前端构造加购表单的逻辑(getPurchaseFormData)。
|
||||
basketDomain 逐商品不同(不同店铺落在不同的 basket 集群),不能写死。
|
||||
"""
|
||||
inventory_flag = _INVENTORY_FLAG.get(as_str(raw_sku.get("inventoryType")), _DEFAULT_INVENTORY_FLAG)
|
||||
form_fields = {
|
||||
"shop_bid": str(shop_id),
|
||||
"item_id": item_id,
|
||||
"inventory_flag": str(inventory_flag),
|
||||
"__event": _NORMAL_PURCHASE_EVENT,
|
||||
}
|
||||
# 单一库存商品的规格是固定的,直接填好,调用方无需再选
|
||||
if inventory_flag == _DEFAULT_INVENTORY_FLAG and item_variant_id:
|
||||
form_fields["variant_id"] = item_variant_id
|
||||
|
||||
options = parse_purchase_options(information.get("options"))
|
||||
return PurchaseInfo(
|
||||
cart_url=as_str(sell_type.get("basketDomain")),
|
||||
form_fields=form_fields,
|
||||
quantity_field="units",
|
||||
variant_field="variant_id",
|
||||
options_field="choice" if options else "",
|
||||
options=options,
|
||||
has_required_options=any(option.is_required for option in options),
|
||||
)
|
||||
|
||||
|
||||
def _pick_sell_type(sell_type: dict[str, Any]) -> dict[str, Any]:
|
||||
"""取售卖方式信息,优先普通购买,其次任意一种带价格的方式(如定期购)"""
|
||||
normal = as_dict(sell_type.get("normalPurchase"))
|
||||
@@ -270,12 +196,4 @@ def parse_item_detail(
|
||||
breadcrumbs=breadcrumbs,
|
||||
shipping=shipping,
|
||||
sku=sku,
|
||||
purchase=_purchase_info(
|
||||
sell_type=sell_type,
|
||||
raw_sku=raw_sku,
|
||||
information=as_dict(purchase.get("information")),
|
||||
shop_id=as_int(shop_information.get("shopId")),
|
||||
item_id=item_id,
|
||||
item_variant_id=as_str(item.get("variantId")),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
ShippingInfo,
|
||||
ShopSummary,
|
||||
SkuInfo,
|
||||
@@ -50,22 +49,6 @@ def _extract_nuxt(html: str) -> dict:
|
||||
return state
|
||||
|
||||
|
||||
def _purchase_info(state: dict, item: dict) -> PurchaseInfo:
|
||||
"""该站加购走自己的 JSON 接口,字段与市场完全不同(没有 shop_bid)
|
||||
|
||||
选项(choices)的取值结构未取到样本验证,因此只如实回报「有没有选项」,
|
||||
不给出可能不准确的选项定义——带选项的商品需要调用方另行处理。
|
||||
"""
|
||||
choices = as_list(state.get("choices"))
|
||||
return PurchaseInfo(
|
||||
cart_url=as_str(item.get("add_cart_api_url")),
|
||||
form_fields={"item_id": str(as_int(item.get("item_id")))},
|
||||
quantity_field="units",
|
||||
options_field="choice" if choices else "",
|
||||
has_required_options=bool(choices),
|
||||
)
|
||||
|
||||
|
||||
def parse(page: SubsitePage) -> ItemDetailData:
|
||||
"""解析ビックカメラ商品页"""
|
||||
nuxt = _extract_nuxt(page.html)
|
||||
@@ -128,5 +111,4 @@ def parse(page: SubsitePage) -> ItemDetailData:
|
||||
show_inventory=inventory > 0,
|
||||
delivery_message=delivery,
|
||||
),
|
||||
purchase=_purchase_info(as_dict(nuxt.get("state")), item),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,6 @@ from app.shared.errors import ScrapeParseError
|
||||
from app.scraping.models.scrape import (
|
||||
Breadcrumb,
|
||||
ItemDetailData,
|
||||
PurchaseInfo,
|
||||
ShopSummary,
|
||||
SkuAttribute,
|
||||
SkuAxis,
|
||||
@@ -37,44 +36,6 @@ SHOP_NAME = "Rakuten Fashion"
|
||||
_COLOR_AXIS = "カラー"
|
||||
_SIZE_AXIS = "サイズ"
|
||||
|
||||
# cart_info.cart_url_type → 加购端点。站点前端用同名映射表(resolveCartUrl)解析;
|
||||
# 若该字段本身已经是一个 URL,则直接使用。
|
||||
_CART_URL_BY_TYPE = {
|
||||
"1": "https://ts.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
|
||||
"2": "https://ts.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
|
||||
"3": "https://t2.basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
|
||||
"4": "https://t2.sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
|
||||
"5": "https://basket.step.rakuten.co.jp/rms/mall/bs/cartadd/set",
|
||||
"6": "https://sp.basket.step.rakuten.co.jp/rms/mall/bss/cartadd/set",
|
||||
}
|
||||
_DEFAULT_PURCHASE_EVENT = "ES01_003_001"
|
||||
|
||||
|
||||
def _resolve_cart_url(cart_url_type: str) -> str:
|
||||
if cart_url_type.startswith("http"):
|
||||
return cart_url_type
|
||||
return _CART_URL_BY_TYPE.get(cart_url_type, "")
|
||||
|
||||
|
||||
def _purchase_info(product: dict) -> PurchaseInfo:
|
||||
"""加购字段与市场是同一套契约,差别只在端点由 cart_url_type 映射得到"""
|
||||
cart_info = as_dict(as_dict(product.get("rms_info")).get("cart_info"))
|
||||
if not cart_info:
|
||||
return PurchaseInfo()
|
||||
return PurchaseInfo(
|
||||
cart_url=_resolve_cart_url(as_str(cart_info.get("cart_url_type"))),
|
||||
form_fields={
|
||||
"shop_bid": as_str(cart_info.get("shop_bid")),
|
||||
"item_id": as_str(cart_info.get("item_id")),
|
||||
"inventory_flag": as_str(cart_info.get("inventory_type")),
|
||||
"__event": as_str(cart_info.get("event")) or _DEFAULT_PURCHASE_EVENT,
|
||||
"encode": "utf8",
|
||||
},
|
||||
quantity_field="units",
|
||||
variant_field="variant_id",
|
||||
)
|
||||
|
||||
|
||||
def validate(html: str) -> str | None:
|
||||
if "window.__INITIAL_STATE__" in html:
|
||||
return None
|
||||
@@ -206,5 +167,4 @@ def parse(page: SubsitePage) -> ItemDetailData:
|
||||
genre_id=as_str(rms.get("genre_id")),
|
||||
breadcrumbs=_breadcrumbs(product),
|
||||
sku=_sku(product, include_variants=page.include_sku_variants),
|
||||
purchase=_purchase_info(product),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""购物车路由:加购 / 查询状态 / 清空 / 删除指定商品
|
||||
|
||||
这些接口的目的是**测试加购与购物车操作是否稳定**,以及在下单流程跑测前
|
||||
清空购物车避免脏数据。不是给上游业务系统调用的——上游走 order-gateway
|
||||
拉单,由 worker 异步处理。
|
||||
|
||||
底层全走 SiteInteractor(Playwright + 账号 cookie);httpx 在带账号的写操作
|
||||
上被 Rakuten TLS 指纹拦死,详见 project://jp-rakuten/checkout-flow-probe-findings。
|
||||
|
||||
所有路由共用一把 asyncio.Lock(持有在 SiteInteractor),同账号写操作串行
|
||||
(per project://jp-rakuten/trading-split「全局并发度 1」硬约束)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
||||
from app.shared.errors import UpstreamRequestError
|
||||
from app.trading.container import TradingContainer
|
||||
from app.trading.models import (
|
||||
CartAddData,
|
||||
CartAddRequest,
|
||||
CartClearData,
|
||||
CartClearRequest,
|
||||
CartRemoveData,
|
||||
CartRemoveRequest,
|
||||
CartStatusData,
|
||||
CartStatusRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cart", tags=["cart"])
|
||||
|
||||
|
||||
def _require_site(container: TradingContainer):
|
||||
"""取 container.site,未就绪时报错
|
||||
|
||||
生产环境 lifespan 一定构造了 site;只有测试或异常启动路径才会 None。
|
||||
"""
|
||||
if container.site is None:
|
||||
raise UpstreamRequestError(
|
||||
"SiteInteractor 未就绪:服务可能未正确启动(Playwright 未初始化)"
|
||||
)
|
||||
return container.site
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add",
|
||||
response_model=ApiResponse[CartAddData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def cart_add(
|
||||
payload: CartAddRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[CartAddData]:
|
||||
"""加购一件商品
|
||||
|
||||
底层:打开 item_url → 抽 __INITIAL_STATE__.purchase → POST basketDomain。
|
||||
variant_id / choice 不传时按商品页定义自动选第一个候选值。
|
||||
成功后顺带查一次 cart count API 一并返回。
|
||||
"""
|
||||
site = _require_site(container)
|
||||
result = await site.add_to_cart_payload( # type: ignore[union-attr]
|
||||
item_url=payload.item_url,
|
||||
quantity=payload.quantity,
|
||||
variant_id=payload.variant_id,
|
||||
choice=payload.choice,
|
||||
)
|
||||
return ApiResponse[CartAddData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=CartAddData(
|
||||
added=True,
|
||||
item_id=result["item_id"],
|
||||
shop_bid=result["shop_bid"],
|
||||
cart_count=result["cart_count"],
|
||||
),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/status",
|
||||
response_model=ApiResponse[CartStatusData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def cart_status(
|
||||
payload: CartStatusRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[CartStatusData]:
|
||||
"""查询购物车状态(轻量)
|
||||
|
||||
只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。
|
||||
"""
|
||||
site = _require_site(container)
|
||||
result = await site.cart_status() # type: ignore[union-attr]
|
||||
return ApiResponse[CartStatusData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=CartStatusData(**result),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/clear",
|
||||
response_model=ApiResponse[CartClearData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def cart_clear(
|
||||
payload: CartClearRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[CartClearData]:
|
||||
"""清空购物车
|
||||
|
||||
策略:渲染 cart SPA → 反复点 button[aria-label="削除"] 直到没有该按钮 →
|
||||
cart count API 校验。返回实际点击的按钮数与末尾 cart count。
|
||||
"""
|
||||
site = _require_site(container)
|
||||
result = await site.clear_cart() # type: ignore[union-attr]
|
||||
return ApiResponse[CartClearData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=CartClearData(**result),
|
||||
code=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/remove",
|
||||
response_model=ApiResponse[CartRemoveData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def cart_remove(
|
||||
payload: CartRemoveRequest,
|
||||
container: TradingContainer = Depends(get_container),
|
||||
) -> ApiResponse[CartRemoveData]:
|
||||
"""删除购物车里指定 item_id 的商品
|
||||
|
||||
策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk
|
||||
parentElement 找 innerText 包含 item_id 的祖先,click 那个按钮。
|
||||
Rakuten cart item 卡片无稳定 data-* 属性,靠文本回溯定位。
|
||||
"""
|
||||
site = _require_site(container)
|
||||
result = await site.remove_item(payload.item_id) # type: ignore[union-attr]
|
||||
return ApiResponse[CartRemoveData](
|
||||
success=True,
|
||||
msg="success",
|
||||
data=CartRemoveData(**result),
|
||||
code=0,
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
"""交易服务容器:集中管理交易侧服务实例,用于依赖注入
|
||||
|
||||
持有两类东西:
|
||||
持有三类东西:
|
||||
- 登录态会话(auth_session)——加购与下单必须用同一份 cookie,全程共享
|
||||
- 站点交互器(site)——持有 Playwright BrowserContext,加购/校验/清空/删除共用。
|
||||
不依赖 gateway URL:HTTP 接口与 worker 都要用它,故总是构造。
|
||||
- 本地下单 worker(可选)——RAKUTEN_ORDER_GATEWAY_URL 留空时整套不构造
|
||||
|
||||
worker 的子组件(gateway_client / local_db / evidence_store / runner)都在
|
||||
@@ -24,15 +26,18 @@ class TradingContainer:
|
||||
订单、页面证据都属于某个具体账号,因此交易服务只能单实例运行
|
||||
(或按账号分片),不能像抓取服务那样随意横向扩容。
|
||||
|
||||
site 字段始终构造(HTTP /api/cart/* 接口与 worker 都依赖它)。
|
||||
worker_* 字段在 RAKUTEN_ORDER_GATEWAY_URL 留空时为 None——此时交易服务
|
||||
只跑登录态接口,不起后台 worker。
|
||||
只跑登录态与购物车接口,不起后台 worker。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
auth_session: AuthSession
|
||||
# 以下五项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动
|
||||
# 站点交互器:持有 Playwright + 账号 cookie,加购/校验/清空/删除共用一份。
|
||||
# 不依赖 gateway URL——HTTP 接口与 worker 都要用它,故总是构造。
|
||||
site: object | None = None # app.trading.worker.site_interact.SiteInteractor
|
||||
# 以下四项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动
|
||||
worker_client: object | None = None # app.trading.worker.client.GatewayClient
|
||||
worker_local_db: object | None = None # app.trading.worker.local_db.LocalDB
|
||||
worker_evidence: object | None = None # app.trading.worker.evidence.EvidenceStore
|
||||
worker_site: object | None = None # app.trading.worker.site_interact.SiteInteractor
|
||||
worker_runner: object | None = None # app.trading.worker.runner.WorkerRunner
|
||||
|
||||
+26
-14
@@ -10,8 +10,10 @@
|
||||
|
||||
因此本服务只能单实例运行(或按账号分片),扩容靠抓取服务那一侧。
|
||||
|
||||
worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL 非空。留空时本服务只跑登录态接口,
|
||||
不构造任何 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建)。
|
||||
SiteInteractor 始终启动:HTTP /api/auth/* 与 /api/cart/* 都要靠它持账号 cookie
|
||||
做站点交互(加购、校验、清空、删除)。worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL
|
||||
非空。留空时不构造 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建),
|
||||
但 SiteInteractor 与 Playwright 仍会启动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,6 +28,7 @@ from app.shared.config import get_settings
|
||||
from app.shared.logging_setup import configure_logging
|
||||
from app.shared.telemetry import instrument_app, setup_telemetry, shutdown_telemetry
|
||||
from app.trading.api.routes.auth import router as auth_router
|
||||
from app.trading.api.routes.cart import router as cart_router
|
||||
from app.trading.api.routes.health import router as health_router
|
||||
from app.trading.container import TradingContainer
|
||||
from app.trading.services.auth_session import AuthSession
|
||||
@@ -34,17 +37,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_container() -> TradingContainer:
|
||||
"""构建交易服务容器:登录态会话始终构造,worker 仅在配置了网关 URL 时构造"""
|
||||
"""构建交易服务容器
|
||||
|
||||
- 登录态会话与站点交互器始终构造(HTTP /api/cart/* 与 /api/auth/* 都依赖)
|
||||
- worker 仅在配置了网关 URL 时构造(依赖 httpx 客户端、本地 DB、证据目录)
|
||||
"""
|
||||
settings = get_settings()
|
||||
container = TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
|
||||
# SiteInteractor 总是构造:HTTP 接口需要它,worker(如果配置了)也需要。
|
||||
# 延迟 import 让没装 Playwright 的环境能起服务做基本配置检查。
|
||||
from app.trading.worker.site_interact import SiteInteractor
|
||||
|
||||
container.site = SiteInteractor(auth_session=container.auth_session, settings=settings)
|
||||
|
||||
if settings.order_gateway_url:
|
||||
# 延迟 import:未配置网关 URL 时不加载 worker 模块(也就不会拉起 aiosqlite 等)
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.runner import WorkerRunner
|
||||
from app.trading.worker.site_interact import SiteInteractor
|
||||
|
||||
client = GatewayClient(
|
||||
settings.order_gateway_url,
|
||||
@@ -53,18 +65,16 @@ def build_container() -> TradingContainer:
|
||||
)
|
||||
local_db = LocalDB(settings.trading_db_path_resolved)
|
||||
evidence = EvidenceStore(settings.evidence_path)
|
||||
site = SiteInteractor(auth_session=container.auth_session, settings=settings)
|
||||
runner = WorkerRunner(
|
||||
settings=settings,
|
||||
gateway_client=client,
|
||||
local_db=local_db,
|
||||
evidence=evidence,
|
||||
site=site,
|
||||
site=container.site,
|
||||
)
|
||||
container.worker_client = client
|
||||
container.worker_local_db = local_db
|
||||
container.worker_evidence = evidence
|
||||
container.worker_site = site
|
||||
container.worker_runner = runner
|
||||
|
||||
return container
|
||||
@@ -72,7 +82,7 @@ def build_container() -> TradingContainer:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:登录态会话 + 可选 worker"""
|
||||
"""应用生命周期管理:登录态会话 + 站点交互器(必起)+ 可选 worker"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
@@ -87,15 +97,16 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
|
||||
await container.auth_session.start()
|
||||
# SiteInteractor 总是启动:HTTP /api/cart/* 接口要靠它发请求
|
||||
assert container.site is not None
|
||||
await container.site.start() # type: ignore[union-attr]
|
||||
|
||||
worker_task: asyncio.Task | None = None
|
||||
if container.worker_runner is not None:
|
||||
# 顺序:先开本地 DB 与 SiteInteractor(worker 写证据/发请求前要先就绪),再起后台任务
|
||||
# 顺序:本地 DB 在 worker 写证据前先就绪;SiteInteractor 已在外层启动
|
||||
assert container.worker_local_db is not None
|
||||
assert container.worker_client is not None
|
||||
assert container.worker_site is not None
|
||||
await container.worker_local_db.start()
|
||||
await container.worker_site.start() # type: ignore[union-attr]
|
||||
worker_task = asyncio.create_task(
|
||||
container.worker_runner.run(), name="trading-worker"
|
||||
)
|
||||
@@ -106,7 +117,7 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态接口)"
|
||||
"未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态与购物车接口)"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -119,8 +130,8 @@ async def lifespan(app: FastAPI):
|
||||
await worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if container.worker_site is not None:
|
||||
await container.worker_site.close() # type: ignore[union-attr]
|
||||
if container.site is not None:
|
||||
await container.site.close() # type: ignore[union-attr]
|
||||
if container.worker_local_db is not None:
|
||||
await container.worker_local_db.close()
|
||||
if container.worker_client is not None:
|
||||
@@ -134,6 +145,7 @@ def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Rakuten Trading Service", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(cart_router)
|
||||
register_exception_handlers(app)
|
||||
instrument_app(app)
|
||||
return app
|
||||
|
||||
@@ -68,3 +68,65 @@ class TradingHealthData(BaseModel):
|
||||
|
||||
status: str
|
||||
auth: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---- 购物车接口(/api/cart/*)----
|
||||
|
||||
|
||||
class CartAddRequest(BaseModel):
|
||||
"""加购请求
|
||||
|
||||
item_url 必填;variant_id / choice 不传时由 SiteInteractor 按商品页
|
||||
__INITIAL_STATE__.purchase 自动选(多规格选第一个非售罄,必填选项选第一个候选值)。
|
||||
choice 接受字符串("颜色:赤")或字符串列表(["颜色:赤", "サイズ:M"])。
|
||||
"""
|
||||
|
||||
item_url: str
|
||||
quantity: int = 1
|
||||
variant_id: str | None = None
|
||||
choice: str | list[str] | None = None
|
||||
|
||||
|
||||
class CartStatusRequest(BaseModel):
|
||||
"""购物车状态查询请求(无入参,保留结构对称)"""
|
||||
|
||||
|
||||
class CartClearRequest(BaseModel):
|
||||
"""清空购物车请求(无入参)"""
|
||||
|
||||
|
||||
class CartRemoveRequest(BaseModel):
|
||||
"""删除指定商品请求"""
|
||||
|
||||
item_id: str
|
||||
|
||||
|
||||
class CartAddData(BaseModel):
|
||||
"""加购响应数据"""
|
||||
|
||||
added: bool = True
|
||||
item_id: str
|
||||
shop_bid: str
|
||||
cart_count: int # -1 表示加购成功但末尾 count 查询失败
|
||||
|
||||
|
||||
class CartStatusData(BaseModel):
|
||||
"""购物车状态响应数据"""
|
||||
|
||||
logged_in: bool
|
||||
count: int
|
||||
raw_status: str # 站点状态码字符串,"100" 表示正常
|
||||
|
||||
|
||||
class CartClearData(BaseModel):
|
||||
"""清空购物车响应数据"""
|
||||
|
||||
removed_count: int # 实际点击「削除」按钮的次数
|
||||
cart_count: int # -1 表示末尾 count 查询失败
|
||||
|
||||
|
||||
class CartRemoveData(BaseModel):
|
||||
"""删除指定商品响应数据"""
|
||||
|
||||
removed: bool # 末尾 cart HTML 已不含 item_id 时为 true
|
||||
item_id: str
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
||||
"""站点交互:加购 / 校验 / 清空 / 删除 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
||||
|
||||
实测进度(详见 project://jp-rakuten/checkout-flow-probe-findings):
|
||||
- add_to_cart、verify_cart 已实测可用(Playwright + storage_state 走 SP 通道)
|
||||
- clear_cart、remove_item 用 UI 点击 `button[aria-label="削除"]` 路径实现,
|
||||
未做真账号实测;首次跑通后回填实测 selector 与 modal 行为到记忆节点
|
||||
- enter_checkout 及之后**未实现**:Rakuten 对 checkout 这类敏感操作要求
|
||||
session upgrade(重输密码),即使当前 SSO 已登录。这是自动 checkout 的硬墙,
|
||||
比 3DS 还前置。本层留接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||
@@ -9,9 +11,14 @@
|
||||
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
|
||||
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
|
||||
的 BrowserContext + APIRequestContext(共享 cookie,绕过 CORS)。
|
||||
|
||||
**并发约束**:所有公开方法入口 acquire `self._lock`,保证 HTTP 路由与 worker
|
||||
主循环不会同时操作同一个 Playwright context——同一账号必须串行
|
||||
(per project://jp-rakuten/trading-split「全局并发度 1」)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -23,6 +30,13 @@ from app.shared.errors import (
|
||||
InvalidRequestError,
|
||||
NotLoggedInError,
|
||||
)
|
||||
from app.shared.purchase_contract import (
|
||||
INVENTORY_FLAG_DEFAULT,
|
||||
INVENTORY_FLAG_MULTIPLE,
|
||||
base_form_fields,
|
||||
basket_domain_of,
|
||||
inventory_flag_for,
|
||||
)
|
||||
from app.trading.core import auth_site
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
@@ -32,14 +46,8 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 普通购买的事件标识,站点前端构造加购表单时固定带上(同 scraping/parsers/item.py)
|
||||
_NORMAL_PURCHASE_EVENT = "ES01_003_001"
|
||||
|
||||
# 库存类型 → 加购表单里的 inventory_flag(与 scraping/parsers/item.py 一致)
|
||||
_INVENTORY_FLAG = {"multiple": "2"}
|
||||
_DEFAULT_INVENTORY_FLAG = "1"
|
||||
|
||||
# 加购端点路径片段(探针实测:basketDomain 逐商品不同,但路径固定)
|
||||
# 普通购买事件标识、inventory_flag 映射、basketDomain 反转义、form_fields 基础构造
|
||||
# 均在 app.shared.purchase_contract,与 scraping/parsers/item.py::_purchase_info 共用同一份契约
|
||||
_BASKET_PATH = "/rms/mall/bss/cartadd/set"
|
||||
|
||||
# 购物车页(SP)与 cart 数量 JSONP API(探针实测)
|
||||
@@ -49,6 +57,16 @@ _CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/j
|
||||
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
|
||||
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
|
||||
|
||||
# 删除按钮稳定 selector(探针回报:.probe/checkout/06-cart-with-items.html 4 处命中)
|
||||
# aria-label 是 a11y 属性,比 CSS modules hash class 稳定;Rakuten 不用 data-testid
|
||||
_DELETE_BUTTON_SELECTOR = 'button[aria-label="削除"]'
|
||||
|
||||
# clear_cart 安全上限:防止 SPA 异常时死循环
|
||||
_CLEAR_CART_MAX_ITER = 50
|
||||
|
||||
# 确认 modal 候选 selector:点击「削除」后站点可能弹「本当に削除しますか?」
|
||||
_CONFIRM_BUTTON_SELECTORS = ('button:has-text("はい")', 'button:has-text("OK")')
|
||||
|
||||
_NOT_IMPLEMENTED_MSG = (
|
||||
"站点交互未实现:见 docs/order-gateway.md §10 与 "
|
||||
"project://jp-rakuten/checkout-flow-probe-findings。"
|
||||
@@ -72,18 +90,27 @@ class SiteInteractor:
|
||||
"""Rakuten 站点交互器:持有 Playwright 浏览器 context,复用账号 cookie
|
||||
|
||||
生命周期:
|
||||
- start() 在 worker 启动时调用一次:启动 Playwright + 创建带 cookie 的 context
|
||||
- add_to_cart / verify_cart 在每个任务里调用
|
||||
- close() 在 worker 关闭时调用
|
||||
- start() 在 trading 服务 lifespan 启动时调用一次:启动 Playwright + 创建带 cookie 的 context
|
||||
- add_to_cart / verify_cart / cart_status / clear_cart / remove_item 在任务或 HTTP 请求里调用
|
||||
- close() 在服务关闭时调用
|
||||
|
||||
浏览器 context 复用同一份登录态,所有任务串行(worker 主循环本就串行),
|
||||
浏览器 context 复用同一份登录态,所有任务串行(self._lock + worker 主循环本就串行),
|
||||
不需要为每个任务开新 context——开销大且 cookie 状态会乱。
|
||||
|
||||
调用方:
|
||||
- worker runner:传入 LeaseTask,调 add_to_cart(task) / verify_cart(task)
|
||||
- HTTP 路由 /api/cart/*:调 add_to_cart_payload(...) / cart_status() /
|
||||
clear_cart() / remove_item(item_id)
|
||||
"""
|
||||
|
||||
# 每任务保留的临时状态:task_id → {"item_id": str, "shop_bid": str}
|
||||
# 用于 add_to_cart 把抓出来的 item_id / shop_bid 喂给 verify_cart
|
||||
_per_task_state: dict[str, dict[str, str]]
|
||||
|
||||
# 所有公开方法共用一把锁:HTTP 接口与 worker 主循环都走它,串行化所有 Playwright 操作。
|
||||
# 同一账号被并发操作 = 风控触发风险 + cart 状态错乱。
|
||||
_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, *, auth_session: "AuthSession", settings: "Settings"):
|
||||
self._auth_session = auth_session
|
||||
self._settings = settings
|
||||
@@ -91,6 +118,7 @@ class SiteInteractor:
|
||||
self._browser = None
|
||||
self._context = None # playwright BrowserContext
|
||||
self._per_task_state = {}
|
||||
self._lock = asyncio.Lock()
|
||||
# 上次 build context 时读到的 storage_state 文件 mtime。
|
||||
# 用于在任务间检测 AuthSession 重登后产生的新 storage_state,触发 context 重建。
|
||||
self._state_mtime: float | None = None
|
||||
@@ -185,10 +213,10 @@ class SiteInteractor:
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
|
||||
# ---- 已实现:add_to_cart / verify_cart ----
|
||||
# ---- 已实现:add_to_cart / verify_cart / cart_status ----
|
||||
|
||||
async def add_to_cart(self, task: LeaseTask) -> None:
|
||||
"""加购:打开商品页 → 抽 __INITIAL_STATE__.purchase → POST basketDomain
|
||||
"""加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields
|
||||
|
||||
调用方需在 task.intent 提供:
|
||||
- item_url: 商品详情页 URL(必填)
|
||||
@@ -209,11 +237,68 @@ class SiteInteractor:
|
||||
if quantity <= 0:
|
||||
raise InvalidRequestError(f"intent.quantity 必须为正整数,收到 {quantity}")
|
||||
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
async with self._lock:
|
||||
result = await self._add_to_cart_with_fields(
|
||||
item_url=item_url,
|
||||
quantity=quantity,
|
||||
variant_id=intent.get("variant_id"),
|
||||
choice=intent.get("choice"),
|
||||
)
|
||||
self._per_task_state[task.task_id] = {
|
||||
"item_id": result["item_id"],
|
||||
"shop_bid": result["shop_bid"],
|
||||
"basket_domain": result["basket_domain"],
|
||||
}
|
||||
|
||||
# 任务开始前刷新 context:AuthSession 可能刚自动重登过,storage_state 变了
|
||||
async def add_to_cart_payload(
|
||||
self,
|
||||
*,
|
||||
item_url: str,
|
||||
quantity: int = 1,
|
||||
variant_id: str | None = None,
|
||||
choice: str | list[str] | None = None,
|
||||
) -> dict:
|
||||
"""加购(HTTP 入口):返回加购结果与最新 cart count,不写 _per_task_state
|
||||
|
||||
与 add_to_cart(task) 共享 _add_to_cart_with_fields,差异仅在:
|
||||
- 入参形态(关键字 vs intent dict)
|
||||
- 返回值(dict vs None,结果记在 _per_task_state)
|
||||
- 不带 task_id(HTTP 调用方自己持有结果)
|
||||
"""
|
||||
if not item_url:
|
||||
raise InvalidRequestError("item_url 必填")
|
||||
if quantity <= 0:
|
||||
raise InvalidRequestError(f"quantity 必须为正整数,收到 {quantity}")
|
||||
async with self._lock:
|
||||
return await self._add_to_cart_with_fields(
|
||||
item_url=item_url,
|
||||
quantity=quantity,
|
||||
variant_id=variant_id,
|
||||
choice=choice,
|
||||
)
|
||||
|
||||
async def _add_to_cart_with_fields(
|
||||
self,
|
||||
*,
|
||||
item_url: str,
|
||||
quantity: int,
|
||||
variant_id: str | None,
|
||||
choice: str | list[str] | None,
|
||||
) -> dict:
|
||||
"""加购核心逻辑(不持锁,由调用方包裹 self._lock)
|
||||
|
||||
返回 dict:{item_id, shop_bid, basket_domain, cart_count}
|
||||
cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。
|
||||
"""
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
|
||||
intent_for_extract: dict = {}
|
||||
if variant_id is not None:
|
||||
intent_for_extract["variant_id"] = variant_id
|
||||
if choice is not None:
|
||||
intent_for_extract["choice"] = choice
|
||||
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
try:
|
||||
@@ -232,12 +317,7 @@ class SiteInteractor:
|
||||
if not state:
|
||||
raise CartOperationError("无法从商品页抽 __INITIAL_STATE__(可能 PC 模板或反爬)")
|
||||
|
||||
fields = _extract_purchase_fields(state, intent_override=intent)
|
||||
self._per_task_state[task.task_id] = {
|
||||
"item_id": fields["form_fields"].get("item_id", ""),
|
||||
"shop_bid": fields["form_fields"].get("shop_bid", ""),
|
||||
"basket_domain": fields["basket_domain"],
|
||||
}
|
||||
fields = _extract_purchase_fields(state, intent_override=intent_for_extract)
|
||||
|
||||
if fields["purchase_condition"] != "enabled":
|
||||
raise CartOperationError(
|
||||
@@ -246,7 +326,7 @@ class SiteInteractor:
|
||||
if not fields["basket_domain"]:
|
||||
raise CartOperationError("basketDomain 为空(商品可能下架)")
|
||||
# 多规格商品要求选了 variant_id
|
||||
if fields["inventory_flag"] == _INVENTORY_FLAG["multiple"] and not fields["form_fields"].get("variant_id"):
|
||||
if fields["inventory_flag"] == INVENTORY_FLAG_MULTIPLE and not fields["form_fields"].get("variant_id"):
|
||||
raise CartOperationError(
|
||||
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
|
||||
)
|
||||
@@ -260,8 +340,7 @@ class SiteInteractor:
|
||||
payload[fields["quantity_field"]] = str(quantity)
|
||||
|
||||
logger.info(
|
||||
"加购请求:task_id=%s basket=%s payload=%s",
|
||||
task.task_id, fields["basket_domain"], payload,
|
||||
"加购请求:basket=%s payload=%s", fields["basket_domain"], payload,
|
||||
)
|
||||
|
||||
resp = await self._context.request.post(
|
||||
@@ -287,12 +366,26 @@ class SiteInteractor:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"加购成功:task_id=%s item_id=%s final=%s",
|
||||
task.task_id, fields["form_fields"].get("item_id"), final_url,
|
||||
"加购成功:item_id=%s final=%s",
|
||||
fields["form_fields"].get("item_id"), final_url,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# 加购成功后顺带查 cart count(best-effort:失败时返回 -1,不掩盖加购成功)
|
||||
try:
|
||||
_, cart_count = await self._query_cart_count()
|
||||
except CartOperationError as exc:
|
||||
logger.warning("加购后查 cart count 失败(不影响加购结果):%s", exc.message)
|
||||
cart_count = -1
|
||||
|
||||
return {
|
||||
"item_id": fields["form_fields"].get("item_id", ""),
|
||||
"shop_bid": fields["form_fields"].get("shop_bid", ""),
|
||||
"basket_domain": fields["basket_domain"],
|
||||
"cart_count": cart_count,
|
||||
}
|
||||
|
||||
async def verify_cart(self, task: LeaseTask) -> None:
|
||||
"""校验购物车里有没有刚加的商品
|
||||
|
||||
@@ -302,57 +395,208 @@ class SiteInteractor:
|
||||
|
||||
登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。
|
||||
"""
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
|
||||
# 同 add_to_cart:刷新 context 以反映可能的自动重登结果
|
||||
await self._refresh_context_if_stale()
|
||||
per_task = self._per_task_state.get(task.task_id, {})
|
||||
item_id = (task.intent or {}).get("item_id") or per_task.get("item_id")
|
||||
if not item_id:
|
||||
raise CartOperationError(
|
||||
"无法确定 item_id:intent 未提供且 add_to_cart 未记录"
|
||||
)
|
||||
|
||||
per_task = self._per_task_state.get(task.task_id, {})
|
||||
item_id = (task.intent or {}).get("item_id") or per_task.get("item_id")
|
||||
# 1. cart count API
|
||||
_, count = await self._query_cart_count()
|
||||
if count == 0:
|
||||
raise CartOperationError("购物车为空,加购可能未生效")
|
||||
logger.info("cart count=%s task_id=%s", count, task.task_id)
|
||||
|
||||
# 2. 渲染 cart 页确认 item_id 在里面
|
||||
await self._verify_item_in_cart_html(item_id, label=f"task_id={task.task_id}")
|
||||
|
||||
async def cart_status(self) -> dict:
|
||||
"""轻量查询购物车状态:调 cart count JSONP API,不渲染整页
|
||||
|
||||
返回 {logged_in, count, raw_status}。
|
||||
count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。
|
||||
raw_status 是站点的状态码字符串,"100" 表示正常。
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
raw_status, count = await self._query_cart_count()
|
||||
return {
|
||||
"logged_in": True,
|
||||
"count": count,
|
||||
"raw_status": raw_status,
|
||||
}
|
||||
|
||||
# ---- 已实现:clear_cart / remove_item(Playwright UI 点击)----
|
||||
|
||||
async def clear_cart(self) -> dict:
|
||||
"""清空购物车:渲染 cart SPA → 反复点第一个「削除」按钮 → count API 校验
|
||||
|
||||
策略:每次循环都重新查 `button[aria-label="削除"]`,点完一个等 SPA 重渲染
|
||||
再点下一个,避免索引漂移。最多 _CLEAR_CART_MAX_ITER 次防死循环。
|
||||
|
||||
未实测前已知边界:
|
||||
- 确认 modal 不确定是否存在,按「先 try 找再 click,找不到就继续」处理
|
||||
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
||||
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
||||
|
||||
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败。
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
|
||||
page = await self._context.new_page()
|
||||
removed = 0
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
await self._wait_cart_rendered(page, label="clear_cart")
|
||||
|
||||
for i in range(_CLEAR_CART_MAX_ITER):
|
||||
btn = page.locator(_DELETE_BUTTON_SELECTOR).first
|
||||
try:
|
||||
await btn.wait_for(state="visible", timeout=2_000)
|
||||
except Exception:
|
||||
logger.info("clear_cart:第 %s 次循环未找到删除按钮,结束", i + 1)
|
||||
break
|
||||
try:
|
||||
await btn.click()
|
||||
except Exception as exc:
|
||||
logger.warning("clear_cart:点击删除按钮失败:%s", exc)
|
||||
break
|
||||
removed += 1
|
||||
await self._handle_confirm_modal(page)
|
||||
# 等 SPA 重新渲染:domcontentloaded 或 1s 兜底
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
||||
except Exception:
|
||||
await page.wait_for_timeout(1_000)
|
||||
else:
|
||||
logger.warning(
|
||||
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
|
||||
_CLEAR_CART_MAX_ITER,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# 末尾用 count API 校验
|
||||
try:
|
||||
_, cart_count = await self._query_cart_count()
|
||||
except CartOperationError as exc:
|
||||
logger.warning("clear_cart 后查 cart count 失败:%s", exc.message)
|
||||
cart_count = -1
|
||||
|
||||
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
||||
return {"removed_count": removed, "cart_count": cart_count}
|
||||
|
||||
async def remove_item(self, item_id: str) -> dict:
|
||||
"""删除购物车里指定 item_id 的商品
|
||||
|
||||
策略:渲染 cart SPA → 在 DOM 里找 button[aria-label="削除"],向上 walk
|
||||
parentElement 找 innerText 包含 item_id 的祖先 → click 那个按钮。
|
||||
|
||||
Rakuten cart item 卡片无稳定 data-* 属性,CSS modules hash class 易变,
|
||||
只能靠「按钮祖先节点的 innerText 包含目标 item_id」做文本回溯定位。
|
||||
item_id 在 Rakuten 是 8 位数字,正常页面其他位置误匹配概率低。
|
||||
|
||||
Raises:
|
||||
InvalidRequestError: item_id 为空
|
||||
CartOperationError: 购物车里找不到 item_id
|
||||
NotLoggedInError: 登录态失效
|
||||
|
||||
返回 {removed, item_id}。removed=false 表示点击了但 SPA 没在末尾 HTML
|
||||
里移除该 item_id(可能删除被站点静默拒绝)。
|
||||
"""
|
||||
if not item_id:
|
||||
raise CartOperationError(
|
||||
"无法确定 item_id:intent 未提供且 add_to_cart 未记录"
|
||||
)
|
||||
raise InvalidRequestError("item_id 必填")
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
|
||||
# 1. cart count API
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
await self._wait_cart_rendered(page, label=f"remove_item {item_id}")
|
||||
|
||||
# JS 在 DOM 里定位包含 item_id 的祖先节点的删除按钮,click 它
|
||||
clicked = await page.evaluate(
|
||||
"""(itemId) => {
|
||||
const buttons = document.querySelectorAll('button[aria-label="削除"]');
|
||||
for (const btn of buttons) {
|
||||
let node = btn.parentElement;
|
||||
for (let i = 0; i < 12 && node; i++) {
|
||||
const text = node.innerText || "";
|
||||
if (text.includes(itemId)) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
str(item_id),
|
||||
)
|
||||
if not clicked:
|
||||
raise CartOperationError(
|
||||
f"购物车里没有 item_id={item_id}(或 SPA 未渲染出来)"
|
||||
)
|
||||
|
||||
await self._handle_confirm_modal(page)
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
|
||||
except Exception:
|
||||
await page.wait_for_timeout(1_000)
|
||||
|
||||
# 校验:item_id 不再出现在 cart HTML
|
||||
html = await page.content()
|
||||
if str(item_id) in html:
|
||||
# SPA 可能还没刷新完,再等 2s 兜底
|
||||
await page.wait_for_timeout(2_000)
|
||||
html = await page.content()
|
||||
removed = str(item_id) not in html
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
logger.info("remove_item 完成:item_id=%s removed=%s", item_id, removed)
|
||||
return {"removed": removed, "item_id": str(item_id)}
|
||||
|
||||
# ---- 内部辅助:cart count API 与 cart 页渲染 ----
|
||||
|
||||
async def _query_cart_count(self) -> tuple[str, int]:
|
||||
"""调 cart count JSONP API,返回 (raw_status, count)
|
||||
|
||||
解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们
|
||||
「请求被拒了」,常见原因是 Referer 错或 cookie 失效。
|
||||
"""
|
||||
resp = await self._context.request.get(
|
||||
_CART_COUNT_API + "?sid=1010",
|
||||
headers={"Referer": _CART_PAGE},
|
||||
)
|
||||
count_body = await resp.text()
|
||||
status_match = re.search(r'"status"\s*:\s*"(\d+)"', count_body)
|
||||
body = await resp.text()
|
||||
status_match = re.search(r'"status"\s*:\s*"(\d+)"', body)
|
||||
if not status_match:
|
||||
raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}")
|
||||
raw_status = status_match.group(1)
|
||||
if raw_status != "100":
|
||||
raise CartOperationError(
|
||||
f"cart count 响应无法解析:{count_body[:200]!r}"
|
||||
f"cart count API 异常:status={raw_status} body={body[:200]!r}"
|
||||
)
|
||||
if status_match.group(1) != "100":
|
||||
raise CartOperationError(
|
||||
f"cart count API 异常:status={status_match.group(1)} body={count_body[:200]!r}"
|
||||
)
|
||||
count_match = re.search(r'"count"\s*:\s*"(\d+)"', count_body)
|
||||
count_match = re.search(r'"count"\s*:\s*"(\d+)"', body)
|
||||
count = int(count_match.group(1)) if count_match and count_match.group(1) else 0
|
||||
if count == 0:
|
||||
raise CartOperationError("购物车为空,加购可能未生效")
|
||||
logger.info("cart count=%s task_id=%s", count, task.task_id)
|
||||
return raw_status, count
|
||||
|
||||
# 2. 渲染 cart 页确认 item_id 在里面
|
||||
async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> None:
|
||||
"""渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车"""
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
# 等 SPA 把 shopUrlList / cartItem 渲染出来
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"""() => {
|
||||
const s = window.__INITIAL_STATE__;
|
||||
return s && s.cart && Array.isArray(s.cart.shopUrlList) && s.cart.shopUrlList.length > 0;
|
||||
}""",
|
||||
timeout=15_000,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"cart SPA 15s 内未渲染出 shopUrlList,继续按静态 HTML 校验:task_id=%s",
|
||||
task.task_id,
|
||||
)
|
||||
await self._wait_cart_rendered(page, label=label)
|
||||
|
||||
html = await page.content()
|
||||
# 旧 marker 出现一定是登录失效;新 SPA 不渲染 marker,所以这判据是单边的
|
||||
@@ -365,13 +609,45 @@ class SiteInteractor:
|
||||
raise CartOperationError(
|
||||
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
|
||||
)
|
||||
logger.info(
|
||||
"cart 校验通过:task_id=%s item_id=%s in cart HTML",
|
||||
task.task_id, item_id,
|
||||
)
|
||||
logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
async def _wait_cart_rendered(self, page, *, label: str) -> None:
|
||||
"""等 cart SPA 把商品列表渲染出来(shopUrlList 非空),最多 15s
|
||||
|
||||
失败时只记 warning 不抛——空购物车时 shopUrlList 本就为空,调用方根据
|
||||
后续业务逻辑(看 HTML、看 count API)自行判断。
|
||||
"""
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"""() => {
|
||||
const s = window.__INITIAL_STATE__;
|
||||
return s && s.cart && Array.isArray(s.cart.shopUrlList) && s.cart.shopUrlList.length > 0;
|
||||
}""",
|
||||
timeout=15_000,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"cart SPA 15s 内未渲染出 shopUrlList(可能购物车为空):%s", label,
|
||||
)
|
||||
|
||||
async def _handle_confirm_modal(self, page) -> None:
|
||||
"""点击「削除」后若弹出确认 modal,尝试找「はい」/「OK」按钮点击
|
||||
|
||||
站点是否弹 modal 未实测确认,按「先 try 找再 click,找不到就跳过」处理。
|
||||
每个候选 selector 给 1.5s 等待,命中后立即返回。
|
||||
"""
|
||||
for sel in _CONFIRM_BUTTON_SELECTORS:
|
||||
try:
|
||||
btn = page.locator(sel).first
|
||||
await btn.wait_for(state="visible", timeout=1_500)
|
||||
await btn.click()
|
||||
logger.info("点击确认 modal 按钮:%s", sel)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# ---- 未实现:保留 NotImplementedError ----
|
||||
|
||||
async def enter_checkout(self, task: LeaseTask) -> str:
|
||||
@@ -414,9 +690,14 @@ def _parse_initial_state(html: str) -> dict | None:
|
||||
|
||||
|
||||
def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> dict:
|
||||
"""从 __INITIAL_STATE__ 抽加购所需字段(与 scraping/parsers/item.py 同源逻辑)
|
||||
"""从 __INITIAL_STATE__ 抽加购所需字段
|
||||
|
||||
intent_override 允许调用方提供 variant_id / choice 覆盖自动选择。
|
||||
与 scraping/parsers/item.py::_purchase_info 共用基础字段构造
|
||||
(app.shared.purchase_contract);本函数额外做:
|
||||
- variant_id 自动选(多规格挑第一个非售罄;调用方覆盖优先)
|
||||
- choice 自动填(必填选项拼「名:值」;调用方覆盖优先)
|
||||
- 返回 basket_domain / min_price / purchase_condition / shop_name / item_name
|
||||
等业务字段,便于日志与错误信息使用
|
||||
"""
|
||||
intent_override = intent_override or {}
|
||||
purchase = state.get("purchase") or {}
|
||||
@@ -426,24 +707,22 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di
|
||||
shop = (state.get("shop") or {}).get("information") or {}
|
||||
information = purchase.get("information") or {}
|
||||
|
||||
basket_domain = (sell_type.get("basketDomain") or "").replace("\\u002F", "/")
|
||||
inventory_type = raw_sku.get("inventoryType")
|
||||
inventory_flag = _INVENTORY_FLAG.get(inventory_type, _DEFAULT_INVENTORY_FLAG)
|
||||
basket_domain = basket_domain_of(sell_type)
|
||||
inventory_flag = inventory_flag_for(raw_sku.get("inventoryType"))
|
||||
shop_id = shop.get("shopId")
|
||||
item_id = item.get("itemId")
|
||||
|
||||
form_fields: dict[str, str] = {
|
||||
"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,
|
||||
}
|
||||
form_fields = base_form_fields(
|
||||
shop_id=shop_id,
|
||||
item_id=item_id,
|
||||
inventory_flag=inventory_flag,
|
||||
)
|
||||
|
||||
# variant_id 选择:调用方覆盖 > 多规格自动选第一个非售罄 > 单规格用 item.variantId
|
||||
chosen_variant = None
|
||||
if intent_override.get("variant_id"):
|
||||
form_fields["variant_id"] = str(intent_override["variant_id"])
|
||||
elif inventory_flag == _INVENTORY_FLAG["multiple"]:
|
||||
elif inventory_flag == INVENTORY_FLAG_MULTIPLE:
|
||||
for v in raw_sku.get("variants") or []:
|
||||
if not v.get("isSoldOut"):
|
||||
chosen_variant = v
|
||||
@@ -454,7 +733,7 @@ def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> di
|
||||
form_fields["variant_id"] = str(
|
||||
chosen_variant.get("variantId") or chosen_variant.get("id") or ""
|
||||
)
|
||||
elif inventory_flag == _DEFAULT_INVENTORY_FLAG and item.get("variantId"):
|
||||
elif inventory_flag == INVENTORY_FLAG_DEFAULT and item.get("variantId"):
|
||||
form_fields["variant_id"] = str(item.get("variantId"))
|
||||
|
||||
# 必填选项:调用方覆盖 > 自动填第一个候选值
|
||||
|
||||
Reference in New Issue
Block a user