Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions mobile/buy_button_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""

import time
from typing import Optional
from typing import Optional, Tuple

try:
from mobile.logger import get_logger
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.

Expand Down
25 changes: 21 additions & 4 deletions mobile/damai_app/delegators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Expand Down
37 changes: 29 additions & 8 deletions mobile/damai_app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
105 changes: 87 additions & 18 deletions mobile/damai_app/sale_waiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions mobile/damai_app/state_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 22 additions & 8 deletions mobile/env_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading