@nitpicker/crawler 0.9.0 → 0.11.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.
Files changed (92) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +147 -24
  4. package/lib/archive/archive.js +252 -86
  5. package/lib/archive/database.d.ts +209 -25
  6. package/lib/archive/database.js +928 -108
  7. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  8. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  9. package/lib/archive/init-schema.d.ts +53 -2
  10. package/lib/archive/init-schema.js +247 -15
  11. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  12. package/lib/archive/meta/assert-compatible-version.js +72 -0
  13. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  14. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  15. package/lib/archive/meta/compare-semver.d.ts +23 -0
  16. package/lib/archive/meta/compare-semver.js +51 -0
  17. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  18. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  19. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  20. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  21. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  22. package/lib/archive/meta/derive-meta-extras.js +23 -0
  23. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  24. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  25. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  26. package/lib/archive/meta/summarize-jsonld.js +29 -0
  27. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  28. package/lib/archive/meta/summarize-tags.js +33 -0
  29. package/lib/archive/meta/types.d.ts +207 -0
  30. package/lib/archive/meta/types.js +33 -0
  31. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  32. package/lib/archive/migrate-crawl-errors.js +38 -0
  33. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  34. package/lib/archive/migrate-html-blob-tables.js +53 -0
  35. package/lib/archive/migrate-page-errors.d.ts +16 -0
  36. package/lib/archive/migrate-page-errors.js +35 -0
  37. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  38. package/lib/archive/migrate-pages-resources-source.js +46 -0
  39. package/lib/archive/page.d.ts +187 -49
  40. package/lib/archive/page.js +258 -63
  41. package/lib/archive/peek-archive-lock.d.ts +40 -0
  42. package/lib/archive/peek-archive-lock.js +62 -0
  43. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  44. package/lib/archive/resolve-redirect-chain.js +27 -0
  45. package/lib/archive/types.d.ts +135 -26
  46. package/lib/crawler/close-browser-safely.d.ts +64 -0
  47. package/lib/crawler/close-browser-safely.js +73 -0
  48. package/lib/crawler/crawler.d.ts +4 -1
  49. package/lib/crawler/crawler.js +290 -32
  50. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  51. package/lib/crawler/create-change-phase-handler.js +44 -0
  52. package/lib/crawler/derive-page-source.d.ts +23 -0
  53. package/lib/crawler/derive-page-source.js +28 -0
  54. package/lib/crawler/derive-resource-source.d.ts +23 -0
  55. package/lib/crawler/derive-resource-source.js +26 -0
  56. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  57. package/lib/crawler/drain-phase-errors.js +35 -0
  58. package/lib/crawler/fetch-destination.js +38 -2
  59. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  60. package/lib/crawler/format-crawl-progress.js +14 -6
  61. package/lib/crawler/handle-browser-close.d.ts +29 -0
  62. package/lib/crawler/handle-browser-close.js +28 -0
  63. package/lib/crawler/is-html-content-type.d.ts +17 -0
  64. package/lib/crawler/is-html-content-type.js +19 -0
  65. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  66. package/lib/crawler/is-likely-html-url.js +65 -0
  67. package/lib/crawler/kill-process-tree.d.ts +94 -0
  68. package/lib/crawler/kill-process-tree.js +178 -0
  69. package/lib/crawler/link-list.js +2 -1
  70. package/lib/crawler/link-to-page-data.d.ts +13 -5
  71. package/lib/crawler/link-to-page-data.js +26 -5
  72. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  73. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  74. package/lib/crawler/normalize-content-type.d.ts +14 -0
  75. package/lib/crawler/normalize-content-type.js +20 -0
  76. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  77. package/lib/crawler/partition-urls-by-html.js +23 -0
  78. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  79. package/lib/crawler/redirect-dest-key.js +27 -0
  80. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  81. package/lib/crawler/resource-to-page-data.js +59 -0
  82. package/lib/crawler/types.d.ts +122 -1
  83. package/lib/crawler-orchestrator.d.ts +93 -1
  84. package/lib/crawler-orchestrator.js +389 -12
  85. package/lib/crawler.d.ts +5 -0
  86. package/lib/crawler.js +3 -0
  87. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  88. package/lib/resource-row-to-lookup-result.js +20 -0
  89. package/lib/types.d.ts +11 -1
  90. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  91. package/lib/utils/object/parse-response-headers.js +26 -0
  92. package/package.json +4 -4
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Builds the `scraper.on('changePhase', ...)` listener used by
3
+ * {@link Crawler.#launchBrowserAndScrape}.
4
+ *
5
+ * Three responsibilities:
6
+ * 1. Render the phase log via the injected `formatLog` and pipe it to `update`.
7
+ * 2. Forward the raw event so external consumers (CLI progress UI etc.) see
8
+ * every transition.
9
+ * 3. Buffer `retryExhausted` events into the per-URL phase-error map so they
10
+ * can be drained as `pageError` events AFTER the `page` event fires.
11
+ *
12
+ * WHY a factory: the listener captures per-scrape state (`buffer`, `urlHref`,
13
+ * `update`). Extracting the factory makes the wiring directly unit-testable
14
+ * with plain stubs, instead of requiring a mocked Puppeteer + beholder + dealer
15
+ * stack to drive the worker.
16
+ *
17
+ * **Caller contract**: register the returned handler at most once per
18
+ * `scraper` instance. The Crawler creates a fresh Scraper per URL so this
19
+ * holds today; if scraper pooling is ever introduced, register exactly one
20
+ * handler per scrape and unregister it on completion to avoid duplicate
21
+ * buffer entries.
22
+ * @param options - Wiring dependencies for the handler.
23
+ * @returns A function suitable for `scraper.on('changePhase', ...)`.
24
+ */
25
+ export function createChangePhaseHandler(options) {
26
+ const { emit, update, formatLog, buffer, urlHref } = options;
27
+ return (event) => {
28
+ const msg = formatLog(event);
29
+ if (msg) {
30
+ update(msg);
31
+ }
32
+ emit(event);
33
+ // retryExhausted fires when beholder's @retryable gives up on a
34
+ // secondary scrape step (e.g. a viewport switch detaching the frame
35
+ // in #fetchImages). The page itself still completes, so we buffer
36
+ // the failure here and emit it as a pageError after the page event
37
+ // has been emitted.
38
+ if (event.name === 'retryExhausted') {
39
+ const list = buffer.get(urlHref) ?? [];
40
+ list.push({ phase: event.name, message: event.message });
41
+ buffer.set(urlHref, list);
42
+ }
43
+ };
44
+ }
@@ -0,0 +1,23 @@
1
+ import type { InventoryMode } from './types.js';
2
+ import type { PageSource } from '../archive/types.js';
3
+ /**
4
+ * Decide which {@link PageSource} label a newly-scraped page row should carry.
5
+ *
6
+ * When the crawler is NOT in inventory mode (`inventoryMode === null`),
7
+ * returns `undefined` — the caller emits no `source` and the DB DEFAULT
8
+ * `'crawled'` ends up on the row. This keeps the normal crawl path
9
+ * untouched.
10
+ *
11
+ * When inventory mode is active, the URL is matched against
12
+ * `inventoryMode.seedUrls`. A hit means the URL came straight from the
13
+ * user-supplied list (`'inventory-seed'`); a miss means the URL was found
14
+ * by following links from a seed page (`'inventory-discovered'`).
15
+ *
16
+ * Sub-resources captured by puppeteer during inventory-mode rendering are
17
+ * NEVER seeds — the caller for those events always passes
18
+ * `'inventory-discovered'` directly without consulting this helper.
19
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
20
+ * @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
21
+ * @returns The label to write to `pages.source`, or `undefined` for the DB default.
22
+ */
23
+ export declare function derivePageSource(inventoryMode: InventoryMode | null, pageUrlWithoutHashAndAuth: string): PageSource | undefined;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Decide which {@link PageSource} label a newly-scraped page row should carry.
3
+ *
4
+ * When the crawler is NOT in inventory mode (`inventoryMode === null`),
5
+ * returns `undefined` — the caller emits no `source` and the DB DEFAULT
6
+ * `'crawled'` ends up on the row. This keeps the normal crawl path
7
+ * untouched.
8
+ *
9
+ * When inventory mode is active, the URL is matched against
10
+ * `inventoryMode.seedUrls`. A hit means the URL came straight from the
11
+ * user-supplied list (`'inventory-seed'`); a miss means the URL was found
12
+ * by following links from a seed page (`'inventory-discovered'`).
13
+ *
14
+ * Sub-resources captured by puppeteer during inventory-mode rendering are
15
+ * NEVER seeds — the caller for those events always passes
16
+ * `'inventory-discovered'` directly without consulting this helper.
17
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
18
+ * @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
19
+ * @returns The label to write to `pages.source`, or `undefined` for the DB default.
20
+ */
21
+ export function derivePageSource(inventoryMode, pageUrlWithoutHashAndAuth) {
22
+ if (inventoryMode === null) {
23
+ return undefined;
24
+ }
25
+ return inventoryMode.seedUrls.has(pageUrlWithoutHashAndAuth)
26
+ ? 'inventory-seed'
27
+ : 'inventory-discovered';
28
+ }
@@ -0,0 +1,23 @@
1
+ import type { InventoryMode } from './types.js';
2
+ import type { PageSource } from '../archive/types.js';
3
+ /**
4
+ * Decide which {@link PageSource} label a newly-captured sub-resource row
5
+ * (CSS / JS / image / fetch response) should carry.
6
+ *
7
+ * Sub-resources are NEVER themselves seeds — even when puppeteer is
8
+ * rendering a page that *is* an inventory seed, the assets it pulls in
9
+ * are downstream and must be labelled `'inventory-discovered'`. The seed
10
+ * label is reserved for URLs that were explicitly handed in by the user
11
+ * via the `--inventory` file.
12
+ *
13
+ * Outside inventory mode (`inventoryMode === null`) this returns
14
+ * `undefined` so the caller emits no `source` and the DB DEFAULT
15
+ * (`'crawled'`) lands on the row — keeps the normal crawl path
16
+ * untouched. This is the sub-resource counterpart of
17
+ * {@link import('./derive-page-source.js').derivePageSource}; the two
18
+ * helpers exist as a pair so a future addition to {@link PageSource}
19
+ * forces a parallel update.
20
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
21
+ * @returns The label to write to `resources.source`, or `undefined` for the DB default.
22
+ */
23
+ export declare function deriveResourceSource(inventoryMode: InventoryMode | null): PageSource | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Decide which {@link PageSource} label a newly-captured sub-resource row
3
+ * (CSS / JS / image / fetch response) should carry.
4
+ *
5
+ * Sub-resources are NEVER themselves seeds — even when puppeteer is
6
+ * rendering a page that *is* an inventory seed, the assets it pulls in
7
+ * are downstream and must be labelled `'inventory-discovered'`. The seed
8
+ * label is reserved for URLs that were explicitly handed in by the user
9
+ * via the `--inventory` file.
10
+ *
11
+ * Outside inventory mode (`inventoryMode === null`) this returns
12
+ * `undefined` so the caller emits no `source` and the DB DEFAULT
13
+ * (`'crawled'`) lands on the row — keeps the normal crawl path
14
+ * untouched. This is the sub-resource counterpart of
15
+ * {@link import('./derive-page-source.js').derivePageSource}; the two
16
+ * helpers exist as a pair so a future addition to {@link PageSource}
17
+ * forces a parallel update.
18
+ * @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
19
+ * @returns The label to write to `resources.source`, or `undefined` for the DB default.
20
+ */
21
+ export function deriveResourceSource(inventoryMode) {
22
+ if (inventoryMode === null) {
23
+ return undefined;
24
+ }
25
+ return 'inventory-discovered';
26
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A buffered phase-error record awaiting emission as a `pageError` event.
3
+ *
4
+ * `phase` is the beholder phase name (typically `'retryExhausted'`) and
5
+ * `message` is the human-readable failure text.
6
+ */
7
+ export interface BufferedPhaseError {
8
+ /** Scrape phase name. */
9
+ phase: string;
10
+ /** Human-readable failure message. */
11
+ message: string;
12
+ }
13
+ /**
14
+ * Emitter signature accepted by {@link drainPhaseErrors} for the `pageError`
15
+ * event. Declared structurally so the Crawler's typed event emitter can be
16
+ * adapted with a thin closure at the call site without leaking through here.
17
+ */
18
+ export type DrainPhaseErrorsEmit = (payload: {
19
+ /** URL of the affected page. */
20
+ url: string;
21
+ /** Scrape phase name. */
22
+ phase: string;
23
+ /** Human-readable failure message. */
24
+ message: string;
25
+ /** Whether the URL is external to the crawl scope. */
26
+ isExternal: boolean;
27
+ }) => void;
28
+ /**
29
+ * Drains the buffered phase errors for a URL: removes the entry from
30
+ * `buffer` and invokes `emit` once per buffered record.
31
+ *
32
+ * WHY a standalone function: the Crawler buffers `retryExhausted` events
33
+ * keyed by `url.href` during scrapeStart, then flushes them as
34
+ * `pageError` events AFTER `page` / `externalPage` has been emitted so the
35
+ * orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
36
+ * Extracting the drain step here makes the flush + delete contract
37
+ * directly unit-testable without spinning up a real Crawler.
38
+ *
39
+ * Idempotent: calling twice for the same `urlHref` is safe — the second
40
+ * call sees an empty buffer and is a no-op.
41
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
42
+ * @param urlHref - URL whose buffered errors should be drained.
43
+ * @param isExternal - Whether the URL is external to the crawl scope.
44
+ * @param emit - Callback invoked once per buffered phase-error record.
45
+ * @returns The number of phase-error events emitted (0 when the buffer
46
+ * had no entry for `urlHref`).
47
+ */
48
+ export declare function drainPhaseErrors(buffer: Map<string, BufferedPhaseError[]>, urlHref: string, isExternal: boolean, emit: DrainPhaseErrorsEmit): number;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Drains the buffered phase errors for a URL: removes the entry from
3
+ * `buffer` and invokes `emit` once per buffered record.
4
+ *
5
+ * WHY a standalone function: the Crawler buffers `retryExhausted` events
6
+ * keyed by `url.href` during scrapeStart, then flushes them as
7
+ * `pageError` events AFTER `page` / `externalPage` has been emitted so the
8
+ * orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
9
+ * Extracting the drain step here makes the flush + delete contract
10
+ * directly unit-testable without spinning up a real Crawler.
11
+ *
12
+ * Idempotent: calling twice for the same `urlHref` is safe — the second
13
+ * call sees an empty buffer and is a no-op.
14
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
15
+ * @param urlHref - URL whose buffered errors should be drained.
16
+ * @param isExternal - Whether the URL is external to the crawl scope.
17
+ * @param emit - Callback invoked once per buffered phase-error record.
18
+ * @returns The number of phase-error events emitted (0 when the buffer
19
+ * had no entry for `urlHref`).
20
+ */
21
+ export function drainPhaseErrors(buffer, urlHref, isExternal, emit) {
22
+ const errors = buffer.get(urlHref);
23
+ if (!errors || errors.length === 0)
24
+ return 0;
25
+ buffer.delete(urlHref);
26
+ for (const err of errors) {
27
+ emit({
28
+ url: urlHref,
29
+ phase: err.phase,
30
+ message: err.message,
31
+ isExternal,
32
+ });
33
+ }
34
+ return errors.length;
35
+ }
@@ -63,12 +63,19 @@ export async function fetchDestination(params) {
63
63
  async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
64
64
  return new Promise((resolve, reject) => {
65
65
  const hostHeader = url.port ? `${url.hostname}:${url.port}` : url.hostname;
66
+ // `trackRedirects` makes follow-redirects populate `res.redirects` with the
67
+ // chain of followed URLs. Without it that array stays empty and the
68
+ // pre-flight cannot tell where a URL lands — required for the redirect
69
+ // chain in `redirectPaths` and for the #73 convergence dedup, which decides
70
+ // whether a redirect destination was already rendered *before* launching
71
+ // the browser.
66
72
  const request = {
67
73
  protocol: url.protocol,
68
74
  hostname: url.hostname,
69
75
  port: url.port || undefined,
70
76
  path: url.pathname,
71
77
  method,
78
+ trackRedirects: true,
72
79
  headers: {
73
80
  host: hostHeader,
74
81
  ...(userAgent ? { 'User-Agent': userAgent } : {}),
@@ -92,7 +99,17 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
92
99
  let totalBytes = 0;
93
100
  let settled = false;
94
101
  const buildPageData = (title) => {
95
- const redirectPaths = res.redirects.map((r) => r.url);
102
+ // `res.redirects` (populated by trackRedirects) ALWAYS starts with the
103
+ // originally requested URL, then each followed hop. We drop that first
104
+ // entry so `redirectPaths` keeps its established contract: empty when the
105
+ // URL did not redirect, and `[...intermediate, finalDest]` when it did
106
+ // (the original URL is NOT included — callers like `resolveRedirectChain`
107
+ // and `updatePage` re-add it). Keeping the original here would (a) make
108
+ // `redirectPaths` non-empty for every page, so a direct page looks like a
109
+ // self-redirect, and (b) leak the query-stripped request-target (the HEAD
110
+ // request uses `url.pathname`), collapsing query-distinguished pages.
111
+ // Redirect *targets* come from Location headers and keep their query.
112
+ const redirectPaths = res.redirects.map((r) => r.url).slice(1);
96
113
  const _contentLength = Number.parseInt(res.headers['content-length'] || '');
97
114
  const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
98
115
  return {
@@ -105,7 +122,26 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
105
122
  contentType: res.headers['content-type']?.split(';')[0] || null,
106
123
  contentLength,
107
124
  responseHeaders: res.headers,
108
- meta: { title },
125
+ // beholder 3.0.0 made jsonLd / speculationRules / tags /
126
+ // others / originTrial required Meta fields. Even this
127
+ // HEAD-only fallback path must populate every slot so
128
+ // downstream insert/derive helpers iterate without crashing.
129
+ meta: {
130
+ title,
131
+ jsonLd: [],
132
+ speculationRules: [],
133
+ tags: { detected: {}, entries: [] },
134
+ others: {
135
+ meta: {},
136
+ property: {},
137
+ httpEquiv: {},
138
+ itemprop: {},
139
+ link: [],
140
+ script: [],
141
+ iframe: [],
142
+ },
143
+ originTrial: [],
144
+ },
109
145
  imageList: [],
110
146
  anchorList: [],
111
147
  html: '',
@@ -12,22 +12,31 @@ interface FormatCrawlProgressParams {
12
12
  readonly externalTotal: number;
13
13
  /** Number of external URLs completed */
14
14
  readonly externalDone: number;
15
+ /**
16
+ * Number of HTML pages actually rendered by the browser in the current session.
17
+ * Excludes HEAD-only resources, title-only metadata fetches, and skipped URLs.
18
+ */
19
+ readonly pagesScraped: number;
15
20
  /** Number of parallel workers */
16
21
  readonly limit: number;
17
22
  }
18
23
  /**
19
24
  * Formats the crawl progress header for the deal() progress display.
20
25
  *
21
- * Shows "done / found (remaining)" format instead of "done/total"
22
- * to make it clearer that the total is expected to grow during crawling.
26
+ * Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
27
+ * to make it clearer that the total is expected to grow during crawling,
28
+ * that the counts are processed URLs (not resulting pages), and how many of
29
+ * those URLs were actually rendered by the browser as HTML pages.
30
+ * Counts are formatted with thousands separators (e.g. `1,234,567`).
23
31
  * @param params - The crawl progress parameters.
24
32
  * @param params.done - Number of URLs completed by the deal queue.
25
33
  * @param params.total - Total number of URLs in the deal queue (including completed).
26
34
  * @param params.resumeOffset - Offset from a previous resumed session.
27
35
  * @param params.externalTotal - Number of external URLs discovered.
28
36
  * @param params.externalDone - Number of external URLs completed.
37
+ * @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
29
38
  * @param params.limit - Number of parallel workers.
30
39
  * @returns The formatted progress string with ANSI color codes.
31
40
  */
32
- export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }: FormatCrawlProgressParams): string;
41
+ export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }: FormatCrawlProgressParams): string;
33
42
  export {};
@@ -1,19 +1,27 @@
1
1
  import c from 'ansi-colors';
2
+ /**
3
+ * Number formatter for thousands-separated count display (e.g. `1,234,567`).
4
+ */
5
+ const countFormat = new Intl.NumberFormat('en-US');
2
6
  /**
3
7
  * Formats the crawl progress header for the deal() progress display.
4
8
  *
5
- * Shows "done / found (remaining)" format instead of "done/total"
6
- * to make it clearer that the total is expected to grow during crawling.
9
+ * Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
10
+ * to make it clearer that the total is expected to grow during crawling,
11
+ * that the counts are processed URLs (not resulting pages), and how many of
12
+ * those URLs were actually rendered by the browser as HTML pages.
13
+ * Counts are formatted with thousands separators (e.g. `1,234,567`).
7
14
  * @param params - The crawl progress parameters.
8
15
  * @param params.done - Number of URLs completed by the deal queue.
9
16
  * @param params.total - Total number of URLs in the deal queue (including completed).
10
17
  * @param params.resumeOffset - Offset from a previous resumed session.
11
18
  * @param params.externalTotal - Number of external URLs discovered.
12
19
  * @param params.externalDone - Number of external URLs completed.
20
+ * @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
13
21
  * @param params.limit - Number of parallel workers.
14
22
  * @returns The formatted progress string with ANSI color codes.
15
23
  */
16
- export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }) {
24
+ export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }) {
17
25
  const allDone = done + resumeOffset;
18
26
  const allTotal = total + resumeOffset;
19
27
  const internalDone = allDone - externalDone;
@@ -22,8 +30,8 @@ export function formatCrawlProgress({ done, total, resumeOffset, externalTotal,
22
30
  const externalRemaining = externalTotal - externalDone;
23
31
  const totalRemaining = internalRemaining + externalRemaining;
24
32
  const pct = allTotal > 0 ? Math.round((allDone / allTotal) * 100) : 0;
25
- return (c.bold(`Crawling: ${internalDone} done / ${internalTotal} found`) +
26
- c.dim(` (+${externalDone}/${externalTotal} ext)`) +
27
- c.bold(` (${pct}%) [${totalRemaining} remaining]`) +
33
+ return (c.bold(`Crawling: ${countFormat.format(internalDone)}(${countFormat.format(pagesScraped)}) done / ${countFormat.format(internalTotal)} found URLs`) +
34
+ c.dim(` (+${countFormat.format(externalDone)}/${countFormat.format(externalTotal)} ext)`) +
35
+ c.bold(` (${pct}%) [${countFormat.format(totalRemaining)} remaining]`) +
28
36
  c.dim(` [${limit} parallel]`));
29
37
  }
@@ -0,0 +1,29 @@
1
+ import type { ClosableBrowser } from './close-browser-safely.js';
2
+ /**
3
+ * Debug-style logger compatible with the `debug` package's printf-style API.
4
+ *
5
+ * Declared structurally so the function stays unit-testable with a `vi.fn()`
6
+ * stub and free of a `debug` import.
7
+ */
8
+ export type BrowserCloseLogger = (
9
+ /** printf-style format string (e.g. `'%s'`, `'%O'`). */
10
+ formatter: string,
11
+ /** Arguments interpolated into the format string. */
12
+ ...args: readonly unknown[]) => void;
13
+ /**
14
+ * Closes a Puppeteer browser used to scrape a single URL, recording any
15
+ * timeout fallback or unexpected cleanup error to the supplied logger.
16
+ *
17
+ * WHY a dedicated function: the prior inline `finally` in
18
+ * {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
19
+ * production log formatting. Splitting it out makes the two observable
20
+ * branches (force-kill notice and unexpected-error notice) directly testable
21
+ * without spawning a real browser, and keeps the rule that a finally block
22
+ * never throws: any error from {@link closeBrowserSafely} is logged here,
23
+ * never re-thrown.
24
+ * @param browser - The browser to close.
25
+ * @param urlHref - URL string included in the log messages for diagnostic
26
+ * context.
27
+ * @param log - Logger used to record timeout and error events.
28
+ */
29
+ export declare function handleBrowserClose(browser: ClosableBrowser, urlHref: string, log: BrowserCloseLogger): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { closeBrowserSafely } from './close-browser-safely.js';
2
+ /**
3
+ * Closes a Puppeteer browser used to scrape a single URL, recording any
4
+ * timeout fallback or unexpected cleanup error to the supplied logger.
5
+ *
6
+ * WHY a dedicated function: the prior inline `finally` in
7
+ * {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
8
+ * production log formatting. Splitting it out makes the two observable
9
+ * branches (force-kill notice and unexpected-error notice) directly testable
10
+ * without spawning a real browser, and keeps the rule that a finally block
11
+ * never throws: any error from {@link closeBrowserSafely} is logged here,
12
+ * never re-thrown.
13
+ * @param browser - The browser to close.
14
+ * @param urlHref - URL string included in the log messages for diagnostic
15
+ * context.
16
+ * @param log - Logger used to record timeout and error events.
17
+ */
18
+ export async function handleBrowserClose(browser, urlHref, log) {
19
+ try {
20
+ const timedOut = await closeBrowserSafely(browser);
21
+ if (timedOut) {
22
+ log('Force-killed wedged Chromium browser for %s (close() timed out)', urlHref);
23
+ }
24
+ }
25
+ catch (error) {
26
+ log('closeBrowserSafely failed for %s: %O', urlHref, error);
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045), and values captured from
5
+ * Puppeteer responses preserve the server's original casing, so the
6
+ * comparison must normalize case — `text/HTML` is HTML. Surrounding
7
+ * whitespace (e.g. `text/html ` left over after parameter stripping)
8
+ * is also tolerated.
9
+ *
10
+ * This is the single source of truth for HTML detection — `Page.isPage()`
11
+ * and the link list delegate here so the classification never diverges
12
+ * between code paths.
13
+ * @param contentType - The media type portion of a Content-Type header
14
+ * (parameters already stripped), or `null` when unknown.
15
+ * @returns `true` when the media type is `text/html` in any letter case.
16
+ */
17
+ export declare function isHtmlContentType(contentType: string | null): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Determine whether a Content-Type media type is HTML.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045), and values captured from
5
+ * Puppeteer responses preserve the server's original casing, so the
6
+ * comparison must normalize case — `text/HTML` is HTML. Surrounding
7
+ * whitespace (e.g. `text/html ` left over after parameter stripping)
8
+ * is also tolerated.
9
+ *
10
+ * This is the single source of truth for HTML detection — `Page.isPage()`
11
+ * and the link list delegate here so the classification never diverges
12
+ * between code paths.
13
+ * @param contentType - The media type portion of a Content-Type header
14
+ * (parameters already stripped), or `null` when unknown.
15
+ * @returns `true` when the media type is `text/html` in any letter case.
16
+ */
17
+ export function isHtmlContentType(contentType) {
18
+ return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
19
+ }
@@ -0,0 +1,22 @@
1
+ import type { ExURL } from '@d-zero/shared/parse-url';
2
+ /**
3
+ * Heuristically decide whether a discovered URL is likely to resolve to an
4
+ * HTML page, based solely on the URL itself.
5
+ *
6
+ * WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
7
+ * `Content-Type` is unknown. The crawler uses the result to prioritise the
8
+ * dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
9
+ * advances ahead of asset/document fetches), so a heuristic is acceptable: a
10
+ * misclassification only changes fetch order, never correctness.
11
+ *
12
+ * Classification rules:
13
+ * - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
14
+ * - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
15
+ * URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
16
+ * the overwhelmingly common shape for navigable pages.
17
+ * - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
18
+ * extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
19
+ * @param url - The parsed URL to classify.
20
+ * @returns `true` when the URL is likely an HTML page.
21
+ */
22
+ export declare function isLikelyHtmlUrl(url: ExURL): boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * File extensions (lowercased, leading dot included) that typically denote a
3
+ * document served as `text/html`. Both static pages (`.html`) and the common
4
+ * server-side template / handler extensions (`.php`, `.aspx`, `.jsp`, `.ashx`,
5
+ * `.jsf` …) are listed because they usually serve an HTML page.
6
+ *
7
+ * A few entries (`.cgi`, `.do`, `.action`) are ambiguous — they sometimes
8
+ * return JSON or binary — but are kept here because a misclassification only
9
+ * changes fetch order, never correctness (see {@link isLikelyHtmlUrl}).
10
+ *
11
+ * Keys keep the leading dot so they can be compared directly against
12
+ * `ExURL.extname` (Node `Path.extname` output) without stripping it.
13
+ */
14
+ const HTML_EXTENSIONS = new Set([
15
+ '.html',
16
+ '.htm',
17
+ '.xhtml',
18
+ '.shtml',
19
+ '.mhtml',
20
+ '.php',
21
+ '.php3',
22
+ '.php4',
23
+ '.php5',
24
+ '.phtml',
25
+ '.asp',
26
+ '.aspx',
27
+ '.ashx',
28
+ '.jsp',
29
+ '.jspx',
30
+ '.jsf',
31
+ '.cfm',
32
+ '.cgi',
33
+ '.do',
34
+ '.action',
35
+ ]);
36
+ /**
37
+ * Heuristically decide whether a discovered URL is likely to resolve to an
38
+ * HTML page, based solely on the URL itself.
39
+ *
40
+ * WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
41
+ * `Content-Type` is unknown. The crawler uses the result to prioritise the
42
+ * dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
43
+ * advances ahead of asset/document fetches), so a heuristic is acceptable: a
44
+ * misclassification only changes fetch order, never correctness.
45
+ *
46
+ * Classification rules:
47
+ * - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
48
+ * - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
49
+ * URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
50
+ * the overwhelmingly common shape for navigable pages.
51
+ * - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
52
+ * extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
53
+ * @param url - The parsed URL to classify.
54
+ * @returns `true` when the URL is likely an HTML page.
55
+ */
56
+ export function isLikelyHtmlUrl(url) {
57
+ if (!url.isHTTP) {
58
+ return false;
59
+ }
60
+ const extname = url.extname;
61
+ if (!extname || extname === '.') {
62
+ return true;
63
+ }
64
+ return HTML_EXTENSIONS.has(extname.toLowerCase());
65
+ }