explorbot 0.5.5 → 0.5.7
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 +15 -4
- package/boat/doc-collector/src/cli.ts +9 -3
- package/boat/doc-collector/src/docbot.ts +11 -2
- package/dist/bin/explorbot-cli.js +17 -5
- package/dist/boat/doc-collector/src/cli.js +9 -3
- package/dist/boat/doc-collector/src/docbot.js +13 -2
- package/dist/models.json +7 -6
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.d.ts +2 -0
- package/dist/src/ai/navigator.js +10 -0
- package/dist/src/ai/pilot.js +9 -2
- package/dist/src/commands/explore-command.d.ts +5 -0
- package/dist/src/commands/explore-command.js +53 -23
- package/dist/src/commands/freesail-command.js +9 -1
- package/dist/src/commands/init-command.js +4 -1
- package/dist/src/commands/recommended-models-command.js +4 -0
- package/dist/src/config.js +6 -0
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +5 -6
- package/dist/src/utils/sitemap.d.ts +11 -0
- package/dist/src/utils/sitemap.js +106 -0
- package/dist/src/utils/spec.d.ts +5 -0
- package/dist/src/utils/spec.js +16 -0
- package/dist/src/utils/url-filter.d.ts +10 -0
- package/dist/src/utils/url-filter.js +52 -0
- package/docs/basics/getting-started.md +2 -1
- package/docs/basics/providers.md +7 -6
- package/docs/workflow/agentic-usage.md +1 -1
- package/models.json +7 -6
- package/package.json +1 -1
- package/src/ai/navigator.ts +13 -1
- package/src/ai/pilot.ts +9 -2
- package/src/commands/explore-command.ts +48 -23
- package/src/commands/freesail-command.ts +10 -1
- package/src/commands/init-command.ts +3 -1
- package/src/commands/recommended-models-command.ts +2 -0
- package/src/config.ts +7 -0
- package/src/test-plan.ts +6 -6
- package/src/utils/sitemap.ts +108 -0
- package/src/utils/spec.ts +21 -0
- package/src/utils/url-filter.ts +53 -0
|
@@ -9,7 +9,10 @@ import { getCliName } from '../utils/cli-name.ts';
|
|
|
9
9
|
import { ErrorPageError, getStateErrorPageError } from '../utils/error-page.ts';
|
|
10
10
|
import { tag } from '../utils/logger.js';
|
|
11
11
|
import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
|
|
12
|
+
import { keepSiteUrls, readSitemap } from '../utils/sitemap.ts';
|
|
13
|
+
import { parseSpecPairs } from '../utils/spec.ts';
|
|
12
14
|
import { safeFilename } from '../utils/strings.ts';
|
|
15
|
+
import { UrlFilter } from '../utils/url-filter.ts';
|
|
13
16
|
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
14
17
|
|
|
15
18
|
const MAX_SUB_PAGE_ATTEMPTS = 30;
|
|
@@ -26,6 +29,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
26
29
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
27
30
|
{ flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
|
|
28
31
|
{ flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
|
|
32
|
+
{ flags: '--sitemap <source>', description: 'Explore every page listed in a sitemap (file path, URL, or - for stdin) instead of discovering sub-pages' },
|
|
33
|
+
{ flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, plus sort:priority|lastmod for a sitemap, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
|
|
29
34
|
];
|
|
30
35
|
suggestions: Suggestion[] = [
|
|
31
36
|
{ command: 'navigate <page>', hint: 'go to another page' },
|
|
@@ -37,6 +42,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
37
42
|
maxDurationMinutes?: number;
|
|
38
43
|
hardDeadlineAt?: number;
|
|
39
44
|
dryRun = false;
|
|
45
|
+
sitemapUrls?: string[];
|
|
46
|
+
urlFilter = new UrlFilter();
|
|
40
47
|
private testsRun = 0;
|
|
41
48
|
private deadlineLogged = false;
|
|
42
49
|
private completedPlans: Plan[] = [];
|
|
@@ -57,6 +64,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
57
64
|
if (opts.maxDuration) {
|
|
58
65
|
this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
|
|
59
66
|
}
|
|
67
|
+
if (opts.urlFilter) this.urlFilter = new UrlFilter(opts.urlFilter as string);
|
|
68
|
+
if (opts.sitemap) this.sitemapUrls = await readSitemap(opts.sitemap as string, this.urlFilter);
|
|
60
69
|
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
61
70
|
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
62
71
|
}
|
|
@@ -76,13 +85,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
76
85
|
Stats.focus ??= feature;
|
|
77
86
|
const mainUrl = this.getCurrentPageUrl();
|
|
78
87
|
const error = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
79
|
-
if (error) {
|
|
88
|
+
if (error && !this.sitemapUrls) {
|
|
80
89
|
tag('warning').log(error.message);
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
try {
|
|
85
|
-
if (
|
|
94
|
+
if (this.sitemapUrls) {
|
|
95
|
+
await this.runSitemapMode(this.sitemapUrls, feature, cfg.styles);
|
|
96
|
+
} else if (cfg.enabled) {
|
|
86
97
|
await this.runReuseMode(mainUrl, feature, cfg);
|
|
87
98
|
} else {
|
|
88
99
|
await this.runFreshMode(mainUrl, feature, cfg.styles);
|
|
@@ -135,6 +146,20 @@ export class ExploreCommand extends BaseCommand {
|
|
|
135
146
|
await this.discoverNewSubPages(mainPlan, mainUrl, styles, new Set());
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
private async runSitemapMode(sitemapUrls: string[], feature: string | undefined, styles?: string[]): Promise<void> {
|
|
150
|
+
const urls = keepSiteUrls(sitemapUrls, this.explorBot.getConfig().playwright.url);
|
|
151
|
+
tag('info').log(`Sitemap: ${urls.length} page(s) to explore`);
|
|
152
|
+
for (const url of urls) {
|
|
153
|
+
if (this.isLimitReached()) break;
|
|
154
|
+
tag('info').log(`Exploring sitemap page: ${url}`);
|
|
155
|
+
try {
|
|
156
|
+
await this.explorePage(url, feature, undefined, styles);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
tag('warning').log(`Sitemap page exploration failed: ${err instanceof Error ? err.message : err}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
private async runReuseMode(mainUrl: string | undefined, feature: string | undefined, cfg: ConfigureSpec): Promise<void> {
|
|
139
164
|
const filename = cfg.fromPath || this.explorBot.generatePlanFilename(feature);
|
|
140
165
|
|
|
@@ -234,6 +259,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
234
259
|
for (const subPlan of subPlans) {
|
|
235
260
|
if (this.isLimitReached()) break;
|
|
236
261
|
if (!subPlan.url) continue;
|
|
262
|
+
if (!this.urlFilter.admit(subPlan.url)) continue;
|
|
237
263
|
try {
|
|
238
264
|
if (!this.dryRun) await this.explorBot.visit(subPlan.url);
|
|
239
265
|
await this.replanAndRun(subPlan.url, undefined, subPlan, cfg.styles);
|
|
@@ -264,27 +290,21 @@ export class ExploreCommand extends BaseCommand {
|
|
|
264
290
|
|
|
265
291
|
const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => {
|
|
266
292
|
const norm = normalizeUrl(c.url);
|
|
267
|
-
return !this.failedSubPages.has(norm) && !knownUrls.has(norm);
|
|
293
|
+
return !this.failedSubPages.has(norm) && !knownUrls.has(norm) && this.urlFilter.allows(c.url);
|
|
268
294
|
});
|
|
269
295
|
if (candidates.length === 0) break;
|
|
270
296
|
|
|
271
297
|
const pick = await planner.pickNextSubPage(candidates);
|
|
272
298
|
if (!pick) break;
|
|
299
|
+
if (!this.urlFilter.admit(pick.url)) break;
|
|
273
300
|
|
|
274
301
|
tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
|
|
275
302
|
try {
|
|
276
|
-
await this.
|
|
277
|
-
|
|
278
|
-
if (errorPage) {
|
|
279
|
-
tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
|
|
303
|
+
const explored = await this.explorePage(pick.url, undefined, mainPlan, styles);
|
|
304
|
+
if (!explored) {
|
|
280
305
|
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
281
306
|
continue;
|
|
282
307
|
}
|
|
283
|
-
await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
|
|
284
|
-
const subPlan = this.explorBot.getCurrentPlan();
|
|
285
|
-
if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
|
|
286
|
-
this.completedPlans.push(subPlan);
|
|
287
|
-
}
|
|
288
308
|
knownUrls.add(normalizeUrl(pick.url));
|
|
289
309
|
} catch (err) {
|
|
290
310
|
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
@@ -293,6 +313,21 @@ export class ExploreCommand extends BaseCommand {
|
|
|
293
313
|
}
|
|
294
314
|
}
|
|
295
315
|
|
|
316
|
+
private async explorePage(url: string, feature: string | undefined, parentPlan: Plan | undefined, styles?: string[]): Promise<boolean> {
|
|
317
|
+
await this.explorBot.visit(url);
|
|
318
|
+
const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
319
|
+
if (errorPage) {
|
|
320
|
+
tag('warning').log(`Skipping page: ${errorPage.message}`);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
await this.runAllStyles(url, feature, parentPlan, this.completedPlans, styles);
|
|
324
|
+
const plan = this.explorBot.getCurrentPlan();
|
|
325
|
+
if (plan?.tests.length && !this.completedPlans.includes(plan)) {
|
|
326
|
+
this.completedPlans.push(plan);
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
296
331
|
private async replanAndRun(pageUrl: string | undefined, feature: string | undefined, existingPlan: Plan, styles?: string[]): Promise<void> {
|
|
297
332
|
const styleList = styles ?? Object.keys(getStyles());
|
|
298
333
|
for (const style of styleList) {
|
|
@@ -369,17 +404,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
369
404
|
const validSubpages = new Set(['none', 'same', 'new', 'both']);
|
|
370
405
|
let hasReuseSignal = false;
|
|
371
406
|
|
|
372
|
-
for (const
|
|
373
|
-
const trimmed = pair.trim();
|
|
374
|
-
if (!trimmed) continue;
|
|
375
|
-
const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
|
|
376
|
-
if (!sepMatch) {
|
|
377
|
-
tag('warning').log(`Ignoring malformed configure pair: ${trimmed}`);
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
const key = sepMatch[1].trim().toLowerCase();
|
|
381
|
-
const value = sepMatch[2].trim();
|
|
382
|
-
|
|
407
|
+
for (const { key, value } of parseSpecPairs(raw)) {
|
|
383
408
|
if (key === 'new') {
|
|
384
409
|
const ratio = parseRatio(value);
|
|
385
410
|
if (ratio == null) {
|
|
@@ -4,6 +4,7 @@ import { normalizeUrl } from '../state-manager.js';
|
|
|
4
4
|
import { Stats } from '../stats.js';
|
|
5
5
|
import { tag } from '../utils/logger.js';
|
|
6
6
|
import { loop } from '../utils/loop.js';
|
|
7
|
+
import { UrlFilter } from '../utils/url-filter.ts';
|
|
7
8
|
import { BaseCommand } from './base-command.js';
|
|
8
9
|
import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
|
|
9
10
|
|
|
@@ -18,6 +19,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
18
19
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
19
20
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
20
21
|
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
22
|
+
{ flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
|
|
21
23
|
];
|
|
22
24
|
|
|
23
25
|
async execute(args: string): Promise<void> {
|
|
@@ -27,6 +29,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
27
29
|
if (opts.deep) strategy = 'deep';
|
|
28
30
|
if (opts.shallow) strategy = 'shallow';
|
|
29
31
|
const scope = opts.scope as string | undefined;
|
|
32
|
+
const urlFilter = new UrlFilter(opts.urlFilter as string | undefined);
|
|
30
33
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
|
|
31
34
|
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
|
|
32
35
|
let hardDeadlineAt: number | undefined;
|
|
@@ -55,6 +58,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
55
58
|
const exploreCmd = new ExploreCommand(this.explorBot);
|
|
56
59
|
if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
|
|
57
60
|
if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
61
|
+
exploreCmd.urlFilter = urlFilter;
|
|
58
62
|
await exploreCmd.execute('');
|
|
59
63
|
|
|
60
64
|
const plan = this.explorBot.getCurrentPlan();
|
|
@@ -65,7 +69,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
65
69
|
|
|
66
70
|
const navigator = this.explorBot.agentNavigator();
|
|
67
71
|
const visitedUrls = stateManager.getAllVisitedUrls();
|
|
68
|
-
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
|
|
72
|
+
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls, urlFilter });
|
|
69
73
|
if (!suggestion) {
|
|
70
74
|
tag('info').log('No navigation suggestion available');
|
|
71
75
|
ctx.stop();
|
|
@@ -76,6 +80,11 @@ export class FreesailCommand extends BaseCommand {
|
|
|
76
80
|
ctx.stop();
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
if (!urlFilter.admit(suggestion.target)) {
|
|
84
|
+
tag('info').log(`URL filter allows no more pages (${suggestion.target} not admitted); stopping exploration`);
|
|
85
|
+
ctx.stop();
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
const currentUrl = stateManager.getCurrentState()?.url;
|
|
80
89
|
if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
|
|
81
90
|
tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
|
|
@@ -260,7 +260,9 @@ export function modelLines(provider: string, only?: ModelRole[]): string {
|
|
|
260
260
|
let selected = roles;
|
|
261
261
|
if (only) selected = roles.filter(([role]) => only.includes(role));
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
const lines = selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`);
|
|
264
|
+
if (!only && recommended.decisionModel) lines.push(` // decision model judging ambiguous outcomes\n decisionModel: { provider: '${provider}', model: '${recommended.decisionModel}' },`);
|
|
265
|
+
return lines.join('\n');
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
function globalConfigTemplate(provider: string): string {
|
|
@@ -52,6 +52,7 @@ export class RecommendedModelsCommand extends BaseCommand {
|
|
|
52
52
|
if (roles[role]) models.push([label, roles[role]]);
|
|
53
53
|
if (!roles[role]) models.push([label, chalk.dim('not served, pair with another provider')]);
|
|
54
54
|
}
|
|
55
|
+
if (roles.decisionModel) models.push(['decisionModel (judge)', roles.decisionModel]);
|
|
55
56
|
lines.push(...renderSection('models', models, 2));
|
|
56
57
|
|
|
57
58
|
const envKey = PROVIDERS[provider]?.envKey;
|
|
@@ -70,6 +71,7 @@ export class RecommendedModelsCommand extends BaseCommand {
|
|
|
70
71
|
env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
|
|
71
72
|
label = '';
|
|
72
73
|
}
|
|
74
|
+
if (roles.decisionModel) env.push(['decision', `EXPLORBOT_DECISION_MODEL=${provider}/${roles.decisionModel}`]);
|
|
73
75
|
lines.push(...renderSection('env variables', env, 2));
|
|
74
76
|
}
|
|
75
77
|
|
package/src/config.ts
CHANGED
|
@@ -420,6 +420,11 @@ export class ConfigParser {
|
|
|
420
420
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
const configuredUrl = loadedConfig?.playwright?.url || loadedConfig?.web?.url || '';
|
|
424
|
+
if (!this.runBase && resolvedPath && !isGlobalConfigPath(resolvedPath) && URL.canParse(configuredUrl)) {
|
|
425
|
+
this.runBase = parseRunBase(configuredUrl);
|
|
426
|
+
}
|
|
427
|
+
|
|
423
428
|
let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
|
|
424
429
|
await resolveConfigModels(config.ai);
|
|
425
430
|
this.site = null;
|
|
@@ -675,6 +680,8 @@ export class ConfigParser {
|
|
|
675
680
|
if (agenticSpec) ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
|
|
676
681
|
if (!agenticSpec && recommended.agenticModel) ai.agenticModel = await resolveModel(provider!, 'agenticModel');
|
|
677
682
|
|
|
683
|
+
if (recommended.decisionModel) ai.decisionModel = { provider: provider!, model: recommended.decisionModel };
|
|
684
|
+
|
|
678
685
|
const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
679
686
|
if (process.env.EXPLORBOT_OUTPUT) dirs.output = '.';
|
|
680
687
|
|
package/src/test-plan.ts
CHANGED
|
@@ -331,10 +331,12 @@ export class Test extends Task {
|
|
|
331
331
|
});
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
hasAchieved(expectation: string): boolean {
|
|
335
|
+
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
336
|
+
}
|
|
337
|
+
|
|
334
338
|
hasAchievedAny(): boolean {
|
|
335
|
-
return this.expected.some((expectation) =>
|
|
336
|
-
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
337
|
-
});
|
|
339
|
+
return this.expected.some((expectation) => this.hasAchieved(expectation));
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
getRunResult(): 'success' | 'partial' | 'failed' {
|
|
@@ -344,9 +346,7 @@ export class Test extends Task {
|
|
|
344
346
|
}
|
|
345
347
|
|
|
346
348
|
hasAchievedAll(): boolean {
|
|
347
|
-
return this.expected.every((expectation) =>
|
|
348
|
-
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
349
|
-
});
|
|
349
|
+
return this.expected.every((expectation) => this.hasAchieved(expectation));
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
isComplete(): boolean {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { tag } from './logger.js';
|
|
3
|
+
import type { UrlFilter } from './url-filter.ts';
|
|
4
|
+
|
|
5
|
+
const ENTRY_PATTERN = /<(url|sitemap)\b[^>]*>(.*?)<\/\1>/gis;
|
|
6
|
+
const XML_ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
7
|
+
const MAX_NESTED_SITEMAPS = 50;
|
|
8
|
+
const DEFAULT_PRIORITY = 0.5;
|
|
9
|
+
const STDIN_SOURCE = '-';
|
|
10
|
+
|
|
11
|
+
export async function readSitemap(source: string, filter?: UrlFilter): Promise<string[]> {
|
|
12
|
+
const entries: SitemapEntry[] = [];
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
const pending = [source];
|
|
15
|
+
let fetched = 0;
|
|
16
|
+
|
|
17
|
+
while (pending.length > 0) {
|
|
18
|
+
if (fetched >= MAX_NESTED_SITEMAPS) {
|
|
19
|
+
tag('warning').log(`Sitemap index lists more than ${MAX_NESTED_SITEMAPS} sitemaps, the rest are ignored`);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
const next = pending.shift()!;
|
|
23
|
+
fetched++;
|
|
24
|
+
const content = await readSource(next);
|
|
25
|
+
const isIndex = content.includes('<sitemapindex');
|
|
26
|
+
for (const entry of extractEntries(content)) {
|
|
27
|
+
if (seen.has(entry.url)) continue;
|
|
28
|
+
seen.add(entry.url);
|
|
29
|
+
if (isIndex) {
|
|
30
|
+
pending.push(entry.url);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
entries.push(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return applyFilter(entries, filter).map((entry) => entry.url);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function readCliSitemap(path: string | undefined, source: string | undefined, filter?: UrlFilter): Promise<string[] | undefined> {
|
|
41
|
+
const usage = 'Pass a page path, --sitemap <source>, or pipe a sitemap to stdin';
|
|
42
|
+
let resolved = source;
|
|
43
|
+
if (!resolved && !path && !process.stdin.isTTY) resolved = STDIN_SOURCE;
|
|
44
|
+
if (!resolved && !path) throw new Error(usage);
|
|
45
|
+
if (!resolved) return undefined;
|
|
46
|
+
const urls = await readSitemap(resolved, filter);
|
|
47
|
+
if (urls.length === 0 && !source) throw new Error(usage);
|
|
48
|
+
if (urls.length === 0) throw new Error(`No URLs left in sitemap ${resolved}`);
|
|
49
|
+
return urls;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function keepSiteUrls(urls: string[], siteUrl: string): string[] {
|
|
53
|
+
const siteOrigin = URL.parse(siteUrl)?.origin;
|
|
54
|
+
const kept = urls.filter((url) => url.startsWith('/') || URL.parse(url)?.origin === siteOrigin);
|
|
55
|
+
if (kept.length < urls.length) tag('warning').log(`Skipped ${urls.length - kept.length} sitemap URL(s) outside ${siteOrigin}`);
|
|
56
|
+
return kept;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function extractEntries(content: string): SitemapEntry[] {
|
|
60
|
+
if (!content.trimStart().startsWith('<')) {
|
|
61
|
+
return content
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.filter((line) => line.startsWith('http://') || line.startsWith('https://') || line.startsWith('/'))
|
|
65
|
+
.map((url) => ({ url, priority: DEFAULT_PRIORITY }));
|
|
66
|
+
}
|
|
67
|
+
const entries: SitemapEntry[] = [];
|
|
68
|
+
for (const match of content.matchAll(ENTRY_PATTERN)) {
|
|
69
|
+
const url = tagValue(match[2], 'loc');
|
|
70
|
+
if (!url) continue;
|
|
71
|
+
const entry: SitemapEntry = { url, priority: DEFAULT_PRIORITY };
|
|
72
|
+
const priority = Number.parseFloat(tagValue(match[2], 'priority') || '');
|
|
73
|
+
if (!Number.isNaN(priority)) entry.priority = priority;
|
|
74
|
+
const lastmod = Date.parse(tagValue(match[2], 'lastmod') || '');
|
|
75
|
+
if (!Number.isNaN(lastmod)) entry.lastmod = lastmod;
|
|
76
|
+
entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function applyFilter(entries: SitemapEntry[], filter: UrlFilter | undefined): SitemapEntry[] {
|
|
82
|
+
if (!filter) return entries;
|
|
83
|
+
let result = entries.filter((entry) => filter.allows(entry.url));
|
|
84
|
+
if (filter.sort === 'priority') result = result.toSorted((a, b) => b.priority - a.priority);
|
|
85
|
+
if (filter.sort === 'lastmod') result = result.toSorted((a, b) => (b.lastmod ?? 0) - (a.lastmod ?? 0));
|
|
86
|
+
return result.slice(0, filter.limit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tagValue(block: string, name: string): string | undefined {
|
|
90
|
+
const match = block.match(new RegExp(`<${name}>\\s*(?:<!\\[CDATA\\[)?\\s*(.*?)\\s*(?:\\]\\]>)?\\s*</${name}>`, 'is'));
|
|
91
|
+
return match?.[1].replace(/&(amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]) || undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readSource(source: string): Promise<string> {
|
|
95
|
+
if (source === STDIN_SOURCE) return readFileSync(0, 'utf8');
|
|
96
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
97
|
+
const response = await fetch(source);
|
|
98
|
+
if (!response.ok) throw new Error(`Sitemap ${source} returned HTTP ${response.status}`);
|
|
99
|
+
return await response.text();
|
|
100
|
+
}
|
|
101
|
+
return readFileSync(source, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SitemapEntry {
|
|
105
|
+
url: string;
|
|
106
|
+
priority: number;
|
|
107
|
+
lastmod?: number;
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { tag } from './logger.js';
|
|
2
|
+
|
|
3
|
+
export function parseSpecPairs(raw: string | undefined): SpecPair[] {
|
|
4
|
+
const pairs: SpecPair[] = [];
|
|
5
|
+
for (const pair of (raw || '').split(';')) {
|
|
6
|
+
const trimmed = pair.trim();
|
|
7
|
+
if (!trimmed) continue;
|
|
8
|
+
const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
|
|
9
|
+
if (!sepMatch) {
|
|
10
|
+
tag('warning').log(`Ignoring malformed spec pair: ${trimmed}`);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
pairs.push({ key: sepMatch[1].trim().toLowerCase(), value: sepMatch[2].trim() });
|
|
14
|
+
}
|
|
15
|
+
return pairs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpecPair {
|
|
19
|
+
key: string;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { tag } from './logger.js';
|
|
2
|
+
import { parseSpecPairs } from './spec.ts';
|
|
3
|
+
import { matchesUrl } from './url-matcher.ts';
|
|
4
|
+
|
|
5
|
+
export class UrlFilter {
|
|
6
|
+
readonly include: string[] = [];
|
|
7
|
+
readonly exclude: string[] = [];
|
|
8
|
+
sort?: 'priority' | 'lastmod';
|
|
9
|
+
limit = Number.POSITIVE_INFINITY;
|
|
10
|
+
private admitted = new Set<string>();
|
|
11
|
+
|
|
12
|
+
constructor(spec?: string) {
|
|
13
|
+
for (const { key, value } of parseSpecPairs(spec)) {
|
|
14
|
+
if (key === 'filter') {
|
|
15
|
+
this.include.push(value);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (key === 'exclude') {
|
|
19
|
+
this.exclude.push(value);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (key === 'sort' && (value === 'priority' || value === 'lastmod')) {
|
|
23
|
+
this.sort = value;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (key === 'limit' && Number.parseInt(value, 10) > 0) {
|
|
27
|
+
this.limit = Number.parseInt(value, 10);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
tag('warning').log(`Ignoring URL filter pair ${key}:${value} (use filter|exclude:<url pattern>, sort:priority|lastmod, limit:<n>)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
allows(url: string): boolean {
|
|
35
|
+
const path = urlPath(url);
|
|
36
|
+
if (this.include.length > 0 && !this.include.some((pattern) => matchesUrl(pattern, path))) return false;
|
|
37
|
+
return !this.exclude.some((pattern) => matchesUrl(pattern, path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
admit(url: string): boolean {
|
|
41
|
+
if (!this.allows(url)) return false;
|
|
42
|
+
const path = urlPath(url);
|
|
43
|
+
if (this.admitted.has(path)) return true;
|
|
44
|
+
if (this.admitted.size >= this.limit) return false;
|
|
45
|
+
this.admitted.add(path);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function urlPath(url: string): string {
|
|
51
|
+
const parsed = new URL(url, 'http://localhost');
|
|
52
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
53
|
+
}
|