diff --git a/README.md b/README.md index 8ba2e39..92883a1 100644 --- a/README.md +++ b/README.md @@ -607,6 +607,7 @@ docker compose --profile gateway up -d - `POST /api/cart/add` — 加购,入参 `{item_url, quantity?, variant_id?, choice?}` - `POST /api/cart/status` — 调 cart count API,返回购物车商品件数 + (空车是正常结果:`count=0, raw_status="101"`;获取失败才报错 5002) - `POST /api/cart/clear` — 清空购物车(UI 点击 `button[aria-label="削除"]`) - `POST /api/cart/remove` — 删除指定 `item_id` diff --git a/app/trading/api/routes/cart.py b/app/trading/api/routes/cart.py index bbbc786..ecb7ad0 100644 --- a/app/trading/api/routes/cart.py +++ b/app/trading/api/routes/cart.py @@ -88,6 +88,7 @@ async def cart_status( """查询购物车状态(轻量) 只调 cart count JSONP API,不渲染整页。返回登录态、商品件数与站点状态码。 + 空车是正常结果:count=0 且 raw_status="101";获取失败走错误码 5002。 """ site = _require_site(container) result = await site.cart_status() # type: ignore[union-attr] diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index f684cde..d627ae2 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -6,6 +6,10 @@ data/evidence/checkout-research-20260811/NOTES.md): - clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现, 2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针 记录的「削除」,selector 已改前缀匹配;modal 是否存在仍未实测确认 +- cart count JSONP API 契约 2026-08-16 真账号实测(scripts/probe_cart_count.py + + .probe/cart_count/):status=101 + count="" 是**空车**(合法响应,记 count=0), + status=300(referer/sid 被拒)才是获取失败——此前一律按失败处理, + 空车时 clear_cart 末尾校验拿 -1、开单前清理被误判失败 - enter_checkout 已实现到「进入 session upgrade 密码页并尝试自动复核」:购物车页 点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」 按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效, @@ -133,6 +137,11 @@ _BASKET_PATH = "/rms/mall/bss/cartadd/set" _CART_PAGE = auth_site.RAKUTEN_CART_URL _CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/" +# cart count API 的「空车」状态码(2026-08-16 真账号实测, +# scripts/probe_cart_count.py):status=101 + message "value not found." + +# count="" 表示购物车为空,是合法响应而非获取失败;status=300 之类才是被拒 +_CART_EMPTY_STATUS = "101" + # 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据) _LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER @@ -942,6 +951,7 @@ class SiteInteractor: 策略(探针实测最稳的两步): 1. 打 cart count JSONP API:status=100 且 count>=1 才算「购物车非空」 + (status=101 是空车,count 记 0,见 _query_cart_count) 2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id Returns: @@ -974,7 +984,8 @@ class SiteInteractor: 返回 {logged_in, count, raw_status}。 count 是站点返回的购物车里商品总件数(含数量,非 SKU 数)。 - raw_status 是站点的状态码字符串,"100" 表示正常。 + raw_status 是站点的状态码字符串:"100" 非空、"101" 空车(count=0); + 其余 status 属「获取失败」,抛 CartOperationError 而不是返回。 """ async with self._lock: await self._auth_session.require_logged_in("rakuten") @@ -999,7 +1010,8 @@ class SiteInteractor: - 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整 - Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位 - 返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败。 + 返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败 + (空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。 """ async with self._lock: await self._auth_session.require_logged_in("rakuten") @@ -1125,8 +1137,14 @@ class SiteInteractor: async def _query_cart_count(self) -> tuple[str, int]: """调 cart count JSONP API,返回 (raw_status, count) - 解析失败、status 非 100 都抛 CartOperationError——这是站点在告诉我们 - 「请求被拒了」,常见原因是 Referer 错或 cookie 失效。 + 站点契约(2026-08-16 真账号实测,scripts/probe_cart_count.py + + .probe/cart_count/)——必须区分「车是空的」与「获取失败」: + - status "100":购物车非空,count 是数字字符串 + - status "101"(message "value not found."):**购物车为空**,count 是 + 空串——这是合法结果,返回 ("101", 0),不能当失败 + - 其余 status(实测如 "300":referer 缺失 / sid 不可用)或响应无法解析, + 才是「获取失败」,抛 CartOperationError——常见原因是 Referer 错或 + cookie 失效 """ resp = await self._context.request.get( _CART_COUNT_API + "?sid=1010", @@ -1137,6 +1155,8 @@ class SiteInteractor: if not status_match: raise CartOperationError(f"cart count 响应无法解析:{body[:200]!r}") raw_status = status_match.group(1) + if raw_status == _CART_EMPTY_STATUS: + return raw_status, 0 if raw_status != "100": raise CartOperationError( f"cart count API 异常:status={raw_status} body={body[:200]!r}" diff --git a/scripts/probe_cart_count.py b/scripts/probe_cart_count.py new file mode 100644 index 0000000..41173cd --- /dev/null +++ b/scripts/probe_cart_count.py @@ -0,0 +1,126 @@ +"""cart count API 探针:实测「空购物车」与「获取失败」两种响应形态 + +背景:_query_cart_count 目前只认 status=="100",其余一律 CartOperationError。 +需要真账号确认:购物车为空时 cart-api.step.rakuten.co.jp 到底返回什么—— +如果空车响应与「请求被拒」长的不一样却被同一错误吞掉,就无法区分 +「车是空的」与「获取失败」。 + +只读探测,不做任何加购/删除操作。 + +用法: + .venv/Scripts/python.exe scripts/probe_cart_count.py + .venv/Scripts/python.exe scripts/probe_cart_count.py --headful +""" +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.shared.config import get_settings # noqa: E402 +from app.trading.core import auth_site # noqa: E402 + +PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "cart_count" +PROBE_DIR.mkdir(parents=True, exist_ok=True) + +CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/" +CART_PAGE = auth_site.RAKUTEN_CART_URL + + +def save(name: str, content: str) -> Path: + path = PROBE_DIR / name + path.write_text(content, encoding="utf-8") + print(f" saved -> {path} ({len(content)} bytes)") + return path + + +async def call_count_api(context, *, referer: str | None, tag: str) -> None: + headers = {"Referer": referer} if referer else {} + resp = await context.request.get(CART_COUNT_API + "?sid=1010", headers=headers) + body = await resp.text() + print(f" [{tag}] http_status={resp.status} body={body!r}") + save(f"{tag}.txt", f"http_status={resp.status}\n{body}") + + +async def run(*, headful: bool) -> int: + from playwright.async_api import async_playwright + + settings = get_settings() + state_path = settings.auth_state_path / "rakuten_state.json" + if not state_path.exists(): + print(f"找不到登录态文件:{state_path}") + return 1 + + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=not headful, + channel=settings.browser_channel or None, + proxy=settings.playwright_proxy, + args=["--no-first-run", "--disable-blink-features=AutomationControlled"], + ) + context = await browser.new_context( + storage_state=state_path, + 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, + ) + + print("=== 1. 带正确 Referer 调 cart count API(生产路径)===") + await call_count_api(context, referer=CART_PAGE, tag="10-with-referer") + + print("=== 2. 不带 Referer 调 cart count API(观测「被拒」形态)===") + await call_count_api(context, referer=None, tag="11-no-referer") + + print("=== 3. 错误 sid 调 cart count API(另一种异常入参)===") + resp = await context.request.get( + CART_COUNT_API + "?sid=999999", headers={"Referer": CART_PAGE} + ) + body = await resp.text() + print(f" [bad-sid] http_status={resp.status} body={body!r}") + save("12-bad-sid.txt", f"http_status={resp.status}\n{body}") + + print("=== 4. 打开购物车页,看当前车状态(只读)===") + page = await context.new_page() + await page.goto(CART_PAGE, wait_until="domcontentloaded", timeout=30_000) + await page.wait_for_timeout(5000) + state = await page.evaluate( + """() => { + const s = window.__INITIAL_STATE__; + if (!s || !s.cart) return {has_state: !!s, keys: s ? Object.keys(s) : []}; + return { + has_state: true, + cart_keys: Object.keys(s.cart), + shopUrlList_len: Array.isArray(s.cart.shopUrlList) ? s.cart.shopUrlList.length : null, + }; + }""" + ) + print(f" __INITIAL_STATE__.cart: {state}") + html = await page.content() + save("20-cart-page.html", html) + delete_btns = await page.locator('button[aria-label^="削除"]').count() + print(f" 削除按钮数={delete_btns} final_url={page.url}") + + print("=== 5. 车页打开后再调一次 count API(贴近 clear_cart 末尾场景)===") + await call_count_api(context, referer=CART_PAGE, tag="21-after-cart-page") + + await browser.close() + + print(f"\n探针输出目录:{PROBE_DIR}") + return 0 + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--headful", action="store_true") + args = parser.parse_args() + return await run(headful=args.headful) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/scripts/verify_cart_empty.py b/scripts/verify_cart_empty.py new file mode 100644 index 0000000..f050028 --- /dev/null +++ b/scripts/verify_cart_empty.py @@ -0,0 +1,51 @@ +"""真账号验证:空车场景下 cart_status / clear_cart 不再被误判为获取失败 + +修复前行为(2026-08-16 探针实测):空车时 cart count API 返回 status=101, +_query_cart_count 一律当失败抛 CartOperationError → cart_status 报错 5002、 +clear_cart 末尾校验拿 cart_count=-1(runner 据此误判「开单前清理未清空」)。 +修复后:status=101 识别为合法空车,count=0。 + +用法: + .venv/Scripts/python.exe scripts/verify_cart_empty.py +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.shared.config import get_settings # noqa: E402 +from app.trading.services.auth_session import AuthSession # noqa: E402 +from app.trading.worker.site_interact import SiteInteractor # noqa: E402 + + +async def run() -> int: + settings = get_settings() + auth = AuthSession(settings) + await auth.start() + site = SiteInteractor(auth_session=auth, settings=settings) + await site.start() + try: + status = await site.cart_status() + print(f"cart_status -> {status}") + assert status["logged_in"] is True + assert status["count"] == 0, f"预期空车 count=0,实际 {status['count']}" + assert status["raw_status"] == "101", f"预期 raw_status=101,实际 {status['raw_status']}" + + cleared = await site.clear_cart() + print(f"clear_cart -> {cleared}") + assert cleared["cart_count"] == 0, ( + f"空车 clear 后 cart_count 应为 0(修复前是 -1),实际 {cleared['cart_count']}" + ) + + print("\n验证通过:空车按正常结果返回(count=0 / raw_status=101),未误判为获取失败") + return 0 + finally: + await site.close() + await auth.close() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(run())) diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index 2494b24..d843020 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -1138,4 +1138,80 @@ async def test_dump_debug_snapshot_swallows_its_own_errors(tmp_path): assert list((tmp_path / "t1").glob("debug-*")) == [] +# ---- _query_cart_count:空车(status=101)与获取失败(其他 status)必须区分 ---- +# +# 响应 body 均为 2026-08-16 真账号实测原样(scripts/probe_cart_count.py + +# .probe/cart_count/):空车是合法响应,当失败抛错会让 clear_cart 末尾校验拿 -1、 +# 开单前清理被 runner 误判为「未清空」。 + + +class _FakeCartCountRequest: + """APIRequestContext 替身:回固定 body,记录入参""" + + def __init__(self, body: str): + self._body = body + self.calls: list[tuple[str, dict[str, str]]] = [] + + async def get(self, url: str, headers: dict[str, str] | None = None) -> Any: + self.calls.append((url, headers or {})) + + class _Resp: + def __init__(self, text: str): + self._text = text + + async def text(self) -> str: + return self._text + + return _Resp(self._body) + + +class _FakeCartCountContext: + def __init__(self, body: str): + self.request = _FakeCartCountRequest(body) + + +def _build_cart_count_site(body: str) -> SiteInteractor: + site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type] + site._context = _FakeCartCountContext(body) # type: ignore[assignment] + return site + + +async def test_query_cart_count_non_empty_returns_count(): + site = _build_cart_count_site( + 'callBack({"status":"100","message":"success.","count":"3"})' + ) + + raw_status, count = await site._query_cart_count() + + assert (raw_status, count) == ("100", 3) + + +async def test_query_cart_count_empty_cart_is_not_a_failure(): + """status=101 + count="" 是空车(合法响应),返回 ("101", 0) 而不是抛错""" + site = _build_cart_count_site( + 'callBack({"status":"101","message":"value not found.","count":""})' + ) + + raw_status, count = await site._query_cart_count() + + assert (raw_status, count) == ("101", 0) + + +async def test_query_cart_count_rejected_request_raises(): + """status=300(referer/sid 被站点拒绝)才是「获取失败」""" + site = _build_cart_count_site( + 'callBack({"status":"300","message":"referer is empty value.","count":""})' + ) + + with pytest.raises(CartOperationError): + await site._query_cart_count() + + +async def test_query_cart_count_unparseable_body_raises(): + site = _build_cart_count_site("not jsonp") + + with pytest.raises(CartOperationError): + await site._query_cart_count() + + # ---- helper ----