modao-prd-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modao_prd_cli/__init__.py +1 -0
- modao_prd_cli/modao_prd/__init__.py +4 -0
- modao_prd_cli/modao_prd/__main__.py +7 -0
- modao_prd_cli/modao_prd/browser.py +578 -0
- modao_prd_cli/modao_prd/capture_evidence.py +547 -0
- modao_prd_cli/modao_prd/classifier.py +96 -0
- modao_prd_cli/modao_prd/cli.py +205 -0
- modao_prd_cli/modao_prd/errors.py +31 -0
- modao_prd_cli/modao_prd/evidence.py +207 -0
- modao_prd_cli/modao_prd/explorer.py +300 -0
- modao_prd_cli/modao_prd/extractor.py +465 -0
- modao_prd_cli/modao_prd/models.py +56 -0
- modao_prd_cli/modao_prd/normalizer.py +135 -0
- modao_prd_cli/modao_prd/schemas/coverage-1.0.json +15 -0
- modao_prd_cli/modao_prd/schemas/document-2.0.json +21 -0
- modao_prd_cli/modao_prd/schemas/document-2.1.json +31 -0
- modao_prd_cli/modao_prd/schemas/manifest-1.0.json +28 -0
- modao_prd_cli/modao_prd/tests/__init__.py +1 -0
- modao_prd_cli/modao_prd/tests/fixtures/modao_sample.html +20 -0
- modao_prd_cli/modao_prd/tests/test_browser.py +54 -0
- modao_prd_cli/modao_prd/tests/test_classifier.py +32 -0
- modao_prd_cli/modao_prd/tests/test_cli.py +88 -0
- modao_prd_cli/modao_prd/tests/test_extractor.py +57 -0
- modao_prd_cli/modao_prd/tests/test_full_e2e.py +23 -0
- modao_prd_cli/modao_prd/tests/test_writers.py +79 -0
- modao_prd_cli/modao_prd/writers.py +468 -0
- modao_prd_cli-0.1.0.dist-info/METADATA +108 -0
- modao_prd_cli-0.1.0.dist-info/RECORD +32 -0
- modao_prd_cli-0.1.0.dist-info/WHEEL +5 -0
- modao_prd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- modao_prd_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
- modao_prd_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python package for the modao-prd-cli tool."""
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""Secure Playwright backend for public Modao prototype shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .capture_evidence import capture_state_evidence, make_response_handler, merge_evidence
|
|
17
|
+
from .errors import ModaoPrdError
|
|
18
|
+
from .evidence import EvidenceBundleWriter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ALLOWED_HOSTS = {"modao.cc", "www.modao.cc"}
|
|
22
|
+
SHARE_PATH_RE = re.compile(r"^/proto/(?P<project_id>[A-Za-z0-9_-]+)/sharing/?$")
|
|
23
|
+
READY_SELECTORS = [".mb-screen", "#screens", ".wRichText"]
|
|
24
|
+
CHROME_PATHS = (
|
|
25
|
+
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
26
|
+
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ShareURL:
|
|
32
|
+
"""Validated public Modao share URL facts."""
|
|
33
|
+
|
|
34
|
+
url: str
|
|
35
|
+
project_id: str
|
|
36
|
+
host: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_share_url(url: str) -> ShareURL:
|
|
40
|
+
"""Validate a URL and prevent this CLI from becoming a generic fetcher."""
|
|
41
|
+
|
|
42
|
+
candidate = (url or "").strip()
|
|
43
|
+
if not candidate:
|
|
44
|
+
raise ModaoPrdError("invalid_url", "墨刀分享链接不能为空")
|
|
45
|
+
try:
|
|
46
|
+
parsed = urlsplit(candidate)
|
|
47
|
+
port = parsed.port
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise ModaoPrdError("invalid_url", f"无法解析 URL:{exc}", url=candidate) from exc
|
|
50
|
+
|
|
51
|
+
if parsed.scheme.lower() != "https":
|
|
52
|
+
raise ModaoPrdError("invalid_url", "只允许使用 HTTPS 墨刀分享链接", url=candidate)
|
|
53
|
+
if parsed.username or parsed.password or port is not None:
|
|
54
|
+
raise ModaoPrdError(
|
|
55
|
+
"invalid_url",
|
|
56
|
+
"链接不能包含用户名、密码或自定义端口",
|
|
57
|
+
url=candidate,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
host = (parsed.hostname or "").lower().rstrip(".")
|
|
61
|
+
if host not in ALLOWED_HOSTS:
|
|
62
|
+
raise ModaoPrdError(
|
|
63
|
+
"unsupported_url",
|
|
64
|
+
"仅支持 modao.cc 的公开分享链接",
|
|
65
|
+
url=candidate,
|
|
66
|
+
diagnostics={"allowed_hosts": sorted(ALLOWED_HOSTS)},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
match = SHARE_PATH_RE.fullmatch(parsed.path)
|
|
70
|
+
if not match:
|
|
71
|
+
raise ModaoPrdError(
|
|
72
|
+
"unsupported_url",
|
|
73
|
+
"链接路径必须符合 /proto/<project-id>/sharing",
|
|
74
|
+
url=candidate,
|
|
75
|
+
)
|
|
76
|
+
return ShareURL(url=candidate, project_id=match.group("project_id"), host=host)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_project_root(start: str | Path | None = None) -> Path:
|
|
80
|
+
"""Return the nearest Git project root, falling back to the working directory."""
|
|
81
|
+
|
|
82
|
+
current = Path(start or Path.cwd()).expanduser().resolve()
|
|
83
|
+
if current.is_file():
|
|
84
|
+
current = current.parent
|
|
85
|
+
for candidate in (current, *current.parents):
|
|
86
|
+
if (candidate / ".git").exists():
|
|
87
|
+
return candidate
|
|
88
|
+
return current
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_output_root(start: str | Path | None = None) -> Path:
|
|
92
|
+
return find_project_root(start) / ".modao-prd"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ensure_default_output_ignored(project_root: str | Path) -> bool:
|
|
96
|
+
"""Idempotently ignore the default generated-output directory."""
|
|
97
|
+
|
|
98
|
+
root = Path(project_root).expanduser().resolve()
|
|
99
|
+
ignore_file = root / ".gitignore"
|
|
100
|
+
entry = "/.modao-prd/"
|
|
101
|
+
try:
|
|
102
|
+
existing = ignore_file.read_text(encoding="utf-8")
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
existing = ""
|
|
105
|
+
if any(line.strip() in {entry, ".modao-prd/", ".modao-prd"} for line in existing.splitlines()):
|
|
106
|
+
return False
|
|
107
|
+
prefix = "" if not existing or existing.endswith(("\n", "\r")) else "\n"
|
|
108
|
+
with ignore_file.open("a", encoding="utf-8") as target:
|
|
109
|
+
target.write(f"{prefix}{entry}\n")
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def doctor() -> dict[str, Any]:
|
|
114
|
+
"""Return local runtime readiness without opening a browser."""
|
|
115
|
+
|
|
116
|
+
playwright_available = importlib.util.find_spec("playwright") is not None
|
|
117
|
+
channels: list[str] = []
|
|
118
|
+
if CHROME_PATHS[0].is_file():
|
|
119
|
+
channels.append("chrome")
|
|
120
|
+
if CHROME_PATHS[1].is_file():
|
|
121
|
+
channels.append("msedge")
|
|
122
|
+
|
|
123
|
+
bundled_path: str | None = None
|
|
124
|
+
if playwright_available:
|
|
125
|
+
# Keep doctor side-effect free: inspecting Playwright's sync runtime can
|
|
126
|
+
# leave an async driver task behind on some Python/macOS combinations.
|
|
127
|
+
# The common cache locations are enough for a readiness diagnostic.
|
|
128
|
+
cache_roots = [
|
|
129
|
+
Path.home() / "Library" / "Caches" / "ms-playwright",
|
|
130
|
+
Path.home() / ".cache" / "ms-playwright",
|
|
131
|
+
]
|
|
132
|
+
executable_names = {
|
|
133
|
+
"Google Chrome for Testing",
|
|
134
|
+
"chrome",
|
|
135
|
+
"chromium",
|
|
136
|
+
"chromium.exe",
|
|
137
|
+
}
|
|
138
|
+
for cache_root in cache_roots:
|
|
139
|
+
if not cache_root.is_dir():
|
|
140
|
+
continue
|
|
141
|
+
for candidate in cache_root.rglob("*"):
|
|
142
|
+
if candidate.is_file() and candidate.name in executable_names:
|
|
143
|
+
bundled_path = str(candidate)
|
|
144
|
+
channels.append("chromium")
|
|
145
|
+
break
|
|
146
|
+
if bundled_path:
|
|
147
|
+
break
|
|
148
|
+
for executable in ("chromium", "chromium-browser", "google-chrome"):
|
|
149
|
+
if shutil.which(executable):
|
|
150
|
+
channels.append("chromium")
|
|
151
|
+
bundled_path = bundled_path or shutil.which(executable)
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
channels = list(dict.fromkeys(channels))
|
|
155
|
+
return {
|
|
156
|
+
"version": __version__,
|
|
157
|
+
"python": sys.version.split()[0],
|
|
158
|
+
"platform": platform.platform(),
|
|
159
|
+
"playwright_available": playwright_available,
|
|
160
|
+
"browser_available": bool(channels),
|
|
161
|
+
"browser_channels": channels,
|
|
162
|
+
"bundled_chromium_path": bundled_path,
|
|
163
|
+
"default_output_root": str(default_output_root()),
|
|
164
|
+
"ready": playwright_available and bool(channels),
|
|
165
|
+
"install_hint": None
|
|
166
|
+
if playwright_available and channels
|
|
167
|
+
else "python3 -m pip install -e . && python3 -m playwright install chromium",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def capture_page(
|
|
172
|
+
url: str,
|
|
173
|
+
*,
|
|
174
|
+
headed: bool = False,
|
|
175
|
+
timeout_seconds: int = 30,
|
|
176
|
+
evidence_writer: EvidenceBundleWriter | None = None,
|
|
177
|
+
evidence_mode: str = "none",
|
|
178
|
+
explore: str = "none",
|
|
179
|
+
max_states: int = 50,
|
|
180
|
+
max_depth: int = 3,
|
|
181
|
+
max_actions: int = 200,
|
|
182
|
+
max_duration_seconds: int = 120,
|
|
183
|
+
max_item: int = 10 * 1024 * 1024,
|
|
184
|
+
max_total: int = 200 * 1024 * 1024,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
"""Render a public share in an ephemeral browser and collect facts/evidence."""
|
|
187
|
+
|
|
188
|
+
share = validate_share_url(url)
|
|
189
|
+
if importlib.util.find_spec("playwright") is None:
|
|
190
|
+
raise ModaoPrdError(
|
|
191
|
+
"playwright_missing",
|
|
192
|
+
"缺少 Playwright;请先安装项目依赖",
|
|
193
|
+
url=url,
|
|
194
|
+
diagnostics={"install_hint": "python3 -m pip install -e ."},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
199
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
200
|
+
from playwright.sync_api import sync_playwright
|
|
201
|
+
except ImportError as exc: # pragma: no cover - guarded above
|
|
202
|
+
raise ModaoPrdError("playwright_missing", str(exc), url=url) from exc
|
|
203
|
+
|
|
204
|
+
timeout_ms = max(1, timeout_seconds) * 1000
|
|
205
|
+
browser = None
|
|
206
|
+
launch_errors: list[str] = []
|
|
207
|
+
try:
|
|
208
|
+
with sync_playwright() as runtime:
|
|
209
|
+
launch_options = (
|
|
210
|
+
("chrome", {"channel": "chrome"}),
|
|
211
|
+
("chromium", {}),
|
|
212
|
+
)
|
|
213
|
+
for label, extra in launch_options:
|
|
214
|
+
try:
|
|
215
|
+
browser = runtime.chromium.launch(headless=not headed, **extra)
|
|
216
|
+
break
|
|
217
|
+
except PlaywrightError as exc:
|
|
218
|
+
launch_errors.append(f"{label}: {_short_error(exc)}")
|
|
219
|
+
|
|
220
|
+
if browser is None:
|
|
221
|
+
raise ModaoPrdError(
|
|
222
|
+
"browser_unavailable",
|
|
223
|
+
"无法启动 Chrome 或 Playwright Chromium",
|
|
224
|
+
url=url,
|
|
225
|
+
diagnostics={
|
|
226
|
+
"attempts": launch_errors,
|
|
227
|
+
"install_hint": "python3 -m playwright install chromium",
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
context = browser.new_context(
|
|
232
|
+
viewport={"width": 1440, "height": 1000},
|
|
233
|
+
locale="zh-CN",
|
|
234
|
+
)
|
|
235
|
+
page = context.new_page()
|
|
236
|
+
page.set_default_timeout(timeout_ms)
|
|
237
|
+
warnings: list[str] = []
|
|
238
|
+
evidence: list[dict[str, Any]] = []
|
|
239
|
+
network_records: list[dict[str, Any]] = []
|
|
240
|
+
response_handler = make_response_handler(
|
|
241
|
+
writer=evidence_writer,
|
|
242
|
+
requested_url=share.url,
|
|
243
|
+
state_id="state-001",
|
|
244
|
+
evidence=evidence,
|
|
245
|
+
network_records=network_records,
|
|
246
|
+
warnings=warnings,
|
|
247
|
+
max_item=max_item,
|
|
248
|
+
max_total=max_total,
|
|
249
|
+
evidence_mode=evidence_mode,
|
|
250
|
+
)
|
|
251
|
+
if evidence_mode != "none":
|
|
252
|
+
context.on("response", response_handler)
|
|
253
|
+
try:
|
|
254
|
+
page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
|
|
255
|
+
page.wait_for_function(
|
|
256
|
+
"""() => {
|
|
257
|
+
const ready = document.querySelector('.mb-screen, #screens, .wRichText');
|
|
258
|
+
const length = document.body?.innerText?.trim().length || 0;
|
|
259
|
+
return Boolean(ready) || length >= 200;
|
|
260
|
+
}""",
|
|
261
|
+
timeout=timeout_ms,
|
|
262
|
+
)
|
|
263
|
+
page.wait_for_timeout(750)
|
|
264
|
+
_expand_scrollable_content(page, timeout_ms)
|
|
265
|
+
except PlaywrightTimeoutError as exc:
|
|
266
|
+
diagnostics = _page_diagnostics(page)
|
|
267
|
+
diagnostics["expected_selectors"] = READY_SELECTORS
|
|
268
|
+
raise ModaoPrdError(
|
|
269
|
+
"page_timeout",
|
|
270
|
+
f"墨刀页面在 {timeout_seconds} 秒内未完成渲染",
|
|
271
|
+
url=url,
|
|
272
|
+
diagnostics=diagnostics,
|
|
273
|
+
) from exc
|
|
274
|
+
except PlaywrightError as exc:
|
|
275
|
+
raise ModaoPrdError(
|
|
276
|
+
"page_not_rendered",
|
|
277
|
+
"墨刀页面无法加载",
|
|
278
|
+
url=url,
|
|
279
|
+
diagnostics={"detail": _short_error(exc)},
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
diagnostics = _page_diagnostics(page)
|
|
283
|
+
try:
|
|
284
|
+
validate_share_url(page.url)
|
|
285
|
+
except ModaoPrdError as exc:
|
|
286
|
+
raise ModaoPrdError(
|
|
287
|
+
"page_access_denied",
|
|
288
|
+
"墨刀分享页跳转到了不受支持的地址",
|
|
289
|
+
url=url,
|
|
290
|
+
diagnostics={"final_url": page.url, "detail": exc.message},
|
|
291
|
+
) from exc
|
|
292
|
+
body_text = page.locator("body").inner_text().strip()
|
|
293
|
+
lowered = body_text.lower()
|
|
294
|
+
if any(marker in lowered for marker in ("无访问权限", "access denied", "请登录后访问")):
|
|
295
|
+
raise ModaoPrdError(
|
|
296
|
+
"page_access_denied",
|
|
297
|
+
"该页面不是可直接读取的公开分享页",
|
|
298
|
+
url=url,
|
|
299
|
+
diagnostics=diagnostics,
|
|
300
|
+
)
|
|
301
|
+
if diagnostics["body_text_length"] < 20:
|
|
302
|
+
raise ModaoPrdError(
|
|
303
|
+
"empty_document",
|
|
304
|
+
"页面已打开,但没有发现可提取的原型内容",
|
|
305
|
+
url=url,
|
|
306
|
+
diagnostics=diagnostics,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
captured = page.evaluate(_DOM_EXTRACTION_SCRIPT)
|
|
311
|
+
except PlaywrightError as exc:
|
|
312
|
+
raise ModaoPrdError(
|
|
313
|
+
"parse_error",
|
|
314
|
+
"读取墨刀 DOM 时失败",
|
|
315
|
+
url=url,
|
|
316
|
+
diagnostics={"detail": _short_error(exc), **diagnostics},
|
|
317
|
+
) from exc
|
|
318
|
+
|
|
319
|
+
state_evidence, dom_snapshot, evidence_stats = capture_state_evidence(
|
|
320
|
+
page,
|
|
321
|
+
context,
|
|
322
|
+
writer=evidence_writer if evidence_mode != "none" else None,
|
|
323
|
+
state_id="state-001",
|
|
324
|
+
requested_url=share.url,
|
|
325
|
+
evidence_mode=evidence_mode,
|
|
326
|
+
max_item=max_item,
|
|
327
|
+
max_total=max_total,
|
|
328
|
+
warnings=warnings,
|
|
329
|
+
network_records=network_records,
|
|
330
|
+
evidence_id_offset=len(evidence),
|
|
331
|
+
)
|
|
332
|
+
# A response callback may have added network evidence before the
|
|
333
|
+
# rendered-state artifacts. Keep one stable evidence list in the
|
|
334
|
+
# raw capture and let the document builder attach these IDs.
|
|
335
|
+
merge_evidence(evidence, state_evidence)
|
|
336
|
+
captured.update(
|
|
337
|
+
{
|
|
338
|
+
"requested_url": share.url,
|
|
339
|
+
"project_id": share.project_id,
|
|
340
|
+
"final_url": page.url,
|
|
341
|
+
"title": page.title().strip() or share.project_id,
|
|
342
|
+
"body_text_length": len(body_text),
|
|
343
|
+
"state_id": "state-001",
|
|
344
|
+
"states": [
|
|
345
|
+
{
|
|
346
|
+
"id": "state-001",
|
|
347
|
+
"url": page.url,
|
|
348
|
+
"depth": 0,
|
|
349
|
+
"action_count": 0,
|
|
350
|
+
"evidence_ids": [item["id"] for item in evidence],
|
|
351
|
+
"dom": dom_snapshot,
|
|
352
|
+
"evidence_stats": evidence_stats,
|
|
353
|
+
}
|
|
354
|
+
],
|
|
355
|
+
"evidence": evidence,
|
|
356
|
+
"network": network_records,
|
|
357
|
+
"warnings": warnings,
|
|
358
|
+
"coverage": {
|
|
359
|
+
"state_count": 1,
|
|
360
|
+
"explored": explore == "safe",
|
|
361
|
+
"max_states": max_states,
|
|
362
|
+
"max_depth": max_depth,
|
|
363
|
+
"max_actions": max_actions,
|
|
364
|
+
"max_duration_seconds": max_duration_seconds,
|
|
365
|
+
},
|
|
366
|
+
"capture": {
|
|
367
|
+
"evidence": evidence_mode,
|
|
368
|
+
"explore": explore,
|
|
369
|
+
"timeout_seconds": timeout_seconds,
|
|
370
|
+
"max_states": max_states,
|
|
371
|
+
"max_depth": max_depth,
|
|
372
|
+
"max_actions": max_actions,
|
|
373
|
+
"max_duration_seconds": max_duration_seconds,
|
|
374
|
+
"max_item": max_item,
|
|
375
|
+
"max_total": max_total,
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
if explore == "safe" and max_states > 1 and max_actions > 0:
|
|
380
|
+
try:
|
|
381
|
+
context.remove_listener("response", response_handler)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
from .explorer import explore_safe
|
|
385
|
+
|
|
386
|
+
explore_safe(
|
|
387
|
+
page,
|
|
388
|
+
context,
|
|
389
|
+
requested_url=share.url,
|
|
390
|
+
timeout_ms=timeout_ms,
|
|
391
|
+
max_states=max_states,
|
|
392
|
+
max_depth=max_depth,
|
|
393
|
+
max_actions=max_actions,
|
|
394
|
+
max_duration_seconds=max_duration_seconds,
|
|
395
|
+
evidence_mode=evidence_mode,
|
|
396
|
+
evidence_writer=evidence_writer,
|
|
397
|
+
max_item=max_item,
|
|
398
|
+
max_total=max_total,
|
|
399
|
+
raw=captured,
|
|
400
|
+
)
|
|
401
|
+
context.close()
|
|
402
|
+
browser.close()
|
|
403
|
+
browser = None
|
|
404
|
+
return captured
|
|
405
|
+
finally:
|
|
406
|
+
# The sync_playwright context owns cleanup on exceptional paths. Do
|
|
407
|
+
# not call browser.close() here after the runtime has stopped.
|
|
408
|
+
browser = None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _page_diagnostics(page: Any) -> dict[str, Any]:
|
|
412
|
+
try:
|
|
413
|
+
text_length = len(page.locator("body").inner_text().strip())
|
|
414
|
+
except Exception:
|
|
415
|
+
text_length = 0
|
|
416
|
+
try:
|
|
417
|
+
title = page.title()
|
|
418
|
+
except Exception:
|
|
419
|
+
title = ""
|
|
420
|
+
return {
|
|
421
|
+
"title": title,
|
|
422
|
+
"final_url": getattr(page, "url", ""),
|
|
423
|
+
"body_text_length": text_length,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _short_error(exc: Exception, limit: int = 500) -> str:
|
|
428
|
+
text = " ".join(str(exc).split())
|
|
429
|
+
return text[:limit] + ("…" if len(text) > limit else "")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _expand_scrollable_content(page: Any, timeout_ms: int) -> None:
|
|
433
|
+
"""Reveal bounded lazy content without treating network-idle as readiness."""
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
page.evaluate(
|
|
437
|
+
"""() => {
|
|
438
|
+
const scrollables = [document.scrollingElement, ...Array.from(document.querySelectorAll('*'))]
|
|
439
|
+
.filter(element => element && element.scrollHeight > element.clientHeight + 8 &&
|
|
440
|
+
['auto', 'scroll'].includes(getComputedStyle(element).overflowY));
|
|
441
|
+
for (const element of scrollables.slice(0, 80)) element.scrollTop = element.scrollHeight;
|
|
442
|
+
window.scrollTo(0, document.scrollingElement?.scrollHeight || document.body.scrollHeight);
|
|
443
|
+
}"""
|
|
444
|
+
)
|
|
445
|
+
page.wait_for_timeout(min(350, timeout_ms))
|
|
446
|
+
page.evaluate("() => window.scrollTo(0, 0)")
|
|
447
|
+
except Exception:
|
|
448
|
+
# A page may contain a detached or cross-origin scroll container; the
|
|
449
|
+
# rendered DOM and screenshot still remain useful evidence.
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
_DOM_EXTRACTION_SCRIPT = r"""
|
|
454
|
+
() => {
|
|
455
|
+
const root = document.querySelector('#screens') || document.querySelector('.mb-screen') || document.body;
|
|
456
|
+
const screenNodes = Array.from(root.querySelectorAll('.mb-screen'));
|
|
457
|
+
if (root.matches && root.matches('.mb-screen')) screenNodes.unshift(root);
|
|
458
|
+
const uniqueScreens = Array.from(new Set(screenNodes));
|
|
459
|
+
if (!uniqueScreens.length) uniqueScreens.push(root);
|
|
460
|
+
|
|
461
|
+
const normalize = value => (value || '')
|
|
462
|
+
.replace(/\u00a0/g, ' ')
|
|
463
|
+
.replace(/[ \t]+/g, ' ')
|
|
464
|
+
.replace(/\s*\n\s*/g, '\n')
|
|
465
|
+
.trim();
|
|
466
|
+
const screenIndex = element => {
|
|
467
|
+
const screen = element.closest?.('.mb-screen');
|
|
468
|
+
const index = screen ? uniqueScreens.indexOf(screen) : 0;
|
|
469
|
+
return index >= 0 ? index : 0;
|
|
470
|
+
};
|
|
471
|
+
const rectOf = element => {
|
|
472
|
+
const rect = element.getBoundingClientRect();
|
|
473
|
+
return {
|
|
474
|
+
x: Math.round(rect.x), y: Math.round(rect.y),
|
|
475
|
+
width: Math.round(rect.width), height: Math.round(rect.height)
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
const semanticSelector = element => {
|
|
479
|
+
if (element.matches('table')) return 'table';
|
|
480
|
+
if (element.matches('img')) return 'img';
|
|
481
|
+
if (element.matches('button')) return 'button';
|
|
482
|
+
if (element.matches('.widget.tree-node.wButton')) return '.widget.tree-node.wButton';
|
|
483
|
+
if (element.matches('.widget.tree-node.wRichText')) return '.widget.tree-node.wRichText';
|
|
484
|
+
return element.tagName.toLowerCase();
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const screens = uniqueScreens.map((screen, index) => {
|
|
488
|
+
const localTitle = screen.querySelector('.canvas-title')?.textContent;
|
|
489
|
+
const globalTitles = Array.from(document.querySelectorAll('.canvas-title'));
|
|
490
|
+
const fallback = globalTitles[index]?.textContent;
|
|
491
|
+
return {
|
|
492
|
+
index,
|
|
493
|
+
source_id: screen.id || null,
|
|
494
|
+
title: normalize(localTitle || fallback) || `页面 ${index + 1}`,
|
|
495
|
+
bounds: rectOf(screen)
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const candidates = [];
|
|
500
|
+
const add = (element, kind, extra = {}) => {
|
|
501
|
+
if (!root.contains(element) || element.offsetParent === null) return;
|
|
502
|
+
candidates.push({
|
|
503
|
+
element,
|
|
504
|
+
kind,
|
|
505
|
+
screen_index: screenIndex(element),
|
|
506
|
+
tag: element.tagName.toLowerCase(),
|
|
507
|
+
selector: semanticSelector(element),
|
|
508
|
+
bounds: rectOf(element),
|
|
509
|
+
...extra
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
root.querySelectorAll('.widget.tree-node.wRichText').forEach(element => {
|
|
514
|
+
if (element.closest('table') || element.closest('.widget.tree-node.wButton')) return;
|
|
515
|
+
const text = normalize(element.innerText || element.textContent);
|
|
516
|
+
if (text) add(element, 'text', {text});
|
|
517
|
+
});
|
|
518
|
+
root.querySelectorAll('table').forEach(element => {
|
|
519
|
+
const matrix = Array.from(element.rows).map(row =>
|
|
520
|
+
Array.from(row.cells).map(cell => normalize(cell.innerText || cell.textContent))
|
|
521
|
+
).filter(row => row.some(Boolean));
|
|
522
|
+
const table = {
|
|
523
|
+
rows: Array.from(element.rows).map((row, rowIndex) => ({
|
|
524
|
+
index: rowIndex,
|
|
525
|
+
cells: Array.from(row.cells).map((cell, columnIndex) => ({
|
|
526
|
+
row_index: rowIndex,
|
|
527
|
+
column_index: columnIndex,
|
|
528
|
+
tag: cell.tagName.toLowerCase(),
|
|
529
|
+
text: normalize(cell.innerText || cell.textContent),
|
|
530
|
+
rowspan: cell.rowSpan || 1,
|
|
531
|
+
colspan: cell.colSpan || 1,
|
|
532
|
+
bounds: rectOf(cell)
|
|
533
|
+
}))
|
|
534
|
+
}))
|
|
535
|
+
};
|
|
536
|
+
if (matrix.length) add(element, 'table', {matrix, table, text: matrix.map(row => row.join(' | ')).join('\n')});
|
|
537
|
+
});
|
|
538
|
+
root.querySelectorAll('button, .widget.tree-node.wButton').forEach(element => {
|
|
539
|
+
if (element.closest('table')) return;
|
|
540
|
+
const nested = element.parentElement?.closest('.widget.tree-node.wButton');
|
|
541
|
+
if (nested) return;
|
|
542
|
+
const text = normalize(element.innerText || element.textContent || element.getAttribute('aria-label'));
|
|
543
|
+
if (text) add(element, 'button', {text});
|
|
544
|
+
});
|
|
545
|
+
root.querySelectorAll('img').forEach(element => {
|
|
546
|
+
const rawSrc = element.currentSrc || element.src || '';
|
|
547
|
+
add(element, 'image', {
|
|
548
|
+
text: normalize(element.alt) || 'image',
|
|
549
|
+
alt: normalize(element.alt),
|
|
550
|
+
src: rawSrc.startsWith('data:') ? null : rawSrc.slice(0, 4096),
|
|
551
|
+
embedded: rawSrc.startsWith('data:'),
|
|
552
|
+
natural_width: element.naturalWidth || 0,
|
|
553
|
+
natural_height: element.naturalHeight || 0
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
candidates.sort((left, right) => {
|
|
558
|
+
if (left.element === right.element) return 0;
|
|
559
|
+
const relation = left.element.compareDocumentPosition(right.element);
|
|
560
|
+
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
|
|
561
|
+
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1;
|
|
562
|
+
return 0;
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
screens,
|
|
567
|
+
records: candidates.map(({element, ...record}, index) => ({...record, dom_order: index})),
|
|
568
|
+
browser_stats: {
|
|
569
|
+
screen_count: screens.length,
|
|
570
|
+
record_count: candidates.length,
|
|
571
|
+
text_node_count: root.querySelectorAll('.widget.tree-node.wRichText').length,
|
|
572
|
+
table_count: root.querySelectorAll('table').length,
|
|
573
|
+
image_count: root.querySelectorAll('img').length,
|
|
574
|
+
control_count: root.querySelectorAll('button, .widget.tree-node.wButton').length
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
"""
|