@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,94 @@
1
+ import type { ChildProcess, SpawnOptions } from 'node:child_process';
2
+ /**
3
+ * Sends a signal to a single process.
4
+ *
5
+ * Abstracted from `process.kill` so tests can verify exactly which PIDs are
6
+ * signalled without actually touching the OS.
7
+ */
8
+ export interface ProcessKiller {
9
+ /**
10
+ * Sends `signal` to the process with the given PID. Implementations should
11
+ * swallow ESRCH ("no such process") since the target may have already
12
+ * exited between enumeration and the kill call.
13
+ * @param pid - The PID to signal.
14
+ * @param signal - The signal name (e.g. `'SIGKILL'`) or number.
15
+ */
16
+ kill(pid: number, signal: NodeJS.Signals | number): void;
17
+ }
18
+ /**
19
+ * Spawner signature accepted by `KillProcessTreeDeps`. Pins
20
+ * `child_process.spawn` to the `(command, args, options)` overload — the
21
+ * bare import is a union of many overloads (including `(command, options)`)
22
+ * that does not narrow to this shape without a wrapper. Tests can pass a
23
+ * `vi.fn()` returning a tiny EventEmitter cast to `ChildProcess`.
24
+ */
25
+ export type Spawner = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
26
+ /**
27
+ * Debug logger compatible with the `debug` package's printf-style API.
28
+ *
29
+ * Called only when a best-effort kill path fails (ESRCH, ENOENT for `ps` or
30
+ * `taskkill`, non-zero exit code). Production callers should pass the
31
+ * crawler-namespaced logger so failures show up under
32
+ * `DEBUG=Nitpicker:Crawler`.
33
+ */
34
+ export type KillProcessTreeLogger = (
35
+ /** printf-style format string. */
36
+ formatter: string,
37
+ /** Arguments interpolated into the format string. */
38
+ ...args: readonly unknown[]) => void;
39
+ /**
40
+ * Dependency overrides for {@link killProcessTree}.
41
+ *
42
+ * Default implementations shell out to `ps` (POSIX) or `taskkill` (Windows),
43
+ * which makes them awkward to unit-test directly. Injecting these lets tests
44
+ * verify the orchestration without invoking real processes.
45
+ */
46
+ export interface KillProcessTreeDeps {
47
+ /**
48
+ * Lists the PIDs of every descendant of `rootPid` in BFS order
49
+ * (parents before children). Used only on POSIX.
50
+ */
51
+ listDescendants?: (rootPid: number) => Promise<readonly number[]>;
52
+ /** Signals a single PID. Used only on POSIX. */
53
+ killer?: ProcessKiller;
54
+ /**
55
+ * Performs an OS-level tree-kill. Used only on Windows, where `taskkill
56
+ * /T /F` handles the entire tree atomically.
57
+ */
58
+ runTreeKill?: (rootPid: number) => Promise<void>;
59
+ /** Override for `process.platform` so cross-platform paths can be tested. */
60
+ platform?: NodeJS.Platform;
61
+ /**
62
+ * `child_process.spawn` substitute used when `runTreeKill` is not
63
+ * supplied (i.e. the default Windows path). Lets tests assert the exact
64
+ * command + args without mocking the global module.
65
+ */
66
+ spawn?: Spawner;
67
+ /**
68
+ * Receives a single line per best-effort failure path (ENOENT,
69
+ * non-zero exit, etc.). Defaults to a no-op so production callers stay
70
+ * silent unless they opt in.
71
+ */
72
+ log?: KillProcessTreeLogger;
73
+ }
74
+ /**
75
+ * Kills a process and every one of its descendants.
76
+ *
77
+ * WHY: After SIGKILL'ing Chromium's parent process, its renderer / network /
78
+ * zygote subprocesses linger on Linux/macOS because puppeteer spawns Chromium
79
+ * with `detached: false`, so we cannot use a process-group signal (a
80
+ * negative-PID kill would also hit our own Node process). Enumerating
81
+ * descendants via `ps -A -o pid=,ppid=` and signalling each one — leaves
82
+ * first — closes the orphan gap. On Windows `taskkill /T /F /PID <pid>`
83
+ * performs the equivalent OS-level tree kill atomically.
84
+ *
85
+ * Best-effort: ESRCH (process already gone) and `ps`/`taskkill` invocation
86
+ * failures are swallowed, so the function never rejects on a partial-kill
87
+ * outcome. The caller treats this as a hard cleanup that must always
88
+ * resolve.
89
+ * @param rootPid - The PID at the root of the tree.
90
+ * @param signal - The signal to send. Defaults to `'SIGKILL'`. Ignored on
91
+ * Windows (`taskkill /F` is always forceful).
92
+ * @param deps - Test-time overrides.
93
+ */
94
+ export declare function killProcessTree(rootPid: number, signal?: NodeJS.Signals | number, deps?: KillProcessTreeDeps): Promise<void>;
@@ -0,0 +1,178 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ /**
3
+ * Kills a process and every one of its descendants.
4
+ *
5
+ * WHY: After SIGKILL'ing Chromium's parent process, its renderer / network /
6
+ * zygote subprocesses linger on Linux/macOS because puppeteer spawns Chromium
7
+ * with `detached: false`, so we cannot use a process-group signal (a
8
+ * negative-PID kill would also hit our own Node process). Enumerating
9
+ * descendants via `ps -A -o pid=,ppid=` and signalling each one — leaves
10
+ * first — closes the orphan gap. On Windows `taskkill /T /F /PID <pid>`
11
+ * performs the equivalent OS-level tree kill atomically.
12
+ *
13
+ * Best-effort: ESRCH (process already gone) and `ps`/`taskkill` invocation
14
+ * failures are swallowed, so the function never rejects on a partial-kill
15
+ * outcome. The caller treats this as a hard cleanup that must always
16
+ * resolve.
17
+ * @param rootPid - The PID at the root of the tree.
18
+ * @param signal - The signal to send. Defaults to `'SIGKILL'`. Ignored on
19
+ * Windows (`taskkill /F` is always forceful).
20
+ * @param deps - Test-time overrides.
21
+ */
22
+ export async function killProcessTree(rootPid, signal = 'SIGKILL', deps = {}) {
23
+ const platform = deps.platform ?? process.platform;
24
+ const log = deps.log ?? noopLog;
25
+ const spawner = deps.spawn ?? defaultSpawner;
26
+ if (platform === 'win32') {
27
+ const runTreeKill = deps.runTreeKill ?? ((pid) => runWindowsTaskkill(pid, spawner, log));
28
+ await runTreeKill(rootPid);
29
+ return;
30
+ }
31
+ const listDescendants = deps.listDescendants ?? ((pid) => listPosixDescendants(pid, spawner, log));
32
+ const killer = deps.killer ?? makePosixDefaultKiller(log);
33
+ const descendants = await listDescendants(rootPid);
34
+ // BFS produces parents before children; reverse so leaves die first and
35
+ // cannot re-spawn anything via their own watchdog before their parent goes.
36
+ for (const pid of descendants.toReversed()) {
37
+ killer.kill(pid, signal);
38
+ }
39
+ killer.kill(rootPid, signal);
40
+ }
41
+ /** No-op `KillProcessTreeLogger` used when the caller does not supply one. */
42
+ const noopLog = () => { };
43
+ /**
44
+ * Default {@link Spawner}: a thin wrapper around `node:child_process.spawn`
45
+ * that pins the call signature to `(command, args, options)`. The bare
46
+ * `spawn` import has many overloads — including `(command, options)` — so it
47
+ * is not assignable to our narrower `Spawner` shape; wrapping it removes the
48
+ * variance without changing runtime behaviour.
49
+ * @param command
50
+ * @param args
51
+ * @param options
52
+ */
53
+ const defaultSpawner = (command, args, options) => nodeSpawn(command, args, options);
54
+ /**
55
+ * Builds the default POSIX killer: `process.kill` with ESRCH/EPERM swallowed
56
+ * and logged via `log` so production callers can observe how often the tree
57
+ * walk hits already-dead PIDs.
58
+ * @param log - Receives one line per swallowed error.
59
+ * @returns A {@link ProcessKiller}.
60
+ */
61
+ function makePosixDefaultKiller(log) {
62
+ return {
63
+ kill(pid, signal) {
64
+ try {
65
+ process.kill(pid, signal);
66
+ }
67
+ catch (error) {
68
+ // Already dead (ESRCH) or denied (EPERM) — best-effort.
69
+ log('process.kill(%d, %s) failed: %O', pid, String(signal), error);
70
+ }
71
+ },
72
+ };
73
+ }
74
+ /**
75
+ * Walks the process table on POSIX and returns every descendant of
76
+ * `rootPid` in BFS order.
77
+ *
78
+ * Shells out to `ps -A -o pid=,ppid=` via `spawn` (no shell), parses each
79
+ * row into `(pid, ppid)`, builds a parent-to-children map, then breadth-first
80
+ * walks from `rootPid`.
81
+ *
82
+ * Failure to invoke `ps` (missing binary, permission denied, non-zero exit)
83
+ * returns an empty array so the caller can still kill the root.
84
+ * @param rootPid - The PID whose descendants to list.
85
+ * @param spawner
86
+ * @param log
87
+ * @returns A promise resolving to descendant PIDs.
88
+ */
89
+ async function listPosixDescendants(rootPid, spawner, log) {
90
+ const parentToChildren = await readPosixProcessMap(spawner, log);
91
+ const descendants = [];
92
+ const queue = [rootPid];
93
+ while (queue.length > 0) {
94
+ const pid = queue.shift();
95
+ const children = parentToChildren.get(pid);
96
+ if (!children)
97
+ continue;
98
+ for (const child of children) {
99
+ descendants.push(child);
100
+ queue.push(child);
101
+ }
102
+ }
103
+ return descendants;
104
+ }
105
+ /**
106
+ * Reads the full POSIX process table by spawning `ps` and parsing its output.
107
+ *
108
+ * Format requested: `pid=,ppid=` (no headers). Each line is `<pid> <ppid>`.
109
+ * @param spawner - Substitute for `child_process.spawn`.
110
+ * @param log - Receives a single line if `ps` cannot be invoked or exits non-zero.
111
+ * @returns A promise resolving to a parent-PID-to-children map. Empty on
112
+ * any invocation failure.
113
+ */
114
+ async function readPosixProcessMap(spawner, log) {
115
+ return new Promise((resolve) => {
116
+ const proc = spawner('ps', ['-A', '-o', 'pid=,ppid='], {
117
+ stdio: ['ignore', 'pipe', 'ignore'],
118
+ });
119
+ let output = '';
120
+ proc.stdout?.on('data', (chunk) => {
121
+ output += chunk.toString('utf8');
122
+ });
123
+ proc.on('error', (error) => {
124
+ log('ps invocation failed: %O', error);
125
+ resolve(new Map());
126
+ });
127
+ proc.on('close', (code) => {
128
+ if (code !== 0) {
129
+ log('ps exited with non-zero code %s — skipping descendant tree-kill', String(code));
130
+ resolve(new Map());
131
+ return;
132
+ }
133
+ const map = new Map();
134
+ for (const line of output.split('\n')) {
135
+ const match = /^\s*(\d+)\s+(\d+)/.exec(line);
136
+ if (!match)
137
+ continue;
138
+ const pid = Number(match[1]);
139
+ const ppid = Number(match[2]);
140
+ const list = map.get(ppid);
141
+ if (list) {
142
+ list.push(pid);
143
+ }
144
+ else {
145
+ map.set(ppid, [pid]);
146
+ }
147
+ }
148
+ resolve(map);
149
+ });
150
+ });
151
+ }
152
+ /**
153
+ * Forces a tree-kill on Windows via `taskkill /T /F /PID <pid>`.
154
+ *
155
+ * The `/T` flag walks descendants; `/F` forces termination. Any failure
156
+ * (ENOENT for taskkill, non-zero exit because the PID is already gone) is
157
+ * logged via `log` and swallowed so the caller's cleanup always resolves.
158
+ * @param rootPid - The PID at the root of the tree.
159
+ * @param spawner - Substitute for `child_process.spawn`.
160
+ * @param log - Receives a single line per failure.
161
+ */
162
+ async function runWindowsTaskkill(rootPid, spawner, log) {
163
+ return new Promise((resolve) => {
164
+ const proc = spawner('taskkill', ['/T', '/F', '/PID', String(rootPid)], {
165
+ stdio: 'ignore',
166
+ });
167
+ proc.on('error', (error) => {
168
+ log('taskkill invocation failed: %O', error);
169
+ resolve();
170
+ });
171
+ proc.on('close', (code) => {
172
+ if (code !== 0) {
173
+ log('taskkill exited with non-zero code %s for PID %d', String(code), rootPid);
174
+ }
175
+ resolve();
176
+ });
177
+ });
178
+ }
@@ -1,6 +1,7 @@
1
1
  import { isError } from '@d-zero/beholder';
