@nitpicker/crawler 0.20.0 → 0.22.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/append-retry-failed-common-setup-phases.d.ts +1 -1
- package/lib/append-retry-failed-common-setup-phases.js +5 -1
- package/lib/append-setup-phases.d.ts +1 -1
- package/lib/archive/archive.d.ts +57 -8
- package/lib/archive/archive.js +83 -12
- package/lib/archive/create-adjunct-tables.d.ts +3 -2
- package/lib/archive/create-adjunct-tables.js +12 -9
- package/lib/archive/create-entity-tables.js +3 -0
- package/lib/archive/database.d.ts +39 -7
- package/lib/archive/database.js +49 -9
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
- package/lib/archive/db-ops/_shared/types.d.ts +39 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
- package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
- package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
- package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
- package/lib/archive/db-ops/lifecycle/init.js +30 -10
- package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
- package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
- package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
- package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
- package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
- package/lib/archive/db-ops/pages/write/update-page.js +28 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +3 -2
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
- package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
- package/lib/archive/meta/types.d.ts +24 -0
- package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
- package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
- package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
- package/lib/archive/migrate-info-created-cwd.js +36 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
- package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
- package/lib/archive/migrate-page-meta-image-scan.js +52 -0
- package/lib/archive/page.d.ts +12 -0
- package/lib/archive/page.js +16 -0
- package/lib/archive/types.d.ts +97 -10
- package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
- package/lib/compute-auto-retry-backoff-delay.js +36 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
- package/lib/crawler/crawler.d.ts +35 -3
- package/lib/crawler/crawler.js +486 -338
- package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
- package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
- package/lib/crawler/dedupe/compute-shape-key.js +73 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
- package/lib/crawler/dedupe/types.d.ts +18 -0
- package/lib/crawler/fetch-destination.js +1 -0
- package/lib/crawler/link-list.d.ts +9 -1
- package/lib/crawler/link-list.js +12 -2
- package/lib/crawler/link-to-page-data.js +1 -0
- package/lib/crawler/resource-to-page-data.js +1 -0
- package/lib/crawler/types.d.ts +75 -0
- package/lib/crawler-orchestrator.d.ts +171 -8
- package/lib/crawler-orchestrator.js +1177 -234
- package/lib/crawler.d.ts +3 -0
- package/lib/crawler.js +3 -0
- package/lib/delay-or-abort.d.ts +20 -0
- package/lib/delay-or-abort.js +35 -0
- package/lib/pending-urls-remain-error.d.ts +35 -0
- package/lib/pending-urls-remain-error.js +43 -0
- package/lib/recrawl-setup-phases.d.ts +38 -0
- package/lib/recrawl-setup-phases.js +52 -0
- package/lib/resume-setup-phases.d.ts +1 -1
- package/lib/resume-setup-phases.js +5 -1
- package/lib/retry-failed-setup-phases.d.ts +1 -1
- package/lib/setup-recovery-phase-labels.d.ts +17 -8
- package/lib/setup-recovery-phase-labels.js +17 -8
- package/lib/types.d.ts +62 -12
- package/lib/utils/compute-file-sha256.d.ts +1 -1
- package/lib/utils/compute-file-sha256.js +1 -1
- package/package.json +4 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
|
@@ -2,21 +2,25 @@ 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
4
|
import { migrateContentItemsDedupeCapEventId } from '../../migrate-content-items-dedupe-cap-event-id.js';
|
|
5
|
+
import { migrateContentItemsIsMetadataOnly } from '../../migrate-content-items-is-metadata-only.js';
|
|
6
|
+
import { migrateInfoCreatedCwd } from '../../migrate-info-created-cwd.js';
|
|
5
7
|
import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
|
|
6
8
|
import { migrateInfoRoots } from '../../migrate-info-roots.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
+
import { migrateInventoryRunsToListReconcileRuns } from '../../migrate-inventory-runs-to-list-reconcile-runs.js';
|
|
10
|
+
import { migrateListReconcileRunsExcludeSkipped } from '../../migrate-list-reconcile-runs-exclude-skipped.js';
|
|
11
|
+
import { migrateListReconcileRunsInvalidSkipped } from '../../migrate-list-reconcile-runs-invalid-skipped.js';
|
|
9
12
|
import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
|
|
10
13
|
import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
|
|
11
14
|
import { migratePageMetaConsoleErrorCount } from '../../migrate-page-meta-console-error-count.js';
|
|
12
15
|
import { migratePageMetaCustomElementCount } from '../../migrate-page-meta-custom-element-count.js';
|
|
16
|
+
import { migratePageMetaImageScan } from '../../migrate-page-meta-image-scan.js';
|
|
13
17
|
import { migratePageTagsToPageTechnologies } from '../../migrate-page-tags-to-page-technologies.js';
|
|
14
18
|
import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-network-outages.js';
|
|
15
19
|
/**
|
|
16
20
|
* Initializes the database schema if tables do not exist, then runs the
|
|
17
21
|
* remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
|
|
18
|
-
* `page_meta.main_content_*`, `
|
|
19
|
-
* `
|
|
22
|
+
* `page_meta.main_content_*`, `list_reconcile_runs.invalid_skipped`,
|
|
23
|
+
* `list_reconcile_runs.exclude_skipped`).
|
|
20
24
|
*
|
|
21
25
|
* There is deliberately no per-table *table-creation* migration chain here:
|
|
22
26
|
* `assertCompatibleVersion` (called below, before any schema work) rejects
|
|
@@ -31,9 +35,10 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
|
|
|
31
35
|
* `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
|
|
32
36
|
* additions to an existing 0.13 table are therefore the one case that still
|
|
33
37
|
* needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
|
|
34
|
-
* `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
|
|
38
|
+
* `migrateInfoCreatedCwd`, `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
|
|
35
39
|
* `migratePageMetaConsoleErrorCount`, `migratePageMetaCustomElementCount`,
|
|
36
|
-
* `migrateContentItemsAliasOfId`,
|
|
40
|
+
* `migratePageMetaImageScan`, `migrateContentItemsAliasOfId`,
|
|
41
|
+
* `migrateContentItemsIsMetadataOnly`,
|
|
37
42
|
*
|
|
38
43
|
* `migratePageTagsToPageTechnologies` is the one exception to "column adds
|
|
39
44
|
* only": it converts `page_tags` (removed) rows into `technology_signals`/
|
|
@@ -41,8 +46,14 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
|
|
|
41
46
|
* not a column add, but it belongs in this same boot phase for the same
|
|
42
47
|
* reason (self-healing an old archive's schema before any reader runs).
|
|
43
48
|
*
|
|
44
|
-
* `migrateContentItemsDedupeCapEventId`, `
|
|
45
|
-
* `
|
|
49
|
+
* `migrateContentItemsDedupeCapEventId`, `migrateListReconcileRunsInvalidSkipped`,
|
|
50
|
+
* `migrateListReconcileRunsExcludeSkipped`) rather than a DDL-string change alone.
|
|
51
|
+
*
|
|
52
|
+
* `migrateInventoryRunsToListReconcileRuns` runs BEFORE `initSchema`
|
|
53
|
+
* (unlike every other migration here) — see its own JSDoc for why: a
|
|
54
|
+
* plain rename must land before `createAdjunctTables`'s
|
|
55
|
+
* `IF NOT EXISTS` guard would otherwise create an empty `list_reconcile_runs`
|
|
56
|
+
* and strand the old table's rows.
|
|
46
57
|
*
|
|
47
58
|
* `closeStaleOpenNetworkOutages` is not a schema migration (no columns
|
|
48
59
|
* change) but belongs at this same boot phase for the same reason the
|
|
@@ -77,13 +88,19 @@ export async function init(knex, readOnly, onLog) {
|
|
|
77
88
|
if (readOnly) {
|
|
78
89
|
return;
|
|
79
90
|
}
|
|
91
|
+
// Must run before `initSchema`: see the function's own JSDoc for why a
|
|
92
|
+
// plain table rename has to land before `createAdjunctTables`'s
|
|
93
|
+
// `IF NOT EXISTS` guard would otherwise create an empty replacement.
|
|
94
|
+
await migrateInventoryRunsToListReconcileRuns(knex, onLog);
|
|
80
95
|
await initSchema(knex);
|
|
81
96
|
await migrateInfoRoots(knex, onLog);
|
|
82
97
|
await migrateInfoMainContentSelector(knex, onLog);
|
|
98
|
+
await migrateInfoCreatedCwd(knex, onLog);
|
|
83
99
|
await migrateMainContentsColumns(knex, onLog);
|
|
84
100
|
await migratePageMetaBodyHash(knex, onLog);
|
|
85
101
|
await migratePageMetaConsoleErrorCount(knex, onLog);
|
|
86
102
|
await migratePageMetaCustomElementCount(knex, onLog);
|
|
103
|
+
await migratePageMetaImageScan(knex, onLog);
|
|
87
104
|
// Table-level migration (converts + drops page_tags), not a column
|
|
88
105
|
// add — see its own JSDoc for why it still belongs in this boot phase.
|
|
89
106
|
await migratePageTagsToPageTechnologies(knex, onLog);
|
|
@@ -93,7 +110,10 @@ export async function init(knex, readOnly, onLog) {
|
|
|
93
110
|
// column's `REFERENCES dedupe_cap_events(id)` target always exists by
|
|
94
111
|
// this point, for both fresh and legacy archives.
|
|
95
112
|
await migrateContentItemsDedupeCapEventId(knex, onLog);
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
// A plain `hasColumn`-guarded ADD COLUMN, same as `migrateContentItemsAliasOfId`
|
|
114
|
+
// above — no adjunct-table dependency, so ordering relative to it doesn't matter.
|
|
115
|
+
await migrateContentItemsIsMetadataOnly(knex, onLog);
|
|
116
|
+
await migrateListReconcileRunsInvalidSkipped(knex, onLog);
|
|
117
|
+
await migrateListReconcileRunsExcludeSkipped(knex, onLog);
|
|
98
118
|
await closeStaleOpenNetworkOutages(knex);
|
|
99
119
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ListReconcileRunMeta } from '../../types.js';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
/**
|
|
4
|
+
* Appends one row to the `list_reconcile_runs` audit log.
|
|
5
|
+
*
|
|
6
|
+
* Called by `CrawlerOrchestrator.inventory` and `CrawlerOrchestrator.recrawl`
|
|
7
|
+
* on every successful `--inventory <list>` / `--recrawl <list>` invocation
|
|
8
|
+
* so the archive carries a durable record of which list was applied when
|
|
9
|
+
* and at what scale — the operational question "did we apply last month's
|
|
10
|
+
* list" the archive itself can answer without consulting external
|
|
11
|
+
* bookkeeping.
|
|
12
|
+
*
|
|
13
|
+
* Append-only. There is intentionally no UPDATE path and no UNIQUE
|
|
14
|
+
* constraint on `source_file_sha256`; two applies of the same list
|
|
15
|
+
* each get their own row. Duplicate detection is left to readers —
|
|
16
|
+
* the hash is recorded as the content-identity key they would use.
|
|
17
|
+
* Field-level NULL semantics live on {@link ListReconcileRunMeta}.
|
|
18
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
19
|
+
* @param meta - The run metadata to record. Only `ran_at` is required.
|
|
20
|
+
* @returns The autoincremented `id` of the newly-inserted row.
|
|
21
|
+
*/
|
|
22
|
+
export declare function recordListReconcileRun(knex: Knex, meta: ListReconcileRunMeta): Promise<number>;
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Appends one row to the `
|
|
2
|
+
* Appends one row to the `list_reconcile_runs` audit log.
|
|
3
3
|
*
|
|
4
|
-
* Called by `CrawlerOrchestrator.inventory`
|
|
5
|
-
* `--inventory <list>`
|
|
6
|
-
* record of which
|
|
7
|
-
* the operational question "did we apply last month's
|
|
8
|
-
* archive itself can answer without consulting external
|
|
4
|
+
* Called by `CrawlerOrchestrator.inventory` and `CrawlerOrchestrator.recrawl`
|
|
5
|
+
* on every successful `--inventory <list>` / `--recrawl <list>` invocation
|
|
6
|
+
* so the archive carries a durable record of which list was applied when
|
|
7
|
+
* and at what scale — the operational question "did we apply last month's
|
|
8
|
+
* list" the archive itself can answer without consulting external
|
|
9
|
+
* bookkeeping.
|
|
9
10
|
*
|
|
10
11
|
* Append-only. There is intentionally no UPDATE path and no UNIQUE
|
|
11
12
|
* constraint on `source_file_sha256`; two applies of the same list
|
|
12
13
|
* each get their own row. Duplicate detection is left to readers —
|
|
13
14
|
* the hash is recorded as the content-identity key they would use.
|
|
14
|
-
* Field-level NULL semantics live on {@link
|
|
15
|
+
* Field-level NULL semantics live on {@link ListReconcileRunMeta}.
|
|
15
16
|
* @param knex - Knex query builder connected to the archive DB.
|
|
16
17
|
* @param meta - The run metadata to record. Only `ran_at` is required.
|
|
17
18
|
* @returns The autoincremented `id` of the newly-inserted row.
|
|
18
19
|
*/
|
|
19
|
-
export async function
|
|
20
|
+
export async function recordListReconcileRun(knex, meta) {
|
|
20
21
|
const inserted = await knex
|
|
21
|
-
.from('
|
|
22
|
+
.from('list_reconcile_runs')
|
|
22
23
|
.insert({
|
|
23
24
|
ran_at: meta.ran_at,
|
|
24
25
|
list_label: meta.list_label ?? null,
|
|
@@ -34,7 +35,7 @@ export async function recordInventoryRun(knex, meta) {
|
|
|
34
35
|
.returning('id');
|
|
35
36
|
const id = inserted[0]?.id;
|
|
36
37
|
if (typeof id !== 'number') {
|
|
37
|
-
throw new TypeError('
|
|
38
|
+
throw new TypeError('recordListReconcileRun: INSERT returned no row id');
|
|
38
39
|
}
|
|
39
40
|
return id;
|
|
40
41
|
}
|
|
@@ -36,5 +36,5 @@ export function buildPageQuery(knex) {
|
|
|
36
36
|
.leftJoin('url_refs as og_image_ur', 'og_image_ur.id', 'pm.og_image_url_id')
|
|
37
37
|
.leftJoin('url_refs as twitter_image_ur', 'twitter_image_ur.id', 'pm.twitter_image_url_id')
|
|
38
38
|
.leftJoin('json_refs as extras_ref', 'extras_ref.id', 'pm.meta_extras_json_id')
|
|
39
|
-
.select('ci.id as id', 'ur.url as url', 'ci.redirect_dest_id as redirectDestId', 'ci.scraped as scraped', 'ci.is_target as isTarget', 'ci.is_external as isExternal', 'ci.status as status', 'ci.status_text as statusText', 'ctr.raw as contentType', 'ci.content_length as contentLength', 'ci.header_set_id as headerSetId', 'pm.lang as lang', 'pm.dir as dir', 'pm.charset as charset', 'pm.base_href as baseHref', 'pm.viewport_raw as viewport_raw', 'pm.theme_color as themeColor', 'pm.application_name as applicationName', 'pm.author as author', 'pm.generator as generator', 'pm.publisher as publisher', 'title_ref.text as title', 'description_ref.text as description', 'keywords_ref.text as keywords', 'robots_raw_ref.text as robots_raw', 'pm.robots_noindex as robots_noindex', 'pm.robots_nofollow as robots_nofollow', 'pm.robots_noarchive as robots_noarchive', 'pm.robots_noimageindex as robots_noimageindex', 'pm.googlebot as googlebot', 'canonical_ur.url as canonical', 'amphtml_ur.url as amphtml', 'manifest_ur.url as manifest', 'icon_ur.url as icon_href', 'apple_ur.url as appleTouchIcon_href', 'pm.og_type as og_type', 'og_title_ref.text as og_title', 'og_url_ur.url as og_url', 'pm.og_site_name as og_site_name', 'og_description_ref.text as og_description', 'og_image_ur.url as og_image', 'pm.og_image_alt as og_image_alt', 'pm.og_image_width as og_image_width', 'pm.og_image_height as og_image_height', 'pm.og_locale as og_locale', 'pm.og_article_published_time as og_article_published_time', 'pm.og_article_modified_time as og_article_modified_time', 'pm.twitter_card as twitter_card', 'pm.twitter_site as twitter_site', 'pm.twitter_creator as twitter_creator', 'twitter_title_ref.text as twitter_title', 'twitter_description_ref.text as twitter_description', 'twitter_image_ur.url as twitter_image', 'pm.fb_app_id as fb_app_id', 'pm.verification_google as verification_google', 'pm.format_detection_telephone as formatDetection_telephone', 'ci.first_crawled_at as firstCrawledAt', 'ci.last_crawled_at as lastCrawledAt', 'pm.tag_count as tag_count', 'pm.jsonld_count as jsonld_count', 'pm.tags_providers_csv as tags_providers_csv', 'pm.main_content_node_name as main_content_node_name', 'pm.main_content_id as main_content_id', 'pm.main_content_role as main_content_role', 'pm.main_content_selector as main_content_selector', 'pm.main_content_class_list as main_content_class_list', 'pm.main_content_word_count as main_content_word_count', 'pm.main_content_body_word_count as main_content_body_word_count', 'pm.main_content_heading_count as main_content_heading_count', 'pm.main_content_image_count as main_content_image_count', 'pm.main_content_table_count as main_content_table_count', 'pm.main_content_button_count as main_content_button_count', 'pm.main_content_iframe_count as main_content_iframe_count', 'pm.main_content_video_count as main_content_video_count', 'pm.main_content_audio_count as main_content_audio_count', 'pm.main_content_canvas_count as main_content_canvas_count', 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', 'extras_ref.json_text as extras_body', 'extras_ref.codec as extras_codec', 'ci.is_skipped as isSkipped', 'ci.skip_reason as skipReason', 'ci.crawl_order as order', 'ci.source as source');
|
|
39
|
+
.select('ci.id as id', 'ur.url as url', 'ci.redirect_dest_id as redirectDestId', 'ci.scraped as scraped', 'ci.is_target as isTarget', 'ci.is_external as isExternal', 'ci.status as status', 'ci.status_text as statusText', 'ctr.raw as contentType', 'ci.content_length as contentLength', 'ci.header_set_id as headerSetId', 'pm.lang as lang', 'pm.dir as dir', 'pm.charset as charset', 'pm.base_href as baseHref', 'pm.viewport_raw as viewport_raw', 'pm.theme_color as themeColor', 'pm.application_name as applicationName', 'pm.author as author', 'pm.generator as generator', 'pm.publisher as publisher', 'title_ref.text as title', 'description_ref.text as description', 'keywords_ref.text as keywords', 'robots_raw_ref.text as robots_raw', 'pm.robots_noindex as robots_noindex', 'pm.robots_nofollow as robots_nofollow', 'pm.robots_noarchive as robots_noarchive', 'pm.robots_noimageindex as robots_noimageindex', 'pm.googlebot as googlebot', 'canonical_ur.url as canonical', 'amphtml_ur.url as amphtml', 'manifest_ur.url as manifest', 'icon_ur.url as icon_href', 'apple_ur.url as appleTouchIcon_href', 'pm.og_type as og_type', 'og_title_ref.text as og_title', 'og_url_ur.url as og_url', 'pm.og_site_name as og_site_name', 'og_description_ref.text as og_description', 'og_image_ur.url as og_image', 'pm.og_image_alt as og_image_alt', 'pm.og_image_width as og_image_width', 'pm.og_image_height as og_image_height', 'pm.og_locale as og_locale', 'pm.og_article_published_time as og_article_published_time', 'pm.og_article_modified_time as og_article_modified_time', 'pm.twitter_card as twitter_card', 'pm.twitter_site as twitter_site', 'pm.twitter_creator as twitter_creator', 'twitter_title_ref.text as twitter_title', 'twitter_description_ref.text as twitter_description', 'twitter_image_ur.url as twitter_image', 'pm.fb_app_id as fb_app_id', 'pm.verification_google as verification_google', 'pm.format_detection_telephone as formatDetection_telephone', 'ci.first_crawled_at as firstCrawledAt', 'ci.last_crawled_at as lastCrawledAt', 'pm.tag_count as tag_count', 'pm.jsonld_count as jsonld_count', 'pm.tags_providers_csv as tags_providers_csv', 'pm.main_content_node_name as main_content_node_name', 'pm.main_content_id as main_content_id', 'pm.main_content_role as main_content_role', 'pm.main_content_selector as main_content_selector', 'pm.main_content_class_list as main_content_class_list', 'pm.main_content_word_count as main_content_word_count', 'pm.main_content_body_word_count as main_content_body_word_count', 'pm.main_content_heading_count as main_content_heading_count', 'pm.main_content_image_count as main_content_image_count', 'pm.main_content_table_count as main_content_table_count', 'pm.main_content_button_count as main_content_button_count', 'pm.main_content_iframe_count as main_content_iframe_count', 'pm.main_content_video_count as main_content_video_count', 'pm.main_content_audio_count as main_content_audio_count', 'pm.main_content_canvas_count as main_content_canvas_count', 'pm.main_content_custom_element_count as main_content_custom_element_count', 'pm.scroll_height_desktop as scroll_height_desktop', 'pm.scroll_height_mobile as scroll_height_mobile', 'pm.image_scan_desktop as image_scan_desktop', 'pm.image_scan_mobile as image_scan_mobile', 'extras_ref.json_text as extras_body', 'extras_ref.codec as extras_codec', 'ci.is_skipped as isSkipped', 'ci.skip_reason as skipReason', 'ci.crawl_order as order', 'ci.source as source');
|
|
40
40
|
}
|
|
@@ -61,10 +61,17 @@ import type { Knex } from 'knex';
|
|
|
61
61
|
* shape is safe to use without the `migrate*` guards that other writer
|
|
62
62
|
* methods carry.
|
|
63
63
|
* @param knex - Knex query builder connected to the archive DB.
|
|
64
|
-
* @returns An object with `scraped` (completed URLs)
|
|
65
|
-
* strict set of in-scope, anchor-referenced, unfinished URLs)
|
|
64
|
+
* @returns An object with `scraped` (completed URLs), `pending` (the
|
|
65
|
+
* strict set of in-scope, anchor-referenced, unfinished URLs), and
|
|
66
|
+
* `pendingMetadataOnly` (the subset of `pending` whose
|
|
67
|
+
* `content_items.is_metadata_only` was persisted as `1` — see
|
|
68
|
+
* `replaceAnchorEdges`/`resolveContentItemId`). Callers that resume a
|
|
69
|
+
* crawl (`Crawler#resume` → `LinkList#resume`) pass this subset through
|
|
70
|
+
* so a metadata-only anchor discovered before an interruption is not
|
|
71
|
+
* silently promoted to a full-scrape target on resume (#369).
|
|
66
72
|
*/
|
|
67
73
|
export declare function getCrawlingState(knex: Knex): Promise<{
|
|
68
74
|
scraped: string[];
|
|
69
75
|
pending: string[];
|
|
76
|
+
pendingMetadataOnly: string[];
|
|
70
77
|
}>;
|
|
@@ -60,8 +60,14 @@
|
|
|
60
60
|
* shape is safe to use without the `migrate*` guards that other writer
|
|
61
61
|
* methods carry.
|
|
62
62
|
* @param knex - Knex query builder connected to the archive DB.
|
|
63
|
-
* @returns An object with `scraped` (completed URLs)
|
|
64
|
-
* strict set of in-scope, anchor-referenced, unfinished URLs)
|
|
63
|
+
* @returns An object with `scraped` (completed URLs), `pending` (the
|
|
64
|
+
* strict set of in-scope, anchor-referenced, unfinished URLs), and
|
|
65
|
+
* `pendingMetadataOnly` (the subset of `pending` whose
|
|
66
|
+
* `content_items.is_metadata_only` was persisted as `1` — see
|
|
67
|
+
* `replaceAnchorEdges`/`resolveContentItemId`). Callers that resume a
|
|
68
|
+
* crawl (`Crawler#resume` → `LinkList#resume`) pass this subset through
|
|
69
|
+
* so a metadata-only anchor discovered before an interruption is not
|
|
70
|
+
* silently promoted to a full-scrape target on resume (#369).
|
|
65
71
|
*/
|
|
66
72
|
export async function getCrawlingState(knex) {
|
|
67
73
|
const ex = (r) => r.url;
|
|
@@ -71,7 +77,7 @@ export async function getCrawlingState(knex) {
|
|
|
71
77
|
.where('content_items.scraped', 1);
|
|
72
78
|
const scraped = $scraped.map(ex);
|
|
73
79
|
const $pending = await knex
|
|
74
|
-
.select('ur.url as url')
|
|
80
|
+
.select('ur.url as url', 'ci.is_metadata_only as isMetadataOnly')
|
|
75
81
|
.from({ ci: 'content_items' })
|
|
76
82
|
.join({ ur: 'url_refs' }, 'ur.id', 'ci.url_id')
|
|
77
83
|
.where('ci.scraped', 0)
|
|
@@ -91,8 +97,10 @@ export async function getCrawlingState(knex) {
|
|
|
91
97
|
}).orWhereNot('ci.source', 'crawled');
|
|
92
98
|
});
|
|
93
99
|
const pending = $pending.map(ex);
|
|
100
|
+
const pendingMetadataOnly = $pending.filter((r) => r.isMetadataOnly === 1).map(ex);
|
|
94
101
|
return {
|
|
95
102
|
scraped,
|
|
96
103
|
pending,
|
|
104
|
+
pendingMetadataOnly,
|
|
97
105
|
};
|
|
98
106
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Deletes every Scoped-Replace derived row for the given page ids, across the
|
|
4
|
+
* 17 tables a page's re-scrape needs to start clean.
|
|
5
|
+
*
|
|
6
|
+
* Shared by {@link resetFailedPages} and {@link repromoteExternalPages} —
|
|
7
|
+
* both un-scrape a page back to `scraped = 0` and need the same "delete this
|
|
8
|
+
* page's derived rows so the re-scrape can re-insert fresh data without
|
|
9
|
+
* duplicates" sweep. `page_errors` is deliberately NOT included here:
|
|
10
|
+
* `repromoteExternalPages` never cleared it (an external page's prior error
|
|
11
|
+
* history stays visible after being promoted back into scope), and folding
|
|
12
|
+
* it into this shared helper would silently change that existing behaviour.
|
|
13
|
+
* Callers that do want `page_errors` cleared (`resetFailedPages`) delete it
|
|
14
|
+
* themselves alongside this call.
|
|
15
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
16
|
+
* @param pageIds - `content_items.id` values whose derived rows should be
|
|
17
|
+
* deleted. Expected to already be chunked below
|
|
18
|
+
* `SQLITE_LIMIT_VARIABLE_NUMBER` by the caller.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clearPageDerivedRows(knex: Knex, pageIds: readonly number[]): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deletes every Scoped-Replace derived row for the given page ids, across the
|
|
3
|
+
* 17 tables a page's re-scrape needs to start clean.
|
|
4
|
+
*
|
|
5
|
+
* Shared by {@link resetFailedPages} and {@link repromoteExternalPages} —
|
|
6
|
+
* both un-scrape a page back to `scraped = 0` and need the same "delete this
|
|
7
|
+
* page's derived rows so the re-scrape can re-insert fresh data without
|
|
8
|
+
* duplicates" sweep. `page_errors` is deliberately NOT included here:
|
|
9
|
+
* `repromoteExternalPages` never cleared it (an external page's prior error
|
|
10
|
+
* history stays visible after being promoted back into scope), and folding
|
|
11
|
+
* it into this shared helper would silently change that existing behaviour.
|
|
12
|
+
* Callers that do want `page_errors` cleared (`resetFailedPages`) delete it
|
|
13
|
+
* themselves alongside this call.
|
|
14
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
15
|
+
* @param pageIds - `content_items.id` values whose derived rows should be
|
|
16
|
+
* deleted. Expected to already be chunked below
|
|
17
|
+
* `SQLITE_LIMIT_VARIABLE_NUMBER` by the caller.
|
|
18
|
+
*/
|
|
19
|
+
export async function clearPageDerivedRows(knex, pageIds) {
|
|
20
|
+
await knex('page_meta').whereIn('page_id', pageIds).delete();
|
|
21
|
+
await knex('anchor_edges').whereIn('page_id', pageIds).delete();
|
|
22
|
+
await knex('image_items').whereIn('page_id', pageIds).delete();
|
|
23
|
+
await knex('resource_ref_edges').whereIn('page_id', pageIds).delete();
|
|
24
|
+
await knex('page_html_ref').whereIn('page_id', pageIds).delete();
|
|
25
|
+
await knex('technology_signals').whereIn('pageId', pageIds).delete();
|
|
26
|
+
await knex('page_technologies').whereIn('pageId', pageIds).delete();
|
|
27
|
+
await knex('page_jsonld').whereIn('pageId', pageIds).delete();
|
|
28
|
+
await knex('page_main_content_headings').whereIn('pageId', pageIds).delete();
|
|
29
|
+
await knex('page_main_content_images').whereIn('pageId', pageIds).delete();
|
|
30
|
+
await knex('page_main_content_tables').whereIn('pageId', pageIds).delete();
|
|
31
|
+
await knex('page_main_content_buttons').whereIn('pageId', pageIds).delete();
|
|
32
|
+
await knex('page_main_content_iframes').whereIn('pageId', pageIds).delete();
|
|
33
|
+
await knex('page_main_content_videos').whereIn('pageId', pageIds).delete();
|
|
34
|
+
await knex('page_main_content_audios').whereIn('pageId', pageIds).delete();
|
|
35
|
+
await knex('page_main_content_canvases').whereIn('pageId', pageIds).delete();
|
|
36
|
+
await knex('page_main_content_custom_elements').whereIn('pageId', pageIds).delete();
|
|
37
|
+
}
|
|
@@ -22,8 +22,9 @@ import type { Knex } from 'knex';
|
|
|
22
22
|
* @param onProgress - Called after each chunk's DELETE/UPDATE statements
|
|
23
23
|
* complete, with the pages processed so far and the total to promote
|
|
24
24
|
* (issue #294: a large `--append` can promote thousands of pages across
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* `content_items` plus the 17 tables {@link clearPageDerivedRows} clears,
|
|
26
|
+
* running for seconds to minutes with no other signal it hasn't hung).
|
|
27
|
+
* Omit for no reporting (the default; e.g. tests).
|
|
27
28
|
* @returns The URLs of the pages that were promoted.
|
|
28
29
|
*/
|
|
29
30
|
export declare function repromoteExternalPages(knex: Knex, scopes: ReadonlyMap<string, readonly ExURL[]>, options?: ParseURLOptions, onProgress?: (processed: number, total: number) => void): Promise<string[]>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
2
2
|
import { findScopeEntry } from '../../../../crawler/find-scope-entry.js';
|
|
3
3
|
import { dbLog } from '../../../debug.js';
|
|
4
|
+
import { clearPageDerivedRows } from './clear-page-derived-rows.js';
|
|
4
5
|
/**
|
|
5
6
|
* Promote previously-external pages whose URL falls under any of the new scope
|
|
6
7
|
* entries back to a "needs scraping" state so that the next crawl picks them up
|
|
@@ -23,8 +24,9 @@ import { dbLog } from '../../../debug.js';
|
|
|
23
24
|
* @param onProgress - Called after each chunk's DELETE/UPDATE statements
|
|
24
25
|
* complete, with the pages processed so far and the total to promote
|
|
25
26
|
* (issue #294: a large `--append` can promote thousands of pages across
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* `content_items` plus the 17 tables {@link clearPageDerivedRows} clears,
|
|
28
|
+
* running for seconds to minutes with no other signal it hasn't hung).
|
|
29
|
+
* Omit for no reporting (the default; e.g. tests).
|
|
28
30
|
* @returns The URLs of the pages that were promoted.
|
|
29
31
|
*/
|
|
30
32
|
export async function repromoteExternalPages(knex, scopes, options, onProgress) {
|
|
@@ -69,41 +71,19 @@ export async function repromoteExternalPages(knex, scopes, options, onProgress)
|
|
|
69
71
|
// left untouched — the last-success timestamp survives the
|
|
70
72
|
// demotion.
|
|
71
73
|
});
|
|
72
|
-
// Clear the prior crawl's data for the repromoted pages
|
|
73
|
-
// also
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// `
|
|
84
|
-
|
|
85
|
-
// of explicit chunked DELETEs rather than relying on CASCADE
|
|
86
|
-
// indirectly (and would not cascade anyway: the parent
|
|
87
|
-
// `content_items` row is updated, not
|
|
88
|
-
// deleted). Orphan blobs in `page_html_blobs` are left behind; #23
|
|
89
|
-
// will add GC.
|
|
90
|
-
await knex('page_meta').whereIn('page_id', chunk).delete();
|
|
91
|
-
await knex('anchor_edges').whereIn('page_id', chunk).delete();
|
|
92
|
-
await knex('image_items').whereIn('page_id', chunk).delete();
|
|
93
|
-
await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
|
|
94
|
-
await knex('page_html_ref').whereIn('page_id', chunk).delete();
|
|
95
|
-
await knex('technology_signals').whereIn('pageId', chunk).delete();
|
|
96
|
-
await knex('page_technologies').whereIn('pageId', chunk).delete();
|
|
97
|
-
await knex('page_jsonld').whereIn('pageId', chunk).delete();
|
|
98
|
-
await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
|
|
99
|
-
await knex('page_main_content_images').whereIn('pageId', chunk).delete();
|
|
100
|
-
await knex('page_main_content_tables').whereIn('pageId', chunk).delete();
|
|
101
|
-
await knex('page_main_content_buttons').whereIn('pageId', chunk).delete();
|
|
102
|
-
await knex('page_main_content_iframes').whereIn('pageId', chunk).delete();
|
|
103
|
-
await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
|
|
104
|
-
await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
|
|
105
|
-
await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
|
|
106
|
-
await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
|
|
74
|
+
// Clear the prior crawl's data for the repromoted pages via the
|
|
75
|
+
// shared sweep (also used by `resetFailedPages` / `resetPagesByUrls`).
|
|
76
|
+
// `updatePage` also replaces anchor_edges/image_items/tags/jsonld/
|
|
77
|
+
// page_main_content_* when it re-scrapes them, but only when the new
|
|
78
|
+
// scrape is non-empty — so this pre-clear is still load-bearing for
|
|
79
|
+
// pages that get repromoted but then re-scrape to nothing (or are
|
|
80
|
+
// never reached again), and it is the only place `resource_ref_edges`
|
|
81
|
+
// is cleared for this operation. Deleting the `page_meta` row (rather
|
|
82
|
+
// than nulling every column) clears title / description / og:* /
|
|
83
|
+
// twitter:* / meta_extras / main_content_* in one statement; a
|
|
84
|
+
// re-scrape re-inserts it via `ON CONFLICT(page_id) DO UPDATE`.
|
|
85
|
+
// Orphan blobs in `page_html_blobs` are left behind; #23 will add GC.
|
|
86
|
+
await clearPageDerivedRows(knex, chunk);
|
|
107
87
|
onProgress?.(Math.min(i + chunkSize, promotedIds.length), promotedIds.length);
|
|
108
88
|
}
|
|
109
89
|
dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
|
|
@@ -13,7 +13,13 @@ import type { Knex } from 'knex';
|
|
|
13
13
|
* - `status IS NULL` — no status was ever stored for the row;
|
|
14
14
|
* - the row has no `content_type_refs` link — the content type could not be
|
|
15
15
|
* determined;
|
|
16
|
-
* - `status` is in the `5xx` range — a (frequently transient) server error
|
|
16
|
+
* - `status` is in the `5xx` range — a (frequently transient) server error;
|
|
17
|
+
* - `page_meta.image_scan_desktop` or `image_scan_mobile` is one of
|
|
18
|
+
* {@link RETRYABLE_IMAGE_SCAN_CODES} — `@d-zero/beholder` abandoned that
|
|
19
|
+
* viewport's `<img>` scan for a transient reason (navigation never
|
|
20
|
+
* settled, or the frame/session was lost mid-scan), independent of the
|
|
21
|
+
* page's own HTTP `status` (a page can score `status = 200` and still
|
|
22
|
+
* have failed its mobile image scan).
|
|
17
23
|
*
|
|
18
24
|
* Definitive `4xx` responses are intentionally excluded: re-fetching a 404
|
|
19
25
|
* almost always yields the same answer.
|
|
@@ -42,6 +48,21 @@ import type { Knex } from 'knex';
|
|
|
42
48
|
* with no recorded outages (`listNetworkOutages` returns `[]`) behaves
|
|
43
49
|
* exactly as before this override existed.
|
|
44
50
|
*
|
|
51
|
+
* **Confirmed same-cluster trap exclusion**: a third pass drops any
|
|
52
|
+
* remaining candidate whose URL shape (`computeShapeKey`) matches a
|
|
53
|
+
* `dedupe_cap_events.shape_key` already recorded in this archive (see
|
|
54
|
+
* `DedupeCapTracker`). A trap page frequently fails outright (timeout / 5xx)
|
|
55
|
+
* rather than rendering a comparable body, so it never reaches
|
|
56
|
+
* `DedupeCapTracker#observe` during the crawl that hit it — without this
|
|
57
|
+
* exclusion, `--retry-failed` would keep re-queueing the very pages the cap
|
|
58
|
+
* already exists to suppress, re-inflating the pending count on every pass
|
|
59
|
+
* for a shape this archive has already confirmed is not worth the cost of
|
|
60
|
+
* re-discovering. Left as a failed row rather than rewritten to a skip —
|
|
61
|
+
* post-hoc marking (`content_items.dedupe_cap_event_id`, computed at
|
|
62
|
+
* `viewer-build`) is what surfaces these pages as capped, not this reset
|
|
63
|
+
* path. An archive with no recorded cap events (`listDedupeCapShapeKeys`
|
|
64
|
+
* returns `[]`) behaves exactly as before this exclusion existed.
|
|
65
|
+
*
|
|
45
66
|
* Matching rows — internal and external alike — are demoted back to pending
|
|
46
67
|
* (`scraped = 0`) and have their stale scrape metadata cleared (the
|
|
47
68
|
* `page_meta` row is deleted outright rather than nulled column-by-column).
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { classifyErrorKind } from '../../../../classify-error-kind.js';
|
|
2
|
+
import { computeShapeKey } from '../../../../crawler/dedupe/compute-shape-key.js';
|
|
2
3
|
import { isWithinOutageWindow } from '../../../../is-within-outage-window.js';
|
|
3
4
|
import { PERMANENT_ERROR_KINDS } from '../../../../permanent-error-kinds.js';
|
|
4
5
|
import { dbLog } from '../../../debug.js';
|
|
5
6
|
import { getFailedPageMessages } from '../../../get-failed-page-messages.js';
|
|
7
|
+
import { listDedupeCapShapeKeys } from '../../dedupe-cap/list-dedupe-cap-shape-keys.js';
|
|
6
8
|
import { listNetworkOutages } from '../../outages/list-network-outages.js';
|
|
9
|
+
import { clearPageDerivedRows } from './clear-page-derived-rows.js';
|
|
10
|
+
import { RETRYABLE_IMAGE_SCAN_CODES } from './retryable-image-scan-codes.js';
|
|
7
11
|
/**
|
|
8
12
|
* Reset previously-attempted pages that ended in a recoverable failure so a
|
|
9
13
|
* follow-up crawl can re-fetch them from scratch.
|
|
@@ -18,7 +22,13 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
|
|
|
18
22
|
* - `status IS NULL` — no status was ever stored for the row;
|
|
19
23
|
* - the row has no `content_type_refs` link — the content type could not be
|
|
20
24
|
* determined;
|
|
21
|
-
* - `status` is in the `5xx` range — a (frequently transient) server error
|
|
25
|
+
* - `status` is in the `5xx` range — a (frequently transient) server error;
|
|
26
|
+
* - `page_meta.image_scan_desktop` or `image_scan_mobile` is one of
|
|
27
|
+
* {@link RETRYABLE_IMAGE_SCAN_CODES} — `@d-zero/beholder` abandoned that
|
|
28
|
+
* viewport's `<img>` scan for a transient reason (navigation never
|
|
29
|
+
* settled, or the frame/session was lost mid-scan), independent of the
|
|
30
|
+
* page's own HTTP `status` (a page can score `status = 200` and still
|
|
31
|
+
* have failed its mobile image scan).
|
|
22
32
|
*
|
|
23
33
|
* Definitive `4xx` responses are intentionally excluded: re-fetching a 404
|
|
24
34
|
* almost always yields the same answer.
|
|
@@ -47,6 +57,21 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
|
|
|
47
57
|
* with no recorded outages (`listNetworkOutages` returns `[]`) behaves
|
|
48
58
|
* exactly as before this override existed.
|
|
49
59
|
*
|
|
60
|
+
* **Confirmed same-cluster trap exclusion**: a third pass drops any
|
|
61
|
+
* remaining candidate whose URL shape (`computeShapeKey`) matches a
|
|
62
|
+
* `dedupe_cap_events.shape_key` already recorded in this archive (see
|
|
63
|
+
* `DedupeCapTracker`). A trap page frequently fails outright (timeout / 5xx)
|
|
64
|
+
* rather than rendering a comparable body, so it never reaches
|
|
65
|
+
* `DedupeCapTracker#observe` during the crawl that hit it — without this
|
|
66
|
+
* exclusion, `--retry-failed` would keep re-queueing the very pages the cap
|
|
67
|
+
* already exists to suppress, re-inflating the pending count on every pass
|
|
68
|
+
* for a shape this archive has already confirmed is not worth the cost of
|
|
69
|
+
* re-discovering. Left as a failed row rather than rewritten to a skip —
|
|
70
|
+
* post-hoc marking (`content_items.dedupe_cap_event_id`, computed at
|
|
71
|
+
* `viewer-build`) is what surfaces these pages as capped, not this reset
|
|
72
|
+
* path. An archive with no recorded cap events (`listDedupeCapShapeKeys`
|
|
73
|
+
* returns `[]`) behaves exactly as before this exclusion existed.
|
|
74
|
+
*
|
|
50
75
|
* Matching rows — internal and external alike — are demoted back to pending
|
|
51
76
|
* (`scraped = 0`) and have their stale scrape metadata cleared (the
|
|
52
77
|
* `page_meta` row is deleted outright rather than nulled column-by-column).
|
|
@@ -72,7 +97,8 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
|
|
|
72
97
|
export async function resetFailedPages(knex, onProgress) {
|
|
73
98
|
const candidates = await knex('content_items')
|
|
74
99
|
.join('url_refs', 'content_items.url_id', 'url_refs.id')
|
|
75
|
-
.
|
|
100
|
+
.leftJoin('page_meta', 'content_items.id', 'page_meta.page_id')
|
|
101
|
+
.select('content_items.id as id', 'url_refs.url as url', 'content_items.status as status', 'content_items.content_type_id as contentTypeId')
|
|
76
102
|
.where('content_items.scraped', 1)
|
|
77
103
|
.whereNull('content_items.redirect_dest_id')
|
|
78
104
|
.where((qb) => {
|
|
@@ -82,19 +108,37 @@ export async function resetFailedPages(knex, onProgress) {
|
|
|
82
108
|
qb.whereNull('content_items.status')
|
|
83
109
|
.orWhere('content_items.status', -1)
|
|
84
110
|
.orWhereNull('content_items.content_type_id')
|
|
85
|
-
.orWhereBetween('content_items.status', [500, 599])
|
|
111
|
+
.orWhereBetween('content_items.status', [500, 599])
|
|
112
|
+
.orWhereIn('page_meta.image_scan_desktop', RETRYABLE_IMAGE_SCAN_CODES)
|
|
113
|
+
.orWhereIn('page_meta.image_scan_mobile', RETRYABLE_IMAGE_SCAN_CODES);
|
|
86
114
|
});
|
|
87
115
|
if (candidates.length === 0) {
|
|
88
116
|
return [];
|
|
89
117
|
}
|
|
118
|
+
// Diagnostic only: how many candidates matched solely because of an
|
|
119
|
+
// image-scan outcome, not the pre-existing status-based conditions —
|
|
120
|
+
// i.e. a page whose HTTP status looks fine but whose mobile/desktop
|
|
121
|
+
// `<img>` scan was abandoned for a transient reason.
|
|
122
|
+
const imageScanOnlyCount = candidates.filter((row) => {
|
|
123
|
+
const statusQualifies = row.status === null ||
|
|
124
|
+
row.status === -1 ||
|
|
125
|
+
row.contentTypeId === null ||
|
|
126
|
+
(row.status >= 500 && row.status <= 599);
|
|
127
|
+
return !statusQualifies;
|
|
128
|
+
}).length;
|
|
129
|
+
if (imageScanOnlyCount > 0) {
|
|
130
|
+
dbLog('%d of %d retry candidate(s) matched via image-scan degradation only', imageScanOnlyCount, candidates.length);
|
|
131
|
+
}
|
|
90
132
|
const candidateIds = candidates.map((row) => row.id);
|
|
91
133
|
const candidateUrls = candidates.map((row) => row.url);
|
|
92
|
-
//
|
|
93
|
-
// dependency between them — run
|
|
94
|
-
// sequential round-trips on every
|
|
95
|
-
|
|
134
|
+
// Three unrelated reads (page_errors/crawl_errors, network_outages,
|
|
135
|
+
// dedupe_cap_events) with no data dependency between them — run
|
|
136
|
+
// concurrently instead of paying three sequential round-trips on every
|
|
137
|
+
// `--retry-failed` pass.
|
|
138
|
+
const [messages, outageWindows, cappedShapeKeys] = await Promise.all([
|
|
96
139
|
getFailedPageMessages(knex, candidateIds, candidateUrls),
|
|
97
140
|
listNetworkOutages(knex),
|
|
141
|
+
listDedupeCapShapeKeys(knex).then((shapeKeys) => new Set(shapeKeys)),
|
|
98
142
|
]);
|
|
99
143
|
// Drop candidates whose latest recorded message classifies as permanent —
|
|
100
144
|
// UNLESS that message's timestamp falls inside a recorded network outage,
|
|
@@ -117,11 +161,26 @@ export async function resetFailedPages(knex, onProgress) {
|
|
|
117
161
|
if (excludedCount > 0) {
|
|
118
162
|
dbLog('Excluded %d page(s) from retry — permanent failure kinds (dns/tls/client-blocked/parse-error/connection-refused)', excludedCount);
|
|
119
163
|
}
|
|
120
|
-
|
|
164
|
+
// Drop candidates whose URL shape already has a confirmed same-cluster
|
|
165
|
+
// trap recorded (see the "Confirmed same-cluster trap exclusion" section
|
|
166
|
+
// of this function's docstring). A row whose shape cannot be computed
|
|
167
|
+
// (`computeShapeKey` returns `null`) stays in the retry pool — no signal
|
|
168
|
+
// either way, so err on the side of retrying it.
|
|
169
|
+
const notCapped = cappedShapeKeys.size === 0
|
|
170
|
+
? retryable
|
|
171
|
+
: retryable.filter((row) => {
|
|
172
|
+
const shapeKey = computeShapeKey(row.url);
|
|
173
|
+
return shapeKey === null || !cappedShapeKeys.has(shapeKey);
|
|
174
|
+
});
|
|
175
|
+
const excludedCappedCount = retryable.length - notCapped.length;
|
|
176
|
+
if (excludedCappedCount > 0) {
|
|
177
|
+
dbLog('Excluded %d page(s) from retry — confirmed same-cluster trap shape', excludedCappedCount);
|
|
178
|
+
}
|
|
179
|
+
if (notCapped.length === 0) {
|
|
121
180
|
return [];
|
|
122
181
|
}
|
|
123
|
-
const ids =
|
|
124
|
-
const urls =
|
|
182
|
+
const ids = notCapped.map((row) => row.id);
|
|
183
|
+
const urls = notCapped.map((row) => row.url);
|
|
125
184
|
const chunkSize = 500;
|
|
126
185
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
127
186
|
const chunk = ids.slice(i, i + chunkSize);
|
|
@@ -136,33 +195,20 @@ export async function resetFailedPages(knex, onProgress) {
|
|
|
136
195
|
// untouched so the last-success timestamp records survive the
|
|
137
196
|
// demotion (the within-archive observation axis for #11/#17/#19).
|
|
138
197
|
});
|
|
139
|
-
// Clear the prior crawl's per-page data so the re-scrape starts clean
|
|
140
|
-
// `
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
198
|
+
// Clear the prior crawl's per-page data so the re-scrape starts clean,
|
|
199
|
+
// via the shared sweep (also used by `repromoteExternalPages` /
|
|
200
|
+
// `resetPagesByUrls`) plus `page_errors`, which that sweep
|
|
201
|
+
// deliberately excludes (see its JSDoc). `updatePage` only replaces
|
|
202
|
+
// anchor_edges/image_items/tags/jsonld when the new scrape is
|
|
203
|
+
// non-empty, so this pre-clear is load-bearing for pages that reset
|
|
204
|
+
// but then fail again (or are never reached), and it is the only
|
|
205
|
+
// place `resource_ref_edges` and `page_errors` are cleared for this
|
|
206
|
+
// operation. Deleting the `page_meta` row (rather than nulling every
|
|
145
207
|
// column) clears title / description / og:* / twitter:* /
|
|
146
208
|
// meta_extras in one statement; a re-scrape re-inserts it via
|
|
147
209
|
// `ON CONFLICT(page_id) DO UPDATE`.
|
|
148
|
-
await knex('page_meta').whereIn('page_id', chunk).delete();
|
|
149
|
-
await knex('anchor_edges').whereIn('page_id', chunk).delete();
|
|
150
|
-
await knex('image_items').whereIn('page_id', chunk).delete();
|
|
151
|
-
await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
|
|
152
210
|
await knex('page_errors').whereIn('pageId', chunk).delete();
|
|
153
|
-
await knex
|
|
154
|
-
await knex('technology_signals').whereIn('pageId', chunk).delete();
|
|
155
|
-
await knex('page_technologies').whereIn('pageId', chunk).delete();
|
|
156
|
-
await knex('page_jsonld').whereIn('pageId', chunk).delete();
|
|
157
|
-
await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
|
|
158
|
-
await knex('page_main_content_images').whereIn('pageId', chunk).delete();
|
|
159
|
-
await knex('page_main_content_tables').whereIn('pageId', chunk).delete();
|
|
160
|
-
await knex('page_main_content_buttons').whereIn('pageId', chunk).delete();
|
|
161
|
-
await knex('page_main_content_iframes').whereIn('pageId', chunk).delete();
|
|
162
|
-
await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
|
|
163
|
-
await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
|
|
164
|
-
await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
|
|
165
|
-
await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
|
|
211
|
+
await clearPageDerivedRows(knex, chunk);
|
|
166
212
|
onProgress?.(Math.min(i + chunkSize, ids.length), ids.length);
|
|
167
213
|
}
|
|
168
214
|
dbLog('Reset %d failed pages back to pending', urls.length);
|