"""新卡代填 + 自动提交 真实站点探针 _fill_new_card_form / _submit_new_card_form 目前只有 2026-08-13 人工手填过一次 (OS 级 SendKeys,非 Playwright .fill()),2026-08-14 新加的自动提交代码 (_submit_new_card_form)从未跑过真实站点。当前账号已有一张匹配 account.yaml 配置的已保存卡,_select_payment_method 走的是「直接点次へ」分支,不会自然触发 新卡代填——本探针绕开这个判断,强制走「新しいカードを追加する」表单,直接调用 生产代码 _fill_new_card_form + _submit_new_card_form,验证三个 iframe 字段选择器 与自动提交回显判定在真实站点上是否成立。 **用的是 account.yaml 里配置的同一张卡号**,不是别的卡;预期效果是在账号已保存卡 列表里多一条记录(不产生额外扣款/开卡费)。**不调用 submit_order / pay**—— 验证完成后直接丢弃 page,不产生下单动作。 用法: .venv/Scripts/python.exe scripts/probe_new_card.py """ 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.services import login_runner # noqa: E402 from app.trading.services.auth_session import AuthSession # noqa: E402 from app.trading.worker.models import LeaseTask # noqa: E402 from app.trading.worker.site_interact import SiteInteractor # noqa: E402 PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "new_card" PROBE_DIR.mkdir(parents=True, exist_ok=True) DEFAULT_ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/" async def _start_visible(site: SiteInteractor, settings) -> None: 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() site._browser = await site._playwright.chromium.launch( 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( 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] 浏览器已启动(headless=False),storage_state={storage_state or '(none)'}") async def run(item_url: str) -> int: settings = get_settings() accounts_by_site = login_runner.load_accounts() account = login_runner.default_account_for("rakuten", accounts_by_site) if account is None or account.credit_card is None: print("account.yaml 未配置 rakuten 默认账号的 credit_card,无法代填,中止") return 1 card = account.credit_card print(f"将代填卡:尾号 {card.number[-4:]},有效期 {card.month}/{card.year}") auth = AuthSession(settings) await auth.start() site = SiteInteractor(auth_session=auth, settings=settings) await _start_visible(site, settings) task = LeaseTask( task_id="probe-new-card-1", site="rakuten", intent={"item_url": item_url, "quantity": 1}, ) try: print("=== add_to_cart ===") await site.add_to_cart(task) print("=== verify_cart ===") await site.verify_cart(task) print("=== enter_checkout(预期直落 order-confirmation)===") try: await site.enter_checkout(task) except Exception as exc: print(f"enter_checkout 抛出:{type(exc).__name__}: {exc}") return 1 page = site._checkout_pages.get(task.task_id) if page is None: print("enter_checkout 没有留存 page,无法继续") return 1 print(f" 落地 url={page.url}") print('=== 点击「支払い方法」区块「変更」,跳回 /payment ===') try: change_btn = page.locator('text="支払い方法"').first.locator( 'xpath=following::button[@aria-label="変更"][1]' ) await change_btn.click(timeout=10_000) await page.wait_for_timeout(2_000) except Exception as exc: print(f"点击「変更」失败:{type(exc).__name__}: {exc}") return 1 print(f" 跳转后 url={page.url}") label = settings.order_payment_method print(f'=== 选中支付方式「{label}」===') try: option = page.locator(f'text="{label}"').first await option.click(timeout=10_000) await page.wait_for_timeout(1_000) except Exception as exc: print(f"选中「{label}」失败:{type(exc).__name__}: {exc}") return 1 (PROBE_DIR / "01-payment-page-before-add-card.html").write_text( await page.content(), encoding="utf-8" ) await page.screenshot( path=str(PROBE_DIR / "01-after-select-label.png"), full_page=True ) print(f'=== 截图显示行内有折叠箭头,再点一次「{label}」尝试展开该行 ===') try: await option.click(timeout=10_000) await page.wait_for_timeout(1_000) except Exception as exc: print(f"第二次点击「{label}」失败:{type(exc).__name__}: {exc}") return 1 await page.screenshot( path=str(PROBE_DIR / "01b-after-second-click.png"), full_page=True ) is_visible_now = await page.locator( 'text="新しいカードを追加する"' ).first.is_visible() print(f" 第二次点击后「新しいカードを追加する」可见={is_visible_now}") print('=== 诊断「新しいカードを追加する」有几个匹配、哪个可见 ===') from app.trading.worker.site_interact import _ADD_CARD_LINK_TEXT candidates = page.locator(f'text="{_ADD_CARD_LINK_TEXT}"') count = await candidates.count() print(f" 匹配数量={count}") visible_index = None for i in range(count): el = candidates.nth(i) is_visible = await el.is_visible() print(f" [{i}] visible={is_visible}") if is_visible and visible_index is None: visible_index = i if visible_index is None: print(" 没有任何一个匹配是可见的,诊断祖先节点的可见性状态") (PROBE_DIR / "02-no-visible-add-card-link.html").write_text( await page.content(), encoding="utf-8" ) diag = await candidates.first.evaluate( """el => { const out = []; let node = el; for (let depth = 0; depth < 12 && node; depth++) { const cs = getComputedStyle(node); const rect = node.getBoundingClientRect(); out.push({ depth, tag: node.tagName, cls: (node.className || '').toString().slice(0, 80), display: cs.display, visibility: cs.visibility, opacity: cs.opacity, height: rect.height, width: rect.width, }); node = node.parentElement; } return out; }""" ) for row in diag: print(f" depth={row['depth']} tag={row['tag']} cls={row['cls']!r} " f"display={row['display']} visibility={row['visibility']} " f"opacity={row['opacity']} size={row['width']}x{row['height']}") await page.screenshot( path=str(PROBE_DIR / "02-no-visible-add-card-link.png"), full_page=True ) print(f" 截图已存盘:{PROBE_DIR / '02-no-visible-add-card-link.png'}") return 1 print(f" 将点击可见的第 [{visible_index}] 个匹配") try: await candidates.nth(visible_index).click(timeout=10_000) await page.wait_for_timeout(1_000) except Exception as exc: print(f"点击「{_ADD_CARD_LINK_TEXT}」失败:{type(exc).__name__}: {exc}") return 1 print("=== 强制走新卡代填:直接调用三个 iframe 字段 + 名義人代填(跳过 _fill_new_card_form 内部的点击)===") try: month = str(card.month).zfill(2) year = str(card.year) from app.trading.worker.site_interact import ( _CARD_MONTH_MOUNT_SELECTOR, _CARD_NAME_LABEL_TEXT, _CARD_NUMBER_MOUNT_SELECTOR, _CARD_YEAR_MOUNT_SELECTOR, ) async def _fill_frame(mount_selector: str, value: str, label_: str) -> None: frame = page.frame_locator(mount_selector) field = frame.locator("input, select").first await field.wait_for(state="visible", timeout=8_000) tag = await field.evaluate("el => el.tagName.toLowerCase()") if tag == "select": await field.select_option(value) else: await field.fill(value) print(f" 已填 {label_}") await _fill_frame(_CARD_NUMBER_MOUNT_SELECTOR, card.number, "卡号") await _fill_frame(_CARD_MONTH_MOUNT_SELECTOR, month, "有効期限(月)") await _fill_frame(_CARD_YEAR_MOUNT_SELECTOR, year, "有効期限(年)") name_locator = page.locator(f'text="{_CARD_NAME_LABEL_TEXT}"').locator( "xpath=following::input[1]" ) await name_locator.wait_for(state="visible", timeout=8_000) await name_locator.fill(card.name) print(" 已填 名義人") # 上一轮实测:名義人 input 填完后焦点仍在它上面,它所在的 # aria-modal="true" 弹层拦截了后续对「追加する」提交按钮的点击 # (pointer events intercepted)。blur 掉这个 input 再继续。 await name_locator.evaluate("el => el.blur()") await page.wait_for_timeout(300) print(" 已 blur 名義人 input") print(" _fill_new_card_form 等效逻辑成功返回") except Exception as exc: print(f"新卡代填抛出:{type(exc).__name__}: {exc}") (PROBE_DIR / "02-fill-new-card-error.html").write_text( await page.content(), encoding="utf-8" ) return 1 (PROBE_DIR / "02-after-fill-new-card.html").write_text( await page.content(), encoding="utf-8" ) print('=== 诊断「追加する」按钮有几个匹配、哪个真正可点 ===') from app.trading.worker.site_interact import ( _ADD_CARD_SUBMIT_TEXT, _SAVED_CARD_SIGNAL_PATTERN, ) submit_candidates = page.locator(f'button:has-text("{_ADD_CARD_SUBMIT_TEXT}")') submit_count = await submit_candidates.count() print(f" 匹配数量={submit_count}") clicked = False for i in range(submit_count): el = submit_candidates.nth(i) is_visible = await el.is_visible() print(f" [{i}] visible={is_visible}") if not is_visible: continue try: await el.click(timeout=5_000) print(f" 成功点击第 [{i}] 个「{_ADD_CARD_SUBMIT_TEXT}」") clicked = True break except Exception as exc: print(f" 第 [{i}] 个点击失败(继续试下一个):{type(exc).__name__}: {exc}") if not clicked: print(" 所有候选都点不中,中止") (PROBE_DIR / "03-submit-new-card-error.html").write_text( await page.content(), encoding="utf-8" ) return 1 confirmed = False for _ in range(10): await page.wait_for_timeout(1_500) html_now = await page.content() if _SAVED_CARD_SIGNAL_PATTERN.search(html_now): confirmed = True break print(f" 提交后已保存卡信号确认={confirmed},当前 url={page.url}") if not confirmed: (PROBE_DIR / "03-submit-new-card-error.html").write_text( await page.content(), encoding="utf-8" ) return 1 (PROBE_DIR / "03-after-submit-new-card.html").write_text( await page.content(), encoding="utf-8" ) print("新卡代填 + 自动提交全链路验证完成,不继续点「次へ」/不调用 submit_order/pay") finally: page = site._checkout_pages.pop(task.task_id, None) if page is not None: print(f" 丢弃 checkout page(不调用 submit_order/pay),最终 url={page.url}") await page.close() try: await site.clear_cart() except Exception as exc: # noqa: BLE001 print(f"清理购物车失败(忽略):{type(exc).__name__}: {exc}") await site.close() await auth.close() print(f"\n探针输出目录:{PROBE_DIR}") return 0 async def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--item-url", default=DEFAULT_ITEM_URL) args = parser.parse_args() return await run(args.item_url) if __name__ == "__main__": raise SystemExit(asyncio.run(main()))