实现 site_interact 加购与购物车校验(Playwright 通道)
探针实测发现 httpx 因 TLS/HTTP2 指纹被 Rakuten 拒认(cookie 有效但 站点不识别 session),site_interact 改为全程 Playwright + storage_state。 add_to_cart 与 verify_cart 已实测可用:从商品页 __INITIAL_STATE__ 抽 purchase 块、调用方补 variant_id 与 choice、POST basketDomain、用 cart count JSONP API 与 SPA 渲染后的 cart 页校验。enter_checkout 及之后仍 NotImplementedError:Rakuten 对 checkout 要求 session upgrade(重输密码), 是自动 checkout 的硬墙,未实测过墙方案前留接口缝。 SiteInteractor 类持有 Playwright BrowserContext,由 WorkerRunner 实例 持有、container/main 接线、lifespan 管理生命周期。测试侧 23 个用例, 全套件 333 passed,架构边界守住。 附 5 个探针脚本(scripts/probe_*.py),记录实测路径与响应结构。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,11 +30,12 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from app.shared.errors import AppError, OrderGuardError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker import site_interact, verify
|
||||
from app.trading.worker import verify
|
||||
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.models import LeaseTask
|
||||
from app.trading.worker.site_interact import SiteInteractor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
@@ -67,11 +68,13 @@ class WorkerRunner:
|
||||
gateway_client: GatewayClient,
|
||||
local_db: LocalDB,
|
||||
evidence: EvidenceStore,
|
||||
site: SiteInteractor,
|
||||
):
|
||||
self._settings = settings
|
||||
self._gateway = gateway_client
|
||||
self._db = local_db
|
||||
self._evidence = evidence
|
||||
self._site = site
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
@@ -231,7 +234,7 @@ class WorkerRunner:
|
||||
# 步骤 1:加购
|
||||
await self._run_step(
|
||||
task, step_no=1, step_name="cart-add",
|
||||
action=lambda: site_interact.add_to_cart(task),
|
||||
action=lambda: self._site.add_to_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="已加入购物车",
|
||||
)
|
||||
@@ -239,14 +242,14 @@ class WorkerRunner:
|
||||
# 步骤 2:校验购物车
|
||||
await self._run_step(
|
||||
task, step_no=2, step_name="cart-check",
|
||||
action=lambda: site_interact.verify_cart(task),
|
||||
action=lambda: self._site.verify_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="购物车已校验",
|
||||
)
|
||||
|
||||
# 步骤 3:进入下单确认页 + 金额守卫
|
||||
checkout_html = await site_interact.enter_checkout(task)
|
||||
summary = await site_interact.parse_checkout(checkout_html)
|
||||
checkout_html = await self._site.enter_checkout(task)
|
||||
summary = await self._site.parse_checkout(checkout_html)
|
||||
self._enforce_amount_guard(task, summary.payable_yen)
|
||||
await self._run_step(
|
||||
task, step_no=3, step_name="order-confirm",
|
||||
@@ -261,7 +264,7 @@ class WorkerRunner:
|
||||
)
|
||||
|
||||
# 步骤 4:提交下单
|
||||
site_order_id = await site_interact.submit_order(task)
|
||||
site_order_id = await self._site.submit_order(task)
|
||||
await self._run_step(
|
||||
task, step_no=4, step_name="order-submit",
|
||||
action=self._noop(),
|
||||
@@ -273,7 +276,7 @@ class WorkerRunner:
|
||||
# 步骤 5:付款
|
||||
await self._run_step(
|
||||
task, step_no=5, step_name="payment",
|
||||
action=lambda: site_interact.pay(task, site_order_id),
|
||||
action=lambda: self._site.pay(task, site_order_id),
|
||||
state=OrderState.AWAITING_PAYMENT,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=summary.payable_yen,
|
||||
@@ -294,7 +297,7 @@ class WorkerRunner:
|
||||
|
||||
# 步骤 6:付款后监控(非阻塞,常驻轮询;当前未实现)
|
||||
try:
|
||||
await site_interact.monitor(task, site_order_id)
|
||||
await self._site.monitor(task, site_order_id)
|
||||
except NotImplementedError:
|
||||
logger.info("付款后监控未实现,跳过:task_id=%s", task.task_id)
|
||||
|
||||
|
||||
@@ -1,28 +1,58 @@
|
||||
"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
||||
|
||||
**全部未实现**。规格 docs/order-gateway.md §10 明确说明:加购 → 下单 → 付款的
|
||||
实际站点交互没有实测过,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||
实测进度(详见 project://jp-rakuten/checkout-flow-probe-findings):
|
||||
- add_to_cart、verify_cart 已实测可用(Playwright + storage_state 走 SP 通道)
|
||||
- enter_checkout 及之后**未实现**:Rakuten 对 checkout 这类敏感操作要求
|
||||
session upgrade(重输密码),即使当前 SSO 已登录。这是自动 checkout 的硬墙,
|
||||
比 3DS 还前置。本层留接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||
|
||||
签名刻意保留:runner 把这一层当接口缝用,等真实账号 + 真实站点把每个动作
|
||||
实现填进来后,主循环逻辑不需要改动。
|
||||
|
||||
实现方需要补齐的实测点(规格 §10):
|
||||
1. 自动付款是否触发 3D Secure / 短信验证。触发则这条路走不通,付款环节改为
|
||||
「下单到 awaiting_payment + 上报 needs_human 交人工」。
|
||||
2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。
|
||||
3. 订单列表页能否按商品 + 时间窗口可靠地反查「这单下没下」。
|
||||
4. ラクマ 侧尚无加购契约(README 已注明未实现)。
|
||||
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
|
||||
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
|
||||
的 BrowserContext + APIRequestContext(共享 cookie,绕过 CORS)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.shared.errors import (
|
||||
CartOperationError,
|
||||
InvalidRequestError,
|
||||
NotLoggedInError,
|
||||
)
|
||||
from app.trading.core import auth_site
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
from app.trading.services.auth_session import AuthSession
|
||||
|
||||
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 逐商品不同,但路径固定)
|
||||
_BASKET_PATH = "/rms/mall/bss/cartadd/set"
|
||||
|
||||
# 购物车页(SP)与 cart 数量 JSONP API(探针实测)
|
||||
_CART_PAGE = auth_site.RAKUTEN_CART_URL
|
||||
_CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/"
|
||||
|
||||
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
|
||||
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
|
||||
|
||||
_NOT_IMPLEMENTED_MSG = (
|
||||
"站点交互未实现:见 docs/order-gateway.md §10。"
|
||||
"未实测前不写猜测的提交逻辑——需要真实账号 + 真实站点把这一步实现填进来。"
|
||||
"站点交互未实现:见 docs/order-gateway.md §10 与 "
|
||||
"project://jp-rakuten/checkout-flow-probe-findings。"
|
||||
"session upgrade 是 checkout 阶段的硬墙,未实测前不写猜测的提交逻辑。"
|
||||
)
|
||||
|
||||
|
||||
@@ -38,36 +68,392 @@ class CheckoutSummary:
|
||||
pay_deadline: str | None = None
|
||||
|
||||
|
||||
async def add_to_cart(task: LeaseTask) -> None:
|
||||
"""加购。需要 auth_session 的登录 cookie + intent 里的 purchase 标识"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
class SiteInteractor:
|
||||
"""Rakuten 站点交互器:持有 Playwright 浏览器 context,复用账号 cookie
|
||||
|
||||
生命周期:
|
||||
- start() 在 worker 启动时调用一次:启动 Playwright + 创建带 cookie 的 context
|
||||
- add_to_cart / verify_cart 在每个任务里调用
|
||||
- close() 在 worker 关闭时调用
|
||||
|
||||
浏览器 context 复用同一份登录态,所有任务串行(worker 主循环本就串行),
|
||||
不需要为每个任务开新 context——开销大且 cookie 状态会乱。
|
||||
"""
|
||||
|
||||
# 每任务保留的临时状态: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]]
|
||||
|
||||
def __init__(self, *, auth_session: "AuthSession", settings: "Settings"):
|
||||
self._auth_session = auth_session
|
||||
self._settings = settings
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._context = None # playwright BrowserContext
|
||||
self._per_task_state = {}
|
||||
|
||||
# ---- 生命周期 ----
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 Playwright 与带 cookie 的浏览器 context
|
||||
|
||||
登录态文件不存在时同样启动(context 没 cookie),后续 add_to_cart 会
|
||||
在 require_logged_in 里报错。这样保持启动路径一致。
|
||||
"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
state_path = self._settings.auth_state_path / auth_site.profile("rakuten").state_filename
|
||||
storage_state = str(state_path) if state_path.exists() else None
|
||||
if storage_state is None:
|
||||
logger.warning(
|
||||
"登录态文件不存在:site_interactor 以无 cookie 状态启动,"
|
||||
"加购请求会被站点拒认"
|
||||
)
|
||||
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=True,
|
||||
channel=self._settings.browser_channel or None,
|
||||
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
self._context = await self._browser.new_context(
|
||||
storage_state=storage_state,
|
||||
user_agent=auth_site.RAKUTEN_USER_AGENT,
|
||||
locale="ja-JP",
|
||||
timezone_id="Asia/Tokyo",
|
||||
viewport={"width": 390, "height": 844},
|
||||
is_mobile=True,
|
||||
has_touch=True,
|
||||
)
|
||||
logger.info("SiteInteractor 已就绪:storage_state=%s", storage_state or "(none)")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 context、browser、playwright,吞掉单个 close 异常"""
|
||||
for resource, name in (
|
||||
(self._context, "context"),
|
||||
(self._browser, "browser"),
|
||||
(self._playwright, "playwright"),
|
||||
):
|
||||
if resource is None:
|
||||
continue
|
||||
try:
|
||||
closer = resource.close() if name != "playwright" else resource.stop()
|
||||
await closer
|
||||
except Exception:
|
||||
logger.debug("关闭 %s 失败", name, exc_info=True)
|
||||
self._context = None
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
|
||||
# ---- 已实现:add_to_cart / verify_cart ----
|
||||
|
||||
async def add_to_cart(self, task: LeaseTask) -> None:
|
||||
"""加购:打开商品页 → 抽 __INITIAL_STATE__.purchase → POST basketDomain
|
||||
|
||||
调用方需在 task.intent 提供:
|
||||
- item_url: 商品详情页 URL(必填)
|
||||
- quantity: 数量,默认 1
|
||||
- variant_id: 多规格商品的 variant_id;不传则从 sku.variants[] 自动选第一个非售罄
|
||||
- choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验)
|
||||
|
||||
Raises:
|
||||
InvalidRequestError: intent.item_url 缺失
|
||||
NotLoggedInError: 登录态失效
|
||||
CartOperationError: 商品页打不开、state 解析失败、商品不可购买、加购返回错误页
|
||||
"""
|
||||
intent = task.intent or {}
|
||||
item_url = intent.get("item_url")
|
||||
if not item_url:
|
||||
raise InvalidRequestError("intent.item_url 必填")
|
||||
quantity = int(intent.get("quantity") or 1)
|
||||
if quantity <= 0:
|
||||
raise InvalidRequestError(f"intent.quantity 必须为正整数,收到 {quantity}")
|
||||
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
try:
|
||||
await page.goto(item_url, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_function(
|
||||
"() => window.__INITIAL_STATE__ && window.__INITIAL_STATE__.purchase",
|
||||
timeout=10_000,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise CartOperationError(
|
||||
f"打开商品页失败或反爬被触发:{type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
|
||||
html = await page.content()
|
||||
state = _parse_initial_state(html)
|
||||
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"],
|
||||
}
|
||||
|
||||
if fields["purchase_condition"] != "enabled":
|
||||
raise CartOperationError(
|
||||
f"商品不可购买:purchaseCondition={fields['purchase_condition']}"
|
||||
)
|
||||
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"):
|
||||
raise CartOperationError(
|
||||
"多规格商品未选 variant,且 sku.variants 全部售罄或为空"
|
||||
)
|
||||
# 必填选项要求填了 choice
|
||||
if fields["has_required_options"] and not fields["form_fields"].get(fields["options_field"]):
|
||||
raise CartOperationError(
|
||||
"商品有必填选项但未提供 choice,且选项无候选值"
|
||||
)
|
||||
|
||||
payload = dict(fields["form_fields"])
|
||||
payload[fields["quantity_field"]] = str(quantity)
|
||||
|
||||
logger.info(
|
||||
"加购请求:task_id=%s basket=%s payload=%s",
|
||||
task.task_id, fields["basket_domain"], payload,
|
||||
)
|
||||
|
||||
resp = await self._context.request.post(
|
||||
fields["basket_domain"],
|
||||
form=payload,
|
||||
max_redirects=5,
|
||||
headers={
|
||||
"Referer": item_url,
|
||||
"Origin": "https://item.rakuten.co.jp",
|
||||
},
|
||||
)
|
||||
final_url = str(resp.url)
|
||||
if "/error" in final_url:
|
||||
body = await resp.text()
|
||||
msg = _extract_error_message(body) or f"错误页 {final_url}"
|
||||
raise CartOperationError(f"加购失败:{msg}")
|
||||
|
||||
# 成功标志:URL 跳到 cart 且带 added_item 参数(探针实测的落地)
|
||||
if "cart" not in final_url:
|
||||
body = await resp.text()
|
||||
raise CartOperationError(
|
||||
f"加购响应异常:final_url={final_url} body_head={body[:200]!r}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"加购成功:task_id=%s item_id=%s final=%s",
|
||||
task.task_id, fields["form_fields"].get("item_id"), final_url,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
async def verify_cart(self, task: LeaseTask) -> None:
|
||||
"""校验购物车里有没有刚加的商品
|
||||
|
||||
策略(探针实测最稳的两步):
|
||||
1. 打 cart count JSONP API:status=100 且 count>=1 才算「购物车非空」
|
||||
2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id
|
||||
|
||||
登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。
|
||||
"""
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
|
||||
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 未记录"
|
||||
)
|
||||
|
||||
# 1. cart count API
|
||||
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)
|
||||
if not status_match:
|
||||
raise CartOperationError(
|
||||
f"cart count 响应无法解析:{count_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 = 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)
|
||||
|
||||
# 2. 渲染 cart 页确认 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,
|
||||
)
|
||||
|
||||
html = await page.content()
|
||||
# 旧 marker 出现一定是登录失效;新 SPA 不渲染 marker,所以这判据是单边的
|
||||
if _LEGACY_LOGGED_OUT_MARKER in html:
|
||||
raise NotLoggedInError(
|
||||
site="rakuten",
|
||||
detail="购物车页出现旧版未登录 marker",
|
||||
)
|
||||
if str(item_id) not in html:
|
||||
raise CartOperationError(
|
||||
f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)"
|
||||
)
|
||||
logger.info(
|
||||
"cart 校验通过:task_id=%s item_id=%s in cart HTML",
|
||||
task.task_id, item_id,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# ---- 未实现:保留 NotImplementedError ----
|
||||
|
||||
async def enter_checkout(self, task: LeaseTask) -> str:
|
||||
"""进入下单确认页。**未实现**:session upgrade 是硬墙"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
async def parse_checkout(self, html: str) -> CheckoutSummary:
|
||||
"""从下单确认页解析应付金额、付款期限、订单号。**未实现**"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
async def submit_order(self, task: LeaseTask) -> str:
|
||||
"""提交下单。**未实现**"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
async def pay(self, task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款。**未实现**"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
async def monitor(self, task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款后监控。**未实现**"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def verify_cart(task: LeaseTask) -> None:
|
||||
"""校验购物车:加购后购物车里有没有这件商品"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
# ---- 模块级辅助函数(纯函数,便于单测)----
|
||||
|
||||
|
||||
async def enter_checkout(task: LeaseTask) -> str:
|
||||
"""进入下单确认页,返回页面 HTML(供解析金额守卫所需字段)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
def _parse_initial_state(html: str) -> dict | None:
|
||||
"""从商品页 HTML 抽 window.__INITIAL_STATE__ 并解析为 dict"""
|
||||
m = re.search(
|
||||
r"window\.__INITIAL_STATE__\s*=\s*(.+?);\s*window\.",
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def parse_checkout(html: str) -> CheckoutSummary:
|
||||
"""从下单确认页解析实际应付金额、付款期限、站点订单号"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
def _extract_purchase_fields(state: dict, *, intent_override: dict | None) -> dict:
|
||||
"""从 __INITIAL_STATE__ 抽加购所需字段(与 scraping/parsers/item.py 同源逻辑)
|
||||
|
||||
intent_override 允许调用方提供 variant_id / choice 覆盖自动选择。
|
||||
"""
|
||||
intent_override = intent_override or {}
|
||||
purchase = state.get("purchase") or {}
|
||||
sell_type = (purchase.get("sellType") or {}).get("normalPurchase") or {}
|
||||
raw_sku = purchase.get("sku") or {}
|
||||
item = state.get("item") or {}
|
||||
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)
|
||||
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,
|
||||
}
|
||||
|
||||
# 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"]:
|
||||
for v in raw_sku.get("variants") or []:
|
||||
if not v.get("isSoldOut"):
|
||||
chosen_variant = v
|
||||
break
|
||||
if chosen_variant is None and (raw_sku.get("variants") or []):
|
||||
chosen_variant = raw_sku["variants"][0]
|
||||
if chosen_variant:
|
||||
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"):
|
||||
form_fields["variant_id"] = str(item.get("variantId"))
|
||||
|
||||
# 必填选项:调用方覆盖 > 自动填第一个候选值
|
||||
options = information.get("options") or []
|
||||
required_options = [o for o in options if o.get("isRequired")]
|
||||
has_required = bool(required_options)
|
||||
if intent_override.get("choice"):
|
||||
# 调用方给的可能是 list 或 str
|
||||
c = intent_override["choice"]
|
||||
form_fields["choice"] = ",".join(c) if isinstance(c, list) else str(c)
|
||||
elif has_required:
|
||||
pairs: list[str] = []
|
||||
for opt in required_options:
|
||||
values = opt.get("values") or []
|
||||
if values:
|
||||
pairs.append(f"{opt.get('name')}:{values[0].get('name')}")
|
||||
if pairs:
|
||||
form_fields["choice"] = ",".join(pairs)
|
||||
|
||||
return {
|
||||
"basket_domain": basket_domain,
|
||||
"form_fields": form_fields,
|
||||
"quantity_field": "units",
|
||||
"variant_field": "variant_id",
|
||||
"options_field": "choice" if options else "",
|
||||
"options": options,
|
||||
"has_required_options": has_required,
|
||||
"inventory_flag": inventory_flag,
|
||||
"purchase_condition": sell_type.get("purchaseCondition"),
|
||||
"min_price": sell_type.get("minPrice"),
|
||||
"shop_name": shop.get("shopName"),
|
||||
"item_name": item.get("itemName"),
|
||||
}
|
||||
|
||||
|
||||
async def submit_order(task: LeaseTask) -> str:
|
||||
"""提交下单。返回站点订单号(site_order_id)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def pay(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款。检测到 3DS / 短信验证等人工环节时抛 OrderGuardError"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def monitor(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款后监控:常驻轮询订单状态与付款期限,状态变化时继续 report"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
def _extract_error_message(body: str) -> str:
|
||||
"""从 Rakuten 错误页 HTML 抽可读的提示文案"""
|
||||
msgs: list[str] = []
|
||||
for m in re.findall(r">([^<>]{20,200})<", body):
|
||||
s = m.strip()
|
||||
# 过滤 CSS/JS 标识与版权之类
|
||||
if not s or any(c in s for c in ["(", ")", "=", "{", "}", "."]):
|
||||
continue
|
||||
if "Rakuten Group" in s or "SSL" in s:
|
||||
continue
|
||||
msgs.append(s)
|
||||
# 取前两条拼起来(实测错误页一般 1~2 条核心提示)
|
||||
return " / ".join(msgs[:2])
|
||||
|
||||
Reference in New Issue
Block a user