@nitpicker/crawler 0.16.0 → 0.18.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.
- package/lib/archive/archive.d.ts +13 -0
- package/lib/archive/archive.js +19 -0
- package/lib/archive/create-adjunct-tables.js +1 -0
- package/lib/archive/create-entity-tables.js +10 -0
- package/lib/archive/database.d.ts +9 -0
- package/lib/archive/database.js +12 -0
- package/lib/archive/db-ops/config/get-config.js +1 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
- package/lib/archive/db-ops/lifecycle/init.js +12 -2
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
- package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
- package/lib/archive/migrate-entity-tables.d.ts +10 -0
- package/lib/archive/migrate-entity-tables.js +10 -0
- package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
- package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
- package/lib/archive/types.d.ts +3 -0
- package/lib/crawler-orchestrator.d.ts +27 -10
- package/lib/crawler-orchestrator.js +81 -22
- package/lib/crawler.d.ts +1 -0
- package/lib/crawler.js +1 -0
- package/lib/types.d.ts +2 -0
- package/package.json +6 -6
package/lib/archive/archive.d.ts
CHANGED
|
@@ -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
|
*
|
package/lib/archive/archive.js
CHANGED
|
@@ -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}.
|
package/lib/archive/database.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
}
|
package/lib/archive/types.d.ts
CHANGED
|
@@ -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.
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"clean": "tsc --build --clean"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@d-zero/beholder": "4.
|
|
31
|
-
"@d-zero/dealer": "1.
|
|
30
|
+
"@d-zero/beholder": "4.2.2",
|
|
31
|
+
"@d-zero/dealer": "1.10.4",
|
|
32
32
|
"@d-zero/fs": "0.2.6",
|
|
33
|
-
"@d-zero/shared": "0.22.
|
|
33
|
+
"@d-zero/shared": "0.22.5",
|
|
34
34
|
"ansi-colors": "4.1.3",
|
|
35
35
|
"debug": "4.4.3",
|
|
36
36
|
"follow-redirects": "1.16.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.
|
|
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": "
|
|
51
|
+
"gitHead": "9be5b756adb5bfaaa3e7fd52fabc93213c7e0b50"
|
|
52
52
|
}
|