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}"
+ )