"""结账页检测(feat-chv-muse-p10,叶子模块,纯标准库)。 对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见 docs/chv-muse-p10-gap.md):已保存支付凭证的网站,浏览器内置 结账页检测,每次都弹出人工审批并展示确切购买详情。 本模块是纯函数,不启动浏览器、不联网、不读原始 DOM: - 输入只有 URL + 无障碍树节点(与 P4 的 ``detect_form_risk`` 同输入形状——P2 的浏览器子代理只返回无障碍树快照,节点 只有 role/name/label/type 等描述字段); - 输出 ``CheckoutVerdict``:是否结账页、置信度、命中原因、 表单风险、页面金额文本; - 确定性规则,不冒充模型分类器(见 P6 口径)。 诚实边界:启发式检测,不是 100% 检出——商户自定义结账页、 单页应用内嵌结账可能漏检。漏检时的安全底线在调用方 (``PaymentGate`` 拒绝任何未经审批的扣款),不在本模块 宣称“所有结账页都会被拦下”。 页面金额文本(``amount_text``)只作展示信号:扣款金额永远 以 ``PurchaseDetails``(订单数据)为准,页面文本是不受信任 输入(P6 口径),不得直接拿去扣款。 """ import re from dataclasses import dataclass, field from urllib.parse import urlparse # URL 路径段信号:结账步骤(高)与购物车(中,结账前一步)。 _CHECKOUT_SEGMENTS = ("checkout", "check-out", "payment", "pay", "billing", "order-review", "place-order", "confirm-order") _CART_SEGMENTS = ("cart", "basket", "bag") # 无障碍树节点文本信号(小写子串匹配)。 _PAYMENT_HINTS = ("card number", "credit card", "debit card", "cardholder", "expiry", "expiration", "exp date", "cvc", "cvv", "security code", "卡号", "信用卡", "有效期", "安全码") _ORDER_HINTS = ("place order", "pay now", "confirm purchase", "complete purchase", "checkout", "下单", "立即支付", "确认购买", "去支付", "提交订单") _PASSWORD_HINTS = ("password", "密码") _AMOUNT_RE = re.compile( r"[$€£¥]\s?\d[\d,]*(?:\.\d{1,2})?" r"|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP|JPY|CNY)\b") @dataclass class CheckoutVerdict: """一次结账检测的结果(只含描述,不含任何凭证值)。""" is_checkout: bool confidence: str # high / medium / low reasons: list = field(default_factory=list) form_risk: str | None = None # payment / password / None amount_text: str | None = None def _node_text(node: dict) -> str: """一个无障碍树节点的描述文本(role/name/label/type 拼起来,小写;原始 DOM 从不到这里)。""" parts = [str(node.get(k, "")) for k in ("role", "name", "label", "type")] return " ".join(parts).lower() def _url_level(url: str) -> str: """URL 信号档位:high(结账路径)/ medium(购物车)/ none。只看 path,不看 query(query 里可能有订单号等 敏感值,不进原因)。""" try: path = urlparse(url).path.lower() except ValueError: return "none" segments = [s for s in path.split("/") if s] if any(s in _CHECKOUT_SEGMENTS for s in segments): return "high" if any(s in _CART_SEGMENTS for s in segments): return "medium" return "none" def _node_signals(nodes: list) -> tuple: """无障碍树信号:(支付字段, 密码字段, 下单按钮, 金额文本)。 节点文本只作特征匹配,不存原文。""" payment = False password = False order = False amount: str | None = None for node in nodes or []: text = _node_text(node) if not text.strip(): continue if any(h in text for h in _PAYMENT_HINTS): payment = True if any(h in text for h in _PASSWORD_HINTS): password = True if any(h in text for h in _ORDER_HINTS): order = True if amount is None: hit = _AMOUNT_RE.search(text) if hit: amount = hit.group(0) return payment, password, order, amount def detect_checkout(url: str, nodes: list | None = None ) -> CheckoutVerdict: """检测一个页面是否为结账页。 判定(确定性规则):支付字段在页 → 结账页(高置信); 结账路径 URL → 结账页(有下单按钮/金额则高,否则中); 下单按钮 + 金额文本 → 结账页(中置信);只命中购物车 路径 → 不是结账页(购物车是结账前一步);其余 → 不是。 ``form_risk``:支付字段在页为 ``payment``,否则密码字 段在页为 ``password``,与 P4 的 ``detect_form_risk`` 口径一致。""" level = _url_level(url) has_payment, has_password, has_order, amount = _node_signals( nodes or []) reasons: list = [] if level == "high": reasons.append("checkout_url") elif level == "medium": reasons.append("cart_url") if has_payment: reasons.append("payment_fields") if has_order: reasons.append("order_button") if amount is not None: reasons.append("amount_text") risk = "payment" if has_payment else ( "password" if has_password else None) if has_payment: return CheckoutVerdict(True, "high", reasons, risk, amount) if level == "high": conf = "high" if has_order or amount else "medium" return CheckoutVerdict(True, conf, reasons, risk, amount) if has_order and amount is not None: return CheckoutVerdict(True, "medium", reasons, risk, amount) return CheckoutVerdict(False, "low", reasons, risk, amount)