@nitpicker/crawler 0.5.0 → 0.6.1
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/lib/archive/database.d.ts +5 -0
- package/lib/archive/database.js +15 -2
- package/lib/crawler/crawler.js +17 -6
- package/lib/crawler/find-best-matching-scope.d.ts +2 -1
- package/lib/crawler/find-best-matching-scope.js +10 -4
- package/lib/crawler-orchestrator.js +1 -9
- package/lib/normalize-to-array.d.ts +9 -0
- package/lib/normalize-to-array.js +49 -0
- package/package.json +2 -2
|
@@ -176,6 +176,11 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
|
|
|
176
176
|
/**
|
|
177
177
|
* Inserts or updates a crawled page in the database, including its redirect chain,
|
|
178
178
|
* anchors, and images. Optionally creates an HTML snapshot file path entry.
|
|
179
|
+
*
|
|
180
|
+
* Self-redirects (where the source URL equals the destination URL after normalization)
|
|
181
|
+
* are skipped to avoid marking a page as redirected to itself — a situation caused by
|
|
182
|
+
* authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
|
|
183
|
+
* from reports via the `whereNull('redirectDestId')` filter.
|
|
179
184
|
* @param page - The page data to store.
|
|
180
185
|
* @param snapshotDir - The directory for saving HTML snapshots, or null to skip snapshots.
|
|
181
186
|
* @param isTarget - Whether this page is a crawl target.
|
package/lib/archive/database.js
CHANGED
|
@@ -562,10 +562,13 @@ let Database = (() => {
|
|
|
562
562
|
}
|
|
563
563
|
const [{ id: resourceId }] = selected;
|
|
564
564
|
const pageId = await this.#getIdByUrl(pageUrl);
|
|
565
|
-
await this.#instance('resources-referrers')
|
|
565
|
+
await this.#instance('resources-referrers')
|
|
566
|
+
.insert({
|
|
566
567
|
resourceId,
|
|
567
568
|
pageId,
|
|
568
|
-
})
|
|
569
|
+
})
|
|
570
|
+
.onConflict(['resourceId', 'pageId'])
|
|
571
|
+
.ignore();
|
|
569
572
|
}
|
|
570
573
|
/**
|
|
571
574
|
* Stores the crawl configuration in the `info` table.
|
|
@@ -633,6 +636,11 @@ let Database = (() => {
|
|
|
633
636
|
/**
|
|
634
637
|
* Inserts or updates a crawled page in the database, including its redirect chain,
|
|
635
638
|
* anchors, and images. Optionally creates an HTML snapshot file path entry.
|
|
639
|
+
*
|
|
640
|
+
* Self-redirects (where the source URL equals the destination URL after normalization)
|
|
641
|
+
* are skipped to avoid marking a page as redirected to itself — a situation caused by
|
|
642
|
+
* authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
|
|
643
|
+
* from reports via the `whereNull('redirectDestId')` filter.
|
|
636
644
|
* @param page - The page data to store.
|
|
637
645
|
* @param snapshotDir - The directory for saving HTML snapshots, or null to skip snapshots.
|
|
638
646
|
* @param isTarget - Whether this page is a crawl target.
|
|
@@ -654,7 +662,12 @@ let Database = (() => {
|
|
|
654
662
|
...page,
|
|
655
663
|
url: destUrlObject,
|
|
656
664
|
}, isTarget, trx);
|
|
665
|
+
const destUrlNormalized = destUrlObject.withoutHashAndAuth;
|
|
657
666
|
for (const redirect of redirectPaths) {
|
|
667
|
+
if (redirect === destUrlNormalized) {
|
|
668
|
+
dbLog('Skip self-redirect: %s', redirect);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
658
671
|
dbLog('Set redirected url: %s -> %s', redirect, destUrl);
|
|
659
672
|
const redirectId = await this.#getIdByUrl(redirect, undefined, trx);
|
|
660
673
|
await trx('pages')
|
package/lib/crawler/crawler.js
CHANGED
|
@@ -369,6 +369,15 @@ class Crawler extends EventEmitter {
|
|
|
369
369
|
update('Creating page%dots%');
|
|
370
370
|
const page = await browser.newPage();
|
|
371
371
|
await page.setUserAgent(this.#options.userAgent);
|
|
372
|
+
// Defence-in-depth: beholder sets Authorization via setExtraHTTPHeaders,
|
|
373
|
+
// but page.authenticate() handles Chromium-level HTTP auth challenges
|
|
374
|
+
// (401 + WWW-Authenticate) that setExtraHTTPHeaders cannot cover.
|
|
375
|
+
if (url.username && url.password) {
|
|
376
|
+
await page.authenticate({
|
|
377
|
+
username: url.username,
|
|
378
|
+
password: url.password,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
372
381
|
const scraper = new Scraper();
|
|
373
382
|
scraper.on('changePhase', (e) => {
|
|
374
383
|
const msg = formatPhaseLog(e);
|
|
@@ -486,7 +495,7 @@ class Crawler extends EventEmitter {
|
|
|
486
495
|
const metadataOnly = this.#linkList.isMetadataOnly(url.withoutHash);
|
|
487
496
|
const isPredicted = this.#linkList.isPredicted(url.withoutHashAndAuth);
|
|
488
497
|
log('Scraping%dots%');
|
|
489
|
-
const result = await this.#scrapePage(url, log, metadataOnly);
|
|
498
|
+
const result = await this.#scrapePage(url, log, metadataOnly, _index);
|
|
490
499
|
// Discard predicted URLs that failed (404, error, etc.)
|
|
491
500
|
if (isPredicted && shouldDiscardPredicted(result)) {
|
|
492
501
|
handleIgnoreAndSkip(url, this.#linkList, this.#scope, this.#options);
|
|
@@ -563,9 +572,10 @@ class Crawler extends EventEmitter {
|
|
|
563
572
|
* @param url - Target URL to scrape
|
|
564
573
|
* @param update - Callback for progress messages
|
|
565
574
|
* @param metadataOnly - When true, only extract title metadata without full browser scraping
|
|
575
|
+
* @param laneIndex - The dealer lane index, used to create unique countdown IDs
|
|
566
576
|
* @returns The scrape result
|
|
567
577
|
*/
|
|
568
|
-
async #scrapePage(url, update, metadataOnly) {
|
|
578
|
+
async #scrapePage(url, update, metadataOnly, laneIndex) {
|
|
569
579
|
const isExternal = isExternalUrl(url, this.#scope);
|
|
570
580
|
// Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
|
|
571
581
|
if (!url.isHTTP) {
|
|
@@ -575,7 +585,7 @@ class Crawler extends EventEmitter {
|
|
|
575
585
|
update('HEAD request%dots%');
|
|
576
586
|
let headCheckResult;
|
|
577
587
|
try {
|
|
578
|
-
headCheckResult = await this.#sendHeadRequest(url, isExternal, update);
|
|
588
|
+
headCheckResult = await this.#sendHeadRequest(url, isExternal, update, laneIndex);
|
|
579
589
|
}
|
|
580
590
|
catch (error) {
|
|
581
591
|
// Server unreachable — skip browser launch entirely
|
|
@@ -641,14 +651,15 @@ class Crawler extends EventEmitter {
|
|
|
641
651
|
* @param url - Target URL to check
|
|
642
652
|
* @param isExternal - Whether the URL is external to the crawl scope
|
|
643
653
|
* @param update - Callback for progress messages shown in the dealer display
|
|
654
|
+
* @param laneIndex - The dealer lane index, used to create unique countdown IDs
|
|
644
655
|
* @returns Lightweight page data from the HEAD response
|
|
645
656
|
*/
|
|
646
|
-
async #sendHeadRequest(url, isExternal, update) {
|
|
657
|
+
async #sendHeadRequest(url, isExternal, update, laneIndex) {
|
|
647
658
|
return retryCall(() => fetchDestination({ url, isExternal, userAgent: this.#options.userAgent }), {
|
|
648
659
|
retries: this.#options.retry,
|
|
649
660
|
label: 'HEAD request',
|
|
650
661
|
onWait: (determinedInterval, retryCount, label, error) => {
|
|
651
|
-
update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${retryCount},s)%s (retry #${retryCount + 1})`);
|
|
662
|
+
update(`${label}: ${error.message} — %countdown(${determinedInterval},fetchHead_${laneIndex}_${retryCount},s)%s (retry #${retryCount + 1})`);
|
|
652
663
|
},
|
|
653
664
|
onGiveUp: (retryCount, error, label) => {
|
|
654
665
|
update(c.red(`${label}: gave up after ${retryCount} retries — ${error.message}`));
|
|
@@ -700,7 +711,7 @@ function formatPhaseLog(e) {
|
|
|
700
711
|
return 'HEAD request%dots%';
|
|
701
712
|
}
|
|
702
713
|
case 'openPage': {
|
|
703
|
-
return e.message
|
|
714
|
+
return `Opening page%dots% ${e.message}`;
|
|
704
715
|
}
|
|
705
716
|
case 'loadDOMContent': {
|
|
706
717
|
return c.dim('DOM loaded');
|
|
@@ -4,7 +4,8 @@ import type { ExURL } from '@d-zero/shared/parse-url';
|
|
|
4
4
|
*
|
|
5
5
|
* Among all scope URLs sharing the same hostname, returns the one whose
|
|
6
6
|
* path segments are a prefix of the target URL's path segments and which
|
|
7
|
-
* has the greatest depth.
|
|
7
|
+
* has the greatest depth. A root scope (paths: `['']`) matches all paths
|
|
8
|
+
* under the same hostname. Returns `null` if no scope URL matches.
|
|
8
9
|
* @param url - The parsed URL to match against scope URLs.
|
|
9
10
|
* @param scopes - The list of scope URLs to search.
|
|
10
11
|
* @returns The best-matching scope URL, or `null` if none match.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Among all scope URLs sharing the same hostname, returns the one whose
|
|
5
5
|
* path segments are a prefix of the target URL's path segments and which
|
|
6
|
-
* has the greatest depth.
|
|
6
|
+
* has the greatest depth. A root scope (paths: `['']`) matches all paths
|
|
7
|
+
* under the same hostname. Returns `null` if no scope URL matches.
|
|
7
8
|
* @param url - The parsed URL to match against scope URLs.
|
|
8
9
|
* @param scopes - The list of scope URLs to search.
|
|
9
10
|
* @returns The best-matching scope URL, or `null` if none match.
|
|
@@ -26,14 +27,19 @@ export function findBestMatchingScope(url, scopes) {
|
|
|
26
27
|
/**
|
|
27
28
|
* Check whether a target path is equal to or is a descendant of a base path.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* A root base path (`['']`) unconditionally matches any target path.
|
|
31
|
+
* Otherwise, compares path segments element by element — the target path
|
|
32
|
+
* matches if all segments of the base path appear in the same positions
|
|
33
|
+
* at the beginning of the target path.
|
|
32
34
|
* @param targetPaths - The path segments of the URL being checked.
|
|
33
35
|
* @param basePaths - The path segments of the scope URL to match against.
|
|
34
36
|
* @returns `true` if the target path starts with or equals the base path.
|
|
35
37
|
*/
|
|
36
38
|
function isPathMatch(targetPaths, basePaths) {
|
|
39
|
+
// Root scope (paths: ['']) matches all paths under the same hostname
|
|
40
|
+
if (basePaths.length === 1 && basePaths[0] === '') {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
37
43
|
if (targetPaths.length < basePaths.length) {
|
|
38
44
|
return false;
|
|
39
45
|
}
|
|
@@ -7,6 +7,7 @@ import Archive from './archive/archive.js';
|
|
|
7
7
|
import { clearDestinationCache } from './crawler/clear-destination-cache.js';
|
|
8
8
|
import Crawler from './crawler/crawler.js';
|
|
9
9
|
import { crawlerLog, log } from './debug.js';
|
|
10
|
+
import { normalizeToArray } from './normalize-to-array.js';
|
|
10
11
|
import { resolveOutputPath } from './resolve-output-path.js';
|
|
11
12
|
import { cleanObject } from './utils/object/clean-object.js';
|
|
12
13
|
import { WriteQueue } from './write-queue.js';
|
|
@@ -314,12 +315,3 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
314
315
|
return orchestrator;
|
|
315
316
|
}
|
|
316
317
|
}
|
|
317
|
-
/**
|
|
318
|
-
* Normalize an optional parameter that may be a single value, an array,
|
|
319
|
-
* null, or undefined into a guaranteed array.
|
|
320
|
-
* @param param - The parameter to normalize.
|
|
321
|
-
* @returns An array containing the parameter value(s), or an empty array if absent.
|
|
322
|
-
*/
|
|
323
|
-
function normalizeToArray(param) {
|
|
324
|
-
return Array.isArray(param) ? param : param ? [param] : [];
|
|
325
|
-
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an optional parameter that may be a single value, an array,
|
|
3
|
+
* null, or undefined into a guaranteed array.
|
|
4
|
+
* Comma-separated strings are split into individual elements.
|
|
5
|
+
* Commas inside brace expressions (e.g. `{html,php}`) are preserved.
|
|
6
|
+
* @param param - The parameter to normalize.
|
|
7
|
+
* @returns An array containing the parameter value(s), or an empty array if absent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeToArray(param: string | string[] | null | undefined): string[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an optional parameter that may be a single value, an array,
|
|
3
|
+
* null, or undefined into a guaranteed array.
|
|
4
|
+
* Comma-separated strings are split into individual elements.
|
|
5
|
+
* Commas inside brace expressions (e.g. `{html,php}`) are preserved.
|
|
6
|
+
* @param param - The parameter to normalize.
|
|
7
|
+
* @returns An array containing the parameter value(s), or an empty array if absent.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeToArray(param) {
|
|
10
|
+
if (!param)
|
|
11
|
+
return [];
|
|
12
|
+
const arr = Array.isArray(param) ? param : [param];
|
|
13
|
+
return arr.flatMap((item) => splitByTopLevelComma(item));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Split a string by commas that are not inside brace expressions (`{}`).
|
|
17
|
+
* @param input - The string to split.
|
|
18
|
+
* @returns An array of trimmed, non-empty segments.
|
|
19
|
+
*/
|
|
20
|
+
function splitByTopLevelComma(input) {
|
|
21
|
+
const segments = [];
|
|
22
|
+
let current = '';
|
|
23
|
+
let depth = 0;
|
|
24
|
+
for (const ch of input) {
|
|
25
|
+
if (ch === '{') {
|
|
26
|
+
depth++;
|
|
27
|
+
current += ch;
|
|
28
|
+
}
|
|
29
|
+
else if (ch === '}') {
|
|
30
|
+
depth = Math.max(0, depth - 1);
|
|
31
|
+
current += ch;
|
|
32
|
+
}
|
|
33
|
+
else if (ch === ',' && depth === 0) {
|
|
34
|
+
const trimmed = current.trim();
|
|
35
|
+
if (trimmed) {
|
|
36
|
+
segments.push(trimmed);
|
|
37
|
+
}
|
|
38
|
+
current = '';
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
current += ch;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const trimmed = current.trim();
|
|
45
|
+
if (trimmed) {
|
|
46
|
+
segments.push(trimmed);
|
|
47
|
+
}
|
|
48
|
+
return segments;
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/crawler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Web crawler engine with headless browser rendering and archive storage",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"@types/tar": "7.0.87",
|
|
49
49
|
"@types/unzipper": "0.10.11"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "bc3a766e87f91560268097b99048b06341cd59cd"
|
|
52
52
|
}
|