"""官方子站(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, proxy=settings.playwright_proxy, 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()))