@nitpicker/crawler 0.16.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 (31) hide show
  1. package/lib/archive/archive.d.ts +13 -0
  2. package/lib/archive/archive.js +19 -0
  3. package/lib/archive/create-adjunct-tables.js +1 -0
  4. package/lib/archive/create-entity-tables.js +10 -0
  5. package/lib/archive/database.d.ts +9 -0
  6. package/lib/archive/database.js +12 -0
  7. package/lib/archive/db-ops/config/get-config.js +1 -0
  8. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  9. package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
  10. package/lib/archive/db-ops/lifecycle/init.js +12 -2
  11. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
  12. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
  13. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
  14. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
  15. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
  16. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
  17. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
  18. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
  19. package/lib/archive/migrate-entity-tables.d.ts +10 -0
  20. package/lib/archive/migrate-entity-tables.js +10 -0
  21. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
  22. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
  23. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
  24. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
  25. package/lib/archive/types.d.ts +3 -0
  26. package/lib/crawler-orchestrator.d.ts +27 -10
  27. package/lib/crawler-orchestrator.js +81 -22
  28. package/lib/crawler.d.ts +1 -0
  29. package/lib/crawler.js +1 -0
  30. package/lib/types.d.ts +2 -0
  31. package/package.json +3 -3
@@ -194,6 +194,19 @@ export default class Archive extends ArchiveAccessor {
194
194
  * @param urls - HTML seed URLs to pre-insert. No-op when empty.
195
195
  */
196
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>;
197
210
  /**
198
211
  * Appends one open row to the `network_outages` journal.
199
212
  *
@@ -281,6 +281,25 @@ export default class Archive extends ArchiveAccessor {
281
281
  dbLog('Insert inventory seeds: %d URL(s)', urls.length);
282
282
  await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
283
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
+ }
284
303
  /**
285
304
  * Appends one open row to the `network_outages` journal.
286
305
  *
@@ -293,6 +293,7 @@ export async function createAdjunctTables(instance) {
293
293
  t.integer('new_pages').nullable();
294
294
  t.integer('new_resources').nullable();
295
295
  t.integer('scope_skipped').nullable();
296
+ t.integer('exclude_skipped').nullable();
296
297
  t.integer('invalid_skipped').nullable();
297
298
  t.text('notes').nullable();
298
299
  t.index('ran_at');
@@ -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)');
@@ -310,6 +310,15 @@ export declare class Database extends EventEmitter<DatabaseEvent> {
310
310
  * @param urls - URL strings already in `withoutHashAndAuth` form.
311
311
  */
312
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>;
313
322
  /**
314
323
  * Appends one open (`ended_at = NULL`) row to the `network_outages`
315
324
  * journal. Delegates to {@link insertNetworkOutageOp}.
@@ -52,6 +52,7 @@ import { getScrapedHtmlPageCount as getScrapedHtmlPageCountOp } from './db-ops/p
52
52
  import { repromoteExternalPages as repromoteExternalPagesOp } from './db-ops/pages/reset/repromote-external-pages.js';
53
53
  import { resetFailedPages as resetFailedPagesOp } from './db-ops/pages/reset/reset-failed-pages.js';
54
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';
55
56
  import { recordRedirect as recordRedirectOp } from './db-ops/pages/write/record-redirect.js';
56
57
  import { setSkippedPage as setSkippedPageOp } from './db-ops/pages/write/set-skipped-page.js';
57
58
  import { updatePage as updatePageOp } from './db-ops/pages/write/update-page.js';
@@ -473,6 +474,17 @@ export class Database extends EventEmitter {
473
474
  async insertInventorySeeds(urls) {
474
475
  return emitErrorAndRetry(this, 'Database.insertInventorySeeds', async () => await insertInventorySeedsOp(this.#instance, this.#writeRefCaches, urls), retrySetting);
475
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
+ }
476
488
  /**
477
489
  * Appends one open (`ended_at = NULL`) row to the `network_outages`
478
490
  * journal. Delegates to {@link insertNetworkOutageOp}.
@@ -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;
@@ -27,6 +27,7 @@ export async function recordInventoryRun(knex, meta) {
27
27
  new_pages: meta.new_pages ?? null,
28
28
  new_resources: meta.new_resources ?? null,
29
29
  scope_skipped: meta.scope_skipped ?? null,
30
+ exclude_skipped: meta.exclude_skipped ?? null,
30
31
  invalid_skipped: meta.invalid_skipped ?? null,
31
32
  notes: meta.notes ?? null,
32
33
  })
@@ -2,7 +2,8 @@ import type { Knex } from 'knex';
2
2
  /**
3
3
  * Initializes the database schema if tables do not exist, then runs the
4
4
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
5
- * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
5
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
6
+ * `inventory_runs.exclude_skipped`).
6
7
  *
7
8
  * There is deliberately no per-table *table-creation* migration chain here:
8
9
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -19,7 +20,8 @@ import type { Knex } from 'knex';
19
20
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
20
21
  * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
21
22
  * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
22
- * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
23
+ * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
24
+ * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
23
25
  *
24
26
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
25
27
  * change) but belongs at this same boot phase for the same reason the
@@ -1,8 +1,10 @@
1
1
  import { applyConnectionPragmas, initSchema } from '../../init-schema.js';
2
2
  import { assertCompatibleVersion } from '../../meta/assert-compatible-version.js';
3
3
  import { migrateContentItemsAliasOfId } from '../../migrate-content-items-alias-of-id.js';
4
+ import { migrateContentItemsDedupeCapEventId } from '../../migrate-content-items-dedupe-cap-event-id.js';
4
5
  import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
5
6
  import { migrateInfoRoots } from '../../migrate-info-roots.js';
7
+ import { migrateInventoryRunsExcludeSkipped } from '../../migrate-inventory-runs-exclude-skipped.js';
6
8
  import { migrateInventoryRunsInvalidSkipped } from '../../migrate-inventory-runs-invalid-skipped.js';
7
9
  import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
8
10
  import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
@@ -11,7 +13,8 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
11
13
  /**
12
14
  * Initializes the database schema if tables do not exist, then runs the
13
15
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
14
- * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
16
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
17
+ * `inventory_runs.exclude_skipped`).
15
18
  *
16
19
  * There is deliberately no per-table *table-creation* migration chain here:
17
20
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -28,7 +31,8 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
28
31
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
29
32
  * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
30
33
  * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
31
- * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
34
+ * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
35
+ * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
32
36
  *
33
37
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
34
38
  * change) but belongs at this same boot phase for the same reason the
@@ -66,6 +70,12 @@ export async function init(knex, readOnly) {
66
70
  await migratePageMetaBodyHash(knex);
67
71
  await migratePageMetaConsoleErrorCount(knex);
68
72
  await migrateContentItemsAliasOfId(knex);
73
+ // Runs after `initSchema` above, which already created
74
+ // `dedupe_cap_events` (an adjunct table) unconditionally — so the new
75
+ // column's `REFERENCES dedupe_cap_events(id)` target always exists by
76
+ // this point, for both fresh and legacy archives.
77
+ await migrateContentItemsDedupeCapEventId(knex);
69
78
  await migrateInventoryRunsInvalidSkipped(knex);
79
+ await migrateInventoryRunsExcludeSkipped(knex);
70
80
  await closeStaleOpenNetworkOutages(knex);
71
81
  }
@@ -0,0 +1,38 @@
1
+ import type { WriteRefCaches } from '../../_shared/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Parameters for {@link insertInventoryContentItems}.
5
+ */
6
+ export interface InsertInventoryContentItemsParams {
7
+ /** Knex query builder connected to the archive DB. */
8
+ readonly knex: Knex;
9
+ /** The connection's write-side id caches. */
10
+ readonly caches: WriteRefCaches;
11
+ /** URL strings already in `withoutHashAndAuth` form. */
12
+ readonly urls: readonly string[];
13
+ /** `content_items` column values shared by every inserted row (everything except `url_id`). */
14
+ readonly row: Readonly<Record<string, number | string>>;
15
+ /** Calling op's name, used to prefix the unresolved-url_ref error message. */
16
+ readonly opName: string;
17
+ }
18
+ /**
19
+ * Shared body of the inventory `content_items` bulk-insert ops
20
+ * (`insertInventorySeeds` / `insertInventorySkippedPages`): chunked
21
+ * `url_refs` upsert → id resolution → `content_items` insert-ignore →
22
+ * write-cache population. The two callers differ only in the row
23
+ * constants they stamp on every row, so the invariant-heavy plumbing
24
+ * lives here exactly once:
25
+ *
26
+ * - Chunked into 500-URL batches so SQLite's bound-parameter limit
27
+ * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
28
+ * tens-of-thousands inventory list.
29
+ * - Both inserts are `ON CONFLICT ... IGNORE`, so existing rows — in
30
+ * particular previously crawled pages — are never overwritten
31
+ * (crawled-wins), and within-list duplicates collapse to one row.
32
+ * - The `urlIds` / `contentItems` write caches are populated from what
33
+ * the DB actually holds after the insert (not from the attempted row
34
+ * values), keeping later cache-hits consistent with conflict-ignored
35
+ * rows.
36
+ * @param params - See {@link InsertInventoryContentItemsParams}.
37
+ */
38
+ export declare function insertInventoryContentItems(params: InsertInventoryContentItemsParams): Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
+ import { resolveUrlRefs } from '../../../populate-entity-tables/resolve-url-refs.js';
3
+ import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
4
+ /**
5
+ * Shared body of the inventory `content_items` bulk-insert ops
6
+ * (`insertInventorySeeds` / `insertInventorySkippedPages`): chunked
7
+ * `url_refs` upsert → id resolution → `content_items` insert-ignore →
8
+ * write-cache population. The two callers differ only in the row
9
+ * constants they stamp on every row, so the invariant-heavy plumbing
10
+ * lives here exactly once:
11
+ *
12
+ * - Chunked into 500-URL batches so SQLite's bound-parameter limit
13
+ * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
14
+ * tens-of-thousands inventory list.
15
+ * - Both inserts are `ON CONFLICT ... IGNORE`, so existing rows — in
16
+ * particular previously crawled pages — are never overwritten
17
+ * (crawled-wins), and within-list duplicates collapse to one row.
18
+ * - The `urlIds` / `contentItems` write caches are populated from what
19
+ * the DB actually holds after the insert (not from the attempted row
20
+ * values), keeping later cache-hits consistent with conflict-ignored
21
+ * rows.
22
+ * @param params - See {@link InsertInventoryContentItemsParams}.
23
+ */
24
+ export async function insertInventoryContentItems(params) {
25
+ const { knex, caches, urls, row, opName } = params;
26
+ if (urls.length === 0) {
27
+ return;
28
+ }
29
+ await eachSplitted([...urls], 500, async (chunk) => {
30
+ await knex('url_refs')
31
+ .insert(chunk.map((url) => ({ url, ...decomposeUrl(url) })))
32
+ .onConflict('url')
33
+ .ignore();
34
+ const urlIds = await resolveUrlRefs(knex, chunk);
35
+ const rows = chunk.map((url) => {
36
+ const urlId = urlIds.get(url);
37
+ if (urlId === undefined) {
38
+ throw new Error(`${opName}: url_refs.id not resolved for ${url}`);
39
+ }
40
+ caches.urlIds.set(url, urlId);
41
+ return {
42
+ url_id: urlId,
43
+ ...row,
44
+ };
45
+ });
46
+ await knex('content_items').insert(rows).onConflict('url_id').ignore();
47
+ const inserted = (await knex
48
+ .select('ci.id', 'ci.source', 'ur.url')
49
+ .from('content_items as ci')
50
+ .join('url_refs as ur', 'ur.id', 'ci.url_id')
51
+ .whereIn('ur.url', chunk));
52
+ for (const insertedRow of inserted) {
53
+ caches.contentItems.set(insertedRow.url, {
54
+ id: insertedRow.id,
55
+ source: insertedRow.source,
56
+ });
57
+ }
58
+ });
59
+ }
@@ -23,15 +23,14 @@ import type { Knex } from 'knex';
23
23
  * behaviour (a seed that turned out to be reachable is not an orphan
24
24
  * and should not retain the inventory label).
25
25
  *
26
- * Chunked into 500-URL batches so SQLite's bound-parameter limit
27
- * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
28
- * tens-of-thousands inventory list.
29
- *
30
26
  * Called by `CrawlerOrchestrator.inventory` during the
31
27
  * `.bak`-protected ingestion phase, so any failure here aborts the run
32
- * and restores from backup — the operator reruns from scratch.
28
+ * and restores from backup — the operator reruns from scratch. The
29
+ * chunking / conflict-ignore / cache-population plumbing lives in
30
+ * {@link insertInventoryContentItems}, shared with
31
+ * `insertInventorySkippedPages`.
33
32
  * @param knex - Knex query builder connected to the archive DB.
34
- * @param caches
33
+ * @param caches - The connection's write-side id caches.
35
34
  * @param urls - URL strings already in `withoutHashAndAuth` form.
36
35
  */
37
36
  export declare function insertInventorySeeds(knex: Knex, caches: WriteRefCaches, urls: readonly string[]): Promise<void>;
@@ -1,6 +1,4 @@
1
- import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
- import { resolveUrlRefs } from '../../../populate-entity-tables/resolve-url-refs.js';
3
- import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
1
+ import { insertInventoryContentItems } from './insert-inventory-content-items.js';
4
2
  /**
5
3
  * Pre-insert inventory HTML seeds into `content_items` as `scraped = 0`,
6
4
  * `source = 'inventory-seed'` placeholders so the URL's existence in the
@@ -24,49 +22,27 @@ import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
24
22
  * behaviour (a seed that turned out to be reachable is not an orphan
25
23
  * and should not retain the inventory label).
26
24
  *
27
- * Chunked into 500-URL batches so SQLite's bound-parameter limit
28
- * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
29
- * tens-of-thousands inventory list.
30
- *
31
25
  * Called by `CrawlerOrchestrator.inventory` during the
32
26
  * `.bak`-protected ingestion phase, so any failure here aborts the run
33
- * and restores from backup — the operator reruns from scratch.
27
+ * and restores from backup — the operator reruns from scratch. The
28
+ * chunking / conflict-ignore / cache-population plumbing lives in
29
+ * {@link insertInventoryContentItems}, shared with
30
+ * `insertInventorySkippedPages`.
34
31
  * @param knex - Knex query builder connected to the archive DB.
35
- * @param caches
32
+ * @param caches - The connection's write-side id caches.
36
33
  * @param urls - URL strings already in `withoutHashAndAuth` form.
37
34
  */
38
35
  export async function insertInventorySeeds(knex, caches, urls) {
39
- if (urls.length === 0) {
40
- return;
41
- }
42
- await eachSplitted([...urls], 500, async (chunk) => {
43
- await knex('url_refs')
44
- .insert(chunk.map((url) => ({ url, ...decomposeUrl(url) })))
45
- .onConflict('url')
46
- .ignore();
47
- const urlIds = await resolveUrlRefs(knex, chunk);
48
- const rows = chunk.map((url) => {
49
- const urlId = urlIds.get(url);
50
- if (urlId === undefined) {
51
- throw new Error(`insertInventorySeeds: url_refs.id not resolved for ${url}`);
52
- }
53
- caches.urlIds.set(url, urlId);
54
- return {
55
- url_id: urlId,
56
- scraped: 0,
57
- is_external: 0,
58
- is_target: 0,
59
- source: 'inventory-seed',
60
- };
61
- });
62
- await knex('content_items').insert(rows).onConflict('url_id').ignore();
63
- const inserted = (await knex
64
- .select('ci.id', 'ci.source', 'ur.url')
65
- .from('content_items as ci')
66
- .join('url_refs as ur', 'ur.id', 'ci.url_id')
67
- .whereIn('ur.url', chunk));
68
- for (const row of inserted) {
69
- caches.contentItems.set(row.url, { id: row.id, source: row.source });
70
- }
36
+ await insertInventoryContentItems({
37
+ knex,
38
+ caches,
39
+ urls,
40
+ row: {
41
+ scraped: 0,
42
+ is_external: 0,
43
+ is_target: 0,
44
+ source: 'inventory-seed',
45
+ },
46
+ opName: 'insertInventorySeeds',
71
47
  });
72
48
  }
@@ -0,0 +1,42 @@
1
+ import type { WriteRefCaches } from '../../_shared/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Record exclude-matched inventory URLs into `content_items` as
5
+ * `scraped = 1`, `is_skipped = 1`, `skip_reason = 'excluded'`,
6
+ * `source = 'inventory-seed'` rows — the same terminal state the normal
7
+ * crawl's fetch-time `shouldSkipUrl` gate produces via `setSkippedPage`
8
+ * for link-discovered excluded URLs.
9
+ *
10
+ * Why a dedicated write path instead of routing these URLs through the
11
+ * crawler's gate: non-HTML inventory URLs never enter the crawler at all
12
+ * (they are recorded straight into `resources`), so the gate cannot see
13
+ * them, and pre-inserting HTML seeds only to have the dealer skip them
14
+ * wastes dealer slots for a verdict already known at ingestion time.
15
+ * Writing the terminal skipped state directly keeps the invariant "the
16
+ * same URL lands in the same archive state regardless of how it was
17
+ * discovered (anchor vs inventory list)" for both classifications
18
+ * (issue #260).
19
+ *
20
+ * `scraped = 1` is load-bearing: it keeps these rows out of
21
+ * `getCrawlingState`'s strict pending set, so `--resume` after an
22
+ * interrupted inventory pass does not try to fetch operator-excluded
23
+ * URLs.
24
+ *
25
+ * Idempotent: both the `url_refs` and `content_items` inserts are
26
+ * `ON CONFLICT ... IGNORE`, so an existing row — in particular a
27
+ * previously crawled page that now matches the exclusion config — is
28
+ * never downgraded to skipped by this path (crawled-wins). The
29
+ * orchestrator additionally filters known URLs out before calling this,
30
+ * so conflicts here are limited to within-list duplicates.
31
+ *
32
+ * Called by `CrawlerOrchestrator.inventory` during the `.bak`-protected
33
+ * ingestion phase, so any failure here aborts the run and restores from
34
+ * backup — the operator reruns from scratch. The chunking /
35
+ * conflict-ignore / cache-population plumbing lives in
36
+ * {@link insertInventoryContentItems}, shared with
37
+ * `insertInventorySeeds`.
38
+ * @param knex - Knex query builder connected to the archive DB.
39
+ * @param caches - The connection's write-side id caches.
40
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
41
+ */
42
+ export declare function insertInventorySkippedPages(knex: Knex, caches: WriteRefCaches, urls: readonly string[]): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { insertInventoryContentItems } from './insert-inventory-content-items.js';
2
+ /**
3
+ * Record exclude-matched inventory URLs into `content_items` as
4
+ * `scraped = 1`, `is_skipped = 1`, `skip_reason = 'excluded'`,
5
+ * `source = 'inventory-seed'` rows — the same terminal state the normal
6
+ * crawl's fetch-time `shouldSkipUrl` gate produces via `setSkippedPage`
7
+ * for link-discovered excluded URLs.
8
+ *
9
+ * Why a dedicated write path instead of routing these URLs through the
10
+ * crawler's gate: non-HTML inventory URLs never enter the crawler at all
11
+ * (they are recorded straight into `resources`), so the gate cannot see
12
+ * them, and pre-inserting HTML seeds only to have the dealer skip them
13
+ * wastes dealer slots for a verdict already known at ingestion time.
14
+ * Writing the terminal skipped state directly keeps the invariant "the
15
+ * same URL lands in the same archive state regardless of how it was
16
+ * discovered (anchor vs inventory list)" for both classifications
17
+ * (issue #260).
18
+ *
19
+ * `scraped = 1` is load-bearing: it keeps these rows out of
20
+ * `getCrawlingState`'s strict pending set, so `--resume` after an
21
+ * interrupted inventory pass does not try to fetch operator-excluded
22
+ * URLs.
23
+ *
24
+ * Idempotent: both the `url_refs` and `content_items` inserts are
25
+ * `ON CONFLICT ... IGNORE`, so an existing row — in particular a
26
+ * previously crawled page that now matches the exclusion config — is
27
+ * never downgraded to skipped by this path (crawled-wins). The
28
+ * orchestrator additionally filters known URLs out before calling this,
29
+ * so conflicts here are limited to within-list duplicates.
30
+ *
31
+ * Called by `CrawlerOrchestrator.inventory` during the `.bak`-protected
32
+ * ingestion phase, so any failure here aborts the run and restores from
33
+ * backup — the operator reruns from scratch. The chunking /
34
+ * conflict-ignore / cache-population plumbing lives in
35
+ * {@link insertInventoryContentItems}, shared with
36
+ * `insertInventorySeeds`.
37
+ * @param knex - Knex query builder connected to the archive DB.
38
+ * @param caches - The connection's write-side id caches.
39
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
40
+ */
41
+ export async function insertInventorySkippedPages(knex, caches, urls) {
42
+ await insertInventoryContentItems({
43
+ knex,
44
+ caches,
45
+ urls,
46
+ row: {
47
+ scraped: 1,
48
+ is_external: 0,
49
+ is_target: 0,
50
+ is_skipped: 1,
51
+ skip_reason: 'excluded',
52
+ source: 'inventory-seed',
53
+ },
54
+ opName: 'insertInventorySkippedPages',
55
+ });
56
+ }
@@ -0,0 +1,41 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `content_items.dedupe_cap_event_id` column to archives created
4
+ * before this feature.
5
+ *
6
+ * `content_items` is provisioned via a bare `CREATE TABLE IF NOT EXISTS` in
7
+ * {@link import('./create-entity-tables.js').createEntityTables}, which
8
+ * self-heals a *missing table* on every `initSchema` call but is a no-op
9
+ * against an *existing* table — adding a column to the DDL string never
10
+ * reaches an archive whose `content_items` predates this change. This
11
+ * mirrors {@link import('./migrate-content-items-alias-of-id.js').migrateContentItemsAliasOfId}'s
12
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
13
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
14
+ *
15
+ * Uses a raw `ALTER TABLE` (not the knex schema builder) so the retrofitted
16
+ * column's `REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED`
17
+ * constraint matches the fresh-archive DDL bit-for-bit.
18
+ *
19
+ * Unlike `migrateContentItemsAliasOfId`, this migration never creates an
20
+ * index for the column — `--dedupe-cap` is opt-in and the number of rows a
21
+ * cap event ever marks is small (capped shapes × matching URLs), so there is
22
+ * no measured hot path to justify one. See `createEntityTables`'s DDL
23
+ * comment for the same reasoning.
24
+ *
25
+ * Only adds the column — it does not compute values for existing rows (they
26
+ * stay `NULL`). That computation runs separately, from
27
+ * `backfillDedupeCapEventId` during a viewer-read-model build, since it
28
+ * requires recomputing `computeShapeKey` against every internal page's URL
29
+ * and matching it against `dedupe_cap_events.shape_key`.
30
+ *
31
+ * Idempotent: adding the column is a no-op once it exists. Guards on
32
+ * `content_items`'s existence defensively, though by the time this runs
33
+ * (after `initSchema`, itself after `assertCompatibleVersion` rejects
34
+ * pre-0.13 archives) the table is always present.
35
+ * @param instance - The Knex query builder instance connected to the database.
36
+ * @example
37
+ * ```ts
38
+ * await migrateContentItemsDedupeCapEventId(knex);
39
+ * ```
40
+ */
41
+ export declare function migrateContentItemsDedupeCapEventId(instance: Knex): Promise<void>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Adds the `content_items.dedupe_cap_event_id` column to archives created
3
+ * before this feature.
4
+ *
5
+ * `content_items` is provisioned via a bare `CREATE TABLE IF NOT EXISTS` in
6
+ * {@link import('./create-entity-tables.js').createEntityTables}, which
7
+ * self-heals a *missing table* on every `initSchema` call but is a no-op
8
+ * against an *existing* table — adding a column to the DDL string never
9
+ * reaches an archive whose `content_items` predates this change. This
10
+ * mirrors {@link import('./migrate-content-items-alias-of-id.js').migrateContentItemsAliasOfId}'s
11
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
12
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
13
+ *
14
+ * Uses a raw `ALTER TABLE` (not the knex schema builder) so the retrofitted
15
+ * column's `REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED`
16
+ * constraint matches the fresh-archive DDL bit-for-bit.
17
+ *
18
+ * Unlike `migrateContentItemsAliasOfId`, this migration never creates an
19
+ * index for the column — `--dedupe-cap` is opt-in and the number of rows a
20
+ * cap event ever marks is small (capped shapes × matching URLs), so there is
21
+ * no measured hot path to justify one. See `createEntityTables`'s DDL
22
+ * comment for the same reasoning.
23
+ *
24
+ * Only adds the column — it does not compute values for existing rows (they
25
+ * stay `NULL`). That computation runs separately, from
26
+ * `backfillDedupeCapEventId` during a viewer-read-model build, since it
27
+ * requires recomputing `computeShapeKey` against every internal page's URL
28
+ * and matching it against `dedupe_cap_events.shape_key`.
29
+ *
30
+ * Idempotent: adding the column is a no-op once it exists. Guards on
31
+ * `content_items`'s existence defensively, though by the time this runs
32
+ * (after `initSchema`, itself after `assertCompatibleVersion` rejects
33
+ * pre-0.13 archives) the table is always present.
34
+ * @param instance - The Knex query builder instance connected to the database.
35
+ * @example
36
+ * ```ts
37
+ * await migrateContentItemsDedupeCapEventId(knex);
38
+ * ```
39
+ */
40
+ export async function migrateContentItemsDedupeCapEventId(instance) {
41
+ const hasContentItems = await instance.schema.hasTable('content_items');
42
+ if (!hasContentItems) {
43
+ return;
44
+ }
45
+ const hasColumn = await instance.schema.hasColumn('content_items', 'dedupe_cap_event_id');
46
+ if (!hasColumn) {
47
+ await instance.raw('ALTER TABLE content_items ADD COLUMN dedupe_cap_event_id INTEGER REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED');
48
+ // eslint-disable-next-line no-console
49
+ console.error('[migrate] content_items.dedupe_cap_event_id column added');
50
+ }
51
+ }
@@ -29,6 +29,16 @@ import type { Knex } from 'knex';
29
29
  * `scripts/migrate-to-0.13.mjs` orders the two calls statically so this
30
30
  * ordering is enforced there, not here.
31
31
  *
32
+ * `content_items` also declares `dedupe_cap_event_id REFERENCES
33
+ * dedupe_cap_events(id)` — an adjunct table (`createAdjunctTables`), not a
34
+ * ref table. This function only creates the empty tables (schema-only, see
35
+ * above), so it does not itself need `dedupe_cap_events` to exist. Callers
36
+ * that write data into `content_items` afterward do: under `PRAGMA
37
+ * foreign_keys = ON`, SQLite refuses to even prepare an INSERT/UPDATE
38
+ * against a table with an unresolvable `REFERENCES` target, so
39
+ * `scripts/migrate-to-0.13.mjs` creates adjunct tables immediately after
40
+ * this migration and before any entity-table data write.
41
+ *
32
42
  * **Idempotency**: `createEntityTables` itself uses
33
43
  * `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` for every
34
44
  * statement, so calling it multiple times against any DB state is safe.
@@ -29,6 +29,16 @@ import { createEntityTables } from './create-entity-tables.js';
29
29
  * `scripts/migrate-to-0.13.mjs` orders the two calls statically so this
30
30
  * ordering is enforced there, not here.
31
31
  *
32
+ * `content_items` also declares `dedupe_cap_event_id REFERENCES
33
+ * dedupe_cap_events(id)` — an adjunct table (`createAdjunctTables`), not a
34
+ * ref table. This function only creates the empty tables (schema-only, see
35
+ * above), so it does not itself need `dedupe_cap_events` to exist. Callers
36
+ * that write data into `content_items` afterward do: under `PRAGMA
37
+ * foreign_keys = ON`, SQLite refuses to even prepare an INSERT/UPDATE
38
+ * against a table with an unresolvable `REFERENCES` target, so
39
+ * `scripts/migrate-to-0.13.mjs` creates adjunct tables immediately after
40
+ * this migration and before any entity-table data write.
41
+ *
32
42
  * **Idempotency**: `createEntityTables` itself uses
33
43
  * `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` for every
34
44
  * statement, so calling it multiple times against any DB state is safe.
@@ -0,0 +1,20 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `inventory_runs.exclude_skipped` column to archives created
4
+ * before it existed. `CREATE TABLE IF NOT EXISTS` (used for `inventory_runs`
5
+ * itself) cannot retrofit a new column onto an already-existing table, so
6
+ * this lightweight, `hasColumn`-guarded `ALTER TABLE` runs on every
7
+ * `initSchema` call — idempotent, and self-healing for archives whose
8
+ * provisioning crashed partway through.
9
+ *
10
+ * Pre-migration rows stay `NULL`: those runs predate ingestion-side
11
+ * exclusion (issue #260), so their excluded URLs were imported as real
12
+ * pages/resources rather than recorded as skipped — `NULL` means "not
13
+ * measured", not `0`.
14
+ *
15
+ * The column is pure audit output: written once per run and read back
16
+ * only by `listInventoryRuns` display surfaces, never consumed by any
17
+ * runtime decision — matching `scope_skipped` / `invalid_skipped`.
18
+ * @param instance - The Knex query builder instance connected to the database.
19
+ */
20
+ export declare function migrateInventoryRunsExcludeSkipped(instance: Knex): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Adds the `inventory_runs.exclude_skipped` column to archives created
3
+ * before it existed. `CREATE TABLE IF NOT EXISTS` (used for `inventory_runs`
4
+ * itself) cannot retrofit a new column onto an already-existing table, so
5
+ * this lightweight, `hasColumn`-guarded `ALTER TABLE` runs on every
6
+ * `initSchema` call — idempotent, and self-healing for archives whose
7
+ * provisioning crashed partway through.
8
+ *
9
+ * Pre-migration rows stay `NULL`: those runs predate ingestion-side
10
+ * exclusion (issue #260), so their excluded URLs were imported as real
11
+ * pages/resources rather than recorded as skipped — `NULL` means "not
12
+ * measured", not `0`.
13
+ *
14
+ * The column is pure audit output: written once per run and read back
15
+ * only by `listInventoryRuns` display surfaces, never consumed by any
16
+ * runtime decision — matching `scope_skipped` / `invalid_skipped`.
17
+ * @param instance - The Knex query builder instance connected to the database.
18
+ */
19
+ export async function migrateInventoryRunsExcludeSkipped(instance) {
20
+ const hasTable = await instance.schema.hasTable('inventory_runs');
21
+ if (!hasTable) {
22
+ return;
23
+ }
24
+ const hasColumn = await instance.schema.hasColumn('inventory_runs', 'exclude_skipped');
25
+ if (hasColumn) {
26
+ return;
27
+ }
28
+ await instance.schema.table('inventory_runs', (t) => {
29
+ t.integer('exclude_skipped');
30
+ });
31
+ // eslint-disable-next-line no-console
32
+ console.error('[migrate] inventory_runs.exclude_skipped column added');
33
+ }
@@ -8,6 +8,13 @@ import knex from 'knex';
8
8
  * actually read.
9
9
  * - The 0.13 ref / header tables (via {@link createRefTables}).
10
10
  * - The 0.13 entity tables (via {@link createEntityTables}).
11
+ * - The 0.13 adjunct tables (via {@link createAdjunctTables}) — required
12
+ * because `content_items.dedupe_cap_event_id REFERENCES
13
+ * dedupe_cap_events(id)`; under `PRAGMA foreign_keys = ON` (enabled
14
+ * below), inserting into `content_items` fails with `no such table:
15
+ * dedupe_cap_events` if the adjunct tables were skipped. `initSchema`
16
+ * always calls both create functions together, so this mirrors a real
17
+ * archive's actual schema rather than an artificially incomplete one.
11
18
  *
12
19
  * Every 0.13 populate spec calls this to obtain a fresh DB. The
13
20
  * caller is responsible for `db.destroy()` (spec `afterEach`).
@@ -1,4 +1,5 @@
1
1
  import knex from 'knex';
2
+ import { createAdjunctTables } from '../../create-adjunct-tables.js';
2
3
  import { createEntityTables } from '../../create-entity-tables.js';
3
4
  import { createRefTables } from '../../create-ref-tables.js';
4
5
  import { LibsqlDialect } from '../../libsql-dialect.js';
@@ -11,6 +12,13 @@ import { LibsqlDialect } from '../../libsql-dialect.js';
11
12
  * actually read.
12
13
  * - The 0.13 ref / header tables (via {@link createRefTables}).
13
14
  * - The 0.13 entity tables (via {@link createEntityTables}).
15
+ * - The 0.13 adjunct tables (via {@link createAdjunctTables}) — required
16
+ * because `content_items.dedupe_cap_event_id REFERENCES
17
+ * dedupe_cap_events(id)`; under `PRAGMA foreign_keys = ON` (enabled
18
+ * below), inserting into `content_items` fails with `no such table:
19
+ * dedupe_cap_events` if the adjunct tables were skipped. `initSchema`
20
+ * always calls both create functions together, so this mirrors a real
21
+ * archive's actual schema rather than an artificially incomplete one.
14
22
  *
15
23
  * Every 0.13 populate spec calls this to obtain a fresh DB. The
16
24
  * caller is responsible for `db.destroy()` (spec `afterEach`).
@@ -174,5 +182,6 @@ export async function setupMigrationDb() {
174
182
  `);
175
183
  await createRefTables(db);
176
184
  await createEntityTables(db);
185
+ await createAdjunctTables(db);
177
186
  return db;
178
187
  }
@@ -121,6 +121,7 @@ export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
121
121
  * new_pages: 1234,
122
122
  * new_resources: 56,
123
123
  * scope_skipped: 7,
124
+ * exclude_skipped: 3,
124
125
  * });
125
126
  */
126
127
  export interface InventoryRunMeta {
@@ -138,6 +139,8 @@ export interface InventoryRunMeta {
138
139
  new_resources?: number | null;
139
140
  /** Number of input URLs dropped because they fell outside the archived scope. */
140
141
  scope_skipped?: number | null;
142
+ /** Number of novel in-scope input URLs recorded as terminal skipped pages (`is_skipped=1`, `skip_reason='excluded'`) instead of being imported, because they matched the effective `excludes` / `excludeUrls` config. Pure audit output like every other count on this row — written once per run, read back only by `listInventoryRuns` display surfaces, never consumed by any runtime decision. `null` on rows written before the column existed (those runs predate ingestion-side exclusion — their excluded URLs were imported as real pages/resources, not counted). */
143
+ exclude_skipped?: number | null;
141
144
  /** Number of source-file lines dropped by the CLI for failing URL validation, before this row's `total_lines` was counted. `null` for programmatic callers that built the URL list in-memory (no source file to have invalid lines). */
142
145
  invalid_skipped?: number | null;
143
146
  /** Free-form text for backfill annotations or operator notes. */
@@ -228,9 +228,10 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
228
228
  * 1. Open the archive (writer mode, takes the archive lock).
229
229
  * 2. Reject list-mode archives — they hold metadata-only rows that
230
230
  * inventory has no business touching.
231
- * 3. Reject archives with unfinished `pending` URLs — those would inherit
232
- * the inventory `source` label by mistake. Operator must resume /
233
- * retry-failed first.
231
+ * 3. Warn (but proceed) on archives with unfinished `pending` URLs —
232
+ * crawled-wins source priority keeps their labels stable; the
233
+ * operator can `--resume` first if they want the prior work
234
+ * finalized.
234
235
  * 4. If `source` is given, archive its exact bytes under
235
236
  * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
236
237
  * Done before scope classification so even a run that discards every
@@ -243,17 +244,33 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
243
244
  * 6. Subtract URLs that already exist in `pages` or `resources` so the
244
245
  * second (and N-th) inventory pass is a no-op for known rows — keeps
245
246
  * `'inventory-seed'` rows from being silently demoted.
246
- * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
247
+ * 7. Split the remaining novel URLs on the effective `excludes` /
248
+ * `excludeUrls` (archived config overlaid with this run's
249
+ * overrides — the same inputs the crawl's fetch-time
250
+ * `shouldSkipUrl` gate uses). Matching URLs are recorded as
251
+ * terminal skipped pages (`is_skipped=1`,
252
+ * `skip_reason='excluded'`, `source='inventory-seed'`) instead of
253
+ * being imported — the same end state a link-discovered excluded
254
+ * URL reaches in a normal crawl — and counted as
255
+ * `exclude_skipped` (issue #260). Running this after step 6 keeps
256
+ * previously crawled rows that newly match the exclusion config
257
+ * untouched (crawled-wins). `excludeKeywords` does not
258
+ * participate here: it matches rendered page content, which a URL
259
+ * list does not have — HTML seeds still get it at render time via
260
+ * the browser verdict.
261
+ * 8. Make `<archive>.bak`. Anything thrown beyond this point restores
247
262
  * from the backup.
248
- * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
249
- * as Crawler seeds (`'inventory-seed'`); everything else is recorded
250
- * in `resources` directly as `'inventory-seed'` (no browser launch).
251
- * 9. If any HTML seeds exist, start a Crawler with
263
+ * 9. Classify each importable novel URL by URL-extension heuristic
264
+ * (no probe see the in-body rationale). HTML-looking URLs are
265
+ * queued as Crawler seeds (`'inventory-seed'`); everything else is
266
+ * recorded in `resources` directly as `'inventory-seed'` (no
267
+ * browser launch, no HEAD).
268
+ * 10. If any HTML seeds exist, start a Crawler with
252
269
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
253
270
  * discovered downstream link is labelled correctly. `resume` is fed
254
271
  * the existing `scraped` / `resources` sets so links into already-
255
272
  * crawled pages stop at the seen-gate without re-rendering.
256
- * 10. Drop the backup on success; restore it on any throw.
273
+ * 11. Drop the backup on success; restore it on any throw.
257
274
  *
258
275
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
259
276
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -276,7 +293,7 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
276
293
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
277
294
  * column will be `NULL` and no source list is archived.
278
295
  * @returns The orchestrator instance after a successful inventory pass.
279
- * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
296
+ * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
280
297
  */
281
298
  static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null): Promise<CrawlerOrchestrator>;
282
299
  /**
@@ -16,6 +16,7 @@ import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
16
16
  import { networkOutageSummaryCounter } from './crawler/network-outage-summary-counter.js';
17
17
  import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
18
18
  import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
19
+ import { shouldSkipUrl } from './crawler/should-skip-url.js';
19
20
  import { crawlerLog, log } from './debug.js';
20
21
  import { normalizeToArray } from './normalize-to-array.js';
21
22
  import { resolveOutputPath } from './resolve-output-path.js';
@@ -658,9 +659,10 @@ export class CrawlerOrchestrator extends EventEmitter {
658
659
  * 1. Open the archive (writer mode, takes the archive lock).
659
660
  * 2. Reject list-mode archives — they hold metadata-only rows that
660
661
  * inventory has no business touching.
661
- * 3. Reject archives with unfinished `pending` URLs — those would inherit
662
- * the inventory `source` label by mistake. Operator must resume /
663
- * retry-failed first.
662
+ * 3. Warn (but proceed) on archives with unfinished `pending` URLs —
663
+ * crawled-wins source priority keeps their labels stable; the
664
+ * operator can `--resume` first if they want the prior work
665
+ * finalized.
664
666
  * 4. If `source` is given, archive its exact bytes under
665
667
  * `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
666
668
  * Done before scope classification so even a run that discards every
@@ -673,17 +675,33 @@ export class CrawlerOrchestrator extends EventEmitter {
673
675
  * 6. Subtract URLs that already exist in `pages` or `resources` so the
674
676
  * second (and N-th) inventory pass is a no-op for known rows — keeps
675
677
  * `'inventory-seed'` rows from being silently demoted.
676
- * 7. Make `<archive>.bak`. Anything thrown beyond this point restores
678
+ * 7. Split the remaining novel URLs on the effective `excludes` /
679
+ * `excludeUrls` (archived config overlaid with this run's
680
+ * overrides — the same inputs the crawl's fetch-time
681
+ * `shouldSkipUrl` gate uses). Matching URLs are recorded as
682
+ * terminal skipped pages (`is_skipped=1`,
683
+ * `skip_reason='excluded'`, `source='inventory-seed'`) instead of
684
+ * being imported — the same end state a link-discovered excluded
685
+ * URL reaches in a normal crawl — and counted as
686
+ * `exclude_skipped` (issue #260). Running this after step 6 keeps
687
+ * previously crawled rows that newly match the exclusion config
688
+ * untouched (crawled-wins). `excludeKeywords` does not
689
+ * participate here: it matches rendered page content, which a URL
690
+ * list does not have — HTML seeds still get it at render time via
691
+ * the browser verdict.
692
+ * 8. Make `<archive>.bak`. Anything thrown beyond this point restores
677
693
  * from the backup.
678
- * 8. HEAD-probe each novel URL. Responses classified as HTML are queued
679
- * as Crawler seeds (`'inventory-seed'`); everything else is recorded
680
- * in `resources` directly as `'inventory-seed'` (no browser launch).
681
- * 9. If any HTML seeds exist, start a Crawler with
694
+ * 9. Classify each importable novel URL by URL-extension heuristic
695
+ * (no probe see the in-body rationale). HTML-looking URLs are
696
+ * queued as Crawler seeds (`'inventory-seed'`); everything else is
697
+ * recorded in `resources` directly as `'inventory-seed'` (no
698
+ * browser launch, no HEAD).
699
+ * 10. If any HTML seeds exist, start a Crawler with
682
700
  * `inventoryMode = { seedUrls }` so the rendered page and every newly
683
701
  * discovered downstream link is labelled correctly. `resume` is fed
684
702
  * the existing `scraped` / `resources` sets so links into already-
685
703
  * crawled pages stop at the seen-gate without re-rendering.
686
- * 10. Drop the backup on success; restore it on any throw.
704
+ * 11. Drop the backup on success; restore it on any throw.
687
705
  *
688
706
  * Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
689
707
  * `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
@@ -706,7 +724,7 @@ export class CrawlerOrchestrator extends EventEmitter {
706
724
  * `inventoryUrls` in-memory; the audit row's `source_file_sha256`
707
725
  * column will be `NULL` and no source list is archived.
708
726
  * @returns The orchestrator instance after a successful inventory pass.
709
- * @throws {Error} When `inventoryUrls` is empty, the archive is in list mode, or pending URLs from a previous crawl remain unresolved.
727
+ * @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
710
728
  */
711
729
  static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
712
730
  if (inventoryUrls.length === 0) {
@@ -795,17 +813,49 @@ export class CrawlerOrchestrator extends EventEmitter {
795
813
  });
796
814
  const knownCount = existingPageUrls.size + existingResourceUrls.size;
797
815
  log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
816
+ // Split the novel URLs on the exclusion config BEFORE the
817
+ // HTML/non-HTML classification, so an exclude-matched URL is
818
+ // recorded as a terminal skipped page instead of being imported
819
+ // (issue #260). The inputs mirror the scrape phase's fetch-time
820
+ // gate (`shouldSkipUrl` in `crawler.ts` fed by the constructor's
821
+ // merge): archived config overlaid with this run's overrides,
822
+ // and `DEFAULT_EXCLUDED_EXTERNAL_URLS` merged ahead of the
823
+ // user's prefixes — classification and gate must never disagree
824
+ // about the same URL. Running this AFTER the known-URL filter is
825
+ // deliberate: a previously crawled row that newly matches the
826
+ // exclusion config stays untouched (crawled-wins), matching how
827
+ // `getExistingPageUrls` shields known rows from re-labelling.
828
+ // `excludeKeywords` is deliberately absent: it matches rendered
829
+ // page content, which a URL list does not have — HTML seeds
830
+ // still get it at render time via the browser verdict.
831
+ const effectiveConfig = { ...archived, ...cleanObject(options) };
832
+ const excludes = normalizeToArray(effectiveConfig.excludes);
833
+ const excludeUrls = [
834
+ ...DEFAULT_EXCLUDED_EXTERNAL_URLS,
835
+ ...normalizeToArray(effectiveConfig.excludeUrls),
836
+ ];
837
+ const excludedNovelUrls = [];
838
+ const importableNovelUrls = [];
839
+ for (const url of novelUrls) {
840
+ if (shouldSkipUrl({ url, excludes, excludeUrls, options: effectiveConfig })) {
841
+ excludedNovelUrls.push(url);
842
+ }
843
+ else {
844
+ importableNovelUrls.push(url);
845
+ }
846
+ }
847
+ if (excludedNovelUrls.length > 0) {
848
+ log('[inventory] %d URL(s) recorded as skipped (matched excludes / excludeUrls)', excludedNovelUrls.length);
849
+ }
798
850
  if (novelUrls.length === 0) {
799
851
  // Nothing to do — release the archive cleanly without taking a
800
852
  // backup. The orchestrator returned here is empty; the caller
801
- // should only invoke `close` on it.
802
- const noopConfig = {
803
- ...archived,
804
- ...cleanObject(options),
805
- };
806
- const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
853
+ // should only invoke `close` on it. `effectiveConfig` is the
854
+ // same archived-plus-overrides merge every other path in this
855
+ // method sees.
856
+ const orchestrator = new CrawlerOrchestrator(archive, effectiveConfig);
807
857
  if (initializedCallback) {
808
- await initializedCallback(orchestrator, noopConfig);
858
+ await initializedCallback(orchestrator, effectiveConfig);
809
859
  }
810
860
  return orchestrator;
811
861
  }
@@ -821,7 +871,7 @@ export class CrawlerOrchestrator extends EventEmitter {
821
871
  // clause). This flag steers the catch below.
822
872
  let ingestionComplete = false;
823
873
  try {
824
- // Classify novel URLs by URL-extension heuristic (no I/O).
874
+ // Classify importable novel URLs by URL-extension heuristic (no I/O).
825
875
  // Source file lists come from `ls` on the doc-root, so the
826
876
  // extension reflects the real file type — a HEAD pre-flight
827
877
  // here would be pure wasted I/O. Edge cases:
@@ -852,7 +902,7 @@ export class CrawlerOrchestrator extends EventEmitter {
852
902
  // null as "not probed" rather than "failed".
853
903
  const rawHtmlSeeds = [];
854
904
  const nonHtmlSeeds = [];
855
- for (const url of novelUrls) {
905
+ for (const url of importableNovelUrls) {
856
906
  if (isLikelyHtmlUrl(url)) {
857
907
  rawHtmlSeeds.push(url);
858
908
  }
@@ -892,7 +942,15 @@ export class CrawlerOrchestrator extends EventEmitter {
892
942
  // these rows up on the next `--resume` via the
893
943
  // `OR p.source != 'crawled'` clause.
894
944
  await archive.insertInventorySeeds(htmlSeeds);
895
- log('[inventory] %d HTML seed(s), %d non-HTML resource(s) recorded', htmlSeeds.length, nonHtmlSeeds.length);
945
+ // Record exclude-matched novel URLs as terminal skipped pages
946
+ // (`is_skipped=1`, `skip_reason='excluded'`,
947
+ // `source='inventory-seed'`) — the same end state the normal
948
+ // crawl's fetch-time gate produces for link-discovered
949
+ // excluded URLs, so the archive looks identical no matter
950
+ // how the URL was discovered. Inside the `.bak` window for
951
+ // the same all-or-nothing reason as the seed inserts above.
952
+ await archive.insertInventorySkippedPages(excludedNovelUrls);
953
+ log('[inventory] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
896
954
  // Audit row is written *inside* the `.bak` window: a libsql
897
955
  // hiccup or transient lock on the INSERT aborts the ingestion
898
956
  // and the `.bak` restore wipes the pre-inserted seeds too,
@@ -906,6 +964,7 @@ export class CrawlerOrchestrator extends EventEmitter {
906
964
  htmlSeedsCount: htmlSeeds.length,
907
965
  nonHtmlCount: nonHtmlSeeds.length,
908
966
  outOfScope,
967
+ excludeSkipped: excludedNovelUrls.length,
909
968
  sourceFileSha256: source?.sha256 ?? null,
910
969
  invalidSkipped: source?.invalidLineCount ?? null,
911
970
  });
@@ -925,8 +984,7 @@ export class CrawlerOrchestrator extends EventEmitter {
925
984
  // (matches the rest of the orchestrator's public surface —
926
985
  // no inventory bookkeeping leaks out).
927
986
  const baseConfig = {
928
- ...archived,
929
- ...cleanObject(options),
987
+ ...effectiveConfig,
930
988
  recursive: true,
931
989
  fromList: false,
932
990
  };
@@ -1252,6 +1310,7 @@ export class CrawlerOrchestrator extends EventEmitter {
1252
1310
  new_pages: aggregates.htmlSeedsCount,
1253
1311
  new_resources: aggregates.nonHtmlCount,
1254
1312
  scope_skipped: aggregates.outOfScope,
1313
+ exclude_skipped: aggregates.excludeSkipped,
1255
1314
  invalid_skipped: aggregates.invalidSkipped,
1256
1315
  });
1257
1316
  }
package/lib/crawler.d.ts CHANGED
@@ -33,6 +33,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
33
33
  export { decodeStoredBlob } from './archive/decode-html-blob.js';
34
34
  export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
35
35
  export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
36
+ export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
36
37
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
37
38
  export * from './types.js';
38
39
  export * from './crawler/types.js';
package/lib/crawler.js CHANGED
@@ -31,6 +31,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
31
31
  export { decodeStoredBlob } from './archive/decode-html-blob.js';
32
32
  export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
33
33
  export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
34
+ export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
34
35
  // Core
35
36
  export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
36
37
  export * from './types.js';
package/lib/types.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface InventoryRunAggregates {
18
18
  nonHtmlCount: number;
19
19
  /** URLs dropped because they fell outside the archived scope. Stored as `scope_skipped`. */
20
20
  outOfScope: number;
21
+ /** Novel in-scope URLs recorded as terminal skipped pages (`is_skipped=1`, `skip_reason='excluded'`) instead of being imported, because they matched the effective `excludes` / `excludeUrls` config — the same inputs the scrape phase's fetch-time `shouldSkipUrl` gate uses. Stored as `exclude_skipped`. */
22
+ excludeSkipped: number;
21
23
  /**
22
24
  * SHA-256 hex digest of the source `.txt`, **pre-computed by the caller**
23
25
  * (typically the CLI's `inventoryCrawl`). Stored verbatim as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/crawler",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Web crawler engine with headless browser rendering and archive storage",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -39,7 +39,7 @@
39
39
  "libsql": "0.5.29",
40
40
  "puppeteer": "25.3.0",
41
41
  "robots-parser": "3.0.1",
42
- "tar": "7.5.20"
42
+ "tar": "7.5.21"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/debug": "4.1.13",
@@ -48,5 +48,5 @@
48
48
  "@types/tar": "7.0.87",
49
49
  "@types/unzipper": "0.10.11"
50
50
  },
51
- "gitHead": "bef8b6d48e3ca5167fee643d6aba8644a065df4a"
51
+ "gitHead": "2cdef7cbb4da489270e0f1c9ec2e8166b3d84e91"
52
52
  }