@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.
@@ -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.
@@ -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').insert({
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')
@@ -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. Returns `null` if no scope URL matches.
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. Returns `null` if no scope URL matches.
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
- * Compares path segments element by element. The target path matches if
30
- * all segments of the base path appear in the same positions at the
31
- * beginning of the target path.
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.5.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": "607d06bd596a0270d7088f32373d7367eb47ea94"
51
+ "gitHead": "bc3a766e87f91560268097b99048b06341cd59cd"
52
52
  }