@eightmedia/flightcheck 1.0.1 → 1.2.0
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/CHANGELOG.md +15 -0
- package/dist/analyze/structuredReport.d.ts +2 -0
- package/dist/analyze/structuredReport.d.ts.map +1 -1
- package/dist/analyze/structuredReport.js +33 -15
- package/dist/checks/agent-ready.d.ts.map +1 -1
- package/dist/checks/agent-ready.js +8 -8
- package/dist/checks/crawl.d.ts.map +1 -1
- package/dist/checks/crawl.js +11 -0
- package/dist/checks/dns.d.ts.map +1 -1
- package/dist/checks/dns.js +2 -2
- package/dist/checks/issues.d.ts +3 -1
- package/dist/checks/issues.d.ts.map +1 -1
- package/dist/checks/issues.js +2 -2
- package/dist/checks/redirect.d.ts.map +1 -1
- package/dist/checks/redirect.js +12 -12
- package/dist/checks/robots-txt.d.ts.map +1 -1
- package/dist/checks/robots-txt.js +6 -6
- package/dist/checks/security-headers.d.ts.map +1 -1
- package/dist/checks/security-headers.js +6 -6
- package/dist/checks/sitemap.d.ts.map +1 -1
- package/dist/checks/sitemap.js +6 -6
- package/dist/cli.js +1 -1
- package/dist/crawler/crawleeStorage.d.ts +18 -0
- package/dist/crawler/crawleeStorage.d.ts.map +1 -0
- package/dist/crawler/crawleeStorage.js +24 -0
- package/dist/crawler/defaults.d.ts +2 -1
- package/dist/crawler/defaults.d.ts.map +1 -1
- package/dist/crawler/defaults.js +2 -1
- package/dist/crawler/runCrawler.d.ts.map +1 -1
- package/dist/crawler/runCrawler.js +112 -84
- package/dist/crawler/smartPageSelection.d.ts +77 -0
- package/dist/crawler/smartPageSelection.d.ts.map +1 -0
- package/dist/crawler/smartPageSelection.js +308 -0
- package/docs/agent-ready.md +2 -2
- package/docs/analyze.md +5 -3
- package/docs/crawler.md +18 -2
- package/docs/index.md +1 -0
- package/package.json +1 -1
package/dist/crawler/defaults.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** Default crawl budget; smart selection prefers root/list pages within this cap. */
|
|
2
|
+
export const DEFAULT_MAX_PAGES = 100;
|
|
2
3
|
export const DEFAULT_DELAY_MS = 500;
|
|
3
4
|
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
4
5
|
export const DEFAULT_CONCURRENCY = 1;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runCrawler.d.ts","sourceRoot":"","sources":["../../src/crawler/runCrawler.ts"],"names":[],"mappings":"AAUA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"runCrawler.d.ts","sourceRoot":"","sources":["../../src/crawler/runCrawler.ts"],"names":[],"mappings":"AAUA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAQjE,OAAO,KAAK,EAAiB,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAMrE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,YAAY,CAAC,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAC/B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,CAAC,CAkM7H"}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { CheerioCrawler, RequestQueue, log, LogLevel } from "crawlee";
|
|
1
|
+
import { CheerioCrawler, Configuration, MemoryStorage, RequestQueue, log, LogLevel } from "crawlee";
|
|
2
2
|
import pino from "pino";
|
|
3
3
|
import { DEFAULT_BURST_DELAY_MS, DEFAULT_CONCURRENCY, DEFAULT_DELAY_MS, DEFAULT_MAX_PAGES, DEFAULT_TIMEOUT_MS, DEFAULT_USER_AGENT, } from "./defaults.js";
|
|
4
4
|
import { CHECK_GROUPS } from "./checkGroups.js";
|
|
5
|
+
import { cleanupRunStorageDir, createRunStoragePath } from "./crawleeStorage.js";
|
|
5
6
|
import { extractInternalLinks, extractVerificationRecord } from "./extractors.js";
|
|
6
7
|
import { detectStackFromHtml } from "./stack.js";
|
|
7
8
|
import { extractSameOriginScripts } from "./jsAssets.js";
|
|
8
9
|
import { loadRobotsPolicy } from "./robots.js";
|
|
9
10
|
import { writeReportToFile } from "./reporter.js";
|
|
10
11
|
import { scanText } from "./secrets.js";
|
|
12
|
+
import { createDiscoveryGate } from "./smartPageSelection.js";
|
|
11
13
|
import { matchesExcludePattern, normalizeUrl, sleep } from "./urlUtils.js";
|
|
12
14
|
const MAX_JS_ASSET_BYTES = 5_000_000; // 5 MB — skip larger bundles to bound memory
|
|
13
15
|
export async function runCrawler(options) {
|
|
@@ -81,92 +83,118 @@ export async function runCrawler(options) {
|
|
|
81
83
|
return { secrets, scannedAssets };
|
|
82
84
|
};
|
|
83
85
|
const robotsPolicy = respectRobotsTxt ? await loadRobotsPolicy(origin, timeoutMs) : null;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
let processedSinceBurst = 0;
|
|
94
|
-
let interrupted = false;
|
|
95
|
-
const persist = () => {
|
|
96
|
-
writeReportToFile(records, options.outputPath);
|
|
97
|
-
return records;
|
|
98
|
-
};
|
|
99
|
-
process.on("SIGINT", async () => {
|
|
100
|
-
interrupted = true;
|
|
101
|
-
logger.info("[crawl] SIGINT ontvangen, graceful stop gestart");
|
|
86
|
+
// Per-run Crawlee storage so concurrent crawls do not share/purge each other,
|
|
87
|
+
// then remove this run's subdirectory when finished.
|
|
88
|
+
const { runDir, runId } = createRunStoragePath({ seed: startUrl });
|
|
89
|
+
const configuration = new Configuration({
|
|
90
|
+
storageClient: new MemoryStorage({ localDataDirectory: runDir }),
|
|
91
|
+
defaultRequestQueueId: runId,
|
|
92
|
+
purgeOnStart: true,
|
|
102
93
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await sleep(delayMs);
|
|
94
|
+
try {
|
|
95
|
+
const requestQueue = await RequestQueue.open(runId, { config: configuration });
|
|
96
|
+
const records = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const discovery = createDiscoveryGate(seedUrls, maxPages);
|
|
99
|
+
// Seeds are already smart-ordered (home/root/list before details); FIFO preserves that.
|
|
100
|
+
for (const seed of seedUrls) {
|
|
101
|
+
if (!matchesExcludePattern(seed, excludePatterns)) {
|
|
102
|
+
const normalized = normalizeUrl(seed);
|
|
103
|
+
await requestQueue.addRequest({ url: seed, uniqueKey: normalized });
|
|
104
|
+
seen.add(normalized);
|
|
105
|
+
discovery.remember(normalized);
|
|
116
106
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
107
|
+
}
|
|
108
|
+
let processedSinceBurst = 0;
|
|
109
|
+
let interrupted = false;
|
|
110
|
+
const persist = () => {
|
|
111
|
+
writeReportToFile(records, options.outputPath);
|
|
112
|
+
return records;
|
|
113
|
+
};
|
|
114
|
+
process.on("SIGINT", async () => {
|
|
115
|
+
interrupted = true;
|
|
116
|
+
logger.info("[crawl] SIGINT ontvangen, graceful stop gestart");
|
|
117
|
+
});
|
|
118
|
+
logger.info(`[crawl] start url=${startUrl} output=${options.outputPath} maxPages=${maxPages} concurrency=${concurrency} delayMs=${delayMs} burstDelayMs=${burstDelayMs} timeoutMs=${timeoutMs} seeds=${seedUrls.length} respectRobotsTxt=${respectRobotsTxt} storage=${runDir}`);
|
|
119
|
+
const crawler = new CheerioCrawler({
|
|
120
|
+
requestQueue,
|
|
121
|
+
maxRequestsPerCrawl: maxPages,
|
|
122
|
+
maxConcurrency: concurrency,
|
|
123
|
+
// +60 s headroom when security scanning is active: the handler does sequential JS fetches
|
|
124
|
+
// per page; the failedAssets/jsScanCache ensures dead/cached assets aren't re-fetched.
|
|
125
|
+
requestHandlerTimeoutSecs: Math.ceil(timeoutMs / 1000) + (activeGroups.has("security") ? 60 : 0),
|
|
126
|
+
requestHandler: async ({ request, body, contentType, response }) => {
|
|
127
|
+
if (interrupted)
|
|
128
|
+
return;
|
|
129
|
+
if (delayMs > 0) {
|
|
130
|
+
await sleep(delayMs);
|
|
131
|
+
}
|
|
132
|
+
if (burstDelayMs > 0 && processedSinceBurst > 0 && processedSinceBurst % concurrency === 0) {
|
|
133
|
+
await sleep(burstDelayMs);
|
|
134
|
+
}
|
|
135
|
+
const currentUrl = normalizeUrl(request.loadedUrl ?? request.url);
|
|
136
|
+
if (robotsPolicy && !robotsPolicy.isAllowed(currentUrl)) {
|
|
137
|
+
const skipped = extractVerificationRecord("", currentUrl, null, "");
|
|
138
|
+
skipped.note = "Skipped by robots.txt policy";
|
|
139
|
+
records.push(skipped);
|
|
140
|
+
const currentRecords = persist();
|
|
141
|
+
processedSinceBurst += 1;
|
|
142
|
+
logger.info(`[crawl] processed=${currentRecords.length} url=${currentUrl} status=robots-blocked`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const html = typeof body === "string" ? body : "";
|
|
146
|
+
const status = response?.statusCode ?? null;
|
|
147
|
+
const effectiveContentType = contentType?.type ?? "";
|
|
148
|
+
const record = extractVerificationRecord(html, currentUrl, status, effectiveContentType, activeGroups);
|
|
149
|
+
if (activeGroups.has("stack") && effectiveContentType.includes("text/html") && html) {
|
|
150
|
+
record.stack = { detections: detectStackFromHtml(html, currentUrl) };
|
|
151
|
+
}
|
|
152
|
+
if (activeGroups.has("security") && effectiveContentType.includes("text/html") && html) {
|
|
153
|
+
record.security = await scanSecurity(html, currentUrl);
|
|
154
|
+
}
|
|
155
|
+
records.push(record);
|
|
125
156
|
const currentRecords = persist();
|
|
126
157
|
processedSinceBurst += 1;
|
|
127
|
-
logger.info(`[crawl] processed=${currentRecords.length} url=${currentUrl} status
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const links = extractInternalLinks(html, currentUrl, origin);
|
|
146
|
-
for (const link of links) {
|
|
147
|
-
const normalized = normalizeUrl(link);
|
|
148
|
-
if (seen.has(normalized))
|
|
149
|
-
continue;
|
|
150
|
-
if (matchesExcludePattern(normalized, excludePatterns))
|
|
151
|
-
continue;
|
|
152
|
-
if (robotsPolicy && !robotsPolicy.isAllowed(normalized))
|
|
153
|
-
continue;
|
|
154
|
-
seen.add(normalized);
|
|
155
|
-
await requestQueue.addRequest({ url: normalized, uniqueKey: normalized });
|
|
158
|
+
logger.info(`[crawl] processed=${currentRecords.length} url=${currentUrl} status=${status ?? "error"}`);
|
|
159
|
+
if (effectiveContentType.includes("text/html")) {
|
|
160
|
+
const links = extractInternalLinks(html, currentUrl, origin);
|
|
161
|
+
for (const link of links) {
|
|
162
|
+
const normalized = normalizeUrl(link);
|
|
163
|
+
if (seen.has(normalized))
|
|
164
|
+
continue;
|
|
165
|
+
if (matchesExcludePattern(normalized, excludePatterns))
|
|
166
|
+
continue;
|
|
167
|
+
if (robotsPolicy && !robotsPolicy.isAllowed(normalized))
|
|
168
|
+
continue;
|
|
169
|
+
if (!discovery.allowEnqueue(normalized))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(normalized);
|
|
172
|
+
discovery.remember(normalized);
|
|
173
|
+
const forefront = discovery.isStructural(normalized);
|
|
174
|
+
await requestQueue.addRequest({ url: normalized, uniqueKey: normalized }, { forefront });
|
|
175
|
+
}
|
|
156
176
|
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
},
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
},
|
|
178
|
+
failedRequestHandler: async ({ request }, error) => {
|
|
179
|
+
const currentUrl = normalizeUrl(request.loadedUrl ?? request.url);
|
|
180
|
+
const record = extractVerificationRecord("", currentUrl, null, "");
|
|
181
|
+
record.error = error instanceof Error ? error.message : "Unknown error";
|
|
182
|
+
records.push(record);
|
|
183
|
+
const currentRecords = persist();
|
|
184
|
+
logger.info(`[crawl] processed=${currentRecords.length} url=${currentUrl} status=error`);
|
|
185
|
+
},
|
|
186
|
+
}, configuration);
|
|
187
|
+
await crawler.run();
|
|
188
|
+
const finalRecords = persist();
|
|
189
|
+
logger.info(`[crawl] klaar pages=${finalRecords.length} interrupted=${interrupted}`);
|
|
190
|
+
return { records: finalRecords, interrupted };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
try {
|
|
194
|
+
await cleanupRunStorageDir(runDir);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
logger.warn(`[crawl] kon Crawlee storage niet opruimen path=${runDir} error=${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
172
200
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart crawl URL selection based on URL-tree shape (not slug names).
|
|
3
|
+
*
|
|
4
|
+
* A "list" parent is a path with many direct children that are mostly leaves
|
|
5
|
+
* (no further subpages) — typical for archives like news/events/blog, whatever
|
|
6
|
+
* they are named. Within --max-pages we keep structural pages and sample
|
|
7
|
+
* leaves under each list parent.
|
|
8
|
+
*/
|
|
9
|
+
export type UrlKind = "home" | "root" | "list" | "detail" | "other";
|
|
10
|
+
export type ClassifiedUrl = {
|
|
11
|
+
url: string;
|
|
12
|
+
kind: UrlKind;
|
|
13
|
+
/** Pathname of the list parent when kind is list/detail, e.g. "/stories". */
|
|
14
|
+
section: string | null;
|
|
15
|
+
/** Sort priority (higher = sooner). */
|
|
16
|
+
priority: number;
|
|
17
|
+
};
|
|
18
|
+
export type UrlTreeAnalysis = {
|
|
19
|
+
/** Pathnames that look like list indexes (many leaf children). */
|
|
20
|
+
listParents: ReadonlySet<string>;
|
|
21
|
+
/** Origin used to rebuild URLs from pathnames when needed. */
|
|
22
|
+
origin: string;
|
|
23
|
+
};
|
|
24
|
+
/** Minimum direct leaf children before a path is treated as a list parent. */
|
|
25
|
+
export declare const DEFAULT_LIST_MIN_CHILDREN = 8;
|
|
26
|
+
/** Share of direct children that must be leaves to count as a list. */
|
|
27
|
+
export declare const DEFAULT_LIST_LEAF_RATIO = 0.7;
|
|
28
|
+
export declare function pathSegments(urlOrPath: string): string[];
|
|
29
|
+
export declare function pathnameOf(urlString: string): string;
|
|
30
|
+
/** Parent pathname: `/a/b` → `/a`, `/a` → `/`, `/` → null. */
|
|
31
|
+
export declare function parentPathname(pathname: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Build parent → direct children maps from a URL set and detect list parents:
|
|
34
|
+
* paths with many children that are mostly leaves (no further children in the set).
|
|
35
|
+
*/
|
|
36
|
+
export declare function analyzeUrlTree(urls: string[], options?: {
|
|
37
|
+
startUrl?: string;
|
|
38
|
+
listMinChildren?: number;
|
|
39
|
+
listLeafRatio?: number;
|
|
40
|
+
}): UrlTreeAnalysis;
|
|
41
|
+
/** Closest list-parent pathname for this path, or null. */
|
|
42
|
+
export declare function listParentForPathname(pathname: string, listParents: ReadonlySet<string>): string | null;
|
|
43
|
+
export declare function classifyCrawlUrl(urlString: string, tree: UrlTreeAnalysis): ClassifiedUrl;
|
|
44
|
+
export declare function crawlRequestPriority(urlString: string, tree: UrlTreeAnalysis): number;
|
|
45
|
+
/**
|
|
46
|
+
* Soft per-list-parent cap for detail URLs discovered during a crawl.
|
|
47
|
+
* Structural pages (home/root/list) are never capped this way.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detailCapPerSection(maxPages: number, listParentCount?: number): number;
|
|
50
|
+
/**
|
|
51
|
+
* Pick up to `maxPages` URLs using URL-tree shape: keep home/root/list pages,
|
|
52
|
+
* then round-robin a sample of leaf details under each detected list parent.
|
|
53
|
+
*/
|
|
54
|
+
export declare function selectSmartCrawlUrls(urls: string[], maxPages: number, options?: {
|
|
55
|
+
startUrl?: string;
|
|
56
|
+
listMinChildren?: number;
|
|
57
|
+
listLeafRatio?: number;
|
|
58
|
+
}): {
|
|
59
|
+
urls: string[];
|
|
60
|
+
tree: UrlTreeAnalysis;
|
|
61
|
+
};
|
|
62
|
+
/** Sort URLs by crawl priority (desc), stable for equal priority. */
|
|
63
|
+
export declare function sortByCrawlPriority(urls: string[], tree: UrlTreeAnalysis): string[];
|
|
64
|
+
/**
|
|
65
|
+
* Tracks list parents during a live crawl: uses seed analysis up front, and
|
|
66
|
+
* promotes parents to "list" when enough leaf-like children are observed.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createDiscoveryGate(seedUrls: string[], maxPages: number, options?: {
|
|
69
|
+
listMinChildren?: number;
|
|
70
|
+
listLeafRatio?: number;
|
|
71
|
+
}): {
|
|
72
|
+
tree: UrlTreeAnalysis;
|
|
73
|
+
allowEnqueue: (url: string) => boolean;
|
|
74
|
+
remember: (url: string) => void;
|
|
75
|
+
isStructural: (url: string) => boolean;
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=smartPageSelection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"smartPageSelection.d.ts","sourceRoot":"","sources":["../../src/crawler/smartPageSelection.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,OAAO,CAAC;IACd,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,kEAAkE;IAClE,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C,uEAAuE;AACvE,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAU3C,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAOxD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQpD;AAED,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK9D;AA2BD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA,eAAe,CA2CjB;AAED,2DAA2D;AAC3D,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B,MAAM,GAAG,IAAI,CAOf;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,aAAa,CAsBxF;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,CAErF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,eAAe,SAAI,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EAAE,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,eAAe,CAAA;CAAE,CAmE3C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,EAAE,CAKnF;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAAE,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7D;IACD,IAAI,EAAE,eAAe,CAAC;IACtB,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACxC,CAqEA"}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart crawl URL selection based on URL-tree shape (not slug names).
|
|
3
|
+
*
|
|
4
|
+
* A "list" parent is a path with many direct children that are mostly leaves
|
|
5
|
+
* (no further subpages) — typical for archives like news/events/blog, whatever
|
|
6
|
+
* they are named. Within --max-pages we keep structural pages and sample
|
|
7
|
+
* leaves under each list parent.
|
|
8
|
+
*/
|
|
9
|
+
/** Minimum direct leaf children before a path is treated as a list parent. */
|
|
10
|
+
export const DEFAULT_LIST_MIN_CHILDREN = 8;
|
|
11
|
+
/** Share of direct children that must be leaves to count as a list. */
|
|
12
|
+
export const DEFAULT_LIST_LEAF_RATIO = 0.7;
|
|
13
|
+
const PRIORITY = {
|
|
14
|
+
home: 100,
|
|
15
|
+
root: 80,
|
|
16
|
+
list: 60,
|
|
17
|
+
detail: 40,
|
|
18
|
+
other: 20,
|
|
19
|
+
};
|
|
20
|
+
export function pathSegments(urlOrPath) {
|
|
21
|
+
try {
|
|
22
|
+
const pathname = urlOrPath.includes("://") ? new URL(urlOrPath).pathname : urlOrPath;
|
|
23
|
+
return pathname.split("/").filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function pathnameOf(urlString) {
|
|
30
|
+
try {
|
|
31
|
+
const pathname = new URL(urlString).pathname;
|
|
32
|
+
if (!pathname || pathname === "")
|
|
33
|
+
return "/";
|
|
34
|
+
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "/";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Parent pathname: `/a/b` → `/a`, `/a` → `/`, `/` → null. */
|
|
41
|
+
export function parentPathname(pathname) {
|
|
42
|
+
if (pathname === "/" || pathname === "")
|
|
43
|
+
return null;
|
|
44
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
45
|
+
if (segments.length <= 1)
|
|
46
|
+
return "/";
|
|
47
|
+
return `/${segments.slice(0, -1).join("/")}`;
|
|
48
|
+
}
|
|
49
|
+
function originOf(urls, startUrl) {
|
|
50
|
+
const candidate = startUrl ?? urls[0];
|
|
51
|
+
if (!candidate)
|
|
52
|
+
return "https://example.com";
|
|
53
|
+
try {
|
|
54
|
+
return new URL(candidate).origin;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "https://example.com";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function uniqueUrls(urls, startUrl) {
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const unique = [];
|
|
63
|
+
if (startUrl) {
|
|
64
|
+
unique.push(startUrl);
|
|
65
|
+
seen.add(startUrl);
|
|
66
|
+
}
|
|
67
|
+
for (const url of urls) {
|
|
68
|
+
if (seen.has(url))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(url);
|
|
71
|
+
unique.push(url);
|
|
72
|
+
}
|
|
73
|
+
return unique;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build parent → direct children maps from a URL set and detect list parents:
|
|
77
|
+
* paths with many children that are mostly leaves (no further children in the set).
|
|
78
|
+
*/
|
|
79
|
+
export function analyzeUrlTree(urls, options) {
|
|
80
|
+
const minChildren = options?.listMinChildren ?? DEFAULT_LIST_MIN_CHILDREN;
|
|
81
|
+
const leafRatio = options?.listLeafRatio ?? DEFAULT_LIST_LEAF_RATIO;
|
|
82
|
+
const unique = uniqueUrls(urls, options?.startUrl);
|
|
83
|
+
const origin = originOf(unique, options?.startUrl);
|
|
84
|
+
const childrenOf = new Map();
|
|
85
|
+
const pathnames = new Set();
|
|
86
|
+
for (const url of unique) {
|
|
87
|
+
const pathname = pathnameOf(url);
|
|
88
|
+
pathnames.add(pathname);
|
|
89
|
+
let child = pathname;
|
|
90
|
+
let parent = parentPathname(child);
|
|
91
|
+
while (parent !== null) {
|
|
92
|
+
let bucket = childrenOf.get(parent);
|
|
93
|
+
if (!bucket) {
|
|
94
|
+
bucket = new Set();
|
|
95
|
+
childrenOf.set(parent, bucket);
|
|
96
|
+
}
|
|
97
|
+
bucket.add(child);
|
|
98
|
+
child = parent;
|
|
99
|
+
parent = parentPathname(child);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const isLeaf = (pathname) => {
|
|
103
|
+
const kids = childrenOf.get(pathname);
|
|
104
|
+
return !kids || kids.size === 0;
|
|
105
|
+
};
|
|
106
|
+
const listParents = new Set();
|
|
107
|
+
for (const [parent, children] of childrenOf) {
|
|
108
|
+
if (parent === "/")
|
|
109
|
+
continue; // home fan-out is structural, not an archive list
|
|
110
|
+
const childList = [...children];
|
|
111
|
+
if (childList.length < minChildren)
|
|
112
|
+
continue;
|
|
113
|
+
const leafCount = childList.filter((child) => isLeaf(child)).length;
|
|
114
|
+
if (leafCount >= minChildren && leafCount / childList.length >= leafRatio) {
|
|
115
|
+
listParents.add(parent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { listParents, origin };
|
|
119
|
+
}
|
|
120
|
+
/** Closest list-parent pathname for this path, or null. */
|
|
121
|
+
export function listParentForPathname(pathname, listParents) {
|
|
122
|
+
let current = parentPathname(pathname);
|
|
123
|
+
while (current !== null) {
|
|
124
|
+
if (listParents.has(current))
|
|
125
|
+
return current;
|
|
126
|
+
current = parentPathname(current);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
export function classifyCrawlUrl(urlString, tree) {
|
|
131
|
+
const pathname = pathnameOf(urlString);
|
|
132
|
+
if (pathname === "/") {
|
|
133
|
+
return { url: urlString, kind: "home", section: null, priority: PRIORITY.home };
|
|
134
|
+
}
|
|
135
|
+
if (tree.listParents.has(pathname)) {
|
|
136
|
+
return { url: urlString, kind: "list", section: pathname, priority: PRIORITY.list };
|
|
137
|
+
}
|
|
138
|
+
const listParent = listParentForPathname(pathname, tree.listParents);
|
|
139
|
+
if (listParent) {
|
|
140
|
+
return { url: urlString, kind: "detail", section: listParent, priority: PRIORITY.detail };
|
|
141
|
+
}
|
|
142
|
+
const depth = pathSegments(pathname).length;
|
|
143
|
+
if (depth <= 2) {
|
|
144
|
+
return { url: urlString, kind: "root", section: null, priority: PRIORITY.root - (depth - 1) * 5 };
|
|
145
|
+
}
|
|
146
|
+
return { url: urlString, kind: "other", section: null, priority: PRIORITY.other };
|
|
147
|
+
}
|
|
148
|
+
export function crawlRequestPriority(urlString, tree) {
|
|
149
|
+
return classifyCrawlUrl(urlString, tree).priority;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Soft per-list-parent cap for detail URLs discovered during a crawl.
|
|
153
|
+
* Structural pages (home/root/list) are never capped this way.
|
|
154
|
+
*/
|
|
155
|
+
export function detailCapPerSection(maxPages, listParentCount = 8) {
|
|
156
|
+
const detailBudget = Math.max(8, Math.floor(maxPages * 0.5));
|
|
157
|
+
const sections = Math.max(1, listParentCount);
|
|
158
|
+
return Math.max(2, Math.floor(detailBudget / Math.min(sections, 8)));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Pick up to `maxPages` URLs using URL-tree shape: keep home/root/list pages,
|
|
162
|
+
* then round-robin a sample of leaf details under each detected list parent.
|
|
163
|
+
*/
|
|
164
|
+
export function selectSmartCrawlUrls(urls, maxPages, options) {
|
|
165
|
+
if (!Number.isFinite(maxPages) || maxPages <= 0) {
|
|
166
|
+
return { urls: [], tree: analyzeUrlTree([], options) };
|
|
167
|
+
}
|
|
168
|
+
const limit = Math.floor(maxPages);
|
|
169
|
+
const unique = uniqueUrls(urls, options?.startUrl);
|
|
170
|
+
const tree = analyzeUrlTree(unique, options);
|
|
171
|
+
if (unique.length <= limit) {
|
|
172
|
+
return { urls: sortByCrawlPriority(unique, tree), tree };
|
|
173
|
+
}
|
|
174
|
+
const classified = unique.map((url) => classifyCrawlUrl(url, tree));
|
|
175
|
+
const home = [];
|
|
176
|
+
const roots = [];
|
|
177
|
+
const lists = [];
|
|
178
|
+
const others = [];
|
|
179
|
+
const detailsBySection = new Map();
|
|
180
|
+
for (const item of classified) {
|
|
181
|
+
if (item.kind === "home")
|
|
182
|
+
home.push(item);
|
|
183
|
+
else if (item.kind === "root")
|
|
184
|
+
roots.push(item);
|
|
185
|
+
else if (item.kind === "list")
|
|
186
|
+
lists.push(item);
|
|
187
|
+
else if (item.kind === "detail") {
|
|
188
|
+
const key = item.section ?? "_";
|
|
189
|
+
const bucket = detailsBySection.get(key) ?? [];
|
|
190
|
+
bucket.push(item);
|
|
191
|
+
detailsBySection.set(key, bucket);
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
others.push(item);
|
|
195
|
+
}
|
|
196
|
+
const selected = [];
|
|
197
|
+
const selectedSet = new Set();
|
|
198
|
+
const take = (items) => {
|
|
199
|
+
for (const item of items) {
|
|
200
|
+
if (selected.length >= limit)
|
|
201
|
+
return;
|
|
202
|
+
if (selectedSet.has(item.url))
|
|
203
|
+
continue;
|
|
204
|
+
selected.push(item.url);
|
|
205
|
+
selectedSet.add(item.url);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
take(home);
|
|
209
|
+
take(roots);
|
|
210
|
+
take(lists);
|
|
211
|
+
const sectionQueues = [...detailsBySection.values()].map((bucket) => [...bucket]);
|
|
212
|
+
let progress = true;
|
|
213
|
+
while (selected.length < limit && progress) {
|
|
214
|
+
progress = false;
|
|
215
|
+
for (const queue of sectionQueues) {
|
|
216
|
+
if (selected.length >= limit)
|
|
217
|
+
break;
|
|
218
|
+
while (queue.length > 0) {
|
|
219
|
+
const next = queue.shift();
|
|
220
|
+
if (selectedSet.has(next.url))
|
|
221
|
+
continue;
|
|
222
|
+
selected.push(next.url);
|
|
223
|
+
selectedSet.add(next.url);
|
|
224
|
+
progress = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
take(others);
|
|
230
|
+
return { urls: selected, tree };
|
|
231
|
+
}
|
|
232
|
+
/** Sort URLs by crawl priority (desc), stable for equal priority. */
|
|
233
|
+
export function sortByCrawlPriority(urls, tree) {
|
|
234
|
+
return urls
|
|
235
|
+
.map((url, index) => ({ url, index, priority: crawlRequestPriority(url, tree) }))
|
|
236
|
+
.sort((a, b) => b.priority - a.priority || a.index - b.index)
|
|
237
|
+
.map((row) => row.url);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Tracks list parents during a live crawl: uses seed analysis up front, and
|
|
241
|
+
* promotes parents to "list" when enough leaf-like children are observed.
|
|
242
|
+
*/
|
|
243
|
+
export function createDiscoveryGate(seedUrls, maxPages, options) {
|
|
244
|
+
const minChildren = options?.listMinChildren ?? DEFAULT_LIST_MIN_CHILDREN;
|
|
245
|
+
const leafRatio = options?.listLeafRatio ?? DEFAULT_LIST_LEAF_RATIO;
|
|
246
|
+
const tree = analyzeUrlTree(seedUrls, options);
|
|
247
|
+
const listParents = new Set(tree.listParents);
|
|
248
|
+
const childrenOf = new Map();
|
|
249
|
+
const known = new Set();
|
|
250
|
+
const detailCounts = new Map();
|
|
251
|
+
const register = (url) => {
|
|
252
|
+
const pathname = pathnameOf(url);
|
|
253
|
+
if (known.has(pathname))
|
|
254
|
+
return;
|
|
255
|
+
known.add(pathname);
|
|
256
|
+
let child = pathname;
|
|
257
|
+
let parent = parentPathname(child);
|
|
258
|
+
while (parent !== null) {
|
|
259
|
+
let bucket = childrenOf.get(parent);
|
|
260
|
+
if (!bucket) {
|
|
261
|
+
bucket = new Set();
|
|
262
|
+
childrenOf.set(parent, bucket);
|
|
263
|
+
}
|
|
264
|
+
bucket.add(child);
|
|
265
|
+
if (parent !== "/") {
|
|
266
|
+
const childList = [...bucket];
|
|
267
|
+
if (childList.length >= minChildren) {
|
|
268
|
+
const leafCount = childList.filter((c) => {
|
|
269
|
+
const grand = childrenOf.get(c);
|
|
270
|
+
return !grand || grand.size === 0;
|
|
271
|
+
}).length;
|
|
272
|
+
if (leafCount >= minChildren && leafCount / childList.length >= leafRatio) {
|
|
273
|
+
listParents.add(parent);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
child = parent;
|
|
278
|
+
parent = parentPathname(child);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
for (const seed of seedUrls)
|
|
282
|
+
register(seed);
|
|
283
|
+
const liveTree = () => ({ listParents, origin: tree.origin });
|
|
284
|
+
const capFor = () => detailCapPerSection(maxPages, Math.max(1, listParents.size));
|
|
285
|
+
return {
|
|
286
|
+
get tree() {
|
|
287
|
+
return liveTree();
|
|
288
|
+
},
|
|
289
|
+
allowEnqueue(url) {
|
|
290
|
+
const pathname = pathnameOf(url);
|
|
291
|
+
const parent = listParentForPathname(pathname, listParents);
|
|
292
|
+
if (!parent)
|
|
293
|
+
return true;
|
|
294
|
+
return (detailCounts.get(parent) ?? 0) < capFor();
|
|
295
|
+
},
|
|
296
|
+
remember(url) {
|
|
297
|
+
register(url);
|
|
298
|
+
const classified = classifyCrawlUrl(url, liveTree());
|
|
299
|
+
if (classified.kind !== "detail" || !classified.section)
|
|
300
|
+
return;
|
|
301
|
+
detailCounts.set(classified.section, (detailCounts.get(classified.section) ?? 0) + 1);
|
|
302
|
+
},
|
|
303
|
+
isStructural(url) {
|
|
304
|
+
const kind = classifyCrawlUrl(url, liveTree()).kind;
|
|
305
|
+
return kind === "home" || kind === "root" || kind === "list";
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
package/docs/agent-ready.md
CHANGED
|
@@ -25,8 +25,8 @@ robots.txt existence and sitemaps remain in the existing `robots-txt` and `sitem
|
|
|
25
25
|
- Network runner: `runAgentReadyCheck()` — parallel fetches of robots, llms.txt, homepage
|
|
26
26
|
(HTML + markdown Accept), and well-known endpoints
|
|
27
27
|
- Wave 1 (parallel with crawl / DNS / headers / robots)
|
|
28
|
-
- Report: issues land in `findings[]` (`category: "infra"`) in `*-report.json
|
|
29
|
-
Markdown report lists them under **Findings**
|
|
28
|
+
- Report: issues land in `findings[]` (`category: "infra"`) in `*-report.json` with a
|
|
29
|
+
`fix` suggestion; the Markdown report lists them under **Findings** with a Fix line
|
|
30
30
|
|
|
31
31
|
## CLI
|
|
32
32
|
|