@nitpicker/crawler 0.15.0 → 0.16.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 (50) hide show
  1. package/lib/archive/archive.d.ts +39 -1
  2. package/lib/archive/archive.js +49 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +42 -0
  5. package/lib/archive/database.d.ts +31 -1
  6. package/lib/archive/database.js +42 -0
  7. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  8. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  9. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  10. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  11. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  12. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  13. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  14. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  15. package/lib/archive/types.d.ts +13 -0
  16. package/lib/classify-error-kind.d.ts +1 -0
  17. package/lib/classify-error-kind.js +14 -0
  18. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  19. package/lib/crawler/assert-chrome-installed.js +43 -0
  20. package/lib/crawler/crawler.d.ts +12 -0
  21. package/lib/crawler/crawler.js +239 -29
  22. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  23. package/lib/crawler/decode-auth-credential.js +39 -0
  24. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  25. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  26. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  27. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  28. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  29. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  30. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  31. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  32. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  33. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  34. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  35. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  36. package/lib/crawler/dedupe/types.d.ts +42 -0
  37. package/lib/crawler/dedupe/types.js +1 -0
  38. package/lib/crawler/fetch-destination.js +14 -2
  39. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  40. package/lib/crawler/generate-predicted-urls.js +33 -2
  41. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  42. package/lib/crawler/types.d.ts +38 -0
  43. package/lib/crawler-orchestrator.d.ts +12 -0
  44. package/lib/crawler-orchestrator.js +106 -1
  45. package/lib/crawler.d.ts +1 -0
  46. package/lib/crawler.js +1 -0
  47. package/lib/permanent-error-kinds.d.ts +9 -4
  48. package/lib/permanent-error-kinds.js +10 -4
  49. package/lib/types.d.ts +2 -1
  50. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import type { TemplateClusterReason } from './db-ops/analysis/types.js';
2
- import type { Config, InsertNetworkOutageParams, InventoryRunMeta, PageSource } from './types.js';
2
+ import type { Config, InsertDedupeCapEventParams, InsertNetworkOutageParams, InventoryRunMeta, PageSource } from './types.js';
3
3
  import type { OutageWindow } from '../is-within-outage-window.js';
4
4
  import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
5
5
  import type { ConsoleLogEntry } from '@d-zero/beholder';
