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.
Files changed (32) hide show
  1. modao_prd_cli/__init__.py +1 -0
  2. modao_prd_cli/modao_prd/__init__.py +4 -0
  3. modao_prd_cli/modao_prd/__main__.py +7 -0
  4. modao_prd_cli/modao_prd/browser.py +578 -0
  5. modao_prd_cli/modao_prd/capture_evidence.py +547 -0
  6. modao_prd_cli/modao_prd/classifier.py +96 -0
  7. modao_prd_cli/modao_prd/cli.py +205 -0
  8. modao_prd_cli/modao_prd/errors.py +31 -0
  9. modao_prd_cli/modao_prd/evidence.py +207 -0
  10. modao_prd_cli/modao_prd/explorer.py +300 -0
  11. modao_prd_cli/modao_prd/extractor.py +465 -0
  12. modao_prd_cli/modao_prd/models.py +56 -0
  13. modao_prd_cli/modao_prd/normalizer.py +135 -0
  14. modao_prd_cli/modao_prd/schemas/coverage-1.0.json +15 -0
  15. modao_prd_cli/modao_prd/schemas/document-2.0.json +21 -0
  16. modao_prd_cli/modao_prd/schemas/document-2.1.json +31 -0
  17. modao_prd_cli/modao_prd/schemas/manifest-1.0.json +28 -0
  18. modao_prd_cli/modao_prd/tests/__init__.py +1 -0
  19. modao_prd_cli/modao_prd/tests/fixtures/modao_sample.html +20 -0
  20. modao_prd_cli/modao_prd/tests/test_browser.py +54 -0
  21. modao_prd_cli/modao_prd/tests/test_classifier.py +32 -0
  22. modao_prd_cli/modao_prd/tests/test_cli.py +88 -0
  23. modao_prd_cli/modao_prd/tests/test_extractor.py +57 -0
  24. modao_prd_cli/modao_prd/tests/test_full_e2e.py +23 -0
  25. modao_prd_cli/modao_prd/tests/test_writers.py +79 -0
  26. modao_prd_cli/modao_prd/writers.py +468 -0
  27. modao_prd_cli-0.1.0.dist-info/METADATA +108 -0
  28. modao_prd_cli-0.1.0.dist-info/RECORD +32 -0
  29. modao_prd_cli-0.1.0.dist-info/WHEEL +5 -0
  30. modao_prd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  31. modao_prd_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
  32. 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,4 @@
1
+ """Public Modao prototype extraction CLI."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,7 @@
1
+ """Run modao-prd-cli with ``python -m modao_prd_cli.modao_prd``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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
+ """