@nitpicker/crawler 0.14.0 → 0.15.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 (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Companion to `dnsBurnedHostCache`: records the epoch-ms timestamp of each
3
+ * host burned by THIS session's `#sendHeadRequest` `onGiveUp` path (the
4
+ * `shouldBurnHost`-gated write in `crawler.ts`).
5
+ *
6
+ * Deliberately NOT populated by `#preloadDnsBurnedHostCache`'s seeding from
7
+ * a previous session's `crawl_errors` — those entries represent hosts
8
+ * already proven dead across sessions and must never be evicted just
9
+ * because THIS session's network happened to recover from an outage. Since
10
+ * `dnsBurnedHostCache`'s value (`ErrorKind`) carries no provenance of its
11
+ * own (preload-seeded and session-learned burns are indistinguishable by
12
+ * value alone), this separate timestamp map is what lets
13
+ * `evict-outage-tainted-dns-burns.ts` tell them apart: only hosts present
14
+ * HERE are eligible for eviction, and only if their burn timestamp falls
15
+ * inside the just-recovered outage's window.
16
+ */
17
+ export const dnsBurnedHostBurnTimestamps = new Map();
@@ -0,0 +1,26 @@
1
+ import type { PageData } from '@d-zero/beholder';
2
+ /**
3
+ * Delete every `destinationCache` entry whose cached value is an `Error`
4
+ * that classifies as a {@link NETWORK_RELATED_ERROR_KINDS network-related}
5
+ * kind, leaving successes and site-specific failures (`tls`,
6
+ * `client-blocked`, `connection-refused`, …) untouched.
7
+ *
8
+ * Called by `Crawler` whenever its network gate transitions from closed to
9
+ * open (whether via a successful recovery probe or an abort), so a URL
10
+ * that failed with `getaddrinfo ENOTFOUND` only because the operator's own
11
+ * network was down does not stay wrongly cached as "this host is dead" for
12
+ * the rest of the session.
13
+ *
14
+ * Deliberately does NOT distinguish "cached during THIS specific outage"
15
+ * from "cached during an earlier blip this session" — any cached error
16
+ * whose KIND looks network-related is, by definition, potentially stale
17
+ * evidence about the operator's network rather than the target site, so it
18
+ * is always safe to re-test after any recovery. The trade-off is a few
19
+ * redundant HEAD attempts for hosts that were already dead independent of
20
+ * the outage; the alternative (a stale "outage-tainted" failure verdict
21
+ * surviving the rest of the session) is strictly worse.
22
+ * @param cache - The cache to sweep. Takes the `Map` explicitly (rather
23
+ * than importing the `destinationCache` singleton directly) so this stays
24
+ * unit-testable without touching module-level state.
25
+ */
26
+ export declare function evictNetworkClassifiedDestinationCacheEntries(cache: Map<string, PageData | Error>): void;
@@ -0,0 +1,34 @@
1
+ import { classifyErrorKind } from '../classify-error-kind.js';
2
+ import { NETWORK_RELATED_ERROR_KINDS } from '../network-related-error-kinds.js';
3
+ /**
4
+ * Delete every `destinationCache` entry whose cached value is an `Error`
5
+ * that classifies as a {@link NETWORK_RELATED_ERROR_KINDS network-related}
6
+ * kind, leaving successes and site-specific failures (`tls`,
7
+ * `client-blocked`, `connection-refused`, …) untouched.
8
+ *
9
+ * Called by `Crawler` whenever its network gate transitions from closed to
10
+ * open (whether via a successful recovery probe or an abort), so a URL
11
+ * that failed with `getaddrinfo ENOTFOUND` only because the operator's own
12
+ * network was down does not stay wrongly cached as "this host is dead" for
13
+ * the rest of the session.
14
+ *
15
+ * Deliberately does NOT distinguish "cached during THIS specific outage"
16
+ * from "cached during an earlier blip this session" — any cached error
17
+ * whose KIND looks network-related is, by definition, potentially stale
18
+ * evidence about the operator's network rather than the target site, so it
19
+ * is always safe to re-test after any recovery. The trade-off is a few
20
+ * redundant HEAD attempts for hosts that were already dead independent of
21
+ * the outage; the alternative (a stale "outage-tainted" failure verdict
22
+ * surviving the rest of the session) is strictly worse.
23
+ * @param cache - The cache to sweep. Takes the `Map` explicitly (rather
24
+ * than importing the `destinationCache` singleton directly) so this stays
25
+ * unit-testable without touching module-level state.
26
+ */
27
+ export function evictNetworkClassifiedDestinationCacheEntries(cache) {
28
+ for (const [key, value] of cache) {
29
+ if (value instanceof Error &&
30
+ NETWORK_RELATED_ERROR_KINDS.has(classifyErrorKind(value.message))) {
31
+ cache.delete(key);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { OutageWindow } from '../is-within-outage-window.js';
2
+ import type { ErrorKind } from '../types.js';
3
+ /**
4
+ * Undo `dnsBurnedHostCache` burns whose recorded timestamp (see
5
+ * `dns-burned-host-burn-timestamps.ts`) falls inside `window` — i.e. hosts
6
+ * THIS session burned because a HEAD request failed with a
7
+ * `dns`-classified error while (or just before) the operator's own network
8
+ * was down, not because the host is actually dead.
9
+ *
10
+ * Preload-seeded burns are structurally immune: they never appear in
11
+ * `burnTimestamps` (see that module's docstring), so this function can
12
+ * never touch them regardless of the window.
13
+ * @param options - Named parameters.
14
+ * @param options.cache - `dnsBurnedHostCache` (or a test double with the same shape).
15
+ * @param options.burnTimestamps - `dnsBurnedHostBurnTimestamps` (or a test double).
16
+ * @param options.window - The just-resolved outage window (`{ startedAt, endedAt }`)
17
+ * to test each burn's timestamp against.
18
+ */
19
+ export declare function evictOutageTaintedDnsBurns(options: {
20
+ cache: Map<string, ErrorKind>;
21
+ burnTimestamps: Map<string, number>;
22
+ window: OutageWindow;
23
+ }): void;
@@ -0,0 +1,26 @@
1
+ import { isWithinOutageWindow } from '../is-within-outage-window.js';
2
+ /**
3
+ * Undo `dnsBurnedHostCache` burns whose recorded timestamp (see
4
+ * `dns-burned-host-burn-timestamps.ts`) falls inside `window` — i.e. hosts
5
+ * THIS session burned because a HEAD request failed with a
6
+ * `dns`-classified error while (or just before) the operator's own network
7
+ * was down, not because the host is actually dead.
8
+ *
9
+ * Preload-seeded burns are structurally immune: they never appear in
10
+ * `burnTimestamps` (see that module's docstring), so this function can
11
+ * never touch them regardless of the window.
12
+ * @param options - Named parameters.
13
+ * @param options.cache - `dnsBurnedHostCache` (or a test double with the same shape).
14
+ * @param options.burnTimestamps - `dnsBurnedHostBurnTimestamps` (or a test double).
15
+ * @param options.window - The just-resolved outage window (`{ startedAt, endedAt }`)
16
+ * to test each burn's timestamp against.
17
+ */
18
+ export function evictOutageTaintedDnsBurns(options) {
19
+ const { cache, burnTimestamps, window } = options;
20
+ for (const [host, burnedAt] of burnTimestamps) {
21
+ if (isWithinOutageWindow(burnedAt, [window])) {
22
+ cache.delete(host);
23
+ burnTimestamps.delete(host);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Level-triggered gate that dealer worker callbacks await before doing any
3
+ * network work. Achieves "pause the crawl until the network recovers"
4
+ * without touching `@d-zero/dealer` internals — a closed gate just makes
5
+ * the worker's task body a long-running `await`, which is all dealer ever
6
+ * sees.
7
+ *
8
+ * **`epoch`** counts outage generations: it increments on every `close()`
9
+ * transition (open → closed) and holds that value for the remainder of the
10
+ * closed period AND through the following open period, until the next
11
+ * `close()`. This is the hook `destinationCache` / `dnsBurnedHostCache`
12
+ * eviction uses: an entry written while the gate was closed is tagged with
13
+ * `gate.epoch` at write time; after `open()`, the caller purges entries
14
+ * tagged with that same epoch value. Because `epoch` only advances on
15
+ * `close()` (never on `open()`), entries written during ordinary (open)
16
+ * operation are never mistakenly tagged with a soon-to-be-purged epoch —
17
+ * only writes that happen while `isOpen` is `false` get tagged at all.
18
+ */
19
+ export default class NetworkGate {
20
+ #private;
21
+ /** `true` when the gate is open (the default). */
22
+ get isOpen(): boolean;
23
+ /**
24
+ * Current outage-generation counter. See the class docstring for how
25
+ * cache-eviction callers are expected to use this value.
26
+ */
27
+ get epoch(): number;
28
+ /**
29
+ * Close the gate, blocking future {@link wait} callers until the next
30
+ * {@link open}. Idempotent — calling `close()` while already closed does
31
+ * nothing (in particular, it does NOT bump {@link epoch} a second time,
32
+ * which would otherwise let a still-open outage's cache entries escape
33
+ * eviction under a stale epoch number).
34
+ */
35
+ close(): void;
36
+ /**
37
+ * Open the gate, resolving every {@link wait} caller currently pending —
38
+ * including ones that subscribed after `close()` but before this call.
39
+ * Idempotent — calling `open()` while already open does nothing.
40
+ */
41
+ open(): void;
42
+ /**
43
+ * Resolve immediately if the gate is open; otherwise resolve on the next
44
+ * {@link open}. Multiple concurrent callers all resolve on the same
45
+ * `open()` call — no awaiter is dropped regardless of when it subscribed
46
+ * relative to others.
47
+ */
48
+ wait(): Promise<void>;
49
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Level-triggered gate that dealer worker callbacks await before doing any
3
+ * network work. Achieves "pause the crawl until the network recovers"
4
+ * without touching `@d-zero/dealer` internals — a closed gate just makes
5
+ * the worker's task body a long-running `await`, which is all dealer ever
6
+ * sees.
7
+ *
8
+ * **`epoch`** counts outage generations: it increments on every `close()`
9
+ * transition (open → closed) and holds that value for the remainder of the
10
+ * closed period AND through the following open period, until the next
11
+ * `close()`. This is the hook `destinationCache` / `dnsBurnedHostCache`
12
+ * eviction uses: an entry written while the gate was closed is tagged with
13
+ * `gate.epoch` at write time; after `open()`, the caller purges entries
14
+ * tagged with that same epoch value. Because `epoch` only advances on
15
+ * `close()` (never on `open()`), entries written during ordinary (open)
16
+ * operation are never mistakenly tagged with a soon-to-be-purged epoch —
17
+ * only writes that happen while `isOpen` is `false` get tagged at all.
18
+ */
19
+ export default class NetworkGate {
20
+ #epoch = 0;
21
+ #open = true;
22
+ #waiters = [];
23
+ /** `true` when the gate is open (the default). */
24
+ get isOpen() {
25
+ return this.#open;
26
+ }
27
+ /**
28
+ * Current outage-generation counter. See the class docstring for how
29
+ * cache-eviction callers are expected to use this value.
30
+ */
31
+ get epoch() {
32
+ return this.#epoch;
33
+ }
34
+ /**
35
+ * Close the gate, blocking future {@link wait} callers until the next
36
+ * {@link open}. Idempotent — calling `close()` while already closed does
37
+ * nothing (in particular, it does NOT bump {@link epoch} a second time,
38
+ * which would otherwise let a still-open outage's cache entries escape
39
+ * eviction under a stale epoch number).
40
+ */
41
+ close() {
42
+ if (!this.#open) {
43
+ return;
44
+ }
45
+ this.#open = false;
46
+ this.#epoch += 1;
47
+ }
48
+ /**
49
+ * Open the gate, resolving every {@link wait} caller currently pending —
50
+ * including ones that subscribed after `close()` but before this call.
51
+ * Idempotent — calling `open()` while already open does nothing.
52
+ */
53
+ open() {
54
+ if (this.#open) {
55
+ return;
56
+ }
57
+ this.#open = true;
58
+ const waiters = this.#waiters;
59
+ this.#waiters = [];
60
+ for (const resolve of waiters) {
61
+ resolve();
62
+ }
63
+ }
64
+ /**
65
+ * Resolve immediately if the gate is open; otherwise resolve on the next
66
+ * {@link open}. Multiple concurrent callers all resolve on the same
67
+ * `open()` call — no awaiter is dropped regardless of when it subscribed
68
+ * relative to others.
69
+ */
70
+ wait() {
71
+ if (this.#open) {
72
+ return Promise.resolve();
73
+ }
74
+ return new Promise((resolve) => {
75
+ this.#waiters.push(resolve);
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,51 @@
1
+ import type { NetworkErrorRecord, NetworkOutageDetectorOptions, OutageSuspect } from './types.js';
2
+ /**
3
+ * Sliding-window detector for "this looks like the crawl operator's own
4
+ * network, not the target sites, is having a bad time" — the trigger that
5
+ * hands off to an active probe (see `probe-network.ts`) before the crawler
6
+ * commits to pausing.
7
+ *
8
+ * Deliberately dependency-free: no timers, no DB, no `Crawler` instance.
9
+ * Time flows in via {@link NetworkErrorRecord.at} only, which is what makes
10
+ * window-boundary behaviour testable with exact values instead of
11
+ * `vi.useFakeTimers()`.
12
+ * @example
13
+ * ```ts
14
+ * const detector = new NetworkOutageDetector({
15
+ * windowMs: 10_000,
16
+ * errorThreshold: 5,
17
+ * hostThreshold: 2,
18
+ * });
19
+ * const suspect = detector.record({ kind: 'dns', host: 'a.example', at: Date.now() });
20
+ * if (suspect) {
21
+ * // probe before closing the gate
22
+ * }
23
+ * ```
24
+ */
25
+ export default class NetworkOutageDetector {
26
+ #private;
27
+ constructor(options: NetworkOutageDetectorOptions);
28
+ /**
29
+ * Record one observed error and check whether it tips the sliding window
30
+ * over both thresholds.
31
+ *
32
+ * Non-network `kind`s (see `NETWORK_RELATED_ERROR_KINDS`) are silently
33
+ * ignored: they never enter the window and can never contribute to a
34
+ * trigger, regardless of how many arrive.
35
+ * @param record - The observed error.
36
+ * @param record.kind
37
+ * @param record.host
38
+ * @param record.at
39
+ * @returns An {@link OutageSuspect} the instant both thresholds are met,
40
+ * or `null` otherwise. On a trigger, the window is cleared so the same
41
+ * batch of errors cannot fire a second time on the next call.
42
+ */
43
+ record({ kind, host, at }: NetworkErrorRecord): OutageSuspect | null;
44
+ /**
45
+ * Clear the sliding window. Called at the start of a fresh crawl session
46
+ * (`Crawler.#runDeal`) so error observations from a prior session on the
47
+ * same `Crawler` instance never carry over — mirrors `#successfulHosts
48
+ * .clear()` / `#scrapedDestinations.clear()`'s per-session reset.
49
+ */
50
+ reset(): void;
51
+ }
@@ -0,0 +1,81 @@
1
+ import { NETWORK_RELATED_ERROR_KINDS } from '../network-related-error-kinds.js';
2
+ /**
3
+ * Sliding-window detector for "this looks like the crawl operator's own
4
+ * network, not the target sites, is having a bad time" — the trigger that
5
+ * hands off to an active probe (see `probe-network.ts`) before the crawler
6
+ * commits to pausing.
7
+ *
8
+ * Deliberately dependency-free: no timers, no DB, no `Crawler` instance.
9
+ * Time flows in via {@link NetworkErrorRecord.at} only, which is what makes
10
+ * window-boundary behaviour testable with exact values instead of
11
+ * `vi.useFakeTimers()`.
12
+ * @example
13
+ * ```ts
14
+ * const detector = new NetworkOutageDetector({
15
+ * windowMs: 10_000,
16
+ * errorThreshold: 5,
17
+ * hostThreshold: 2,
18
+ * });
19
+ * const suspect = detector.record({ kind: 'dns', host: 'a.example', at: Date.now() });
20
+ * if (suspect) {
21
+ * // probe before closing the gate
22
+ * }
23
+ * ```
24
+ */
25
+ export default class NetworkOutageDetector {
26
+ #entries = [];
27
+ #errorThreshold;
28
+ #hostThreshold;
29
+ #windowMs;
30
+ constructor(options) {
31
+ this.#windowMs = options.windowMs;
32
+ this.#errorThreshold = options.errorThreshold;
33
+ this.#hostThreshold = options.hostThreshold;
34
+ }
35
+ /**
36
+ * Record one observed error and check whether it tips the sliding window
37
+ * over both thresholds.
38
+ *
39
+ * Non-network `kind`s (see `NETWORK_RELATED_ERROR_KINDS`) are silently
40
+ * ignored: they never enter the window and can never contribute to a
41
+ * trigger, regardless of how many arrive.
42
+ * @param record - The observed error.
43
+ * @param record.kind
44
+ * @param record.host
45
+ * @param record.at
46
+ * @returns An {@link OutageSuspect} the instant both thresholds are met,
47
+ * or `null` otherwise. On a trigger, the window is cleared so the same
48
+ * batch of errors cannot fire a second time on the next call.
49
+ */
50
+ record({ kind, host, at }) {
51
+ if (!NETWORK_RELATED_ERROR_KINDS.has(kind)) {
52
+ return null;
53
+ }
54
+ const cutoff = at - this.#windowMs;
55
+ this.#entries = this.#entries.filter((entry) => entry.at >= cutoff);
56
+ this.#entries.push({ host, at });
57
+ const distinctHosts = new Set(this.#entries.map((entry) => entry.host)).size;
58
+ if (this.#entries.length < this.#errorThreshold ||
59
+ distinctHosts < this.#hostThreshold) {
60
+ return null;
61
+ }
62
+ const startedAt = Math.min(...this.#entries.map((entry) => entry.at));
63
+ const suspect = {
64
+ startedAt,
65
+ detectedAt: at,
66
+ triggerErrorCount: this.#entries.length,
67
+ triggerHostCount: distinctHosts,
68
+ };
69
+ this.#entries = [];
70
+ return suspect;
71
+ }
72
+ /**
73
+ * Clear the sliding window. Called at the start of a fresh crawl session
74
+ * (`Crawler.#runDeal`) so error observations from a prior session on the
75
+ * same `Crawler` instance never carry over — mirrors `#successfulHosts
76
+ * .clear()` / `#scrapedDestinations.clear()`'s per-session reset.
77
+ */
78
+ reset() {
79
+ this.#entries = [];
80
+ }
81
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Session-scoped counter for how many operator-network outages were
3
+ * confirmed, and their total duration. The orchestrator accumulates into
4
+ * this at every `networkOutageRecovered` event, reads it at crawl shutdown
5
+ * to emit a `[network] N outage(s), Ms total` summary line, then zeroes it
6
+ * for the next session (mirrors `dnsBurnedHostShortCircuitCounter`'s
7
+ * lifecycle, including its known limitation: two `CrawlerOrchestrator`
8
+ * crawls running concurrently in the same process would accumulate into and
9
+ * zero the same shared counter, cross-contaminating each other's summary.
10
+ * The CLI only ever runs one crawl per process, so this has never been
11
+ * observed in practice; fixing it would mean threading per-session state
12
+ * through both counters together, out of scope here).
13
+ *
14
+ * Exposed as a mutable object (not a plain `let`) so the counter remains a
15
+ * single shared reference across crawler / orchestrator imports — `let`
16
+ * bindings cannot be mutated from another module.
17
+ */
18
+ export declare const networkOutageSummaryCounter: {
19
+ confirmedCount: number;
20
+ totalDurationMs: number;
21
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Session-scoped counter for how many operator-network outages were
3
+ * confirmed, and their total duration. The orchestrator accumulates into
4
+ * this at every `networkOutageRecovered` event, reads it at crawl shutdown
5
+ * to emit a `[network] N outage(s), Ms total` summary line, then zeroes it
6
+ * for the next session (mirrors `dnsBurnedHostShortCircuitCounter`'s
7
+ * lifecycle, including its known limitation: two `CrawlerOrchestrator`
8
+ * crawls running concurrently in the same process would accumulate into and
9
+ * zero the same shared counter, cross-contaminating each other's summary.
10
+ * The CLI only ever runs one crawl per process, so this has never been
11
+ * observed in practice; fixing it would mean threading per-session state
12
+ * through both counters together, out of scope here).
13
+ *
14
+ * Exposed as a mutable object (not a plain `let`) so the counter remains a
15
+ * single shared reference across crawler / orchestrator imports — `let`
16
+ * bindings cannot be mutated from another module.
17
+ */
18
+ export const networkOutageSummaryCounter = { confirmedCount: 0, totalDurationMs: 0 };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Function shape for an injectable network-reachability probe: given a
3
+ * hostname, resolve to `true` if the network path to it appears healthy,
4
+ * `false` otherwise. Never rejects — a probe failure is a normal outcome
5
+ * (that's the whole point of probing), not an exceptional one.
6
+ *
7
+ * Injected via `CrawlerOptions.networkProbe` so tests can simulate
8
+ * confirmed outages and recoveries deterministically (a function that fails
9
+ * N times then starts succeeding) without touching the real network — the
10
+ * same injection pattern as `ResourceLookup` / `PageSourceLookup`.
11
+ */
12
+ export type NetworkProbe = (host: string) => Promise<boolean>;
13
+ /**
14
+ * Default {@link NetworkProbe}: an active `dns.lookup` against `host`.
15
+ *
16
+ * Deliberately does not hit a hardcoded external address (e.g. `1.1.1.1`) —
17
+ * probing a host that has already answered successfully during this crawl
18
+ * session (see `choose-probe-host.ts`) avoids depending on infrastructure
19
+ * outside the sites actually being crawled. `dns.lookup` alone is enough:
20
+ * it exercises the operator's local resolver / network path, which is
21
+ * exactly the layer an operator-side outage breaks.
22
+ * @param host - Hostname to resolve.
23
+ * @returns `true` if the lookup succeeds, `false` on any error (including
24
+ * NXDOMAIN, timeout, or resolver unavailability).
25
+ */
26
+ export declare function probeNetwork(host: string): Promise<boolean>;
@@ -0,0 +1,21 @@
1
+ import { lookup } from 'node:dns';
2
+ /**
3
+ * Default {@link NetworkProbe}: an active `dns.lookup` against `host`.
4
+ *
5
+ * Deliberately does not hit a hardcoded external address (e.g. `1.1.1.1`) —
6
+ * probing a host that has already answered successfully during this crawl
7
+ * session (see `choose-probe-host.ts`) avoids depending on infrastructure
8
+ * outside the sites actually being crawled. `dns.lookup` alone is enough:
9
+ * it exercises the operator's local resolver / network path, which is
10
+ * exactly the layer an operator-side outage breaks.
11
+ * @param host - Hostname to resolve.
12
+ * @returns `true` if the lookup succeeds, `false` on any error (including
13
+ * NXDOMAIN, timeout, or resolver unavailability).
14
+ */
15
+ export function probeNetwork(host) {
16
+ return new Promise((resolve) => {
17
+ lookup(host, (error) => {
18
+ resolve(!error);
19
+ });
20
+ });
21
+ }
@@ -1,6 +1,8 @@
1
+ import type { NetworkProbe } from './probe-network.js';
1
2
  import type { PageSource } from '../archive/types.js';
3
+ import type { ErrorKind } from '../types.js';
2
4
  import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
3
- import type { ChangePhaseEvent, ScrapeResult } from '@d-zero/beholder';
5
+ import type { ChangePhaseEvent, ConsoleLogEntry, ScrapeResult } from '@d-zero/beholder';
4
6
  import type { ParseURLOptions } from '@d-zero/shared/parse-url';
5
7
  /**
6
8
  * Result of resolving a URL that redirects to a destination already rendered
@@ -160,6 +162,34 @@ export interface CrawlerOptions extends Required<Pick<ParseURLOptions, 'disableQ
160
162
  * the DB DEFAULT `'crawled'` applies.
161
163
  */
162
164
  inventoryMode: InventoryMode | null;
165
+ /**
166
+ * Sliding-window size in ms for network-outage suspicion. See
167
+ * `NetworkOutageDetector`.
168
+ */
169
+ networkOutageWindowMs: number;
170
+ /**
171
+ * Minimum error count within {@link networkOutageWindowMs} to declare a
172
+ * suspect outage.
173
+ */
174
+ networkOutageErrorThreshold: number;
175
+ /**
176
+ * Minimum distinct-host count within {@link networkOutageWindowMs} to
177
+ * declare a suspect outage. Guards against a single flaky host looking
178
+ * like a network-wide event.
179
+ */
180
+ networkOutageHostThreshold: number;
181
+ /**
182
+ * Interval in ms between recovery probes while the network gate is
183
+ * closed.
184
+ */
185
+ networkOutageProbeIntervalMs: number;
186
+ /**
187
+ * Injectable network-reachability probe, or `null` to use the default
188
+ * `dns.lookup`-based `probeNetwork`. Overriding this is the seam tests
189
+ * use to simulate confirmed outages and recoveries deterministically
190
+ * without touching the real network.
191
+ */
192
+ networkProbe: NetworkProbe | null;
163
193
  }
164
194
  /**
165
195
  * Inventory-mode runtime configuration. Passed from
@@ -294,6 +324,31 @@ export interface CrawlerEventTypes {
294
324
  /** The URL of the referenced resource (without hash). */
295
325
  src: string;
296
326
  };
327
+ /**
328
+ * Emitted once per scrape with the console messages / page errors
329
+ * beholder captured for that page (issue #228). Only emitted when
330
+ * `entries` is non-empty — see `Crawler#handleConsoleLogs` for why a
331
+ * degraded re-scrape that captures nothing must not clear prior good
332
+ * data.
333
+ */
334
+ consoleLogs: {
335
+ /**
336
+ * The originally-requested URL, normalised (`withoutHashAndAuth`
337
+ * form) — the same identity `updatePage` resolves its redirect
338
+ * chain from, NOT necessarily the page that ends up holding the
339
+ * content.
340
+ */
341
+ pageUrl: string;
342
+ /**
343
+ * The redirect chain hops captured during fetch, in order. Empty
344
+ * when the page was not redirected, or when the scrape produced no
345
+ * `pageData` (a `'skipped'` / `'error'` result) and no redirect
346
+ * information is available.
347
+ */
348
+ redirectPaths: readonly string[];
349
+ /** The captured console messages / page errors, in capture order. */
350
+ entries: ConsoleLogEntry[];
351
+ };
297
352
  /**
298
353
  * Emitted when the entire crawl process has completed or been aborted.
299
354
  */
@@ -349,4 +404,95 @@ export interface CrawlerEventTypes {
349
404
  */
350
405
  source: PageSource | undefined;
351
406
  };
407
+ /**
408
+ * Emitted the instant `Crawler` closes its internal network gate after a
409
+ * recovery probe CONFIRMS a suspect outage (the sliding-window threshold
410
+ * alone only makes it a suspect — see `NetworkOutageDetector`). The
411
+ * orchestrator persists this via `Archive.insertNetworkOutage` and must
412
+ * remember the returned row id to pass to the matching
413
+ * `networkOutageRecovered` event, since `Crawler` itself never touches
414
+ * the archive and has no way to know the row's id.
415
+ */
416
+ networkOutageConfirmed: {
417
+ /** Backdated to the earliest error still inside the detector's window at trigger time. */
418
+ startedAt: number;
419
+ /** When the sliding window actually crossed both thresholds. */
420
+ detectedAt: number;
421
+ /** Hostname the recovery probe is targeting, or `null` if none was available. */
422
+ probeHost: string | null;
423
+ triggerErrorCount: number;
424
+ triggerHostCount: number;
425
+ };
426
+ /**
427
+ * Emitted the instant `Crawler` reopens its internal network gate after
428
+ * a recovery probe succeeds. NOT emitted when the gate is opened
429
+ * because the crawl was aborted while paused — in that case the outage
430
+ * row is deliberately left open for the next writer session's
431
+ * boot-time finalizer to resolve (see
432
+ * `db-ops/outages/close-stale-open-network-outages.ts`), since an abort
433
+ * says nothing about whether the network actually recovered.
434
+ */
435
+ networkOutageRecovered: {
436
+ /** Epoch ms the recovery probe first succeeded. */
437
+ endedAt: number;
438
+ };
439
+ }
440
+ /**
441
+ * Tunables for `NetworkOutageDetector`.
442
+ */
443
+ export interface NetworkOutageDetectorOptions {
444
+ /**
445
+ * Sliding-window size in ms (`W`). Before each check, entries older than
446
+ * `at - windowMs` (inclusive boundary — an entry exactly `windowMs` old
447
+ * still counts) are evicted.
448
+ */
449
+ readonly windowMs: number;
450
+ /** Minimum error count within the window to declare a suspect outage (`N`). */
451
+ readonly errorThreshold: number;
452
+ /**
453
+ * Minimum number of DISTINCT hosts represented in the window to declare
454
+ * a suspect outage (`M`). Guards against one flaky host (a site that is
455
+ * genuinely retrying/failing on its own) looking like a network-wide
456
+ * event — a real local-network blip surfaces across unrelated hosts at
457
+ * once.
458
+ */
459
+ readonly hostThreshold: number;
460
+ }
461
+ /**
462
+ * One observed error, as fed to `NetworkOutageDetector.record`.
463
+ */
464
+ export interface NetworkErrorRecord {
465
+ readonly kind: ErrorKind;
466
+ readonly host: string;
467
+ /**
468
+ * Epoch ms this error was observed. Caller-supplied — the detector never
469
+ * calls `Date.now()` itself, so window-boundary behaviour can be pinned
470
+ * with exact values instead of fake timers. Callers MUST supply
471
+ * non-decreasing values across successive `record()` calls; the window
472
+ * eviction is a simple cutoff against the latest `at` and does not
473
+ * re-sort out-of-order input.
474
+ */
475
+ readonly at: number;
476
+ }
477
+ /**
478
+ * Emitted by `NetworkOutageDetector.record` the instant the sliding window
479
+ * crosses both thresholds.
480
+ */
481
+ export interface OutageSuspect {
482
+ /**
483
+ * Backdated to the earliest error still inside the window at trigger
484
+ * time — NOT the trigger instant itself. A sliding-window detector only
485
+ * confirms an outage after `W` seconds and `N` errors have accumulated,
486
+ * so the outage itself started earlier; backdating lets the persisted
487
+ * `network_outages` row (and the failures it retroactively covers) reach
488
+ * back to that earlier point instead of losing everything the detector
489
+ * missed while still accumulating evidence.
490
+ */
491
+ readonly startedAt: number;
492
+ /** The `at` of the record that tripped the threshold. */
493
+ readonly detectedAt: number;
494
+ /** Window size at trigger time (== `errorThreshold` or more). */
495
+ readonly triggerErrorCount: number;
496
+ /** Distinct host count at trigger time (== `hostThreshold` or more). */
497
+ readonly triggerHostCount: number;
352
498
  }