explorbot 0.1.29 → 0.1.30
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/boat/doc-collector/src/ai/documentarian.ts +37 -13
- package/boat/doc-collector/src/ai/tools.ts +60 -20
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +7 -0
- package/boat/doc-collector/src/docbot.ts +23 -5
- package/boat/doc-collector/src/docs-renderer.ts +14 -1
- package/boat/doc-collector/src/screenshots.ts +126 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
- package/dist/boat/doc-collector/src/ai/tools.js +53 -20
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/config.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +19 -4
- package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
- package/dist/boat/doc-collector/src/screenshots.js +90 -0
- package/dist/package.json +1 -1
- package/dist/src/action.js +26 -23
- package/dist/src/ai/historian/codeceptjs.js +3 -2
- package/dist/src/ai/historian/experience.js +48 -6
- package/dist/src/ai/historian/playwright.js +2 -1
- package/dist/src/ai/historian/utils.js +1 -19
- package/dist/src/ai/historian.js +1 -1
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/tester.js +3 -0
- package/dist/src/ai/tools.js +0 -1
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +7 -1
- package/dist/src/explorer.js +30 -27
- package/dist/src/utils/browser-errors.js +5 -0
- package/dist/src/utils/page-readiness.js +48 -0
- package/dist/src/utils/step-analyzer.js +68 -0
- package/package.json +1 -1
- package/src/action.ts +24 -26
- package/src/ai/historian/codeceptjs.ts +3 -2
- package/src/ai/historian/experience.ts +51 -6
- package/src/ai/historian/playwright.ts +2 -1
- package/src/ai/historian/utils.ts +1 -21
- package/src/ai/historian.ts +1 -1
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/tester.ts +3 -0
- package/src/ai/tools.ts +0 -1
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +7 -1
- package/src/explorer.ts +28 -27
- package/src/utils/browser-errors.ts +6 -0
- package/src/utils/page-readiness.ts +59 -0
- package/src/utils/step-analyzer.ts +73 -0
|
@@ -45,7 +45,7 @@ class Documentarian {
|
|
|
45
45
|
try {
|
|
46
46
|
tag('info').log('Starting interactive exploration...');
|
|
47
47
|
|
|
48
|
-
const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research);
|
|
48
|
+
const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config);
|
|
49
49
|
const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
|
|
50
50
|
if (meaningfulInteractions.length > 0) {
|
|
51
51
|
tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
|
|
@@ -240,10 +240,15 @@ class Documentarian {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
private normalizeDocumentation(documentation: PageDocumentation, _state: WebPageState, _research: string): PageDocumentation {
|
|
243
|
-
const
|
|
243
|
+
const normalized = { ...documentation };
|
|
244
|
+
if (!normalized.interactions) {
|
|
245
|
+
normalized.interactions = undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const qualityNotes = this.evaluateDocumentationQuality(normalized);
|
|
244
249
|
|
|
245
250
|
return {
|
|
246
|
-
...
|
|
251
|
+
...normalized,
|
|
247
252
|
qualityNotes,
|
|
248
253
|
};
|
|
249
254
|
}
|
|
@@ -321,36 +326,55 @@ const stateTransitionSchema = z.object({
|
|
|
321
326
|
action: z.string(),
|
|
322
327
|
before: z.string(),
|
|
323
328
|
after: z.string(),
|
|
324
|
-
targetUrl: z.string().
|
|
325
|
-
discoveredUrls: z.array(z.string()).
|
|
326
|
-
newCapabilities: z.array(z.string()).
|
|
329
|
+
targetUrl: z.string().nullable(),
|
|
330
|
+
discoveredUrls: z.array(z.string()).nullable(),
|
|
331
|
+
newCapabilities: z.array(z.string()).nullable(),
|
|
327
332
|
element: z
|
|
328
333
|
.object({
|
|
329
334
|
role: z.string(),
|
|
330
335
|
name: z.string(),
|
|
331
336
|
section: z.string(),
|
|
332
|
-
container: z.string().
|
|
333
|
-
locator: z.string().
|
|
337
|
+
container: z.string().nullable(),
|
|
338
|
+
locator: z.string().nullable(),
|
|
334
339
|
})
|
|
335
|
-
.
|
|
340
|
+
.nullable(),
|
|
336
341
|
changes: z
|
|
337
342
|
.object({
|
|
338
343
|
urlChanged: z.boolean(),
|
|
339
344
|
newElements: z.number(),
|
|
340
345
|
removedElements: z.number(),
|
|
341
346
|
})
|
|
342
|
-
.
|
|
347
|
+
.nullable(),
|
|
343
348
|
});
|
|
344
349
|
|
|
345
350
|
const pageDocumentationSchema = z.object({
|
|
346
351
|
summary: z.string(),
|
|
347
352
|
can: z.array(capabilitySchema),
|
|
348
353
|
might: z.array(capabilitySchema),
|
|
349
|
-
interactions: z.array(stateTransitionSchema).
|
|
354
|
+
interactions: z.array(stateTransitionSchema).nullable(),
|
|
350
355
|
});
|
|
351
356
|
|
|
352
|
-
type StateTransition =
|
|
353
|
-
|
|
357
|
+
type StateTransition = {
|
|
358
|
+
action: string;
|
|
359
|
+
before: string;
|
|
360
|
+
after: string;
|
|
361
|
+
targetUrl?: string | null;
|
|
362
|
+
discoveredUrls?: string[] | null;
|
|
363
|
+
newCapabilities?: string[] | null;
|
|
364
|
+
element?: {
|
|
365
|
+
role: string;
|
|
366
|
+
name: string;
|
|
367
|
+
section: string;
|
|
368
|
+
container?: string | null;
|
|
369
|
+
locator?: string | null;
|
|
370
|
+
} | null;
|
|
371
|
+
changes?: {
|
|
372
|
+
urlChanged: boolean;
|
|
373
|
+
newElements: number;
|
|
374
|
+
removedElements: number;
|
|
375
|
+
} | null;
|
|
376
|
+
};
|
|
377
|
+
type PageDocumentation = Omit<z.infer<typeof pageDocumentationSchema>, 'interactions'> & {
|
|
354
378
|
interactions?: StateTransition[];
|
|
355
379
|
qualityNotes?: string[];
|
|
356
380
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
|
|
2
2
|
import type Explorer from '../../../../src/explorer.ts';
|
|
3
3
|
import type { WebPageState } from '../../../../src/state-manager.ts';
|
|
4
|
+
import type { DocbotConfig } from '../config.ts';
|
|
4
5
|
|
|
5
6
|
export interface DocStateTransition {
|
|
6
7
|
action: string;
|
|
@@ -34,23 +35,25 @@ interface InteractionChanges {
|
|
|
34
35
|
removedElements: number;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
38
|
+
const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
|
|
39
|
+
const DEFAULT_MAX_INTERACTIONS = 5;
|
|
39
40
|
const MAX_LINKS = 15;
|
|
40
41
|
const DEFAULT_WAIT_MS = 700;
|
|
41
42
|
const TAB_WAIT_MS = 500;
|
|
43
|
+
const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
|
|
42
44
|
|
|
43
|
-
export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string): Promise<DocStateTransition[]> {
|
|
45
|
+
export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}): Promise<DocStateTransition[]> {
|
|
44
46
|
const sections = parseResearchSections(research);
|
|
45
47
|
const transitions: DocStateTransition[] = [];
|
|
48
|
+
const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
|
|
46
49
|
const tabGroup = findTabGroup(sections);
|
|
47
50
|
|
|
48
51
|
if (tabGroup) {
|
|
49
|
-
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
|
|
52
|
+
transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions)));
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
for (const candidate of findActionCandidates(sections)) {
|
|
53
|
-
if (transitions.length >=
|
|
55
|
+
for (const candidate of findActionCandidates(sections, config)) {
|
|
56
|
+
if (transitions.length >= maxInteractions) {
|
|
54
57
|
break;
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -65,18 +68,22 @@ export async function collectDocInteractions(explorer: Explorer, state: WebPageS
|
|
|
65
68
|
return transitions;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
export function pickDocActionCandidates(research: string): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
|
|
69
|
-
return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
|
|
71
|
+
export function pickDocActionCandidates(research: string, config: DocbotConfig = {}): Array<{ label: string; role: InteractionCandidate['role']; section: string }> {
|
|
72
|
+
return findActionCandidates(parseResearchSections(research), config).map((candidate) => ({
|
|
70
73
|
label: candidate.element.name.trim(),
|
|
71
74
|
role: candidate.role,
|
|
72
75
|
section: candidate.sectionName,
|
|
73
76
|
}));
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string): Promise<DocStateTransition[]> {
|
|
79
|
+
async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number): Promise<DocStateTransition[]> {
|
|
77
80
|
const transitions: DocStateTransition[] = [];
|
|
78
81
|
|
|
79
82
|
for (const element of tabGroup.elements) {
|
|
83
|
+
if (transitions.length >= maxInteractions) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
const transition = await executeInteraction(
|
|
81
88
|
explorer,
|
|
82
89
|
{
|
|
@@ -241,23 +248,21 @@ function findTabGroup(sections: ReturnType<typeof parseResearchSections>): { ele
|
|
|
241
248
|
return null;
|
|
242
249
|
}
|
|
243
250
|
|
|
244
|
-
function findActionCandidates(sections: ReturnType<typeof parseResearchSections
|
|
251
|
+
function findActionCandidates(sections: ReturnType<typeof parseResearchSections>, config: DocbotConfig): InteractionCandidate[] {
|
|
245
252
|
const candidates: InteractionCandidate[] = [];
|
|
246
253
|
const seen = new Set<string>();
|
|
247
254
|
const navigationLabels = collectNavigationLabels(sections);
|
|
255
|
+
const maxPrimaryCandidates = getPositiveConfigNumber(config.docs?.maxPrimaryCandidates, DEFAULT_MAX_PRIMARY_CANDIDATES);
|
|
248
256
|
|
|
249
257
|
for (const section of sections) {
|
|
250
258
|
const sectionName = section.name.toLowerCase();
|
|
251
259
|
const container = section.containerCss?.toLowerCase() || '';
|
|
252
|
-
if (
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (isNavigationSection(sectionName)) {
|
|
260
|
+
if (isIgnoredSection(sectionName, container)) {
|
|
256
261
|
continue;
|
|
257
262
|
}
|
|
258
263
|
|
|
259
264
|
for (const element of section.elements) {
|
|
260
|
-
const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
|
|
265
|
+
const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels, config);
|
|
261
266
|
if (!candidate) {
|
|
262
267
|
continue;
|
|
263
268
|
}
|
|
@@ -272,10 +277,10 @@ function findActionCandidates(sections: ReturnType<typeof parseResearchSections>
|
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
|
|
275
|
-
return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0,
|
|
280
|
+
return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, maxPrimaryCandidates);
|
|
276
281
|
}
|
|
277
282
|
|
|
278
|
-
function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string
|
|
283
|
+
function toInteractionCandidate(element: ResearchElement, sectionName: string, container: string | null | undefined, navigationLabels: Set<string>, config: DocbotConfig): InteractionCandidate | null {
|
|
279
284
|
const role = getElementRole(element);
|
|
280
285
|
if (role !== 'link' && role !== 'button' && role !== 'tab') {
|
|
281
286
|
return null;
|
|
@@ -283,7 +288,10 @@ function toInteractionCandidate(element: ResearchElement, sectionName: string, c
|
|
|
283
288
|
if (!hasUsableName(element)) {
|
|
284
289
|
return null;
|
|
285
290
|
}
|
|
286
|
-
if (
|
|
291
|
+
if (isPageShellContainer(element.css) || isPageShellContainer(element.xpath)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (isDestructiveAction(element, config)) {
|
|
287
295
|
return null;
|
|
288
296
|
}
|
|
289
297
|
if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
|
|
@@ -454,6 +462,20 @@ function isNavigationSection(sectionName: string): boolean {
|
|
|
454
462
|
return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
|
|
455
463
|
}
|
|
456
464
|
|
|
465
|
+
function isContentControlSection(sectionName: string): boolean {
|
|
466
|
+
return /(content|control|filter|toolbar|action|list|data)/i.test(sectionName);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isIgnoredSection(sectionName: string, container: string): boolean {
|
|
470
|
+
if (isOverlaySection(sectionName, container)) {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (isContentControlSection(sectionName)) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return isNavigationSection(sectionName) || isPageShellContainer(container);
|
|
477
|
+
}
|
|
478
|
+
|
|
457
479
|
function isOverlaySection(sectionName: string, container: string): boolean {
|
|
458
480
|
return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
|
|
459
481
|
}
|
|
@@ -483,12 +505,12 @@ function scoreCandidate(candidate: InteractionCandidate): number {
|
|
|
483
505
|
return score;
|
|
484
506
|
}
|
|
485
507
|
|
|
486
|
-
function
|
|
508
|
+
function isPageShellContainer(locator: string | null | undefined): boolean {
|
|
487
509
|
if (!locator) {
|
|
488
510
|
return false;
|
|
489
511
|
}
|
|
490
512
|
|
|
491
|
-
return /(
|
|
513
|
+
return /(^|[\s>+~,.#\[])(nav|navigation|mainnav|header|menu|breadcrumb|footer)([\s>+~,.#\]_-]|$)/i.test(locator);
|
|
492
514
|
}
|
|
493
515
|
|
|
494
516
|
function collectNavigationLabels(sections: ReturnType<typeof parseResearchSections>): Set<string> {
|
|
@@ -515,6 +537,24 @@ function normalizeCandidateLabel(label: string): string {
|
|
|
515
537
|
return label.trim().toLowerCase();
|
|
516
538
|
}
|
|
517
539
|
|
|
540
|
+
function isDestructiveAction(element: ResearchElement, config: DocbotConfig): boolean {
|
|
541
|
+
const label = normalizeCandidateLabel(element.name);
|
|
542
|
+
const deniedLabels = config.docs?.deniedActionLabels || DEFAULT_DENIED_ACTION_LABELS;
|
|
543
|
+
if (deniedLabels.some((denied) => label.includes(normalizeCandidateLabel(denied)))) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const locator = `${element.css || ''} ${element.xpath || ''}`.toLowerCase();
|
|
548
|
+
return deniedLabels.some((denied) => locator.includes(normalizeCandidateLabel(denied)));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getPositiveConfigNumber(value: number | undefined, fallback: number): number {
|
|
552
|
+
if (!value || value <= 0) {
|
|
553
|
+
return fallback;
|
|
554
|
+
}
|
|
555
|
+
return value;
|
|
556
|
+
}
|
|
557
|
+
|
|
518
558
|
function limitInlineText(text: string, maxLength: number): string {
|
|
519
559
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
520
560
|
if (normalized.length <= maxLength) {
|
|
@@ -101,6 +101,9 @@ export function createDocsCommands(name = 'docs'): Command {
|
|
|
101
101
|
includePaths: [],
|
|
102
102
|
excludePaths: [],
|
|
103
103
|
deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
|
|
104
|
+
deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
|
|
105
|
+
maxPrimaryCandidates: 3,
|
|
106
|
+
maxInteractions: 5,
|
|
104
107
|
minCanActions: 1,
|
|
105
108
|
minInteractiveElements: 3,
|
|
106
109
|
// prompt: 'Add domain-specific documentation guidance here',
|
|
@@ -120,6 +120,9 @@ class DocbotConfigParser {
|
|
|
120
120
|
includePaths: [],
|
|
121
121
|
excludePaths: [],
|
|
122
122
|
deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
|
|
123
|
+
deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
|
|
124
|
+
maxPrimaryCandidates: 3,
|
|
125
|
+
maxInteractions: 5,
|
|
123
126
|
minCanActions: 1,
|
|
124
127
|
minInteractiveElements: 3,
|
|
125
128
|
},
|
|
@@ -154,6 +157,10 @@ interface DocbotConfig {
|
|
|
154
157
|
includePaths?: string[];
|
|
155
158
|
excludePaths?: string[];
|
|
156
159
|
deniedPathSegments?: string[];
|
|
160
|
+
deniedActionLabels?: string[];
|
|
161
|
+
maxPrimaryCandidates?: number;
|
|
162
|
+
maxInteractions?: number;
|
|
163
|
+
maxSectionScreenshots?: number;
|
|
157
164
|
minCanActions?: number;
|
|
158
165
|
minInteractiveElements?: number;
|
|
159
166
|
interactive?: boolean;
|
|
@@ -3,13 +3,14 @@ import path from 'node:path';
|
|
|
3
3
|
import { ExplorBot, type ExplorBotOptions } from '../../../src/explorbot.ts';
|
|
4
4
|
import type { Link, WebPageState } from '../../../src/state-manager.ts';
|
|
5
5
|
import { normalizeUrl } from '../../../src/state-manager.ts';
|
|
6
|
-
import { sanitizeFilename } from '../../../src/utils/strings.ts';
|
|
7
6
|
import { tag } from '../../../src/utils/logger.ts';
|
|
7
|
+
import { sanitizeFilename } from '../../../src/utils/strings.ts';
|
|
8
8
|
import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
|
|
9
9
|
import { type DocbotConfig, DocbotConfigParser } from './config.ts';
|
|
10
|
-
import { type DocumentedPage, renderPageDocumentation, renderSpecIndex
|
|
10
|
+
import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
|
|
11
11
|
import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
|
|
12
12
|
import { extractResearchNavigationTargets } from './research-navigation.ts';
|
|
13
|
+
import { type DocumentationScreenshot, captureDocumentationScreenshots } from './screenshots.ts';
|
|
13
14
|
|
|
14
15
|
class DocBot {
|
|
15
16
|
private explorBot: ExplorBot;
|
|
@@ -120,7 +121,7 @@ class DocBot {
|
|
|
120
121
|
documented.add(pageKey);
|
|
121
122
|
continue;
|
|
122
123
|
}
|
|
123
|
-
const filePath = this.savePageDocumentation(state, documentation);
|
|
124
|
+
const filePath = await this.savePageDocumentation(state, documentation, research);
|
|
124
125
|
|
|
125
126
|
pages.push({
|
|
126
127
|
url: state.url,
|
|
@@ -397,12 +398,25 @@ class DocBot {
|
|
|
397
398
|
return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
|
|
398
399
|
}
|
|
399
400
|
|
|
400
|
-
private savePageDocumentation(state: WebPageState, documentation: PageDocumentation): string {
|
|
401
|
+
private async savePageDocumentation(state: WebPageState, documentation: PageDocumentation, research: string): Promise<string> {
|
|
401
402
|
const pagePath = this.getPageFilePath(state.url);
|
|
402
|
-
|
|
403
|
+
const screenshots = await this.captureScreenshots(state, research, pagePath);
|
|
404
|
+
writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
|
|
403
405
|
return pagePath;
|
|
404
406
|
}
|
|
405
407
|
|
|
408
|
+
private async captureScreenshots(state: WebPageState, research: string, pagePath: string): Promise<DocumentationScreenshot[]> {
|
|
409
|
+
if (!this.shouldUseScreenshots()) {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return captureDocumentationScreenshots(this.explorBot.getExplorer(), state, research, {
|
|
414
|
+
pageFilePath: pagePath,
|
|
415
|
+
screenshotsDir: this.getScreenshotsDir(),
|
|
416
|
+
config: this.config,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
406
420
|
private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
|
|
407
421
|
const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
|
|
408
422
|
writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
|
|
@@ -413,6 +427,10 @@ class DocBot {
|
|
|
413
427
|
return path.join(this.configParser.getOutputDir(), 'pages');
|
|
414
428
|
}
|
|
415
429
|
|
|
430
|
+
private getScreenshotsDir(): string {
|
|
431
|
+
return path.join(this.configParser.getOutputDir(), 'screenshots');
|
|
432
|
+
}
|
|
433
|
+
|
|
416
434
|
private getPageFilePath(pageUrl: string): string {
|
|
417
435
|
const normalized = normalizeUrl(pageUrl || '/');
|
|
418
436
|
const baseName = sanitizeFilename(normalized || 'root');
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
3
3
|
import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
|
|
4
|
+
import type { DocumentationScreenshot } from './screenshots.ts';
|
|
4
5
|
|
|
5
|
-
function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation): string {
|
|
6
|
+
function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
|
|
6
7
|
const lines: string[] = [];
|
|
7
8
|
lines.push(`# ${state.url}`);
|
|
8
9
|
lines.push('');
|
|
@@ -17,6 +18,18 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
|
|
|
17
18
|
lines.push(ensureSentence(documentation.summary));
|
|
18
19
|
lines.push('');
|
|
19
20
|
|
|
21
|
+
if (screenshots.length > 0) {
|
|
22
|
+
lines.push('## Screenshots');
|
|
23
|
+
lines.push('');
|
|
24
|
+
for (const screenshot of screenshots) {
|
|
25
|
+
lines.push(``);
|
|
26
|
+
if (screenshot.selector) {
|
|
27
|
+
lines.push(`Section: \`${screenshot.selector}\``);
|
|
28
|
+
}
|
|
29
|
+
lines.push('');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
const interactions = documentation.interactions;
|
|
21
34
|
if (interactions && interactions.length > 0) {
|
|
22
35
|
lines.push('## State Transitions');
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
|
|
4
|
+
import type Explorer from '../../../src/explorer.ts';
|
|
5
|
+
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
6
|
+
import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
|
|
7
|
+
import type { DocbotConfig } from './config.ts';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
|
|
10
|
+
|
|
11
|
+
export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
|
|
12
|
+
const page = explorer.playwrightHelper?.page;
|
|
13
|
+
if (!page) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
mkdirSync(options.screenshotsDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
const screenshots: DocumentationScreenshot[] = [];
|
|
20
|
+
const pageName = sanitizeFilename(state.url || 'page') || 'page';
|
|
21
|
+
const fullPage = await captureFullPageScreenshot(page, pageName, options);
|
|
22
|
+
if (fullPage) {
|
|
23
|
+
screenshots.push(fullPage);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const maxSections = getMaxSectionScreenshots(options.config);
|
|
27
|
+
for (const section of getScreenshotSections(research).slice(0, maxSections)) {
|
|
28
|
+
const screenshot = await captureSectionScreenshot(page, pageName, section, options);
|
|
29
|
+
if (!screenshot) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
screenshots.push(screenshot);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return screenshots;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getScreenshotSections(research: string): ScreenshotSection[] {
|
|
39
|
+
const sections: ScreenshotSection[] = [];
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const section of parseResearchSections(research)) {
|
|
43
|
+
if (!section.containerCss) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (section.elements.length === 0) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(section.containerCss)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
seen.add(section.containerCss);
|
|
53
|
+
sections.push({
|
|
54
|
+
title: section.name,
|
|
55
|
+
selector: section.containerCss,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return sections;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function captureFullPageScreenshot(page: any, pageName: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
63
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
|
|
64
|
+
try {
|
|
65
|
+
await page.screenshot({ path: filePath, fullPage: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
title: 'Page screenshot',
|
|
72
|
+
path: filePath,
|
|
73
|
+
relativePath: toMarkdownPath(options.pageFilePath, filePath),
|
|
74
|
+
kind: 'page',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function captureSectionScreenshot(page: any, pageName: string, section: ScreenshotSection, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
|
|
79
|
+
const sectionName = sanitizeFilename(section.title) || 'section';
|
|
80
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${sectionName}`, '.png'));
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await page.locator(section.selector).first().screenshot({ path: filePath });
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: section.title,
|
|
90
|
+
path: filePath,
|
|
91
|
+
relativePath: toMarkdownPath(options.pageFilePath, filePath),
|
|
92
|
+
kind: 'section',
|
|
93
|
+
selector: section.selector,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getMaxSectionScreenshots(config: DocbotConfig): number {
|
|
98
|
+
const configured = config.docs?.maxSectionScreenshots;
|
|
99
|
+
if (configured && configured > 0) {
|
|
100
|
+
return configured;
|
|
101
|
+
}
|
|
102
|
+
return DEFAULT_MAX_SECTION_SCREENSHOTS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toMarkdownPath(pageFilePath: string, assetPath: string): string {
|
|
106
|
+
return path.relative(path.dirname(pageFilePath), assetPath).replaceAll('\\', '/');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DocumentationScreenshot {
|
|
110
|
+
title: string;
|
|
111
|
+
path: string;
|
|
112
|
+
relativePath: string;
|
|
113
|
+
kind: 'page' | 'section';
|
|
114
|
+
selector?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface DocumentationScreenshotOptions {
|
|
118
|
+
pageFilePath: string;
|
|
119
|
+
screenshotsDir: string;
|
|
120
|
+
config: DocbotConfig;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ScreenshotSection {
|
|
124
|
+
title: string;
|
|
125
|
+
selector: string;
|
|
126
|
+
}
|
|
@@ -34,7 +34,7 @@ class Documentarian {
|
|
|
34
34
|
async documentWithInteraction(state, research) {
|
|
35
35
|
try {
|
|
36
36
|
tag('info').log('Starting interactive exploration...');
|
|
37
|
-
const deterministicInteractions = await collectDocInteractions(this.explorer, state, research);
|
|
37
|
+
const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config);
|
|
38
38
|
const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
|
|
39
39
|
if (meaningfulInteractions.length > 0) {
|
|
40
40
|
tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
|
|
@@ -210,9 +210,13 @@ class Documentarian {
|
|
|
210
210
|
return message.includes('Failed to generate JSON') || message.includes('Failed to validate JSON') || message.includes('failed_generation') || message.includes('No object generated') || message.includes('response did not match schema');
|
|
211
211
|
}
|
|
212
212
|
normalizeDocumentation(documentation, _state, _research) {
|
|
213
|
-
const
|
|
213
|
+
const normalized = { ...documentation };
|
|
214
|
+
if (!normalized.interactions) {
|
|
215
|
+
normalized.interactions = undefined;
|
|
216
|
+
}
|
|
217
|
+
const qualityNotes = this.evaluateDocumentationQuality(normalized);
|
|
214
218
|
return {
|
|
215
|
-
...
|
|
219
|
+
...normalized,
|
|
216
220
|
qualityNotes,
|
|
217
221
|
};
|
|
218
222
|
}
|
|
@@ -277,30 +281,30 @@ const stateTransitionSchema = z.object({
|
|
|
277
281
|
action: z.string(),
|
|
278
282
|
before: z.string(),
|
|
279
283
|
after: z.string(),
|
|
280
|
-
targetUrl: z.string().
|
|
281
|
-
discoveredUrls: z.array(z.string()).
|
|
282
|
-
newCapabilities: z.array(z.string()).
|
|
284
|
+
targetUrl: z.string().nullable(),
|
|
285
|
+
discoveredUrls: z.array(z.string()).nullable(),
|
|
286
|
+
newCapabilities: z.array(z.string()).nullable(),
|
|
283
287
|
element: z
|
|
284
288
|
.object({
|
|
285
289
|
role: z.string(),
|
|
286
290
|
name: z.string(),
|
|
287
291
|
section: z.string(),
|
|
288
|
-
container: z.string().
|
|
289
|
-
locator: z.string().
|
|
292
|
+
container: z.string().nullable(),
|
|
293
|
+
locator: z.string().nullable(),
|
|
290
294
|
})
|
|
291
|
-
.
|
|
295
|
+
.nullable(),
|
|
292
296
|
changes: z
|
|
293
297
|
.object({
|
|
294
298
|
urlChanged: z.boolean(),
|
|
295
299
|
newElements: z.number(),
|
|
296
300
|
removedElements: z.number(),
|
|
297
301
|
})
|
|
298
|
-
.
|
|
302
|
+
.nullable(),
|
|
299
303
|
});
|
|
300
304
|
const pageDocumentationSchema = z.object({
|
|
301
305
|
summary: z.string(),
|
|
302
306
|
can: z.array(capabilitySchema),
|
|
303
307
|
might: z.array(capabilitySchema),
|
|
304
|
-
interactions: z.array(stateTransitionSchema).
|
|
308
|
+
interactions: z.array(stateTransitionSchema).nullable(),
|
|
305
309
|
});
|
|
306
310
|
export { Documentarian };
|