explorbot 0.4.1 → 0.4.2

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 (128) hide show
  1. package/bin/explorbot-cli.ts +8 -3
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +9 -3
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
  35. package/dist/src/ai/fisherman/request-haul.js +28 -0
  36. package/dist/src/ai/fisherman-tools.d.ts +10 -1
  37. package/dist/src/ai/fisherman-tools.js +35 -31
  38. package/dist/src/ai/fisherman.d.ts +3 -2
  39. package/dist/src/ai/fisherman.js +8 -6
  40. package/dist/src/ai/pilot.js +21 -16
  41. package/dist/src/ai/provider.js +8 -14
  42. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  43. package/dist/src/ai/researcher/coordinates.js +13 -1
  44. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  45. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  46. package/dist/src/ai/researcher.js +1 -1
  47. package/dist/src/ai/rules.js +8 -0
  48. package/dist/src/ai/tester.js +20 -20
  49. package/dist/src/ai/tools.d.ts +1 -1
  50. package/dist/src/ai/tools.js +18 -74
  51. package/dist/src/api/request-store.d.ts +1 -5
  52. package/dist/src/api/request-store.js +12 -22
  53. package/dist/src/commands/config-command.js +2 -9
  54. package/dist/src/commands/index.js +2 -0
  55. package/dist/src/commands/init-command.js +3 -7
  56. package/dist/src/commands/plans-command.d.ts +4 -1
  57. package/dist/src/commands/plans-command.js +4 -4
  58. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  59. package/dist/src/commands/recommended-models-command.js +85 -0
  60. package/dist/src/config.d.ts +2 -0
  61. package/dist/src/config.js +7 -2
  62. package/dist/src/experience-tracker.js +1 -1
  63. package/dist/src/explorbot.js +3 -3
  64. package/dist/src/global-config.d.ts +2 -0
  65. package/dist/src/global-config.js +6 -0
  66. package/dist/src/state-manager.d.ts +3 -3
  67. package/dist/src/state-manager.js +8 -8
  68. package/dist/src/test-plan.d.ts +1 -0
  69. package/dist/src/test-plan.js +13 -9
  70. package/dist/src/utils/aria.d.ts +1 -1
  71. package/dist/src/utils/aria.js +23 -2
  72. package/dist/src/utils/cli-section.d.ts +1 -0
  73. package/dist/src/utils/cli-section.js +12 -0
  74. package/dist/src/utils/html-diff.d.ts +1 -0
  75. package/dist/src/utils/html-diff.js +5 -5
  76. package/dist/src/utils/html.d.ts +2 -0
  77. package/dist/src/utils/html.js +5 -4
  78. package/dist/src/utils/overlay.d.ts +19 -55
  79. package/dist/src/utils/overlay.js +94 -148
  80. package/dist/src/utils/region.d.ts +41 -0
  81. package/dist/src/utils/region.js +99 -0
  82. package/dist/src/utils/similarity.d.ts +1 -0
  83. package/dist/src/utils/similarity.js +11 -0
  84. package/dist/src/utils/url-matcher.d.ts +1 -1
  85. package/dist/src/utils/url-matcher.js +4 -4
  86. package/docs/doc-collection/basics.md +11 -5
  87. package/docs/doc-collection/crawling.md +21 -1
  88. package/docs/reference/commands.md +3 -0
  89. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  90. package/docs/workflow/application-spec.md +4 -0
  91. package/package.json +1 -1
  92. package/src/action-result.ts +15 -16
  93. package/src/action.ts +16 -15
  94. package/src/ai/fisherman/request-haul.ts +32 -0
  95. package/src/ai/fisherman-tools.ts +38 -32
  96. package/src/ai/fisherman.ts +8 -7
  97. package/src/ai/pilot.ts +19 -14
  98. package/src/ai/provider.ts +8 -12
  99. package/src/ai/researcher/coordinates.ts +13 -1
  100. package/src/ai/researcher/deep-analysis.ts +9 -9
  101. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  102. package/src/ai/researcher.ts +1 -1
  103. package/src/ai/rules.ts +8 -0
  104. package/src/ai/tester.ts +18 -18
  105. package/src/ai/tools.ts +18 -86
  106. package/src/api/request-store.ts +13 -27
  107. package/src/commands/config-command.ts +2 -7
  108. package/src/commands/index.ts +2 -0
  109. package/src/commands/init-command.ts +4 -11
  110. package/src/commands/plans-command.ts +5 -5
  111. package/src/commands/recommended-models-command.ts +92 -0
  112. package/src/config.ts +9 -2
  113. package/src/experience-tracker.ts +1 -1
  114. package/src/explorbot.ts +3 -3
  115. package/src/global-config.ts +8 -0
  116. package/src/state-manager.ts +10 -9
  117. package/src/test-plan.ts +12 -10
  118. package/src/utils/aria.ts +26 -3
  119. package/src/utils/cli-section.ts +12 -0
  120. package/src/utils/html-diff.ts +6 -6
  121. package/src/utils/html.ts +6 -4
  122. package/src/utils/overlay.ts +105 -192
  123. package/src/utils/region.ts +132 -0
  124. package/src/utils/similarity.ts +9 -0
  125. package/src/utils/url-matcher.ts +3 -3
  126. package/dist/src/utils/request-map.d.ts +0 -7
  127. package/dist/src/utils/request-map.js +0 -16
  128. package/src/utils/request-map.ts +0 -19