2
2
  import { isLowerLayer } from '@d-zero/shared/is-lower-layer';
3
3
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
4
+ import { isHtmlContentType } from './is-html-content-type.js';
4
5
  import { protocolAgnosticKey } from './protocol-agnostic-key.js';
5
6
  /**
6
7
  * Manages the queue of URLs discovered during crawling.
@@ -241,7 +242,7 @@ function isPage(link) {
241
242
  if (isError(link.dest.status)) {
242
243
  return false;
243
244
  }
244
- if (link.dest.contentType === 'text/html') {
245
+ if (isHtmlContentType(link.dest.contentType)) {
245
246
  return true;
246
247
  }
247
248
  return false;
@@ -2,12 +2,20 @@ import type { Link, PageData } from '../utils/types/types.js';
2
2
  /**
3
3
  * Convert a {@link Link} object into a {@link PageData} structure.
4
4
  *
5
- * Creates a minimal PageData from the link's destination metadata. This is used
6
- * when a full scrape is not performed (e.g., for external pages when
7
- * `fetchExternal` is disabled, or when a scrape error produces a fallback result).
5
+ * Creates a minimal PageData from the link's destination metadata. This is
6
+ * used when a full scrape is not performed (e.g., for external pages when
7
+ * `fetchExternal` is disabled, or when a scrape error produces a fallback
8
+ * result).
8
9
  *
9
- * Missing destination fields are filled with sensible defaults (e.g., status -1
10
- * for unknown, empty arrays for anchors/images, empty string for HTML).
10
+ * Missing destination fields are filled with sensible defaults (e.g.,
11
+ * status -1 for unknown, empty arrays for anchors/images, empty string for
12
+ * HTML).
13
+ *
14
+ * beholder 3.0.0 elevated several Meta sub-fields from "optional" to
15
+ * "required array / object": `jsonLd`, `speculationRules`, `tags`, `others`,
16
+ * `originTrial`. The dummy meta object below populates every required slot
17
+ * so downstream consumers (database `#insertJsonLd` / `#insertTags`,
18
+ * `deriveFlatFromMeta`) never hit `undefined` while iterating.
11
19
  * @param link - The link to convert, containing URL and optional destination metadata.
12
20
  * @returns A PageData object populated from the link's available data.
13
21
  */
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * Convert a {@link Link} object into a {@link PageData} structure.
3
3
  *