@@ -49,6 +49,17 @@ export default class Archive extends ArchiveAccessor {
49
49
  * @deprecated This method is no longer functional.
50
50
  */
51
51
  abort(): void;
52
+ /**
53
+ * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
54
+ * looked up by `shape_key` rather than `id` — used for a shape that
55
+ * capped in an earlier session (preloaded into `DedupeCapTracker`'s
56
+ * sticky set) and so has no event id from the current session.
57
+ *
58
+ * Thin facade over {@link Database.accumulateDedupeCapRejectedCount}.
59
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
60
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
61
+ */
62
+ accumulateDedupeCapRejectedCount(shapeKey: string, rejectedCount: number): Promise<void>;
52
63
  /**
53
64
  * Records a crawler-level error to both the human-readable `error.log` (full
54
65
  * stack, for debugging) and the structured `crawl_errors` table (queryable,
@@ -80,6 +91,15 @@ export default class Archive extends ArchiveAccessor {
80
91
  * @param endedAt - Epoch ms the outage is considered to have ended.
81
92
  */
82
93
  closeNetworkOutage(id: number, endedAt: number): Promise<void>;
94
+ /**
95
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
96
+ * no-op if already finalized.
97
+ *
98
+ * Thin facade over {@link Database.finalizeDedupeCapEvent}.
99
+ * @param id - The `dedupe_cap_events.id` to finalize.
100
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
101
+ */
102
+ finalizeDedupeCapEvent(id: number, rejectedCount: number): Promise<void>;
83
103
  /**
84
104
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
85
105
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -136,6 +156,15 @@ export default class Archive extends ArchiveAccessor {
136
156
  * @returns The base URL string.
137
157
  */
138
158
  getUrl(): Promise<string>;
159
+ /**
160
+ * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
161
+ * journal.
162
+ *
163
+ * Thin facade over {@link Database.insertDedupeCapEvent}.
164
+ * @param params - The newly-capped shape's fields to record.
165
+ * @returns The autoincremented `id` of the inserted row.
166
+ */
167
+ insertDedupeCapEvent(params: InsertDedupeCapEventParams): Promise<number>;
139
168
  /**
140
169
  * Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
141
170
  * placeholders in the `resources` table — the non-HTML counterpart of
@@ -174,6 +203,15 @@ export default class Archive extends ArchiveAccessor {
174
203
  * @returns The autoincremented `id` of the inserted row.
175
204
  */
176
205
  insertNetworkOutage(params: InsertNetworkOutageParams): Promise<number>;
206
+ /**
207
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
208
+ * Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
209
+ * sticky set on `--resume` / `--append` / `--retry-failed` /
210
+ * `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
211
+ * writer-only exposure.
212
+ * @returns Distinct shape keys already confirmed capped.
213
+ */
214
+ listDedupeCapShapeKeys(): Promise<string[]>;
177
215
  /**
178
216
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
179
217
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -94,6 +94,20 @@ export default class Archive extends ArchiveAccessor {
94
94
  * @deprecated This method is no longer functional.
95
95
  */
96
96
  abort() { }
97
+ /**
98
+ * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
99
+ * looked up by `shape_key` rather than `id` — used for a shape that
100
+ * capped in an earlier session (preloaded into `DedupeCapTracker`'s
101
+ * sticky set) and so has no event id from the current session.
102
+ *
103
+ * Thin facade over {@link Database.accumulateDedupeCapRejectedCount}.
104
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
105
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
106
+ */
107
+ async accumulateDedupeCapRejectedCount(shapeKey, rejectedCount) {
108
+ dbLog('Accumulate dedupe cap rejected count shapeKey=%s rejectedCount=%d', shapeKey, rejectedCount);
109
+ return await this.#db.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount);
110
+ }
97
111
  /**
98
112
  * Records a crawler-level error to both the human-readable `error.log` (full
99
113
  * stack, for debugging) and the structured `crawl_errors` table (queryable,
@@ -135,6 +149,18 @@ export default class Archive extends ArchiveAccessor {
135
149
  dbLog('Close network outage id=%d endedAt=%d', id, endedAt);
136
150
  return await this.#db.closeNetworkOutage(id, endedAt);
137
151
  }
152
+ /**
153
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
154
+ * no-op if already finalized.
155
+ *
156
+ * Thin facade over {@link Database.finalizeDedupeCapEvent}.
157
+ * @param id - The `dedupe_cap_events.id` to finalize.
158
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
159
+ */
160
+ async finalizeDedupeCapEvent(id, rejectedCount) {
161
+ dbLog('Finalize dedupe cap event id=%d rejectedCount=%d', id, rejectedCount);
162
+ return await this.#db.finalizeDedupeCapEvent(id, rejectedCount);
163
+ }
138
164
  /**
139
165
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
140
166
  * @returns An object with `scraped` and `pending` URL arrays.
@@ -202,6 +228,18 @@ export default class Archive extends ArchiveAccessor {
202
228
  async getUrl() {
203
229
  return this.#db.getBaseUrl();
204
230
  }
231
+ /**
232
+ * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
233
+ * journal.
234
+ *
235
+ * Thin facade over {@link Database.insertDedupeCapEvent}.
236
+ * @param params - The newly-capped shape's fields to record.
237
+ * @returns The autoincremented `id` of the inserted row.
238
+ */
239
+ async insertDedupeCapEvent(params) {
240
+ dbLog('Insert dedupe cap event: shapeKey=%s', params.shapeKey);
241
+ return await this.#db.insertDedupeCapEvent(params);
242
+ }
205
243
  /**
206
244
  * Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
207
245
  * placeholders in the `resources` table — the non-HTML counterpart of
@@ -255,6 +293,17 @@ export default class Archive extends ArchiveAccessor {
255
293
  dbLog('Insert network outage: startedAt=%d probeHost=%s', params.startedAt, params.probeHost);
256
294
  return await this.#db.insertNetworkOutage(params);
257
295
  }
296
+ /**
297
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
298
+ * Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
299
+ * sticky set on `--resume` / `--append` / `--retry-failed` /
300
+ * `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
301
+ * writer-only exposure.
302
+ * @returns Distinct shape keys already confirmed capped.
303
+ */
304
+ async listDedupeCapShapeKeys() {
305
+ return this.#db.listDedupeCapShapeKeys();
306
+ }
258
307
  /**
259
308
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
260
309
  * for which no recent 2xx/3xx page or resource is recorded. Consumed by
@@ -16,6 +16,9 @@ import type { Knex } from 'knex';
16
16
  * - `inventory_runs` — `--inventory` audit log (no FK; append-only)
17
17
  * - `network_outages` — operator-network-outage journal (no FK; append-only
18
18
  * except `ended_at`, which is written once on recovery)
19
+ * - `dedupe_cap_events` — `--dedupe-cap` same-cluster soft-cap audit log (no
20
+ * FK; append-only except `rejected_count`, which is written once at
21
+ * `crawlEnd`)
19
22
  * - `analysis_text_refs` + `analysis_violations` — analyze-phase findings,
20
23
  * FK → `content_items(id)`
21
24
  * - `page_templates` — DOM-structure template classification (`--templates`,
@@ -15,6 +15,9 @@
15
15
  * - `inventory_runs` — `--inventory` audit log (no FK; append-only)
16
16
  * - `network_outages` — operator-network-outage journal (no FK; append-only
17
17
  * except `ended_at`, which is written once on recovery)
18
+ * - `dedupe_cap_events` — `--dedupe-cap` same-cluster soft-cap audit log (no
19
+ * FK; append-only except `rejected_count`, which is written once at
20
+ * `crawlEnd`)
18
21
  * - `analysis_text_refs` + `analysis_violations` — analyze-phase findings,
19
22
  * FK → `content_items(id)`
20
23
  * - `page_templates` — DOM-structure template classification (`--templates`,
@@ -326,6 +329,45 @@ export async function createAdjunctTables(instance) {
326
329
  t.integer('trigger_host_count').notNullable();
327
330
  });
328
331
  }
332
+ if (!(await instance.schema.hasTable('dedupe_cap_events'))) {
333
+ await instance.schema.createTable('dedupe_cap_events', (t) => {
334
+ // One row per URL shape the `--dedupe-cap` same-cluster soft cap
335
+ // (`DedupeCapTracker`) confirmed as a trap during this crawl. No
336
+ // index: a crawl produces at most a handful of these rows (same
337
+ // reasoning as `network_outages`, above).
338
+ t.increments('id');
339
+ // The URL shape key (`computeShapeKey`) that capped — a template
340
+ // with placeholders (e.g. `example.com/news/date/{n}/`), not a
341
+ // literal URL.
342
+ t.string('shape_key').notNullable();
343
+ // One concrete URL matching this shape, captured at cap time so a
344
+ // human reading the audit log can identify what was being
345
+ // crawled — `shape_key` alone is a template, not a navigable URL.
346
+ t.string('sample_url').notNullable();
347
+ // `computeBodyHash` result recorded at cap time. Nullable only in
348
+ // the sense that BLOB columns are nullable by default; every row
349
+ // this feature writes populates it (a page with no rendered
350
+ // `<body>` never reaches the tracker — see `Crawler#handleResult`).
351
+ t.binary('body_hash').nullable();
352
+ // The Misra-Gries threshold that actually triggered the cap,
353
+ // after halving for the `body_hash`-match / `og:url`-mismatch
354
+ // confidence signals — NOT necessarily equal to `--dedupe-cap`'s
355
+ // raw value.
356
+ t.integer('effective_threshold').notNullable();
357
+ // The tracker's Misra-Gries counter value at cap time: a LOWER
358
+ // BOUND on the number of matching-signature pages seen for this
359
+ // shape, not an exact observation count (see `DedupeCapTracker`).
360
+ t.integer('observed_count').notNullable();
361
+ t.integer('detected_at').notNullable();
362
+ // NULL until `crawlEnd` finalizes it (see
363
+ // `Crawler#getDedupeCapRejections`). Unlike `network_outages.ended_at`,
364
+ // a NULL here has no ambiguous "still ongoing" reading — a
365
+ // crawl that never reached `crawlEnd` simply left the count
366
+ // undetermined, so no boot-time reconciliation pass is needed
367
+ // (readers display "unknown", not "0" or "unbounded").
368
+ t.integer('rejected_count').nullable();
369
+ });
370
+ }
329
371
  if (!(await instance.schema.hasTable('analysis_text_refs'))) {
330
372
  await instance.raw(`
331
373
  CREATE TABLE analysis_text_refs (
@@ -1,6 +1,6 @@
1
1
  import type { TemplateClusterReason } from './db-ops/analysis/types.js';
2
2
  import type { JsonLdRow, MainContentAudioRow, MainContentButtonRow, MainContentCanvasRow, MainContentHeadingRow, MainContentIframeRow, MainContentImageRow, MainContentTableRow, MainContentVideoRow, TagRow } from './meta/types.js';
3
- import type { Config, DatabaseOption, DB_Redirect, DB_Resource, DatabaseEvent, InsertNetworkOutageParams, InventoryRunMeta, PageFilter, PageSource } from './types.js';
3
+ import type { Config, DatabaseOption, DB_Redirect, DB_Resource, DatabaseEvent, InsertDedupeCapEventParams, InsertNetworkOutageParams, InventoryRunMeta, PageFilter, PageSource } from './types.js';
4
4
  import type { OutageWindow } from '../is-within-outage-window.js';
5
5
  import type { PageData, Resource } from '../utils/types/types.js';
6
6
  import type { ConsoleLogEntry } from '@d-zero/beholder';
@@ -32,6 +32,14 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
32
32
  export declare class Database extends EventEmitter<DatabaseEvent> {
33
33
  #private;
34
34
  private constructor();
35
+ /**
36
+ * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
37
+ * looked up by `shape_key` rather than `id`. Delegates to
38
+ * {@link accumulateDedupeCapRejectedCountOp}.
39
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
40
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
41
+ */
42
+ accumulateDedupeCapRejectedCount(shapeKey: string, rejectedCount: number): Promise<void>;
35
43
  /**
36
44
  * Forces a WAL checkpoint, writing all pending WAL data back to the main
37
45
  * database file. Delegates to {@link checkpointOp}.
@@ -49,6 +57,14 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
49
57
  * Delegates to {@link destroyOp}.
50
58
  */
51
59
  destroy(): Promise<void>;
60
+ /**
61
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
62
+ * no-op if the row is already finalized. Delegates to
63
+ * {@link finalizeDedupeCapEventOp}.
64
+ * @param id - The `dedupe_cap_events.id` to finalize.
65
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
66
+ */
67
+ finalizeDedupeCapEvent(id: number, rejectedCount: number): Promise<void>;
52
68
  /**
53
69
  * Retrieves all anchors (outgoing links) on a specific page.
54
70
  * Delegates to {@link getAnchorsOnPageOp}.
@@ -273,6 +289,13 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
273
289
  * @param isExternal - Whether the URL is external to the crawl scope.
274
290
  */
275
291
  insertCrawlError(url: string | null, message: string, isExternal?: boolean): Promise<void>;
292
+ /**
293
+ * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
294
+ * journal. Delegates to {@link insertDedupeCapEventOp}.
295
+ * @param params - The newly-capped shape's fields to record.
296
+ * @returns The autoincremented `id` of the newly-inserted row.
297
+ */
298
+ insertDedupeCapEvent(params: InsertDedupeCapEventParams): Promise<number>;
276
299
  /**
277
300
  * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows.
278
301
  * Delegates to {@link insertInventoryResourcesOp}.
@@ -317,6 +340,13 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
317
340
  * @param pageUrl - The URL of the page that references the resource.
318
341
  */
319
342
  insertResourceReferrers(src: string, pageUrl: string): Promise<void>;
343
+ /**
344
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
345
+ * Delegates to {@link listDedupeCapShapeKeysOp}.
346
+ * @returns Distinct shape keys already confirmed capped, or `[]` on an
347
+ * archive that predates `dedupe_cap_events` or has recorded none.
348
+ */
349
+ listDedupeCapShapeKeys(): Promise<string[]>;
320
350
  /**
321
351
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
322
352
  * for which no recent 2xx-3xx page or resource is recorded.
@@ -16,6 +16,10 @@ import { getName as getNameOp } from './db-ops/config/get-name.js';
16
16
  import { setConfig as setConfigOp } from './db-ops/config/set-config.js';
17
17
  import { updateConfig as updateConfigOp } from './db-ops/config/update-config.js';
18
18
  import { replaceConsoleLogs as replaceConsoleLogsOp } from './db-ops/console-logs/replace-console-logs.js';
19
+ import { accumulateDedupeCapRejectedCount as accumulateDedupeCapRejectedCountOp } from './db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js';
20
+ import { finalizeDedupeCapEvent as finalizeDedupeCapEventOp } from './db-ops/dedupe-cap/finalize-dedupe-cap-event.js';
21
+ import { insertDedupeCapEvent as insertDedupeCapEventOp } from './db-ops/dedupe-cap/insert-dedupe-cap-event.js';
22
+ import { listDedupeCapShapeKeys as listDedupeCapShapeKeysOp } from './db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js';
19
23
  import { insertCrawlError as insertCrawlErrorOp } from './db-ops/errors/insert-crawl-error.js';
20
24
  import { insertPageError as insertPageErrorOp } from './db-ops/errors/insert-page-error.js';
21
25
  import { listDnsBurnedHostCandidates as listDnsBurnedHostCandidatesOp } from './db-ops/errors/list-dns-burned-host-candidates.js';
@@ -118,6 +122,16 @@ export class Database extends EventEmitter {
118
122
  },
119
123
  });
120
124
  }
125
+ /**
126
+ * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
127
+ * looked up by `shape_key` rather than `id`. Delegates to
128
+ * {@link accumulateDedupeCapRejectedCountOp}.
129
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
130
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
131
+ */
132
+ async accumulateDedupeCapRejectedCount(shapeKey, rejectedCount) {
133
+ return emitErrorAndRetry(this, 'Database.accumulateDedupeCapRejectedCount', async () => await accumulateDedupeCapRejectedCountOp(this.#instance, shapeKey, rejectedCount), retrySetting);
134
+ }
121
135
  /**
122
136
  * Forces a WAL checkpoint, writing all pending WAL data back to the main
123
137
  * database file. Delegates to {@link checkpointOp}.
@@ -141,6 +155,16 @@ export class Database extends EventEmitter {
141
155
  async destroy() {
142
156
  await destroyOp(this.#instance);
143
157
  }
158
+ /**
159
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
160
+ * no-op if the row is already finalized. Delegates to
161
+ * {@link finalizeDedupeCapEventOp}.
162
+ * @param id - The `dedupe_cap_events.id` to finalize.
163
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
164
+ */
165
+ async finalizeDedupeCapEvent(id, rejectedCount) {
166
+ return emitErrorAndRetry(this, 'Database.finalizeDedupeCapEvent', async () => await finalizeDedupeCapEventOp(this.#instance, id, rejectedCount), retrySetting);
167
+ }
144
168
  /**
145
169
  * Retrieves all anchors (outgoing links) on a specific page.
146
170
  * Delegates to {@link getAnchorsOnPageOp}.
@@ -422,6 +446,15 @@ export class Database extends EventEmitter {
422
446
  async insertCrawlError(url, message, isExternal = false) {
423
447
  return emitErrorAndRetry(this, 'Database.insertCrawlError', async () => await insertCrawlErrorOp(this.#instance, url, message, isExternal), retrySetting);
424
448
  }
449
+ /**
450
+ * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
451
+ * journal. Delegates to {@link insertDedupeCapEventOp}.
452
+ * @param params - The newly-capped shape's fields to record.
453
+ * @returns The autoincremented `id` of the newly-inserted row.
454
+ */
455
+ async insertDedupeCapEvent(params) {
456
+ return emitErrorAndRetry(this, 'Database.insertDedupeCapEvent', async () => await insertDedupeCapEventOp(this.#instance, params), retrySetting);
457
+ }
425
458
  /**
426
459
  * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows.
427
460
  * Delegates to {@link insertInventoryResourcesOp}.
@@ -478,6 +511,15 @@ export class Database extends EventEmitter {
478
511
  async insertResourceReferrers(src, pageUrl) {
479
512
  return emitErrorAndRetry(this, 'Database.insertResourceReferrers', async () => await insertResourceReferrersOp(this.#instance, this.#writeRefCaches, src, pageUrl), retrySetting);
480
513
  }
514
+ /**
515
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
516
+ * Delegates to {@link listDedupeCapShapeKeysOp}.
517
+ * @returns Distinct shape keys already confirmed capped, or `[]` on an
518
+ * archive that predates `dedupe_cap_events` or has recorded none.
519
+ */
520
+ async listDedupeCapShapeKeys() {
521
+ return emitErrorAndRetry(this, 'Database.listDedupeCapShapeKeys', async () => await listDedupeCapShapeKeysOp(this.#instance), retrySetting);
522
+ }
481
523
  /**
482
524
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
483
525
  * for which no recent 2xx-3xx page or resource is recorded.
@@ -0,0 +1,18 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds `rejectedCount` onto the `rejected_count` of the `dedupe_cap_events`
4
+ * row for `shapeKey`, treating a still-`NULL` count as `0`. Unlike
5
+ * `finalize-dedupe-cap-event.ts` (which stamps a session's own newly-capped
6
+ * shape exactly once, guarded by `whereNull`), this targets a shape that
7
+ * capped in an EARLIER session and was preloaded into `DedupeCapTracker`'s
8
+ * sticky set (see `DedupeCapTracker`'s constructor JSDoc) — gate rejections
9
+ * for such a shape still occur in the current session, but no `dedupeCap`
10
+ * event (and thus no new row) is ever emitted for it, since the tracker
11
+ * short-circuits on an already-sticky shape before `observe` runs. Matches
12
+ * by `shape_key` rather than `id` because the caller (`CrawlerOrchestrator`)
13
+ * only has the shape key for a preloaded-sticky shape, never its row id.
14
+ * @param knex - Knex query builder connected to the archive DB.
15
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
16
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
17
+ */
18
+ export declare function accumulateDedupeCapRejectedCount(knex: Knex, shapeKey: string, rejectedCount: number): Promise<void>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Adds `rejectedCount` onto the `rejected_count` of the `dedupe_cap_events`
3
+ * row for `shapeKey`, treating a still-`NULL` count as `0`. Unlike
4
+ * `finalize-dedupe-cap-event.ts` (which stamps a session's own newly-capped
5
+ * shape exactly once, guarded by `whereNull`), this targets a shape that
6
+ * capped in an EARLIER session and was preloaded into `DedupeCapTracker`'s
7
+ * sticky set (see `DedupeCapTracker`'s constructor JSDoc) — gate rejections
8
+ * for such a shape still occur in the current session, but no `dedupeCap`
9
+ * event (and thus no new row) is ever emitted for it, since the tracker
10
+ * short-circuits on an already-sticky shape before `observe` runs. Matches
11
+ * by `shape_key` rather than `id` because the caller (`CrawlerOrchestrator`)
12
+ * only has the shape key for a preloaded-sticky shape, never its row id.
13
+ * @param knex - Knex query builder connected to the archive DB.
14
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
15
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
16
+ */
17
+ export async function accumulateDedupeCapRejectedCount(knex, shapeKey, rejectedCount) {
18
+ await knex('dedupe_cap_events')
19
+ .where({ shape_key: shapeKey })
20
+ .update({
21
+ rejected_count: knex.raw('COALESCE(rejected_count, 0) + ?', [rejectedCount]),
22
+ });
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — but
4
+ * ONLY if it is still unset. The `whereNull('rejected_count')` guard makes
5
+ * this idempotent, mirroring `close-network-outage.ts`'s `ended_at` guard: a
6
+ * second call matches zero rows and is a silent no-op rather than
7
+ * overwriting an already-finalized count.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param id - The `dedupe_cap_events.id` to finalize.
10
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
11
+ */
12
+ export declare function finalizeDedupeCapEvent(knex: Knex, id: number, rejectedCount: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — but
3
+ * ONLY if it is still unset. The `whereNull('rejected_count')` guard makes
4
+ * this idempotent, mirroring `close-network-outage.ts`'s `ended_at` guard: a
5
+ * second call matches zero rows and is a silent no-op rather than
6
+ * overwriting an already-finalized count.
7
+ * @param knex - Knex query builder connected to the archive DB.
8
+ * @param id - The `dedupe_cap_events.id` to finalize.
9
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
10
+ */
11
+ export async function finalizeDedupeCapEvent(knex, id, rejectedCount) {
12
+ await knex('dedupe_cap_events').where({ id }).whereNull('rejected_count').update({
13
+ rejected_count: rejectedCount,
14
+ });
15
+ }
@@ -0,0 +1,14 @@
1
+ import type { InsertDedupeCapEventParams } from '../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Appends one row to the `dedupe_cap_events` journal, with `rejected_count`
5
+ * left `NULL` — the row starts life without a finalized rejection count.
6
+ *
7
+ * Called the instant `DedupeCapTracker#observe` confirms a URL shape as a
8
+ * same-cluster trap (the `dedupeCap` event). See
9
+ * `finalize-dedupe-cap-event.ts` for how `rejected_count` is later set.
10
+ * @param knex - Knex query builder connected to the archive DB.
11
+ * @param params - The newly-capped shape's fields to record.
12
+ * @returns The autoincremented `id` of the newly-inserted row.
13
+ */
14
+ export declare function insertDedupeCapEvent(knex: Knex, params: InsertDedupeCapEventParams): Promise<number>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Appends one row to the `dedupe_cap_events` journal, with `rejected_count`
3
+ * left `NULL` — the row starts life without a finalized rejection count.
4
+ *
5
+ * Called the instant `DedupeCapTracker#observe` confirms a URL shape as a
6
+ * same-cluster trap (the `dedupeCap` event). See
7
+ * `finalize-dedupe-cap-event.ts` for how `rejected_count` is later set.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param params - The newly-capped shape's fields to record.
10
+ * @returns The autoincremented `id` of the newly-inserted row.
11
+ */
12
+ export async function insertDedupeCapEvent(knex, params) {
13
+ const inserted = await knex
14
+ .from('dedupe_cap_events')
15
+ .insert({
16
+ shape_key: params.shapeKey,
17
+ sample_url: params.sampleUrl,
18
+ body_hash: params.bodyHash,
19
+ effective_threshold: params.effectiveThreshold,
20
+ observed_count: params.observedCount,
21
+ detected_at: params.detectedAt,
22
+ rejected_count: null,
23
+ })
24
+ .returning('id');
25
+ const id = inserted[0]?.id;
26
+ if (typeof id !== 'number') {
27
+ throw new TypeError('insertDedupeCapEvent: INSERT returned no row id');
28
+ }
29
+ return id;
30
+ }
@@ -0,0 +1,21 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
4
+ * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
5
+ * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
6
+ * this crawl already paid the cost of discovering once is not re-admitted
7
+ * in a later session. Fresh (non-resuming) crawls do not call this — there
8
+ * is no archive history to seed from.
9
+ *
10
+ * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
11
+ * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
12
+ * confirmed — there is no equivalent of "the host might have recovered
13
+ * since".
14
+ *
15
+ * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
16
+ * table (self-healed on next writer open, so this is never a permanent
17
+ * state) or that have recorded no capped shapes.
18
+ * @param knex - Knex query builder connected to the archive DB.
19
+ * @returns Distinct shape keys already confirmed capped.
20
+ */
21
+ export declare function listDedupeCapShapeKeys(knex: Knex): Promise<string[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
3
+ * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
4
+ * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
5
+ * this crawl already paid the cost of discovering once is not re-admitted
6
+ * in a later session. Fresh (non-resuming) crawls do not call this — there
7
+ * is no archive history to seed from.
8
+ *
9
+ * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
10
+ * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
11
+ * confirmed — there is no equivalent of "the host might have recovered
12
+ * since".
13
+ *
14
+ * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
15
+ * table (self-healed on next writer open, so this is never a permanent
16
+ * state) or that have recorded no capped shapes.
17
+ * @param knex - Knex query builder connected to the archive DB.
18
+ * @returns Distinct shape keys already confirmed capped.
19
+ */
20
+ export async function listDedupeCapShapeKeys(knex) {
21
+ const hasTable = await knex.schema.hasTable('dedupe_cap_events');
22
+ if (!hasTable) {
23
+ return [];
24
+ }
25
+ const rows = (await knex('dedupe_cap_events').distinct('shape_key'));
26
+ return rows.map((row) => row.shape_key);
27
+ }
@@ -186,6 +186,19 @@ export interface InsertNetworkOutageParams {
186
186
  triggerErrorCount: number;
187
187
  triggerHostCount: number;
188
188
  }
189
+ /**
190
+ * Fields required to record a newly-capped URL shape via
191
+ * `Database.insertDedupeCapEvent`. camelCase, mapped to snake_case columns
192
+ * on write — same convention as {@link InsertNetworkOutageParams}.
193
+ */
194
+ export interface InsertDedupeCapEventParams {
195
+ shapeKey: string;
196
+ sampleUrl: string;
197
+ bodyHash: Buffer;
198
+ effectiveThreshold: number;
199
+ observedCount: number;
200
+ detectedAt: number;
201
+ }
189
202
  /**
190
203
  * Filter type for querying pages from the database.
191
204
  *
@@ -14,6 +14,7 @@ import type { ErrorKind } from './types.js';
14
14
  * classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
15
15
  * classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
16
16
  * classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
17
+ * classifyErrorKind('Maximum number of redirects exceeded'); // 'redirect-loop'
17
18
  * ```
18
19
  */
19
20
  export declare function classifyErrorKind(message: string): ErrorKind;
@@ -53,6 +53,19 @@ const MATCHERS = [
53
53
  kind: 'parse-error',
54
54
  pattern: /Parse Error|Expected HTTP\/|Unexpected end of stream/i,
55
55
  },
56
+ // `follow-redirects` (pinned at 1.16.0, `fetch-destination.ts`'s HEAD/GET
57
+ // pre-flight) throws exactly "Maximum number of redirects exceeded" —
58
+ // no cause token, no URL — when a chain never terminates within its
59
+ // `maxRedirects` budget. `ERR_TOO_MANY_REDIRECTS` is the equivalent
60
+ // Chromium net-error code, covering the same symptom surfaced through a
61
+ // puppeteer navigation instead of the Node HTTP client. Both mean the
62
+ // SAME thing: the site's own redirect configuration loops and will loop
63
+ // again on any future fetch, which is why this is deterministic (not a
64
+ // transient network condition) — see `PERMANENT_ERROR_KINDS`.
65
+ {
66
+ kind: 'redirect-loop',
67
+ pattern: /Maximum number of redirects exceeded|ERR_TOO_MANY_REDIRECTS/i,
68
+ },
56
69
  // `client-blocked` covers Chromium's ERR_BLOCKED_* family — the browser
57
70
  // actively decided to reject the request (ad/tracker heuristics, CSP,
58
71
  // CORB / ORB, administrator block list, fingerprinting protection,
@@ -110,6 +123,7 @@ const MATCHERS = [
110
123
  * classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
111
124
  * classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
112
125
  * classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
126
+ * classifyErrorKind('Maximum number of redirects exceeded'); // 'redirect-loop'
113
127
  * ```
114
128
  */
115
129
  export function classifyErrorKind(message) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Verifies that Puppeteer can resolve an installed Chrome/Chromium executable
3
+ * before a crawl starts.
4
+ *
5
+ * A crawl otherwise only discovers a missing browser deep inside the
6
+ * per-URL scrape loop (`Crawler#_launchBrowserAndScrape`), where it surfaces
7
+ * as one more scrape error among many — the CLI still prints "Crawl
8
+ * completed" and writes an archive, so a missing Chrome (a fatal
9
+ * precondition, not a per-page failure) is easy to miss. Calling this once,
10
+ * before any archive I/O begins, turns it into an immediate, actionable
11
+ * failure instead.
12
+ * @param executablePath - Explicit override, matching
13
+ * {@link CrawlerOptions.executablePath}. Pass `null` (or omit) to check
14
+ * Puppeteer's own pinned Chrome resolution instead.
15
+ * @throws {Error} When the resolved executable path does not exist on disk.
16
+ * @example
17
+ * ```ts
18
+ * import { assertChromeIsInstalled } from '@nitpicker/crawler';
19
+ *
20
+ * // Throws with install instructions before any crawl work starts.
21
+ * await assertChromeIsInstalled();
22
+ * ```
23
+ */
24
+ export declare function assertChromeIsInstalled(executablePath?: string | null): Promise<void>;