@nitpicker/crawler 0.6.5-alpha.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.
@@ -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
- scope: options?.scope ?? [],
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.scope) {
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 a single root URL.
138
+ * Start crawling from one or more root URLs.
139
139
  *
140
- * Adds the root URL to the scope (if not already present) and the link list,
141
- * then begins the deal-based concurrent crawl. Discovered child pages are
142
- * automatically added to the queue when recursive mode is enabled.
143
- * @param url - The root URL to begin crawling from.
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(url) {
146
- const existing = this.#scope.get(url.hostname) || [];
147
- if (!existing.some((u) => u.href === url.href)) {
148
- this.#scope.set(url.hostname, [...existing, url]);
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
- const initialUrls = isResuming ? this.#resumedPending : [url];
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, url.href);
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 || isExternalUrl(newUrl, this.#scope)) {
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: isExternalUrl(result.ignored.url, this.#scope),
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 = isExternalUrl(url, this.#scope);
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 (isExternalUrl(url, this.#scope)) {
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 isExternal = isExternalUrl(url, this.#scope);
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
- injectScopeAuth(url, this.#scope);
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 (isExternalUrl(url, this.#scope)) {
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 = isExternalUrl(url, this.#scope);
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);
@@ -28,13 +28,19 @@ export async function fetchDestination(params) {
28
28
  return cache;
29
29
  }
30
30
  const effectiveMethod = titleBytesLimit == null ? method : 'GET';
31
+ // Race the fetch against a 10-second timeout. The losing timer is cleared
32
+ // explicitly so it never keeps the event loop alive after the race settles
33
+ // (a plain `delay()` in `Promise.race` would leak the timer until it fires).
34
+ let timeoutHandle;
31
35
  const result = await Promise.race([
32
36
  _fetchHead(url, isExternal, effectiveMethod, titleBytesLimit, userAgent).catch((error) => (error instanceof Error ? error : new Error(String(error)))),
33
- (async () => {
34
- await delay(10 * 1000);
35
- return new NetTimeoutError(url.href);
36
- })(),
37
- ]);
37
+ new Promise((resolve) => {
38
+ timeoutHandle = setTimeout(() => resolve(new NetTimeoutError(url.href)), 10 * 1000);
39
+ }),
40
+ ]).finally(() => {
41
+ if (timeoutHandle)
42
+ clearTimeout(timeoutHandle);
43
+ });
38
44
  destinationCache.set(cacheKey, result);
39
45
  if (result instanceof Error) {
40
46
  throw result;
@@ -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. Determines if it is external (outside the crawl scope)
40
- * 2. Injects authentication credentials from matching scope URLs
41
- * 3. Reconstructs the `withoutHash` URL with injected auth
42
- * 4. In recursive mode: enqueues internal lower-layer URLs for full scraping,
43
- * and external URLs for metadata-only scraping (if `fetchExternal` is enabled)
44
- * 5. In non-recursive mode: enqueues all URLs for metadata-only scraping
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 isExternal = isExternalUrl(anchor.href, scope);
54
+ const matchedScope = findScopeEntry(anchor.href, scope, options);
55
+ const isExternal = matchedScope === null;
54
56
  anchor.isExternal = isExternal;
55
- if (!isExternal && (!anchor.href.username || !anchor.href.password)) {
56
- injectScopeAuth(anchor.href, scope);
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
- const scopes = scope.get(anchor.href.hostname);
72
- if (scopes && isInAnyLowerLayer(anchor.href, scopes, options)) {
73
+ if (matchedScope) {
73
74
  addUrl(anchor.href);
74
75
  }
75
- else if (isExternal && options.fetchExternal) {
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
- * Inject authentication credentials from a matching scope URL into the target URL.
3
+ * Copy `username` / `password` from a matched scope URL into the target URL.
4
4
  *
5
- * Finds the best-matching scope URL (deepest path match) for the given URL's
6
- * hostname and copies its `username` and `password` properties. This mutates
7
- * the `url` parameter in place.
8
- * @param url - The parsed URL to receive authentication credentials (mutated in place).
9
- * @param scope - Map of hostnames to their scope URLs.
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, scope: ReadonlyMap<string, readonly ExURL[]>): void;
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
- * Inject authentication credentials from a matching scope URL into the target URL.
2
+ * Copy `username` / `password` from a matched scope URL into the target URL.
4
3
  *
5
- * Finds the best-matching scope URL (deepest path match) for the given URL's
6
- * hostname and copies its `username` and `password` properties. This mutates
7
- * the `url` parameter in place.
8
- * @param url - The parsed URL to receive authentication credentials (mutated in place).
9
- * @param scope - Map of hostnames to their scope URLs.
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, scope) {
12
- const scopes = scope.get(url.hostname);
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 considered external if its hostname does not appear
6
- * as a key in the scope map.
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 - Map of hostnames to their scope URLs.
9
- * @returns `true` if the URL is outside the crawl scope.
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[]>): boolean;
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 considered external if its hostname does not appear
5
- * as a key in the scope map.
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 - Map of hostnames to their scope URLs.
8
- * @returns `true` if the URL is outside the crawl scope.
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 !scope.has(url.hostname);
18
+ export function isExternalUrl(url, scope, options) {
19
+ return findScopeEntry(url, scope, options) === null;
12
20
  }
@@ -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
- /** List of scope URL strings that define the crawl boundary. */
28
- scope: string[];
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
  *