4
- * Creates a minimal PageData from the link's destination metadata. This is used
5
- * when a full scrape is not performed (e.g., for external pages when
6
- * `fetchExternal` is disabled, or when a scrape error produces a fallback result).
4
+ * Creates a minimal PageData from the link's destination metadata. This is
5
+ * used when a full scrape is not performed (e.g., for external pages when
6
+ * `fetchExternal` is disabled, or when a scrape error produces a fallback
7
+ * result).
7
8
  *
8
- * Missing destination fields are filled with sensible defaults (e.g., status -1
9
- * for unknown, empty arrays for anchors/images, empty string for HTML).
9
+ * Missing destination fields are filled with sensible defaults (e.g.,
10
+ * status -1 for unknown, empty arrays for anchors/images, empty string for
11
+ * HTML).
12
+ *
13
+ * beholder 3.0.0 elevated several Meta sub-fields from "optional" to
14
+ * "required array / object": `jsonLd`, `speculationRules`, `tags`, `others`,
15
+ * `originTrial`. The dummy meta object below populates every required slot
16
+ * so downstream consumers (database `#insertJsonLd` / `#insertTags`,
17
+ * `deriveFlatFromMeta`) never hit `undefined` while iterating.
10
18
  * @param link - The link to convert, containing URL and optional destination metadata.