package/src/test-plan.ts CHANGED
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import figures from 'figures';
5
5
  import type { ActionResult } from './action-result.ts';
6
- import { listSites } from './global-config.ts';
6
+ import { listSitePlanDirs } from './global-config.ts';
7
7
  import { WebPageState } from './state-manager.ts';
8
8
  import { tag } from './utils/logger.ts';
9
9
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
@@ -391,8 +391,6 @@ export class Test extends Task {
391
391
  }
392
392
  }
393
393
 
394
- const SITE_PLANS_DIR = ['output', 'plans'];
395
-
396
394
  type PlanChangeListener = (tests: Test[]) => void;
397
395
 
398
396
  export class Plan {
@@ -479,25 +477,29 @@ export class Plan {
479
477
 
480
478
  updateStatus(): void {}
481
479
 
482
- static loadFromFile(file: string, plansDir?: string): Plan | null {
480
+ static resolveFile(file: string, plansDir?: string): string | null {
483
481
  const names = [file];
484
482
  if (!file.endsWith('.md')) names.push(`${file}.md`);
485
483
 
486
484
  const dirs = [process.cwd()];
487
485
  if (plansDir) dirs.push(plansDir);
488
- if (!plansDir) dirs.push(...listSites().map((site) => path.join(site.dir, ...SITE_PLANS_DIR)));
486
+ if (!plansDir) dirs.push(...listSitePlanDirs());
489
487
 
490
488
  for (const dir of dirs) {
491
489
  const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
492
- if (!filePath) continue;
493
- const loaded = parsePlanFromMarkdown(filePath);
494
- loaded.filePath = filePath;
495
- return loaded;
490
+ if (filePath) return filePath;
496
491
  }
497
-
498
492
  return null;
499
493
  }
500
494
 
495
+ static loadFromFile(file: string, plansDir?: string): Plan | null {
496
+ const filePath = Plan.resolveFile(file, plansDir);
497
+ if (!filePath) return null;
498
+ const loaded = parsePlanFromMarkdown(filePath);
499
+ loaded.filePath = filePath;
500
+ return loaded;
501
+ }
502
+
501
503
  static fromMarkdown(filePath: string): Plan {
502
504
  return parsePlanFromMarkdown(filePath);
503
505
  }
package/src/utils/aria.ts CHANGED
@@ -469,7 +469,6 @@ const formatDiff = (added: string[], removed: string[], toggled: string[], typed
469
469
  // ─────────────────────────────────────────────────────────────────
470
470
 
471
471
  export interface FocusAreaResult {
472
- detected: boolean;
473
472
  type: 'modal' | null;
474
473
  name: string | null;
475
474
  }
@@ -478,7 +477,7 @@ const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
478
477
  let topmost: FocusAreaResult | null = null;
479
478
  for (const node of nodes) {
480
479
  if (node.role === 'dialog' || node.role === 'alertdialog' || node.attributes.modal === true || node.attributes.modal === 'true') {
481
- topmost = { detected: true, type: 'modal', name: node.name || null };
480
+ topmost = { type: 'modal', name: node.name || null };
482
481
  }
483
482
  const child = findDialogOrModal(node.children);
484
483
  if (child) topmost = child;
@@ -530,7 +529,7 @@ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
530
529
  tree = unwrapIgnored(tree);
531
530
  tree = dropEmpty(tree, { keepNamed: true });
532
531
 
533
- return findDialogOrModal(tree) ?? { detected: false, type: null, name: null };
532
+ return findDialogOrModal(tree) ?? { type: null, name: null };
534
533
  };
535
534
 
536
535
  export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {
@@ -541,6 +540,30 @@ export const collectInteractiveNodes = (snapshot: string | null): Array<Record<s
541
540
  return flatten(tree).map((e) => e.entry);
542
541
  };
543
542
 
543
+ const TEMPLATE_CHROME_ROLES = new Set(['banner', 'navigation', 'contentinfo', 'complementary']);
544
+ const TEMPLATE_PROSE_ROLE = 'text';
545
+
546
+ const dropChrome = (nodes: AriaNode[]): AriaNode[] =>
547
+ nodes.flatMap((node) => {
548
+ if (TEMPLATE_CHROME_ROLES.has(node.role)) return [];
549
+ return [{ ...node, children: dropChrome(node.children) }];
550
+ });
551
+
552
+ const collectRolePaths = (nodes: AriaNode[], prefix: string, into: Set<string>): void => {
553
+ for (const node of nodes) {
554
+ if (node.role === TEMPLATE_PROSE_ROLE) continue;
555
+ const path = prefix ? `${prefix}/${node.role}` : node.role;
556
+ into.add(path);
557
+ collectRolePaths(node.children, path, into);
558
+ }
559
+ };
560
+
561
+ export const ariaTemplateSignature = (snapshot: string | null): Set<string> => {
562
+ const paths = new Set<string>();
563
+ collectRolePaths(dropChrome(parseSnapshot(snapshot)), '', paths);
564
+ return paths;
565
+ };
566
+
544
567
  // ─────────────────────────────────────────────────────────────────
545
568
  // Standalone helpers (regex on raw strings — not part of the pipeline)
546
569
  // ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,12 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function renderSection(title: string, entries: [string, string][], indent = 0): string[] {
4
+ if (!entries.length) return [];
5
+
6
+ const pad = ' '.repeat(indent);
7
+ const width = Math.max(...entries.map(([label]) => label.length));
8
+ const lines = [`${pad}${chalk.bold(title)}`];
9
+ for (const [label, value] of entries) lines.push(`${pad} ${chalk.dim(label.padEnd(width))} ${value}`);
10
+ lines.push('');
11
+ return lines;
12
+ }
@@ -1,7 +1,7 @@
1
1
  import { parse, serialize } from 'parse5';
2
2
  import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';
3
3
  import type { HtmlConfig } from '../config.ts';
4
- import { TAILWIND_CLASS_PATTERNS, TRASH_HTML_CLASSES, minifyHtml } from './html.ts';
4
+ import { isTailwindClass, isTrashClass, minifyHtml } from './html.ts';
5
5
  import { isDynamicId, isGenericClass } from './xpath.ts';
6
6
 
7
7
  export interface HtmlDiffPart {
@@ -11,6 +11,7 @@ export interface HtmlDiffPart {
11
11
  added: string[];
12
12
  removed: string[];
13
13
  appearedSelector?: string;
14
+ containerWasEmpty?: boolean;
14
15
  }
15
16
 
16
17
  export interface HtmlDiffResult {
@@ -467,15 +468,13 @@ const directTextDiffer = (current: ElementNode, previous: ElementNode): boolean
467
468
  return false;
468
469
  };
469
470
 
470
- const trashHtmlClasses = TRASH_HTML_CLASSES;
471
-
472
471
  function filterContainerClasses(classes: string[]): string[] {
473
472
  return classes
474
473
  .filter((cls) => !/\d/.test(cls))
475
474
  .filter((cls) => !isGenericClass(cls))
476
- .filter((cls) => !trashHtmlClasses.test(cls))
475
+ .filter((cls) => !isTrashClass(cls))
477
476
  .filter((cls) => !/(:|__)/.test(cls))
478
- .filter((cls) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(cls)));
477
+ .filter((cls) => !isTailwindClass(cls));
479
478
  }
480
479
 
481
480
  function buildContainerSelector(element: ElementNode, allElements: NodeMap): string | null {
@@ -641,7 +640,8 @@ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap, pageSi
641
640
  const addedLines = appearedPaths.map((p) => `ELEMENT:${p}`);
642
641
  const removedLines: string[] = [];
643
642
 
644
- const part: HtmlDiffPart = { container: selector, subtree, rawSize: serialized.length, added: addedLines, removed: removedLines };
643
+ const heldContentBefore = (originalMap.get(containerPath)?.childNodes ?? []).some((child) => 'tagName' in child && !!child.tagName);
644
+ const part: HtmlDiffPart = { container: selector, subtree, rawSize: serialized.length, added: addedLines, removed: removedLines, containerWasEmpty: !heldContentBefore };
645
645
  const appearedPath = appearedPaths[0];
646
646
  if (appearedPath) {
647
647
  const appearedElement = modifiedMap.get(appearedPath);
package/src/utils/html.ts CHANGED
@@ -544,6 +544,9 @@ export const TAILWIND_CLASS_PATTERNS: RegExp[] = [
544
544
  /^prose$/i,
545
545
  ];
546
546
 
547
+ export const isTailwindClass = (cls: string): boolean => TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(cls));
548
+ export const isTrashClass = (cls: string): boolean => TRASH_HTML_CLASSES.test(cls);
549
+
547
550
  const NON_SEMANTIC_TAGS = new Set([
548
551
  'style',
549
552
  'script',
@@ -758,7 +761,6 @@ export function sanitizeHtmlString(html: string, htmlConfig?: HtmlConfig): strin
758
761
  */
759
762
  export function htmlMinimalUISnapshot(html: string, htmlConfig?: HtmlConfig['minimal']) {
760
763
  const document = parse(html);
761
- const trashHtmlClasses = TRASH_HTML_CLASSES;
762
764
  const removeElements = ['path', 'script'];
763
765
 
764
766
  function isFilteredOut(node) {
@@ -867,9 +869,9 @@ export function htmlMinimalUISnapshot(html: string, htmlConfig?: HtmlConfig['min
867
869
  attr.value = value
868
870
  .split(' ')
869
871
  .filter((className) => !/\d/.test(className))
870
- .filter((className) => !className.match(trashHtmlClasses))
872
+ .filter((className) => !isTrashClass(className))
871
873
  .filter((className) => !className.match(/(:|__)/))
872
- .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
874
+ .filter((className) => !isTailwindClass(className))
873
875
  .join(' ');
874
876
  if (attr.value === '') return false;
875
877
  }
@@ -1451,7 +1453,7 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
1451
1453
  .filter(Boolean)
1452
1454
  .filter((className) => !/\d/.test(className))
1453
1455
  .filter((className) => !className.includes(':'))
1454
- .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
1456
+ .filter((className) => !isTailwindClass(className))
1455
1457
  .join(' ');
1456
1458
 
1457
1459
  if (!attr.value) {
@@ -1,56 +1,21 @@
1
1
  import { detectFocusArea } from './aria.js';
2
- import type { HtmlDiffPart } from './html-diff.js';
3
- import { pathToXPath } from './html-diff.js';
4
- import { extractHeadings } from './html.js';
2
+ import { HTML_SELECTORS } from './html.js';
5
3
  import { createDebug } from './logger.js';
4
+ import { Region, type RegionData, type RegionDiff, addedSubtrees } from './region.js';
6
5
 
7
6
  const debugLog = createDebug('explorbot:overlay');
8
7
 
9
- export type OverlayType = 'modal' | 'region';
10
- export type OverlayData = {
11
- type?: OverlayType | null;
12
- name?: string | null;
13
- root?: string | null;
14
- html?: string | null;
15
- xpath?: string | null;
16
- parent?: OverlayData | null;
17
- };
18
-
19
- export class Overlay {
20
- readonly type: OverlayType | null;
21
- readonly name: string | null;
22
- readonly root: string | null;
23
- readonly html: string | null;
24
- readonly xpath: string | null;
25
- readonly parent: OverlayData | null;
26
-
27
- constructor(data: OverlayData = {}) {
28
- this.type = data.type ?? null;
29
- this.name = data.name ?? null;
30
- this.root = data.root ?? null;
31
- this.html = data.html ?? null;
32
- this.xpath = data.xpath ?? null;
33
- this.parent = data.parent ?? null;
34
- }
35
-
36
- get detected(): boolean {
37
- return this.type !== null && this.type !== 'region';
38
- }
39
-
40
- get present(): boolean {
41
- return this.type !== null;
8
+ export class Overlay extends Region {
9
+ constructor(data: RegionData = {}) {
10
+ super({ ...data, type: 'overlay' });
42
11
  }
43
12
 
44
- describe(): string {
45
- if (!this.present) return '';
46
- let text = `${this.type} "${this.name || 'unnamed'}" opened`;
47
- if (this.root) text += `, scope: ${this.root}`;
48
- return text;
13
+ get isModal(): boolean {
14
+ return true;
49
15
  }
50
16
 
51
- withGeometry(geometry: Overlay): Overlay {
17
+ withGeometry(geometry: Region): Overlay {
52
18
  return new Overlay({
53
- type: this.type,
54
19
  name: this.name || geometry.name,
55
20
  root: geometry.root,
56
21
  html: geometry.html,
@@ -58,23 +23,15 @@ export class Overlay {
58
23
  });
59
24
  }
60
25
 
61
- withParent(parent: Overlay): Overlay {
62
- return new Overlay({
63
- type: this.type,
64
- name: this.name,
65
- root: this.root,
66
- html: this.html,
67
- xpath: this.xpath,
68
- parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
69
- });
26
+ static fromAria(snapshot: string | null): Region {
27
+ const focus = detectFocusArea(snapshot);
28
+ if (!focus.type) return new Region();
29
+ return new Overlay({ name: focus.name });
70
30
  }
71
31
 
72
- static fromAria(snapshot: string | null): Overlay {
73
- return new Overlay(detectFocusArea(snapshot));
74
- }
75
-
76
- static resolve(data: { overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
77
- if (data.overlay) return new Overlay(data.overlay);
32
+ static resolve(data: { overlay?: RegionData | null; ariaSnapshot?: string | null }): Region {
33
+ if (data.overlay?.type === 'overlay') return new Overlay(data.overlay);
34
+ if (data.overlay) return new Region(data.overlay);
78
35
  return Overlay.fromAria(data.ariaSnapshot ?? null);
79
36
  }
80
37
  }
@@ -82,173 +39,129 @@ export class Overlay {
82
39
  export class OverlayPage {
83
40
  constructor(private page: { evaluate(fn: any, arg: any): Promise<any> } | null) {}
84
41
 
85
- async detectRegion(diff: RegionDiff): Promise<Overlay | null> {
42
+ async detectRegion(diff: RegionDiff): Promise<Region | null> {
86
43
  if (!diff.sameUrl && diff.similarity < SOFT_NAVIGATION_SIMILARITY) return null;
87
- for (const subRoot of this.appearedSubRoots(diff)) {
88
- const probe = await this.probe(subRoot.elementXPath);
89
- if (probe?.found && probe.onScreen && !probe.centerBelongs) {
90
- debugLog('Appeared region is hidden or covered, trying the next candidate');
44
+ for (const added of addedSubtrees(diff)) {
45
+ const layout = await this.measure(added.xpath);
46
+ if (layout?.rendered && layout.inViewport && !layout.centerBelongs) {
47
+ debugLog('Added content is hidden or covered, trying the next candidate');
48
+ continue;
49
+ }
50
+ const overlaying = !!layout?.rendered && layout.outOfFlow && layout.inViewport;
51
+ if (overlaying && modalScore(layout) < MIN_MODAL_SCORE) {
52
+ debugLog('Out-of-flow content is a popover, not an overlay; ignoring it');
91
53
  continue;
92
54
  }
93
- const overlay = this.toOverlay(subRoot, probe, diff.previousHtml);
94
- if (!overlay.name && !overlay.root) {
95
- debugLog('Appeared region has no name and no semantic root, ignoring it');
55
+ if (!overlaying && !added.sizable) {
56
+ debugLog('In-flow content is under the region floor, ignoring it');
96
57
  continue;
97
58
  }
98
- debugLog(`Region detected: ${overlay.describe()}`);
99
- return overlay;
59
+ const data = { name: added.name, root: added.root, html: added.html, xpath: added.xpath };
60
+ let region: Region = new Region({ ...data, type: 'region' });
61
+ if (overlaying) region = new Overlay(data);
62
+ debugLog(`Region detected: ${region.describe()}`);
63
+ return region;
100
64
  }
101
65
  return null;
102
66
  }
103
67
 
104
- async isStillOpen(overlay: Overlay): Promise<boolean> {
105
- if (!overlay.xpath) return true;
106
- const probe = await this.probe(overlay.xpath);
68
+ async isStillOpen(region: Region): Promise<boolean> {
69
+ if (!region.xpath) return true;
70
+ const probe = await this.measure(region.xpath);
107
71
  if (!probe) return true;
108
- if (!probe.found) return false;
109
- if (probe.onScreen && !probe.centerBelongs && !probe.coveredByFloating) return false;
72
+ if (!probe.rendered) return false;
73
+ if (probe.inViewport && !probe.centerBelongs && !probe.coveredOutOfFlow) return false;
110
74
  return true;
111
75
  }
112
76
 
113
- private appearedSubRoots(diff: RegionDiff): AppearedSubRoot[] {
114
- const candidates: AppearedSubRoot[] = [];
115
- for (const part of diff.parts) {
116
- const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
117
- if (!appeared) continue;
118
- if (part.subtree.length < SUBROOT_MIN_HTML) continue;
119
- if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
120
- debugLog(`Appeared subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
121
- continue;
122
- }
123
- candidates.push({
124
- container: part.container,
125
- elementXPath: pathToXPath(appeared.slice('ELEMENT:'.length)),
126
- subtree: part.subtree,
127
- size: part.subtree.length,
128
- rawSize: part.rawSize,
129
- appearedSelector: part.appearedSelector,
130
- fresh: !!this.freshHeading(part.subtree, diff.previousHtml),
131
- });
132
- }
133
- if (candidates.length === 0) return [];
134
- const totalRawSize = candidates.reduce((sum, c) => sum + c.rawSize, 0);
135
- const largest = Math.max(...candidates.map((c) => c.rawSize));
136
- if (largest < REGION_DOMINANCE * totalRawSize) {
137
- debugLog('Changes are scattered across the page, no dominant region');
138
- return [];
139
- }
140
- candidates.sort((a, b) => Number(b.fresh) - Number(a.fresh) || b.size - a.size);
141
- return candidates;
142
- }
143
-
144
- private async probe(xpath: string): Promise<RegionProbe | null> {
77
+ private async measure(xpath: string): Promise<RegionLayout | null> {
145
78
  if (!this.page) return null;
146
- return this.page
147
- .evaluate(
148
- ({ probeSource, config }: { probeSource: string; config: any }) => {
149
- const probe = new Function(`return ${probeSource}`)() as (config: any) => any;
150
- return probe(config);
151
- },
152
- { probeSource: inspectRegion.toString(), config: { xpath } }
153
- )
154
- .catch((err: Error) => {
155
- debugLog('Region probe failed:', err.message);
156
- return null;
157
- });
158
- }
159
-
160
- private toOverlay(subRoot: AppearedSubRoot, probe: RegionProbe | null, previousHtml: string): Overlay {
161
- let type: OverlayType = 'region';
162
- if (probe?.centerBelongs && probe.floating) type = 'modal';
163
- let root: string | null = null;
164
- if (subRoot.container !== 'body' && !subRoot.container.startsWith('//')) root = subRoot.container;
165
- if (!root && subRoot.appearedSelector) root = subRoot.appearedSelector;
166
- return new Overlay({ type, name: this.nameFrom(subRoot.subtree, previousHtml), root, html: subRoot.subtree, xpath: subRoot.elementXPath });
167
- }
168
-
169
- private nameFrom(html: string, previousHtml: string): string | null {
170
- const fresh = this.freshHeading(html, previousHtml);
171
- if (fresh) return fresh;
172
- const candidates = this.headingsOf(html);
173
- if (candidates.length === 0) return null;
174
- return candidates.join(' ');
175
- }
176
-
177
- private freshHeading(html: string, previousHtml: string): string | null {
178
- const fresh = this.headingsOf(html).filter((heading) => !previousHtml.includes(heading));
179
- return fresh[0] ?? null;
180
- }
181
-
182
- private headingsOf(html: string): string[] {
183
- const headings = extractHeadings(html);
184
- return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean) as string[];
79
+ return this.page.evaluate(measureLayout, { config: { xpath, controlsSelector: HTML_SELECTORS.interactiveControl } }).catch((err: Error) => {
80
+ debugLog('Layout measurement failed:', err.message);
81
+ return null;
82
+ });
185
83
  }
186
84
  }
187
85
 
188
- const SUBROOT_MIN_HTML = 5_000;
189
86
  const SOFT_NAVIGATION_SIMILARITY = 50;
190
- const REGION_MAX_RATIO = 0.6;
191
- const REGION_DOMINANCE = 0.7;
87
+ const TYPICAL_DIALOG_CONTROLS = 3;
88
+ const MIN_MODAL_SCORE = 0.25;
89
+
90
+ const modalScore = (layout: RegionLayout | null): number => {
91
+ if (!layout) return 0;
92
+ let blocked = layout.viewportCoverage;
93
+ if (layout.holdsViewportCenter) blocked = 1;
94
+ return blocked * Math.min(1, layout.controls / TYPICAL_DIALOG_CONTROLS);
95
+ };
192
96
 
193
- // Serialized via toString() into page.evaluate — must stay a plain function with no outer-scope references.
194
- function inspectRegion(config: { xpath: string }): RegionProbe {
195
- const probe: RegionProbe = { found: false, onScreen: false, floating: false, centerBelongs: false, coveredByFloating: false };
97
+ // Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
98
+ function measureLayout({ config }: { config: { xpath: string; controlsSelector: string } }): RegionLayout {
99
+ const layout: RegionLayout = { rendered: false, inViewport: false, outOfFlow: false, centerBelongs: false, coveredOutOfFlow: false, holdsViewportCenter: false, viewportCoverage: 0, controls: 0 };
196
100
 
197
- const isFloating = (start: HTMLElement | null): boolean => {
101
+ const isOutOfFlow = (start: HTMLElement | null): boolean => {
198
102
  let node = start;
199
103
  while (node && node !== document.body) {
200
- const style = window.getComputedStyle(node);
201
- if (style.position === 'fixed' || style.position === 'absolute' || (Number.parseInt(style.zIndex || '0', 10) || 0) > 0) return true;
104
+ const position = window.getComputedStyle(node).position;
105
+ if (position === 'fixed' || position === 'absolute') return true;
202
106
  node = node.parentElement;
203
107
  }
204
108
  return false;
205
109
  };
206
110
 
111
+ const clip = (rect: DOMRect) => {
112
+ const left = Math.max(rect.left, 0);
113
+ const top = Math.max(rect.top, 0);
114
+ const right = Math.min(rect.right, window.innerWidth);
115
+ const bottom = Math.min(rect.bottom, window.innerHeight);
116
+ return { left, top, right, bottom, area: Math.max(0, right - left) * Math.max(0, bottom - top) };
117
+ };
118
+
207
119
  const result = document.evaluate(config.xpath, document, null, 9, null);
208
120
  const node = result.singleNodeValue;
209
- if (!node || node.nodeType !== 1) return probe;
121
+ if (!node || node.nodeType !== 1) return layout;
210
122
  const element = node as HTMLElement;
211
- const rect = element.getBoundingClientRect();
212
- if (rect.width === 0 && rect.height === 0) return probe;
213
- probe.found = true;
214
-
215
- probe.floating = isFloating(element);
216
-
217
- const left = Math.max(rect.left, 0);
218
- const top = Math.max(rect.top, 0);
219
- const right = Math.min(rect.right, window.innerWidth);
220
- const bottom = Math.min(rect.bottom, window.innerHeight);
221
- if (right <= left || bottom <= top) return probe;
222
- probe.onScreen = true;
123
+ const belongs = (hit: Element | null): boolean => !!hit && (hit === element || element.contains(hit));
124
+
125
+ let box: HTMLElement = element;
126
+ let outOfFlow = isOutOfFlow(element);
127
+ let boxArea = 0;
128
+ if (outOfFlow) boxArea = clip(element.getBoundingClientRect()).area;
129
+ const MAX_DESCENDANTS_SCANNED = 200;
130
+ const descendants = Array.from(element.querySelectorAll('*')).slice(0, MAX_DESCENDANTS_SCANNED) as HTMLElement[];
131
+ for (const descendant of descendants) {
132
+ if (!isOutOfFlow(descendant)) continue;
133
+ const area = clip(descendant.getBoundingClientRect()).area;
134
+ if (area <= boxArea) continue;
135
+ box = descendant;
136
+ boxArea = area;
137
+ outOfFlow = true;
138
+ }
139
+
140
+ const rect = box.getBoundingClientRect();
141
+ if (rect.width === 0 && rect.height === 0) return layout;
142
+ layout.rendered = true;
143
+ layout.outOfFlow = outOfFlow;
144
+ layout.controls = Array.from(element.querySelectorAll(config.controlsSelector)).filter((control) => control.getClientRects().length > 0).length;
145
+
146
+ const { left, top, right, bottom, area } = clip(rect);
147
+ if (right <= left || bottom <= top) return layout;
148
+ layout.inViewport = true;
149
+ layout.viewportCoverage = area / (window.innerWidth * window.innerHeight);
223
150
 
224
151
  const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
225
- probe.centerBelongs = !!hit && (hit === element || element.contains(hit));
226
- if (hit && !probe.centerBelongs) probe.coveredByFloating = isFloating(hit as HTMLElement);
227
- return probe;
228
- }
229
-
230
- export interface RegionDiff {
231
- parts: HtmlDiffPart[];
232
- pageSize: number;
233
- similarity: number;
234
- sameUrl: boolean;
235
- previousHtml: string;
236
- }
237
-
238
- interface AppearedSubRoot {
239
- container: string;
240
- elementXPath: string;
241
- subtree: string;
242
- size: number;
243
- rawSize: number;
244
- appearedSelector?: string;
245
- fresh: boolean;
152
+ layout.centerBelongs = belongs(hit);
153
+ if (hit && !layout.centerBelongs) layout.coveredOutOfFlow = isOutOfFlow(hit as HTMLElement);
154
+ layout.holdsViewportCenter = belongs(document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2));
155
+ return layout;
246
156
  }
247
157
 
248
- interface RegionProbe {
249
- found: boolean;
250
- onScreen: boolean;
251
- floating: boolean;
158
+ interface RegionLayout {
159
+ rendered: boolean;
160
+ inViewport: boolean;
161
+ outOfFlow: boolean;
252
162
  centerBelongs: boolean;
253
- coveredByFloating: boolean;
163
+ coveredOutOfFlow: boolean;
164
+ holdsViewportCenter: boolean;
165
+ viewportCoverage: number;
166
+ controls: number;
254
167
  }