@eightmedia/flightcheck 1.1.0 → 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 +1 -0
- 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/runCrawler.d.ts.map +1 -1
- package/dist/crawler/runCrawler.js +111 -92
- package/docs/crawler.md +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Root directory for ephemeral Crawlee request-queue storage (cwd-relative). */
|
|
2
|
+
export declare const FLIGHTCHECK_STORAGE_ROOT = ".flightcheckStorage";
|
|
3
|
+
/**
|
|
4
|
+
* Create a unique per-run storage path: `.flightcheckStorage/<hash>`.
|
|
5
|
+
* Concurrent runs each get their own subdirectory so cleanup of one does not
|
|
6
|
+
* wipe another run's queue files.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createRunStoragePath(options?: {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
seed?: string;
|
|
11
|
+
}): {
|
|
12
|
+
rootDir: string;
|
|
13
|
+
runDir: string;
|
|
14
|
+
runId: string;
|
|
15
|
+
};
|
|
16
|
+
/** Remove a single run subdirectory. Ignores missing paths. */
|
|
17
|
+
export declare function cleanupRunStorageDir(runDir: string): Promise<void>;
|
|
18
|
+
//# sourceMappingURL=crawleeStorage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawleeStorage.d.ts","sourceRoot":"","sources":["../../src/crawler/crawleeStorage.ts"],"names":[],"mappings":"AAIA,iFAAiF;AACjF,eAAO,MAAM,wBAAwB,wBAAwB,CAAC;AAE9D;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE;IAC7C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASrD;AAED,+DAA+D;AAC/D,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAExE"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Root directory for ephemeral Crawlee request-queue storage (cwd-relative). */
|
|
5
|
+
export const FLIGHTCHECK_STORAGE_ROOT = ".flightcheckStorage";
|
|
6
|
+
/**
|
|
7
|
+
* Create a unique per-run storage path: `.flightcheckStorage/<hash>`.
|
|
8
|
+
* Concurrent runs each get their own subdirectory so cleanup of one does not
|
|
9
|
+
* wipe another run's queue files.
|
|
10
|
+
*/
|
|
11
|
+
export function createRunStoragePath(options) {
|
|
12
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
13
|
+
const runId = createHash("sha256")
|
|
14
|
+
.update(`${options?.seed ?? ""}:${Date.now()}:${process.pid}:${randomBytes(8).toString("hex")}`)
|
|
15
|
+
.digest("hex")
|
|
16
|
+
.slice(0, 16);
|
|
17
|
+
const rootDir = path.join(cwd, FLIGHTCHECK_STORAGE_ROOT);
|
|
18
|
+
const runDir = path.join(rootDir, runId);
|
|
19
|
+
return { rootDir, runDir, runId };
|
|
20
|
+
}
|
|
21
|
+
/** Remove a single run subdirectory. Ignores missing paths. */
|
|
22
|
+
export async function cleanupRunStorageDir(runDir) {
|
|
23
|
+
await rm(runDir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
@@ -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,7 +1,8 @@
|
|
|
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";
|
|
@@ -82,100 +83,118 @@ export async function runCrawler(options) {
|
|
|
82
83
|
return { secrets, scannedAssets };
|
|
83
84
|
};
|
|
84
85
|
const robotsPolicy = respectRobotsTxt ? await loadRobotsPolicy(origin, timeoutMs) : null;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const normalized = normalizeUrl(seed);
|
|
93
|
-
await requestQueue.addRequest({ url: seed, uniqueKey: normalized });
|
|
94
|
-
seen.add(normalized);
|
|
95
|
-
discovery.remember(normalized);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
let processedSinceBurst = 0;
|
|
99
|
-
let interrupted = false;
|
|
100
|
-
const persist = () => {
|
|
101
|
-
writeReportToFile(records, options.outputPath);
|
|
102
|
-
return records;
|
|
103
|
-
};
|
|
104
|
-
process.on("SIGINT", async () => {
|
|
105
|
-
interrupted = true;
|
|
106
|
-
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,
|
|
107
93
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
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);
|
|
121
106
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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);
|
|
130
156
|
const currentRecords = persist();
|
|
131
157
|
processedSinceBurst += 1;
|
|
132
|
-
logger.info(`[crawl] processed=${currentRecords.length} url=${currentUrl} status
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const links = extractInternalLinks(html, currentUrl, origin);
|
|
151
|
-
for (const link of links) {
|
|
152
|
-
const normalized = normalizeUrl(link);
|
|
153
|
-
if (seen.has(normalized))
|
|
154
|
-
continue;
|
|
155
|
-
if (matchesExcludePattern(normalized, excludePatterns))
|
|
156
|
-
continue;
|
|
157
|
-
if (robotsPolicy && !robotsPolicy.isAllowed(normalized))
|
|
158
|
-
continue;
|
|
159
|
-
if (!discovery.allowEnqueue(normalized))
|
|
160
|
-
continue;
|
|
161
|
-
seen.add(normalized);
|
|
162
|
-
discovery.remember(normalized);
|
|
163
|
-
const forefront = discovery.isStructural(normalized);
|
|
164
|
-
await requestQueue.addRequest({ url: normalized, uniqueKey: normalized }, { forefront });
|
|
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
|
+
}
|
|
165
176
|
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
}
|
|
181
200
|
}
|
package/docs/crawler.md
CHANGED
|
@@ -169,3 +169,6 @@ Selection lives in `src/crawler/smartPageSelection.ts`.
|
|
|
169
169
|
- With `--respect-robots-txt`, URLs excluded by `robots.txt` are skipped.
|
|
170
170
|
- After each URL, output is written atomically.
|
|
171
171
|
- On `SIGINT`, the crawl stops gracefully while preserving partial output.
|
|
172
|
+
- Crawlee request-queue files go under `.flightcheckStorage/<run-hash>/` (not the generic
|
|
173
|
+
`storage/` folder). That subdirectory is deleted when the crawl finishes, so a second
|
|
174
|
+
concurrent run keeps its own files until it completes.
|
package/package.json
CHANGED