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.
- package/bin/explorbot-cli.ts +8 -3
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +9 -3
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
- package/dist/src/ai/fisherman/request-haul.js +28 -0
- package/dist/src/ai/fisherman-tools.d.ts +10 -1
- package/dist/src/ai/fisherman-tools.js +35 -31
- package/dist/src/ai/fisherman.d.ts +3 -2
- package/dist/src/ai/fisherman.js +8 -6
- package/dist/src/ai/pilot.js +21 -16
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +8 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-store.d.ts +1 -5
- package/dist/src/api/request-store.js +12 -22
- package/dist/src/commands/config-command.js +2 -9
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +3 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +32 -0
- package/src/ai/fisherman-tools.ts +38 -32
- package/src/ai/fisherman.ts +8 -7
- package/src/ai/pilot.ts +19 -14
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +8 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-store.ts +13 -27
- package/src/commands/config-command.ts +2 -7
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- 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 {
|
|
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
|
|
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(...
|
|
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 (
|
|
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 = {
|
|
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) ?? {
|
|
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
|
+
}
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -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 {
|
|
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) => !
|
|
475
|
+
.filter((cls) => !isTrashClass(cls))
|
|
477
476
|
.filter((cls) => !/(:|__)/.test(cls))
|
|
478
|
-
.filter((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
|
|
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
|
|
872
|
+
.filter((className) => !isTrashClass(className))
|
|
871
873
|
.filter((className) => !className.match(/(:|__)/))
|
|
872
|
-
.filter((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) => !
|
|
1456
|
+
.filter((className) => !isTailwindClass(className))
|
|
1455
1457
|
.join(' ');
|
|
1456
1458
|
|
|
1457
1459
|
if (!attr.value) {
|
package/src/utils/overlay.ts
CHANGED
|
@@ -1,56 +1,21 @@
|
|
|
1
1
|
import { detectFocusArea } from './aria.js';
|
|
2
|
-
import
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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:
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
73
|
-
return new Overlay(
|
|
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<
|
|
42
|
+
async detectRegion(diff: RegionDiff): Promise<Region | null> {
|
|
86
43
|
if (!diff.sameUrl && diff.similarity < SOFT_NAVIGATION_SIMILARITY) return null;
|
|
87
|
-
for (const
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
debugLog('
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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(
|
|
105
|
-
if (!
|
|
106
|
-
const probe = await this.
|
|
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.
|
|
109
|
-
if (probe.
|
|
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
|
|
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
|
-
.
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
191
|
-
const
|
|
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
|
-
//
|
|
194
|
-
function
|
|
195
|
-
const
|
|
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
|
|
101
|
+
const isOutOfFlow = (start: HTMLElement | null): boolean => {
|
|
198
102
|
let node = start;
|
|
199
103
|
while (node && node !== document.body) {
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
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
|
|
121
|
+
if (!node || node.nodeType !== 1) return layout;
|
|
210
122
|
const element = node as HTMLElement;
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
if (hit && !
|
|
227
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
158
|
+
interface RegionLayout {
|
|
159
|
+
rendered: boolean;
|
|
160
|
+
inViewport: boolean;
|
|
161
|
+
outOfFlow: boolean;
|
|
252
162
|
centerBelongs: boolean;
|
|
253
|
-
|
|
163
|
+
coveredOutOfFlow: boolean;
|
|
164
|
+
holdsViewportCenter: boolean;
|
|
165
|
+
viewportCoverage: number;
|
|
166
|
+
controls: number;
|
|
254
167
|
}
|