explorbot 0.1.20 → 0.1.21
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 +54 -25
- package/boat/doc-collector/src/ai/documentarian.ts +259 -84
- package/boat/doc-collector/src/ai/tools.ts +544 -0
- package/boat/doc-collector/src/cli.ts +1 -0
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +64 -5
- package/boat/doc-collector/src/docs-renderer.ts +56 -2
- package/dist/bin/explorbot-cli.js +30 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +220 -71
- package/dist/boat/doc-collector/src/ai/tools.js +415 -0
- package/dist/boat/doc-collector/src/cli.js +1 -0
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +57 -5
- package/dist/boat/doc-collector/src/docs-renderer.js +46 -0
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.js +82 -6
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/plans-command.js +83 -0
- package/dist/src/commands/test-command.js +14 -8
- package/dist/src/playwright-recorder.js +19 -5
- package/package.json +1 -1
- package/src/ai/navigator.ts +79 -7
- package/src/commands/index.ts +2 -0
- package/src/commands/plans-command.ts +99 -0
- package/src/commands/test-command.ts +15 -8
- package/src/playwright-recorder.ts +19 -5
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
|
|
2
|
+
import type Explorer from '../../../../src/explorer.ts';
|
|
3
|
+
import type { WebPageState } from '../../../../src/state-manager.ts';
|
|
4
|
+
|
|
5
|
+
export interface DocStateTransition {
|
|
6
|
+
action: string;
|
|
7
|
+
before: string;
|
|
8
|
+
after: string;
|
|
9
|
+
targetUrl?: string;
|
|
10
|
+
discoveredUrls?: string[];
|
|
11
|
+
newCapabilities?: string[];
|
|
12
|
+
element?: InteractionElement;
|
|
13
|
+
changes?: InteractionChanges;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface InteractionCandidate {
|
|
17
|
+
element: ResearchElement;
|
|
18
|
+
container?: string;
|
|
19
|
+
role: 'link' | 'button' | 'tab';
|
|
20
|
+
sectionName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InteractionElement {
|
|
24
|
+
role: string;
|
|
25
|
+
name: string;
|
|
26
|
+
section: string;
|
|
27
|
+
container?: string;
|
|
28
|
+
locator?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface InteractionChanges {
|
|
32
|
+
urlChanged: boolean;
|
|
33
|
+
newElements: number;
|
|
34
|
+
removedElements: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAX_PRIMARY_CANDIDATES = 3;
|
|
38
|
+
const MAX_INTERACTIONS = 5;
|
|
39
|
+
const MAX_LINKS = 15;
|
|
40
|
+
const DEFAULT_WAIT_MS = 700;
|
|
41
|
+
const TAB_WAIT_MS = 500;
|
|
42
|
+
|
|
43
|
+
export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise<DocStateTransition[]> {
|
|
44
|
+
const sections = parseResearchSections(research);
|
|
45
|
+
const transitions: DocStateTransition[] = [];
|
|
46
|
+
const tabGroup = findTabGroup(sections);
|
|
47
|
+
|
|
48
|
+
if (tabGroup) {
|
|
49
|
+
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const candidate of findActionCandidates(sections)) {
|
|
53
|
+
if (transitions.length >= MAX_INTERACTIONS) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS);
|
|
58
|
+
if (!transition) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
transitions.push(transition);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return transitions;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function pickDocActionCandidates(research: string): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
|
|
69
|
+
return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
|
|
70
|
+
label: candidate.element.name.trim(),
|
|
71
|
+
role: candidate.role,
|
|
72
|
+
section: candidate.sectionName,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string): Promise<DocStateTransition[]> {
|
|
77
|
+
const transitions: DocStateTransition[] = [];
|
|
78
|
+
|
|
79
|
+
for (const element of tabGroup.elements) {
|
|
80
|
+
const transition = await executeInteraction(
|
|
81
|
+
explorer,
|
|
82
|
+
{
|
|
83
|
+
element,
|
|
84
|
+
container: tabGroup.container,
|
|
85
|
+
role: 'tab',
|
|
86
|
+
sectionName: tabGroup.sectionName,
|
|
87
|
+
},
|
|
88
|
+
restoreUrl,
|
|
89
|
+
TAB_WAIT_MS
|
|
90
|
+
);
|
|
91
|
+
if (!transition) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
transitions.push(transition);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
|
|
99
|
+
return transitions;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number): Promise<DocStateTransition | null> {
|
|
103
|
+
const beforeState = explorer.getStateManager().getCurrentState();
|
|
104
|
+
if (!beforeState) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const executed = await attemptInteraction(explorer, candidate);
|
|
109
|
+
if (!executed) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await wait(waitMs);
|
|
114
|
+
|
|
115
|
+
const afterState = explorer.getStateManager().getCurrentState();
|
|
116
|
+
if (!afterState) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ariaChanges = countAriaChanges(beforeState.ariaSnapshot || '', afterState.ariaSnapshot || '');
|
|
121
|
+
const urlChanged = beforeState.url !== afterState.url;
|
|
122
|
+
const transition = buildTransition(candidate, beforeState, afterState, {
|
|
123
|
+
urlChanged,
|
|
124
|
+
newElements: ariaChanges.newCount,
|
|
125
|
+
removedElements: ariaChanges.removedCount,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (urlChanged) {
|
|
129
|
+
await restoreInteractionState(explorer, restoreUrl);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return transition;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
|
|
136
|
+
const action = explorer.createAction();
|
|
137
|
+
|
|
138
|
+
for (const command of buildClickCommands(candidate.element, candidate.container)) {
|
|
139
|
+
const success = await action.attempt(command, buildPurpose(candidate), false);
|
|
140
|
+
if (success) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
|
|
149
|
+
if (primaryCommand) {
|
|
150
|
+
const action = explorer.createAction();
|
|
151
|
+
const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`, false);
|
|
152
|
+
if (restored) {
|
|
153
|
+
await wait(TAB_WAIT_MS);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const action = explorer.createAction();
|
|
159
|
+
await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`, false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildTransition(candidate: InteractionCandidate, beforeState: WebPageState, afterState: WebPageState, changes: InteractionChanges): DocStateTransition {
|
|
163
|
+
const transition: DocStateTransition = {
|
|
164
|
+
action: describeAction(candidate),
|
|
165
|
+
before: summarizeInteractiveState(beforeState),
|
|
166
|
+
after: summarizeInteractiveState(afterState),
|
|
167
|
+
discoveredUrls: collectLinks(afterState).map((link) => link.url),
|
|
168
|
+
newCapabilities: collectDiscoveryNotes(afterState, changes),
|
|
169
|
+
element: buildInteractionElement(candidate),
|
|
170
|
+
changes,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (changes.urlChanged) {
|
|
174
|
+
transition.targetUrl = afterState.url;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return transition;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildInteractionElement(candidate: InteractionCandidate): InteractionElement {
|
|
181
|
+
const element: InteractionElement = {
|
|
182
|
+
role: candidate.role,
|
|
183
|
+
name: candidate.element.name.trim(),
|
|
184
|
+
section: candidate.sectionName,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (candidate.container) {
|
|
188
|
+
element.container = candidate.container;
|
|
189
|
+
}
|
|
190
|
+
if (candidate.element.css || candidate.element.xpath) {
|
|
191
|
+
element.locator = candidate.element.css || candidate.element.xpath || undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return element;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function collectDiscoveryNotes(state: WebPageState, changes: InteractionChanges): string[] {
|
|
198
|
+
const notes: string[] = [];
|
|
199
|
+
const headings = collectHeadings(state);
|
|
200
|
+
const links = collectLinks(state);
|
|
201
|
+
|
|
202
|
+
if (changes.urlChanged) {
|
|
203
|
+
notes.push('URL changed after interaction');
|
|
204
|
+
}
|
|
205
|
+
if (changes.newElements > 0) {
|
|
206
|
+
notes.push(`ARIA snapshot gained ${changes.newElements} elements`);
|
|
207
|
+
}
|
|
208
|
+
if (changes.removedElements > 0) {
|
|
209
|
+
notes.push(`ARIA snapshot removed ${changes.removedElements} elements`);
|
|
210
|
+
}
|
|
211
|
+
if (headings.length > 0) {
|
|
212
|
+
notes.push(`Visible headings after interaction: ${headings.slice(0, 3).join(' | ')}`);
|
|
213
|
+
}
|
|
214
|
+
if (links.length > 0) {
|
|
215
|
+
notes.push(`Visible links after interaction: ${Math.min(links.length, MAX_LINKS)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return notes;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function findTabGroup(sections: ReturnType<typeof parseResearchSections>): { elements: ResearchElement[]; container?: string; sectionName: string } | null {
|
|
222
|
+
for (const section of sections) {
|
|
223
|
+
const sectionName = section.name.toLowerCase();
|
|
224
|
+
const container = section.containerCss?.toLowerCase() || '';
|
|
225
|
+
if (isOverlaySection(sectionName, container)) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const elements = section.elements.filter((element) => getElementRole(element) === 'tab');
|
|
230
|
+
if (elements.length < 2 || elements.length > 6) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
elements,
|
|
236
|
+
container: section.containerCss || undefined,
|
|
237
|
+
sectionName: section.name,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function findActionCandidates(sections: ReturnType<typeof parseResearchSections>): InteractionCandidate[] {
|
|
245
|
+
const candidates: InteractionCandidate[] = [];
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
const navigationLabels = collectNavigationLabels(sections);
|
|
248
|
+
|
|
249
|
+
for (const section of sections) {
|
|
250
|
+
const sectionName = section.name.toLowerCase();
|
|
251
|
+
const container = section.containerCss?.toLowerCase() || '';
|
|
252
|
+
if (isOverlaySection(sectionName, container)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (isNavigationSection(sectionName)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const element of section.elements) {
|
|
260
|
+
const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
|
|
261
|
+
if (!candidate) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const key = `${candidate.role}:${normalizeCandidateLabel(candidate.element.name)}`;
|
|
266
|
+
if (seen.has(key)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
seen.add(key);
|
|
271
|
+
candidates.push(candidate);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>): InteractionCandidate | null {
|
|
279
|
+
const role = getElementRole(element);
|
|
280
|
+
if (role !== 'link' && role !== 'button' && role !== 'tab') {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (!hasUsableName(element)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
element,
|
|
295
|
+
container: container || undefined,
|
|
296
|
+
role,
|
|
297
|
+
sectionName,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildClickCommands(element: ResearchElement, container?: string): string[] {
|
|
302
|
+
const commands: string[] = [];
|
|
303
|
+
|
|
304
|
+
if (element.css) {
|
|
305
|
+
if (container && !element.css.startsWith(container)) {
|
|
306
|
+
commands.push(`I.click(${JSON.stringify(element.css)}, ${JSON.stringify(container)})`);
|
|
307
|
+
}
|
|
308
|
+
commands.push(`I.click(${JSON.stringify(element.css)})`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (element.aria) {
|
|
312
|
+
if (container) {
|
|
313
|
+
commands.push(`I.click(${JSON.stringify(element.aria)}, ${JSON.stringify(container)})`);
|
|
314
|
+
}
|
|
315
|
+
commands.push(`I.click(${JSON.stringify(element.aria)})`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const xpath = buildXPathLocator(element);
|
|
319
|
+
if (xpath) {
|
|
320
|
+
commands.push(`I.click(${JSON.stringify(xpath)})`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return [...new Set(commands)];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function buildPrimaryCommand(element: ResearchElement, container?: string): string | null {
|
|
327
|
+
return buildClickCommands(element, container)[0] || null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildXPathLocator(element: ResearchElement): string | null {
|
|
331
|
+
if (!element.name) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const text = xpathStringLiteral(element.name.trim());
|
|
336
|
+
const role = getElementRole(element);
|
|
337
|
+
if (role === 'link') {
|
|
338
|
+
return `//a[normalize-space()=${text}]`;
|
|
339
|
+
}
|
|
340
|
+
if (role === 'button' || role === 'tab') {
|
|
341
|
+
return `//*[self::button or @role="button" or @role="tab"][normalize-space()=${text}]`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return `//*[normalize-space()=${text}]`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function buildPurpose(candidate: InteractionCandidate): string {
|
|
348
|
+
return `Explore ${candidate.role} ${candidate.element.name.trim()}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function summarizeAria(aria: string): string {
|
|
352
|
+
const lines = aria.split('\n').filter((line) => line.trim());
|
|
353
|
+
if (lines.length === 0) {
|
|
354
|
+
return 'No elements';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const roleCounts: Record<string, number> = {};
|
|
358
|
+
for (const role of lines.map(extractAriaRole).filter((role): role is string => Boolean(role))) {
|
|
359
|
+
roleCounts[role] = (roleCounts[role] || 0) + 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const topRoles = Object.entries(roleCounts)
|
|
363
|
+
.sort((a, b) => b[1] - a[1])
|
|
364
|
+
.slice(0, 5)
|
|
365
|
+
.map(([role, count]) => `${role}:${count}`)
|
|
366
|
+
.join(', ');
|
|
367
|
+
|
|
368
|
+
return `${lines.length} elements (${topRoles})`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function extractAriaRole(line: string): string | null {
|
|
372
|
+
const roleMatch = line.match(/\[role: ([\w-]+)\]/);
|
|
373
|
+
if (roleMatch) {
|
|
374
|
+
return roleMatch[1];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const yamlMatch = line.trim().match(/^- ([\w-]+)(?:\s|$|:)/);
|
|
378
|
+
if (yamlMatch) {
|
|
379
|
+
return yamlMatch[1];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function countAriaChanges(before: string, after: string): { newCount: number; removedCount: number } {
|
|
386
|
+
const beforeLines = new Set(before.split('\n').filter((line) => line.trim()));
|
|
387
|
+
const afterLines = new Set(after.split('\n').filter((line) => line.trim()));
|
|
388
|
+
let newCount = 0;
|
|
389
|
+
let removedCount = 0;
|
|
390
|
+
|
|
391
|
+
for (const line of afterLines) {
|
|
392
|
+
if (!beforeLines.has(line)) {
|
|
393
|
+
newCount++;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const line of beforeLines) {
|
|
398
|
+
if (!afterLines.has(line)) {
|
|
399
|
+
removedCount++;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { newCount, removedCount };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function summarizeInteractiveState(state: WebPageState): string {
|
|
407
|
+
const parts = [summarizeAria(state.ariaSnapshot || '')];
|
|
408
|
+
const headings = collectHeadings(state).slice(0, 3);
|
|
409
|
+
const links = collectLinks(state).slice(0, 3);
|
|
410
|
+
|
|
411
|
+
if (state.url) {
|
|
412
|
+
parts.push(`URL ${state.url}`);
|
|
413
|
+
}
|
|
414
|
+
if (headings.length > 0) {
|
|
415
|
+
parts.push(`Headings: ${headings.map((heading) => limitInlineText(heading, 90)).join(' | ')}`);
|
|
416
|
+
}
|
|
417
|
+
if (links.length > 0) {
|
|
418
|
+
parts.push(`Links: ${links.map((link) => `${link.title} -> ${link.url}`).join('; ')}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return parts.join('. ');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function collectHeadings(state: { h1?: string; h2?: string; h3?: string; h4?: string }): string[] {
|
|
425
|
+
return [state.h1, state.h2, state.h3, state.h4].filter((heading): heading is string => Boolean(heading)).map((heading) => heading.trim());
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function collectLinks(state: { links?: Array<{ title: string; url: string }> }): Array<{ title: string; url: string }> {
|
|
429
|
+
return (state.links || [])
|
|
430
|
+
.filter((link) => link.url)
|
|
431
|
+
.slice(0, MAX_LINKS)
|
|
432
|
+
.map((link) => ({
|
|
433
|
+
title: link.title || link.url,
|
|
434
|
+
url: link.url,
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function describeAction(candidate: InteractionCandidate): string {
|
|
439
|
+
return `Clicked ${candidate.role}: ${candidate.element.name.trim()}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function hasUsableName(element: ResearchElement): boolean {
|
|
443
|
+
const name = element.name.trim();
|
|
444
|
+
if (!name) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
if (name.length < 2) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isNavigationSection(sectionName: string): boolean {
|
|
454
|
+
return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isOverlaySection(sectionName: string, container: string): boolean {
|
|
458
|
+
return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function scoreCandidate(candidate: InteractionCandidate): number {
|
|
462
|
+
let score = 0;
|
|
463
|
+
|
|
464
|
+
if (candidate.role === 'link') {
|
|
465
|
+
score += 50;
|
|
466
|
+
}
|
|
467
|
+
if (candidate.role === 'button') {
|
|
468
|
+
score += 40;
|
|
469
|
+
}
|
|
470
|
+
if (candidate.role === 'tab') {
|
|
471
|
+
score += 30;
|
|
472
|
+
}
|
|
473
|
+
if (candidate.container) {
|
|
474
|
+
score += 10;
|
|
475
|
+
}
|
|
476
|
+
if (candidate.element.css) {
|
|
477
|
+
score += 5;
|
|
478
|
+
}
|
|
479
|
+
if (candidate.element.name.trim().length > 8) {
|
|
480
|
+
score += 5;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return score;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function isShellLocator(locator: string | null | undefined): boolean {
|
|
487
|
+
if (!locator) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function collectNavigationLabels(sections: ReturnType<typeof parseResearchSections>): Set<string> {
|
|
495
|
+
const labels = new Set<string>();
|
|
496
|
+
|
|
497
|
+
for (const section of sections) {
|
|
498
|
+
if (!isNavigationSection(section.name.toLowerCase())) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const element of section.elements) {
|
|
503
|
+
const label = normalizeCandidateLabel(element.name);
|
|
504
|
+
if (!label) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
labels.add(label);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return labels;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function normalizeCandidateLabel(label: string): string {
|
|
515
|
+
return label.trim().toLowerCase();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function limitInlineText(text: string, maxLength: number): string {
|
|
519
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
520
|
+
if (normalized.length <= maxLength) {
|
|
521
|
+
return normalized;
|
|
522
|
+
}
|
|
523
|
+
return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function getElementRole(element: ResearchElement): 'link' | 'button' | 'tab' | string {
|
|
527
|
+
return (element.aria?.role || element.type || '').toLowerCase();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function xpathStringLiteral(value: string): string {
|
|
531
|
+
if (!value.includes("'")) {
|
|
532
|
+
return `'${value}'`;
|
|
533
|
+
}
|
|
534
|
+
if (!value.includes('"')) {
|
|
535
|
+
return `"${value}"`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const parts = value.split("'").map((part) => `'${part}'`);
|
|
539
|
+
return `concat(${parts.join(`, "'", `)})`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function wait(timeout: number): Promise<void> {
|
|
543
|
+
await new Promise((resolve) => setTimeout(resolve, timeout));
|
|
544
|
+
}
|
|
@@ -114,6 +114,7 @@ class DocbotConfigParser {
|
|
|
114
114
|
maxPages: 100,
|
|
115
115
|
output: 'docs',
|
|
116
116
|
screenshot: true,
|
|
117
|
+
interactive: false,
|
|
117
118
|
collapseDynamicPages: true,
|
|
118
119
|
scope: 'site',
|
|
119
120
|
includePaths: [],
|
|
@@ -155,6 +156,7 @@ interface DocbotConfig {
|
|
|
155
156
|
deniedPathSegments?: string[];
|
|
156
157
|
minCanActions?: number;
|
|
157
158
|
minInteractiveElements?: number;
|
|
159
|
+
interactive?: boolean;
|
|
158
160
|
};
|
|
159
161
|
}
|
|
160
162
|
|
|
@@ -41,7 +41,7 @@ class DocBot {
|
|
|
41
41
|
config: this.options.docsConfig,
|
|
42
42
|
path: this.options.path,
|
|
43
43
|
});
|
|
44
|
-
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config);
|
|
44
|
+
this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
|
|
45
45
|
this.ensureDirectory(this.configParser.getOutputDir());
|
|
46
46
|
this.ensureDirectory(this.getPagesDir());
|
|
47
47
|
}
|
|
@@ -128,18 +128,22 @@ class DocBot {
|
|
|
128
128
|
summary: documentation.summary,
|
|
129
129
|
canCount: documentation.can.length,
|
|
130
130
|
mightCount: documentation.might.length,
|
|
131
|
+
interactionCount: (documentation.interactions || []).length,
|
|
131
132
|
canActions: documentation.can.map((item) => item.action),
|
|
132
133
|
mightActions: documentation.might.map((item) => item.action),
|
|
134
|
+
interactionActions: (documentation.interactions || []).map((item) => item.action),
|
|
135
|
+
qualityNotes: documentation.qualityNotes || [],
|
|
133
136
|
filePath,
|
|
134
137
|
});
|
|
135
138
|
documented.add(pageKey);
|
|
136
139
|
|
|
137
|
-
const nextPaths = this.extractNextPaths(state, baseUrl, research);
|
|
140
|
+
const nextPaths = this.extractNextPaths(state, baseUrl, research, documentation);
|
|
141
|
+
const interactionPriorityPaths = new Set(this.extractInteractionPaths(baseUrl, documentation));
|
|
138
142
|
for (const nextPath of nextPaths) {
|
|
139
143
|
if (documented.has(this.getPageKey(nextPath))) {
|
|
140
144
|
continue;
|
|
141
145
|
}
|
|
142
|
-
if (stateManager.hasVisitedState(nextPath)) {
|
|
146
|
+
if (!interactionPriorityPaths.has(nextPath) && stateManager.hasVisitedState(nextPath)) {
|
|
143
147
|
continue;
|
|
144
148
|
}
|
|
145
149
|
this.enqueuePath(nextPath, queue, queued);
|
|
@@ -185,10 +189,18 @@ class DocBot {
|
|
|
185
189
|
return true;
|
|
186
190
|
}
|
|
187
191
|
|
|
188
|
-
private extractNextPaths(state: WebPageState, baseUrl: string, research: string): string[] {
|
|
192
|
+
private extractNextPaths(state: WebPageState, baseUrl: string, research: string, documentation?: PageDocumentation): string[] {
|
|
189
193
|
const paths: string[] = [];
|
|
190
194
|
const seen = new Set<string>();
|
|
191
195
|
|
|
196
|
+
for (const interactionPath of this.extractInteractionPaths(baseUrl, documentation)) {
|
|
197
|
+
if (seen.has(interactionPath)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
seen.add(interactionPath);
|
|
201
|
+
paths.push(interactionPath);
|
|
202
|
+
}
|
|
203
|
+
|
|
192
204
|
for (const link of state.links || []) {
|
|
193
205
|
const nextPath = this.resolveLink(link, baseUrl);
|
|
194
206
|
if (!nextPath) {
|
|
@@ -224,11 +236,58 @@ class DocBot {
|
|
|
224
236
|
return paths;
|
|
225
237
|
}
|
|
226
238
|
|
|
239
|
+
private extractInteractionPaths(baseUrl: string, documentation?: PageDocumentation): string[] {
|
|
240
|
+
const paths: string[] = [];
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
const interactions = documentation?.interactions;
|
|
243
|
+
|
|
244
|
+
for (const interaction of interactions || []) {
|
|
245
|
+
if (interaction.targetUrl) {
|
|
246
|
+
const nextPath = this.resolveRawUrl(interaction.targetUrl, baseUrl);
|
|
247
|
+
if (nextPath && this.isEligibleNextPath(nextPath) && !seen.has(nextPath)) {
|
|
248
|
+
seen.add(nextPath);
|
|
249
|
+
paths.push(nextPath);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const discoveredUrl of interaction.discoveredUrls || []) {
|
|
254
|
+
const discoveredPath = this.resolveRawUrl(discoveredUrl, baseUrl);
|
|
255
|
+
if (!discoveredPath) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!this.isEligibleNextPath(discoveredPath)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (seen.has(discoveredPath)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
seen.add(discoveredPath);
|
|
265
|
+
paths.push(discoveredPath);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return paths;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private isEligibleNextPath(nextPath: string): boolean {
|
|
273
|
+
if (!shouldCrawlDocPath(nextPath, this.config)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (!this.isInScope(nextPath)) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
227
282
|
private resolveLink(link: Link, baseUrl: string): string | null {
|
|
283
|
+
return this.resolveRawUrl(link.url, baseUrl);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private resolveRawUrl(rawUrl: string, baseUrl: string): string | null {
|
|
228
287
|
let resolved: URL;
|
|
229
288
|
|
|
230
289
|
try {
|
|
231
|
-
resolved = new URL(
|
|
290
|
+
resolved = new URL(rawUrl, baseUrl);
|
|
232
291
|
} catch {
|
|
233
292
|
return null;
|
|
234
293
|
}
|