@nitpicker/crawler 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/archive/archive.d.ts +52 -1
- package/lib/archive/archive.js +68 -0
- package/lib/archive/create-adjunct-tables.d.ts +3 -0
- package/lib/archive/create-adjunct-tables.js +43 -0
- package/lib/archive/create-entity-tables.js +10 -0
- package/lib/archive/database.d.ts +40 -1
- package/lib/archive/database.js +54 -0
- package/lib/archive/db-ops/config/get-config.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -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 +16 -0
- package/lib/classify-error-kind.d.ts +1 -0
- package/lib/classify-error-kind.js +14 -0
- package/lib/crawler/assert-chrome-installed.d.ts +24 -0
- package/lib/crawler/assert-chrome-installed.js +43 -0
- package/lib/crawler/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +239 -29
- package/lib/crawler/decode-auth-credential.d.ts +29 -0
- package/lib/crawler/decode-auth-credential.js +39 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
- package/lib/crawler/dedupe/compute-shape-key.js +56 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
- package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
- package/lib/crawler/dedupe/is-shape-capped.js +12 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
- package/lib/crawler/dedupe/types.d.ts +42 -0
- package/lib/crawler/dedupe/types.js +1 -0
- package/lib/crawler/fetch-destination.js +14 -2
- package/lib/crawler/generate-predicted-urls.d.ts +12 -0
- package/lib/crawler/generate-predicted-urls.js +33 -2
- package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
- package/lib/crawler/types.d.ts +38 -0
- package/lib/crawler-orchestrator.d.ts +39 -10
- package/lib/crawler-orchestrator.js +187 -23
- package/lib/crawler.d.ts +2 -0
- package/lib/crawler.js +2 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +4 -1
- package/package.json +3 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
|
|
3
|
+
* used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
|
|
4
|
+
* on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
|
|
5
|
+
* this crawl already paid the cost of discovering once is not re-admitted
|
|
6
|
+
* in a later session. Fresh (non-resuming) crawls do not call this — there
|
|
7
|
+
* is no archive history to seed from.
|
|
8
|
+
*
|
|
9
|
+
* Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
|
|
10
|
+
* needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
|
|
11
|
+
* confirmed — there is no equivalent of "the host might have recovered
|
|
12
|
+
* since".
|
|
13
|
+
*
|
|
14
|
+
* Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
|
|
15
|
+
* table (self-healed on next writer open, so this is never a permanent
|
|
16
|
+
* state) or that have recorded no capped shapes.
|
|
17
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
18
|
+
* @returns Distinct shape keys already confirmed capped.
|
|
19
|
+
*/
|
|
20
|
+
export async function listDedupeCapShapeKeys(knex) {
|
|
21
|
+
const hasTable = await knex.schema.hasTable('dedupe_cap_events');
|
|
22
|
+
if (!hasTable) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const rows = (await knex('dedupe_cap_events').distinct('shape_key'));
|
|
26
|
+
return rows.map((row) => row.shape_key);
|
|
27
|
+
}
|
|
@@ -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
|
}
|