From 3dc2aeb3a23110f8e060a0b3af3d8ebea61dcc19 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 14 Aug 2026 14:28:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=AE=98=E6=96=B9=E5=AD=90=E7=AB=99?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E9=93=BE=E8=B7=AF=E6=8E=A2=E9=92=88=EF=BC=9A?= =?UTF-8?q?=E8=B5=B0=E5=88=B0=E7=A1=AE=E8=AE=A4=E9=A1=B5=E4=B8=BA=E6=AD=A2?= =?UTF-8?q?=EF=BC=8C=E7=BB=9D=E4=B8=8D=E6=8F=90=E4=BA=A4=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主站 ichiba 的「加购→确认页→提交」已经用真账号跑通,但 books / brandavenue / biccamera 三个官方子站只有加购契约的静态记录,checkout 这段的选择器与流程从没在 子站上真实走过。本脚本把这段空白补上:每个子站自动挑一件便宜的在售商品,用真账号 走到**下单确认页为止**,逐步落 .probe/subsite// 快照与 report-.json。 两道闸保证不产生订单:_FORBIDDEN_BUTTON_TEXTS 黑名单(候选按钮文案命中就停手), 以及一旦出现确认页特征(「注文を確定する」等)立即停并落证据。会产生的真实副作用 已写在脚本文档里:往真实账号购物车加商品(结束时尝试清空,清不掉的在报告里点名)、 可能触发 session upgrade(走与生产同一条自动复核路径)、站点留下未提交的订单草稿。 探针脚本,不进生产链路,也不被任何测试导入。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/probe_subsite_checkout.py | 849 ++++++++++++++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 scripts/probe_subsite_checkout.py diff --git a/scripts/probe_subsite_checkout.py b/scripts/probe_subsite_checkout.py new file mode 100644 index 0000000..b1b1301 --- /dev/null +++ b/scripts/probe_subsite_checkout.py @@ -0,0 +1,849 @@ +"""官方子站(books / brandavenue / biccamera)加购→下单链路探针 + +背景:主站 ichiba 的「加购→确认页→提交」已经用真账号跑通(见 +project://jp-rakuten/trading-split),但 [[cart_contract]] 记录的三个官方子站 +(楽天ブックス / Rakuten Fashion / ビックカメラ楽天市場店)只有**加购契约**的 +静态记录,checkout 这一段的选择器与流程从没在子站上真实走过。本探针把这段空白 +补上:每个子站自动挑一件便宜的在售商品,用真账号走到**下单确认页为止**。 + +**绝不提交订单**:全流程有两道闸—— +1. `_FORBIDDEN_BUTTON_TEXTS` 黑名单,任何候选按钮文案命中就不点、直接停; +2. 一旦页面出现确认页特征(「注文を確定する」等),立即停手并落证据。 +所以本脚本不会产生订单,也不会扣款。它**会**产生的真实副作用: +- 往真实账号的购物车里加商品(结束时尝试清空,清不掉的会在报告里点名); +- 可能触发 session upgrade(用 account.yaml 里的密码自动复核,与生产同一条路径); +- 在站点上留下订单草稿(未提交,站点侧一般随会话失效,不影响账号)。 + +用法: + .venv/Scripts/python.exe scripts/probe_subsite_checkout.py # 三个子站依次跑 + .venv/Scripts/python.exe scripts/probe_subsite_checkout.py --site books + .venv/Scripts/python.exe scripts/probe_subsite_checkout.py --site biccamera \ + --item-url https://item.rakuten.co.jp/biccamera/4904530012150/ + +输出:.probe/subsite//NN-*.html|png 逐步快照 + report-.json 结构化结论。 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import traceback +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +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 ( # noqa: E402 + SiteInteractor, + _parse_checkout_summary, +) + +PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "subsite" + + +@dataclass +class SubsiteSpec: + """一个官方子站的探测配置""" + + name: str + host: str # 商品页最终落地的域名,用来确认确实跳出了 item.rakuten.co.jp + sid: str # 市场侧 shop_id,用于用搜索页自动挑商品 + max_price: int # 自动挑商品的价格上限(挑不到就抬价重试) + # item.rakuten.co.jp 的 302 有时只把人送到子站首页而丢掉商品(brandavenue 实测), + # 这时用商品编号直接拼子站 URL 兜底。upper_code=True 表示子站 URL 用大写编号。 + direct_url: str = "" + upper_code: bool = False + + +SUBSITES: dict[str, SubsiteSpec] = { + "books": SubsiteSpec( + name="books", + host="books.rakuten.co.jp", + sid="213310", + max_price=1200, + direct_url="https://books.rakuten.co.jp/rb/{code}/", + ), + "brandavenue": SubsiteSpec( + name="brandavenue", + host="brandavenue.rakuten.co.jp", + sid="279405", + max_price=6000, + direct_url="https://brandavenue.rakuten.co.jp/item/{code}/", + upper_code=True, + ), + "biccamera": SubsiteSpec( + name="biccamera", + host="biccamera.rakuten.co.jp", + sid="269553", + max_price=1200, + direct_url="https://biccamera.rakuten.co.jp/item/{code}/", + ), +} + +_SEARCH_URL = "https://search.rakuten.co.jp/search/mall/-/?sid={sid}&max={max_price}&min=100" + +# ---- 加购按钮候选(子站各自的文案未知,列一串按顺序试,命中哪条记在报告里)---- +_ADD_CART_TEXTS = ( + "カートに入れる", + "カートに追加", + "かごに追加", + "買い物かごに入れる", + "カートへ入れる", + "レジに進む", +) + +# ---- 「继续下一步」候选按钮文案:从加购落地页一路点到确认页 ---- +_NEXT_STEP_TEXTS = ( + "購入手続き", + "ご購入手続き", + "購入手続きへ", + "レジに進む", + "注文手続きへ", + "ご注文手続き", + "次へ", + "進む", +) + +# ---- 绝对不点的按钮:命中即停手。宁可少走一步,也不能提交订单 ---- +_FORBIDDEN_BUTTON_TEXTS = ( + "注文を確定", + "ご注文を確定", + "この内容で注文", + "購入を確定", + "注文する", + "決済する", + "支払う", + "購入する", +) + +# ---- 已经走到下单确认页的判据 ---- +# **不能按 HTML 全文匹配文案**:楽天ブックス 的购物车页正文里就写着「注文画面の +# 「注文を確定する」ボタンを押した時点で…」这样的注意事项,全文匹配会把购物车页 +# 误判成确认页(2026-08-14 books 探测实测踩到)。改成「页面上真实存在一个可见的 +# 确认按钮」或 URL 命中确认页路径两个判据。 +_CONFIRM_PAGE_URL_MARKERS = ("order-confirmation", "/step/confirm", "ConfirmOrder") + +# 确认页判定专用的按钮文案:比 _FORBIDDEN_BUTTON_TEXTS 窄。黑名单里的「購入する」 +# 会命中商品页上的「Rakuten Fashionアプリで購入する」(装 App 引导),拿它判定 +# 确认页会把商品页误判成确认页(2026-08-14 brandavenue 实测)。判定要窄、 +# 点击闸门要宽,两者不能共用一份列表。 +_CONFIRM_BUTTON_TEXTS = ("注文を確定", "ご注文を確定", "この内容で注文", "購入を確定") + +# session upgrade / 中间步骤判据:与生产 site_interact 保持一致的口径 +_SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password") + +# 2026-08-14 biccamera 探测新发现的一步:密码复核通过后,SSO 会再要求同意 +# **该子站自己的**利用規約/プライバシーポリシー(URL 还是 session/upgrade, +# 只是 hash 变成 #/agree/service)。这一步是账号级的协议同意,不是下单动作, +# 默认**不自动点**,要显式加 --accept-subsite-terms 才代用户同意。 +_AGREE_STEP_URL_MARKER = "#/agree/" +_AGREE_STEP_TEXT_MARKER = "ご利用いただくサービスに関するご確認" +_PHONE_REGISTRATION_MARKER = "会員情報の追加登録" +# 同一个「会員情報の追加登録」标题下还有另一种变体(2026-08-14 biccamera 实测, +# hash #/profiling/1):必填项是**誕生日 + 性別**,页面明写「登録した情報は、 +# 変更できません」。这类不可撤销的个人信息不能由脚本代填,探针识别到就停手。 +_PROFILE_REGISTRATION_MARKERS = ("誕生日", "性別") +_PROFILE_STEP_URL_MARKER = "#/profiling" +_ADDRESS_STEP_URL_MARKER = "/ship" +_PAYMENT_STEP_URL_MARKER = "/pay" + +_MAX_HOPS = 8 +# 一个子站最多试几个候选商品(多规格商品可能整件都缺货,见 discover_candidates) +_MAX_CANDIDATES = 4 + + +@dataclass +class SiteReport: + """单个子站的探测结论(最终写成 report-.json)""" + + site: str + item_url: str = "" + item_price: int | None = None + item_name: str = "" + landing_host: str = "" + add_cart_contract: dict[str, Any] = field(default_factory=dict) + add_cart_button: str = "" + add_cart_landing_url: str = "" + add_cart_ok: bool = False + cart_signals: dict[str, Any] = field(default_factory=dict) + ichiba_cart_count: int | None = None + hops: list[dict[str, str]] = field(default_factory=list) + reached_confirm: bool = False + confirm_buttons: list[str] = field(default_factory=list) + payable_yen: int | None = None + stopped_reason: str = "" + errors: list[str] = field(default_factory=list) + + +class Probe: + """把一个子站从挑商品走到确认页;每一步都落快照,任何异常只记录不中断整轮""" + + def __init__(self, site: SiteInteractor, spec: SubsiteSpec, *, accept_terms: bool): + self._site = site + self._spec = spec + self._accept_terms = accept_terms + self._dir = PROBE_DIR / spec.name + self._dir.mkdir(parents=True, exist_ok=True) + self._step = 0 + self.report = SiteReport(site=spec.name) + + # ---- 通用工具 ---- + + def log(self, message: str) -> None: + print(f"[{self._spec.name}] {message}", flush=True) + + async def snapshot(self, page, label: str) -> str: + """落一份 HTML + 截图,返回 HTML;截图失败不影响主流程""" + self._step += 1 + stem = f"{self._step:02d}-{label}" + html = await page.content() + (self._dir / f"{stem}.html").write_text(html, encoding="utf-8") + try: + await page.screenshot(path=str(self._dir / f"{stem}.png"), full_page=False) + except Exception as exc: # noqa: BLE001 + self.log(f"截图失败(忽略):{type(exc).__name__}: {exc}") + self.log(f"快照 {stem} url={page.url}") + return html + + async def _visible_texts(self, page) -> list[str]: + """页面上所有可点控件的文案,供报告里留证据(子站文案基本靠这个发现)""" + return await page.evaluate( + """() => Array.from( + document.querySelectorAll('button, a[role="button"], input[type="submit"], div[role="button"]') + ).map(el => (el.innerText || el.value || '').trim().replace(/\\s+/g, ' ')) + .filter(t => t && t.length <= 30).slice(0, 60)""" + ) + + # ---- 步骤 1:挑一件便宜的在售商品 ---- + + async def discover_candidates(self, page) -> list[dict[str, Any]]: + """用市场搜索页按 shop_id 过滤,按价格升序给出候选商品 + + 走浏览器而不是 httpx:search.rakuten.co.jp 对 curl/httpx 直接 503, + 浏览器带完整指纹能正常拿到 __INITIAL_STATE__。 + 返回列表而不是单个:搜索页的 isSoldOut 是**商品级**的,多规格商品可能 + 「商品在售但每个颜色尺码都缺货」(brandavenue 实测第一个候选就是这样), + 调用方要能换下一个候选试。 + """ + url = _SEARCH_URL.format(sid=self._spec.sid, max_price=self._spec.max_price) + await self._goto_with_retry(page, url) + items = await page.evaluate( + """() => { + const s = window.__INITIAL_STATE__; + const list = s && s.state && s.state.data && s.state.data.ichibaSearch + ? s.state.data.ichibaSearch.items : null; + if (!list) return null; + return list.filter(i => !i.isSoldOut) + .map(i => ({price: i.price, url: i.url, name: i.name})); + }""" + ) + if not items: + raise RuntimeError(f"搜索页没拿到商品列表(sid={self._spec.sid})") + items.sort(key=lambda i: i["price"]) + self.log(f"候选 {len(items)} 件,最低 {items[0]['price']}円") + return items + + def record_item(self, candidate: dict[str, Any]) -> None: + self.report.item_url = candidate["url"] + self.report.item_price = candidate["price"] + self.report.item_name = candidate["name"] + self.log(f"试 {candidate['price']}円 {candidate['url']}") + + # ---- 步骤 2:打开商品页,抽这个子站的加购契约 ---- + + async def open_item(self, page, item_url: str) -> None: + await self._goto_with_retry(page, item_url) + await page.wait_for_timeout(3_000) + # 302 落到子站首页(路径只剩 /)时,用商品编号直接拼子站商品页兜底 + if page.url.rstrip("/").endswith(self._spec.host) and self._spec.direct_url: + code = [part for part in item_url.split("?")[0].split("/") if part][-1] + direct = self._spec.direct_url.format( + code=code.upper() if self._spec.upper_code else code + ) + self.log(f"302 只落到子站首页,改用直连商品页:{direct}") + self.report.errors.append( + f"item.rakuten.co.jp 的 302 丢掉了商品(落到 {page.url}),改用 {direct}" + ) + await self._goto_with_retry(page, direct) + await page.wait_for_timeout(3_000) + self.report.landing_host = page.url.split("/")[2] if "//" in page.url else "" + await self.snapshot(page, "item-page") + if self._spec.host not in page.url: + self.report.errors.append( + f"商品页没有落到 {self._spec.host},实际 url={page.url}" + ) + self.report.add_cart_contract = await self._extract_contract(page) + self.log(f"加购契约:{json.dumps(self.report.add_cart_contract, ensure_ascii=False)[:400]}") + + async def _goto_with_retry(self, page, url: str, attempts: int = 3) -> None: + """item.rakuten.co.jp → 子站的 302 跳转偶发 ERR_HTTP2_PROTOCOL_ERROR + + 2026-08-14 brandavenue 探测实测:同一个 URL 第一次 goto 直接 HTTP2 协议 + 报错,重试就过。这是站点/网络侧的偶发问题,不是选择器或登录态问题,所以 + 重试而不是当失败——但重试次数用尽仍失败就如实抛出,不静默跳过这个子站。 + """ + last: Exception | None = None + for attempt in range(attempts): + try: + await page.goto(url, wait_until="domcontentloaded", timeout=45_000) + return + except Exception as exc: # noqa: BLE001 + last = exc + self.log(f"goto 第 {attempt + 1} 次失败:{type(exc).__name__},2s 后重试") + await page.wait_for_timeout(2_000) + raise RuntimeError(f"打开 {url} 连续 {attempts} 次失败:{last}") + + async def _extract_contract(self, page) -> dict[str, Any]: + """按子站各自的数据源抽加购要素,核对 [[cart_contract]] 的记录是否还成立""" + if self._spec.name == "biccamera": + return await page.evaluate( + """() => { + const it = window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.item; + if (!it) return {error: 'no __NUXT__.state.item'}; + return { + source: '__NUXT__.state.item', + item_id: it.item_id, item_number: it.item_number, shop_id: it.shop_id, + add_cart_api_url: it.add_cart_api_url, + buying_procedure_url: it.buying_procedure_url, + cart_addable: it.cart_addable, price_with_tax: it.price_with_tax, + }; + }""" + ) + if self._spec.name == "books": + return await page.evaluate( + """() => { + const forms = Array.from(document.querySelectorAll('form')) + .filter(f => (f.action || '').includes('Cart')); + if (!forms.length) return {error: 'no cart form'}; + const f = forms[0]; + const fields = {}; + f.querySelectorAll('input, select').forEach(i => { + if (i.name) fields[i.name] = i.value; + }); + return {source: 'form[action*=Cart]', action: f.action, method: f.method, fields}; + }""" + ) + return await page.evaluate( + """() => { + const s = window.__INITIAL_STATE__; + const p = s && s.itemDetail && s.itemDetail.data && s.itemDetail.data.product; + if (!p) return {error: 'no itemDetail.data.product'}; + const rms = p.rms_info || {}; + return { + source: '__INITIAL_STATE__.itemDetail.data.product', + rms_item_id: rms.rms_item_id, shop_id: (s.env || {}).shop_id, + cart_url_type: rms.cart_url_type ?? p.cart_url_type ?? null, + sku_count: (p.product_sku || []).length, + inventory_count: (rms.inventory_list || []).length, + first_variant: (rms.inventory_list || [])[0] || null, + }; + }""" + ) + + # ---- 步骤 3:点站点自己的加购按钮(不重放表单,看真实交互长什么样)---- + + async def add_to_cart(self, page) -> None: + if self._spec.name == "brandavenue": + await self._add_to_cart_brandavenue(page) + await self._assert_in_cart(page, "かごに追加(SKU モーダル)") + self.report.add_cart_ok = True + return + await self._select_required_options(page) + button = await self._find_clickable(page, _ADD_CART_TEXTS) + if button is None: + texts = await self._visible_texts(page) + self.report.errors.append(f"商品页没找到加购按钮,页面控件文案:{texts}") + await self.snapshot(page, "add-cart-notfound") + raise RuntimeError("找不到加购按钮") + + locator, text = button + self.report.add_cart_button = text + self.log(f"点击加购按钮「{text}」") + await locator.click(timeout=15_000) + await page.wait_for_timeout(4_000) + self.report.add_cart_landing_url = page.url + await self.snapshot(page, "after-add-cart") + + await self._assert_in_cart(page, text) + self.report.add_cart_ok = True + + async def _assert_in_cart(self, page, button_text: str) -> None: + """点了不等于进车:两个独立信号,任一成立就算进车 + + 1. 落地 URL 带 `added_item=`——市场侧加购成功的标准落地(brandavenue 走这条) + 2. cart count JSONP API 的 count > 0(books / biccamera 走这条) + 两个信号都要记进报告:**count API 并不总可靠**——brandavenue 明明已经 + 进车并跳到 sp.cart.step 的 cart 页,count API 仍返回 status=101 + 「value not found」(2026-08-14 实测,末尾 clear_cart 真删掉了 4 件, + 证明东西确实在车里)。只信 count API 会把成功判成失败。 + """ + added_signal = "added_item=" in page.url + count: int | None = None + try: + _, count = await self._site._query_cart_count() # noqa: SLF001 + except Exception as exc: # noqa: BLE001 + self.log(f"cart count 查询失败:{type(exc).__name__}: {exc}") + self.report.cart_signals = { + "landing_url_has_added_item": added_signal, + "cart_count_api": count, + } + if added_signal or (count or 0) > 0: + return + texts = await self._visible_texts(page) + self.report.errors.append( + f"点了「{button_text}」但两个进车信号都不成立,可能还有规格/数量选择层。" + f"当时控件文案:{texts}" + ) + raise RuntimeError("加购未生效(URL 无 added_item 且 cart count 为 0)") + + async def _add_to_cart_brandavenue(self, page) -> None: + """Rakuten Fashion 的加购是三步,不是一步 + + 实测(2026-08-14):页面上的「かごに追加」只负责**弹出**「カラー・サイズを選ぶ」 + 模态层;真正入车的按钮在模态层里(同样叫「かごに追加」,但带 + data-tracking="sku-modal-size-cart")。中间必须先选颜色再选尺码。 + 另外搜索页的 isSoldOut 只表示「整件商品还挂着」,颜色/尺码级缺货要看 + product_sku[].inventory_exist_flg——最便宜的候选商品全尺码「なし」, + 点加购只会弹「再入荷リクエスト」。 + """ + variants = await page.evaluate( + """() => { + const s = window.__INITIAL_STATE__; + const p = s && s.itemDetail && s.itemDetail.data && s.itemDetail.data.product; + if (!p) return []; + return (p.product_sku || []) + .filter(e => String(e.inventory_exist_flg) === '1') + .map(e => ({color: e.product_color_name, size: e.product_size_name})); + }""" + ) + if not variants: + raise RuntimeError("该商品所有颜色/尺码都缺货(product_sku 无 inventory_exist_flg=1)") + target = variants[0] + self.log(f"选中规格:颜色={target['color']} 尺码={target['size']}(共 {len(variants)} 个有货组合)") + self.report.add_cart_contract["picked_variant"] = target + + opener = await self._find_clickable(page, ("かごに追加",)) + if opener is None: + raise RuntimeError("商品页没找到「かごに追加」(打开规格模态层的按钮)") + self.report.add_cart_button = "かごに追加 → SKU モーダル → かごに追加" + await opener[0].click(timeout=15_000) + await page.wait_for_timeout(2_500) + await self.snapshot(page, "sku-modal") + + for label, value in (("颜色", target["color"]), ("尺码", target["size"])): + if not value: + continue + if await self._click_variant_chip(page, value): + self.log(f"{label}「{value}」已点选") + await page.wait_for_timeout(1_500) + else: + self.report.errors.append(f"{label}「{value}」在模态层里没找到可点的选项") + await self.snapshot(page, "variant-selected") + + # 点尺码这一下**本身就入车**并整页跳到市场侧 cart(实测落地 + # sp.cart.step.rakuten.co.jp/cart?shop_bid=279405&added_item=), + # 模态层里那个带 data-tracking="sku-modal-size-cart" 的「かごに追加」根本 + # 等不到——页面已经走了。所以只在没跳走时才去点它兜底。 + if "added_item=" not in page.url: + modal_add = page.locator('button:has([data-tracking="sku-modal-size-cart"])').first + try: + await modal_add.click(timeout=10_000) + except Exception as exc: + raise RuntimeError( + f"选完尺码没自动入车,模态层里的「かごに追加」也点不到:" + f"{type(exc).__name__}: {exc}" + ) from exc + await page.wait_for_timeout(4_000) + self.report.add_cart_landing_url = page.url + await self.snapshot(page, "after-add-cart") + + async def _click_variant_chip(self, page, value: str) -> bool: + """点一个规格选项:颜色是带 img[alt] 的按钮,尺码是文案就等于尺码的按钮""" + for selector in ( + f'button:has(img[alt="{value}"])', + f'button[aria-label="{value}"]', + f'li:has-text("{value}") button', + ): + candidates = page.locator(selector) + try: + total = await candidates.count() + except Exception: + continue + for index in range(min(total, 20)): + locator = candidates.nth(index) + try: + if not await locator.is_visible(): + continue + await locator.click(timeout=5_000) + return True + except Exception: + continue + # 尺码这类纯文本 chip:按「可见控件文案完全等于目标值」精确点,避免撞到别的数字 + clicked = await page.evaluate( + """(value) => { + const els = Array.from(document.querySelectorAll('button, div[role="button"], label, li')); + for (const el of els) { + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) continue; + if ((el.innerText || '').trim() === value) { el.click(); return true; } + } + return false; + }""", + value, + ) + return bool(clicked) + + async def _select_required_options(self, page) -> None: + """加购前尽力选一个可售规格(Rakuten Fashion 的尺码/颜色是必选) + + 只做「有明显的下拉框就选第一个非空项」这一档:真实规格控件长什么样正是 + 本探针要探的东西,猜太多反而会掩盖真实结构,选不出来就继续往下走, + 让站点自己报错、把错误文案记进报告。 + """ + selects = page.locator("select") + for index in range(min(await selects.count(), 3)): + select = selects.nth(index) + try: + values = await select.evaluate( + "el => Array.from(el.options).filter(o => o.value && !o.disabled).map(o => o.value)" + ) + if values: + await select.select_option(values[0]) + self.log(f"下拉框 #{index} 选中 {values[0]}") + except Exception as exc: # noqa: BLE001 + self.log(f"下拉框 #{index} 选择失败(忽略):{type(exc).__name__}") + + # ---- 步骤 4:购物车归属确认 ---- + + async def check_cart(self, page) -> None: + """加购后查市场侧 cart count:子站是否共用 ichiba 购物车,这一步能直接看出来""" + try: + _, count = await self._site._query_cart_count() # noqa: SLF001 + self.report.ichiba_cart_count = count + self.log(f"ichiba cart count = {count}") + except Exception as exc: # noqa: BLE001 + self.report.errors.append(f"cart count 查询失败:{type(exc).__name__}: {exc}") + + # ---- 步骤 5:一路点到确认页为止 ---- + + async def drive_checkout(self, page) -> None: + for hop in range(_MAX_HOPS): + await page.wait_for_timeout(2_000) + url = page.url + html = await self.snapshot(page, f"hop{hop}") + hop_record = {"hop": str(hop), "url": url} + + confirm_buttons = await self._confirm_buttons(page) + if confirm_buttons or any(m in url for m in _CONFIRM_PAGE_URL_MARKERS): + hop_record["action"] = f"确认页命中(确认按钮={confirm_buttons}),停手(不提交)" + self.report.hops.append(hop_record) + self.report.reached_confirm = True + self.report.confirm_buttons = confirm_buttons + self.report.stopped_reason = "已到下单确认页,按要求不提交" + await self._parse_amount(html) + return + + # 同样必须排在 session upgrade 判据前面(URL 仍带 session/upgrade)。 + # 生年月日/性別 这种「注册后不可变更」的个人信息,脚本一律不代填。 + if _PROFILE_STEP_URL_MARKER in url or ( + _PHONE_REGISTRATION_MARKER in html + and all(m in html for m in _PROFILE_REGISTRATION_MARKERS) + ): + hop_record["action"] = "会員情報の追加登録(誕生日+性別)页,脚本不代填,停手" + self.report.hops.append(hop_record) + self.report.stopped_reason = ( + "卡在「会員情報の追加登録」的誕生日+性別 变体(hash #/profiling/1):" + "两项都是必填且页面明写「登録した情報は、変更できません」," + "属于不可撤销的个人信息,脚本不代填,必须人工填一次" + ) + return + + # 必须排在 session upgrade 判据前面:同意页的 URL 里仍然带 + # session/upgrade,只有 hash 不同,顺序反了会拿密码框选择器去撞同意页 + if _AGREE_STEP_URL_MARKER in url or _AGREE_STEP_TEXT_MARKER in html: + if not self._accept_terms: + hop_record["action"] = "子站利用規約同意页,未授权自动同意,停手" + self.report.hops.append(hop_record) + self.report.stopped_reason = ( + "卡在子站利用規約/プライバシーポリシー同意页(" + "#/agree/service):这是账号级协议同意,需显式授权," + "加 --accept-subsite-terms 才会自动点「次へ」" + ) + return + hop_record["action"] = "子站利用規約同意页:点「次へ」(已显式授权)" + self.report.hops.append(hop_record) + agree = await self._find_clickable(page, ("次へ",)) + if agree is None: + self.report.stopped_reason = "同意页没找到「次へ」" + return + await agree[0].click(timeout=15_000) + continue + + if any(marker in url for marker in _SESSION_UPGRADE_URL_MARKERS): + hop_record["action"] = "session upgrade:调生产 _complete_session_upgrade" + self.report.hops.append(hop_record) + await self._site._complete_session_upgrade( # noqa: SLF001 + page, task_id=f"probe-{self._spec.name}" + ) + continue + + if _PHONE_REGISTRATION_MARKER in html: + hop_record["action"] = "电话补录:调生产 _complete_phone_registration" + self.report.hops.append(hop_record) + await self._site._complete_phone_registration( # noqa: SLF001 + page, task_id=f"probe-{self._spec.name}" + ) + continue + + if _ADDRESS_STEP_URL_MARKER in url: + hop_record["action"] = "地址确认:调生产 _confirm_default_address" + self.report.hops.append(hop_record) + await self._site._confirm_default_address( # noqa: SLF001 + page, task_id=f"probe-{self._spec.name}" + ) + continue + + if _PAYMENT_STEP_URL_MARKER in url: + hop_record["action"] = "支付方式:调生产 _select_payment_method" + self.report.hops.append(hop_record) + await self._site._select_payment_method( # noqa: SLF001 + page, task_id=f"probe-{self._spec.name}" + ) + continue + + found = await self._find_clickable(page, _NEXT_STEP_TEXTS) + if found is None: + texts = await self._visible_texts(page) + hop_record["action"] = f"没有可继续的按钮,停。页面控件文案:{texts}" + self.report.hops.append(hop_record) + self.report.stopped_reason = "找不到下一步按钮" + return + + locator, text = found + hop_record["action"] = f"点击「{text}」" + self.report.hops.append(hop_record) + self.log(f"hop{hop} 点击「{text}」") + await locator.click(timeout=15_000) + + self.report.stopped_reason = f"跳转超过 {_MAX_HOPS} 跳仍未到确认页" + + async def _parse_amount(self, html: str) -> None: + """确认页金额解析:直接用生产的 _parse_checkout_summary,验证它在子站是否也成立""" + try: + summary = _parse_checkout_summary(html) + self.report.payable_yen = summary.payable_yen + self.log(f"确认页金额解析成功:{summary.payable_yen} 円") + except Exception as exc: # noqa: BLE001 + self.report.errors.append( + f"确认页金额解析失败(生产 _parse_checkout_summary):{type(exc).__name__}: {exc}" + ) + + async def _confirm_buttons(self, page) -> list[str]: + """页面上**可见的**下单确认按钮文案;空列表表示还没到确认页 + + 只看真实控件(button / role=button / submit)且尺寸非零,不看正文文案—— + 正文里出现「注文を確定する」这五个字的说明性文字并不代表这是确认页。 + """ + return await page.evaluate( + """(texts) => Array.from(document.querySelectorAll( + 'button, div[role="button"], a[role="button"], input[type="submit"]' + )) + .filter(el => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }) + .map(el => (el.innerText || el.value || '').trim().replace(/\\s+/g, ' ')) + .filter(t => t && texts.some(x => t.includes(x)))""", + list(_CONFIRM_BUTTON_TEXTS), + ) + + # ---- 按钮定位:文案匹配 + 黑名单闸门 ---- + + async def _find_clickable(self, page, texts: tuple[str, ...]): + """按候选文案找第一个可见可点的控件;命中黑名单立即抛错,绝不返回 + + `button:has-text()` 是子串匹配,「購入手続き」会同时命中促销按钮 + 「カード入会&購入手続き」——主站踩过这个坑(见 site_interact + _CHECKOUT_BUTTON_SELECTOR 注释),所以这里按 aria-label 精确匹配优先, + 再退到文案子串匹配,并且每个候选都要过一遍黑名单。 + """ + for text in texts: + for selector in ( + f'button[aria-label="{text}"]', + f'button:has-text("{text}")', + f'a:has-text("{text}")', + f'div[role="button"]:has-text("{text}")', + f'input[type="submit"][value*="{text}"]', + ): + candidates = page.locator(selector) + # 子站页面同一个按钮常有 PC/SP 两套 DOM,只有一套可见;.first 会选中 + # 隐藏的那个然后死等 visible 超时(biccamera 实测踩过),必须逐个筛可见的 + try: + total = await candidates.count() + except Exception: + continue + for index in range(min(total, 10)): + locator = candidates.nth(index) + try: + if not await locator.is_visible(): + continue + except Exception: + continue + actual = text + if "input" not in selector: + try: + actual = ((await locator.inner_text()) or "").strip() or text + except Exception: + actual = text + if any(bad in actual for bad in _FORBIDDEN_BUTTON_TEXTS): + raise RuntimeError( + f"候选按钮「{actual}」命中提交订单黑名单,停手(selector={selector})" + ) + return locator, actual + return None + + +async def _start_visible(site: SiteInteractor, settings) -> None: + """headless=False 启动(与 probe_payment_method.py 同一写法) + + 无头模式下结算 SPA 表现异常,见 site_interact.start() 的注释。 + """ + from playwright.async_api import async_playwright + + from app.trading.core import auth_site + + state_path = settings.auth_state_path / auth_site.profile("rakuten").state_filename + storage_state = str(state_path) if state_path.exists() else None + + site._playwright = await async_playwright().start() # noqa: SLF001 + site._browser = await site._playwright.chromium.launch( # noqa: SLF001 + headless=False, + channel=settings.browser_channel or None, + args=["--no-first-run", "--disable-blink-features=AutomationControlled"], + ) + site._context = await site._browser.new_context( # noqa: SLF001 + 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, + ) + print(f"[visible] 浏览器已启动,storage_state={storage_state or '(none)'}", flush=True) + + +async def probe_one( + site: SiteInteractor, spec: SubsiteSpec, item_url: str | None, *, accept_terms: bool +) -> SiteReport: + probe = Probe(site, spec, accept_terms=accept_terms) + page = await site._context.new_page() # noqa: SLF001 + try: + if item_url: + candidates = [{"url": item_url, "price": None, "name": "(指定)"}] + else: + candidates = (await probe.discover_candidates(page))[:_MAX_CANDIDATES] + for index, candidate in enumerate(candidates): + probe.record_item(candidate) + try: + await probe.open_item(page, candidate["url"]) + await probe.add_to_cart(page) + break + except Exception as exc: # noqa: BLE001 + probe.report.errors.append( + f"候选 {index + 1}/{len(candidates)}({candidate['url']})加购失败:" + f"{type(exc).__name__}: {exc}" + ) + probe.log(f"候选 {index + 1} 加购失败,换下一个:{type(exc).__name__}: {exc}") + if not probe.report.add_cart_ok: + raise RuntimeError(f"{len(candidates)} 个候选商品都没能加购成功") + await probe.check_cart(page) + await probe.drive_checkout(page) + except Exception as exc: # noqa: BLE001 + probe.report.errors.append(f"{type(exc).__name__}: {exc}") + probe.report.stopped_reason = probe.report.stopped_reason or f"异常中断:{type(exc).__name__}" + probe.log(f"异常:{type(exc).__name__}: {exc}") + traceback.print_exc() + try: + await probe.snapshot(page, "error") + except Exception: # noqa: BLE001 + pass + finally: + await page.close() + # 每个子站跑完都清一次购物车:下一个子站要从空车开始,否则确认页会混单 + try: + result = await site.clear_cart() + probe.log(f"清空购物车:{result}") + if result.get("cart_count") not in (0, -1): + probe.report.errors.append(f"购物车未清干净:{result}") + except Exception as exc: # noqa: BLE001 + probe.report.errors.append(f"清空购物车失败:{type(exc).__name__}: {exc}") + + (PROBE_DIR / f"report-{spec.name}.json").write_text( + json.dumps(probe.report.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return probe.report + + +async def run(sites: list[str], item_url: str | None, *, accept_terms: bool) -> int: + PROBE_DIR.mkdir(parents=True, exist_ok=True) + settings = get_settings() + auth = AuthSession(settings) + await auth.start() + site = SiteInteractor(auth_session=auth, settings=settings) + await _start_visible(site, settings) + + reports: list[SiteReport] = [] + try: + try: + first = await site.clear_cart() + print(f"[setup] 起始清空购物车:{first}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f"[setup] 起始清空失败(继续):{type(exc).__name__}: {exc}", flush=True) + + for name in sites: + print(f"\n{'=' * 20} {name} {'=' * 20}", flush=True) + reports.append( + await probe_one(site, SUBSITES[name], item_url, accept_terms=accept_terms) + ) + finally: + await site.close() + await auth.close() + + print("\n==== 汇总 ====", flush=True) + for report in reports: + print( + f"{report.site}: 加购={'OK' if report.add_cart_ok else 'NG'} " + f"落地域名={report.landing_host} cart_count={report.ichiba_cart_count} " + f"到确认页={'是' if report.reached_confirm else '否'} " + f"金额={report.payable_yen} 停在={report.stopped_reason}", + flush=True, + ) + for err in report.errors: + print(f" ! {err}", flush=True) + print(f"\n证据目录:{PROBE_DIR}", flush=True) + return 0 + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--site", choices=[*SUBSITES, "all"], default="all") + parser.add_argument("--item-url", default=None, help="跳过自动选品,直接用这个商品 URL") + parser.add_argument( + "--accept-subsite-terms", + action="store_true", + help="遇到子站利用規約同意页时代用户点「次へ」(账号级协议同意,默认不做)", + ) + args = parser.parse_args() + sites = list(SUBSITES) if args.site == "all" else [args.site] + if args.item_url and len(sites) > 1: + parser.error("--item-url 只能配合单个 --site 使用") + return await run(sites, args.item_url, accept_terms=args.accept_subsite_terms) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main()))