From 5e27870ad7ca07604a8d396296614502f1f9e362 Mon Sep 17 00:00:00 2001 From: currycan Date: Wed, 6 May 2026 23:03:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor(mobile):=20damai=5Fapp.py=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E4=B8=BA=E5=8C=85=EF=BC=88=E9=9B=B6=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把单文件 mobile/damai_app.py(2072 行)拆分为 mobile/damai_app/ 包: - __init__.py 包入口;保留所有原模块级符号 + 自定义 ModuleType hook 把 patch("mobile.damai_app.{time,datetime,logger}") 镜像到所有子模块,使测试零修改 - __main__.py 支持 python -m damai_app(替代原 python damai_app.py) - orchestrator.py DamaiBot 主类 + run_ticket_grabbing + run_with_retry + 启动/弹窗/会话探测/票价确认等编排 - sale_waiter.py wait_for_sale_start + 自适应轮询 - purchase_flow.py _enter_purchase_flow_from_detail_page + _submit_order_fast - recovery_strategies.py 状态机 dispatcher + back-press 恢复 - coords_cache.py 热路径坐标缓存命中检查与坐标读取 - state_probe.py 页面状态探测 + SKU 检视 + 标题/场地解析 - delegators.py AttendeeSelector / PriceSelector / EventNavigator / FastPipeline 的薄封装 零行为变更: - 仅搬代码,不动逻辑、不改默认值、不重命名(_submit_order_fast 等保持下划线) - 外部 API 不变:from mobile.damai_app import DamaiBot 仍兼容; Config / logger / time / datetime 等模块属性仍可被测试 patch - 测试零修改:1020 通过 + 1 预存 xfail,覆盖率 81.03%(≥80% 门槛) 启动脚本同步: - mobile/scripts/start_ticket_grabbing.sh:python damai_app.py → python -m damai_app --- mobile/damai_app/__init__.py | 215 ++++ mobile/damai_app/__main__.py | 28 + mobile/damai_app/coords_cache.py | 39 + mobile/damai_app/delegators.py | 247 ++++ .../orchestrator.py} | 1087 +---------------- mobile/damai_app/purchase_flow.py | 169 +++ mobile/damai_app/recovery_strategies.py | 187 +++ mobile/damai_app/sale_waiter.py | 144 +++ mobile/damai_app/state_probe.py | 295 +++++ mobile/scripts/start_ticket_grabbing.sh | 4 +- 10 files changed, 1371 insertions(+), 1044 deletions(-) create mode 100644 mobile/damai_app/__init__.py create mode 100644 mobile/damai_app/__main__.py create mode 100644 mobile/damai_app/coords_cache.py create mode 100644 mobile/damai_app/delegators.py rename mobile/{damai_app.py => damai_app/orchestrator.py} (52%) create mode 100644 mobile/damai_app/purchase_flow.py create mode 100644 mobile/damai_app/recovery_strategies.py create mode 100644 mobile/damai_app/sale_waiter.py create mode 100644 mobile/damai_app/state_probe.py diff --git a/mobile/damai_app/__init__.py b/mobile/damai_app/__init__.py new file mode 100644 index 0000000..7971920 --- /dev/null +++ b/mobile/damai_app/__init__.py @@ -0,0 +1,215 @@ +# -*- coding: UTF-8 -*- +""" +__Author__ = "BlueCestbon" +__Version__ = "2.0.0" +__Description__ = "大麦app抢票自动化 - 优化版" +__Created__ = 2025/09/13 19:27 +""" + +from __future__ import annotations + +import re +import sys +import time +import types +from contextlib import contextmanager +from datetime import datetime, timezone, timedelta +from pathlib import Path + +try: + from selenium.webdriver.common.by import By +except ModuleNotFoundError as e: + raise SystemExit( + "依赖缺失:selenium 未安装。\n" + "→ 请在项目根目录运行:poetry install\n" + "→ 然后通过 mobile/scripts/start_ticket_grabbing.sh 启动而非直接运行 .py" + ) from e + +try: + from mobile.config import Config +except ImportError: + from config import Config # type: ignore[no-redef] + +try: + from mobile.item_resolver import ( + DamaiItemResolver, + DamaiItemResolveError, + city_keyword, + normalize_text, + ) +except ImportError: + from item_resolver import ( # type: ignore[no-redef] + normalize_text, + ) + +try: + from mobile.logger import get_logger +except ImportError: + from logger import get_logger # type: ignore[no-redef] + +try: + from mobile.ui_primitives import UIPrimitives, ANDROID_UIAUTOMATOR +except ImportError: + from ui_primitives import UIPrimitives, ANDROID_UIAUTOMATOR # type: ignore[no-redef] + +try: + from mobile.buy_button_guard import BuyButtonGuard + from mobile.page_probe import PageProbe, PageState + from mobile.fast_pipeline import FastPipeline, poll_until, batch_shell_taps + from mobile.recovery import RecoveryHelper + from mobile.event_navigator import ( + EventNavigator, + SessionNotFoundError, + select_session, + ) + from mobile.price_selector import ( + PriceSelector, + PriceSelectorError, + SoldOutError, + ) + from mobile.attendee_selector import AttendeeSelector +except ImportError: + from buy_button_guard import BuyButtonGuard # type: ignore[no-redef] + from page_probe import PageProbe # type: ignore[no-redef] + from fast_pipeline import FastPipeline # type: ignore[no-redef] + from recovery import RecoveryHelper # type: ignore[no-redef] + from event_navigator import EventNavigator # type: ignore[no-redef] + from price_selector import PriceSelector # type: ignore[no-redef] + from attendee_selector import AttendeeSelector # type: ignore[no-redef] + + +logger = get_logger(__name__) + +_PRICE_UNAVAILABLE_TAGS = { + "无票", + "缺货", + "缺货登记", + "售罄", + "已售罄", + "不可选", + "暂不可售", +} +# 开售后大麦详情页 / 票务面板上"可点购票"的安全文案集合(issue #29)。 +# 任意一个文案出现即视为开售已开放购买入口;不在此列的文案(如"预约抢票""即将开抢") +# 一律视为未开售,避免误点预约入口。 +SALE_READY_TEXTS: tuple[str, ...] = ( + "立即购票", + "立即预定", + "立即预订", # 大麦 2026-04 后新增(issue #29) + "立即抢票", + "Book Now", # 国际化场景兜底 +) +# UiSelector textMatches 用:基于 SALE_READY_TEXTS 自动生成的正则联合 +# (新增/修改 SALE_READY_TEXTS 时此处自动同步,避免文案分散) +_SALE_READY_TEXT_REGEX_OR = "|".join(f".*{t}.*" for t in SALE_READY_TEXTS) +_CTA_READY_KEYWORDS = ( + *SALE_READY_TEXTS, + "立即购买", + "选座购买", + "购买", + "抢票", + "预定", + "提交订单", + "去结算", + "确定", +) +_CTA_BLOCKED_KEYWORDS = ( + "预约", + "预售", + "即将开抢", + "待开售", + "未开售", + "倒计时", + "无票", + "售罄", + "缺货", +) +_MANUAL_STEP_BASELINES = { + "搜索页输入并提交关键词": 6.0, + "搜索结果扫描并打开目标": 12.0, +} + + +# --------------------------------------------------------------------------- # +# Test compatibility hook +# --------------------------------------------------------------------------- # +# Before W4-01, mobile/damai_app.py was a single module. Tests rely on +# ``patch("mobile.damai_app.time")`` / ``patch("mobile.damai_app.datetime")`` +# / ``patch("mobile.damai_app.logger")`` to swap module-level bindings. +# After splitting into a package whose code lives under +# ``mobile.damai_app.``, those patches no longer reach the +# submodule's local binding. To preserve zero-test-modification behavior, +# this custom module class mirrors writes of a small whitelist of attributes +# back onto every submodule that hosts code formerly inside damai_app.py. +_MIRROR_SUBMODULES: tuple[str, ...] = tuple( + f"{__name__}.{_n}" + for _n in ( + "orchestrator", + "sale_waiter", + "purchase_flow", + "recovery_strategies", + "coords_cache", + "state_probe", + "delegators", + ) +) +_MIRRORED_ATTRS: frozenset[str] = frozenset({"time", "datetime", "logger", "re"}) + + +class _DamaiPackage(types.ModuleType): + """Package module that mirrors selected attribute writes to submodules. + + Allows tests written against the pre-split single-file layout to + monkeypatch ``mobile.damai_app.time`` / ``mobile.damai_app.datetime`` / + ``mobile.damai_app.logger`` and have those patches reach the actual code + in submodules. Only the attributes in :data:`_MIRRORED_ATTRS` are mirrored + to avoid surprising behavior. + """ + + def __setattr__(self, name: str, value): # type: ignore[override] + super().__setattr__(name, value) + if name in _MIRRORED_ATTRS: + for submod_name in _MIRROR_SUBMODULES: + submod = sys.modules.get(submod_name) + if submod is not None: + object.__setattr__(submod, name, value) + + +sys.modules[__name__].__class__ = _DamaiPackage + + +# Load the orchestrator (transitively loads each mixin submodule). +from .orchestrator import DamaiBot # noqa: E402 + + +__all__ = [ + "DamaiBot", + "SALE_READY_TEXTS", + "logger", + "Config", + "ANDROID_UIAUTOMATOR", + "UIPrimitives", + "PageProbe", + "PageState", + "FastPipeline", + "RecoveryHelper", + "EventNavigator", + "SessionNotFoundError", + "select_session", + "PriceSelector", + "PriceSelectorError", + "SoldOutError", + "AttendeeSelector", + "BuyButtonGuard", + "DamaiItemResolver", + "DamaiItemResolveError", + "city_keyword", + "normalize_text", + "get_logger", + "By", + "Path", + "datetime", + "timezone", + "timedelta", + "contextmanager", +] diff --git a/mobile/damai_app/__main__.py b/mobile/damai_app/__main__.py new file mode 100644 index 0000000..1c58cf8 --- /dev/null +++ b/mobile/damai_app/__main__.py @@ -0,0 +1,28 @@ +# -*- coding: UTF-8 -*- +"""Allow ``python -m mobile.damai_app`` (or ``python -m damai_app`` from the +``mobile/`` directory) to launch the bot, mirroring the pre-W4-01 behaviour +of running ``python mobile/damai_app.py`` directly. +""" + +from __future__ import annotations + +from . import DamaiBot, logger + + +def main() -> None: + bot = None + try: + bot = DamaiBot() + bot.run_with_retry(max_retries=3) + except (ValueError, RuntimeError) as exc: + logger.error(str(exc)) + finally: + try: + if bot and bot.driver: + bot.driver.quit() + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/mobile/damai_app/coords_cache.py b/mobile/damai_app/coords_cache.py new file mode 100644 index 0000000..1456594 --- /dev/null +++ b/mobile/damai_app/coords_cache.py @@ -0,0 +1,39 @@ +# -*- coding: UTF-8 -*- +"""Coordinate-cache helpers for DamaiBot's hot-path mixins. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). Each helper either delegates to ``self._price_sel`` / +``self._pipeline`` or reads the bot-level coordinate cache directly. +""" + +from __future__ import annotations + + +class CoordsCacheMixin: + """Mixin contributing hot-path coordinate cache helpers to ``DamaiBot``.""" + + def _has_warm_pipeline_coords(self): + """Check if all coordinates required for the blind pipeline are cached.""" + if hasattr(self, "_pipeline"): + return self._pipeline.has_warm_coords() + c = self._cached_hot_path_coords + return all( + [ + c.get("detail_buy"), + c.get("price"), + c.get("sku_buy"), + c.get("attendee_checkboxes"), + ] + ) + + def _get_buy_button_coordinates(self, xml_root=None): + if hasattr(self, "_price_sel"): + return self._price_sel._get_buy_button_coordinates(xml_root) + return None + + def _get_price_option_coordinates_by_config_index(self, xml_root=None): + if hasattr(self, "_price_sel"): + return self._price_sel._get_price_option_coordinates_by_config_index( + xml_root + ) + return None diff --git a/mobile/damai_app/delegators.py b/mobile/damai_app/delegators.py new file mode 100644 index 0000000..44647e9 --- /dev/null +++ b/mobile/damai_app/delegators.py @@ -0,0 +1,247 @@ +# -*- coding: UTF-8 -*- +"""Thin sub-module delegator helpers for DamaiBot. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). These are 1-liner wrappers that forward to the appropriate +sub-module attached on the bot (``self._attendee_sel``, ``self._price_sel``, +``self._navigator``, ``self._pipeline``). Kept on the bot class so external +callers and tests that exercise the whole bot interface keep working. +""" + +from __future__ import annotations + +from . import logger + + +class DelegatorsMixin: + """Mixin contributing thin delegator methods to ``DamaiBot``. + + The implementations forward to ``AttendeeSelector`` / ``PriceSelector`` / + ``EventNavigator`` / ``FastPipeline`` instances configured during + ``DamaiBot.__init__``. + """ + + # ------------------------------------------------------------------ # + # AttendeeSelector delegators + # ------------------------------------------------------------------ # + + def _attendee_required_count_on_confirm_page(self): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._attendee_required_count_on_confirm_page() + logger.warning("AttendeeSelector 未初始化") + return max(1, len(self.config.users or [])) + + def _attendee_checkbox_elements(self): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._attendee_checkbox_elements() + return [] + + @staticmethod + def _is_checkbox_selected(checkbox): + # Static helper — defers to the shared ``_is_checked`` static method + # inherited from UIPrimitives. Imports lazily to avoid loading the + # orchestrator at module-import time. + try: + from mobile.ui_primitives import UIPrimitives + except ImportError: # pragma: no cover + from ui_primitives import UIPrimitives # type: ignore[no-redef] + return UIPrimitives._is_checked(checkbox) + + def _attendee_selected_count( + self, checkbox_elements=None, use_source_fallback=True + ): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._attendee_selected_count( + checkbox_elements, use_source_fallback + ) + return 0 + + def _click_attendee_checkbox(self, checkbox): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._click_attendee_checkbox(checkbox) + return False + + def _click_attendee_checkbox_fast(self, checkbox): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._click_attendee_checkbox_fast(checkbox) + return False + + def _select_attendee_checkbox_by_name(self, user_name): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._select_attendee_checkbox_by_name(user_name) + return False + + def _ensure_attendees_selected_on_confirm_page( + self, require_attendee_section=False + ): + if hasattr(self, "_attendee_sel"): + return self._attendee_sel._ensure_attendees_selected_on_confirm_page( + require_attendee_section + ) + logger.warning("AttendeeSelector 未初始化") + return False + + # ------------------------------------------------------------------ # + # PriceSelector delegators + # ------------------------------------------------------------------ # + + def _build_compound_price_text(self, container): + if hasattr(self, "_price_sel"): + return self._price_sel._build_compound_price_text(container) + return "" + + def _price_option_text_from_descendants(self, texts): + if hasattr(self, "_price_sel"): + return self._price_sel._price_option_text_from_descendants(texts) + return "" + + def _normalize_ocr_price_text(self, ocr_output): + if hasattr(self, "_price_sel"): + return self._price_sel._normalize_ocr_price_text(ocr_output) + return "" + + def _ocr_price_text_from_card(self, screenshot_path, rect): + if hasattr(self, "_price_sel"): + return self._price_sel._ocr_price_text_from_card(screenshot_path, rect) + return "" + + def _extract_price_digits(self, text): + if hasattr(self, "_price_sel"): + return self._price_sel._extract_price_digits(text) + return None + + def _price_text_matches_target(self, text): + if hasattr(self, "_price_sel"): + return self._price_sel._price_text_matches_target(text) + return False + + def _is_price_option_available(self, option): + if hasattr(self, "_price_sel"): + return self._price_sel._is_price_option_available(option) + return True + + def _click_visible_price_option(self, card_index): + if hasattr(self, "_price_sel"): + return self._price_sel._click_visible_price_option(card_index) + return False + + def _click_price_option_by_config_index(self, burst=False, coords=None): + if hasattr(self, "_price_sel"): + return self._price_sel._click_price_option_by_config_index(burst, coords) + return False + + def _select_price_option_fast(self, cached_coords=None): + if hasattr(self, "_price_sel"): + return self._price_sel._select_price_option_fast(cached_coords) + return None + + def _select_price_option(self, cached_coords=None): + if hasattr(self, "_price_sel"): + return self._price_sel._select_price_option(cached_coords) + return False + + def get_visible_price_options(self, allow_ocr=True, xml_root=None): + if hasattr(self, "_price_sel"): + return self._price_sel.get_visible_price_options( + allow_ocr=allow_ocr, xml_root=xml_root + ) + return [] + + def _get_visible_price_options_from_xml(self, xml_root, allow_ocr=True): + if hasattr(self, "_price_sel"): + return self._price_sel._get_visible_price_options_from_xml( + xml_root, allow_ocr=allow_ocr + ) + return [] + + # ------------------------------------------------------------------ # + # EventNavigator delegators + # ------------------------------------------------------------------ # + + def _keyword_tokens(self): + if hasattr(self, "_navigator"): + return self._navigator._keyword_tokens() + return [] + + def _title_matches_target(self, title_text): + if hasattr(self, "_navigator"): + return self._navigator._title_matches_target(title_text) + return False + + def _current_page_matches_target(self, page_probe): + if hasattr(self, "_navigator"): + return self._navigator._current_page_matches_target(page_probe) + return False + + def _open_search_from_homepage(self): + if hasattr(self, "_navigator"): + return self._navigator._open_search_from_homepage() + return False + + def _submit_search_keyword(self): + if hasattr(self, "_navigator"): + return self._navigator._submit_search_keyword() + return False + + def _score_search_result(self, title_text, venue_text): + if hasattr(self, "_navigator"): + return self._navigator._score_search_result(title_text, venue_text) + return -1 + + def _scroll_search_results(self): + if hasattr(self, "_navigator"): + return self._navigator._scroll_search_results() + + def _open_target_from_search_results( + self, max_scrolls=2, max_results=5, return_details=False + ): + if hasattr(self, "_navigator"): + return self._navigator._open_target_from_search_results( + max_scrolls, max_results, return_details + ) + return {"opened": False, "search_results": []} if return_details else False + + def collect_search_results(self, max_scrolls=0, max_results=5): + if hasattr(self, "_navigator"): + return self._navigator.collect_search_results(max_scrolls, max_results) + return [] + + def navigate_to_target_event(self, initial_probe=None): + """Navigate to the target event. Delegates to EventNavigator.""" + if hasattr(self, "_navigator") and self._navigator is not None: + return self._navigator.navigate_to_target_event(initial_probe=initial_probe) + return self._navigate_to_target_impl(initial_probe=initial_probe) + + def _navigate_to_target_impl(self, initial_probe=None): + if hasattr(self, "_navigator"): + return self._navigator._navigate_to_target_impl(initial_probe) + return False + + def discover_target_event( + self, keyword_candidates, initial_probe=None, search_scrolls=1, result_limit=5 + ): + if hasattr(self, "_navigator"): + return self._navigator.discover_target_event( + keyword_candidates, initial_probe, search_scrolls, result_limit + ) + return None + + # ------------------------------------------------------------------ # + # FastPipeline delegators + # ------------------------------------------------------------------ # + + def _run_cold_validation_pipeline(self, start_time): + self._ensure_pipeline() + return self._pipeline.run_cold_validation(start_time) + + def _cold_pipeline_finish_confirm(self, start_time): + self._ensure_pipeline() + return self._pipeline._finish_confirm(start_time) + + def _run_warm_validation_pipeline(self, start_time): + self._ensure_pipeline() + return self._pipeline.run_warm_validation(start_time) + + def _rush_preselect_and_buy_via_xml(self): + self._ensure_pipeline() + return self._pipeline.rush_preselect_and_buy_via_xml() diff --git a/mobile/damai_app.py b/mobile/damai_app/orchestrator.py similarity index 52% rename from mobile/damai_app.py rename to mobile/damai_app/orchestrator.py index 2ac3792..a59a89d 100755 --- a/mobile/damai_app.py +++ b/mobile/damai_app/orchestrator.py @@ -1,9 +1,12 @@ # -*- coding: UTF-8 -*- -""" -__Author__ = "BlueCestbon" -__Version__ = "2.0.0" -__Description__ = "大麦app抢票自动化 - 优化版" -__Created__ = 2025/09/13 19:27 +"""DamaiBot orchestrator: __init__, main run loops, and helper methods that +remain on the bot class after W4-01 split. + +Module constants (``SALE_READY_TEXTS`` etc.), the package-level ``logger`` +and shared dependency classes are imported from :mod:`mobile.damai_app`'s +``__init__`` (the package). Hot-path helpers split into mixins live in +sibling submodules: ``sale_waiter``, ``purchase_flow``, +``recovery_strategies`` and ``coords_cache``. """ from __future__ import annotations @@ -11,126 +14,46 @@ import re import time from contextlib import contextmanager -from datetime import datetime, timezone, timedelta +from datetime import datetime from pathlib import Path -try: - from selenium.webdriver.common.by import By -except ModuleNotFoundError as e: - raise SystemExit( - "依赖缺失:selenium 未安装。\n" - "→ 请在项目根目录运行:poetry install\n" - "→ 然后通过 mobile/scripts/start_ticket_grabbing.sh 启动而非直接运行 .py" - ) from e - -try: - from mobile.config import Config -except ImportError: - from config import Config - -try: - from mobile.item_resolver import ( - DamaiItemResolver, - DamaiItemResolveError, - city_keyword, - normalize_text, - ) -except ImportError: - from item_resolver import ( - normalize_text, - ) - -try: - from mobile.logger import get_logger -except ImportError: - from logger import get_logger - -try: - from mobile.ui_primitives import UIPrimitives, ANDROID_UIAUTOMATOR -except ImportError: - from ui_primitives import UIPrimitives, ANDROID_UIAUTOMATOR - -try: - from mobile.buy_button_guard import BuyButtonGuard - from mobile.page_probe import PageProbe, PageState - from mobile.fast_pipeline import FastPipeline, poll_until, batch_shell_taps - from mobile.recovery import RecoveryHelper - from mobile.event_navigator import ( - EventNavigator, - SessionNotFoundError, - select_session, - ) - from mobile.price_selector import ( - PriceSelector, - PriceSelectorError, - SoldOutError, - ) - from mobile.attendee_selector import AttendeeSelector -except ImportError: - from buy_button_guard import BuyButtonGuard - from page_probe import PageProbe # type: ignore[no-redef] - from fast_pipeline import FastPipeline - from recovery import RecoveryHelper - from event_navigator import ( # type: ignore[no-redef] - EventNavigator, - ) - from price_selector import PriceSelector - from attendee_selector import AttendeeSelector - - -logger = get_logger(__name__) - -_PRICE_UNAVAILABLE_TAGS = { - "无票", - "缺货", - "缺货登记", - "售罄", - "已售罄", - "不可选", - "暂不可售", -} -# 开售后大麦详情页 / 票务面板上"可点购票"的安全文案集合(issue #29)。 -# 任意一个文案出现即视为开售已开放购买入口;不在此列的文案(如"预约抢票""即将开抢") -# 一律视为未开售,避免误点预约入口。 -SALE_READY_TEXTS: tuple[str, ...] = ( - "立即购票", - "立即预定", - "立即预订", # 大麦 2026-04 后新增(issue #29) - "立即抢票", - "Book Now", # 国际化场景兜底 -) -# UiSelector textMatches 用:基于 SALE_READY_TEXTS 自动生成的正则联合 -# (新增/修改 SALE_READY_TEXTS 时此处自动同步,避免文案分散) -_SALE_READY_TEXT_REGEX_OR = "|".join(f".*{t}.*" for t in SALE_READY_TEXTS) -_CTA_READY_KEYWORDS = ( - *SALE_READY_TEXTS, - "立即购买", - "选座购买", - "购买", - "抢票", - "预定", - "提交订单", - "去结算", - "确定", +from selenium.webdriver.common.by import By + +from . import ( + ANDROID_UIAUTOMATOR, + AttendeeSelector, + BuyButtonGuard, + Config, + EventNavigator, + FastPipeline, + PageProbe, + PageState, + PriceSelector, + PriceSelectorError, + RecoveryHelper, + SessionNotFoundError, + SoldOutError, + UIPrimitives, + logger, + select_session, ) -_CTA_BLOCKED_KEYWORDS = ( - "预约", - "预售", - "即将开抢", - "待开售", - "未开售", - "倒计时", - "无票", - "售罄", - "缺货", -) -_MANUAL_STEP_BASELINES = { - "搜索页输入并提交关键词": 6.0, - "搜索结果扫描并打开目标": 12.0, -} - - -class DamaiBot(UIPrimitives): +from .coords_cache import CoordsCacheMixin +from .delegators import DelegatorsMixin +from .purchase_flow import PurchaseFlowMixin +from .recovery_strategies import RecoveryStrategiesMixin +from .sale_waiter import SaleWaiterMixin +from .state_probe import StateProbeMixin + + +class DamaiBot( + SaleWaiterMixin, + PurchaseFlowMixin, + RecoveryStrategiesMixin, + CoordsCacheMixin, + StateProbeMixin, + DelegatorsMixin, + UIPrimitives, +): def __init__(self, config=None, setup_driver=True): self.config = config or Config.load_config() self.item_detail = None @@ -444,122 +367,6 @@ def _click_sku_buy_button_element(self, burst_count=1): time.sleep(0.03) return True - def _attendee_required_count_on_confirm_page(self): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._attendee_required_count_on_confirm_page() - logger.warning("AttendeeSelector 未初始化") - return max(1, len(self.config.users or [])) - - def _attendee_checkbox_elements(self): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._attendee_checkbox_elements() - return [] - - @staticmethod - def _is_checkbox_selected(checkbox): - return DamaiBot._is_checked(checkbox) - - def _attendee_selected_count( - self, checkbox_elements=None, use_source_fallback=True - ): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._attendee_selected_count( - checkbox_elements, use_source_fallback - ) - return 0 - - def _click_attendee_checkbox(self, checkbox): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._click_attendee_checkbox(checkbox) - return False - - def _click_attendee_checkbox_fast(self, checkbox): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._click_attendee_checkbox_fast(checkbox) - return False - - def _select_attendee_checkbox_by_name(self, user_name): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._select_attendee_checkbox_by_name(user_name) - return False - - def _ensure_attendees_selected_on_confirm_page( - self, require_attendee_section=False - ): - if hasattr(self, "_attendee_sel"): - return self._attendee_sel._ensure_attendees_selected_on_confirm_page( - require_attendee_section - ) - logger.warning("AttendeeSelector 未初始化") - return False - - def _get_buy_button_coordinates(self, xml_root=None): - if hasattr(self, "_price_sel"): - return self._price_sel._get_buy_button_coordinates(xml_root) - return None - - def _get_price_option_coordinates_by_config_index(self, xml_root=None): - if hasattr(self, "_price_sel"): - return self._price_sel._get_price_option_coordinates_by_config_index( - xml_root - ) - return None - - def _build_compound_price_text(self, container): - if hasattr(self, "_price_sel"): - return self._price_sel._build_compound_price_text(container) - return "" - - def _price_option_text_from_descendants(self, texts): - if hasattr(self, "_price_sel"): - return self._price_sel._price_option_text_from_descendants(texts) - return "" - - def _normalize_ocr_price_text(self, ocr_output): - if hasattr(self, "_price_sel"): - return self._price_sel._normalize_ocr_price_text(ocr_output) - return "" - - def _ocr_price_text_from_card(self, screenshot_path, rect): - if hasattr(self, "_price_sel"): - return self._price_sel._ocr_price_text_from_card(screenshot_path, rect) - return "" - - def _extract_price_digits(self, text): - if hasattr(self, "_price_sel"): - return self._price_sel._extract_price_digits(text) - return None - - def _price_text_matches_target(self, text): - if hasattr(self, "_price_sel"): - return self._price_sel._price_text_matches_target(text) - return False - - def _is_price_option_available(self, option): - if hasattr(self, "_price_sel"): - return self._price_sel._is_price_option_available(option) - return True - - def _click_visible_price_option(self, card_index): - if hasattr(self, "_price_sel"): - return self._price_sel._click_visible_price_option(card_index) - return False - - def _click_price_option_by_config_index(self, burst=False, coords=None): - if hasattr(self, "_price_sel"): - return self._price_sel._click_price_option_by_config_index(burst, coords) - return False - - def _select_price_option_fast(self, cached_coords=None): - if hasattr(self, "_price_sel"): - return self._price_sel._select_price_option_fast(cached_coords) - return None - - def _select_price_option(self, cached_coords=None): - if hasattr(self, "_price_sel"): - return self._price_sel._select_price_option(cached_coords) - return False - # ------------------------------------------------------------------ # Failure diagnostics (P1 #31) # ------------------------------------------------------------------ @@ -608,202 +415,6 @@ def _save_price_failure_dump(self, reason: str = "") -> Path | None: logger.warning("保存 price dump 失败: %s", exc) return None - def _keyword_tokens(self): - if hasattr(self, "_navigator"): - return self._navigator._keyword_tokens() - return [] - - def _get_detail_title_text(self, xml_root=None): - """Read title text from detail/sku pages.""" - if xml_root is not None and self._using_u2(): - title = self._xml_find_text_by_resource_id(xml_root, "cn.damai:id/title_tv") - if title: - return title - parts = [ - self._xml_find_text_by_resource_id(xml_root, rid) - for rid in ( - "cn.damai:id/project_title_tv1", - "cn.damai:id/project_title_tv2", - ) - ] - return "".join(p.strip() for p in parts if p).strip() - - title = "" - try: - title = self._safe_element_text(self.driver, By.ID, "cn.damai:id/title_tv") - except Exception: - title = "" - - if title: - return title - - title_parts = [] - for resource_id in ( - "cn.damai:id/project_title_tv1", - "cn.damai:id/project_title_tv2", - ): - part = self._safe_element_text(self.driver, By.ID, resource_id) - if part: - title_parts.append(part.strip()) - - return "".join(title_parts).strip() - - def _title_matches_target(self, title_text): - if hasattr(self, "_navigator"): - return self._navigator._title_matches_target(title_text) - return False - - def _current_page_matches_target(self, page_probe): - if hasattr(self, "_navigator"): - return self._navigator._current_page_matches_target(page_probe) - return False - - def _exit_non_target_event_context( - self, page_probe, max_back_steps=4, back_delay=0.5 - ): - """Back out from a non-target detail/sku page until search/homepage is reachable.""" - current_probe = page_probe - - for _ in range(max_back_steps): - if current_probe["state"] not in {"detail_page", "sku_page"}: - return current_probe - if self._current_page_matches_target(current_probe): - return current_probe - - if not self._press_keycode_safe(4, context="退出非目标演出页"): - break - time.sleep(back_delay) - self.dismiss_startup_popups() - current_probe = self.probe_current_page() - - return current_probe - - def _recover_to_navigation_start(self, page_probe, max_back_steps=3): - """Recover to a navigable page such as homepage or search page.""" - navigable_states = {"homepage", "search_page", "detail_page", "sku_page"} - current_probe = page_probe - if current_probe["state"] in navigable_states: - return current_probe - - for _ in range(max_back_steps): - if not self._press_keycode_safe(4, context="恢复导航起点"): - break - time.sleep(0.4) - current_probe = self.probe_current_page() - if current_probe["state"] in navigable_states: - return current_probe - - try: - if not self._using_u2(): - self.driver.activate_app(self.config.app_package) - else: - self.d.app_start(self.config.app_package, stop=False) - time.sleep(1) - except Exception: - pass - - return self.probe_current_page() - - def _recover_to_detail_page_for_local_retry( - self, initial_probe=None, max_back_steps=8, back_delay=0.15 - ): - """Recover locally to the current event detail/sku page without rebuilding the Appium session.""" - # Delegate to RecoveryHelper if available - if hasattr(self, "_recovery") and initial_probe is None: - result = self._recovery.recover_to_detail_page() - if result["state"] in {"detail_page", "sku_page"}: - return result - # Fall through to existing logic if recovery failed - - # Original logic below (unchanged) - current_probe = initial_probe or self.probe_current_page(fast=True) - retryable_states = {"detail_page", "sku_page"} - - if current_probe["state"] in retryable_states and ( - not self.item_detail or self._current_page_matches_target(current_probe) - ): - return current_probe - - self.dismiss_startup_popups() - current_probe = self.probe_current_page() - if current_probe["state"] in retryable_states and ( - not self.item_detail or self._current_page_matches_target(current_probe) - ): - return current_probe - - for _ in range(max_back_steps): - if not self._press_keycode_safe(4, context="本地快速回退"): - break - time.sleep(back_delay) - # Use lightweight probe during back-navigation (skip popup - # dismissal and full probe — saves ~2s per step). - current_probe = self.probe_current_page(fast=True) - if current_probe["state"] in retryable_states and ( - not self.item_detail or self._current_page_matches_target(current_probe) - ): - return current_probe - - # If we ended up on homepage, try forward navigation - if current_probe["state"] in {"homepage"}: - logger.info("回退到首页,尝试正向导航回详情页") - self.navigate_to_target_event() - current_probe = self.probe_current_page() - - return current_probe - - def _open_search_from_homepage(self): - if hasattr(self, "_navigator"): - return self._navigator._open_search_from_homepage() - return False - - def _submit_search_keyword(self): - if hasattr(self, "_navigator"): - return self._navigator._submit_search_keyword() - return False - - def _score_search_result(self, title_text, venue_text): - if hasattr(self, "_navigator"): - return self._navigator._score_search_result(title_text, venue_text) - return -1 - - def _scroll_search_results(self): - if hasattr(self, "_navigator"): - return self._navigator._scroll_search_results() - - def _open_target_from_search_results( - self, max_scrolls=2, max_results=5, return_details=False - ): - if hasattr(self, "_navigator"): - return self._navigator._open_target_from_search_results( - max_scrolls, max_results, return_details - ) - return {"opened": False, "search_results": []} if return_details else False - - def collect_search_results(self, max_scrolls=0, max_results=5): - if hasattr(self, "_navigator"): - return self._navigator.collect_search_results(max_scrolls, max_results) - return [] - - def navigate_to_target_event(self, initial_probe=None): - """Navigate to the target event. Delegates to EventNavigator.""" - if hasattr(self, "_navigator") and self._navigator is not None: - return self._navigator.navigate_to_target_event(initial_probe=initial_probe) - return self._navigate_to_target_impl(initial_probe=initial_probe) - - def _navigate_to_target_impl(self, initial_probe=None): - if hasattr(self, "_navigator"): - return self._navigator._navigate_to_target_impl(initial_probe) - return False - - def discover_target_event( - self, keyword_candidates, initial_probe=None, search_scrolls=1, result_limit=5 - ): - if hasattr(self, "_navigator"): - return self._navigator.discover_target_event( - keyword_candidates, initial_probe, search_scrolls, result_limit - ) - return None - def select_performance_date(self, timeout=1.0): """选择演出场次日期""" if not self.config.date: @@ -883,118 +494,6 @@ def _prepare_detail_page_hot_path(self): return prepared - def _rush_preselect_and_buy_via_xml(self): - self._ensure_pipeline() - return self._pipeline.rush_preselect_and_buy_via_xml() - - def _enter_purchase_flow_from_detail_page(self, prepared=False): - """Open the purchase panel from the detail page with a low-latency hot path.""" - if self.config.rush_mode: - self._dismiss_fast_blocking_dialogs() - if not prepared: - if self.config.rush_mode: - # 极速模式冷路径:单次 XML dump 提取所有坐标(~0.3s),替代多次 _cached_tap(~3-4s)。 - # 热路径(有缓存)用 _cached_tap 直接点击缓存坐标(1次 HTTP/元素)。 - if self._using_u2() and not self._cached_hot_path_coords.get( - "detail_buy" - ): - # Cold path: single XML dump for all detail page elements. - if self._rush_preselect_and_buy_via_xml(): - next_probe = self._wait_for_purchase_entry_result( - timeout=6.0, poll_interval=0.03 - ) - if next_probe["state"] in {"sku_page", "order_confirm_page"}: - return next_probe - else: - # Warm path: cached coords for date/city/buy. - if ( - self.config.date - and "date" not in self._cached_hot_path_no_match - ): - _date_found = self._cached_tap( - "date", - ANDROID_UIAUTOMATOR, - f'new UiSelector().textContains("{self.config.date}")', - timeout=0.1, - ) - if _date_found: - logger.info(f"极速模式预选日期: {self.config.date}") - elif "date" not in self._cached_hot_path_coords: - self._cached_hot_path_no_match.add("date") - if ( - self.config.city - and "city" not in self._cached_hot_path_no_match - ): - _city_found = self._cached_tap( - "city", - ANDROID_UIAUTOMATOR, - f'new UiSelector().text("{self.config.city}")', - timeout=0.2, - ) - if not _city_found: - _city_found = self._cached_tap( - "city", - ANDROID_UIAUTOMATOR, - f'new UiSelector().textContains("{self.config.city}")', - timeout=0.15, - ) - if _city_found: - logger.info(f"极速模式预选城市: {self.config.city}") - elif "city" not in self._cached_hot_path_coords: - self._cached_hot_path_no_match.add("city") - logger.debug("极速模式未命中城市选择,继续抢占购票入口") - else: - self.select_performance_date() - logger.info("选择城市...") - if not self._select_city_from_detail_page(timeout=1.0): - logger.warning("城市选择失败") - return None - - if not self._cached_hot_path_coords.get("detail_buy"): - logger.info("点击购票按钮...") - if self.config.rush_mode: - # 极速模式:_cached_tap 冷路径查找并缓存购票按钮坐标,热路径直接点击(1次HTTP)。 - # 点击一次后等足够长时间,避免重复点击重置 sku_page 加载。 - _buy_clicked = self._cached_tap( - "detail_buy", - By.ID, - "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", - timeout=0.2, - ) - if not _buy_clicked: - # 文案集合源 SALE_READY_TEXTS(issue #29)+ 旧文案兜底 - _buy_clicked = self._cached_tap( - "detail_buy", - ANDROID_UIAUTOMATOR, - f'new UiSelector().textMatches("{_SALE_READY_TEXT_REGEX_OR}|.*购票.*|.*抢票.*|.*购买.*")', - timeout=0.25, - ) - if _buy_clicked: - next_probe = self._wait_for_purchase_entry_result( - timeout=6.0, poll_interval=0.03 - ) - if next_probe["state"] in {"sku_page", "order_confirm_page"}: - return next_probe - - # 文案集合源 SALE_READY_TEXTS(issue #29)+ 旧"预约/购买"兜底 - book_selectors = [ - ( - By.ID, - "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", - ), - ( - ANDROID_UIAUTOMATOR, - f'new UiSelector().textMatches("{_SALE_READY_TEXT_REGEX_OR}|.*预约.*|.*购买.*")', - ), - (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), - ] - if not self.smart_wait_and_click( - *book_selectors[0], book_selectors[1:], timeout=0.8 - ): - logger.warning("购票按钮点击失败") - return None - return self._wait_for_purchase_entry_result(timeout=5, poll_interval=0.08) - def check_session_valid(self): """检查大麦 App 登录状态是否有效""" activity = self._get_current_activity() @@ -1013,111 +512,6 @@ def check_session_valid(self): return True - def _purchase_bar_text_ready(self): - """Inspect the detail-page CTA text and decide whether sale has opened.""" - try: - purchase_bar = self._find( - By.ID, - "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", - ) - except Exception: - return False - - texts = [ - text.strip() - for text in self._collect_descendant_texts(purchase_bar) - if text.strip() - ] - merged = normalize_text("".join(texts)) - if not merged: - return False - if any(normalize_text(keyword) in merged for keyword in _CTA_BLOCKED_KEYWORDS): - return False - return any(normalize_text(keyword) in merged for keyword in _CTA_READY_KEYWORDS) - - def _is_sale_ready(self): - """Check whether the current UI state is actionable for purchase. - - Sale-readiness is detected via :data:`SALE_READY_TEXTS` (开售文案) plus - a small set of post-confirm CTAs ("立即购买" / "选座购买" / "提交订单" 等) - that may appear once the user has already entered the SKU/order page. - """ - ready_texts = ( - *SALE_READY_TEXTS, - "立即购买", - "选座购买", - "立即提交", - "提交订单", - ) - for text in ready_texts: - if self._has_element( - ANDROID_UIAUTOMATOR, - f'new UiSelector().textContains("{text}")', - ): - self._last_sale_ready_text = text - return True - - if self._has_element( - By.ID, "cn.damai:id/project_detail_perform_price_flowlayout" - ): - return not self.is_reservation_sku_mode() - - if self._has_element( - By.ID, "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" - ): - return self._purchase_bar_text_ready() - - return False - - def wait_for_sale_start(self): - """等待开售时间,在开售前 countdown_lead_ms 毫秒开始轮询。""" - if self.config.sell_start_time is None: - if self.config.wait_cta_ready_timeout_ms > 0: - logger.info("未配置 sell_start_time,已跳过 CTA 等待,直接开始执行") - return - - _tz_shanghai = timezone(timedelta(hours=8)) - sell_time = datetime.fromisoformat(self.config.sell_start_time) - # Ensure timezone-aware - if sell_time.tzinfo is None: - sell_time = sell_time.replace(tzinfo=_tz_shanghai) - - now = datetime.now(tz=_tz_shanghai) - if now >= sell_time: - logger.info("开售时间已过,跳过等待") - return - - lead_delta = timedelta(milliseconds=self.config.countdown_lead_ms) - poll_start = sell_time - lead_delta - sleep_seconds = (poll_start - now).total_seconds() - - if sleep_seconds > 0: - logger.info( - f"等待开售,将在 {self.config.sell_start_time} 前 " - f"{self.config.countdown_lead_ms}ms 开始轮询" - ) - time.sleep(sleep_seconds) - - # Use BuyButtonGuard for precise button-text monitoring - if hasattr(self, "_guard") and self._guard.wait_until_safe( - timeout_s=8.0, poll_ms=50 - ): - logger.info("BuyButtonGuard 检测到可购买按钮") - return - - # Tight polling loop with multiple purchase signals until the page becomes actionable. - deadline = sell_time + timedelta(seconds=8) - polls = 0 - while datetime.now(tz=_tz_shanghai) < deadline: - polls += 1 - if self._is_sale_ready(): - cta_text = getattr(self, "_last_sale_ready_text", None) or "?" - logger.info(f"CTA_MATCH: text={cta_text!r} polls={polls} (开售已开始)") - return - time.sleep(0.08) - - logger.warning(f"等待开售超时(轮询 {polls} 次),继续执行") - def verify_order_result(self, timeout=5): """验证订单提交结果""" start = time.time() @@ -1188,107 +582,6 @@ def verify_order_result(self, timeout=5): logger.warning("订单验证超时") return "timeout" - def _submit_order_fast(self, submit_selectors): - """Attempt submit quickly and retry within the confirm page before falling back.""" - attempt_count = 3 - has_submitted_once = False - for attempt in range(attempt_count): - submit_success = False - if self.ultra_fast_click(*submit_selectors[0], timeout=0.35): - submit_success = True - elif self.ultra_fast_click(*submit_selectors[1], timeout=0.35): - submit_success = True - elif self.smart_wait_and_click( - *submit_selectors[0], submit_selectors[1:], timeout=0.6 - ): - submit_success = True - - if not submit_success: - logger.warning("提交订单按钮未找到,请手动确认订单状态") - if has_submitted_once: - followup_result = self.verify_order_result(timeout=2) - if followup_result != "timeout": - return followup_result - return "timeout" - - has_submitted_once = True - verify_timeout = 1.2 if attempt < attempt_count - 1 else 3 - result = self.verify_order_result(timeout=verify_timeout) - if result != "timeout": - return result - logger.warning( - f"提交后暂未确认结果,快速重试提交 {attempt + 2}/{attempt_count}" - ) - - return "timeout" - - def _fast_retry_from_current_state(self): - """根据当前页面状态进行快速重试。""" - page_probe = self.probe_current_page() - state = page_probe["state"] - - if state in ("detail_page", "sku_page"): - if self.item_detail and not self._current_page_matches_target(page_probe): - if not self.config.auto_navigate: - logger.warning( - "当前详情页不是目标演出,手动起跑模式下停止本地快速重试" - ) - return False - logger.info("当前详情页不是目标演出,转为自动导航") - return ( - self.navigate_to_target_event(page_probe) - and self.run_ticket_grabbing() - ) - return self.run_ticket_grabbing() - elif state == "order_confirm_page": - if not self.config.if_commit_order: - if not self._ensure_attendees_selected_on_confirm_page(): - self._set_terminal_failure("attendee_unselected") - logger.error("开发验证模式下观演人未选择完整,已停止") - return False - submit_selectors = [ - (ANDROID_UIAUTOMATOR, 'new UiSelector().text("立即提交")'), - ( - ANDROID_UIAUTOMATOR, - 'new UiSelector().textMatches(".*提交.*|.*确认.*")', - ), - (By.XPATH, '//*[contains(@text,"提交")]'), - ] - return self.smart_wait_for_element( - *submit_selectors[0], submit_selectors[1:] - ) - if not self._ensure_attendees_selected_on_confirm_page(): - self._set_terminal_failure("attendee_unselected") - return False - submit_selectors = [ - (ANDROID_UIAUTOMATOR, 'new UiSelector().text("立即提交")'), - ( - ANDROID_UIAUTOMATOR, - 'new UiSelector().textMatches(".*提交.*|.*确认.*")', - ), - (By.XPATH, '//*[contains(@text,"提交")]'), - ] - return self.smart_wait_and_click(*submit_selectors[0], submit_selectors[1:]) - elif state == "pending_order_dialog": - self._set_run_outcome("order_pending_payment") - logger.info( - "检测到未支付订单弹窗(已占单待支付),请立即前往订单页完成支付" - ) - return True - else: - if self.config.auto_navigate: - return ( - self.navigate_to_target_event(page_probe) - and self.run_ticket_grabbing() - ) - recovered_probe = self._recover_to_detail_page_for_local_retry(page_probe) - if recovered_probe["state"] not in {"detail_page", "sku_page"}: - logger.warning( - f"本地快速回退后仍未回到演出页,当前状态: {recovered_probe['state']}" - ) - return False - return self.run_ticket_grabbing() - def dismiss_startup_popups(self): """处理首启的一次性系统/应用弹窗。""" dismissed = False @@ -1339,280 +632,6 @@ def _dismiss_fast_blocking_dialogs(self): return dismissed - def is_reservation_sku_mode(self): - """识别当前 SKU 页是否仍处于抢票预约流,而非正式下单流。""" - reservation_indicators = [ - (By.ID, "cn.damai:id/btn_cancel_reservation"), - (ANDROID_UIAUTOMATOR, 'new UiSelector().text("预约想看场次")'), - (ANDROID_UIAUTOMATOR, 'new UiSelector().text("预约想看票档")'), - (ANDROID_UIAUTOMATOR, 'new UiSelector().textContains("提交抢票预约")'), - (ANDROID_UIAUTOMATOR, 'new UiSelector().textContains("已预约")'), - ] - - return any(self._has_element(by, value) for by, value in reservation_indicators) - - def get_visible_date_options(self, xml_root=None): - """Return visible date options on the current page.""" - if xml_root is not None and self._using_u2(): - dates = [] - seen = set() - for node in xml_root.iter("node"): - if node.get("resource-id") == "cn.damai:id/tv_date": - text = (node.get("text") or "").strip() - if text and text not in seen: - dates.append(text) - seen.add(text) - return dates - - dates = [] - seen = set() - for element in self._find_all(By.ID, "cn.damai:id/tv_date"): - text = self._read_element_text(element).strip() - if not text or text in seen: - continue - dates.append(text) - seen.add(text) - return dates - - def get_visible_price_options(self, allow_ocr=True, xml_root=None): - if hasattr(self, "_price_sel"): - return self._price_sel.get_visible_price_options( - allow_ocr=allow_ocr, xml_root=xml_root - ) - return [] - - def _get_visible_price_options_from_xml(self, xml_root, allow_ocr=True): - if hasattr(self, "_price_sel"): - return self._price_sel._get_visible_price_options_from_xml( - xml_root, allow_ocr=allow_ocr - ) - return [] - - def _get_detail_venue_text(self, xml_root=None): - """Read venue text from the detail page if present.""" - if xml_root is not None and self._using_u2(): - for resource_id in ( - "cn.damai:id/venue_name_0", - "cn.damai:id/tv_project_venueName", - ): - value = self._xml_find_text_by_resource_id(xml_root, resource_id) - if value: - return value.strip() - return "" - - for resource_id in ( - "cn.damai:id/venue_name_0", - "cn.damai:id/tv_project_venueName", - ): - value = self._safe_element_text(self.driver, By.ID, resource_id) - if value: - return value.strip() - return "" - - def ensure_sku_page_for_inspection(self, page_probe=None): - """Safely enter the sku page so prompt-based flows can inspect dates and prices.""" - page_probe = page_probe or self.probe_current_page() - if page_probe["state"] == "sku_page": - return page_probe - - if page_probe["state"] != "detail_page": - return page_probe - - book_selectors = [ - ( - By.ID, - "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", - ), - ( - ANDROID_UIAUTOMATOR, - 'new UiSelector().textMatches(".*预约.*|.*购买.*|.*立即.*")', - ), - (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), - ] - if not self.smart_wait_and_click( - *book_selectors[0], book_selectors[1:], timeout=0.5 - ): - return self.probe_current_page() - - return self._wait_for_purchase_entry_result(timeout=5, poll_interval=0.04) - - def inspect_current_target_event(self, page_probe=None): - """Summarize the currently opened event for prompt-based confirmation.""" - page_probe = page_probe or self.probe_current_page() - - xml_root = None - sku_probe = page_probe - - if page_probe["state"] == "detail_page": - # Click buy immediately so sku_page starts loading before we do anything else. - book_selectors = [ - ( - By.ID, - "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", - ), - ( - ANDROID_UIAUTOMATOR, - 'new UiSelector().textMatches(".*预约.*|.*购买.*|.*立即.*")', - ), - (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), - ] - clicked = self.smart_wait_and_click( - *book_selectors[0], book_selectors[1:], timeout=0.5 - ) - # Dump detail_page hierarchy while sku_page loads (~1.5s parallel time). - xml_root = self._dump_hierarchy_xml() - if clicked: - sku_probe = self._wait_for_purchase_entry_result( - timeout=4.0, poll_interval=0.04 - ) - else: - sku_probe = self.probe_current_page() - elif page_probe["state"] != "sku_page": - sku_probe = self.ensure_sku_page_for_inspection(page_probe) - - summary = { - "state": sku_probe["state"], - "title": self._get_detail_title_text(xml_root=xml_root), - "venue": self._get_detail_venue_text(xml_root=xml_root), - "dates": [], - "price_options": [], - "reservation_mode": sku_probe.get("reservation_mode", False), - } - - if sku_probe["state"] == "sku_page": - # Re-dump for sku_page content (different screen from detail_page). - xml_root = self._dump_hierarchy_xml() - if not summary["title"]: - summary["title"] = self._get_detail_title_text(xml_root=xml_root) - if not summary["venue"]: - summary["venue"] = self._get_detail_venue_text(xml_root=xml_root) - summary["reservation_mode"] = sku_probe.get("reservation_mode", False) - summary["dates"] = self.get_visible_date_options(xml_root=xml_root) - summary["price_options"] = self.get_visible_price_options(xml_root=xml_root) - - return summary - - def probe_current_page(self, fast=False): - """探测当前页面状态和关键控件可见性。""" - # Delegate to PageProbe when available (u2 backend) - if hasattr(self, "_page_probe"): - result = self._page_probe.probe_current_page(fast=fast) - if result["state"] != "unknown" or fast: - logger.info(f"当前页面状态: {result['state']}") - return result - - # Fallback: element-based probe using _has_element - return self._probe_current_page_element_based() - - def _probe_current_page_element_based(self): - """Full probe using _has_element calls (fallback when PageProbe unavailable).""" - state = "unknown" - current_activity = self._get_current_activity() - purchase_button = self._has_element( - 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" - ) - sku_price_container = ( - self._has_element( - By.ID, "cn.damai:id/project_detail_perform_price_flowlayout" - ) - or self._has_element(By.ID, "cn.damai:id/layout_price") - or self._has_element(By.ID, "cn.damai:id/tv_price_name") - ) - quantity_picker = self._has_element(By.ID, "layout_num") - submit_button = self._has_element(By.ID, "cn.damai:id/checkbox") - pending_order_dialog = self._has_element( - By.ID, "cn.damai:id/damai_theme_dialog_confirm_btn" - ) - reservation_mode = False - - if self._has_element(By.ID, "cn.damai:id/id_boot_action_agree"): - state = "consent_dialog" - elif pending_order_dialog: - state = "pending_order_dialog" - elif ( - "MainActivity" in current_activity - or self._has_element(By.ID, "cn.damai:id/homepage_header_search") - or self._has_element( - By.ID, "cn.damai:id/pioneer_homepage_header_search_btn" - ) - ): - state = "homepage" - elif "SearchActivity" in current_activity or self._has_element( - By.ID, "cn.damai:id/header_search_v2_input" - ): - state = "search_page" - elif submit_button: - state = "order_confirm_page" - elif ( - "NcovSkuActivity" in current_activity - or self._has_element(By.ID, "cn.damai:id/layout_sku") - or self._has_element(By.ID, "cn.damai:id/sku_contanier") - ): - state = "sku_page" - elif ( - "ProjectDetailActivity" in current_activity - or purchase_button - or detail_price_summary - or self._has_element(By.ID, "cn.damai:id/title_tv") - ): - state = "detail_page" - - if state == "sku_page": - reservation_mode = self.is_reservation_sku_mode() - - result = { - "state": state, - "purchase_button": purchase_button, - "price_container": sku_price_container or detail_price_summary, - "quantity_picker": quantity_picker, - "submit_button": submit_button, - "reservation_mode": reservation_mode, - "pending_order_dialog": pending_order_dialog, - } - - logger.info(f"当前页面状态: {result['state']}") - if current_activity: - logger.debug(f"当前 Activity: {current_activity}") - logger.debug( - "探测结果: " - f"purchase_button={result['purchase_button']}, " - f"price_container={result['price_container']}, " - f"quantity_picker={result['quantity_picker']}, " - f"submit_button={result['submit_button']}, " - f"reservation_mode={result['reservation_mode']}" - ) - - return result - - def _has_warm_pipeline_coords(self): - """Check if all coordinates required for the blind pipeline are cached.""" - if hasattr(self, "_pipeline"): - return self._pipeline.has_warm_coords() - c = self._cached_hot_path_coords - return all( - [ - c.get("detail_buy"), - c.get("price"), - c.get("sku_buy"), - c.get("attendee_checkboxes"), - ] - ) - - def _run_cold_validation_pipeline(self, start_time): - self._ensure_pipeline() - return self._pipeline.run_cold_validation(start_time) - - def _cold_pipeline_finish_confirm(self, start_time): - self._ensure_pipeline() - return self._pipeline._finish_confirm(start_time) - - def _run_warm_validation_pipeline(self, start_time): - self._ensure_pipeline() - return self._pipeline.run_warm_validation(start_time) - def run_ticket_grabbing(self, initial_page_probe=None): """执行抢票主流程""" try: @@ -2054,19 +1073,3 @@ def run_with_retry(self, max_retries=3, initial_page_probe=None): logger.error("所有尝试均失败") return False - - -# 使用示例 -if __name__ == "__main__": - bot = None - try: - bot = DamaiBot() - bot.run_with_retry(max_retries=3) - except (ValueError, RuntimeError) as exc: - logger.error(str(exc)) - finally: - try: - if bot and bot.driver: - bot.driver.quit() - except Exception: - pass diff --git a/mobile/damai_app/purchase_flow.py b/mobile/damai_app/purchase_flow.py new file mode 100644 index 0000000..8715f2c --- /dev/null +++ b/mobile/damai_app/purchase_flow.py @@ -0,0 +1,169 @@ +# -*- coding: UTF-8 -*- +"""Purchase-flow helpers for DamaiBot. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). Hosts the detail→purchase entry path and the fast submit retry loop. +""" + +from __future__ import annotations + +from . import ( + _SALE_READY_TEXT_REGEX_OR, + logger, +) + +try: + from mobile.ui_primitives import ANDROID_UIAUTOMATOR +except ImportError: # pragma: no cover + from ui_primitives import ANDROID_UIAUTOMATOR # type: ignore[no-redef] + +try: + from selenium.webdriver.common.by import By +except ModuleNotFoundError: # pragma: no cover + raise + + +class PurchaseFlowMixin: + """Mixin contributing detail→purchase entry and submit logic to ``DamaiBot``.""" + + def _enter_purchase_flow_from_detail_page(self, prepared=False): + """Open the purchase panel from the detail page with a low-latency hot path.""" + if self.config.rush_mode: + self._dismiss_fast_blocking_dialogs() + if not prepared: + if self.config.rush_mode: + # 极速模式冷路径:单次 XML dump 提取所有坐标(~0.3s),替代多次 _cached_tap(~3-4s)。 + # 热路径(有缓存)用 _cached_tap 直接点击缓存坐标(1次 HTTP/元素)。 + if self._using_u2() and not self._cached_hot_path_coords.get( + "detail_buy" + ): + # Cold path: single XML dump for all detail page elements. + if self._rush_preselect_and_buy_via_xml(): + next_probe = self._wait_for_purchase_entry_result( + timeout=6.0, poll_interval=0.03 + ) + if next_probe["state"] in {"sku_page", "order_confirm_page"}: + return next_probe + else: + # Warm path: cached coords for date/city/buy. + if ( + self.config.date + and "date" not in self._cached_hot_path_no_match + ): + _date_found = self._cached_tap( + "date", + ANDROID_UIAUTOMATOR, + f'new UiSelector().textContains("{self.config.date}")', + timeout=0.1, + ) + if _date_found: + logger.info(f"极速模式预选日期: {self.config.date}") + elif "date" not in self._cached_hot_path_coords: + self._cached_hot_path_no_match.add("date") + if ( + self.config.city + and "city" not in self._cached_hot_path_no_match + ): + _city_found = self._cached_tap( + "city", + ANDROID_UIAUTOMATOR, + f'new UiSelector().text("{self.config.city}")', + timeout=0.2, + ) + if not _city_found: + _city_found = self._cached_tap( + "city", + ANDROID_UIAUTOMATOR, + f'new UiSelector().textContains("{self.config.city}")', + timeout=0.15, + ) + if _city_found: + logger.info(f"极速模式预选城市: {self.config.city}") + elif "city" not in self._cached_hot_path_coords: + self._cached_hot_path_no_match.add("city") + logger.debug("极速模式未命中城市选择,继续抢占购票入口") + else: + self.select_performance_date() + logger.info("选择城市...") + if not self._select_city_from_detail_page(timeout=1.0): + logger.warning("城市选择失败") + return None + + if not self._cached_hot_path_coords.get("detail_buy"): + logger.info("点击购票按钮...") + if self.config.rush_mode: + # 极速模式:_cached_tap 冷路径查找并缓存购票按钮坐标,热路径直接点击(1次HTTP)。 + # 点击一次后等足够长时间,避免重复点击重置 sku_page 加载。 + _buy_clicked = self._cached_tap( + "detail_buy", + By.ID, + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + timeout=0.2, + ) + if not _buy_clicked: + # 文案集合源 SALE_READY_TEXTS(issue #29)+ 旧文案兜底 + _buy_clicked = self._cached_tap( + "detail_buy", + ANDROID_UIAUTOMATOR, + f'new UiSelector().textMatches("{_SALE_READY_TEXT_REGEX_OR}|.*购票.*|.*抢票.*|.*购买.*")', + timeout=0.25, + ) + if _buy_clicked: + next_probe = self._wait_for_purchase_entry_result( + timeout=6.0, poll_interval=0.03 + ) + if next_probe["state"] in {"sku_page", "order_confirm_page"}: + return next_probe + + # 文案集合源 SALE_READY_TEXTS(issue #29)+ 旧"预约/购买"兜底 + book_selectors = [ + ( + By.ID, + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + ), + ( + ANDROID_UIAUTOMATOR, + f'new UiSelector().textMatches("{_SALE_READY_TEXT_REGEX_OR}|.*预约.*|.*购买.*")', + ), + (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), + ] + if not self.smart_wait_and_click( + *book_selectors[0], book_selectors[1:], timeout=0.8 + ): + logger.warning("购票按钮点击失败") + return None + return self._wait_for_purchase_entry_result(timeout=5, poll_interval=0.08) + + def _submit_order_fast(self, submit_selectors): + """Attempt submit quickly and retry within the confirm page before falling back.""" + attempt_count = 3 + has_submitted_once = False + for attempt in range(attempt_count): + submit_success = False + if self.ultra_fast_click(*submit_selectors[0], timeout=0.35): + submit_success = True + elif self.ultra_fast_click(*submit_selectors[1], timeout=0.35): + submit_success = True + elif self.smart_wait_and_click( + *submit_selectors[0], submit_selectors[1:], timeout=0.6 + ): + submit_success = True + + if not submit_success: + logger.warning("提交订单按钮未找到,请手动确认订单状态") + if has_submitted_once: + followup_result = self.verify_order_result(timeout=2) + if followup_result != "timeout": + return followup_result + return "timeout" + + has_submitted_once = True + verify_timeout = 1.2 if attempt < attempt_count - 1 else 3 + result = self.verify_order_result(timeout=verify_timeout) + if result != "timeout": + return result + logger.warning( + f"提交后暂未确认结果,快速重试提交 {attempt + 2}/{attempt_count}" + ) + + return "timeout" diff --git a/mobile/damai_app/recovery_strategies.py b/mobile/damai_app/recovery_strategies.py new file mode 100644 index 0000000..7497344 --- /dev/null +++ b/mobile/damai_app/recovery_strategies.py @@ -0,0 +1,187 @@ +# -*- coding: UTF-8 -*- +"""Recovery / fast-retry helpers for DamaiBot. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). Hosts the post-failure state-machine dispatcher and back-press +recovery loops shared between the cold-path and warm-path retries. +""" + +from __future__ import annotations + +import time + +from . import logger + +try: + from mobile.ui_primitives import ANDROID_UIAUTOMATOR +except ImportError: # pragma: no cover + from ui_primitives import ANDROID_UIAUTOMATOR # type: ignore[no-redef] + +try: + from selenium.webdriver.common.by import By +except ModuleNotFoundError: # pragma: no cover + raise + + +class RecoveryStrategiesMixin: + """Mixin contributing recovery and fast-retry orchestration to ``DamaiBot``.""" + + def _exit_non_target_event_context( + self, page_probe, max_back_steps=4, back_delay=0.5 + ): + """Back out from a non-target detail/sku page until search/homepage is reachable.""" + current_probe = page_probe + + for _ in range(max_back_steps): + if current_probe["state"] not in {"detail_page", "sku_page"}: + return current_probe + if self._current_page_matches_target(current_probe): + return current_probe + + if not self._press_keycode_safe(4, context="退出非目标演出页"): + break + time.sleep(back_delay) + self.dismiss_startup_popups() + current_probe = self.probe_current_page() + + return current_probe + + def _recover_to_navigation_start(self, page_probe, max_back_steps=3): + """Recover to a navigable page such as homepage or search page.""" + navigable_states = {"homepage", "search_page", "detail_page", "sku_page"} + current_probe = page_probe + if current_probe["state"] in navigable_states: + return current_probe + + for _ in range(max_back_steps): + if not self._press_keycode_safe(4, context="恢复导航起点"): + break + time.sleep(0.4) + current_probe = self.probe_current_page() + if current_probe["state"] in navigable_states: + return current_probe + + try: + if not self._using_u2(): + self.driver.activate_app(self.config.app_package) + else: + self.d.app_start(self.config.app_package, stop=False) + time.sleep(1) + except Exception: + pass + + return self.probe_current_page() + + def _recover_to_detail_page_for_local_retry( + self, initial_probe=None, max_back_steps=8, back_delay=0.15 + ): + """Recover locally to the current event detail/sku page without rebuilding the Appium session.""" + # Delegate to RecoveryHelper if available + if hasattr(self, "_recovery") and initial_probe is None: + result = self._recovery.recover_to_detail_page() + if result["state"] in {"detail_page", "sku_page"}: + return result + # Fall through to existing logic if recovery failed + + # Original logic below (unchanged) + current_probe = initial_probe or self.probe_current_page(fast=True) + retryable_states = {"detail_page", "sku_page"} + + if current_probe["state"] in retryable_states and ( + not self.item_detail or self._current_page_matches_target(current_probe) + ): + return current_probe + + self.dismiss_startup_popups() + current_probe = self.probe_current_page() + if current_probe["state"] in retryable_states and ( + not self.item_detail or self._current_page_matches_target(current_probe) + ): + return current_probe + + for _ in range(max_back_steps): + if not self._press_keycode_safe(4, context="本地快速回退"): + break + time.sleep(back_delay) + # Use lightweight probe during back-navigation (skip popup + # dismissal and full probe — saves ~2s per step). + current_probe = self.probe_current_page(fast=True) + if current_probe["state"] in retryable_states and ( + not self.item_detail or self._current_page_matches_target(current_probe) + ): + return current_probe + + # If we ended up on homepage, try forward navigation + if current_probe["state"] in {"homepage"}: + logger.info("回退到首页,尝试正向导航回详情页") + self.navigate_to_target_event() + current_probe = self.probe_current_page() + + return current_probe + + def _fast_retry_from_current_state(self): + """根据当前页面状态进行快速重试。""" + page_probe = self.probe_current_page() + state = page_probe["state"] + + if state in ("detail_page", "sku_page"): + if self.item_detail and not self._current_page_matches_target(page_probe): + if not self.config.auto_navigate: + logger.warning( + "当前详情页不是目标演出,手动起跑模式下停止本地快速重试" + ) + return False + logger.info("当前详情页不是目标演出,转为自动导航") + return ( + self.navigate_to_target_event(page_probe) + and self.run_ticket_grabbing() + ) + return self.run_ticket_grabbing() + elif state == "order_confirm_page": + if not self.config.if_commit_order: + if not self._ensure_attendees_selected_on_confirm_page(): + self._set_terminal_failure("attendee_unselected") + logger.error("开发验证模式下观演人未选择完整,已停止") + return False + submit_selectors = [ + (ANDROID_UIAUTOMATOR, 'new UiSelector().text("立即提交")'), + ( + ANDROID_UIAUTOMATOR, + 'new UiSelector().textMatches(".*提交.*|.*确认.*")', + ), + (By.XPATH, '//*[contains(@text,"提交")]'), + ] + return self.smart_wait_for_element( + *submit_selectors[0], submit_selectors[1:] + ) + if not self._ensure_attendees_selected_on_confirm_page(): + self._set_terminal_failure("attendee_unselected") + return False + submit_selectors = [ + (ANDROID_UIAUTOMATOR, 'new UiSelector().text("立即提交")'), + ( + ANDROID_UIAUTOMATOR, + 'new UiSelector().textMatches(".*提交.*|.*确认.*")', + ), + (By.XPATH, '//*[contains(@text,"提交")]'), + ] + return self.smart_wait_and_click(*submit_selectors[0], submit_selectors[1:]) + elif state == "pending_order_dialog": + self._set_run_outcome("order_pending_payment") + logger.info( + "检测到未支付订单弹窗(已占单待支付),请立即前往订单页完成支付" + ) + return True + else: + if self.config.auto_navigate: + return ( + self.navigate_to_target_event(page_probe) + and self.run_ticket_grabbing() + ) + recovered_probe = self._recover_to_detail_page_for_local_retry(page_probe) + if recovered_probe["state"] not in {"detail_page", "sku_page"}: + logger.warning( + f"本地快速回退后仍未回到演出页,当前状态: {recovered_probe['state']}" + ) + return False + return self.run_ticket_grabbing() diff --git a/mobile/damai_app/sale_waiter.py b/mobile/damai_app/sale_waiter.py new file mode 100644 index 0000000..2f3be13 --- /dev/null +++ b/mobile/damai_app/sale_waiter.py @@ -0,0 +1,144 @@ +# -*- coding: UTF-8 -*- +"""Sale-start waiting helpers for DamaiBot. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). Reads constants from the package namespace so that tests' +``patch("mobile.damai_app.time")`` / ``patch("mobile.damai_app.datetime")`` +patches still take effect via the package's mirror hook. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone, timedelta + +from . import ( + SALE_READY_TEXTS, + _CTA_BLOCKED_KEYWORDS, + _CTA_READY_KEYWORDS, + logger, +) + +try: + from mobile.item_resolver import normalize_text +except ImportError: # pragma: no cover + from item_resolver import normalize_text # type: ignore[no-redef] + +try: + from mobile.ui_primitives import ANDROID_UIAUTOMATOR +except ImportError: # pragma: no cover + from ui_primitives import ANDROID_UIAUTOMATOR # type: ignore[no-redef] + +try: + from selenium.webdriver.common.by import By +except ModuleNotFoundError: # pragma: no cover + raise + + +class SaleWaiterMixin: + """Mixin contributing sale-start detection helpers to ``DamaiBot``.""" + + def _purchase_bar_text_ready(self): + """Inspect the detail-page CTA text and decide whether sale has opened.""" + try: + purchase_bar = self._find( + By.ID, + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + ) + except Exception: + return False + + texts = [ + text.strip() + for text in self._collect_descendant_texts(purchase_bar) + if text.strip() + ] + merged = normalize_text("".join(texts)) + if not merged: + return False + if any(normalize_text(keyword) in merged for keyword in _CTA_BLOCKED_KEYWORDS): + return False + return any(normalize_text(keyword) in merged for keyword in _CTA_READY_KEYWORDS) + + def _is_sale_ready(self): + """Check whether the current UI state is actionable for purchase. + + Sale-readiness is detected via :data:`SALE_READY_TEXTS` (开售文案) plus + a small set of post-confirm CTAs ("立即购买" / "选座购买" / "提交订单" 等) + that may appear once the user has already entered the SKU/order page. + """ + ready_texts = ( + *SALE_READY_TEXTS, + "立即购买", + "选座购买", + "立即提交", + "提交订单", + ) + for text in ready_texts: + if self._has_element( + ANDROID_UIAUTOMATOR, + f'new UiSelector().textContains("{text}")', + ): + self._last_sale_ready_text = text + return True + + if self._has_element( + By.ID, "cn.damai:id/project_detail_perform_price_flowlayout" + ): + return not self.is_reservation_sku_mode() + + if self._has_element( + By.ID, "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl" + ): + return self._purchase_bar_text_ready() + + return False + + def wait_for_sale_start(self): + """等待开售时间,在开售前 countdown_lead_ms 毫秒开始轮询。""" + if self.config.sell_start_time is None: + if self.config.wait_cta_ready_timeout_ms > 0: + logger.info("未配置 sell_start_time,已跳过 CTA 等待,直接开始执行") + return + + _tz_shanghai = timezone(timedelta(hours=8)) + sell_time = datetime.fromisoformat(self.config.sell_start_time) + # Ensure timezone-aware + if sell_time.tzinfo is None: + sell_time = sell_time.replace(tzinfo=_tz_shanghai) + + now = datetime.now(tz=_tz_shanghai) + if now >= sell_time: + logger.info("开售时间已过,跳过等待") + return + + lead_delta = timedelta(milliseconds=self.config.countdown_lead_ms) + poll_start = sell_time - lead_delta + sleep_seconds = (poll_start - now).total_seconds() + + if sleep_seconds > 0: + logger.info( + f"等待开售,将在 {self.config.sell_start_time} 前 " + f"{self.config.countdown_lead_ms}ms 开始轮询" + ) + time.sleep(sleep_seconds) + + # Use BuyButtonGuard for precise button-text monitoring + if hasattr(self, "_guard") and self._guard.wait_until_safe( + timeout_s=8.0, poll_ms=50 + ): + logger.info("BuyButtonGuard 检测到可购买按钮") + return + + # Tight polling loop with multiple purchase signals until the page becomes actionable. + deadline = sell_time + timedelta(seconds=8) + polls = 0 + while datetime.now(tz=_tz_shanghai) < deadline: + polls += 1 + if self._is_sale_ready(): + cta_text = getattr(self, "_last_sale_ready_text", None) or "?" + logger.info(f"CTA_MATCH: text={cta_text!r} polls={polls} (开售已开始)") + return + time.sleep(0.08) + + logger.warning(f"等待开售超时(轮询 {polls} 次),继续执行") diff --git a/mobile/damai_app/state_probe.py b/mobile/damai_app/state_probe.py new file mode 100644 index 0000000..e33703d --- /dev/null +++ b/mobile/damai_app/state_probe.py @@ -0,0 +1,295 @@ +# -*- coding: UTF-8 -*- +"""Page-state inspection helpers for DamaiBot. + +Methods relocated from ``mobile/damai_app.py`` (W4-01 split, zero behavior +change). Hosts the page-state probe entrypoints, the SKU-page reservation +detector, and the prompt-mode inspection helpers that summarise visible +date / price options for the user. +""" + +from __future__ import annotations + +from . import logger + +try: + from mobile.ui_primitives import ANDROID_UIAUTOMATOR +except ImportError: # pragma: no cover + from ui_primitives import ANDROID_UIAUTOMATOR # type: ignore[no-redef] + +try: + from selenium.webdriver.common.by import By +except ModuleNotFoundError: # pragma: no cover + raise + + +class StateProbeMixin: + """Mixin contributing page-state and SKU inspection methods to ``DamaiBot``.""" + + def _get_detail_title_text(self, xml_root=None): + """Read title text from detail/sku pages.""" + if xml_root is not None and self._using_u2(): + title = self._xml_find_text_by_resource_id(xml_root, "cn.damai:id/title_tv") + if title: + return title + parts = [ + self._xml_find_text_by_resource_id(xml_root, rid) + for rid in ( + "cn.damai:id/project_title_tv1", + "cn.damai:id/project_title_tv2", + ) + ] + return "".join(p.strip() for p in parts if p).strip() + + title = "" + try: + title = self._safe_element_text(self.driver, By.ID, "cn.damai:id/title_tv") + except Exception: + title = "" + + if title: + return title + + title_parts = [] + for resource_id in ( + "cn.damai:id/project_title_tv1", + "cn.damai:id/project_title_tv2", + ): + part = self._safe_element_text(self.driver, By.ID, resource_id) + if part: + title_parts.append(part.strip()) + + return "".join(title_parts).strip() + + def is_reservation_sku_mode(self): + """识别当前 SKU 页是否仍处于抢票预约流,而非正式下单流。""" + reservation_indicators = [ + (By.ID, "cn.damai:id/btn_cancel_reservation"), + (ANDROID_UIAUTOMATOR, 'new UiSelector().text("预约想看场次")'), + (ANDROID_UIAUTOMATOR, 'new UiSelector().text("预约想看票档")'), + (ANDROID_UIAUTOMATOR, 'new UiSelector().textContains("提交抢票预约")'), + (ANDROID_UIAUTOMATOR, 'new UiSelector().textContains("已预约")'), + ] + + return any(self._has_element(by, value) for by, value in reservation_indicators) + + def get_visible_date_options(self, xml_root=None): + """Return visible date options on the current page.""" + if xml_root is not None and self._using_u2(): + dates = [] + seen = set() + for node in xml_root.iter("node"): + if node.get("resource-id") == "cn.damai:id/tv_date": + text = (node.get("text") or "").strip() + if text and text not in seen: + dates.append(text) + seen.add(text) + return dates + + dates = [] + seen = set() + for element in self._find_all(By.ID, "cn.damai:id/tv_date"): + text = self._read_element_text(element).strip() + if not text or text in seen: + continue + dates.append(text) + seen.add(text) + return dates + + def _get_detail_venue_text(self, xml_root=None): + """Read venue text from the detail page if present.""" + if xml_root is not None and self._using_u2(): + for resource_id in ( + "cn.damai:id/venue_name_0", + "cn.damai:id/tv_project_venueName", + ): + value = self._xml_find_text_by_resource_id(xml_root, resource_id) + if value: + return value.strip() + return "" + + for resource_id in ( + "cn.damai:id/venue_name_0", + "cn.damai:id/tv_project_venueName", + ): + value = self._safe_element_text(self.driver, By.ID, resource_id) + if value: + return value.strip() + return "" + + def ensure_sku_page_for_inspection(self, page_probe=None): + """Safely enter the sku page so prompt-based flows can inspect dates and prices.""" + page_probe = page_probe or self.probe_current_page() + if page_probe["state"] == "sku_page": + return page_probe + + if page_probe["state"] != "detail_page": + return page_probe + + book_selectors = [ + ( + By.ID, + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + ), + ( + ANDROID_UIAUTOMATOR, + 'new UiSelector().textMatches(".*预约.*|.*购买.*|.*立即.*")', + ), + (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), + ] + if not self.smart_wait_and_click( + *book_selectors[0], book_selectors[1:], timeout=0.5 + ): + return self.probe_current_page() + + return self._wait_for_purchase_entry_result(timeout=5, poll_interval=0.04) + + def inspect_current_target_event(self, page_probe=None): + """Summarize the currently opened event for prompt-based confirmation.""" + page_probe = page_probe or self.probe_current_page() + + xml_root = None + sku_probe = page_probe + + if page_probe["state"] == "detail_page": + # Click buy immediately so sku_page starts loading before we do anything else. + book_selectors = [ + ( + By.ID, + "cn.damai:id/trade_project_detail_purchase_status_bar_container_fl", + ), + ( + ANDROID_UIAUTOMATOR, + 'new UiSelector().textMatches(".*预约.*|.*购买.*|.*立即.*")', + ), + (By.XPATH, '//*[contains(@text,"预约") or contains(@text,"购买")]'), + ] + clicked = self.smart_wait_and_click( + *book_selectors[0], book_selectors[1:], timeout=0.5 + ) + # Dump detail_page hierarchy while sku_page loads (~1.5s parallel time). + xml_root = self._dump_hierarchy_xml() + if clicked: + sku_probe = self._wait_for_purchase_entry_result( + timeout=4.0, poll_interval=0.04 + ) + else: + sku_probe = self.probe_current_page() + elif page_probe["state"] != "sku_page": + sku_probe = self.ensure_sku_page_for_inspection(page_probe) + + summary = { + "state": sku_probe["state"], + "title": self._get_detail_title_text(xml_root=xml_root), + "venue": self._get_detail_venue_text(xml_root=xml_root), + "dates": [], + "price_options": [], + "reservation_mode": sku_probe.get("reservation_mode", False), + } + + if sku_probe["state"] == "sku_page": + # Re-dump for sku_page content (different screen from detail_page). + xml_root = self._dump_hierarchy_xml() + if not summary["title"]: + summary["title"] = self._get_detail_title_text(xml_root=xml_root) + if not summary["venue"]: + summary["venue"] = self._get_detail_venue_text(xml_root=xml_root) + summary["reservation_mode"] = sku_probe.get("reservation_mode", False) + summary["dates"] = self.get_visible_date_options(xml_root=xml_root) + summary["price_options"] = self.get_visible_price_options(xml_root=xml_root) + + return summary + + def probe_current_page(self, fast=False): + """探测当前页面状态和关键控件可见性。""" + # Delegate to PageProbe when available (u2 backend) + if hasattr(self, "_page_probe"): + result = self._page_probe.probe_current_page(fast=fast) + if result["state"] != "unknown" or fast: + logger.info(f"当前页面状态: {result['state']}") + return result + + # Fallback: element-based probe using _has_element + return self._probe_current_page_element_based() + + def _probe_current_page_element_based(self): + """Full probe using _has_element calls (fallback when PageProbe unavailable).""" + state = "unknown" + current_activity = self._get_current_activity() + purchase_button = self._has_element( + 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" + ) + sku_price_container = ( + self._has_element( + By.ID, "cn.damai:id/project_detail_perform_price_flowlayout" + ) + or self._has_element(By.ID, "cn.damai:id/layout_price") + or self._has_element(By.ID, "cn.damai:id/tv_price_name") + ) + quantity_picker = self._has_element(By.ID, "layout_num") + submit_button = self._has_element(By.ID, "cn.damai:id/checkbox") + pending_order_dialog = self._has_element( + By.ID, "cn.damai:id/damai_theme_dialog_confirm_btn" + ) + reservation_mode = False + + if self._has_element(By.ID, "cn.damai:id/id_boot_action_agree"): + state = "consent_dialog" + elif pending_order_dialog: + state = "pending_order_dialog" + elif ( + "MainActivity" in current_activity + or self._has_element(By.ID, "cn.damai:id/homepage_header_search") + or self._has_element( + By.ID, "cn.damai:id/pioneer_homepage_header_search_btn" + ) + ): + state = "homepage" + elif "SearchActivity" in current_activity or self._has_element( + By.ID, "cn.damai:id/header_search_v2_input" + ): + state = "search_page" + elif submit_button: + state = "order_confirm_page" + elif ( + "NcovSkuActivity" in current_activity + or self._has_element(By.ID, "cn.damai:id/layout_sku") + or self._has_element(By.ID, "cn.damai:id/sku_contanier") + ): + state = "sku_page" + elif ( + "ProjectDetailActivity" in current_activity + or purchase_button + or detail_price_summary + or self._has_element(By.ID, "cn.damai:id/title_tv") + ): + state = "detail_page" + + if state == "sku_page": + reservation_mode = self.is_reservation_sku_mode() + + result = { + "state": state, + "purchase_button": purchase_button, + "price_container": sku_price_container or detail_price_summary, + "quantity_picker": quantity_picker, + "submit_button": submit_button, + "reservation_mode": reservation_mode, + "pending_order_dialog": pending_order_dialog, + } + + logger.info(f"当前页面状态: {result['state']}") + if current_activity: + logger.debug(f"当前 Activity: {current_activity}") + logger.debug( + "探测结果: " + f"purchase_button={result['purchase_button']}, " + f"price_container={result['price_container']}, " + f"quantity_picker={result['quantity_picker']}, " + f"submit_button={result['submit_button']}, " + f"reservation_mode={result['reservation_mode']}" + ) + + return result diff --git a/mobile/scripts/start_ticket_grabbing.sh b/mobile/scripts/start_ticket_grabbing.sh index 5d4e31c..6fd6420 100755 --- a/mobile/scripts/start_ticket_grabbing.sh +++ b/mobile/scripts/start_ticket_grabbing.sh @@ -295,9 +295,9 @@ echo "" # 运行抢票脚本(优先使用项目 .venv,其次使用 Poetry) if [ -x "$ROOT_DIR/.venv/bin/python" ]; then - HATICKETS_CONFIG_PATH="$CONFIG_FILE" "$ROOT_DIR/.venv/bin/python" damai_app.py + HATICKETS_CONFIG_PATH="$CONFIG_FILE" "$ROOT_DIR/.venv/bin/python" -m damai_app elif command -v poetry &> /dev/null; then - HATICKETS_CONFIG_PATH="$CONFIG_FILE" poetry run python damai_app.py + HATICKETS_CONFIG_PATH="$CONFIG_FILE" poetry run python -m damai_app else echo "❌ 未找到可用的 Python 环境" echo " 请先安装依赖:"