@nitpicker/crawler 0.15.0 → 0.17.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 (69) hide show
  1. package/lib/archive/archive.d.ts +52 -1
  2. package/lib/archive/archive.js +68 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +43 -0
  5. package/lib/archive/create-entity-tables.js +10 -0
  6. package/lib/archive/database.d.ts +40 -1
  7. package/lib/archive/database.js +54 -0
  8. package/lib/archive/db-ops/config/get-config.js +1 -0
  9. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  10. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  11. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  12. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  13. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  14. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  15. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  16. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  17. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  18. package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
  19. package/lib/archive/db-ops/lifecycle/init.js +12 -2
  20. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
  21. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
  22. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
  23. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
  24. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
  25. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
  26. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
  27. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
  28. package/lib/archive/migrate-entity-tables.d.ts +10 -0
  29. package/lib/archive/migrate-entity-tables.js +10 -0
  30. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
  31. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
  32. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
  33. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
  34. package/lib/archive/types.d.ts +16 -0
  35. package/lib/classify-error-kind.d.ts +1 -0
  36. package/lib/classify-error-kind.js +14 -0
  37. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  38. package/lib/crawler/assert-chrome-installed.js +43 -0
  39. package/lib/crawler/crawler.d.ts +12 -0
  40. package/lib/crawler/crawler.js +239 -29
  41. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  42. package/lib/crawler/decode-auth-credential.js +39 -0
  43. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  44. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  45. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  46. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  47. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  48. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  49. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  50. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  51. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  52. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  53. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  54. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  55. package/lib/crawler/dedupe/types.d.ts +42 -0
  56. package/lib/crawler/dedupe/types.js +1 -0
  57. package/lib/crawler/fetch-destination.js +14 -2
  58. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  59. package/lib/crawler/generate-predicted-urls.js +33 -2
  60. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  61. package/lib/crawler/types.d.ts +38 -0
  62. package/lib/crawler-orchestrator.d.ts +39 -10
  63. package/lib/crawler-orchestrator.js +187 -23
  64. package/lib/crawler.d.ts +2 -0
  65. package/lib/crawler.js +2 -0
  66. package/lib/permanent-error-kinds.d.ts +9 -4
  67. package/lib/permanent-error-kinds.js +10 -4
  68. package/lib/types.d.ts +4 -1
  69. package/package.json +3 -3
