"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控 实测进度(详见 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 还前置。本层留接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。 **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 与 " "project://jp-rakuten/checkout-flow-probe-findings。" "session upgrade 是 checkout 阶段的硬墙,未实测前不写猜测的提交逻辑。" ) @dataclass(slots=True) class CheckoutSummary: """下单确认页解析结果(待实测确认字段位置) payable_yen 用于金额守卫;其他字段在实测确认后补全。 """ payable_yen: int site_order_id: str | None = None pay_deadline: str | None = None 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) # ---- 模块级辅助函数(纯函数,便于单测)---- 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 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"), } 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])