MB-X Bilibili Pipeline
6 days ago b83f5c673106a2bb023e192a3fa8ad2cdca80e75
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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))