@nitpicker/crawler 0.9.0 → 0.12.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 (148) 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 +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -0,0 +1,63 @@
1
+ import { classifyErrorKind } from '../classify-error-kind.js';
2
+ /**
3
+ * Error kinds where a full puppeteer navigation has a realistic chance of
4
+ * succeeding even though the HEAD pre-flight (and its GET fallback) failed.
5
+ *
6
+ * These are the failure modes that a misconfigured WAF / middlebox / slow
7
+ * origin tends to produce against a bare HEAD/GET probe while still letting
8
+ * a real browser through — the browser uses a different request shape (full
9
+ * navigation lifecycle, JS-capable Accept headers, real cookies, optionally
10
+ * client TLS hints), and some hostile middleboxes only inspect the cheap
11
+ * shape. The fallback is one attempt only; if puppeteer also fails the URL
12
+ * is recorded as `status = -1` like before.
13
+ *
14
+ * Excluded kinds:
15
+ * - **dns / dns-transient** — DNS resolution happens at the OS level before
16
+ * any browser request; puppeteer hits the same `getaddrinfo` outcome.
17
+ * - **tls** — Chromium will refuse the same certificate the Node TLS stack
18
+ * refused (expired, wrong SAN, untrusted CA).
19
+ * - **client-blocked** — by definition the browser is the one rejecting.
20
+ * - **connection-refused** — TCP RST from the listener; same answer regardless
21
+ * of client.
22
+ * - **connection-timeout** — `ETIMEDOUT` at the TCP connect stage means the
23
+ * packets never reached the host (no SYN-ACK); puppeteer issues the same
24
+ * `connect()` call and gets the same answer. Reserved for the middlebox
25
+ * case (request reached the server, response timed out), which classifies
26
+ * as `timeout` via the `NetTimeoutError "Timeout: <url>"` shape.
27
+ * - **local-network** — operator-side connectivity loss; nothing on this
28
+ * machine will reach the host.
29
+ * - **protocol** — puppeteer lifecycle race; bouncing back to puppeteer
30
+ * reproduces the same race.
31
+ * - **unknown** — by design. Spinning up a fresh Chromium for every
32
+ * unclassifiable error is too expensive; if a real-world WAF / middlebox
33
+ * pattern lands in `unknown`, add a matcher to {@link classifyErrorKind}
34
+ * so it lands in one of the four included kinds above (where the fallback
35
+ * has a meaningful chance of succeeding) instead of widening this set.
36
+ *
37
+ * `PreloadShortCircuitError`'s synthesised `getaddrinfo ENOTFOUND` message
38
+ * classifies into `dns` and is therefore filtered out automatically — no
39
+ * separate instanceof guard is needed at the call site.
40
+ */
41
+ const PUPPETEER_FALLBACK_KINDS = new Set([
42
+ 'timeout',
43
+ 'connection-reset',
44
+ 'parse-error',
45
+ ]);
46
+ /**
47
+ * Decide whether a failed HEAD/GET pre-flight error message warrants one
48
+ * puppeteer fallback attempt. Pure and deterministic — the same message
49
+ * always gives the same answer, so the decision can be unit-tested without
50
+ * spinning up a browser.
51
+ * @param message - The pre-flight error message (typically the last rejected
52
+ * `retryCall` attempt).
53
+ * @returns `true` when puppeteer should be tried once, `false` to give up.
54
+ * @example
55
+ * ```ts
56
+ * isPuppeteerFallbackCandidate('Timeout: https://slow.example.org/'); // true
57
+ * isPuppeteerFallbackCandidate('getaddrinfo ENOTFOUND host.invalid'); // false
58
+ * isPuppeteerFallbackCandidate('net::ERR_CERT_DATE_INVALID'); // false
59
+ * ```
60
+ */
61
+ export function isPuppeteerFallbackCandidate(message) {
62
+ return PUPPETEER_FALLBACK_KINDS.has(classifyErrorKind(message));
63
+ }
@@ -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
+ }
@@ -45,12 +45,32 @@ export default class LinkList {
45
45
  * @param resource.page - The scraped page data, if the scrape succeeded.
46
46
  * @param resource.error - The error object, if the scrape failed.
47
47
  * @param options - URL parsing options (e.g., `disableQueries`).
48
+ * @param completion - Behaviour overrides for how the redirect chain is folded into
49
+ * the done-set.
50
+ * @param completion.includeRedirectPaths - When `false`, do NOT mark the URLs in
51
+ * `resource.page.redirectPaths` as done. The default `true` preserves the
52
+ * long-standing behaviour where a redirect chain (`/a → /b → /c`) folds every
53
+ * intermediate URL into the done-set in a single sweep — correct for HTTP-layer
54
+ * chains because the browser actually followed each hop, so reaching `/b` later
55
+ * is a no-op. Pass `false` from the JS-redirect rescue: there `redirectPaths`
56
+ * contains a single URL (the JS target Chromium navigated to after `page.goto()`
57
+ * returned null), which the browser has NOT yet rendered. Folding it into the
58
+ * done-set would make a subsequent `linkList.add(destinationUrl)` no-op (the
59
+ * add() guard at line 51-53 refuses keys already in `#done`), so the dest URL
60
+ * never enters `#pending` and the dealer never sees a push to enqueue. The
61
+ * dealer's own `seen` Set in `#runDeal` (the gate `onPush` consults) is a
62
+ * separate registry from `#done` — they are NOT kept in sync — but here it
63
+ * does not matter: the rescue's `add()` is what feeds the eventual `enqueue()`
64
+ * call, so blocking `add()` alone is enough to silently lose the JS target
65
+ * from the archive.
48
66
  * @returns The constructed {@link Link} object, or `null` if the URL was not in the queue.
49
67
  */
50
68
  done(url: ExURL, scope: ReadonlyMap<string, readonly ExURL[]>, resource: {
51
69
  page?: PageData;
52
70
  error?: Error;
53
- }, options: ParseURLOptions): Link | null;
71
+ }, options: ParseURLOptions, completion?: {
72
+ includeRedirectPaths?: boolean;
73
+ }): Link | null;
54
74
  /**
55
75
  * Get the current pending and in-progress URL lists.
56
76
  * @returns An object containing arrays of pending and in-progress URL strings.
@@ -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.
@@ -63,9 +64,27 @@ export default class LinkList {
63
64
  * @param resource.page - The scraped page data, if the scrape succeeded.
64
65
  * @param resource.error - The error object, if the scrape failed.
65
66
  * @param options - URL parsing options (e.g., `disableQueries`).
67
+ * @param completion - Behaviour overrides for how the redirect chain is folded into
68
+ * the done-set.
69
+ * @param completion.includeRedirectPaths - When `false`, do NOT mark the URLs in
70
+ * `resource.page.redirectPaths` as done. The default `true` preserves the
71
+ * long-standing behaviour where a redirect chain (`/a → /b → /c`) folds every
72
+ * intermediate URL into the done-set in a single sweep — correct for HTTP-layer
73
+ * chains because the browser actually followed each hop, so reaching `/b` later
74
+ * is a no-op. Pass `false` from the JS-redirect rescue: there `redirectPaths`
75
+ * contains a single URL (the JS target Chromium navigated to after `page.goto()`
76
+ * returned null), which the browser has NOT yet rendered. Folding it into the
77
+ * done-set would make a subsequent `linkList.add(destinationUrl)` no-op (the
78
+ * add() guard at line 51-53 refuses keys already in `#done`), so the dest URL
79
+ * never enters `#pending` and the dealer never sees a push to enqueue. The
80
+ * dealer's own `seen` Set in `#runDeal` (the gate `onPush` consults) is a
81
+ * separate registry from `#done` — they are NOT kept in sync — but here it
82
+ * does not matter: the rescue's `add()` is what feeds the eventual `enqueue()`
83
+ * call, so blocking `add()` alone is enough to silently lose the JS target
84
+ * from the archive.
66
85
  * @returns The constructed {@link Link} object, or `null` if the URL was not in the queue.
67
86
  */
68
- done(url, scope, resource, options) {
87
+ done(url, scope, resource, options, completion) {
69
88
  const key = protocolAgnosticKey(url.withoutHashAndAuth);
70
89
  if (!(this.#pending.has(key) || this.#progress.has(key))) {
71
90
  return null;
@@ -95,8 +114,10 @@ export default class LinkList {
95
114
  responseHeaders: resource.page.responseHeaders,
96
115
  title: resource.page.meta.title,
97
116
  };
98
- for (const path of resource.page.redirectPaths) {
99
- urlList.add(protocolAgnosticKey(path));
117
+ if (completion?.includeRedirectPaths !== false) {
118
+ for (const path of resource.page.redirectPaths) {
119
+ urlList.add(protocolAgnosticKey(path));
120
+ }
100
121
  }
101
122
  }
102
123
  if (resource.error?.message.includes('ERR_NAME_NOT_RESOLVED')) {
@@ -241,7 +262,7 @@ function isPage(link) {
241
262
  if (isError(link.dest.status)) {
242
263
  return false;
243
264
  }
244
- if (link.dest.contentType === 'text/html') {
265
+ if (isHtmlContentType(link.dest.contentType)) {
245
266
  return true;
246
267
  }
247
268
  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
+ }