11
19
  * @returns A PageData object populated from the link's available data.
12
20
  */
@@ -23,6 +31,19 @@ export function linkToPageData(link) {
23
31
  responseHeaders: link.dest?.responseHeaders || null,
24
32
  meta: {
25
33
  title: link.dest?.title || '',
34
+ jsonLd: [],
35
+ speculationRules: [],
36
+ tags: { detected: {}, entries: [] },
37
+ others: {
38
+ meta: {},
39
+ property: {},
40
+ httpEquiv: {},
41
+ itemprop: {},
42
+ link: [],
43
+ script: [],
44
+ iframe: [],
45
+ },
46
+ originTrial: [],
26
47
  },
27
48
  anchorList: [],
28
49
  imageList: [],
@@ -0,0 +1,37 @@
1
+ import type { BufferedPhaseError } from './drain-phase-errors.js';
2
+ /**
3
+ * Debug logger compatible with the `debug` package's printf-style API.
4
+ *
5
+ * Receives a single line per URL whose buffered phase errors were
6
+ * undrained at finally time.
7
+ */
8
+ export type PhaseErrorDropLogger = (
9
+ /** printf-style format string. */
10
+ formatter: string,
11
+ /** Arguments interpolated into the format string. */
12
+ ...args: readonly unknown[]) => void;
13
+ /**
14
+ * Logs and clears any phase errors still buffered for `urlHref` at
15
+ * worker-finally time.
16
+ *
17
+ * WHY: a Crawler worker's finally clause runs for every code path —
18
+ * success, hard error, and the early `return` taken for predicted URLs
19
+ * that were discarded after probing. The success and catch branches
20
+ * drain via `drainPhaseErrors`, but the predicted-discard branch
21
+ * skips drain entirely, so any `retryExhausted` events captured during
22
+ * its probe would silently leak from the Map.
23
+ *
24
+ * Calling this function as the final cleanup step:
25
+ * - Surfaces the drop via `DEBUG=Nitpicker:Crawler` so production runs
26
+ * are observable instead of silent.
27
+ * - Removes the buffer entry so it does not grow across crawls.
28
+ *
29
+ * Safe to call after a successful drain: the buffer entry is already
30
+ * gone and both the log and the delete become no-ops.
31
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
32
+ * @param urlHref - URL whose buffer entry should be flushed.
33
+ * @param log - Receives one line if any undrained errors are present.
34
+ * @returns The number of phase-error records dropped (0 when nothing
35
+ * was buffered).
36
+ */
37
+ export declare function logUndrainedPhaseErrors(buffer: Map<string, BufferedPhaseError[]>, urlHref: string, log: PhaseErrorDropLogger): number;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Logs and clears any phase errors still buffered for `urlHref` at
3
+ * worker-finally time.
4
+ *
5
+ * WHY: a Crawler worker's finally clause runs for every code path —
6
+ * success, hard error, and the early `return` taken for predicted URLs
7
+ * that were discarded after probing. The success and catch branches
8
+ * drain via `drainPhaseErrors`, but the predicted-discard branch
9
+ * skips drain entirely, so any `retryExhausted` events captured during
10
+ * its probe would silently leak from the Map.
11
+ *
12
+ * Calling this function as the final cleanup step:
13
+ * - Surfaces the drop via `DEBUG=Nitpicker:Crawler` so production runs
14
+ * are observable instead of silent.
15
+ * - Removes the buffer entry so it does not grow across crawls.
16
+ *
17
+ * Safe to call after a successful drain: the buffer entry is already
18
+ * gone and both the log and the delete become no-ops.
19
+ * @param buffer - The pending-phase-errors map, keyed by URL href.
20
+ * @param urlHref - URL whose buffer entry should be flushed.
21
+ * @param log - Receives one line if any undrained errors are present.
22
+ * @returns The number of phase-error records dropped (0 when nothing
23
+ * was buffered).
24
+ */
25
+ export function logUndrainedPhaseErrors(buffer, urlHref, log) {
26
+ const remaining = buffer.get(urlHref);
27
+ if (!remaining || remaining.length === 0) {
28
+ buffer.delete(urlHref);
29
+ return 0;
30
+ }
31
+ log('Dropped %d phase error(s) for %s (no archive entry created)', remaining.length, urlHref);
32
+ buffer.delete(urlHref);
33
+ return remaining.length;
34
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Canonicalizes a Content-Type media type for storage.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045) and may arrive with surrounding
5
+ * whitespace (e.g. `text/html ` left after stripping the `; charset=...`
6
+ * parameter). Responses are recorded verbatim (`header.split(';')[0]`) without
7
+ * normalization, so `Text/HTML` or `text/html ` can otherwise reach the
8
+ * database. Storing the canonical (trimmed, lower-cased) form lets the exact
9
+ * SQL page-ness predicate (`WHERE contentType = 'text/html'`) agree with the
10
+ * code-level {@link isHtmlContentType} check, which trims and lower-cases.
11
+ * @param contentType - The raw media type, or `null` when unknown.
12
+ * @returns The trimmed, lower-cased media type, or `null` when unknown/blank.
13
+ */
14
+ export declare function normalizeContentType(contentType: string | null): string | null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Canonicalizes a Content-Type media type for storage.
3
+ *
4
+ * MIME types are case-insensitive (RFC 2045) and may arrive with surrounding
5
+ * whitespace (e.g. `text/html ` left after stripping the `; charset=...`
6
+ * parameter). Responses are recorded verbatim (`header.split(';')[0]`) without
7
+ * normalization, so `Text/HTML` or `text/html ` can otherwise reach the
8
+ * database. Storing the canonical (trimmed, lower-cased) form lets the exact
9
+ * SQL page-ness predicate (`WHERE contentType = 'text/html'`) agree with the
10
+ * code-level {@link isHtmlContentType} check, which trims and lower-cases.
11
+ * @param contentType - The raw media type, or `null` when unknown.
12
+ * @returns The trimmed, lower-cased media type, or `null` when unknown/blank.
13
+ */
14
+ export function normalizeContentType(contentType) {
15
+ if (contentType === null) {
16
+ return null;
17
+ }
18
+ const normalized = contentType.trim().toLowerCase();
19
+ return normalized === '' ? null : normalized;
20
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExURL } from '@d-zero/shared/parse-url';
2
+ /**
3
+ * Split URLs into a likely-HTML group and a non-HTML group, preserving input
4
+ * order within each group.
5
+ *
6
+ * Used by the crawler's enqueue path to route discovered URLs: likely-HTML URLs
7
+ * are `unshift`ed to the front of the dealer queue (so page crawling advances
8
+ * ahead of asset/document fetches) while the rest are `push`ed to the tail.
9
+ * Keeping the order stable within each group means a batch (e.g. predicted
10
+ * pagination URLs) stays in ascending order at the front when unshifted as one
11
+ * call. Classification is delegated to {@link isLikelyHtmlUrl}.
12
+ * @param urls - The discovered URLs to partition.
13
+ * @returns A `[html, other]` tuple: likely-HTML URLs and the remainder, each in
14
+ * original input order.
15
+ */
16
+ export declare function partitionUrlsByHtml(urls: readonly ExURL[]): [html: ExURL[], other: ExURL[]];
@@ -0,0 +1,23 @@
1
+ import { isLikelyHtmlUrl } from './is-likely-html-url.js';
2
+ /**
3
+ * Split URLs into a likely-HTML group and a non-HTML group, preserving input
4
+ * order within each group.
5
+ *
6
+ * Used by the crawler's enqueue path to route discovered URLs: likely-HTML URLs
7
+ * are `unshift`ed to the front of the dealer queue (so page crawling advances
8
+ * ahead of asset/document fetches) while the rest are `push`ed to the tail.
9
+ * Keeping the order stable within each group means a batch (e.g. predicted
10
+ * pagination URLs) stays in ascending order at the front when unshifted as one
11
+ * call. Classification is delegated to {@link isLikelyHtmlUrl}.
12
+ * @param urls - The discovered URLs to partition.
13
+ * @returns A `[html, other]` tuple: likely-HTML URLs and the remainder, each in
14
+ * original input order.
15
+ */
16
+ export function partitionUrlsByHtml(urls) {
17
+ const html = [];
18
+ const other = [];
19
+ for (const url of urls) {
20
+ (isLikelyHtmlUrl(url) ? html : other).push(url);
21
+ }
22
+ return [html, other];
23
+ }
@@ -0,0 +1,19 @@
1
+ import type { ExURL } from '@d-zero/shared/parse-url';
2
+ /**
3
+ * Computes the protocol-agnostic dedup key for the final destination a request
4
+ * lands on after following its redirect chain.
5
+ *
6
+ * Used by the redirect-convergence optimisation (#73): the crawler remembers
7
+ * which final destinations have already been rendered, keyed by this value, so
8
+ * that many source URLs all redirecting to one destination render it only once.
9
+ * When there is no redirect, the destination is the requested URL itself.
10
+ *
11
+ * The key matches the form used elsewhere in the crawler (`protocolAgnosticKey`
12
+ * over the normalised URL without hash/auth) so HTTP and HTTPS variants of the
13
+ * same destination collapse to one entry.
14
+ * @param url - The originally requested URL.
15
+ * @param redirectPaths - The redirect hop URLs captured during the HEAD
16
+ * pre-flight, in order. The last entry is the final destination.
17
+ * @returns The protocol-agnostic key of the final destination.
18
+ */
19
+ export declare function redirectDestKey(url: ExURL, redirectPaths: readonly string[]): string;
@@ -0,0 +1,27 @@
1
+ import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
2
+ import { protocolAgnosticKey } from './protocol-agnostic-key.js';
3
+ /**
4
+ * Computes the protocol-agnostic dedup key for the final destination a request
5
+ * lands on after following its redirect chain.
6
+ *
7
+ * Used by the redirect-convergence optimisation (#73): the crawler remembers
8
+ * which final destinations have already been rendered, keyed by this value, so
9
+ * that many source URLs all redirecting to one destination render it only once.
10
+ * When there is no redirect, the destination is the requested URL itself.
11
+ *
12
+ * The key matches the form used elsewhere in the crawler (`protocolAgnosticKey`
13
+ * over the normalised URL without hash/auth) so HTTP and HTTPS variants of the
14
+ * same destination collapse to one entry.
15
+ * @param url - The originally requested URL.
16
+ * @param redirectPaths - The redirect hop URLs captured during the HEAD
17
+ * pre-flight, in order. The last entry is the final destination.
18
+ * @returns The protocol-agnostic key of the final destination.
19
+ */
20
+ export function redirectDestKey(url, redirectPaths) {
21
+ const last = redirectPaths.at(-1);
22
+ if (last === undefined) {
23
+ return protocolAgnosticKey(url.withoutHashAndAuth);
24
+ }
25
+ const parsed = parseUrl(last);
26
+ return protocolAgnosticKey(parsed ? parsed.withoutHashAndAuth : last);
27
+ }
@@ -0,0 +1,28 @@
1
+ import type { ResourceLookupResult } from './types.js';
2
+ import type { PageData } from '../utils/types/types.js';
3
+ import type { ExURL } from '@d-zero/shared/parse-url';
4
+ /**
5
+ * Parameters for {@link resourceToPageData}.
6
+ */
7
+ interface ResourceToPageDataParams {
8
+ /** The queued URL being processed. */
9
+ readonly url: ExURL;
10
+ /** Whether the URL is external to the crawl scope. */
11
+ readonly isExternal: boolean;
12
+ /** The recorded sub-resource data captured during page rendering. */
13
+ readonly resource: ResourceLookupResult;
14
+ }
15
+ /**
16
+ * Synthesize {@link PageData} from a recorded sub-resource row, or return
17
+ * `null` when the resource is not eligible for reuse.
18
+ *
19
+ * Eligible: the status is 2xx AND the content type is known and not
20
+ * `text/html`. Non-2xx rows (redirect hops, errors, 304s) and HTML rows must
21
+ * fall back to the normal HEAD pre-flight / browser scrape, which also
22
+ * guarantees that `redirectPaths: []` here is accurate — a URL that
23
+ * redirects is recorded with its 3xx status and never reaches this path.
24
+ * @param params - The queued URL, its external flag, and the recorded resource data.
25
+ * @returns The synthesized page data, or `null` when the caller must fall back.
26
+ */
27
+ export declare function resourceToPageData(params: ResourceToPageDataParams): PageData | null;
28
+ export {};
@@ -0,0 +1,59 @@
1
+ import { isHtmlContentType } from './is-html-content-type.js';
2
+ /**
3
+ * Synthesize {@link PageData} from a recorded sub-resource row, or return
4
+ * `null` when the resource is not eligible for reuse.
5
+ *
6
+ * Eligible: the status is 2xx AND the content type is known and not
7
+ * `text/html`. Non-2xx rows (redirect hops, errors, 304s) and HTML rows must
8
+ * fall back to the normal HEAD pre-flight / browser scrape, which also
9
+ * guarantees that `redirectPaths: []` here is accurate — a URL that
10
+ * redirects is recorded with its 3xx status and never reaches this path.
11
+ * @param params - The queued URL, its external flag, and the recorded resource data.
12
+ * @returns The synthesized page data, or `null` when the caller must fall back.
13
+ */
14
+ export function resourceToPageData(params) {
15
+ const { url, isExternal, resource } = params;
16
+ if (resource.status == null ||
17
+ resource.status < 200 ||
18
+ resource.status >= 300 ||
19
+ resource.contentType == null ||
20
+ isHtmlContentType(resource.contentType)) {
21
+ return null;
22
+ }
23
+ return {
24
+ url,
25
+ redirectPaths: [],
26
+ isTarget: !isExternal,
27
+ isExternal,
28
+ status: resource.status,
29
+ statusText: resource.statusText ?? '',
30
+ contentType: resource.contentType,
31
+ contentLength: resource.contentLength,
32
+ responseHeaders: resource.responseHeaders,
33
+ // beholder 3.0.0 made jsonLd / speculationRules / tags / others /
34
+ // originTrial required Meta fields. This fallback wraps a non-HTML
35
+ // resource (PDF, image, etc.) that was discovered via subresource
36
+ // inspection rather than full scraping; populate every required slot
37
+ // so downstream insert/derive helpers iterate without crashing.
38
+ meta: {
39
+ title: '',
40
+ jsonLd: [],
41
+ speculationRules: [],
42
+ tags: { detected: {}, entries: [] },
43
+ others: {
44
+ meta: {},
45
+ property: {},
46
+ httpEquiv: {},
47
+ itemprop: {},
48
+ link: [],
49
+ script: [],
50
+ iframe: [],
51
+ },
52
+ originTrial: [],
53
+ },
54
+ anchorList: [],
55
+ imageList: [],
56
+ html: '',
57
+ isSkipped: false,
58
+ };
59
+ }