@@ -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
@@ -165,6 +194,19 @@ export default class Archive extends ArchiveAccessor {
165
194
  * @param urls - HTML seed URLs to pre-insert. No-op when empty.
166
195
  */
167
196
  insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
197
+ /**
198
+ * Records exclude-matched inventory URLs as terminal skipped pages —
199
+ * the same `is_skipped=1, skip_reason='excluded'` state the normal
200
+ * crawl's fetch-time gate writes for link-discovered excluded URLs,
201
+ * labelled `source='inventory-seed'`. Thin facade over
202
+ * {@link Database.insertInventorySkippedPages} — see the underlying
203
+ * op's JSDoc for the parity rationale and crawled-wins safety.
204
+ *
205
+ * `ExURL` inputs are normalised to `withoutHashAndAuth` here for the
206
+ * same storage-key consistency reason as {@link insertInventorySeeds}.
207
+ * @param urls - Exclude-matched URLs to record. No-op when empty.
208
+ */
209
+ insertInventorySkippedPages(urls: readonly ExURL[]): Promise<void>;
168
210
  /**
169
211
  * Appends one open row to the `network_outages` journal.
170
212
  *
@@ -174,6 +216,15 @@ export default class Archive extends ArchiveAccessor {
174
216
  * @returns The autoincremented `id` of the inserted row.
175
217
  */
176
218
  insertNetworkOutage(params: InsertNetworkOutageParams): Promise<number>;
219
+ /**
220
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
221
+ * Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
222
+ * sticky set on `--resume` / `--append` / `--retry-failed` /
223
+ * `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
224
+ * writer-only exposure.
225
+ * @returns Distinct shape keys already confirmed capped.
226
+ */
227
+ listDedupeCapShapeKeys(): Promise<string[]>;
177
228
  /**
178
229
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
179
230
  * 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
@@ -243,6 +281,25 @@ export default class Archive extends ArchiveAccessor {
243
281
  dbLog('Insert inventory seeds: %d URL(s)', urls.length);
244
282
  await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
245
283
  }
284
+ /**
285
+ * Records exclude-matched inventory URLs as terminal skipped pages —
286
+ * the same `is_skipped=1, skip_reason='excluded'` state the normal
287
+ * crawl's fetch-time gate writes for link-discovered excluded URLs,
288
+ * labelled `source='inventory-seed'`. Thin facade over
289
+ * {@link Database.insertInventorySkippedPages} — see the underlying
290
+ * op's JSDoc for the parity rationale and crawled-wins safety.
291
+ *
292
+ * `ExURL` inputs are normalised to `withoutHashAndAuth` here for the
293
+ * same storage-key consistency reason as {@link insertInventorySeeds}.
294
+ * @param urls - Exclude-matched URLs to record. No-op when empty.
295
+ */
296
+ async insertInventorySkippedPages(urls) {
297
+ if (urls.length === 0) {
298
+ return;
299
+ }
300
+ dbLog('Insert inventory skipped pages: %d URL(s)', urls.length);
301
+ await this.#db.insertInventorySkippedPages(urls.map((u) => u.withoutHashAndAuth));
302
+ }
246
303
  /**
247
304
  * Appends one open row to the `network_outages` journal.
248
305
  *
@@ -255,6 +312,17 @@ export default class Archive extends ArchiveAccessor {
255
312
  dbLog('Insert network outage: startedAt=%d probeHost=%s', params.startedAt, params.probeHost);
256
313
  return await this.#db.insertNetworkOutage(params);
257
314
  }
315
+ /**
316
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
317
+ * Consumed by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s
318
+ * sticky set on `--resume` / `--append` / `--retry-failed` /
319
+ * `--inventory`, mirroring {@link listDnsBurnedHostCandidates}'s
320
+ * writer-only exposure.
321
+ * @returns Distinct shape keys already confirmed capped.
322
+ */
323
+ async listDedupeCapShapeKeys() {
324
+ return this.#db.listDedupeCapShapeKeys();
325
+ }
258
326
  /**
259
327
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
260
328
  * 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`,
@@ -290,6 +293,7 @@ export async function createAdjunctTables(instance) {
290
293
  t.integer('new_pages').nullable();
291
294
  t.integer('new_resources').nullable();
292
295
  t.integer('scope_skipped').nullable();
296
+ t.integer('exclude_skipped').nullable();
293
297
  t.integer('invalid_skipped').nullable();
294
298
  t.text('notes').nullable();
295
299
  t.index('ran_at');
@@ -326,6 +330,45 @@ export async function createAdjunctTables(instance) {
326
330
  t.integer('trigger_host_count').notNullable();
327
331
  });
328
332
  }
333
+ if (!(await instance.schema.hasTable('dedupe_cap_events'))) {
334
+ await instance.schema.createTable('dedupe_cap_events', (t) => {
335
+ // One row per URL shape the `--dedupe-cap` same-cluster soft cap
336
+ // (`DedupeCapTracker`) confirmed as a trap during this crawl. No
337
+ // index: a crawl produces at most a handful of these rows (same
338
+ // reasoning as `network_outages`, above).
339
+ t.increments('id');
340
+ // The URL shape key (`computeShapeKey`) that capped — a template
341
+ // with placeholders (e.g. `example.com/news/date/{n}/`), not a
342
+ // literal URL.
343
+ t.string('shape_key').notNullable();
344
+ // One concrete URL matching this shape, captured at cap time so a
345
+ // human reading the audit log can identify what was being
346
+ // crawled — `shape_key` alone is a template, not a navigable URL.
347
+ t.string('sample_url').notNullable();
348
+ // `computeBodyHash` result recorded at cap time. Nullable only in
349
+ // the sense that BLOB columns are nullable by default; every row
350
+ // this feature writes populates it (a page with no rendered
351
+ // `<body>` never reaches the tracker — see `Crawler#handleResult`).
352
+ t.binary('body_hash').nullable();
353
+ // The Misra-Gries threshold that actually triggered the cap,
354
+ // after halving for the `body_hash`-match / `og:url`-mismatch
355
+ // confidence signals — NOT necessarily equal to `--dedupe-cap`'s
356
+ // raw value.
357
+ t.integer('effective_threshold').notNullable();
358
+ // The tracker's Misra-Gries counter value at cap time: a LOWER
359
+ // BOUND on the number of matching-signature pages seen for this
360
+ // shape, not an exact observation count (see `DedupeCapTracker`).
361
+ t.integer('observed_count').notNullable();
362
+ t.integer('detected_at').notNullable();
363
+ // NULL until `crawlEnd` finalizes it (see
364
+ // `Crawler#getDedupeCapRejections`). Unlike `network_outages.ended_at`,
365
+ // a NULL here has no ambiguous "still ongoing" reading — a
366
+ // crawl that never reached `crawlEnd` simply left the count
367
+ // undetermined, so no boot-time reconciliation pass is needed
368
+ // (readers display "unknown", not "0" or "unbounded").
369
+ t.integer('rejected_count').nullable();
370
+ });
371
+ }
329
372
  if (!(await instance.schema.hasTable('analysis_text_refs'))) {
330
373
  await instance.raw(`
331
374
  CREATE TABLE analysis_text_refs (
@@ -198,6 +198,7 @@ export async function createEntityTables(instance) {
198
198
  header_set_id INTEGER REFERENCES header_sets(id),
199
199
  redirect_dest_id INTEGER REFERENCES content_items(id) DEFERRABLE INITIALLY DEFERRED,
200
200
  alias_of_id INTEGER REFERENCES content_items(id) DEFERRABLE INITIALLY DEFERRED,
201
+ dedupe_cap_event_id INTEGER REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED,
201
202
  source TEXT NOT NULL DEFAULT 'crawled',
202
203
  first_crawled_at INTEGER,
203
204
  last_crawled_at INTEGER,
@@ -218,6 +219,15 @@ export async function createEntityTables(instance) {
218
219
  // `migrateContentItemsAliasOfId` instead, which runs after the
219
220
  // column-add guard for both fresh and legacy archives (same reasoning as
220
221
  // `page_meta.body_hash`'s index).
222
+ //
223
+ // `dedupe_cap_event_id` gets no index anywhere, not even in its own
224
+ // migration (`migrateContentItemsDedupeCapEventId`) — unlike
225
+ // `alias_of_id`, there is no known hot read path filtering on this
226
+ // column yet (`--dedupe-cap` is opt-in and the marked row count is
227
+ // small: capped shapes × matching URLs). Adding a speculative index
228
+ // without a measured query to justify it violates this archive's
229
+ // "no speculative index" rule; add one later with `EXPLAIN QUERY PLAN`
230
+ // evidence if a real hot path emerges.
221
231
  await instance.raw('CREATE INDEX IF NOT EXISTS idx_content_items_content_type_id ON content_items(content_type_id)');
222
232
  await instance.raw('CREATE INDEX IF NOT EXISTS idx_content_items_crawl_order ON content_items(crawl_order)');
223
233
  await instance.raw('CREATE INDEX IF NOT EXISTS idx_content_items_source ON content_items(source)');
@@ -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}.
@@ -287,6 +310,15 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
287
310
  * @param urls - URL strings already in `withoutHashAndAuth` form.
288
311
  */
289
312
  insertInventorySeeds(urls: readonly string[]): Promise<void>;
313
+ /**
314
+ * Records exclude-matched inventory URLs as terminal skipped pages
315
+ * (`scraped=1`, `is_skipped=1`, `skip_reason='excluded'`,
316
+ * `source='inventory-seed'`). Delegates to
317
+ * {@link insertInventorySkippedPagesOp} — see that op's JSDoc for the
318
+ * normal-crawl parity rationale.
319
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
320
+ */
321
+ insertInventorySkippedPages(urls: readonly string[]): Promise<void>;
290
322
  /**
291
323
  * Appends one open (`ended_at = NULL`) row to the `network_outages`
292
324
  * journal. Delegates to {@link insertNetworkOutageOp}.
@@ -317,6 +349,13 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
317
349
  * @param pageUrl - The URL of the page that references the resource.
318
350
  */
319
351
  insertResourceReferrers(src: string, pageUrl: string): Promise<void>;
352
+ /**
353
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
354
+ * Delegates to {@link listDedupeCapShapeKeysOp}.
355
+ * @returns Distinct shape keys already confirmed capped, or `[]` on an
356
+ * archive that predates `dedupe_cap_events` or has recorded none.
357
+ */
358
+ listDedupeCapShapeKeys(): Promise<string[]>;
320
359
  /**
321
360
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
322
361
  * 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';
@@ -48,6 +52,7 @@ import { getScrapedHtmlPageCount as getScrapedHtmlPageCountOp } from './db-ops/p
48
52
  import { repromoteExternalPages as repromoteExternalPagesOp } from './db-ops/pages/reset/repromote-external-pages.js';
49
53
  import { resetFailedPages as resetFailedPagesOp } from './db-ops/pages/reset/reset-failed-pages.js';
50
54
  import { insertInventorySeeds as insertInventorySeedsOp } from './db-ops/pages/write/insert-inventory-seeds.js';
55
+ import { insertInventorySkippedPages as insertInventorySkippedPagesOp } from './db-ops/pages/write/insert-inventory-skipped-pages.js';
51
56
  import { recordRedirect as recordRedirectOp } from './db-ops/pages/write/record-redirect.js';
52
57
  import { setSkippedPage as setSkippedPageOp } from './db-ops/pages/write/set-skipped-page.js';
53
58
  import { updatePage as updatePageOp } from './db-ops/pages/write/update-page.js';
@@ -118,6 +123,16 @@ export class Database extends EventEmitter {
118
123
  },
119
124
  });
120
125
  }
126
+ /**
127
+ * Adds onto the `rejected_count` of a shape's `dedupe_cap_events` row,
128
+ * looked up by `shape_key` rather than `id`. Delegates to
129
+ * {@link accumulateDedupeCapRejectedCountOp}.
130
+ * @param shapeKey - The capped shape whose rejection count to accumulate.
131
+ * @param rejectedCount - Additional anchors rejected for this shape in the current session.
132
+ */
133
+ async accumulateDedupeCapRejectedCount(shapeKey, rejectedCount) {
134
+ return emitErrorAndRetry(this, 'Database.accumulateDedupeCapRejectedCount', async () => await accumulateDedupeCapRejectedCountOp(this.#instance, shapeKey, rejectedCount), retrySetting);
135
+ }
121
136
  /**
122
137
  * Forces a WAL checkpoint, writing all pending WAL data back to the main
123
138
  * database file. Delegates to {@link checkpointOp}.
@@ -141,6 +156,16 @@ export class Database extends EventEmitter {
141
156
  async destroy() {
142
157
  await destroyOp(this.#instance);
143
158
  }
159
+ /**
160
+ * Finalizes a `dedupe_cap_events` row by stamping `rejected_count` — a
161
+ * no-op if the row is already finalized. Delegates to
162
+ * {@link finalizeDedupeCapEventOp}.
163
+ * @param id - The `dedupe_cap_events.id` to finalize.
164
+ * @param rejectedCount - Number of anchors rejected for this shape after it capped.
165
+ */
166
+ async finalizeDedupeCapEvent(id, rejectedCount) {
167
+ return emitErrorAndRetry(this, 'Database.finalizeDedupeCapEvent', async () => await finalizeDedupeCapEventOp(this.#instance, id, rejectedCount), retrySetting);
168
+ }
144
169
  /**
145
170
  * Retrieves all anchors (outgoing links) on a specific page.
146
171
  * Delegates to {@link getAnchorsOnPageOp}.
@@ -422,6 +447,15 @@ export class Database extends EventEmitter {
422
447
  async insertCrawlError(url, message, isExternal = false) {
423
448
  return emitErrorAndRetry(this, 'Database.insertCrawlError', async () => await insertCrawlErrorOp(this.#instance, url, message, isExternal), retrySetting);
424
449
  }
450
+ /**
451
+ * Appends one row (`rejected_count = NULL`) to the `dedupe_cap_events`
452
+ * journal. Delegates to {@link insertDedupeCapEventOp}.
453
+ * @param params - The newly-capped shape's fields to record.
454
+ * @returns The autoincremented `id` of the newly-inserted row.
455
+ */
456
+ async insertDedupeCapEvent(params) {
457
+ return emitErrorAndRetry(this, 'Database.insertDedupeCapEvent', async () => await insertDedupeCapEventOp(this.#instance, params), retrySetting);
458
+ }
425
459
  /**
426
460
  * Pre-insert inventory non-HTML URLs into `resources` as placeholder rows.
427
461
  * Delegates to {@link insertInventoryResourcesOp}.
@@ -440,6 +474,17 @@ export class Database extends EventEmitter {
440
474
  async insertInventorySeeds(urls) {
441
475
  return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () => await insertInventorySeedsOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
442
476
  }
477
+ /**
478
+ * Records exclude-matched inventory URLs as terminal skipped pages
479
+ * (`scraped=1`, `is_skipped=1`, `skip_reason='excluded'`,
480
+ * `source='inventory-seed'`). Delegates to
481
+ * {@link insertInventorySkippedPagesOp} — see that op's JSDoc for the
482
+ * normal-crawl parity rationale.
483
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
484
+ */
485
+ async insertInventorySkippedPages(urls) {
486
+ return emitErrorAndRetry(this, 'Database.insertInventorySkippedPages', async () => await insertInventorySkippedPagesOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
487
+ }
443
488
  /**
444
489
  * Appends one open (`ended_at = NULL`) row to the `network_outages`
445
490
  * journal. Delegates to {@link insertNetworkOutageOp}.
@@ -478,6 +523,15 @@ export class Database extends EventEmitter {
478
523
  async insertResourceReferrers(src, pageUrl) {
479
524
  return emitErrorAndRetry(this, 'Database.insertResourceReferrers', async () => await insertResourceReferrersOp(this.#instance, this.#writeRefCaches, src, pageUrl), retrySetting);
480
525
  }
526
+ /**
527
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive.
528
+ * Delegates to {@link listDedupeCapShapeKeysOp}.
529
+ * @returns Distinct shape keys already confirmed capped, or `[]` on an
530
+ * archive that predates `dedupe_cap_events` or has recorded none.
531
+ */
532
+ async listDedupeCapShapeKeys() {
533
+ return emitErrorAndRetry(this, 'Database.listDedupeCapShapeKeys', async () => await listDedupeCapShapeKeysOp(this.#instance), retrySetting);
534
+ }
481
535
  /**
482
536
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
483
537
  * for which no recent 2xx-3xx page or resource is recorded.
@@ -19,6 +19,7 @@ export async function getConfig(knex) {
19
19
  excludeUrls: getJSON(config.excludeUrls, []),
20
20
  roots: getJSON(config.roots, []),
21
21
  retry: config.retry ?? 3,
22
+ maxExcludedDepth: config.maxExcludedDepth ?? 0,
22
23
  };
23
24
  // @ts-expect-error — `id` is the primary key, not part of the public Config shape
24
25
  delete opt.id;
@@ -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[]>;