From 069bb1ea413487ceb07ae7e8b5ca216cce1a39f4 Mon Sep 17 00:00:00 2001 From: currycan Date: Sat, 11 Jul 2026 11:58:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20=E4=BF=AE=E5=A4=8D=204=20?= =?UTF-8?q?=E4=B8=AA=E5=BC=80=E6=94=BE=20issue=EF=BC=88#41=20=E5=BC=80?= =?UTF-8?q?=E5=94=AE=E8=BD=AE=E8=AF=A2=20/=20#45=20=E7=A5=A8=E4=BB=B7?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20/=20#51=20=E6=90=9C=E7=B4=A2=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=20/=20#50=20=E7=BC=96=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多 agent 根因分析 + 对抗验证后实施,真机(REDMI Turbo 4 Pro / 大麦 9.0.26)验证通过。 #41 (P0) 配 sell_start_time 后开抢晚 ~8s:btn_buy_view 在大麦 9.0.2x 已废弃, BuyButtonGuard 8s + 文案轮询 8s 对 Canvas 自绘 CTA 全盲。 - buy_button_guard:单一 resource-id 改多 ID 候选 + get_cta_center_coords 坐标兜底 - sale_waiter:重构 wait_for_sale_start 为交错轮询,Canvas 页开售前跳过昂贵 u2 查询, 到点即坐标兜底(event=cta_canvas_fallback),开抢延迟从 ~8s 收紧到与开售同秒 - orchestrator:_is_buy_button_sold_out 多 ID 候选 + 文档序陷阱防御 - page_probe/state_probe:详情页价格区容器多 ID(info_v2_price_layout 新增), 修复 probe_only 在 9.0.2x 详情页恒「关键控件未就绪」 #45 (P1) 票价识别错误:两位日份(「5月30号」的 30)被宽松正则当票价吞掉真价。 - prompt_parser:新增 _strip_schedule_fragments(剥离日期/时间/张数)+ _extract_numeric_price (上下文锚定优先 + 回溯封堵),price_range 同步先剥日程片段 #51 + #50(匹配) (P1) 搜索/标题匹配对词序/前缀/截断零容忍: - item_resolver:新增 title_similarity(子串/覆盖率+bigram Dice)与 find_conflicting_city - event_navigator:城市冲突 veto + 标题模糊回退 + clicked_title 锚定 + 失败卡黑名单 + 候选打分增强;失败时列 top-5 候选引导(绝不自动点低置信,杜绝误买) #50(编码) (P2) Windows GBK 解 pm dump 巨量中文崩溃: - 新增 mobile/proc_utils.run_captured(encoding=utf-8, errors=replace) - env_snapshot / prompt_runner 迁移至统一封装;macOS/Linux 行为零变化 测试:1067→1162 passed(+95),覆盖率 81.92%→83.06%,稳过 80% 门槛。 Co-Authored-By: Claude Fable 5 --- mobile/buy_button_guard.py | 61 ++++- mobile/damai_app/delegators.py | 25 +- mobile/damai_app/orchestrator.py | 37 ++- mobile/damai_app/sale_waiter.py | 105 ++++++-- mobile/damai_app/state_probe.py | 4 +- mobile/env_snapshot.py | 30 ++- mobile/event_navigator.py | 227 +++++++++++++++- mobile/item_resolver.py | 125 +++++++++ mobile/page_probe.py | 24 +- mobile/price_selector.py | 4 + mobile/proc_utils.py | 55 ++++ mobile/prompt_parser.py | 199 ++++++++++---- mobile/prompt_runner.py | 46 +++- tests/unit/test_buy_button_guard.py | 122 +++++++++ tests/unit/test_env_snapshot.py | 41 ++- tests/unit/test_event_navigator.py | 263 +++++++++++++++++- tests/unit/test_mobile_damai_app.py | 343 +++++++++++++++++++++++- tests/unit/test_mobile_item_resolver.py | 105 ++++++++ tests/unit/test_mobile_prompt_parser.py | 206 ++++++++++++++ tests/unit/test_mobile_prompt_runner.py | 110 ++++++++ tests/unit/test_page_probe.py | 65 +++++ tests/unit/test_proc_utils.py | 126 +++++++++ 22 files changed, 2193 insertions(+), 130 deletions(-) create mode 100644 mobile/proc_utils.py create mode 100644 tests/unit/test_proc_utils.py diff --git a/mobile/buy_button_guard.py b/mobile/buy_button_guard.py index 49c9d6e..b91f923 100644 --- a/mobile/buy_button_guard.py +++ b/mobile/buy_button_guard.py @@ -8,7 +8,7 @@ """ import time -from typing import Optional +from typing import Optional, Tuple try: from mobile.logger import get_logger @@ -44,7 +44,17 @@ } ) -_BUY_BUTTON_RESOURCE_ID = "cn.damai:id/btn_buy_view" +# CTA 候选 resource-id(issue #41): +# - v8.x 传统购买按钮 btn_buy_view(带文案,可做安全文案校验),旧 ID 在前保兼容; +# - 大麦 ≥9.0.2x 详情页移除了 btn_buy_view,CTA 变为 Canvas 自绘,仅剩底部购买栏 +# 容器 trade_project_detail_purchase_status_bar_container_fl(无任何文案节点)。 +_BUY_BUTTON_RESOURCE_IDS = ( + "cn.damai:id/btn_buy_view", + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", +) + +# 兼容别名:保留旧常量名,防外部引用破坏(issue #41) +_BUY_BUTTON_RESOURCE_ID = _BUY_BUTTON_RESOURCE_IDS[0] class BuyButtonGuard: @@ -56,6 +66,8 @@ class BuyButtonGuard: def __init__(self, device): self._device = device + # 最近一次命中的候选 resource-id(None 表示未命中),供调用方结构化日志使用 + self._last_matched_resource_id: Optional[str] = None def is_safe_to_click(self, button_text: Optional[str]) -> bool: """Return True only if button_text is a known safe purchase text. @@ -72,19 +84,24 @@ def is_safe_to_click(self, button_text: Optional[str]) -> bool: return safe def _find_buy_button(self): - """Find the buy button element by resource ID. + """Find the buy button element by candidate resource IDs. + + 依序遍历 :data:`_BUY_BUTTON_RESOURCE_IDS`(旧 ID 在前,保 v8.x 兼容), + 返回首个存在的元素,并将命中的 ID 记入 ``self._last_matched_resource_id``。 Returns: The UI element if found, or None. """ - try: - el = self._device(resourceId=_BUY_BUTTON_RESOURCE_ID) - if el.exists: - return el - return None - except Exception: - logger.debug("Failed to find buy button element") - return None + for rid in _BUY_BUTTON_RESOURCE_IDS: + try: + el = self._device(resourceId=rid) + if el.exists: + self._last_matched_resource_id = rid + return el + except Exception: + logger.debug("Failed to find buy button element (resource-id=%s)", rid) + self._last_matched_resource_id = None + return None def get_current_text(self) -> Optional[str]: """Read current button text without clicking. @@ -102,6 +119,28 @@ def get_current_text(self) -> Optional[str]: logger.debug("Failed to read buy button text") return None + def get_cta_center_coords(self) -> Optional[Tuple[int, int]]: + """定位 CTA(购买按钮/购买栏容器)的中心坐标(issue #41)。 + + 大麦 ≥9.0.2x 详情页 CTA 为 Canvas 自绘、读不到任何文案,此方法按候选 + resource-id 取首个存在元素的 bounds 中心,供下游坐标兜底点击。 + + Returns: + (x, y) 中心坐标;候选 ID 均不存在或读取失败时返回 None。 + """ + for rid in _BUY_BUTTON_RESOURCE_IDS: + try: + el = self._device(resourceId=rid) + if el.exists: + bounds = el.info["bounds"] + x = (int(bounds["left"]) + int(bounds["right"])) // 2 + y = (int(bounds["top"]) + int(bounds["bottom"])) // 2 + self._last_matched_resource_id = rid + return (x, y) + except Exception: + logger.debug("Failed to read CTA bounds (resource-id=%s)", rid) + return None + def wait_until_safe(self, timeout_s: float = 10.0, poll_ms: int = 50) -> bool: """Poll button text until a safe text is detected or timeout expires. diff --git a/mobile/damai_app/delegators.py b/mobile/damai_app/delegators.py index 44647e9..2239943 100644 --- a/mobile/damai_app/delegators.py +++ b/mobile/damai_app/delegators.py @@ -168,9 +168,11 @@ def _title_matches_target(self, title_text): return self._navigator._title_matches_target(title_text) return False - def _current_page_matches_target(self, page_probe): + def _current_page_matches_target(self, page_probe, clicked_title=None): if hasattr(self, "_navigator"): - return self._navigator._current_page_matches_target(page_probe) + return self._navigator._current_page_matches_target( + page_probe, clicked_title=clicked_title + ) return False def _open_search_from_homepage(self): @@ -183,11 +185,26 @@ def _submit_search_keyword(self): return self._navigator._submit_search_keyword() return False - def _score_search_result(self, title_text, venue_text): + def _score_search_result(self, title_text, venue_text, city_text=None): if hasattr(self, "_navigator"): - return self._navigator._score_search_result(title_text, venue_text) + return self._navigator._score_search_result( + title_text, venue_text, city_text + ) return -1 + @property + def _last_failed_candidates(self): + """discover_target_event 最终失败时缓存的 top-5 候选(只读)。 + + issue #51+#50:prompt_runner 在 discovery 为 None 时读取本属性, + 在失败文案中列出候选引导用户补全标题。候选实际存放在 + EventNavigator 上;无 navigator 或数据非法时兜底返回空列表。 + """ + if hasattr(self, "_navigator") and self._navigator is not None: + candidates = getattr(self._navigator, "_last_failed_candidates", []) + return candidates if isinstance(candidates, list) else [] + return [] + def _scroll_search_results(self): if hasattr(self, "_navigator"): return self._navigator._scroll_search_results() diff --git a/mobile/damai_app/orchestrator.py b/mobile/damai_app/orchestrator.py index b7833f9..5371f98 100755 --- a/mobile/damai_app/orchestrator.py +++ b/mobile/damai_app/orchestrator.py @@ -141,23 +141,44 @@ def _ensure_pipeline(self): _SOLD_OUT_RE = re.compile(r"缺货|售罄|无票") + _BUY_BUTTON_NODE_IDS = ("btn_buy_view", "cn.damai:id/btn_buy_view") + _BUY_BAR_CONTAINER_NODE_IDS = ( + "trade_project_detail_purchase_status_bar_container_fl", + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + ) + + def _node_shows_sold_out(self, node): + """判断单个 XML 节点的 text/content-desc 是否包含缺货文案。""" + text = node.get("text", "") + desc = node.get("content-desc", "") + return bool( + self._SOLD_OUT_RE.search(text) or self._SOLD_OUT_RE.search(desc) + ) + def _is_buy_button_sold_out(self): """Check if the buy button itself shows sold-out text. - Only inspects the btn_buy_view element, not the whole page, to avoid - false positives from other price tiers showing '缺货登记'. + Only inspects the buy button / purchase-bar element, not the whole + page, to avoid false positives from other price tiers showing '缺货登记'. + + 优先匹配 v8.x 的 btn_buy_view(有文案,可判缺货);仅当其在整棵树中 + 不存在时才回退到 ≥9.0.2x 的购买栏容器(issue #41)。注意 iter("node") + 是文档序(父先于子),容器很可能是 btn_buy_view 的祖先,不能在首个 + 命中节点上早退,否则 v8.x 的缺货文本会被空文案容器短路漏检。 + 容器为 Canvas 自绘(text/content-desc 均空)时返回 False,不误判缺货。 """ xml_root = self._dump_hierarchy_xml() if xml_root is None: return False + container_node = None for node in xml_root.iter("node"): rid = node.get("resource-id", "") - if rid in ("btn_buy_view", "cn.damai:id/btn_buy_view"): - text = node.get("text", "") - desc = node.get("content-desc", "") - if self._SOLD_OUT_RE.search(text) or self._SOLD_OUT_RE.search(desc): - return True - return False + if rid in self._BUY_BUTTON_NODE_IDS: + return self._node_shows_sold_out(node) + if container_node is None and rid in self._BUY_BAR_CONTAINER_NODE_IDS: + container_node = node + if container_node is not None: + return self._node_shows_sold_out(container_node) return False def _set_terminal_failure(self, reason): diff --git a/mobile/damai_app/sale_waiter.py b/mobile/damai_app/sale_waiter.py index 3149f97..b9e87e8 100644 --- a/mobile/damai_app/sale_waiter.py +++ b/mobile/damai_app/sale_waiter.py @@ -129,27 +129,96 @@ def wait_for_sale_start(self): ) time.sleep(sleep_seconds) - # Use BuyButtonGuard for precise button-text monitoring - guard_t0 = time.monotonic() - if hasattr(self, "_guard") and self._guard.wait_until_safe( - timeout_s=8.0, poll_ms=50 - ): - log_event( - logger, - "sale_ready", - source="buy_button_guard", - cta_text=None, - polls=None, - duration_ms=int((time.monotonic() - guard_t0) * 1000), - ) - return - - # Tight polling loop with multiple purchase signals until the page becomes actionable. + # 交错轮询循环(issue #41):每轮依次做「guard 文案读取(廉价)→ Canvas + # 坐标兜底 → 页面结构多信号兜底」。旧实现先串行烧 guard.wait_until_safe + # 8s,再串行烧文案轮询 8s;大麦 ≥9.0.2x 详情页 CTA 为 Canvas 自绘 + # (btn_buy_view 已移除、无任何文案节点),两段全部超时,开抢比配置晚 ~8s。 deadline = sell_time + timedelta(seconds=8) polls = 0 + saw_cta_text = False + guard = getattr(self, "_guard", None) + # 开售前预判 CTA 是否为 Canvas 自绘(大麦 ≥9.0.2x):读不到任何文案、但能 + # 按候选 resource-id 定位到中心坐标。若是,则开售前不再每轮跑昂贵的 + # _is_sale_ready(对 Canvas 详情页恒 False,单次 ~1s 串行 u2 查询)——否则 + # 当 sell_time 落在某轮 in-flight 的 _is_sale_ready 期间,开抢会被这一次 + # 查询整体拖后 ~1 个周期(真机实测晚 ~2s)。get_current_text 在前,短路 + # 保证 v8.x(有文案)路径不会触发多余的坐标查询(issue #41 收尾)。 + canvas_cta = False + if guard is not None: + try: + canvas_cta = ( + guard.get_current_text() is None + and guard.get_cta_center_coords() is not None + ) + except Exception: + canvas_cta = False loop_t0 = time.monotonic() - while datetime.now(tz=_tz_shanghai) < deadline: + while True: + now = datetime.now(tz=_tz_shanghai) + if now >= deadline: + break polls += 1 + + # (0) Canvas 自绘 CTA 快路径(大麦 ≥9.0.2x,issue #41 核心+收尾): + # 开售前只做 ~20ms 短睡、绝不做任何 u2 文案查询(get_current_text + # 对纯 Canvas 容器要遍历子树 ~1s,恒返回 None,每轮查询只会把开抢 + # 时刻整体拖后 ~1 个周期);到点即按候选 resource-id 中心坐标兜底 + # 返回,点击交给下游 _enter_purchase_flow_from_detail_page 的容器 + # ID/坐标路径(预约风险由 SKU 页 reservation_mode 检测兜底)。置于 + # 文案查询之前,把开抢延迟从 ~1 个 u2 查询周期收紧到一个短睡间隔。 + if canvas_cta and not saw_cta_text: + if now < sell_time: + time.sleep(0.02) + continue + coords = guard.get_cta_center_coords() + if coords is not None: + log_event( + logger, + "cta_canvas_fallback", + resource_id=guard._last_matched_resource_id, + coords=coords, + waited_ms=int((time.monotonic() - loop_t0) * 1000), + polls=polls, + ) + return + # 坐标意外丢失:放弃 Canvas 快路径,回落通用轮询兜底。 + canvas_cta = False + + # (1) v8.x 文案路径:读到安全购买文案立即返回(保留开售前文案翻转 + # 即提前返回的能力,SAFE/BLOCKED 校验行为不变)。 + text = guard.get_current_text() if guard is not None else None + if text: + saw_cta_text = True + if guard.is_safe_to_click(text): + log_event( + logger, + "sale_ready", + source="buy_button_guard", + cta_text=text, + polls=polls, + duration_ms=int((time.monotonic() - loop_t0) * 1000), + ) + return + + # (2) Canvas 自绘 CTA 兜底(issue #41 核心):已到开售时刻、全程未读到 + # 任何 CTA 文案、且能按候选 resource-id 定位到 CTA 中心坐标时立即 + # 返回,把点击交给下游 _enter_purchase_flow_from_detail_page 的 + # 容器 ID/坐标路径(预约风险由 SKU 页 reservation_mode 检测兜底)。 + # 必须置于 _is_sale_ready 之前,避免被其每轮 ~1s 的串行 u2 查询拖后。 + if now >= sell_time and not saw_cta_text and guard is not None: + coords = guard.get_cta_center_coords() + if coords is not None: + log_event( + logger, + "cta_canvas_fallback", + resource_id=guard._last_matched_resource_id, + coords=coords, + waited_ms=int((time.monotonic() - loop_t0) * 1000), + polls=polls, + ) + return + + # (3) 老版页面结构兜底:多购买信号文案轮询(行为不变)。 if self._is_sale_ready(): cta_text = getattr(self, "_last_sale_ready_text", None) or "?" log_event( @@ -161,7 +230,7 @@ def wait_for_sale_start(self): duration_ms=int((time.monotonic() - loop_t0) * 1000), ) return - time.sleep(0.08) + time.sleep(0.05) log_event( logger, diff --git a/mobile/damai_app/state_probe.py b/mobile/damai_app/state_probe.py index e33703d..be5e939 100644 --- a/mobile/damai_app/state_probe.py +++ b/mobile/damai_app/state_probe.py @@ -219,8 +219,8 @@ def _probe_current_page_element_based(self): By.ID, "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" ) detail_price_summary = self._has_element( - By.ID, "cn.damai:id/project_detail_price_layout" - ) + By.ID, "cn.damai:id/info_v2_price_layout" + ) or self._has_element(By.ID, "cn.damai:id/project_detail_price_layout") sku_price_container = ( self._has_element( By.ID, "cn.damai:id/project_detail_perform_price_flowlayout" diff --git a/mobile/env_snapshot.py b/mobile/env_snapshot.py index a7acc16..37aa0a3 100644 --- a/mobile/env_snapshot.py +++ b/mobile/env_snapshot.py @@ -12,11 +12,21 @@ from __future__ import annotations +import logging import re import shutil -import subprocess +import subprocess # 仍需保留:except 元组引用 subprocess.SubprocessError from typing import Optional +try: + from mobile.logger import get_logger, log_event + from mobile.proc_utils import run_captured +except ImportError: # pragma: no cover + from logger import get_logger, log_event # type: ignore[no-redef] + from proc_utils import run_captured # type: ignore[no-redef] + +logger = get_logger(__name__) + # Damai Android package id (matches Config.app_package default). _DAMAI_PACKAGE = "cn.damai" @@ -47,14 +57,18 @@ def _run_adb_pm_dump( cmd.extend(["shell", "pm", "dump", package]) try: - result = subprocess.run( # noqa: S603 — adb is a trusted binary - cmd, - capture_output=True, - text=True, - timeout=timeout_s, - check=False, + # 经 run_captured 显式 UTF-8 解码,修复 Windows GBK 下 pm dump + # 巨量中文输出的 UnicodeDecodeError(issue #50)。 + result = run_captured(cmd, timeout=timeout_s, check=False) + except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as exc: + # UnicodeDecodeError 为纵深防御:errors='replace' 后理论上不再抛, + # 但保持 best-effort 契约(任何失败 → None → damai_version=unknown)。 + log_event( + logger, + "env_snapshot_adb_failed", + level=logging.DEBUG, + reason=type(exc).__name__, ) - except (OSError, subprocess.SubprocessError): return None if result.returncode != 0: diff --git a/mobile/event_navigator.py b/mobile/event_navigator.py index 5e6a96e..240dfaa 100644 --- a/mobile/event_navigator.py +++ b/mobile/event_navigator.py @@ -21,9 +21,19 @@ from mobile.logger import get_logger, log_event try: - from mobile.item_resolver import normalize_text, city_keyword + from mobile.item_resolver import ( + city_keyword, + find_conflicting_city, + normalize_text, + title_similarity, + ) except ImportError: - from item_resolver import normalize_text, city_keyword + from item_resolver import ( # type: ignore[no-redef] + city_keyword, + find_conflicting_city, + normalize_text, + title_similarity, + ) try: from mobile.date_utils import normalize_date @@ -36,6 +46,18 @@ logger = get_logger(__name__) +# issue #51+#50 搜索/标题匹配阈值(离线校准,调参原则:负向信号——城市/年份 +# 冲突——永远优先于放松匹配): +# - _TITLE_FUZZY_THRESHOLD:标题模糊回退的相似度下限(词序颠倒 0.870 / +# 官方词序 0.823 / 省略号截断 0.854 需通过;无关演出 <=0.31 需拒绝) +# - _CLICK_SCORE_THRESHOLD:搜索卡点击分数阈值(原硬编码 60 提取为常量) +# - _SIMILARITY_BONUS_MIN:打分相似度加分的下限(#50 巡演写法 0.482 需加分, +# 无关演出 0.314 不加分) +_TITLE_FUZZY_THRESHOLD = 0.75 +_CLICK_SCORE_THRESHOLD = 60 +_SIMILARITY_BONUS_MIN = 0.45 + + # Re-export the page-recovery helpers so callers and tests can keep importing # from ``mobile.event_navigator``. Implementations live in # :mod:`mobile.page_helpers` to keep this module under the 800-line ceiling. @@ -258,6 +280,10 @@ def __init__(self, device, config, probe: PageProbe, bot=None) -> None: self._config = config self._probe = probe self._bot = bot # DamaiBot reference for delegation + # discover_target_event 最终失败时的候选缓存(issue #51+#50: + # prompt_runner 通过 DamaiBot._last_failed_candidates 只读 property + # 读取,用于失败文案中列出 top-5 候选引导用户补全标题) + self._last_failed_candidates: list = [] def set_bot(self, bot) -> None: """Set the DamaiBot reference (breaks circular init dependency).""" @@ -305,6 +331,25 @@ def _title_matches_target(self, title_text): if not normalized_title: return False + # 城市冲突 veto(issue #50 反向风险收紧):目标城市明确、标题却写着 + # 另一个已知城市时直接拒绝——置于所有通过路径之前,防止错城市场次 + # 被全 token 命中放行。isinstance 守卫兼容 MagicMock config。 + target_city = ( + self._config.city if isinstance(self._config.city, str) else None + ) + if target_city: + conflict_city = find_conflicting_city(normalized_title, target_city) + if conflict_city: + log_event( + logger, + "title_city_conflict", + level=logging.WARNING, + title=title_text, + conflict_city=conflict_city, + target_city=target_city, + ) + return False + candidates = [] if bot.item_detail: candidates.extend( @@ -331,10 +376,36 @@ def _title_matches_target(self, title_text): ): return True + # 模糊回退(issue #51):词序颠倒/短标题前缀/UI 省略号截断等文案变体 + # 在精确路径全部失败后按相似度兜底。 + best_similarity = 0.0 + best_candidate = None + for candidate in candidates: + similarity = title_similarity(candidate, title_text) + if similarity > best_similarity: + best_similarity = similarity + best_candidate = candidate + if best_similarity >= _TITLE_FUZZY_THRESHOLD: + log_event( + logger, + "title_fuzzy_match", + title=title_text, + candidate=best_candidate, + similarity=round(best_similarity, 3), + ) + return True + return False - def _current_page_matches_target(self, page_probe): - """Check if the current detail/sku page already points at the expected event.""" + def _current_page_matches_target(self, page_probe, clicked_title=None): + """Check if the current detail/sku page already points at the expected event. + + Args: + page_probe: 页面状态探测结果。 + clicked_title: 刚点击的搜索卡片标题原文(issue #51:详情页标题与 + 搜索卡片是两套文案,用「刚点击的卡片」锚定校验可容忍词序/ + 短标题/截断差异)。``None`` 时保持旧行为。 + """ bot = self._bot if page_probe["state"] not in {"detail_page", "sku_page"}: return False @@ -346,7 +417,55 @@ def _current_page_matches_target(self, page_probe): ): return True - return bot._title_matches_target(bot._get_detail_title_text()) + detail_title = bot._get_detail_title_text() + + if clicked_title: + if not detail_title: + # 详情页标题偶发未渲染读空:短重试一次再回落(对抗审查补充 3a) + time.sleep(0.3) + detail_title = bot._get_detail_title_text() + if not detail_title: + # clicked_title 高分但详情标题为空:显式记录,不静默 False + log_event( + logger, + "detail_title_empty", + level=logging.WARNING, + clicked_title=clicked_title, + ) + else: + normalized_clicked = normalize_text(clicked_title) + normalized_detail = normalize_text(detail_title) + similarity = title_similarity(clicked_title, detail_title) + if ( + normalized_clicked + and normalized_detail + and ( + normalized_clicked in normalized_detail + or normalized_detail in normalized_clicked + ) + ) or similarity >= _TITLE_FUZZY_THRESHOLD: + log_event( + logger, + "detail_title_verified", + match="clicked_card", + similarity=round(similarity, 3), + ) + return True + + if bot._title_matches_target(detail_title): + return True + + if clicked_title: + log_event( + logger, + "detail_title_mismatch", + level=logging.WARNING, + detail_title=detail_title, + clicked_title=clicked_title, + keyword=self._config.keyword, + similarity=round(title_similarity(clicked_title, detail_title), 3), + ) + return False def _open_search_from_homepage(self): """Enter the homepage search flow.""" @@ -447,8 +566,15 @@ def _submit_search_keyword(self): return True - def _score_search_result(self, title_text, venue_text): - """Score a search result against the configured target.""" + def _score_search_result(self, title_text, venue_text, city_text=None): + """Score a search result against the configured target. + + Args: + title_text: 搜索卡片标题(tv_project_name)。 + venue_text: 搜索卡片场馆(tv_project_venueName)。 + city_text: 搜索卡片城市字段(tv_project_city,issue #50:此前被 + 采集却不参与打分)。``None`` 时跳过城市字段加/罚分。 + """ bot = self._bot normalized_title = normalize_text(title_text) normalized_venue = normalize_text(venue_text) @@ -491,6 +617,45 @@ def _score_search_result(self, title_text, venue_text): if expected_venue and expected_venue in normalized_venue: score += 30 + # 相似度分(issue #50:「巡演」等写法不含连写「演唱会」时,token 分 + # 不够过点击阈值;按 keyword 相似度补分,无关演出 <0.45 不加分) + keyword_for_similarity = ( + self._config.keyword if isinstance(self._config.keyword, str) else None + ) + if keyword_for_similarity: + similarity = title_similarity(keyword_for_similarity, title_text) + if similarity >= _SIMILARITY_BONUS_MIN: + score += int(50 * similarity) + + # 城市字段分(issue #50:tv_project_city 参与打分)。加分为 +10 而非 + # +20——对抗审查修正 2:+20 会把「同城无关演出」精确推到点击阈值 60。 + target_city = ( + self._config.city if isinstance(self._config.city, str) else None + ) + if target_city and isinstance(city_text, str) and city_text: + normalized_city_text = normalize_text(city_text) + normalized_target_city = normalize_text(city_keyword(target_city)) + if normalized_target_city and normalized_target_city in normalized_city_text: + score += 10 + elif find_conflicting_city(city_text, target_city): + score -= 80 + log_event( + logger, + "search_result_city_conflict", + title=title_text, + city_text=city_text, + target_city=target_city, + ) + + # 年份冲突罚分:keyword 与标题都写明 4 位年份且无交集时罚 60 分, + # 防同名跨年巡演误配(限定 19xx/20xx,避免误伤票价类 4 位数字) + if keyword_for_similarity and isinstance(title_text, str): + year_pattern = r"(? best_score: best_score = score best_match = card + best_title = title_text - if best_match is not None and best_score >= 60: + if best_match is not None and best_score >= _CLICK_SCORE_THRESHOLD: bot._click_element_center(best_match) detail_probe = bot.wait_for_page_state( {"detail_page", "sku_page"}, timeout=5.5 @@ -578,7 +752,9 @@ def _open_target_from_search_results( if detail_probe["state"] in { "detail_page", "sku_page", - } and bot._current_page_matches_target(detail_probe): + } and bot._current_page_matches_target( + detail_probe, clicked_title=best_title + ): collected.sort(key=lambda item: item["score"], reverse=True) details = { "opened": True, @@ -589,6 +765,7 @@ def _open_target_from_search_results( logger.warning( "已进入详情页,但标题与目标演出不一致,返回搜索结果继续尝试" ) + rejected_titles.add(normalize_text(best_title)) if not bot._press_keycode_safe(4, context="返回搜索列表"): break time.sleep(0.25) @@ -710,6 +887,7 @@ def discover_target_event( """Try multiple keywords, collect candidate summaries, and open the best match.""" bot = self._bot bot._last_discovery_step_timings = [] + self._last_failed_candidates = [] page_probe = initial_probe or bot.probe_current_page() page_probe = bot._recover_to_navigation_start(page_probe) @@ -751,6 +929,7 @@ def discover_target_event( return None tried = set() + failed_candidates: List[Dict[str, Any]] = [] for keyword in keyword_candidates: normalized_keyword = normalize_text(keyword) if not normalized_keyword or normalized_keyword in tried: @@ -807,8 +986,36 @@ def discover_target_event( "step_timings": list(bot._last_discovery_step_timings), } + for item in search_results: + entry = dict(item) + entry["used_keyword"] = keyword + failed_candidates.append(entry) + tried.add(normalized_keyword) + # 最终失败:合并各关键词轮次候选(按标题归一化去重,重复取高分), + # 按 score 降序缓存 top-5,供 prompt_runner 失败文案引导用户补全标题。 + # 契约保持不变:失败仍 return None(调用方依赖 falsy 判定)。 + merged: Dict[str, Dict[str, Any]] = {} + for entry in failed_candidates: + key = normalize_text(str(entry.get("title") or "")) + if not key: + continue + existing = merged.get(key) + if existing is None or entry.get("score", 0) > existing.get("score", 0): + merged[key] = entry + top_candidates = sorted( + merged.values(), key=lambda item: item.get("score", 0), reverse=True + )[:5] + self._last_failed_candidates = top_candidates + log_event( + logger, + "discovery_failed", + level=logging.WARNING, + keywords_tried=len(tried), + candidates=len(top_candidates), + top_score=top_candidates[0].get("score") if top_candidates else None, + ) logger.warning("根据提示词尝试多个搜索关键词后,仍未打开目标演出") return None diff --git a/mobile/item_resolver.py b/mobile/item_resolver.py index b17b52f..4659248 100644 --- a/mobile/item_resolver.py +++ b/mobile/item_resolver.py @@ -7,6 +7,7 @@ import json import re import time +from collections import Counter from dataclasses import dataclass from http.cookiejar import CookieJar from typing import Optional @@ -75,6 +76,130 @@ def city_keyword(city_name: Optional[str]) -> str: return re.sub(r"(特别行政区|自治州|地区|盟|市)$", "", city_name.strip()) +# 已知城市 token 全集(issue #51+#50:自 mobile/prompt_parser.py 迁入, +# prompt_parser 通过别名 ``_KNOWN_CITY_TOKENS`` 保持向后兼容)。 +KNOWN_CITY_TOKENS = ( + "北京", + "上海", + "深圳", + "广州", + "杭州", + "成都", + "重庆", + "武汉", + "南京", + "西安", + "苏州", + "天津", + "长沙", + "郑州", + "青岛", + "宁波", + "福州", + "厦门", + "南昌", + "沈阳", + "大连", + "合肥", + "无锡", + "佛山", + "东莞", + "珠海", + "昆明", + "贵阳", + "南宁", + "长春", + "哈尔滨", + "太原", + "石家庄", + "济南", + "兰州", + "海口", + "三亚", + "乌鲁木齐", + "呼和浩特", +) + + +def _char_bigram_counts(text: str) -> Counter: + """字符 bigram 多重集(multiset 口径,对抗审查修正 3b 钉死)。""" + return Counter(text[i : i + 2] for i in range(len(text) - 1)) + + +def title_similarity(candidate: str, title: str) -> float: + """计算候选串与标题的模糊相似度(issue #51+#50 搜索/标题匹配修复核心)。 + + 规则(离线校准,校准值见 tests/unit/test_mobile_item_resolver.py): + + 1. 双方经 :func:`normalize_text` 归一化,任一为空返回 ``0.0``; + 2. 互为连续子串返回 ``1.0``(与既有精确路径语义一致); + 3. 否则返回 ``0.5 * 字符覆盖率 + 0.5 * 字符 bigram Dice 系数``, + 两项均按 multiset(重复字符计次)口径统计。 + + 校准参考:同事件词序颠倒 0.870、官方词序变体 0.823、省略号截断 0.854、 + 无关演出 <= 0.31。纯函数、无外部依赖。 + """ + if not isinstance(candidate, str) or not isinstance(title, str): + # 防御:MagicMock/None 等非字符串输入一律视为不相似 + return 0.0 + + normalized_candidate = normalize_text(candidate) + normalized_title = normalize_text(title) + if not normalized_candidate or not normalized_title: + return 0.0 + if ( + normalized_candidate in normalized_title + or normalized_title in normalized_candidate + ): + return 1.0 + + # 字符覆盖率:candidate 字符被 title 覆盖的比例(multiset) + title_chars = Counter(normalized_title) + covered = sum( + min(count, title_chars.get(char, 0)) + for char, count in Counter(normalized_candidate).items() + ) + coverage = covered / len(normalized_candidate) + + # 字符 bigram Dice 系数(multiset) + candidate_bigrams = _char_bigram_counts(normalized_candidate) + title_bigrams = _char_bigram_counts(normalized_title) + overlap = sum( + min(count, title_bigrams.get(bigram, 0)) + for bigram, count in candidate_bigrams.items() + ) + total = sum(candidate_bigrams.values()) + sum(title_bigrams.values()) + dice = (2 * overlap / total) if total else 0.0 + + return 0.5 * coverage + 0.5 * dice + + +def find_conflicting_city(text: Optional[str], target_city: Optional[str]) -> Optional[str]: + """返回 ``text`` 中出现的第一个不等于 ``target_city`` 的已知城市 token。 + + ``target_city`` 本身也出现在 ``text`` 中时返回 ``None``,避免 + 「北京·上海联演」这类多城市文案被误伤(issue #50 错城市 veto 依据)。 + 任一入参为空/非字符串时返回 ``None``。 + """ + if not isinstance(text, str) or not isinstance(target_city, str): + return None + + normalized_text = normalize_text(text) + normalized_target = normalize_text(city_keyword(target_city)) + if not normalized_text or not normalized_target: + return None + if normalized_target in normalized_text: + return None + + for city in KNOWN_CITY_TOKENS: + normalized_city = normalize_text(city) + if normalized_city == normalized_target: + continue + if normalized_city in normalized_text: + return city + return None + + def build_search_keyword( item_name: Optional[str], item_name_display: Optional[str] = None ) -> str: diff --git a/mobile/page_probe.py b/mobile/page_probe.py index 75ec9e2..d004d26 100644 --- a/mobile/page_probe.py +++ b/mobile/page_probe.py @@ -117,6 +117,14 @@ def detect_price_panel_state(driver: Any) -> str: ("SearchActivity", "search_page"), ) +# 详情页价格区容器候选(多版本兼容):大麦 9.0.2x 详情页已移除 +# project_detail_price_layout,价格区改为 info_v2_price_layout(2026-07-11 +# 真机 dump 证实)。新 ID 在前,现网主流版本一次命中少一次元素查询。 +_DETAIL_PRICE_LAYOUT_IDS = ( + "cn.damai:id/info_v2_price_layout", + "cn.damai:id/project_detail_price_layout", +) + # Default result template _DEFAULT_RESULT: Dict[str, Any] = { "state": "unknown", @@ -331,7 +339,17 @@ def _probe_full(self) -> Dict[str, Any]: purchase_bar = self._exists_by_resource_id( "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" ) - return _make_result(state="detail_page", purchase_button=purchase_bar) + # price_container 不能停留在默认 False:probe_only 就绪判定 + # (orchestrator)依赖它,否则安全探测在 detail_page 上永远「未就绪」。 + # 本探测发生在开售等待之前,不在点击临界路径上。 + price_layout = any( + self._exists_by_resource_id(rid) for rid in _DETAIL_PRICE_LAYOUT_IDS + ) + return _make_result( + state="detail_page", + purchase_button=purchase_bar, + price_container=price_layout, + ) if "NcovSku" in activity: if self._has_session_picker_markers(): @@ -396,8 +414,8 @@ def _probe_full(self) -> Dict[str, Any]: purchase_bar = self._exists_by_resource_id( "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" ) - price_layout = self._exists_by_resource_id( - "cn.damai:id/project_detail_price_layout" + price_layout = any( + self._exists_by_resource_id(rid) for rid in _DETAIL_PRICE_LAYOUT_IDS ) title_tv = self._exists_by_resource_id("cn.damai:id/title_tv") if purchase_bar or price_layout or title_tv: diff --git a/mobile/price_selector.py b/mobile/price_selector.py index f2633d6..cb3d9ad 100644 --- a/mobile/price_selector.py +++ b/mobile/price_selector.py @@ -846,6 +846,8 @@ def _ocr_price_text_from_card(self, screenshot_path, rect): candidates = [] for variant_name, crop_rect, extra_args in crop_variants: + # bytes 模式(不消费 stdout),勿改为 text=True——Windows GBK + # locale 下会触发解码问题(见 issue #50 / mobile/proc_utils.py)。 subprocess.run( [ _MAGICK_BIN, @@ -863,6 +865,8 @@ def _ocr_price_text_from_card(self, screenshot_path, rect): ) for psm in ("13", "7", "11", "6"): + # bytes 模式,解码在下方 result.stdout.decode("utf-8", "ignore") + # 显式完成,勿改为 text=True(Windows GBK 兼容,见 issue #50)。 result = subprocess.run( [ _TESSERACT_BIN, diff --git a/mobile/proc_utils.py b/mobile/proc_utils.py new file mode 100644 index 0000000..dbeceb4 --- /dev/null +++ b/mobile/proc_utils.py @@ -0,0 +1,55 @@ +# -*- coding: UTF-8 -*- +"""统一的 subprocess 文本模式封装(issue #50:Windows GBK 解码崩溃修复)。 + +背景:Windows(简体中文)上 ``subprocess.run(text=True)`` 未显式传 encoding 时 +会按 locale 首选编码(cp936/GBK)解码子进程输出;``adb shell pm dump`` 等命令 +输出的 UTF-8 中文字节按 GBK 对齐后可能落到非法组合上,在 Windows 读取线程内 +抛出 UnicodeDecodeError(子线程崩溃、stdout 变 None)。 + +规则:``mobile/`` 内任何 text 模式的 subprocess 调用必须经由本模块的 +:func:`run_captured` 封装,或自行显式传 ``encoding`` 参数 +(tests/unit/test_proc_utils.py 内有 AST 守卫测试强制此规则)。 + +说明:不给子进程设置 ``PYTHONIOENCODING``——子进程全是原生二进制 +(adb/magick/tesseract)而非 Python,该环境变量对其无效;adb 转发的本就是 +设备端 UTF-8 字节流,问题纯在父进程解码侧。 +""" + +from __future__ import annotations + +import subprocess +from typing import Optional + + +def run_captured( + cmd, + *, + timeout: Optional[float] = None, + check: bool = False, + env: Optional[dict] = None, +) -> subprocess.CompletedProcess: + """以 UTF-8 文本模式执行命令并捕获 stdout/stderr。 + + 显式 ``encoding="utf-8"`` 使 Windows/macOS/Linux 行为一致;macOS/Linux + 本就是 UTF-8 locale,正常路径输出逐字节相同、零行为变化。 + ``errors="replace"`` 只在此前必崩的非法字节场景生效(替换为 U+FFFD)。 + + Args: + cmd: 命令及参数列表(与 ``subprocess.run`` 首参一致)。 + timeout: 超时秒数;超时抛 ``subprocess.TimeoutExpired``(原样透传)。 + check: 为 True 时非零退出码抛 ``subprocess.CalledProcessError``(原样透传)。 + env: 传给子进程的环境变量映射;None 表示继承当前进程环境。 + + Returns: + ``subprocess.CompletedProcess``,其 stdout/stderr 为 UTF-8 解码后的 str。 + """ + return subprocess.run( # noqa: S603 — 调用方保证命令来源可信 + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=check, + env=env, + ) diff --git a/mobile/prompt_parser.py b/mobile/prompt_parser.py index 141a353..faeaf5c 100644 --- a/mobile/prompt_parser.py +++ b/mobile/prompt_parser.py @@ -9,9 +9,9 @@ try: from mobile.date_utils import normalize_date as _normalize_date_external - from mobile.item_resolver import normalize_text + from mobile.item_resolver import KNOWN_CITY_TOKENS, normalize_text except ImportError: - from item_resolver import normalize_text + from item_resolver import KNOWN_CITY_TOKENS, normalize_text # type: ignore[no-redef] _CHINESE_DIGITS = { @@ -29,47 +29,9 @@ "十": 10, } -_KNOWN_CITY_TOKENS = ( - "北京", - "上海", - "深圳", - "广州", - "杭州", - "成都", - "重庆", - "武汉", - "南京", - "西安", - "苏州", - "天津", - "长沙", - "郑州", - "青岛", - "宁波", - "福州", - "厦门", - "南昌", - "沈阳", - "大连", - "合肥", - "无锡", - "佛山", - "东莞", - "珠海", - "昆明", - "贵阳", - "南宁", - "长春", - "哈尔滨", - "太原", - "石家庄", - "济南", - "兰州", - "海口", - "三亚", - "乌鲁木齐", - "呼和浩特", -) +# issue #51+#50:城市 token 全集迁移至 mobile/item_resolver.KNOWN_CITY_TOKENS +# (event_navigator 的城市冲突 veto 也要用)。保留旧名别名,兼容既有 import。 +_KNOWN_CITY_TOKENS = KNOWN_CITY_TOKENS _REQUEST_STOPWORDS = ( "帮我", @@ -311,7 +273,9 @@ def _compact_keyword_phrase(value: str) -> str: def _parse_artist_and_keyword( - prompt: str, removable_tokens: Optional[Iterable[str]] = None + prompt: str, + removable_tokens: Optional[Iterable[str]] = None, + city: Optional[str] = None, ) -> tuple[Optional[str], Optional[str], list[str]]: cleaned = _clean_prompt_for_keyword(prompt, removable_tokens=removable_tokens) @@ -331,6 +295,21 @@ def _parse_artist_and_keyword( if artist: candidates.extend([f"{artist} 演唱会", artist]) + # issue #51:城市全局 replace 会把「龙拳·北京」这类标题自带前缀拦腰挖成 + # 「龙拳·」。追加一个「保留城市版」完整短语候选(removable_tokens 中剔除 + # 城市与「城市+站」后重新清洗),插入 index=2——search_keyword 与 + # candidate_keywords[:2] 保持不变,不破坏既有顺序锁定。 + if city and artist: + city_tokens = {city, f"{city}站"} + tokens_without_city = [ + token for token in (removable_tokens or ()) if token not in city_tokens + ] + city_preserving = _clean_prompt_for_keyword( + prompt, removable_tokens=tokens_without_city + ) + if city_preserving: + candidates.insert(min(2, len(candidates)), city_preserving) + if cleaned: candidates.append(cleaned) @@ -352,6 +331,110 @@ def _parse_artist_and_keyword( return artist, (deduped[0] if deduped else None), deduped +# --------------------------------------------------------------------------- +# issue #45:票价解析前的日程片段剥离 +# +# 旧版宽松价格正则「([1-9]\d{1,4})\s*元?」中「元」可选且取首个匹配,导致 +# 「5月30号」的日份、「23点/23:00」的开抢时间、「抢12张」的张数等数字 +# 抢先被当成票价(如「5月30号…票价1380元」误判为 30 元)。 +# 修复思路与 _parse_quantity / _clean_prompt_for_keyword 的既有惯例对齐: +# 先剥离日期/时间/张数片段,再做价格匹配;日期剥离形态与 +# mobile/date_utils._PATTERNS 保持一致,保证「日期能识别的片段价格一定不误食」。 +# --------------------------------------------------------------------------- + +_SCHEDULE_FRAGMENT_PATTERNS = ( + # 5月30号 / 4 月 6 日 / 04月06日 + re.compile(r"\d{1,2}\s*月\s*\d{1,2}\s*[号日好]?"), + # 2026-04-06 / 2026/04/06 / 2026.04.06(带年份的完整日期) + re.compile(r"(? str: + month = int(match.group(1)) + day = int(match.group(2)) + if 1 <= month <= 12 and 1 <= day <= 31: + return " " + return match.group(0) + + +def _strip_schedule_fragments(text: str) -> str: + """剥离日期/时间/张数片段,避免票价解析误食其中的数字(issue #45)。""" + stripped = text or "" + for pattern in _SCHEDULE_FRAGMENT_PATTERNS: + stripped = pattern.sub(" ", stripped) + stripped = _SHORT_DATE_PATTERN.sub(_replace_short_date_if_valid, stripped) + return _QUANTITY_FRAGMENT_PATTERN.sub(" ", stripped) + + +# seat token 预剥离顺序:按长度降序,保证「看台区」先于「看台」被替换 +_SEAT_TOKENS_BY_LENGTH = tuple( + sorted(set(_SEAT_HINTS), key=lambda token: (-len(token), token)) +) + +# Tier1 上下文锚定:带「票价/价格/¥/元」等价格语境的数字优先命中 +_PRICE_ANCHORED_PATTERNS = ( + re.compile(r"(?:票价|价格|价位|单价)\s*[为是::]?\s*([1-9]\d{1,4})(?!\d)"), + re.compile(r"[¥¥]\s*([1-9]\d{1,4})(?!\d)"), + re.compile(r"([1-9]\d{1,4})(?!\d)\s*元"), +) + +# Tier2 宽松回退:无价格语境的裸数字(兼容「内场280」「看台票 899」等输入)。 +# (? Optional[int]: + """从提示词中提取票价数字(issue #45 修复核心)。 + + 步骤: + 1. 剥离日期/时间/张数片段(``_strip_schedule_fragments``); + 2. 把 seat token 替换为空格,使「VIP1680」的数字独立成词 + (否则 Tier2 的 lookbehind 会挡掉紧贴 seat 的价格); + 3. 两层匹配:Tier1 上下文锚定优先,Tier2 宽松回退;层内取首个匹配, + 保持现行「多候选取第一个」语义。 + """ + stripped = _strip_schedule_fragments(prompt or "") + for token in _SEAT_TOKENS_BY_LENGTH: + stripped = stripped.replace(token, " ") + for pattern in _PRICE_ANCHORED_PATTERNS: + match = pattern.search(stripped) + if match: + return int(match.group(1)) + loose_match = _PRICE_LOOSE_PATTERN.search(stripped) + if loose_match: + return int(loose_match.group(1)) + return None + + def _parse_price_hints( prompt: str, ) -> tuple[Optional[str], Optional[str], Optional[int]]: @@ -361,10 +444,7 @@ def _parse_price_hints( seat_hint = token break - numeric_price = None - numeric_match = re.search(r"([1-9]\d{1,4})\s*元?", prompt) - if numeric_match: - numeric_price = int(numeric_match.group(1)) + numeric_price = _extract_numeric_price(prompt) if seat_hint and numeric_price: return f"{seat_hint}{numeric_price}元", seat_hint, numeric_price @@ -376,10 +456,15 @@ def _parse_price_hints( def _parse_price_range(prompt: str) -> tuple[Optional[int], Optional[int]]: - """从 ``500-800元`` / ``500到800`` 等区间表达中识别 (min, max)。""" + """从 ``500-800元`` / ``500到800`` 等区间表达中识别 (min, max)。 + + 先剥离日程片段:避免「12-15」这类短横线日期被误判为价格区间 + (issue #45 同族缺陷)。 + """ + stripped = _strip_schedule_fragments(prompt or "") match = re.search( r"([1-9]\d{1,4})\s*[-~到至]\s*([1-9]\d{1,4})\s*元?", - prompt or "", + stripped, ) if not match: return None, None @@ -434,6 +519,7 @@ def parse_prompt(prompt: str) -> "ParseResult": artist, keyword, candidate_keywords = _parse_artist_and_keyword( normalized_prompt, removable_tokens=removable_tokens, + city=parsed_city, ) intent = PromptIntent( @@ -470,6 +556,17 @@ def parse_prompt(prompt: str) -> "ParseResult": if not intent.price_hint: intent.notes.append("提示词中未识别到明确票档偏好,后续会使用查询结果确认票档") + # issue #45 可观测性:票价/区间是在剥离日程片段后解析出来的时, + # 同时写入 notes(summary「提示:」段可见)与 diagnostics(不可执行路径可见) + price_recognized = numeric_price is not None or ( + price_min is not None and price_max is not None + ) + schedule_fragments_stripped = ( + _strip_schedule_fragments(normalized_prompt) != normalized_prompt + ) + if price_recognized and schedule_fragments_stripped: + intent.notes.append(_SCHEDULE_STRIP_NOTE) + diagnostics: list[str] = [] confidence = 0.0 if intent.search_keyword: @@ -494,6 +591,8 @@ def parse_prompt(prompt: str) -> "ParseResult": ) else: diagnostics.append("未识别到票档偏好,将依赖页面默认推荐") + if price_recognized and schedule_fragments_stripped: + diagnostics.append(_SCHEDULE_STRIP_NOTE) if intent.city: confidence += 0.05 diagnostics.append(f"识别目标城市:{intent.city}(+0.05)") diff --git a/mobile/prompt_runner.py b/mobile/prompt_runner.py index 4ae5a2f..2c3f9f8 100644 --- a/mobile/prompt_runner.py +++ b/mobile/prompt_runner.py @@ -24,6 +24,7 @@ ) from mobile.damai_app import DamaiBot from mobile.logger import get_logger + from mobile.proc_utils import run_captured from mobile.prompt_parser import ( choose_price_option, is_price_option_available, @@ -38,6 +39,7 @@ ) from damai_app import DamaiBot from logger import get_logger + from proc_utils import run_captured from prompt_parser import ( choose_price_option, is_price_option_available, @@ -164,12 +166,9 @@ def _load_base_config_dict(config_path: Path) -> dict: def _list_connected_device_ids() -> list[str] | None: """Return adb-connected Android device ids, or None if adb is unavailable.""" try: - result = subprocess.run( - ["adb", "devices"], - capture_output=True, - text=True, - check=True, - ) + # 经 run_captured 显式 UTF-8 解码,避免 Windows GBK locale 下的 + # 解码问题(issue #50);check=True 语义由封装原样透传。 + result = run_captured(["adb", "devices"], check=True) except (FileNotFoundError, subprocess.CalledProcessError): return None @@ -708,6 +707,39 @@ def parse_args(argv=None): return parser.parse_args(argv) +def _build_discovery_failure_message(bot, intent) -> str: + """discover 失败时组装错误文案(issue #51+#50)。 + + 有候选时列出 top-5 并引导用户把完整演出标题原文写进提示词;候选缺失或 + 非法(MagicMock/旧版 bot 等)时回落到原有错误文案。安全底线:低置信 + 候选一律报错引导,绝不自动点击(误买不可接受)。 + """ + candidates = getattr(bot, "_last_failed_candidates", None) + if not isinstance(candidates, list): + candidates = [] + candidates = [item for item in candidates if isinstance(item, dict)] + if not candidates: + return "未能根据提示词打开目标演出" + + tried_keywords = "、".join(intent.candidate_keywords or []) + lines = [ + f"未能自动确认目标演出(已尝试关键词:{tried_keywords})", + "以下是本次搜索到的候选(按匹配分数降序,最多 5 条):", + ] + for index, item in enumerate(candidates[:5], start=1): + lines.append( + f" {index}. score={item.get('score')} | {item.get('title')}" + f" | {item.get('city') or '-'} | {item.get('venue') or '-'}" + f" | {item.get('time') or '-'}" + ) + lines.append( + "为避免误买,低置信候选不会被自动点击。" + "如目标在列表中,请把完整演出标题原文写进提示词后重试," + "例如:帮<观演人>抢<日期> <完整标题>" + ) + return "\n".join(lines) + + def main(argv=None): args = parse_args(argv) config_path = _config_path(args.config) @@ -764,7 +796,7 @@ def main(argv=None): intent.candidate_keywords, initial_probe=page_probe ) if not discovery: - raise RuntimeError("未能根据提示词打开目标演出") + raise RuntimeError(_build_discovery_failure_message(bot, intent)) discovery["summary"] = bot.inspect_current_target_event( discovery.get("page_probe") diff --git a/tests/unit/test_buy_button_guard.py b/tests/unit/test_buy_button_guard.py index 139a358..9b95115 100644 --- a/tests/unit/test_buy_button_guard.py +++ b/tests/unit/test_buy_button_guard.py @@ -134,3 +134,125 @@ def test_timeout_button_not_found(self, guard, mock_device): mock_time.time.side_effect = [0.0, 11.0] mock_time.sleep = Mock() assert guard.wait_until_safe(timeout_s=10.0, poll_ms=50) is False + + +# ── multi-ID candidates + coordinate fallback (issue #41) ── + + +LEGACY_ID = "cn.damai:id/btn_buy_view" +CONTAINER_ID = "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + + +def _make_element(exists=True, text=None): + """构造单个元素桩(exists 用真实 bool,避免 Mock 恒 truthy 陷阱)。""" + el = Mock() + el.exists = exists + el.get_text.return_value = text + return el + + +def _make_dispatch_device(mapping): + """构造按 resourceId 分派元素桩的假设备;未注册的 ID 返回不存在元素。""" + device = Mock() + + def _selector(**kwargs): + el = mapping.get(kwargs.get("resourceId")) + if el is None: + absent = Mock() + absent.exists = False + return absent + return el + + device.side_effect = _selector + return device + + +class _ExplodingInfoElement: + """exists 为真但读取 .info 抛异常的元素桩。""" + + exists = True + + def get_text(self): + return "" + + @property + def info(self): + raise RuntimeError("boom") + + +class TestMultiIdCandidates: + """issue #41:大麦 ≥9.0.2x 移除 btn_buy_view 后的多 resource-id 候选。""" + + def test_find_buy_button_falls_back_to_container_id(self): + container = _make_element(exists=True, text="") + guard = BuyButtonGuard(_make_dispatch_device({CONTAINER_ID: container})) + el = guard._find_buy_button() + assert el is container + assert guard._last_matched_resource_id == CONTAINER_ID + + def test_find_buy_button_prefers_legacy_id(self): + """v8.x 回归:两个候选同时存在时旧 ID 优先。""" + legacy = _make_element(exists=True, text="立即购票") + container = _make_element(exists=True, text="") + guard = BuyButtonGuard( + _make_dispatch_device({LEGACY_ID: legacy, CONTAINER_ID: container}) + ) + el = guard._find_buy_button() + assert el is legacy + assert guard._last_matched_resource_id == LEGACY_ID + + def test_find_buy_button_none_when_all_absent(self): + guard = BuyButtonGuard(_make_dispatch_device({})) + assert guard._find_buy_button() is None + assert guard._last_matched_resource_id is None + + def test_get_current_text_none_for_canvas_container(self): + """Canvas 容器无文案:get_current_text 维持原语义返回 None。""" + container = _make_element(exists=True, text="") + guard = BuyButtonGuard(_make_dispatch_device({CONTAINER_ID: container})) + assert guard.get_current_text() is None + + def test_wait_until_safe_true_on_v8_text(self): + legacy = _make_element(exists=True, text="立即购票") + guard = BuyButtonGuard(_make_dispatch_device({LEGACY_ID: legacy})) + with patch("mobile.buy_button_guard.time") as mock_time: + mock_time.time.side_effect = [0.0, 0.0] + mock_time.sleep = Mock() + assert guard.wait_until_safe(timeout_s=1.0, poll_ms=50) is True + + def test_wait_until_safe_blocks_reservation_on_container(self): + """安全属性回归:容器读到「预约抢票」时 wait_until_safe 仍拒绝。""" + container = _make_element(exists=True, text="预约抢票") + guard = BuyButtonGuard(_make_dispatch_device({CONTAINER_ID: container})) + with patch("mobile.buy_button_guard.time") as mock_time: + mock_time.time.side_effect = [0.0, 11.0] + mock_time.sleep = Mock() + assert guard.wait_until_safe(timeout_s=10.0, poll_ms=50) is False + + +class TestGetCtaCenterCoords: + """issue #41:Canvas 自绘 CTA 的坐标兜底定位。""" + + def test_center_coords_from_container_bounds(self): + container = _make_element(exists=True, text="") + container.info = { + "bounds": {"left": 341, "top": 2544, "right": 1248, "bottom": 2691} + } + guard = BuyButtonGuard(_make_dispatch_device({CONTAINER_ID: container})) + assert guard.get_cta_center_coords() == (794, 2617) + assert guard._last_matched_resource_id == CONTAINER_ID + + def test_none_when_no_candidate_exists(self): + guard = BuyButtonGuard(_make_dispatch_device({})) + assert guard.get_cta_center_coords() is None + + def test_none_when_info_raises(self): + guard = BuyButtonGuard( + _make_dispatch_device({CONTAINER_ID: _ExplodingInfoElement()}) + ) + assert guard.get_cta_center_coords() is None + + def test_none_when_device_raises(self): + device = Mock(side_effect=Exception("device error")) + guard = BuyButtonGuard(device) + assert guard.get_cta_center_coords() is None diff --git a/tests/unit/test_env_snapshot.py b/tests/unit/test_env_snapshot.py index 592167c..51ac4ad 100644 --- a/tests/unit/test_env_snapshot.py +++ b/tests/unit/test_env_snapshot.py @@ -63,6 +63,10 @@ def test_custom_package_is_forwarded(self): # --------------------------------------------------------------------------- # _run_adb_pm_dump — real-ish behavior with mocked subprocess # +# NOTE: patch 目标是 "mobile.proc_utils.subprocess.run"——env_snapshot 已经改为 +# 经 mobile.proc_utils.run_captured 调用 subprocess(issue #50 UTF-8 修复), +# patch env_snapshot 内的 subprocess 将绕过 mock 真实执行 adb。 +# # NOTE: Avoid Python 3.10's parenthesised ``with (..., ...)`` syntax to keep # the suite parseable on the project's Python 3.8 baseline. # --------------------------------------------------------------------------- @@ -79,7 +83,7 @@ def test_calls_subprocess_run_with_expected_command(self): ) with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): with patch( - "mobile.env_snapshot.subprocess.run", return_value=completed + "mobile.proc_utils.subprocess.run", return_value=completed ) as mock_run: assert _run_adb_pm_dump(serial=None) == "versionName=1.2.3" cmd = mock_run.call_args.args[0] @@ -91,7 +95,7 @@ def test_includes_serial_flag_when_provided(self): ) with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): with patch( - "mobile.env_snapshot.subprocess.run", return_value=completed + "mobile.proc_utils.subprocess.run", return_value=completed ) as mock_run: _run_adb_pm_dump(serial="dev1") cmd = mock_run.call_args.args[0] @@ -102,13 +106,13 @@ def test_returns_none_on_non_zero_exit(self): args=["adb"], returncode=1, stdout="", stderr="error" ) with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): - with patch("mobile.env_snapshot.subprocess.run", return_value=completed): + with patch("mobile.proc_utils.subprocess.run", return_value=completed): assert _run_adb_pm_dump() is None def test_returns_none_on_subprocess_timeout(self): with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): with patch( - "mobile.env_snapshot.subprocess.run", + "mobile.proc_utils.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="adb", timeout=3.0), ): assert _run_adb_pm_dump() is None @@ -116,7 +120,7 @@ def test_returns_none_on_subprocess_timeout(self): def test_returns_none_on_oserror(self): with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): with patch( - "mobile.env_snapshot.subprocess.run", + "mobile.proc_utils.subprocess.run", side_effect=FileNotFoundError("adb missing at runtime"), ): assert _run_adb_pm_dump() is None @@ -126,7 +130,32 @@ def test_returns_none_when_stdout_is_empty(self): args=["adb"], returncode=0, stdout="", stderr="" ) with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): - with patch("mobile.env_snapshot.subprocess.run", return_value=completed): + with patch("mobile.proc_utils.subprocess.run", return_value=completed): + assert _run_adb_pm_dump() is None + + def test_pm_dump_passes_utf8_encoding(self): + """issue #50 回归锁:pm dump 必须显式 encoding=utf-8 + errors=replace。""" + completed = subprocess.CompletedProcess( + args=["adb"], returncode=0, stdout="versionName=9.0.26", stderr="" + ) + with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): + with patch( + "mobile.proc_utils.subprocess.run", return_value=completed + ) as mock_run: + assert _run_adb_pm_dump(serial="c6c4eb67") == "versionName=9.0.26" + kwargs = mock_run.call_args.kwargs + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + + def test_pm_dump_returns_none_on_unicode_decode_error(self): + """模拟 Windows GBK 解码崩溃语义:UnicodeDecodeError → None 不抛异常。""" + with patch("mobile.env_snapshot.shutil.which", return_value="/usr/bin/adb"): + with patch( + "mobile.proc_utils.subprocess.run", + side_effect=UnicodeDecodeError( + "gbk", b"\xa7", 0, 1, "illegal multibyte sequence" + ), + ): assert _run_adb_pm_dump() is None diff --git a/tests/unit/test_event_navigator.py b/tests/unit/test_event_navigator.py index 20f0b08..804d04d 100644 --- a/tests/unit/test_event_navigator.py +++ b/tests/unit/test_event_navigator.py @@ -1,6 +1,6 @@ """Unit tests for EventNavigator.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -567,3 +567,264 @@ def test_select_session_falls_back_to_index_when_no_match(self): assert idx == 1 # Center of [540,0][1080,200] = (810, 100) driver.click.assert_called_once_with(810, 100) + + +# --------------------------------------------------------------------------- +# issue #51+#50:标题模糊匹配 / 城市冲突 veto / 打分增强 +# --------------------------------------------------------------------------- + + +class _NavBotShim: + """把 bot 委托回环转发到 EventNavigator 本体的最小替身。 + + 与生产链路一致:navigator 内部通过 ``bot._keyword_tokens()`` / + ``bot._title_matches_target()`` 回环调用(delegators 门面行为)。 + """ + + def __init__(self, nav, detail_title=None): + self._nav = nav + self.item_detail = None + self._detail_title = detail_title + + def _keyword_tokens(self): + return self._nav._keyword_tokens() + + def _title_matches_target(self, title_text): + return self._nav._title_matches_target(title_text) + + def _get_detail_title_text(self): + return self._detail_title + + +def _make_fuzzy_nav( + keyword=None, city=None, target_title=None, target_venue=None, detail_title=None +): + config = MagicMock() + config.keyword = keyword + config.city = city + config.target_title = target_title + config.target_venue = target_venue + nav = EventNavigator(device=MagicMock(), config=config, probe=MagicMock()) + nav.set_bot(_NavBotShim(nav, detail_title=detail_title)) + return nav + + +class TestTitleMatchesTargetFuzzy: + """issue #51 回归:词序/前缀/截断变体经模糊回退通过;无关演出仍拒绝。""" + + def _nav(self, city=None): + return _make_fuzzy_nav(keyword="嘉年华2026周杰伦 演唱会", city=city) + + def test_title_matches_target_fuzzy_word_order(self): + # 修复前 False:词序颠倒(相似度 0.870 >= 0.75) + assert ( + self._nav()._title_matches_target("周杰伦嘉年华2026演唱会(北京站)") + is True + ) + + def test_title_matches_target_official_word_order(self): + # 修复前 False:官方全称词序变体(相似度 0.823) + assert ( + self._nav()._title_matches_target( + "2026周杰伦嘉年华世界巡回演唱会-北京站" + ) + is True + ) + + def test_title_matches_target_truncated_ellipsis(self): + # 修复前 False:UI 省略号截断(相似度 0.854) + assert ( + self._nav()._title_matches_target("龙拳·北京 嘉年华2026周杰伦演唱…") + is True + ) + + def test_title_matches_target_unrelated_false(self): + # 防放松过度:无关演出(相似度 0.272)仍为 False + assert ( + self._nav()._title_matches_target("张学友60+巡回演唱会北京站") is False + ) + + def test_title_matches_target_city_conflict_veto(self): + # issue #50 反向风险收紧:目标广州、标题北京站——修复前全 token + # 命中放行(True),修复后城市冲突 veto 直接拒绝 + nav = _make_fuzzy_nav(keyword="凤凰传奇 演唱会", city="广州") + assert ( + nav._title_matches_target("凤凰传奇2026吉祥如意巡回演唱会——北京站") + is False + ) + + def test_title_matches_target_target_city_in_title_not_vetoed(self): + nav = _make_fuzzy_nav(keyword="凤凰传奇 演唱会", city="广州") + assert ( + nav._title_matches_target("凤凰传奇2026吉祥如意巡回演唱会——广州站") + is True + ) + + def test_title_matches_target_non_string_city_skips_veto(self): + # MagicMock config.city(非 str)不触发 veto——既有 MagicMock 用例不受影响 + nav = _make_fuzzy_nav(keyword="张杰 演唱会", city=MagicMock()) + assert nav._title_matches_target("张杰2026巡回演唱会北京站") is True + + +class TestCurrentPageMatchesClickedTitle: + """issue #51 回归:详情页短标题/截断用「刚点击的卡片标题」锚定校验。""" + + def test_current_page_matches_clicked_title_short_detail(self): + nav = _make_fuzzy_nav( + keyword="嘉年华2026周杰伦 演唱会", + detail_title="龙拳·北京 嘉年华", + ) + assert ( + nav._current_page_matches_target( + {"state": "detail_page"}, + clicked_title="龙拳·北京 嘉年华2026周杰伦演唱会", + ) + is True + ) + + def test_current_page_matches_clicked_title_fuzzy(self): + # 详情页与卡片文案词序不同:靠 title_similarity >= 0.75 锚定通过 + nav = _make_fuzzy_nav( + keyword="周杰伦 演唱会", + detail_title="周杰伦嘉年华2026演唱会(北京站)", + ) + assert ( + nav._current_page_matches_target( + {"state": "detail_page"}, + clicked_title="嘉年华2026周杰伦演唱会", + ) + is True + ) + + def test_current_page_empty_detail_title_retries_then_falls_back(self): + # 对抗审查补充 3a:详情页标题读空时短重试一次;仍空则显式记录并回落 + nav = _make_fuzzy_nav(keyword="张杰 演唱会", detail_title="") + bot = nav._bot + calls = {"n": 0} + + def fake_title(): + calls["n"] += 1 + return "" + + bot._get_detail_title_text = fake_title + with patch("mobile.event_navigator.time.sleep") as mock_sleep: + result = nav._current_page_matches_target( + {"state": "detail_page"}, clicked_title="张杰2026巡回演唱会" + ) + assert result is False + assert calls["n"] == 2 # 首读 + 短重试 + mock_sleep.assert_called_once() + + def test_current_page_no_clicked_title_keeps_old_path(self): + # 向后兼容:不传 clicked_title 时走既有 keyword 校验路径 + nav = _make_fuzzy_nav(keyword="张杰 演唱会", detail_title="张杰2026巡回演唱会北京站") + assert nav._current_page_matches_target({"state": "detail_page"}) is True + + def test_current_page_mismatch_returns_false(self): + # 详情页标题与被点卡片、keyword 均不相干:锚定与回退双双失败 → False + nav = _make_fuzzy_nav(keyword="张杰 演唱会", detail_title="开心麻花爆笑舞台剧") + assert ( + nav._current_page_matches_target( + {"state": "detail_page"}, clicked_title="张杰2026巡回演唱会北京站" + ) + is False + ) + + +class TestScoreSearchResultEnhancements: + """issue #50 回归:相似度加分 / 城市字段分 / 年份冲突罚分。""" + + def _nav(self): + return _make_fuzzy_nav( + keyword="凤凰传奇 演唱会", city="广州", target_venue=None + ) + + def test_score_search_result_similarity_bonus(self): + # 修复前 40 分被拒(<60);相似度 0.482 补分后过点击阈值 + score = self._nav()._score_search_result( + "凤凰传奇「吉祥如意」2026巡演·广州站", "广州体育馆" + ) + assert score >= 60 + + def test_score_search_result_unrelated_stays_below(self): + # 同城无关演出不得过阈值(相似度 0.314 无加分) + score = self._nav()._score_search_result( + "五月天2026巡回演唱会广州站", "广州体育馆" + ) + assert score < 60 + + def test_score_search_result_unrelated_with_city_field_stays_below(self): + # 对抗审查修正 2 边界:城市字段分(+10)不得把同城无关演出推过阈值 + score = self._nav()._score_search_result( + "五月天2026巡回演唱会广州站", "广州体育馆", "广州" + ) + assert score < 60 + + def test_score_search_result_city_field(self): + # 城市字段加/罚分(+10 vs -80):同一标题下差距至少 90 + nav = self._nav() + title = "凤凰传奇「吉祥如意」2026巡演·广州站" + score_match = nav._score_search_result(title, "体育馆", "广州") + score_conflict = nav._score_search_result(title, "体育馆", "北京") + assert score_match - score_conflict >= 90 + + def test_score_search_result_city_field_none_backward_compatible(self): + # 不传 city_text 与传 None 等价(旧调用方/delegators 兼容) + nav = self._nav() + title = "凤凰传奇「吉祥如意」2026巡演·广州站" + assert nav._score_search_result(title, "体育馆") == nav._score_search_result( + title, "体育馆", None + ) + + def test_score_search_result_year_conflict(self): + # keyword 与标题都含 4 位年份且不同:至少低 60 分(防跨年巡演误配) + nav = _make_fuzzy_nav(keyword="张杰2026演唱会", city=None) + score_same_year = nav._score_search_result("张杰2026巡回演唱会", "") + score_conflict = nav._score_search_result("张杰2025巡回演唱会", "") + assert score_same_year - score_conflict >= 60 + + +class TestOpenTargetRejectedBlacklist: + """issue #51:详情页校验失败的卡片进入黑名单,不再反复点击。""" + + def test_open_target_blacklists_mismatched_card(self): + nav = _make_fuzzy_nav(keyword="张杰 演唱会", city=None) + bot = nav._bot + + card_high, card_low = MagicMock(name="card_high"), MagicMock(name="card_low") + texts = { + (id(card_high), "cn.damai:id/tv_project_name"): "张杰2026巡回演唱会北京站", + (id(card_low), "cn.damai:id/tv_project_name"): "张杰2026", + } + + bot._find_all = MagicMock(return_value=[card_high, card_low]) + bot._safe_element_text = lambda container, by, value: texts.get( + (id(container), value), "" + ) + bot._score_search_result = ( + lambda title, venue, city_text=None: nav._score_search_result( + title, venue, city_text + ) + ) + bot._click_element_center = MagicMock() + bot.wait_for_page_state = MagicMock(return_value={"state": "detail_page"}) + bot._current_page_matches_target = MagicMock(return_value=False) + bot._press_keycode_safe = MagicMock(return_value=True) + bot.dismiss_startup_popups = MagicMock() + bot._scroll_search_results = MagicMock() + bot._timed_step = MagicMock() + bot._timed_step.return_value.__enter__ = MagicMock() + bot._timed_step.return_value.__exit__ = MagicMock(return_value=False) + + with patch("mobile.event_navigator.time.sleep"): + result = nav._open_target_from_search_results( + max_scrolls=1, return_details=True + ) + + assert result["opened"] is False + # 高分卡首轮被点击并校验失败后进黑名单:第二轮不再点击(总共 1 次) + bot._click_element_center.assert_called_once_with(card_high) + # 点击后的详情页校验必须携带被点卡片标题(clicked_title 锚定) + bot._current_page_matches_target.assert_called_once_with( + {"state": "detail_page"}, clicked_title="张杰2026巡回演唱会北京站" + ) diff --git a/tests/unit/test_mobile_damai_app.py b/tests/unit/test_mobile_damai_app.py index bc062fa..236a12a 100644 --- a/tests/unit/test_mobile_damai_app.py +++ b/tests/unit/test_mobile_damai_app.py @@ -498,7 +498,11 @@ def test_smart_wait_and_click_no_backups(self, bot): class TestAutoNavigation: def test_title_matches_target_with_keyword_tokens(self, bot): + # issue #51+#50 对抗审查修正 1:城市冲突 veto 落地后,fixture 的 + # city="深圳" 会否决含「北京」的标题。本用例显式置 city=None, + # 单独锁定 token 命中路径;veto 行为由下一个用例正向固化。 bot.config.keyword = "张杰 演唱会" + bot.config.city = None assert ( bot._title_matches_target( @@ -507,6 +511,19 @@ def test_title_matches_target_with_keyword_tokens(self, bot): is True ) + def test_title_matches_target_city_conflict_veto(self, bot): + # issue #50 反向风险收紧:目标城市深圳(fixture 默认)、标题北京站 + # ——即便全 token 命中也必须被城市冲突 veto 拒绝 + bot.config.keyword = "张杰 演唱会" + assert bot.config.city == "深圳" + + assert ( + bot._title_matches_target( + "【北京】2026张杰未·LIVE—「开往1982」演唱会-北京站" + ) + is False + ) + def test_current_page_matches_target_uses_keyword_when_item_detail_missing( self, bot ): @@ -579,6 +596,54 @@ def test_discover_target_event_exits_wrong_sku_page_before_search(self, bot): exit_context.assert_called_once() submit_keyword.assert_called_once() + def test_discover_failure_exposes_candidates(self, bot): + # issue #51+#50:全部关键词失败时 discover 仍返回 None(契约不变), + # 但候选按 score 降序、标题去重(取高分)、top-5 缓存在 + # bot._last_failed_candidates(只读 property → EventNavigator) + bot.config.keyword = "凤凰传奇 演唱会" + open_results = [ + { + "opened": False, + "search_results": [ + {"title": "候选A", "city": "广州", "venue": "V1", "time": "T1", "score": 50}, + {"title": "候选B", "city": "广州", "venue": "V2", "time": "T2", "score": 40}, + ], + }, + { + "opened": False, + "search_results": [ + {"title": "候选A", "city": "广州", "venue": "V1", "time": "T1", "score": 70}, + {"title": "候选C", "city": "广州", "venue": "V3", "time": "T3", "score": 30}, + ], + }, + ] + + with patch.object( + bot, "_recover_to_navigation_start", return_value={"state": "search_page"} + ): + with patch.object(bot, "_submit_search_keyword", return_value=True): + with patch.object( + bot, "_open_target_from_search_results", side_effect=open_results + ): + with patch.object(bot, "dismiss_startup_popups"): + with patch.object( + bot, + "probe_current_page", + return_value={"state": "search_page"}, + ): + result = bot.discover_target_event( + ["凤凰传奇 演唱会", "凤凰传奇"] + ) + + assert result is None + candidates = bot._last_failed_candidates + assert isinstance(candidates, list) + assert len(candidates) <= 5 + assert [item["title"] for item in candidates] == ["候选A", "候选B", "候选C"] + assert [item["score"] for item in candidates] == [70, 40, 30] + # 去重取高分的那条应来自第二个关键词轮次 + assert candidates[0]["used_keyword"] == "凤凰传奇" + def test_navigate_to_target_event_from_search_page(self, bot): with patch.object( bot, @@ -2031,6 +2096,27 @@ def test_probe_current_page_detects_search_activity(self, bot): assert result["state"] == "search_page" assert result["purchase_button"] is False + def test_probe_current_page_detects_detail_page_by_new_price_layout(self, bot): + """9.0.2x 详情页价格区改为 info_v2_price_layout(issue #41 probe 侧面回归锁)。""" + present = { + (By.ID, "cn.damai:id/info_v2_price_layout"), + } + + with patch.object( + bot, + "_has_element", + side_effect=lambda by, value: (by, value) in present, + ): + with patch.object( + bot, + "_get_current_activity", + return_value=".trade.newtradeorder.ui.projectdetail.ui.activity.ProjectDetailActivity", + ): + result = bot._probe_current_page_element_based() + + assert result["state"] == "detail_page" + assert result["price_container"] is True + def test_probe_current_page_detects_detail_page_by_activity_and_summary_price( self, bot ): @@ -2435,6 +2521,172 @@ def test_wait_for_sale_start_skips_cta_wait_timeout_branch_without_sell_start_ti is_ready.assert_not_called() mock_sleep.assert_not_called() + def test_wait_for_sale_start_canvas_fallback_returns_at_sale_time( + self, bot, caplog + ): + """Canvas 自绘 CTA(读不到任何文案)时,到点立即走坐标兜底返回(issue #41)。""" + _tz = timezone(timedelta(hours=8)) + sell_time = datetime(2026, 6, 1, 20, 0, 10, tzinfo=_tz) + bot.config.sell_start_time = sell_time.isoformat() + bot.config.countdown_lead_ms = 3000 + + now_base = datetime(2026, 6, 1, 20, 0, 0, tzinfo=_tz) + now_calls = [0] + + def mock_now(tz=None): + now_calls[0] += 1 + if now_calls[0] == 1: + # 初始检查:开售前 10s + return now_base + # 轮询期间:刚过开售时刻(远早于 deadline = sell_time + 8s) + return sell_time + timedelta(milliseconds=100) + + bot._guard = Mock() + bot._guard.get_current_text = Mock(return_value=None) + bot._guard.get_cta_center_coords = Mock(return_value=(794, 2617)) + bot._guard._last_matched_resource_id = ( + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + ) + + with caplog.at_level("INFO", logger="mobile.damai_app"): + with patch("mobile.damai_app.datetime") as mock_dt: + with patch("mobile.damai_app.time.sleep"): + with patch.object( + bot, "_is_sale_ready", return_value=False + ) as is_ready: + mock_dt.fromisoformat = datetime.fromisoformat + mock_dt.now = mock_now + bot.wait_for_sale_start() + + # 到点即刻返回:不进入 _is_sale_ready 慢查询,也不烧到超时 + is_ready.assert_not_called() + assert "event=cta_canvas_fallback" in caplog.text + assert "trade_project_detail_purchase_status_bar_container_fl" in caplog.text + assert "event=sale_wait_timeout" not in caplog.text + + def test_wait_for_sale_start_canvas_skips_is_sale_ready_before_sale( + self, bot, caplog + ): + """Canvas 页开售前不跑昂贵的 _is_sale_ready,到点才坐标兜底(issue #41 收尾)。 + + 回归锁:若开售前每轮都跑 _is_sale_ready,sell_time 落在某轮 in-flight 查询 + 期间会把开抢拖后 ~1 个周期(真机曾晚 ~2s)。本用例安排轮询在开售前先转两轮, + 断言这两轮里 _is_sale_ready 一次都没被调用,且到点后走 cta_canvas_fallback。 + """ + _tz = timezone(timedelta(hours=8)) + sell_time = datetime(2026, 6, 1, 20, 0, 10, tzinfo=_tz) + bot.config.sell_start_time = sell_time.isoformat() + bot.config.countdown_lead_ms = 3000 + + now_base = datetime(2026, 6, 1, 20, 0, 0, tzinfo=_tz) + # 初始「已过?」检查 → 开售前 10s;随后两轮仍在开售前;最后越过开售时刻。 + now_seq = iter( + [ + now_base, + sell_time - timedelta(seconds=2), + sell_time - timedelta(seconds=1), + sell_time + timedelta(milliseconds=80), + ] + ) + last_now = [now_base] + + def mock_now(tz=None): + try: + last_now[0] = next(now_seq) + except StopIteration: + pass + return last_now[0] + + bot._guard = Mock() + bot._guard.get_current_text = Mock(return_value=None) + bot._guard.get_cta_center_coords = Mock(return_value=(794, 2617)) + bot._guard._last_matched_resource_id = ( + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + ) + + with caplog.at_level("INFO", logger="mobile.damai_app"): + with patch("mobile.damai_app.datetime") as mock_dt: + with patch("mobile.damai_app.time.sleep"): + with patch.object( + bot, "_is_sale_ready", return_value=False + ) as is_ready: + mock_dt.fromisoformat = datetime.fromisoformat + mock_dt.now = mock_now + bot.wait_for_sale_start() + + # 开售前两轮 + 到点那轮,_is_sale_ready 全程未被调用 + is_ready.assert_not_called() + assert "event=cta_canvas_fallback" in caplog.text + assert "event=sale_wait_timeout" not in caplog.text + + def test_wait_for_sale_start_guard_text_early_return(self, bot, caplog): + """v8.x 文案路径回归:guard 读到安全文案即刻返回,不进慢轮询。""" + _tz = timezone(timedelta(hours=8)) + sell_time = datetime(2026, 6, 1, 20, 0, 10, tzinfo=_tz) + bot.config.sell_start_time = sell_time.isoformat() + bot.config.countdown_lead_ms = 3000 + + now_base = datetime(2026, 6, 1, 20, 0, 0, tzinfo=_tz) + + bot._guard = Mock() + bot._guard.get_current_text = Mock(return_value="立即购票") + bot._guard.is_safe_to_click = Mock(return_value=True) + + with caplog.at_level("INFO", logger="mobile.damai_app"): + with patch("mobile.damai_app.datetime") as mock_dt: + with patch("mobile.damai_app.time.sleep"): + with patch.object( + bot, "_is_sale_ready", return_value=False + ) as is_ready: + mock_dt.fromisoformat = datetime.fromisoformat + mock_dt.now = lambda tz=None: now_base + bot.wait_for_sale_start() + + is_ready.assert_not_called() + # 文案在手,无需坐标兜底 + bot._guard.get_cta_center_coords.assert_not_called() + assert "event=sale_ready" in caplog.text + assert "source=buy_button_guard" in caplog.text + assert "cta_text=立即购票" in caplog.text + + def test_wait_for_sale_start_timeout_when_nothing_found(self, bot, caplog): + """全信号落空(无文案、无坐标、无结构信号)时保留原超时行为。""" + _tz = timezone(timedelta(hours=8)) + sell_time = datetime(2026, 6, 1, 20, 0, 10, tzinfo=_tz) + bot.config.sell_start_time = sell_time.isoformat() + bot.config.countdown_lead_ms = 3000 + + now_base = datetime(2026, 6, 1, 20, 0, 0, tzinfo=_tz) + now_calls = [0] + + def mock_now(tz=None): + now_calls[0] += 1 + if now_calls[0] == 1: + return now_base + if now_calls[0] == 2: + # 第一轮:已过开售时刻但仍在 deadline 内 + return sell_time + timedelta(seconds=1) + # 之后越过 deadline(sell_time + 8s),触发超时 + return sell_time + timedelta(seconds=9) + + bot._guard = Mock() + bot._guard.get_current_text = Mock(return_value=None) + bot._guard.get_cta_center_coords = Mock(return_value=None) + + with caplog.at_level("INFO", logger="mobile.damai_app"): + with patch("mobile.damai_app.datetime") as mock_dt: + with patch("mobile.damai_app.time.sleep"): + with patch.object( + bot, "_is_sale_ready", return_value=False + ) as is_ready: + mock_dt.fromisoformat = datetime.fromisoformat + mock_dt.now = mock_now + bot.wait_for_sale_start() + + assert "event=sale_wait_timeout" in caplog.text + is_ready.assert_called() + bot._guard.get_cta_center_coords.assert_called() + def test_prepare_detail_page_hot_path_preselects_date_and_city(self, bot): with patch.object( bot, @@ -4829,9 +5081,13 @@ def mock_now(tz=None): # During polling: stay before deadline so the loop can run at least once return now_base - # BuyButtonGuard NOT used in this test path + # BuyButtonGuard NOT used in this test path —— issue #41 后 guard 以 + # get_current_text/get_cta_center_coords 参与交错轮询(不再串行调用 + # wait_until_safe),Mock 默认返回 truthy Mock 会误触发文案早退,这里 + # 显式置空让本用例聚焦 _is_sale_ready 的文案轮询路径。 bot._guard = Mock() - bot._guard.wait_until_safe = Mock(return_value=False) + bot._guard.get_current_text = Mock(return_value=None) + bot._guard.get_cta_center_coords = Mock(return_value=None) # _has_element returns True only for textContains("立即预订") def has_element(by, value): @@ -4858,6 +5114,89 @@ def has_element(by, value): assert getattr(bot, "_last_sale_ready_text", None) == "立即预订" +# --------------------------------------------------------------------------- +# _is_buy_button_sold_out — issue #41 多 ID 候选与文档序防御 +# --------------------------------------------------------------------------- + + +class TestIsBuyButtonSoldOut: + """_is_buy_button_sold_out:优先 btn_buy_view,容器仅作回退且不误判 Canvas。""" + + _CONTAINER_ID = "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + + def test_legacy_btn_sold_out_returns_true(self, bot): + """老结构回归:btn_buy_view 显示缺货 → True。""" + bot.d.dump_hierarchy = Mock( + return_value=( + '' + ) + ) + assert bot._is_buy_button_sold_out() is True + + def test_btn_inside_container_not_shadowed_by_document_order(self, bot): + """文档序陷阱防御:容器(空文案)是 btn_buy_view 的祖先、先被遍历到, + 仍必须读到子节点 btn_buy_view 的缺货文案(verdict 修正 4)。""" + bot.d.dump_hierarchy = Mock( + return_value=( + "" + f'' + '' + "" + ) + ) + assert bot._is_buy_button_sold_out() is True + + def test_btn_priority_over_container_text(self, bot): + """btn_buy_view 存在且可购时,容器文案不参与判定。""" + bot.d.dump_hierarchy = Mock( + return_value=( + "" + f'' + '' + "" + ) + ) + assert bot._is_buy_button_sold_out() is False + + def test_container_sold_out_returns_true_without_btn(self, bot): + """≥9.0.2x:btn_buy_view 不存在时回退容器,容器带缺货文案 → True。""" + bot.d.dump_hierarchy = Mock( + return_value=( + "" + f'' + "" + ) + ) + assert bot._is_buy_button_sold_out() is True + + def test_canvas_container_empty_text_returns_false(self, bot): + """≥9.0.2x Canvas 容器(text/content-desc 均空)→ False,不误判缺货。""" + bot.d.dump_hierarchy = Mock( + return_value=( + "" + f'' + '' + "" + ) + ) + assert bot._is_buy_button_sold_out() is False + + def test_no_candidate_nodes_returns_false(self, bot): + bot.d.dump_hierarchy = Mock( + return_value='' + ) + assert bot._is_buy_button_sold_out() is False + + def test_dump_failure_returns_false(self, bot): + bot.d.dump_hierarchy = Mock(side_effect=RuntimeError("device gone")) + assert bot._is_buy_button_sold_out() is False + + # --------------------------------------------------------------------------- # Price failure dump (P1 #31, Step 2) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_mobile_item_resolver.py b/tests/unit/test_mobile_item_resolver.py index bb3dd86..d53cf66 100644 --- a/tests/unit/test_mobile_item_resolver.py +++ b/tests/unit/test_mobile_item_resolver.py @@ -8,13 +8,16 @@ import pytest from mobile.item_resolver import ( + KNOWN_CITY_TOKENS, DamaiItemDetail, DamaiItemResolveError, DamaiItemResolver, build_search_keyword, city_keyword, extract_item_id, + find_conflicting_city, normalize_text, + title_similarity, ) @@ -328,3 +331,105 @@ def test_prime_token_returns_cookie_prefix(self): with patch.object(resolver, "_request", return_value="ok"): assert resolver._prime_token("123", "https://referer.example", "{}") == "token" + + +# --------------------------------------------------------------------------- +# title_similarity(issue #51+#50:搜索/标题模糊匹配核心纯函数) +# --------------------------------------------------------------------------- + + +class TestTitleSimilarity: + """校准值按 multiset 口径钉死(对抗审查修正 3b),用 pytest.approx 锁定。""" + + _KEYWORD = "嘉年华2026周杰伦演唱会" + + def test_title_similarity_substring_is_one(self): + assert ( + title_similarity(self._KEYWORD, "龙拳·北京 嘉年华2026周杰伦演唱会") + == 1.0 + ) + + def test_title_similarity_reverse_substring_is_one(self): + # 短标题是候选串的子串(详情页短标题场景)同样返回 1.0 + assert title_similarity("龙拳·北京 嘉年华", "龙拳北京嘉年华2026") == 1.0 + + def test_title_similarity_word_order_variant(self): + # issue #51:词序颠倒(校准值 0.870) + similarity = title_similarity( + self._KEYWORD, "周杰伦嘉年华2026演唱会(北京站)" + ) + assert similarity >= 0.75 + assert similarity == pytest.approx(0.870, abs=1e-3) + + def test_title_similarity_official_word_order(self): + # issue #51:官方全称词序变体(校准值 0.823) + similarity = title_similarity( + self._KEYWORD, "2026周杰伦嘉年华世界巡回演唱会-北京站" + ) + assert similarity >= 0.75 + assert similarity == pytest.approx(0.823, abs=1e-3) + + def test_title_similarity_truncated_ellipsis(self): + # issue #51:UI 省略号截断(multiset 校准值 0.854) + similarity = title_similarity( + self._KEYWORD, "龙拳·北京 嘉年华2026周杰伦演唱…" + ) + assert similarity >= 0.75 + assert similarity == pytest.approx(0.854, abs=1e-3) + + def test_title_similarity_unrelated_low(self): + # 防放松过度:无关演出必须显著低于模糊阈值 + assert title_similarity(self._KEYWORD, "开心麻花爆笑舞台剧") == 0.0 + assert title_similarity(self._KEYWORD, "张学友60+巡回演唱会北京站") < 0.45 + + def test_title_similarity_issue50_tour_wording(self): + # issue #50:「巡演」写法(校准值 0.482,须过相似度加分门槛 0.45) + similarity = title_similarity( + "凤凰传奇 演唱会", "凤凰传奇「吉祥如意」2026巡演·广州站" + ) + assert similarity == pytest.approx(0.482, abs=1e-3) + assert similarity >= 0.45 + + def test_title_similarity_same_city_unrelated_below_bonus_gate(self): + # 同城无关演出(校准值 0.314)不得触发相似度加分 + similarity = title_similarity("凤凰传奇 演唱会", "五月天2026巡回演唱会广州站") + assert similarity == pytest.approx(0.314, abs=1e-3) + assert similarity < 0.45 + + def test_title_similarity_empty_or_non_string_returns_zero(self): + assert title_similarity("", "张杰演唱会") == 0.0 + assert title_similarity("张杰演唱会", "") == 0.0 + assert title_similarity(None, "张杰演唱会") == 0.0 + assert title_similarity("张杰演唱会", MagicMock()) == 0.0 + + +# --------------------------------------------------------------------------- +# find_conflicting_city(issue #50:错城市 veto 依据) +# --------------------------------------------------------------------------- + + +class TestFindConflictingCity: + def test_returns_conflicting_city(self): + assert find_conflicting_city("凤凰传奇巡回演唱会北京站", "广州") == "北京" + + def test_target_city_present_returns_none(self): + assert find_conflicting_city("凤凰传奇巡回演唱会广州站", "广州") is None + + def test_multi_city_copy_with_target_not_vetoed(self): + # 「北京·上海联演」类多城市文案:目标城市在文案中即不算冲突 + assert find_conflicting_city("北京·上海联演", "上海") is None + + def test_none_target_returns_none(self): + assert find_conflicting_city("张杰演唱会北京站", None) is None + + def test_none_text_returns_none(self): + assert find_conflicting_city(None, "广州") is None + + def test_city_suffix_stripped_from_target(self): + # target 带「市」后缀时按 city_keyword 归一(北京市 → 北京) + assert find_conflicting_city("张杰演唱会北京站", "北京市") is None + + def test_known_city_tokens_exported(self): + # prompt_parser 迁移契约:城市全集从 item_resolver 导出 + assert "北京" in KNOWN_CITY_TOKENS + assert "呼和浩特" in KNOWN_CITY_TOKENS diff --git a/tests/unit/test_mobile_prompt_parser.py b/tests/unit/test_mobile_prompt_parser.py index ff9d6fb..fa24209 100644 --- a/tests/unit/test_mobile_prompt_parser.py +++ b/tests/unit/test_mobile_prompt_parser.py @@ -11,7 +11,9 @@ _parse_city, _parse_date, _parse_price_hints, + _parse_price_range, _parse_quantity, + _strip_schedule_fragments, choose_price_option, is_price_option_available, parse_prompt, @@ -70,6 +72,9 @@ def test_parse_prompt_supports_station_city_and_slash_date(self): assert intent.city == "成都" assert intent.date == "04.18" assert intent.artist == "顽童mj116" + # issue #45:斜杠日期「4/18」不再被误报为 18 元票价(显式契约固化) + assert intent.numeric_price_hint is None + assert intent.price_hint is None def test_parse_prompt_extracts_single_attendee_name(self): intent = parse_prompt( @@ -157,6 +162,64 @@ def test_parse_prompt_adds_note_when_attendee_count_mismatches_quantity(self): assert intent.quantity_explicit is True assert any("观演人" in note and "购票张数" in note for note in intent.notes) + def test_parse_prompt_single_digit_day_price_regression(self): + # issue #45 回归对照组:单位数日「4月4号」修复前后都必须解析出 1080 + intent = parse_prompt( + "帮张志涛抢一张 4 月 4 号余佳运的演唱会门票,内场,票价 1080 元" + ) + + assert intent.numeric_price_hint == 1080 + assert intent.price_hint == "内场1080元" + + def test_full_title_candidate_keeps_city(self): + # issue #51 回归:城市全局 replace 把标题前缀「龙拳·北京」挖成「龙拳·」。 + # 修复后新增「保留城市版」完整短语候选(index=2),城市名保留在候选内; + # candidate_keywords[:2] 顺序锁定不破坏。 + # 注:#45 修复已把「2026」识别为票价 token 并进入 removable_tokens, + # 因此本工作区现状下前两候选为 ['周杰伦 演唱会','周杰伦'](而非 + # 分析报告成文时的 '嘉年华2026周杰伦 演唱会' 系列)。 + intent = parse_prompt("给xxx抢6月28号 龙拳·北京 嘉年华2026周杰伦演唱会") + + assert intent.candidate_keywords[:2] == ["周杰伦 演唱会", "周杰伦"] + assert "龙拳·北京" in intent.candidate_keywords[2] + # 修复前的残缺形态「龙拳· 」不再是唯一保留(保留城市版必须存在) + assert any("龙拳·北京" in kw for kw in intent.candidate_keywords) + + def test_full_title_candidate_keeps_city_issue50(self): + # issue #50 同族:城市写在演出短语外时,保留城市版候选含「广州」 + intent = parse_prompt("帮张三抢广州的凤凰传奇演唱会门票") + + assert intent.candidate_keywords[:2] == ["凤凰传奇 演唱会", "凤凰传奇"] + assert any("广州" in kw for kw in intent.candidate_keywords) + + def test_city_preserving_candidate_absent_without_city(self): + # 无城市的提示词:候选列表行为与修复前一致(不新增候选) + intent = parse_prompt("帮我抢一张 4 月 6 号张杰的演唱会门票,内场") + + assert intent.candidate_keywords[:2] == ["张杰 演唱会", "张杰"] + + def test_parse_prompt_e2e_issue45(self): + # issue #45 端到端:两位数日份「30」不再吞并票价 1380 + result = parse_prompt( + "帮张三抢一张 5月30号 陈奕迅的演唱会门票,内场,票价1380元" + ) + + assert result.numeric_price_hint == 1380 + assert result.price_hint == "内场1380元" + assert result.date == "05.30" + assert result.artist == "陈奕迅" + assert result.search_keyword == "陈奕迅 演唱会" + # 可观测性(对抗审查修正 #1):剥离事件写入 notes(summary「提示:」段 + # 会打印)与 diagnostics 两个通道 + assert any( + "price_parse.schedule_fragments_stripped" in note + for note in result.notes + ) + assert any( + "price_parse.schedule_fragments_stripped" in item + for item in result.diagnostics + ) + class TestPromptParserInternals: def test_parse_chinese_int_variants(self): @@ -204,6 +267,22 @@ def test_choose_price_option_returns_none_when_seat_hint_is_ambiguous(self): assert selected is None + def test_choose_price_option_after_issue45_fix(self): + # issue #45 下游修复验证:票价 1380 精确命中(+100), + # 而非修复前 seat 文字掩盖或最近邻全拒返回 None + intent = parse_prompt( + "帮张三抢一张 5月30号 陈奕迅的演唱会门票,内场,票价1380元" + ) + options = [ + {"index": 0, "text": "580元", "tag": "可预约"}, + {"index": 1, "text": "1380元", "tag": "可预约"}, + ] + + selected = choose_price_option(intent, options) + + assert selected is not None + assert selected["index"] == 1 + # --------------------------------------------------------------------------- # _parse_chinese_int @@ -388,6 +467,133 @@ def test_front_row_vip(self): assert seat == "VIP" assert numeric == 1680 + # -- issue #45:日期/时间/张数数字不再被误判为票价 ---------------------- + + def test_parse_price_hints_ignores_two_digit_day_before_price(self): + # issue #45 场景 1:修复前「5月30号」的日份 30 抢先命中,误报 内场30元 + hint, seat, numeric = _parse_price_hints( + "帮张三抢一张 5月30号 陈奕迅的演唱会门票,内场,票价1380元" + ) + assert hint == "内场1380元" + assert seat == "内场" + assert numeric == 1380 + + def test_parse_price_hints_ignores_day_23_with_bare_price(self): + # issue #45 场景 2(980→23 截图案例):修复前返回 ('23元', None, 23) + hint, seat, numeric = _parse_price_hints( + "帮李四抢一张 5月23号 周杰伦的演唱会门票 980元" + ) + assert hint == "980元" + assert seat is None + assert numeric == 980 + + def test_parse_price_hints_ignores_time_tokens(self): + # 开抢时间「23点」「23:00」同为 issue #45 根因形态,修复前均误报 23 + _, _, numeric_dian = _parse_price_hints( + "5月8号 23点开抢 周杰伦演唱会 980元" + ) + _, _, numeric_colon = _parse_price_hints( + "5月8号 23:00开抢 周杰伦演唱会 980元" + ) + assert numeric_dian == 980 + assert numeric_colon == 980 + + def test_parse_price_hints_ignores_quantity_digits(self): + # 张数「12张」修复前被误吃为 12 元 + _, _, numeric = _parse_price_hints("帮我抢12张 5月30号 演唱会 580元") + assert numeric == 580 + + def test_parse_price_hints_ignores_year_token(self): + _, _, numeric = _parse_price_hints("2026年5月30号 张杰演唱会 票价1380元") + assert numeric == 1380 + + def test_parse_price_hints_ignores_slash_date_and_alnum_artist(self): + # 斜杠日期「4/18」不误报 18 元;艺人名「mj116」里的 116 不被宽松层误吃 + hint, seat, numeric = _parse_price_hints( + "帮我抢两张 成都站 4/18 顽童mj116 演唱会" + ) + assert hint is None + assert seat is None + assert numeric is None + + def test_parse_price_hints_context_anchor_beats_leading_bare_number(self): + # Tier1 上下文锚定(票价…)优先于位置更靠前的 Tier2 裸数字 + _, _, numeric = _parse_price_hints("编号45 张杰演唱会 票价1380元") + assert numeric == 1380 + + def test_parse_price_hints_keeps_seat_adjacent_digits(self): + # 回归保护:seat token 预剥离不破坏「seat 紧贴数字」的既有行为 + hint, seat, numeric = _parse_price_hints("前排VIP1680") + assert (hint, seat, numeric) == ("VIP1680元", "VIP", 1680) + + hint, seat, numeric = _parse_price_hints("内场280") + assert (hint, seat, numeric) == ("内场280元", "内场", 280) + + hint, seat, numeric = _parse_price_hints("看台票 899") + assert seat == "看台" + assert numeric == 899 + + def test_parse_price_hints_no_backtrack_truncation(self): + # 对抗审查实锤缺陷回归:无 (?!\d) 时「1380号」会贪婪回溯成「138」 + # 骗过负向 lookahead;修复后三位以上数字紧跟单位必须整体排除 + hint, seat, numeric = _parse_price_hints("1380号 张杰演唱会") + assert hint is None + assert seat is None + assert numeric is None + + # 两位数紧跟单位(30号 / 25分)同样排除 + assert _parse_price_hints("30号 张杰演唱会")[2] is None + assert _parse_price_hints("开抢25分 张杰演唱会")[2] is None + + def test_parse_price_hints_bare_year_with_anchor(self): + # 裸年份(无「年」字)依赖 Tier1 锚定词兜底,不被误报为票价 + _, _, numeric = _parse_price_hints("张杰2024巡演 票价680元") + assert numeric == 680 + + def test_parse_price_hints_currency_symbol_anchor(self): + # Tier1 货币符号锚定:¥/¥ 后的数字优先于日期残余 + _, _, numeric = _parse_price_hints("5月30号 张杰演唱会 ¥1380") + assert numeric == 1380 + + +# --------------------------------------------------------------------------- +# _parse_price_range(issue #45 同族缺陷:短横线日期 vs 价格区间) +# --------------------------------------------------------------------------- + + +class TestParsePriceRange: + def test_parse_price_range_survives_date_prefix(self): + assert _parse_price_range("5月30号 500-800元") == (500, 800) + + def test_parse_price_range_not_fooled_by_dash_date(self): + # 修复前「12-15」短横线日期被误判为 (12, 15) 价格区间 + assert _parse_price_range("12-15 张杰演唱会") == (None, None) + + def test_parse_price_range_keeps_two_digit_range(self): + # 50 不是合法月份,两位数价格区间不被日期剥离误伤 + assert _parse_price_range("50-80元") == (50, 80) + + +# --------------------------------------------------------------------------- +# _strip_schedule_fragments +# --------------------------------------------------------------------------- + + +class TestStripScheduleFragments: + def test_strip_schedule_fragments_preserves_range_interior_digits(self): + # 数字边界 lookaround 防误吃:区间内部的「80-13」不能被当短日期剥掉 + assert "980-1380元" in _strip_schedule_fragments("980-1380元") + + def test_strip_schedule_fragments_removes_schedule_tokens(self): + stripped = _strip_schedule_fragments( + "2026年5月30号 2026-04-06 23:00 19点30分 抢12张 4/18" + ) + assert not any(ch.isdigit() for ch in stripped) + + def test_strip_schedule_fragments_keeps_invalid_month_pair(self): + # 50-80 不是合法「月-日」,必须原样保留 + assert "50-80" in _strip_schedule_fragments("50-80元") + # --------------------------------------------------------------------------- # is_price_option_available diff --git a/tests/unit/test_mobile_prompt_runner.py b/tests/unit/test_mobile_prompt_runner.py index 4bfa4ab..c60ee78 100644 --- a/tests/unit/test_mobile_prompt_runner.py +++ b/tests/unit/test_mobile_prompt_runner.py @@ -1,5 +1,6 @@ """Unit tests for mobile/prompt_runner.py.""" +import subprocess from pathlib import Path from unittest.mock import Mock, patch @@ -517,6 +518,26 @@ def test_config_path_returns_jsonc(monkeypatch): assert path.name == "config.jsonc" +def test_list_connected_device_ids_uses_utf8_wrapper(): + """issue #50 回归锁:adb devices 经 run_captured 显式 UTF-8 解码。""" + completed = subprocess.CompletedProcess( + args=["adb"], + returncode=0, + stdout="List of devices attached\nc6c4eb67\tdevice\n", + stderr="", + ) + with patch( + "mobile.proc_utils.subprocess.run", return_value=completed + ) as mock_run: + device_ids = prompt_runner._list_connected_device_ids() + + assert device_ids == ["c6c4eb67"] + kwargs = mock_run.call_args.kwargs + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + assert kwargs["check"] is True + + class TestAutoSyncDeviceConfig: def test_auto_sync_uses_single_connected_device(self): base_config = {"serial": "emulator-5554"} @@ -746,6 +767,95 @@ def test_discovery_failure_returns_1_with_friendly_error(self): mock_logger.error.assert_called_once() assert "未能根据提示词打开目标演出" in mock_logger.error.call_args[0][0] + def test_discovery_failure_prints_candidates(self): + # issue #51+#50:discover 失败且 bot 暴露候选列表时,错误文案列出 + # top-5 候选并引导用户补全标题;exit code 仍为 1,绝不自动点击候选 + from mobile.prompt_runner import main + + mock_bot = Mock() + mock_bot.driver = None + mock_bot.probe_current_page.return_value = {"state": "search_page"} + mock_bot.discover_target_event.return_value = None + mock_bot._last_failed_candidates = [ + { + "title": "凤凰传奇「吉祥如意」2026巡演·广州站", + "city": "广州", + "venue": "广州体育馆", + "time": "2026.07.18", + "score": 64, + "used_keyword": "凤凰传奇 演唱会", + }, + { + "title": "五月天2026巡回演唱会广州站", + "city": "广州", + "venue": "宝能观致文化中心", + "time": "2026.08.01", + "score": 40, + "used_keyword": "凤凰传奇", + }, + ] + + with \ + patch( + "mobile.prompt_runner._config_path", + return_value=Mock(__str__=lambda s: "/mock/config.jsonc"), + ), \ + patch("mobile.prompt_runner.load_config_dict", return_value={}), \ + patch("mobile.prompt_runner.Config.load_config") as mock_cfg, \ + patch("mobile.prompt_runner.DamaiBot", return_value=mock_bot), \ + patch("mobile.prompt_runner.logger") as mock_logger: + mock_cfg.return_value = Mock( + to_dict=lambda: { + "serial": "ABC", + "app_package": "cn.damai", + "app_activity": ".SplashMainActivity", + "keyword": "凤凰传奇 演唱会", + "users": ["张志涛"], + "city": "广州", + "date": "07.18", + "price": "580元", + "price_index": 0, + "if_commit_order": False, + "probe_only": True, + "auto_navigate": True, + }, + city="广州", + date="07.18", + price="580元", + price_index=0, + ) + result = main( + ["帮张志涛抢广州的凤凰传奇演唱会门票", "--mode", "summary"] + ) + + assert result == 1 + mock_logger.error.assert_called_once() + message = mock_logger.error.call_args[0][0] + assert "未能自动确认目标演出" in message + assert "凤凰传奇「吉祥如意」2026巡演·广州站" in message + assert "五月天2026巡回演唱会广州站" in message + assert "把完整演出标题原文写进提示词" in message + + def test_discovery_failure_with_non_list_candidates_keeps_old_message(self): + # 防御回归:_last_failed_candidates 为 MagicMock(非 list,旧版 bot / + # 全 Mock 场景)时不崩溃,输出原有错误文案 + from mobile.prompt_runner import _build_discovery_failure_message + + intent = Mock(candidate_keywords=["张杰 演唱会", "张杰"]) + bot = Mock() # _last_failed_candidates 自动成为 Mock(非 list) + assert ( + _build_discovery_failure_message(bot, intent) + == "未能根据提示词打开目标演出" + ) + + # list 里混入非 dict 项同样兜底回原文案 + bot_bad_items = Mock() + bot_bad_items._last_failed_candidates = ["不是字典"] + assert ( + _build_discovery_failure_message(bot_bad_items, intent) + == "未能根据提示词打开目标演出" + ) + def _make_full_mock_bot(self, discovery=None): """Helper: build a fully-mocked bot for non-summary mode tests.""" if discovery is None: diff --git a/tests/unit/test_page_probe.py b/tests/unit/test_page_probe.py index 73211dd..1432d8a 100644 --- a/tests/unit/test_page_probe.py +++ b/tests/unit/test_page_probe.py @@ -179,6 +179,71 @@ def element_factory(**kwargs): assert result["state"] == "detail_page" assert result["purchase_button"] is True + def test_detail_page_fast_path_price_container_new_layout(self): + """9.0.2x 详情页价格区为 info_v2_price_layout,price_container 须为 True。 + + 回归锁(issue #41 probe 侧面):ProjectDetail 快路径曾不填 price_container, + 导致 probe_only 就绪判定在 detail_page 上恒为「未就绪」。 + """ + device = _make_device("com.damai.ProjectDetailActivity") + + def element_factory(**kwargs): + el = Mock() + el.exists = kwargs.get("resourceId", "") in ( + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + "cn.damai:id/info_v2_price_layout", + ) + return el + + device.side_effect = element_factory + probe = PageProbe(device, cache_ttl_s=0) + + result = probe.probe_current_page(fast=False) + + assert result["state"] == "detail_page" + assert result["purchase_button"] is True + assert result["price_container"] is True + + def test_detail_page_fast_path_price_container_legacy_layout(self): + """v8.x 详情页仍用 project_detail_price_layout,多 ID 候选保持兼容。""" + device = _make_device("com.damai.ProjectDetailActivity") + + def element_factory(**kwargs): + el = Mock() + el.exists = kwargs.get("resourceId", "") in ( + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + "cn.damai:id/project_detail_price_layout", + ) + return el + + device.side_effect = element_factory + probe = PageProbe(device, cache_ttl_s=0) + + result = probe.probe_current_page(fast=False) + + assert result["state"] == "detail_page" + assert result["price_container"] is True + + def test_detail_page_fast_path_price_container_absent(self): + """无任何价格区锚点时 price_container 保持 False(probe_only 判未就绪)。""" + device = _make_device("com.damai.ProjectDetailActivity") + + def element_factory(**kwargs): + el = Mock() + el.exists = ( + kwargs.get("resourceId", "") + == "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + ) + return el + + device.side_effect = element_factory + probe = PageProbe(device, cache_ttl_s=0) + + result = probe.probe_current_page(fast=False) + + assert result["state"] == "detail_page" + assert result["price_container"] is False + def test_sku_page_by_activity_fast_path(self): """Full probe uses Activity shortcut for NcovSku, sets price_container.""" device = _make_device("com.damai.NcovSkuActivity") diff --git a/tests/unit/test_proc_utils.py b/tests/unit/test_proc_utils.py new file mode 100644 index 0000000..f063da6 --- /dev/null +++ b/tests/unit/test_proc_utils.py @@ -0,0 +1,126 @@ +# -*- coding: UTF-8 -*- +"""Tests for mobile.proc_utils (issue #50: Windows GBK 解码崩溃修复).""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +import mobile.proc_utils +from mobile.proc_utils import run_captured + + +class TestRunCaptured: + def test_run_captured_forces_utf8_and_replace(self): + """『所有 text 模式调用都带 encoding』的 Windows 语义 mock 验证(不依赖平台)。""" + completed = subprocess.CompletedProcess( + args=["adb"], returncode=0, stdout="", stderr="" + ) + with patch( + "mobile.proc_utils.subprocess.run", return_value=completed + ) as mock_run: + result = run_captured(["adb", "devices"]) + + assert result is completed + mock_run.assert_called_once() + kwargs = mock_run.call_args.kwargs + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + + def test_run_captured_forwards_cmd_timeout_check_env(self): + completed = subprocess.CompletedProcess( + args=["adb"], returncode=0, stdout="", stderr="" + ) + with patch( + "mobile.proc_utils.subprocess.run", return_value=completed + ) as mock_run: + run_captured( + ["adb", "shell", "pm", "dump", "cn.damai"], + timeout=3.0, + check=True, + env={"PATH": "/usr/bin"}, + ) + + assert mock_run.call_args.args[0] == ["adb", "shell", "pm", "dump", "cn.damai"] + kwargs = mock_run.call_args.kwargs + assert kwargs["timeout"] == 3.0 + assert kwargs["check"] is True + assert kwargs["env"] == {"PATH": "/usr/bin"} + + def test_run_captured_decodes_multibyte_output_end_to_end(self): + """端到端:GBK 下会在 0xa7 崩溃的字节序列,显式 utf-8 后任何 locale 均成功。""" + result = run_captured( + [ + sys.executable, + "-c", + "import sys; sys.stdout.buffer.write('channelName=大麦‧\\n'.encode('utf-8'))", + ] + ) + assert result.returncode == 0 + assert "大麦‧" in result.stdout + + def test_run_captured_propagates_check_and_timeout(self): + """异常原样透传:保证调用方的 except CalledProcessError/SubprocessError 分支生效。""" + with patch( + "mobile.proc_utils.subprocess.run", + side_effect=subprocess.CalledProcessError(returncode=1, cmd=["adb"]), + ): + with pytest.raises(subprocess.CalledProcessError): + run_captured(["adb", "devices"], check=True) + + with patch( + "mobile.proc_utils.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="adb", timeout=1), + ): + with pytest.raises(subprocess.TimeoutExpired): + run_captured(["adb", "devices"], timeout=1) + + +def _iter_subprocess_text_calls(tree: ast.AST): + """Yield subprocess.run/Popen/check_output/check_call/call 调用节点中 + 带 text=True / universal_newlines=True 的调用及其关键字集合。""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess" + and func.attr in {"run", "Popen", "check_output", "check_call", "call"} + ): + continue + keyword_names = {kw.arg for kw in node.keywords if kw.arg} + text_mode = any( + kw.arg in {"text", "universal_newlines"} + and isinstance(kw.value, ast.Constant) + and kw.value.value is True + for kw in node.keywords + ) + if text_mode: + yield node, keyword_names + + +def test_no_naked_text_subprocess_in_mobile(): + """守卫测试:mobile/ 内 text 模式 subprocess 调用必须显式传 encoding, + 或位于 proc_utils.py 内(防止 issue #50 类问题复发)。""" + mobile_dir = Path(mobile.proc_utils.__file__).resolve().parent + violations = [] + for py_file in sorted(mobile_dir.rglob("*.py")): + if py_file.name == "proc_utils.py": + continue + tree = ast.parse(py_file.read_text(encoding="utf-8")) + for node, keyword_names in _iter_subprocess_text_calls(tree): + if "encoding" not in keyword_names: + violations.append(f"{py_file.relative_to(mobile_dir)}:{node.lineno}") + assert violations == [], ( + "以下 subprocess 调用为 text 模式但未显式传 encoding," + f"请改用 mobile.proc_utils.run_captured 或补 encoding 参数: {violations}" + )