@nitpicker/crawler 0.7.0 → 0.8.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/lib/archive/archive-lock.d.ts +38 -0
- package/lib/archive/archive-lock.js +147 -0
- package/lib/archive/archive.d.ts +17 -2
- package/lib/archive/archive.js +86 -31
- package/lib/archive/database.d.ts +41 -4
- package/lib/archive/database.js +176 -42
- package/lib/archive/init-schema.js +1 -1
- package/lib/archive/libsql-dialect.d.ts +25 -0
- package/lib/archive/libsql-dialect.js +28 -0
- package/lib/archive/migrate-info-roots.d.ts +15 -0
- package/lib/archive/migrate-info-roots.js +41 -0
- package/lib/archive/types.d.ts +6 -26
- package/lib/crawler/crawler.d.ts +18 -14
- package/lib/crawler/crawler.js +62 -54
- package/lib/crawler/find-scope-entry.d.ts +25 -0
- package/lib/crawler/find-scope-entry.js +45 -0
- package/lib/crawler/handle-scrape-end.js +15 -14
- package/lib/crawler/inject-scope-auth.d.ts +10 -7
- package/lib/crawler/inject-scope-auth.js +13 -14
- package/lib/crawler/is-external-url.d.ts +13 -6
- package/lib/crawler/is-external-url.js +14 -6
- package/lib/crawler/types.d.ts +2 -2
- package/lib/crawler-orchestrator.d.ts +21 -0
- package/lib/crawler-orchestrator.js +131 -10
- package/package.json +3 -3
- package/lib/crawler/find-best-matching-scope.d.ts +0 -13
- package/lib/crawler/find-best-matching-scope.js +0 -52
- package/lib/crawler/is-in-any-lower-layer.d.ts +0 -13
- package/lib/crawler/is-in-any-lower-layer.js +0 -15
package/lib/crawler/crawler.js
CHANGED
|
@@ -11,6 +11,7 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
11
11
|
import { crawlerLog } from '../debug.js';
|
|
12
12
|
import { detectPaginationPattern } from './detect-pagination-pattern.js';
|
|
13
13
|
import { fetchDestination } from './fetch-destination.js';
|
|
14
|
+
import { findScopeEntry } from './find-scope-entry.js';
|
|
14
15
|
import { formatCrawlProgress } from './format-crawl-progress.js';
|
|
15
16
|
import { generatePredictedUrls } from './generate-predicted-urls.js';
|
|
16
17
|
import { handleIgnoreAndSkip } from './handle-ignore-and-skip.js';
|
|
@@ -18,7 +19,6 @@ import { handleResourceResponse } from './handle-resource-response.js';
|
|
|
18
19
|
import { handleScrapeEnd } from './handle-scrape-end.js';
|
|
19
20
|
import { handleScrapeError } from './handle-scrape-error.js';
|
|
20
21
|
import { injectScopeAuth } from './inject-scope-auth.js';
|
|
21
|
-
import { isExternalUrl } from './is-external-url.js';
|
|
22
22
|
import LinkList from './link-list.js';
|
|
23
23
|
import { linkToPageData } from './link-to-page-data.js';
|
|
24
24
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
@@ -77,7 +77,7 @@ class Crawler extends EventEmitter {
|
|
|
77
77
|
captureImages: options?.captureImages ?? true,
|
|
78
78
|
executablePath: options?.executablePath ?? null,
|
|
79
79
|
fetchExternal: options?.fetchExternal ?? true,
|
|
80
|
-
|
|
80
|
+
roots: options?.roots ?? [],
|
|
81
81
|
excludes: options?.excludes || [],
|
|
82
82
|
excludeKeywords: options?.excludeKeywords || [],
|
|
83
83
|
excludeUrls: options?.excludeUrls || [],
|
|
@@ -89,7 +89,7 @@ class Crawler extends EventEmitter {
|
|
|
89
89
|
ignoreRobots: options?.ignoreRobots ?? false,
|
|
90
90
|
};
|
|
91
91
|
this.#robotsChecker = new RobotsChecker(this.#options.userAgent, !this.#options.ignoreRobots);
|
|
92
|
-
for (const urlStr of this.#options.
|
|
92
|
+
for (const urlStr of this.#options.roots) {
|
|
93
93
|
const url = parseUrl(urlStr, this.#options);
|
|
94
94
|
if (url) {
|
|
95
95
|
const existing = this.#scope.get(url.hostname) || [];
|
|
@@ -135,21 +135,56 @@ class Crawler extends EventEmitter {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
/**
|
|
138
|
-
* Start crawling from
|
|
138
|
+
* Start crawling from one or more root URLs.
|
|
139
139
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
140
|
+
* Each URL is registered as a scope entry (if not already present) and added
|
|
141
|
+
* to the link list. When `opts.recursive` is `false`, recursion is disabled
|
|
142
|
+
* and the crawler behaves like the former `startMultiple` (list mode);
|
|
143
|
+
* otherwise discovered child pages within the scope are followed.
|
|
144
|
+
*
|
|
145
|
+
* When resume state is present, the resumed pending URLs are merged with the
|
|
146
|
+
* newly-provided roots. The merge is deduplicated by protocol-agnostic key
|
|
147
|
+
* before reaching the dealer so a URL that exists in both sources — which
|
|
148
|
+
* is common in append-mode when a new root coincides with a repromoted
|
|
149
|
+
* previously-external page — does not race on two parallel slots.
|
|
150
|
+
* @param urls - The list of root URLs to begin crawling from. Must be non-empty.
|
|
151
|
+
* @param opts - Optional overrides; currently only `recursive` is honoured.
|
|
152
|
+
* @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
|
|
153
|
+
* Defaults to the constructor option's `recursive` value.
|
|
154
|
+
* @throws {Error} If the URL list is empty.
|
|
144
155
|
*/
|
|
145
|
-
start(
|
|
146
|
-
const
|
|
147
|
-
if (!
|
|
148
|
-
|
|
156
|
+
start(urls, opts) {
|
|
157
|
+
const root = urls[0];
|
|
158
|
+
if (!root) {
|
|
159
|
+
throw new Error('urls is empty');
|
|
160
|
+
}
|
|
161
|
+
for (const url of urls) {
|
|
162
|
+
const existing = this.#scope.get(url.hostname) || [];
|
|
163
|
+
if (!existing.some((u) => u.href === url.href)) {
|
|
164
|
+
this.#scope.set(url.hostname, [...existing, url]);
|
|
165
|
+
}
|
|
166
|
+
this.#linkList.add(url);
|
|
167
|
+
}
|
|
168
|
+
const recursive = opts?.recursive ?? this.#options.recursive;
|
|
169
|
+
if (!recursive) {
|
|
170
|
+
this.#options.recursive = false;
|
|
171
|
+
this.#options.fromList = true;
|
|
149
172
|
}
|
|
150
|
-
this.#linkList.add(url);
|
|
151
173
|
const isResuming = this.#resumedScraped.length > 0;
|
|
152
|
-
|
|
174
|
+
// Dedupe by the same protocol-agnostic key the dealer uses internally.
|
|
175
|
+
// Append-mode in particular can put the same URL into both
|
|
176
|
+
// `#resumedPending` (via `repromoteExternalPages`) and `urls` (the
|
|
177
|
+
// new root); without this dedupe both copies would grab a parallel
|
|
178
|
+
// slot and race on the same URL.
|
|
179
|
+
const seenInitial = new Set();
|
|
180
|
+
const initialUrls = [];
|
|
181
|
+
for (const url of isResuming ? [...this.#resumedPending, ...urls] : urls) {
|
|
182
|
+
const key = protocolAgnosticKey(url.withoutHashAndAuth);
|
|
183
|
+
if (seenInitial.has(key))
|
|
184
|
+
continue;
|
|
185
|
+
seenInitial.add(key);
|
|
186
|
+
initialUrls.push(url);
|
|
187
|
+
}
|
|
153
188
|
const resumeOffset = this.#resumedScraped.length;
|
|
154
189
|
if (initialUrls.length === 0) {
|
|
155
190
|
crawlerLog('Crawl End (nothing to resume)');
|
|
@@ -158,38 +193,7 @@ class Crawler extends EventEmitter {
|
|
|
158
193
|
}
|
|
159
194
|
void this.#runDeal(initialUrls, resumeOffset).catch((error) => {
|
|
160
195
|
crawlerLog('runDeal error: %O', error);
|
|
161
|
-
this.#emitDealErrors(error,
|
|
162
|
-
void this.emit('crawlEnd', {});
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Start crawling a pre-defined list of URLs in non-recursive mode.
|
|
167
|
-
*
|
|
168
|
-
* Each URL in the list is added to the scope and the link list. Recursive
|
|
169
|
-
* crawling is disabled; only the provided URLs will be scraped.
|
|
170
|
-
* @param pageList - The list of URLs to crawl. Must contain at least one URL.
|
|
171
|
-
* @throws {Error} If the page list is empty.
|
|
172
|
-
*/
|
|
173
|
-
startMultiple(pageList) {
|
|
174
|
-
if (!pageList[0]) {
|
|
175
|
-
throw new Error('pageList is empty');
|
|
176
|
-
}
|
|
177
|
-
const scopeMap = new Map();
|
|
178
|
-
for (const pageUrl of pageList) {
|
|
179
|
-
const existing = this.#scope.get(pageUrl.hostname) || [];
|
|
180
|
-
const existingHrefs = scopeMap.get(pageUrl.hostname) || new Set(existing.map((u) => u.href));
|
|
181
|
-
if (!existingHrefs.has(pageUrl.href)) {
|
|
182
|
-
this.#scope.set(pageUrl.hostname, [...existing, pageUrl]);
|
|
183
|
-
existingHrefs.add(pageUrl.href);
|
|
184
|
-
}
|
|
185
|
-
scopeMap.set(pageUrl.hostname, existingHrefs);
|
|
186
|
-
this.#linkList.add(pageUrl);
|
|
187
|
-
}
|
|
188
|
-
this.#options.recursive = false;
|
|
189
|
-
this.#options.fromList = true;
|
|
190
|
-
void this.#runDeal(pageList).catch((error) => {
|
|
191
|
-
crawlerLog('runDeal error: %O', error);
|
|
192
|
-
this.#emitDealErrors(error, pageList[0].href);
|
|
196
|
+
this.#emitDealErrors(error, root.href);
|
|
193
197
|
void this.emit('crawlEnd', {});
|
|
194
198
|
});
|
|
195
199
|
}
|
|
@@ -260,7 +264,8 @@ class Crawler extends EventEmitter {
|
|
|
260
264
|
if (!paginationState || !concurrency)
|
|
261
265
|
return;
|
|
262
266
|
// metadataOnly / external: update tracking but skip pattern detection
|
|
263
|
-
if (opts?.metadataOnly ||
|
|
267
|
+
if (opts?.metadataOnly ||
|
|
268
|
+
findScopeEntry(newUrl, this.#scope, this.#options) === null) {
|
|
264
269
|
paginationState.lastPushedUrl = newUrl.withoutHashAndAuth;
|
|
265
270
|
paginationState.lastPushedWasPredicted = false;
|
|
266
271
|
return;
|
|
@@ -301,7 +306,7 @@ class Crawler extends EventEmitter {
|
|
|
301
306
|
void this.emit('skip', {
|
|
302
307
|
url: result.ignored.url.href,
|
|
303
308
|
reason: JSON.stringify(result.ignored),
|
|
304
|
-
isExternal:
|
|
309
|
+
isExternal: findScopeEntry(result.ignored.url, this.#scope, this.#options) === null,
|
|
305
310
|
});
|
|
306
311
|
break;
|
|
307
312
|
}
|
|
@@ -317,7 +322,7 @@ class Crawler extends EventEmitter {
|
|
|
317
322
|
shutdown: result.error.shutdown,
|
|
318
323
|
pid: undefined,
|
|
319
324
|
}, this.#linkList, this.#scope, this.#options);
|
|
320
|
-
const isExternal =
|
|
325
|
+
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
321
326
|
if (pageResult) {
|
|
322
327
|
if (pageResult.isExternal) {
|
|
323
328
|
void this.emit('externalPage', { result: pageResult });
|
|
@@ -436,7 +441,7 @@ class Crawler extends EventEmitter {
|
|
|
436
441
|
const externalDoneUrls = new Set();
|
|
437
442
|
// 初期 URL を分類(onPush を通らないため)
|
|
438
443
|
for (const url of initialUrls) {
|
|
439
|
-
if (
|
|
444
|
+
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
440
445
|
externalUrls.add(protocolAgnosticKey(url.withoutHashAndAuth));
|
|
441
446
|
}
|
|
442
447
|
}
|
|
@@ -449,10 +454,13 @@ class Crawler extends EventEmitter {
|
|
|
449
454
|
lastPushedWasPredicted: false,
|
|
450
455
|
};
|
|
451
456
|
await deal(initialUrls, (url, update, _index, setLineHeader, push) => {
|
|
452
|
-
const
|
|
457
|
+
const matchedScope = findScopeEntry(url, this.#scope, this.#options);
|
|
458
|
+
const isExternal = matchedScope === null;
|
|
453
459
|
const urlText = isExternal ? c.dim(url.href) : c.cyan(url.href);
|
|
454
460
|
setLineHeader(`%braille% ${urlText}: `);
|
|
455
|
-
|
|
461
|
+
if (matchedScope) {
|
|
462
|
+
injectScopeAuth(url, matchedScope);
|
|
463
|
+
}
|
|
456
464
|
this.#linkList.add(url);
|
|
457
465
|
this.#linkList.progress(url);
|
|
458
466
|
return async () => {
|
|
@@ -551,7 +559,7 @@ class Crawler extends EventEmitter {
|
|
|
551
559
|
if (seen.has(key))
|
|
552
560
|
return false;
|
|
553
561
|
seen.add(key);
|
|
554
|
-
if (
|
|
562
|
+
if (findScopeEntry(url, this.#scope, this.#options) === null) {
|
|
555
563
|
externalUrls.add(key);
|
|
556
564
|
}
|
|
557
565
|
return true;
|
|
@@ -576,7 +584,7 @@ class Crawler extends EventEmitter {
|
|
|
576
584
|
* @returns The scrape result
|
|
577
585
|
*/
|
|
578
586
|
async #scrapePage(url, update, metadataOnly, laneIndex) {
|
|
579
|
-
const isExternal =
|
|
587
|
+
const isExternal = findScopeEntry(url, this.#scope, this.#options) === null;
|
|
580
588
|
// Non-HTTP protocols (mailto:, tel:, etc.) — let the scraper handle early return
|
|
581
589
|
if (!url.isHTTP) {
|
|
582
590
|
return this.#launchBrowserAndScrape(url, update, isExternal, metadataOnly);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
2
|
+
/**
|
|
3
|
+
* Find the most-specific scope entry that contains the given URL.
|
|
4
|
+
*
|
|
5
|
+
* A scope entry is an `(hostname, port, path)` triple. The target URL belongs
|
|
6
|
+
* to a scope entry when its hostname AND port match and its path is at the
|
|
7
|
+
* same level or deeper than the scope entry's path. Among all matching entries,
|
|
8
|
+
* the one with the greatest depth wins (e.g. `/blog/2024/` is preferred over
|
|
9
|
+
* `/blog/`). Port comparison uses the WHATWG-normalized `port` field, so
|
|
10
|
+
* default ports (`80` for http, `443` for https) collapse to an empty string
|
|
11
|
+
* and match each other regardless of whether the user wrote them explicitly.
|
|
12
|
+
* A non-default port like `:3000` only matches scope entries that also carry
|
|
13
|
+
* the same port — this prevents credentialed dev sites (`localhost:3000`)
|
|
14
|
+
* from leaking auth into siblings on the same hostname (`localhost:8080`).
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` if no scope entry contains the URL — i.e. the URL is external.
|
|
17
|
+
* This single function replaces the previous trio of `isExternalUrl`,
|
|
18
|
+
* `isInAnyLowerLayer`, and `findBestMatchingScope`, performing a single
|
|
19
|
+
* hostname lookup and a single pass over the scope array.
|
|
20
|
+
* @param url - The target URL to classify.
|
|
21
|
+
* @param scope - Hostname-indexed map of scope URLs.
|
|
22
|
+
* @param options - URL parsing options forwarded to {@link isLowerLayer}.
|
|
23
|
+
* @returns The deepest matching scope URL, or `null` when the URL is external.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findScopeEntry(url: ExURL, scope: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): ExURL | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isLowerLayer } from '@d-zero/shared/is-lower-layer';
|
|
2
|
+
/**
|
|
3
|
+
* Find the most-specific scope entry that contains the given URL.
|
|
4
|
+
*
|
|
5
|
+
* A scope entry is an `(hostname, port, path)` triple. The target URL belongs
|
|
6
|
+
* to a scope entry when its hostname AND port match and its path is at the
|
|
7
|
+
* same level or deeper than the scope entry's path. Among all matching entries,
|
|
8
|
+
* the one with the greatest depth wins (e.g. `/blog/2024/` is preferred over
|
|
9
|
+
* `/blog/`). Port comparison uses the WHATWG-normalized `port` field, so
|
|
10
|
+
* default ports (`80` for http, `443` for https) collapse to an empty string
|
|
11
|
+
* and match each other regardless of whether the user wrote them explicitly.
|
|
12
|
+
* A non-default port like `:3000` only matches scope entries that also carry
|
|
13
|
+
* the same port — this prevents credentialed dev sites (`localhost:3000`)
|
|
14
|
+
* from leaking auth into siblings on the same hostname (`localhost:8080`).
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` if no scope entry contains the URL — i.e. the URL is external.
|
|
17
|
+
* This single function replaces the previous trio of `isExternalUrl`,
|
|
18
|
+
* `isInAnyLowerLayer`, and `findBestMatchingScope`, performing a single
|
|
19
|
+
* hostname lookup and a single pass over the scope array.
|
|
20
|
+
* @param url - The target URL to classify.
|
|
21
|
+
* @param scope - Hostname-indexed map of scope URLs.
|
|
22
|
+
* @param options - URL parsing options forwarded to {@link isLowerLayer}.
|
|
23
|
+
* @returns The deepest matching scope URL, or `null` when the URL is external.
|
|
24
|
+
*/
|
|
25
|
+
export function findScopeEntry(url, scope, options) {
|
|
26
|
+
const scopes = scope.get(url.hostname);
|
|
27
|
+
if (!scopes) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
let bestMatch = null;
|
|
31
|
+
let maxDepth = -1;
|
|
32
|
+
for (const entry of scopes) {
|
|
33
|
+
if (entry.port !== url.port) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!isLowerLayer(url, entry, options)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (entry.depth > maxDepth) {
|
|
40
|
+
bestMatch = entry;
|
|
41
|
+
maxDepth = entry.depth;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return bestMatch;
|
|
45
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { crawlerLog } from '../debug.js';
|
|
2
|
+
import { findScopeEntry } from './find-scope-entry.js';
|
|
2
3
|
import { injectScopeAuth } from './inject-scope-auth.js';
|
|
3
|
-
import { isExternalUrl } from './is-external-url.js';
|
|
4
|
-
import { isInAnyLowerLayer } from './is-in-any-lower-layer.js';
|
|
5
4
|
/**
|
|
6
5
|
* Process the result of a successful page scrape.
|
|
7
6
|
*
|
|
@@ -36,12 +35,14 @@ export function handleScrapeEnd(result, linkList, scope, options, addUrl) {
|
|
|
36
35
|
* Process anchor elements extracted from a scraped page and enqueue new URLs.
|
|
37
36
|
*
|
|
38
37
|
* For each anchor:
|
|
39
|
-
* 1.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* and
|
|
44
|
-
*
|
|
38
|
+
* 1. Resolves the matching scope entry via a single {@link findScopeEntry} call.
|
|
39
|
+
* If `null`, the anchor is external; otherwise it is internal under the
|
|
40
|
+
* deepest matching scope.
|
|
41
|
+
* 2. For internal anchors without credentials, inherits auth from the matched
|
|
42
|
+
* scope and rebuilds `withoutHash` with the injected auth.
|
|
43
|
+
* 3. In recursive mode: enqueues internal anchors for full scraping, and
|
|
44
|
+
* external anchors for metadata-only scraping (when `fetchExternal` is on).
|
|
45
|
+
* 4. In non-recursive mode: enqueues every anchor for metadata-only scraping.
|
|
45
46
|
* @param anchors - The list of anchor data extracted from the page.
|
|
46
47
|
* @param scope - Map of hostnames to their scope URLs.
|
|
47
48
|
* @param options - Crawler configuration options.
|
|
@@ -50,10 +51,11 @@ export function handleScrapeEnd(result, linkList, scope, options, addUrl) {
|
|
|
50
51
|
*/
|
|
51
52
|
function processAnchors(anchors, scope, options, addUrl) {
|
|
52
53
|
for (const anchor of anchors) {
|
|
53
|
-
const
|
|
54
|
+
const matchedScope = findScopeEntry(anchor.href, scope, options);
|
|
55
|
+
const isExternal = matchedScope === null;
|
|
54
56
|
anchor.isExternal = isExternal;
|
|
55
|
-
if (
|
|
56
|
-
injectScopeAuth(anchor.href,
|
|
57
|
+
if (matchedScope && (!anchor.href.username || !anchor.href.password)) {
|
|
58
|
+
injectScopeAuth(anchor.href, matchedScope);
|
|
57
59
|
const auth = anchor.href.username && anchor.href.password
|
|
58
60
|
? `${anchor.href.username}:${anchor.href.password}@`
|
|
59
61
|
: '';
|
|
@@ -68,11 +70,10 @@ function processAnchors(anchors, scope, options, addUrl) {
|
|
|
68
70
|
anchor.href.withoutHash = withoutHash;
|
|
69
71
|
}
|
|
70
72
|
if (options.recursive) {
|
|
71
|
-
|
|
72
|
-
if (scopes && isInAnyLowerLayer(anchor.href, scopes, options)) {
|
|
73
|
+
if (matchedScope) {
|
|
73
74
|
addUrl(anchor.href);
|
|
74
75
|
}
|
|
75
|
-
else if (
|
|
76
|
+
else if (options.fetchExternal) {
|
|
76
77
|
addUrl(anchor.href, { metadataOnly: true });
|
|
77
78
|
}
|
|
78
79
|
continue;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Copy `username` / `password` from a matched scope URL into the target URL.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The matched scope is supplied by the caller (typically the result of a single
|
|
6
|
+
* {@link findScopeEntry} call). This avoids the previous implementation's
|
|
7
|
+
* redundant hostname lookup and re-search.
|
|
8
|
+
*
|
|
9
|
+
* Mutates the `url` parameter in place. Only non-empty credentials overwrite
|
|
10
|
+
* existing values.
|
|
11
|
+
* @param url - The parsed URL to receive credentials (mutated in place).
|
|
12
|
+
* @param matchedScope - The scope URL whose credentials should be inherited.
|
|
10
13
|
*/
|
|
11
|
-
export declare function injectScopeAuth(url: ExURL,
|
|
14
|
+
export declare function injectScopeAuth(url: ExURL, matchedScope: ExURL): void;
|
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import { findBestMatchingScope } from './find-best-matching-scope.js';
|
|
2
1
|
/**
|
|
3
|
-
*
|
|
2
|
+
* Copy `username` / `password` from a matched scope URL into the target URL.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The matched scope is supplied by the caller (typically the result of a single
|
|
5
|
+
* {@link findScopeEntry} call). This avoids the previous implementation's
|
|
6
|
+
* redundant hostname lookup and re-search.
|
|
7
|
+
*
|
|
8
|
+
* Mutates the `url` parameter in place. Only non-empty credentials overwrite
|
|
9
|
+
* existing values.
|
|
10
|
+
* @param url - The parsed URL to receive credentials (mutated in place).
|
|
11
|
+
* @param matchedScope - The scope URL whose credentials should be inherited.
|
|
10
12
|
*/
|
|
11
|
-
export function injectScopeAuth(url,
|
|
12
|
-
|
|
13
|
-
if (!scopes) {
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
const matchedScope = findBestMatchingScope(url, scopes);
|
|
17
|
-
if (matchedScope) {
|
|
13
|
+
export function injectScopeAuth(url, matchedScope) {
|
|
14
|
+
if (matchedScope.username) {
|
|
18
15
|
url.username = matchedScope.username;
|
|
16
|
+
}
|
|
17
|
+
if (matchedScope.password) {
|
|
19
18
|
url.password = matchedScope.password;
|
|
20
19
|
}
|
|
21
20
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
1
|
+
import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
|
|
2
2
|
/**
|
|
3
3
|
* Determine whether a URL is external to the crawl scope.
|
|
4
4
|
*
|
|
5
|
-
* A URL is
|
|
6
|
-
*
|
|
5
|
+
* A URL is external when no scope entry contains it — i.e. either no scope
|
|
6
|
+
* entry shares its hostname, or none of the same-hostname scope entries is at
|
|
7
|
+
* the same level or shallower in the path hierarchy.
|
|
8
|
+
*
|
|
9
|
+
* This is a thin wrapper around {@link findScopeEntry}. Callers that already
|
|
10
|
+
* need the matched scope (for `injectScopeAuth` etc.) should call
|
|
11
|
+
* `findScopeEntry` directly instead of `isExternalUrl` to avoid a redundant
|
|
12
|
+
* lookup.
|
|
7
13
|
* @param url - The parsed URL to check.
|
|
8
|
-
* @param scope -
|
|
9
|
-
* @
|
|
14
|
+
* @param scope - Hostname-indexed map of scope URLs.
|
|
15
|
+
* @param options - URL parsing options forwarded to the scope-entry lookup.
|
|
16
|
+
* @returns `true` if the URL is outside every scope entry.
|
|
10
17
|
*/
|
|
11
|
-
export declare function isExternalUrl(url: ExURL, scope: ReadonlyMap<string, readonly ExURL[]
|
|
18
|
+
export declare function isExternalUrl(url: ExURL, scope: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions): boolean;
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { findScopeEntry } from './find-scope-entry.js';
|
|
1
2
|
/**
|
|
2
3
|
* Determine whether a URL is external to the crawl scope.
|
|
3
4
|
*
|
|
4
|
-
* A URL is
|
|
5
|
-
*
|
|
5
|
+
* A URL is external when no scope entry contains it — i.e. either no scope
|
|
6
|
+
* entry shares its hostname, or none of the same-hostname scope entries is at
|
|
7
|
+
* the same level or shallower in the path hierarchy.
|
|
8
|
+
*
|
|
9
|
+
* This is a thin wrapper around {@link findScopeEntry}. Callers that already
|
|
10
|
+
* need the matched scope (for `injectScopeAuth` etc.) should call
|
|
11
|
+
* `findScopeEntry` directly instead of `isExternalUrl` to avoid a redundant
|
|
12
|
+
* lookup.
|
|
6
13
|
* @param url - The parsed URL to check.
|
|
7
|
-
* @param scope -
|
|
8
|
-
* @
|
|
14
|
+
* @param scope - Hostname-indexed map of scope URLs.
|
|
15
|
+
* @param options - URL parsing options forwarded to the scope-entry lookup.
|
|
16
|
+
* @returns `true` if the URL is outside every scope entry.
|
|
9
17
|
*/
|
|
10
|
-
export function isExternalUrl(url, scope) {
|
|
11
|
-
return
|
|
18
|
+
export function isExternalUrl(url, scope, options) {
|
|
19
|
+
return findScopeEntry(url, scope, options) === null;
|
|
12
20
|
}
|
package/lib/crawler/types.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ export interface CrawlerOptions extends Required<Pick<ParseURLOptions, 'disableQ
|
|
|
24
24
|
executablePath: string | null;
|
|
25
25
|
/** Whether to fetch and scrape external (out-of-scope) pages. */
|
|
26
26
|
fetchExternal: boolean;
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/** Root URL strings that define the crawl boundary. Each root is also a scope entry (a `(hostname, port, path)` triple) — out-of-bound URLs are classified as external. */
|
|
28
|
+
roots: string[];
|
|
29
29
|
/** Glob patterns for URLs to exclude from crawling. */
|
|
30
30
|
excludes: string[];
|
|
31
31
|
/** Keywords that trigger page exclusion when found in content. */
|
|
@@ -125,6 +125,27 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
|
|
|
125
125
|
* @throws {Error} If the URL list is empty or contains no valid URLs.
|
|
126
126
|
*/
|
|
127
127
|
static crawling(url: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback): Promise<CrawlerOrchestrator>;
|
|
128
|
+
/**
|
|
129
|
+
* Append a fresh crawl to an existing `.nitpicker` archive.
|
|
130
|
+
*
|
|
131
|
+
* The given `newUrls` become additional recursive roots: their `withoutHash`
|
|
132
|
+
* form is merged into `info.roots` and the crawler picks them up as
|
|
133
|
+
* starting URLs. Previously-external pages whose URL now falls under
|
|
134
|
+
* the expanded scope are demoted back to "needs scraping" so the next pass
|
|
135
|
+
* re-fetches them as full internal pages. A `<archive>.bak` is created
|
|
136
|
+
* before the crawl and removed on success; if the crawl throws, the backup
|
|
137
|
+
* is restored to keep the original archive intact.
|
|
138
|
+
*
|
|
139
|
+
* List-mode archives (`info.fromList === true`) are rejected because their
|
|
140
|
+
* pages are all metadata-only and cannot host a recursive append.
|
|
141
|
+
* @param archivePath - Absolute or relative path to the existing `.nitpicker`.
|
|
142
|
+
* @param newUrls - New root URLs to add and crawl.
|
|
143
|
+
* @param options - Optional config overrides applied on top of the archived config.
|
|
144
|
+
* @param initializedCallback - Optional callback invoked after initialization but before crawling resumes.
|
|
145
|
+
* @returns The orchestrator instance after the append crawl completes.
|
|
146
|
+
* @throws {Error} When `newUrls` is empty, the archive is in list mode, or it cannot be parsed.
|
|
147
|
+
*/
|
|
148
|
+
static append(archivePath: string, newUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback): Promise<CrawlerOrchestrator>;
|
|
128
149
|
/**
|
|
129
150
|
* Resume a previously interrupted crawl from an existing archive file.
|
|
130
151
|
*
|