from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone import hashlib import re import time from typing import Any, Mapping, Sequence from .adb import AdbClient, UiNode from .models import Candidate, ContractError, ErrorCode from .selection import FastScanner, ScanPage, fingerprint_text, score_candidate from .quota import AppQuotaObservation ACCESS_TERMS = ("验证码", "付费", "开通会员", "订阅后阅读", "登录后阅读") @dataclass(frozen=True) class UiConfig: search_entry_texts: tuple[str, ...] search_input_resource_id: str search_submit_texts: tuple[str, ...] result_resource_id: str open_texts: tuple[str, ...] institution_resource_id: str date_resource_id: str analysts_resource_id: str page_count_resource_id: str swipe: tuple[int, int, int, int, int] quota_remaining_resource_id: str = "" quota_remaining_pattern: str = "" @classmethod def from_source_scope(cls, source_scope: Mapping[str, Any]) -> "UiConfig": ui = source_scope.get("ui") if isinstance(source_scope, Mapping) else None if not isinstance(ui, Mapping): raise ContractError(ErrorCode.TASK_SPEC_INVALID, "source_scope.ui", "mapping required") try: swipe = tuple(int(x) for x in ui["swipe"]) value = cls(tuple(ui["search_entry_texts"]), str(ui["search_input_resource_id"]), tuple(ui["search_submit_texts"]), str(ui["result_resource_id"]), tuple(ui["open_texts"]), str(ui.get("institution_resource_id", "")), str(ui.get("date_resource_id", "")), str(ui.get("analysts_resource_id", "")), str(ui.get("page_count_resource_id", "")), swipe, str(ui.get("quota_remaining_resource_id", "")), str(ui.get("quota_remaining_pattern", ""))) except (KeyError, TypeError, ValueError) as exc: raise ContractError(ErrorCode.TASK_SPEC_INVALID, "source_scope.ui", "invalid config") from exc if len(value.swipe) != 5: raise ContractError(ErrorCode.TASK_SPEC_INVALID, "swipe", "five ints") return value class HiborUiDriver: def __init__(self, adb: AdbClient, config: UiConfig): self.adb = adb self.config = config @staticmethod def _center(node: UiNode) -> tuple[int, int]: x1, y1, x2, y2 = node.bounds return (x1 + x2) // 2, (y1 + y2) // 2 @staticmethod def _assert_no_access(nodes: Sequence[UiNode]) -> None: visible = "\n".join(node.text for node in nodes) if any(term in visible for term in ACCESS_TERMS): raise ContractError(ErrorCode.ACCESS_CONTROL_PRESENT, "ui", "access boundary visible") def search(self, query: str) -> None: nodes = self.adb.ui_nodes() started = False for _ in range(20): self._assert_no_access(nodes) field = next(( n for n in nodes if n.resource_id == self.config.search_input_resource_id ), None) if field is not None: break search_icon = next(( n for n in nodes if n.resource_id == "cn.com.hibor:id/iv_search" ), None) entry = next((n for n in nodes if n.text in self.config.search_entry_texts), None) if search_icon is not None: self.adb.tap(*self._center(search_icon)) elif any(n.resource_id.endswith("detail_iv_left") for n in nodes): self.adb.back() elif entry is not None: self.adb.tap(*self._center(entry)) elif not started: self.adb.start_package() started = True time.sleep(0.25) nodes = self.adb.ui_nodes() else: raise ContractError(ErrorCode.UI_ANCHOR_DRIFT, "search_input", "anchor absent") self.adb.tap(*self._center(field)) self.adb.input_text(query) nodes = self.adb.ui_nodes() exact_field = next(( n for n in nodes if n.resource_id == self.config.search_input_resource_id and n.text == query ), None) if exact_field is None: raise ContractError(ErrorCode.UI_ANCHOR_DRIFT, "search_input", "text mismatch") submit = next((n for n in nodes if n.text in self.config.search_submit_texts), None) if submit is None: raise ContractError(ErrorCode.UI_ANCHOR_DRIFT, "search_submit", "anchor absent") self.adb.tap(*self._center(submit)) def observe_app_remaining(self, quota_date: str) -> AppQuotaObservation | None: """Return a durable-observation preimage only when the configured UI anchor is exact.""" if not self.config.quota_remaining_resource_id or not self.config.quota_remaining_pattern: return None nodes = self.adb.ui_nodes() self._assert_no_access(nodes) matched = [node for node in nodes if node.resource_id == self.config.quota_remaining_resource_id] if len(matched) != 1: raise ContractError(ErrorCode.QUOTA_LEDGER_INVALID, "app_quota", "anchor not unique") pattern = re.fullmatch(self.config.quota_remaining_pattern, matched[0].text) if pattern is None or len(pattern.groups()) != 1: raise ContractError(ErrorCode.QUOTA_LEDGER_INVALID, "app_quota", "value mismatch") remaining = int(pattern.group(1)) captured = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") fingerprint = fingerprint_text( f"{node.resource_id}|{node.text}|{node.bounds}" for node in nodes ) return AppQuotaObservation.build( device_serial=str(self.adb.serial or ""), quota_date=quota_date, captured_at_utc=captured, visible_remaining=remaining, ui_snapshot_fingerprint=fingerprint, ) def scan(self, *, query: str, quantity: int, aliases: Sequence[str], institutions: Sequence[str], minimum_pages: int, min_screens: int, normal_max_screens: int, hard_max_screens: int, hard_max_candidates: int): def provider(screen: int) -> ScanPage: nodes = self.adb.ui_nodes() self._assert_no_access(nodes) texts = [n.text for n in nodes if n.text] candidates: list[Candidate] = [] for index, node in enumerate(nodes): if not node.text: continue if self.config.result_resource_id and node.resource_id != self.config.result_resource_id: continue if not self.config.result_resource_id and query.casefold() not in node.text.casefold(): continue candidate_id = hashlib.sha256(f"{node.text}|{node.bounds}".encode("utf-8")).hexdigest()[:24] candidate = Candidate(candidate_id, node.text, None, None, (), None, 0, node.bounds) candidate = Candidate(**{**candidate.__dict__, "score": score_candidate( candidate, query=query, aliases=aliases, institutions=institutions, minimum_pages=minimum_pages)}) candidates.append(candidate) page = ScanPage(tuple(candidates), fingerprint_text(texts)) if screen < hard_max_screens: self.adb.swipe(*self.config.swipe) return page return FastScanner(provider).scan(quantity=quantity, min_screens=min_screens, normal_max_screens=normal_max_screens, hard_max_screens=hard_max_screens, hard_max_candidates=hard_max_candidates) def open_detail(self, candidate: Candidate, *, max_locate_screens: int = 20) -> Candidate: located = None for screen in range(1, max_locate_screens + 1): nodes = self.adb.ui_nodes() self._assert_no_access(nodes) located = next(( node for node in nodes if node.text == candidate.title and ( not self.config.result_resource_id or node.resource_id == self.config.result_resource_id ) ), None) if located is not None: break if screen < max_locate_screens: self.adb.swipe(*self.config.swipe) if located is None: raise ContractError(ErrorCode.UI_CURSOR_RESTORE_FAILED, "candidate", "title not relocated") self.adb.tap(*self._center(located)) nodes = self.adb.ui_nodes() self._assert_no_access(nodes) def value_for(resource_id: str) -> str | None: if not resource_id: return None node = next((n for n in nodes if n.resource_id == resource_id and n.text), None) return node.text.strip() if node else None def value_after_label(label: str) -> str | None: for index, node in enumerate(nodes): if node.text != label: continue for following in nodes[index + 1:]: if following.text and following.resource_id.endswith("detail_tv_typecontent"): return following.text.strip() if following.text.endswith(":"): break return None institution = value_for(self.config.institution_resource_id) if not institution: institution = value_after_label("研报出处:") report_date = value_for(self.config.date_resource_id) analyst_values = tuple( n.text.strip() for n in nodes if self.config.analysts_resource_id and n.resource_id == self.config.analysts_resource_id and n.text.strip() ) page_text = value_for(self.config.page_count_resource_id) if not page_text or not re.search(r"\d+\s*页", page_text): page_text = value_after_label("研报大小:") pages = None if page_text: match = re.search(r"(\d+)\s*页", page_text) pages = int(match.group(1)) if match else None date_match = re.search( r"\b(20\d{2}[-./]\d{1,2}[-./]\d{1,2})\b", report_date or "\n".join(n.text for n in nodes), ) report_date = (date_match.group(1).replace("/", "-").replace(".", "-") if date_match else None) analysts = tuple( part for value in analyst_values for part in (x.strip() for x in re.split(r"[、,,;/]", value)) if part ) if not institution or not report_date or pages is None: raise ContractError(ErrorCode.DETAIL_RESULT_MISMATCH, "detail", "institution/date/page required") return Candidate(candidate.candidate_id, candidate.title, institution, report_date, analysts, pages, candidate.score, candidate.bounds) def trigger_current_detail(self) -> None: nodes = self.adb.ui_nodes() self._assert_no_access(nodes) opener = next((n for n in nodes if n.text in self.config.open_texts), None) if opener is None: raise ContractError(ErrorCode.DETAIL_RESULT_MISMATCH, "open", "anchor absent") self.adb.tap(*self._center(opener))