explorbot 0.4.0 → 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 +13 -6
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +15 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- 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 +21 -3
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +57 -12
- 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/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -6
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +14 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- 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 +18 -3
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +53 -11
- 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/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +5 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/rules/planner/styles/normal.md +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/captain.js +1 -1
- 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/navigator.js +1 -1
- package/dist/src/ai/pilot.js +21 -16
- package/dist/src/ai/planner.js +9 -7
- 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 +3 -1
- package/dist/src/ai/rules.js +11 -3
- 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/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +3 -10
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +12 -1
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- 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 +3 -0
- package/dist/src/config.js +18 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +4 -4
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- 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/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +27 -4
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +11 -2
- package/docs/workflow/application-spec.md +4 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +1 -1
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/captain.ts +1 -1
- 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/navigator.ts +1 -1
- package/src/ai/pilot.ts +19 -14
- package/src/ai/planner.ts +9 -8
- 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 +2 -1
- package/src/ai/rules.ts +11 -3
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-store.ts +13 -27
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +3 -8
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +12 -1
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +20 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +4 -4
- package/src/global-config.ts +8 -0
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- 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
|
@@ -1,62 +1,34 @@
|
|
|
1
1
|
import { detectFocusArea } from './aria.js';
|
|
2
|
-
import {
|
|
3
|
-
import { extractHeadings } from './html.js';
|
|
2
|
+
import { HTML_SELECTORS } from './html.js';
|
|
4
3
|
import { createDebug } from './logger.js';
|
|
4
|
+
import { Region, addedSubtrees } from './region.js';
|
|
5
5
|
const debugLog = createDebug('explorbot:overlay');
|
|
6
|
-
export class Overlay {
|
|
7
|
-
type;
|
|
8
|
-
name;
|
|
9
|
-
root;
|
|
10
|
-
html;
|
|
11
|
-
xpath;
|
|
12
|
-
parent;
|
|
6
|
+
export class Overlay extends Region {
|
|
13
7
|
constructor(data = {}) {
|
|
14
|
-
|
|
15
|
-
this.name = data.name ?? null;
|
|
16
|
-
this.root = data.root ?? null;
|
|
17
|
-
this.html = data.html ?? null;
|
|
18
|
-
this.xpath = data.xpath ?? null;
|
|
19
|
-
this.parent = data.parent ?? null;
|
|
8
|
+
super({ ...data, type: 'overlay' });
|
|
20
9
|
}
|
|
21
|
-
get
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
get present() {
|
|
25
|
-
return this.type !== null;
|
|
26
|
-
}
|
|
27
|
-
describe() {
|
|
28
|
-
if (!this.present)
|
|
29
|
-
return '';
|
|
30
|
-
let text = `${this.type} "${this.name || 'unnamed'}" opened`;
|
|
31
|
-
if (this.root)
|
|
32
|
-
text += `, scope: ${this.root}`;
|
|
33
|
-
return text;
|
|
10
|
+
get isModal() {
|
|
11
|
+
return true;
|
|
34
12
|
}
|
|
35
13
|
withGeometry(geometry) {
|
|
36
14
|
return new Overlay({
|
|
37
|
-
type: this.type,
|
|
38
15
|
name: this.name || geometry.name,
|
|
39
16
|
root: geometry.root,
|
|
40
17
|
html: geometry.html,
|
|
41
18
|
xpath: geometry.xpath,
|
|
42
19
|
});
|
|
43
20
|
}
|
|
44
|
-
withParent(parent) {
|
|
45
|
-
return new Overlay({
|
|
46
|
-
type: this.type,
|
|
47
|
-
name: this.name,
|
|
48
|
-
root: this.root,
|
|
49
|
-
html: this.html,
|
|
50
|
-
xpath: this.xpath,
|
|
51
|
-
parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
21
|
static fromAria(snapshot) {
|
|
55
|
-
|
|
22
|
+
const focus = detectFocusArea(snapshot);
|
|
23
|
+
if (!focus.type)
|
|
24
|
+
return new Region();
|
|
25
|
+
return new Overlay({ name: focus.name });
|
|
56
26
|
}
|
|
57
27
|
static resolve(data) {
|
|
58
|
-
if (data.overlay)
|
|
28
|
+
if (data.overlay?.type === 'overlay')
|
|
59
29
|
return new Overlay(data.overlay);
|
|
30
|
+
if (data.overlay)
|
|
31
|
+
return new Region(data.overlay);
|
|
60
32
|
return Overlay.fromAria(data.ariaSnapshot ?? null);
|
|
61
33
|
}
|
|
62
34
|
}
|
|
@@ -68,146 +40,120 @@ export class OverlayPage {
|
|
|
68
40
|
async detectRegion(diff) {
|
|
69
41
|
if (!diff.sameUrl && diff.similarity < SOFT_NAVIGATION_SIMILARITY)
|
|
70
42
|
return null;
|
|
71
|
-
for (const
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
debugLog('
|
|
43
|
+
for (const added of addedSubtrees(diff)) {
|
|
44
|
+
const layout = await this.measure(added.xpath);
|
|
45
|
+
if (layout?.rendered && layout.inViewport && !layout.centerBelongs) {
|
|
46
|
+
debugLog('Added content is hidden or covered, trying the next candidate');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const overlaying = !!layout?.rendered && layout.outOfFlow && layout.inViewport;
|
|
50
|
+
if (overlaying && modalScore(layout) < MIN_MODAL_SCORE) {
|
|
51
|
+
debugLog('Out-of-flow content is a popover, not an overlay; ignoring it');
|
|
75
52
|
continue;
|
|
76
53
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
debugLog('Appeared region has no name and no semantic root, ignoring it');
|
|
54
|
+
if (!overlaying && !added.sizable) {
|
|
55
|
+
debugLog('In-flow content is under the region floor, ignoring it');
|
|
80
56
|
continue;
|
|
81
57
|
}
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
const data = { name: added.name, root: added.root, html: added.html, xpath: added.xpath };
|
|
59
|
+
let region = new Region({ ...data, type: 'region' });
|
|
60
|
+
if (overlaying)
|
|
61
|
+
region = new Overlay(data);
|
|
62
|
+
debugLog(`Region detected: ${region.describe()}`);
|
|
63
|
+
return region;
|
|
84
64
|
}
|
|
85
65
|
return null;
|
|
86
66
|
}
|
|
87
|
-
async isStillOpen(
|
|
88
|
-
if (!
|
|
67
|
+
async isStillOpen(region) {
|
|
68
|
+
if (!region.xpath)
|
|
89
69
|
return true;
|
|
90
|
-
const probe = await this.
|
|
70
|
+
const probe = await this.measure(region.xpath);
|
|
91
71
|
if (!probe)
|
|
92
72
|
return true;
|
|
93
|
-
if (!probe.
|
|
73
|
+
if (!probe.rendered)
|
|
94
74
|
return false;
|
|
95
|
-
if (probe.
|
|
75
|
+
if (probe.inViewport && !probe.centerBelongs && !probe.coveredOutOfFlow)
|
|
96
76
|
return false;
|
|
97
77
|
return true;
|
|
98
78
|
}
|
|
99
|
-
|
|
100
|
-
const candidates = [];
|
|
101
|
-
for (const part of diff.parts) {
|
|
102
|
-
const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
|
|
103
|
-
if (!appeared)
|
|
104
|
-
continue;
|
|
105
|
-
if (part.subtree.length < SUBROOT_MIN_HTML)
|
|
106
|
-
continue;
|
|
107
|
-
if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
|
|
108
|
-
debugLog(`Appeared subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
candidates.push({
|
|
112
|
-
container: part.container,
|
|
113
|
-
elementXPath: pathToXPath(appeared.slice('ELEMENT:'.length)),
|
|
114
|
-
subtree: part.subtree,
|
|
115
|
-
size: part.subtree.length,
|
|
116
|
-
rawSize: part.rawSize,
|
|
117
|
-
appearedSelector: part.appearedSelector,
|
|
118
|
-
fresh: !!this.freshHeading(part.subtree, diff.previousHtml),
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
if (candidates.length === 0)
|
|
122
|
-
return [];
|
|
123
|
-
const totalRawSize = candidates.reduce((sum, c) => sum + c.rawSize, 0);
|
|
124
|
-
const largest = Math.max(...candidates.map((c) => c.rawSize));
|
|
125
|
-
if (largest < REGION_DOMINANCE * totalRawSize) {
|
|
126
|
-
debugLog('Changes are scattered across the page, no dominant region');
|
|
127
|
-
return [];
|
|
128
|
-
}
|
|
129
|
-
candidates.sort((a, b) => Number(b.fresh) - Number(a.fresh) || b.size - a.size);
|
|
130
|
-
return candidates;
|
|
131
|
-
}
|
|
132
|
-
async probe(xpath) {
|
|
79
|
+
async measure(xpath) {
|
|
133
80
|
if (!this.page)
|
|
134
81
|
return null;
|
|
135
|
-
return this.page
|
|
136
|
-
|
|
137
|
-
const probe = new Function(`return ${probeSource}`)();
|
|
138
|
-
return probe(config);
|
|
139
|
-
}, { probeSource: inspectRegion.toString(), config: { xpath } })
|
|
140
|
-
.catch((err) => {
|
|
141
|
-
debugLog('Region probe failed:', err.message);
|
|
82
|
+
return this.page.evaluate(measureLayout, { config: { xpath, controlsSelector: HTML_SELECTORS.interactiveControl } }).catch((err) => {
|
|
83
|
+
debugLog('Layout measurement failed:', err.message);
|
|
142
84
|
return null;
|
|
143
85
|
});
|
|
144
86
|
}
|
|
145
|
-
toOverlay(subRoot, probe, previousHtml) {
|
|
146
|
-
let type = 'region';
|
|
147
|
-
if (probe?.centerBelongs && probe.floating)
|
|
148
|
-
type = 'modal';
|
|
149
|
-
let root = null;
|
|
150
|
-
if (subRoot.container !== 'body' && !subRoot.container.startsWith('//'))
|
|
151
|
-
root = subRoot.container;
|
|
152
|
-
if (!root && subRoot.appearedSelector)
|
|
153
|
-
root = subRoot.appearedSelector;
|
|
154
|
-
return new Overlay({ type, name: this.nameFrom(subRoot.subtree, previousHtml), root, html: subRoot.subtree, xpath: subRoot.elementXPath });
|
|
155
|
-
}
|
|
156
|
-
nameFrom(html, previousHtml) {
|
|
157
|
-
const fresh = this.freshHeading(html, previousHtml);
|
|
158
|
-
if (fresh)
|
|
159
|
-
return fresh;
|
|
160
|
-
const candidates = this.headingsOf(html);
|
|
161
|
-
if (candidates.length === 0)
|
|
162
|
-
return null;
|
|
163
|
-
return candidates.join(' ');
|
|
164
|
-
}
|
|
165
|
-
freshHeading(html, previousHtml) {
|
|
166
|
-
const fresh = this.headingsOf(html).filter((heading) => !previousHtml.includes(heading));
|
|
167
|
-
return fresh[0] ?? null;
|
|
168
|
-
}
|
|
169
|
-
headingsOf(html) {
|
|
170
|
-
const headings = extractHeadings(html);
|
|
171
|
-
return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean);
|
|
172
|
-
}
|
|
173
87
|
}
|
|
174
|
-
const SUBROOT_MIN_HTML = 5_000;
|
|
175
88
|
const SOFT_NAVIGATION_SIMILARITY = 50;
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
89
|
+
const TYPICAL_DIALOG_CONTROLS = 3;
|
|
90
|
+
const MIN_MODAL_SCORE = 0.25;
|
|
91
|
+
const modalScore = (layout) => {
|
|
92
|
+
if (!layout)
|
|
93
|
+
return 0;
|
|
94
|
+
let blocked = layout.viewportCoverage;
|
|
95
|
+
if (layout.holdsViewportCenter)
|
|
96
|
+
blocked = 1;
|
|
97
|
+
return blocked * Math.min(1, layout.controls / TYPICAL_DIALOG_CONTROLS);
|
|
98
|
+
};
|
|
99
|
+
// Passed to page.evaluate as a function — must stay self-contained with no outer-scope references.
|
|
100
|
+
function measureLayout({ config }) {
|
|
101
|
+
const layout = { rendered: false, inViewport: false, outOfFlow: false, centerBelongs: false, coveredOutOfFlow: false, holdsViewportCenter: false, viewportCoverage: 0, controls: 0 };
|
|
102
|
+
const isOutOfFlow = (start) => {
|
|
182
103
|
let node = start;
|
|
183
104
|
while (node && node !== document.body) {
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
105
|
+
const position = window.getComputedStyle(node).position;
|
|
106
|
+
if (position === 'fixed' || position === 'absolute')
|
|
186
107
|
return true;
|
|
187
108
|
node = node.parentElement;
|
|
188
109
|
}
|
|
189
110
|
return false;
|
|
190
111
|
};
|
|
112
|
+
const clip = (rect) => {
|
|
113
|
+
const left = Math.max(rect.left, 0);
|
|
114
|
+
const top = Math.max(rect.top, 0);
|
|
115
|
+
const right = Math.min(rect.right, window.innerWidth);
|
|
116
|
+
const bottom = Math.min(rect.bottom, window.innerHeight);
|
|
117
|
+
return { left, top, right, bottom, area: Math.max(0, right - left) * Math.max(0, bottom - top) };
|
|
118
|
+
};
|
|
191
119
|
const result = document.evaluate(config.xpath, document, null, 9, null);
|
|
192
120
|
const node = result.singleNodeValue;
|
|
193
121
|
if (!node || node.nodeType !== 1)
|
|
194
|
-
return
|
|
122
|
+
return layout;
|
|
195
123
|
const element = node;
|
|
196
|
-
const
|
|
124
|
+
const belongs = (hit) => !!hit && (hit === element || element.contains(hit));
|
|
125
|
+
let box = element;
|
|
126
|
+
let outOfFlow = isOutOfFlow(element);
|
|
127
|
+
let boxArea = 0;
|
|
128
|
+
if (outOfFlow)
|
|
129
|
+
boxArea = clip(element.getBoundingClientRect()).area;
|
|
130
|
+
const MAX_DESCENDANTS_SCANNED = 200;
|
|
131
|
+
const descendants = Array.from(element.querySelectorAll('*')).slice(0, MAX_DESCENDANTS_SCANNED);
|
|
132
|
+
for (const descendant of descendants) {
|
|
133
|
+
if (!isOutOfFlow(descendant))
|
|
134
|
+
continue;
|
|
135
|
+
const area = clip(descendant.getBoundingClientRect()).area;
|
|
136
|
+
if (area <= boxArea)
|
|
137
|
+
continue;
|
|
138
|
+
box = descendant;
|
|
139
|
+
boxArea = area;
|
|
140
|
+
outOfFlow = true;
|
|
141
|
+
}
|
|
142
|
+
const rect = box.getBoundingClientRect();
|
|
197
143
|
if (rect.width === 0 && rect.height === 0)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const top =
|
|
203
|
-
const right = Math.min(rect.right, window.innerWidth);
|
|
204
|
-
const bottom = Math.min(rect.bottom, window.innerHeight);
|
|
144
|
+
return layout;
|
|
145
|
+
layout.rendered = true;
|
|
146
|
+
layout.outOfFlow = outOfFlow;
|
|
147
|
+
layout.controls = Array.from(element.querySelectorAll(config.controlsSelector)).filter((control) => control.getClientRects().length > 0).length;
|
|
148
|
+
const { left, top, right, bottom, area } = clip(rect);
|
|
205
149
|
if (right <= left || bottom <= top)
|
|
206
|
-
return
|
|
207
|
-
|
|
150
|
+
return layout;
|
|
151
|
+
layout.inViewport = true;
|
|
152
|
+
layout.viewportCoverage = area / (window.innerWidth * window.innerHeight);
|
|
208
153
|
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
|
209
|
-
|
|
210
|
-
if (hit && !
|
|
211
|
-
|
|
212
|
-
|
|
154
|
+
layout.centerBelongs = belongs(hit);
|
|
155
|
+
if (hit && !layout.centerBelongs)
|
|
156
|
+
layout.coveredOutOfFlow = isOutOfFlow(hit);
|
|
157
|
+
layout.holdsViewportCenter = belongs(document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2));
|
|
158
|
+
return layout;
|
|
213
159
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HtmlDiffPart } from './html-diff.js';
|
|
2
|
+
export declare class Region {
|
|
3
|
+
readonly type: RegionType | null;
|
|
4
|
+
readonly name: string | null;
|
|
5
|
+
readonly root: string | null;
|
|
6
|
+
readonly html: string | null;
|
|
7
|
+
readonly xpath: string | null;
|
|
8
|
+
readonly parent: RegionData | null;
|
|
9
|
+
constructor(data?: RegionData);
|
|
10
|
+
get isOpen(): boolean;
|
|
11
|
+
get isModal(): boolean;
|
|
12
|
+
describe(): string;
|
|
13
|
+
withParent(parent: Region): Region;
|
|
14
|
+
}
|
|
15
|
+
export declare function addedSubtrees(diff: RegionDiff): AddedSubtree[];
|
|
16
|
+
export type RegionType = 'overlay' | 'region';
|
|
17
|
+
export type RegionData = {
|
|
18
|
+
type?: RegionType | null;
|
|
19
|
+
name?: string | null;
|
|
20
|
+
root?: string | null;
|
|
21
|
+
html?: string | null;
|
|
22
|
+
xpath?: string | null;
|
|
23
|
+
parent?: RegionData | null;
|
|
24
|
+
};
|
|
25
|
+
export interface RegionDiff {
|
|
26
|
+
parts: HtmlDiffPart[];
|
|
27
|
+
pageSize: number;
|
|
28
|
+
similarity: number;
|
|
29
|
+
sameUrl: boolean;
|
|
30
|
+
previousHtml: string;
|
|
31
|
+
}
|
|
32
|
+
export interface AddedSubtree {
|
|
33
|
+
xpath: string;
|
|
34
|
+
html: string;
|
|
35
|
+
size: number;
|
|
36
|
+
rawSize: number;
|
|
37
|
+
sizable: boolean;
|
|
38
|
+
name: string | null;
|
|
39
|
+
root: string | null;
|
|
40
|
+
hasNewHeading: boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { pathToXPath } from './html-diff.js';
|
|
2
|
+
import { extractHeadings } from './html.js';
|
|
3
|
+
import { createDebug } from './logger.js';
|
|
4
|
+
const debugLog = createDebug('explorbot:region');
|
|
5
|
+
export class Region {
|
|
6
|
+
type;
|
|
7
|
+
name;
|
|
8
|
+
root;
|
|
9
|
+
html;
|
|
10
|
+
xpath;
|
|
11
|
+
parent;
|
|
12
|
+
constructor(data = {}) {
|
|
13
|
+
this.type = data.type ?? null;
|
|
14
|
+
this.name = data.name ?? null;
|
|
15
|
+
this.root = data.root ?? null;
|
|
16
|
+
this.html = data.html ?? null;
|
|
17
|
+
this.xpath = data.xpath ?? null;
|
|
18
|
+
this.parent = data.parent ?? null;
|
|
19
|
+
}
|
|
20
|
+
get isOpen() {
|
|
21
|
+
return this.type !== null;
|
|
22
|
+
}
|
|
23
|
+
get isModal() {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
describe() {
|
|
27
|
+
if (!this.isOpen)
|
|
28
|
+
return '';
|
|
29
|
+
let text = `${this.type} "${this.name || 'unnamed'}" opened`;
|
|
30
|
+
if (this.root)
|
|
31
|
+
text += `, scope: ${this.root}`;
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
withParent(parent) {
|
|
35
|
+
const Ctor = this.constructor;
|
|
36
|
+
return new Ctor({
|
|
37
|
+
type: this.type,
|
|
38
|
+
name: this.name,
|
|
39
|
+
root: this.root,
|
|
40
|
+
html: this.html,
|
|
41
|
+
xpath: this.xpath,
|
|
42
|
+
parent: { type: parent.type, name: parent.name, root: parent.root, xpath: parent.xpath },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function addedSubtrees(diff) {
|
|
47
|
+
const candidates = [];
|
|
48
|
+
for (const part of diff.parts) {
|
|
49
|
+
const appeared = part.added.find((line) => line.startsWith('ELEMENT:'));
|
|
50
|
+
if (!appeared)
|
|
51
|
+
continue;
|
|
52
|
+
if (diff.pageSize > 0 && part.rawSize > REGION_MAX_RATIO * diff.pageSize) {
|
|
53
|
+
debugLog(`Added subtree spans ${Math.round((part.rawSize / diff.pageSize) * 100)}% of the page — a new state, not a region`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const headings = headingsOf(part.subtree);
|
|
57
|
+
const newHeading = headings.find((heading) => !diff.previousHtml.includes(heading)) ?? null;
|
|
58
|
+
let name = newHeading;
|
|
59
|
+
if (!name && headings.length > 0)
|
|
60
|
+
name = headings.join(' ');
|
|
61
|
+
const namedContainer = part.container !== 'body' && !part.container.startsWith('//');
|
|
62
|
+
let root = null;
|
|
63
|
+
if (namedContainer && part.containerWasEmpty)
|
|
64
|
+
root = part.container;
|
|
65
|
+
if (!root)
|
|
66
|
+
root = part.appearedSelector ?? null;
|
|
67
|
+
if (!root && namedContainer)
|
|
68
|
+
root = part.container;
|
|
69
|
+
candidates.push({
|
|
70
|
+
xpath: pathToXPath(appeared.slice('ELEMENT:'.length)),
|
|
71
|
+
html: part.subtree,
|
|
72
|
+
size: part.subtree.length,
|
|
73
|
+
rawSize: part.rawSize,
|
|
74
|
+
sizable: part.subtree.length >= REGION_MIN_HTML,
|
|
75
|
+
name,
|
|
76
|
+
root,
|
|
77
|
+
hasNewHeading: !!newHeading,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const sizable = candidates.filter((candidate) => candidate.sizable);
|
|
81
|
+
const totalRawSize = sizable.reduce((sum, candidate) => sum + candidate.rawSize, 0);
|
|
82
|
+
const largest = Math.max(0, ...sizable.map((candidate) => candidate.rawSize));
|
|
83
|
+
if (largest < REGION_DOMINANCE * totalRawSize) {
|
|
84
|
+
debugLog('Changes are scattered across the page, no dominant region');
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const identified = candidates.filter((candidate) => candidate.name || candidate.root);
|
|
88
|
+
if (identified.length < candidates.length)
|
|
89
|
+
debugLog('Dropped added content with no name and no scoping root');
|
|
90
|
+
identified.sort((a, b) => Number(b.hasNewHeading) - Number(a.hasNewHeading) || b.size - a.size);
|
|
91
|
+
return identified;
|
|
92
|
+
}
|
|
93
|
+
const headingsOf = (html) => {
|
|
94
|
+
const headings = extractHeadings(html);
|
|
95
|
+
return [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean);
|
|
96
|
+
};
|
|
97
|
+
const REGION_MIN_HTML = 5_000;
|
|
98
|
+
const REGION_MAX_RATIO = 0.6;
|
|
99
|
+
const REGION_DOMINANCE = 0.7;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function diceSimilarity(a: Set<string>, b: Set<string>): number;
|
|
@@ -2,7 +2,7 @@ export declare function isDynamicSegment(segment: string): boolean;
|
|
|
2
2
|
export declare function hasDynamicUrlSegment(url: string): boolean;
|
|
3
3
|
export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
|
|
4
4
|
export declare function generalizeSegment(segment: string): string;
|
|
5
|
-
export declare function generalizeUrl(url: string): string;
|
|
5
|
+
export declare function generalizeUrl(url: string, replaceSegment?: (segment: string) => string): string;
|
|
6
6
|
export declare function matchesUrl(pattern: string, path: string): boolean;
|
|
7
7
|
export declare function extractStatePath(url: string): string;
|
|
8
8
|
export declare function matchesNavigationUrl(expected: string, current: string): boolean;
|
|
@@ -3,8 +3,8 @@ import { ConfigParser } from '../config.js';
|
|
|
3
3
|
export function isDynamicSegment(segment) {
|
|
4
4
|
try {
|
|
5
5
|
const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
|
|
6
|
-
if (configRegex)
|
|
7
|
-
return
|
|
6
|
+
if (configRegex && new RegExp(configRegex, 'i').test(segment))
|
|
7
|
+
return true;
|
|
8
8
|
}
|
|
9
9
|
catch {
|
|
10
10
|
/* config not loaded yet */
|
|
@@ -62,10 +62,10 @@ export function generalizeSegment(segment) {
|
|
|
62
62
|
return '[a-f0-9]+';
|
|
63
63
|
return '[^/]+';
|
|
64
64
|
}
|
|
65
|
-
export function generalizeUrl(url) {
|
|
65
|
+
export function generalizeUrl(url, replaceSegment = generalizeSegment) {
|
|
66
66
|
return url
|
|
67
67
|
.split('/')
|
|
68
|
-
.map((seg) => (seg.length > 0 && isDynamicSegment(seg) ?
|
|
68
|
+
.map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? replaceSegment(seg) : seg))
|
|
69
69
|
.join('/');
|
|
70
70
|
}
|
|
71
71
|
export function matchesUrl(pattern, path) {
|
|
@@ -56,6 +56,21 @@ api: {
|
|
|
56
56
|
|
|
57
57
|
A matching `teardown` hook runs after all tests finish — use it to clean up data.
|
|
58
58
|
|
|
59
|
+
### Without a config file
|
|
60
|
+
|
|
61
|
+
Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx explorbot api plan /users \
|
|
65
|
+
--endpoint https://api.example.com/v1 \
|
|
66
|
+
--spec ./openapi.yaml \
|
|
67
|
+
--knowledge 'Send X-Api-Key: ${env.API_KEY} on every request'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`--endpoint` and `--spec` each have an environment twin — `EXPLORBOT_URL` and `EXPLORBOT_API_SPEC` — and the flag wins when both are set. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
|
|
71
|
+
|
|
72
|
+
`--endpoint` keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads it from the flag or the variable.
|
|
73
|
+
|
|
59
74
|
### A dedicated API project
|
|
60
75
|
|
|
61
76
|
If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.ts` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.config.*`.
|
|
@@ -21,7 +21,16 @@ endpoint: "/users"
|
|
|
21
21
|
CRUD for users. Admin role required for writes. IDs are UUIDs.
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Chief loads knowledge matching the endpoint it's planning. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
|
|
24
|
+
Chief loads knowledge matching the endpoint it's planning, and Curler loads it again for the endpoint it's testing, so auth headers and payload rules reach the requests themselves. Running `know` again on the same endpoint appends to the file. See [knowledge](../workflow/knowledge.md) for how matching and files work.
|
|
25
|
+
|
|
26
|
+
For a fact that should not be stored — a token, a one-off fixture — pass `--knowledge` instead. It applies to the run only:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx explorbot api explore /users --knowledge '---
|
|
30
|
+
endpoint: /users/*
|
|
31
|
+
---
|
|
32
|
+
Send X-Api-Key: ${env.API_KEY} on every request'
|
|
33
|
+
```
|
|
25
34
|
|
|
26
35
|
## Choose a planning style
|
|
27
36
|
|
|
@@ -105,22 +105,28 @@ Title: User Management
|
|
|
105
105
|
|
|
106
106
|
Lists all user accounts and provides entry points for managing them.
|
|
107
107
|
|
|
108
|
-
## Screenshots
|
|
109
|
-
|
|
110
|
-

|
|
111
|
-
|
|
112
108
|
## User Can
|
|
113
109
|
|
|
114
110
|
- user can search users by name -> list of items
|
|
115
111
|
Proof: Search input above the users table.
|
|
112
|
+

|
|
116
113
|
|
|
117
114
|
## User Might
|
|
118
115
|
|
|
119
116
|
- user might export the user list -> all items
|
|
120
117
|
Signal: Toolbar shows an unlabeled download icon.
|
|
118
|
+
|
|
119
|
+
## Screenshots
|
|
120
|
+
|
|
121
|
+

|
|
122
|
+
|
|
123
|
+
## Navigation
|
|
124
|
+
|
|
125
|
+
- Create user: /admin/users/new
|
|
126
|
+
- Audit log: /admin/audit
|
|
121
127
|
```
|
|
122
128
|
|
|
123
|
-
`User Can` lists capabilities proven by visible UI, each with its evidence. `User Might` lists capabilities the UI suggests but research could not fully confirm. Pages where research found almost nothing are skipped rather than padded with guesses.
|
|
129
|
+
`User Can` lists capabilities proven by visible UI, each with its evidence. `User Might` lists capabilities the UI suggests but research could not fully confirm. Links to other pages are collected in their own `Navigation` section and never counted as capabilities. Pages where research found almost nothing are skipped rather than padded with guesses.
|
|
124
130
|
|
|
125
131
|
## Next steps
|
|
126
132
|
|
|
@@ -4,7 +4,7 @@ With defaults, a run crawls everything it can reach on the same origin, up to 10
|
|
|
4
4
|
|
|
5
5
|
## How the queue is built
|
|
6
6
|
|
|
7
|
-
After documenting a page, the crawler queues new targets from three sources: links found on the page, navigation entries identified by research (this is how hash-navigated pages such as OpenAPI reference docs get crawled), and URLs discovered through clicks when [interactive mode](./interactive-mode.md) is on. Every target must pass the filters on this page before it is queued. Pages already visited in this session are not revisited
|
|
7
|
+
After documenting a page, the crawler queues new targets from three sources: links found on the page, navigation entries identified by research (this is how hash-navigated pages such as OpenAPI reference docs get crawled), and URLs discovered through clicks when [interactive mode](./interactive-mode.md) is on. Every target must pass the filters on this page before it is queued. Pages already visited in this session are not revisited.
|
|
8
8
|
|
|
9
9
|
## maxPages — the budget
|
|
10
10
|
|
|
@@ -65,3 +65,23 @@ docs: {
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Set it to `false` when such pages genuinely differ and you want each URL documented separately. Expect the page budget to fill up faster.
|
|
68
|
+
|
|
69
|
+
## collapseTemplatePages — skip pages with a documented layout
|
|
70
|
+
|
|
71
|
+
URL collapsing cannot see slugs: `/blog/my-post` and `/blog/other-post` look like different pages, yet they share one layout and differ only in their text. By default, after a page is loaded the crawler compares the page structure — the tree of ARIA roles without any text — against the pages it has already documented. A page whose layout matches one of them is skipped and listed under Skipped in the index with the reason `same layout as <url> (only content differs)`. Skipped pages do not consume the page budget.
|
|
72
|
+
|
|
73
|
+
The first page of each layout is always documented in full; only its clones are skipped.
|
|
74
|
+
|
|
75
|
+
A page counts as a clone when its structure is at least 90% similar to a documented page. Identical templates score close to 100; pages that merely look alike (same kind of form or list, different purpose) score around 80 and are still documented. If your site leans either way, move the bar with `docs.templateSimilarity` or per-run with `--template-similarity <percent>`; invalid values fall back to the default.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
docs: {
|
|
79
|
+
collapseTemplatePages: false,
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For a one-off run over every page, use the CLI flag instead:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
explorbot docs collect /blog --no-collapse-template-pages
|
|
87
|
+
```
|