@nitpicker/crawler 0.8.0 → 0.11.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-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +147 -24
- package/lib/archive/archive.js +252 -86
- package/lib/archive/database.d.ts +209 -25
- package/lib/archive/database.js +928 -108
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +247 -15
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +135 -26
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +4 -1
- package/lib/crawler/crawler.js +290 -32
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +23 -0
- package/lib/crawler/derive-resource-source.js +26 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.js +38 -2
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.js +2 -1
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/types.d.ts +122 -1
- package/lib/crawler-orchestrator.d.ts +93 -1
- package/lib/crawler-orchestrator.js +389 -12
- package/lib/crawler.d.ts +5 -0
- package/lib/crawler.js +3 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +11 -1
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +4 -4
package/lib/archive/database.js
CHANGED
|
@@ -32,27 +32,90 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
32
32
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
33
|
done = true;
|
|
34
34
|
};
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
35
37
|
import path from 'node:path';
|
|
38
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
36
39
|
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
37
40
|
import { retry } from '@d-zero/shared/retry';
|
|
38
41
|
import { pathComparator } from '@d-zero/shared/sort/path';
|
|
39
42
|
import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
|
|
40
43
|
import knex from 'knex';
|
|
41
44
|
import { findScopeEntry } from '../crawler/find-scope-entry.js';
|
|
45
|
+
import { isHtmlContentType } from '../crawler/is-html-content-type.js';
|
|
46
|
+
import { normalizeContentType } from '../crawler/normalize-content-type.js';
|
|
42
47
|
import { eachSplitted } from '../utils/array/each-splitted.js';
|
|
43
48
|
import { ErrorEmitter } from '../utils/error/error-emitter.js';
|
|
44
49
|
import { dbLog } from './debug.js';
|
|
45
50
|
import { mkdir } from './filesystem/mkdir.js';
|
|
46
51
|
import { getJSON } from './get-json.js';
|
|
47
|
-
import { initSchema } from './init-schema.js';
|
|
52
|
+
import { applyConnectionPragmas, initSchema } from './init-schema.js';
|
|
48
53
|
import { LibsqlDialect } from './libsql-dialect.js';
|
|
49
54
|
import { limitedPageIds } from './limited-page-ids.js';
|
|
55
|
+
import { assertCompatibleVersion } from './meta/assert-compatible-version.js';
|
|
56
|
+
import { classifyJsonLdType } from './meta/classify-jsonld-type.js';
|
|
57
|
+
import { computePageDenormalized } from './meta/compute-page-denormalized.js';
|
|
58
|
+
import { deriveFlatFromMeta } from './meta/derive-flat-from-meta.js';
|
|
59
|
+
import { deriveMetaExtras } from './meta/derive-meta-extras.js';
|
|
60
|
+
import { extractTagsForArchive } from './meta/extract-tags-for-archive.js';
|
|
61
|
+
import { migrateCrawlErrors } from './migrate-crawl-errors.js';
|
|
62
|
+
import { migrateHtmlBlobTables } from './migrate-html-blob-tables.js';
|
|
50
63
|
import { migrateInfoRoots } from './migrate-info-roots.js';
|
|
64
|
+
import { migratePageErrors } from './migrate-page-errors.js';
|
|
65
|
+
import { migratePagesResourcesSource } from './migrate-pages-resources-source.js';
|
|
51
66
|
import { redirectTable } from './redirect-table.js';
|
|
67
|
+
import { resolveRedirectChain } from './resolve-redirect-chain.js';
|
|
52
68
|
const retrySetting = {
|
|
53
69
|
interval: 300,
|
|
54
70
|
retries: 3,
|
|
55
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Decodes a stored HTML body BLOB according to its codec marker. The codec
|
|
74
|
+
* column on `page_html_blobs` exists so individual rows can be migrated to
|
|
75
|
+
* a future encoder without rewriting the whole table; readers must dispatch
|
|
76
|
+
* on it. The body is typed `Uint8Array` (not `Buffer`) because libsql
|
|
77
|
+
* returns BLOB columns as bare `Uint8Array`; `Buffer.from` wraps it
|
|
78
|
+
* zero-copy.
|
|
79
|
+
* @param body - Raw bytes as stored in `page_html_blobs.body`.
|
|
80
|
+
* @param codec - The `codec` column value (e.g. `'zstd'`, `'none'`).
|
|
81
|
+
* @returns UTF-8 decoded HTML string.
|
|
82
|
+
* @throws {Error} If the codec is not recognised.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Parses a JSON column value, returning `null` on parse failure rather than
|
|
86
|
+
* throwing. JSON columns in `page_jsonld` (`parsed`) and `page_tags`
|
|
87
|
+
* (`categories`, `sources`) are written by `JSON.stringify` and round-trip
|
|
88
|
+
* cleanly under normal conditions; a hand-edited archive that has
|
|
89
|
+
* malformed JSON in those columns should degrade gracefully rather than
|
|
90
|
+
* propagate a parse error up to the consumer.
|
|
91
|
+
* @param value - JSON-encoded text.
|
|
92
|
+
*/
|
|
93
|
+
function safeParseJson(value) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(value);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
*
|
|
103
|
+
* @param body
|
|
104
|
+
* @param codec
|
|
105
|
+
*/
|
|
106
|
+
function decodeStoredBlob(body, codec) {
|
|
107
|
+
// `Buffer.from(buffer)` accepts Uint8Array, Buffer, and array-like
|
|
108
|
+
// shapes uniformly; libsql may hand back any of these for a BLOB
|
|
109
|
+
// column depending on the row encoding.
|
|
110
|
+
const buffer = Buffer.from(body);
|
|
111
|
+
if (codec === 'zstd') {
|
|
112
|
+
return zstdDecompressSync(buffer).toString('utf8');
|
|
113
|
+
}
|
|
114
|
+
if (codec === 'none') {
|
|
115
|
+
return buffer.toString('utf8');
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Unknown page_html_blobs.codec: ${codec}`);
|
|
118
|
+
}
|
|
56
119
|
/**
|
|
57
120
|
* Columns of the `info` table that `setConfig` / `updateConfig` are allowed to
|
|
58
121
|
* write. Any key outside this set is silently dropped so callers can splat a
|
|
@@ -89,13 +152,99 @@ const INFO_JSON_COLUMNS = new Set([
|
|
|
89
152
|
'excludeKeywords',
|
|
90
153
|
'excludeUrls',
|
|
91
154
|
]);
|
|
155
|
+
/**
|
|
156
|
+
* Columns of the `pages` table that should be reset to `null` whenever a
|
|
157
|
+
* previously-scraped row is demoted back to "pending" (i.e. by
|
|
158
|
+
* `resetFailedPages` and `repromoteExternalPages`).
|
|
159
|
+
*
|
|
160
|
+
* Includes all flat meta columns, the denormalised aggregates, and the
|
|
161
|
+
* `meta_extras` JSON catch-all. **Excludes** `firstCrawledAt` / `lastCrawledAt`
|
|
162
|
+
* by design — failure reset must not erase the last-success timestamp, which
|
|
163
|
+
* is the within-archive observation axis for #11 / #17 / #19 use cases.
|
|
164
|
+
*
|
|
165
|
+
* Centralised in one constant so schema growth and reset logic stay in lock-
|
|
166
|
+
* step: adding a flat meta column without updating this list would leave
|
|
167
|
+
* stale data after a reset.
|
|
168
|
+
*/
|
|
169
|
+
const META_NULLABLE_COLUMNS = [
|
|
170
|
+
// Document basics
|
|
171
|
+
'lang',
|
|
172
|
+
'dir',
|
|
173
|
+
'charset',
|
|
174
|
+
'baseHref',
|
|
175
|
+
'viewport_raw',
|
|
176
|
+
'themeColor',
|
|
177
|
+
'applicationName',
|
|
178
|
+
'author',
|
|
179
|
+
'generator',
|
|
180
|
+
'publisher',
|
|
181
|
+
// Title / description / keywords
|
|
182
|
+
'title',
|
|
183
|
+
'description',
|
|
184
|
+
'keywords',
|
|
185
|
+
// Robots
|
|
186
|
+
'robots_raw',
|
|
187
|
+
'robots_noindex',
|
|
188
|
+
'robots_nofollow',
|
|
189
|
+
'robots_noarchive',
|
|
190
|
+
'robots_noimageindex',
|
|
191
|
+
'googlebot',
|
|
192
|
+
// Link (1:1)
|
|
193
|
+
'canonical',
|
|
194
|
+
'amphtml',
|
|
195
|
+
'manifest',
|
|
196
|
+
'icon_href',
|
|
197
|
+
'appleTouchIcon_href',
|
|
198
|
+
// Open Graph
|
|
199
|
+
'og_type',
|
|
200
|
+
'og_title',
|
|
201
|
+
'og_url',
|
|
202
|
+
'og_site_name',
|
|
203
|
+
'og_description',
|
|
204
|
+
'og_image',
|
|
205
|
+
'og_image_alt',
|
|
206
|
+
'og_image_width',
|
|
207
|
+
'og_image_height',
|
|
208
|
+
'og_locale',
|
|
209
|
+
'og_article_published_time',
|
|
210
|
+
'og_article_modified_time',
|
|
211
|
+
// Twitter
|
|
212
|
+
'twitter_card',
|
|
213
|
+
'twitter_site',
|
|
214
|
+
'twitter_creator',
|
|
215
|
+
'twitter_title',
|
|
216
|
+
'twitter_description',
|
|
217
|
+
'twitter_image',
|
|
218
|
+
// One-offs
|
|
219
|
+
'fb_app_id',
|
|
220
|
+
'verification_google',
|
|
221
|
+
'formatDetection_telephone',
|
|
222
|
+
// Denormalised aggregates
|
|
223
|
+
'tag_count',
|
|
224
|
+
'jsonld_count',
|
|
225
|
+
'tags_providers_csv',
|
|
226
|
+
// Catch-all
|
|
227
|
+
'meta_extras',
|
|
228
|
+
];
|
|
229
|
+
/**
|
|
230
|
+
* Builds the reset payload for {@link META_NULLABLE_COLUMNS} as a plain object
|
|
231
|
+
* suitable for `knex.update(...)`. All listed columns are mapped to `null`.
|
|
232
|
+
*/
|
|
233
|
+
function makeMetaResetPayload() {
|
|
234
|
+
const payload = {};
|
|
235
|
+
for (const col of META_NULLABLE_COLUMNS) {
|
|
236
|
+
payload[col] = null;
|
|
237
|
+
}
|
|
238
|
+
return payload;
|
|
239
|
+
}
|
|
92
240
|
/**
|
|
93
241
|
* Low-level database abstraction layer for the archive's SQLite database.
|
|
94
242
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
243
|
+
* Public methods that perform database queries use the `@retryable`
|
|
244
|
+
* decorator for automatic retry on transient failures, and `@ErrorEmitter`
|
|
245
|
+
* to propagate errors as events. The set of tables this layer manages is
|
|
246
|
+
* defined by `init-schema.ts` (the source of truth — query that file for
|
|
247
|
+
* the canonical list).
|
|
99
248
|
*
|
|
100
249
|
* Use the static {@link Database.connect} factory method to create instances.
|
|
101
250
|
* The constructor is private.
|
|
@@ -103,12 +252,14 @@ const INFO_JSON_COLUMNS = new Set([
|
|
|
103
252
|
let Database = (() => {
|
|
104
253
|
let _classSuper = EventEmitter;
|
|
105
254
|
let _instanceExtraInitializers = [];
|
|
106
|
-
let _clearHtmlPath_decorators;
|
|
107
255
|
let _getAnchorsOnPage_decorators;
|
|
108
256
|
let _getBaseUrl_decorators;
|
|
109
257
|
let _getConfig_decorators;
|
|
110
258
|
let _getCrawlingState_decorators;
|
|
111
|
-
let
|
|
259
|
+
let _getExistingPageUrls_decorators;
|
|
260
|
+
let _getExistingResourceUrls_decorators;
|
|
261
|
+
let _getHtmlOfPageById_decorators;
|
|
262
|
+
let _getJsonLdOfPage_decorators;
|
|
112
263
|
let _getName_decorators;
|
|
113
264
|
let _getPageCount_decorators;
|
|
114
265
|
let _getPages_decorators;
|
|
@@ -116,11 +267,18 @@ let Database = (() => {
|
|
|
116
267
|
let _getRedirectsForPages_decorators;
|
|
117
268
|
let _getReferrersOfPage_decorators;
|
|
118
269
|
let _getReferrersOfResource_decorators;
|
|
270
|
+
let _getResourceByUrl_decorators;
|
|
119
271
|
let _getResources_decorators;
|
|
120
272
|
let _getResourceUrlList_decorators;
|
|
273
|
+
let _getScrapedHtmlPageCount_decorators;
|
|
274
|
+
let _getTagsOfPage_decorators;
|
|
275
|
+
let _insertCrawlError_decorators;
|
|
276
|
+
let _insertPageError_decorators;
|
|
121
277
|
let _insertResource_decorators;
|
|
122
278
|
let _insertResourceReferrers_decorators;
|
|
279
|
+
let _recordRedirect_decorators;
|
|
123
280
|
let _repromoteExternalPages_decorators;
|
|
281
|
+
let _resetFailedPages_decorators;
|
|
124
282
|
let _setConfig_decorators;
|
|
125
283
|
let _setSkippedPage_decorators;
|
|
126
284
|
let _updateConfig_decorators;
|
|
@@ -128,12 +286,14 @@ let Database = (() => {
|
|
|
128
286
|
return class Database extends _classSuper {
|
|
129
287
|
static {
|
|
130
288
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
131
|
-
_clearHtmlPath_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
132
289
|
_getAnchorsOnPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
133
290
|
_getBaseUrl_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
134
291
|
_getConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
135
292
|
_getCrawlingState_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
136
|
-
|
|
293
|
+
_getExistingPageUrls_decorators = [ErrorEmitter()];
|
|
294
|
+
_getExistingResourceUrls_decorators = [ErrorEmitter()];
|
|
295
|
+
_getHtmlOfPageById_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
296
|
+
_getJsonLdOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
137
297
|
_getName_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
138
298
|
_getPageCount_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
139
299
|
_getPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
@@ -141,21 +301,30 @@ let Database = (() => {
|
|
|
141
301
|
_getRedirectsForPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
142
302
|
_getReferrersOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
143
303
|
_getReferrersOfResource_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
304
|
+
_getResourceByUrl_decorators = [retry(retrySetting)];
|
|
144
305
|
_getResources_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
145
306
|
_getResourceUrlList_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
307
|
+
_getScrapedHtmlPageCount_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
308
|
+
_getTagsOfPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
309
|
+
_insertCrawlError_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
310
|
+
_insertPageError_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
146
311
|
_insertResource_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
147
312
|
_insertResourceReferrers_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
313
|
+
_recordRedirect_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
148
314
|
_repromoteExternalPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
315
|
+
_resetFailedPages_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
149
316
|
_setConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
150
317
|
_setSkippedPage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
151
318
|
_updateConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
152
319
|
_updatePage_decorators = [ErrorEmitter(), retry(retrySetting)];
|
|
153
|
-
__esDecorate(this, null, _clearHtmlPath_decorators, { kind: "method", name: "clearHtmlPath", static: false, private: false, access: { has: obj => "clearHtmlPath" in obj, get: obj => obj.clearHtmlPath }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
154
320
|
__esDecorate(this, null, _getAnchorsOnPage_decorators, { kind: "method", name: "getAnchorsOnPage", static: false, private: false, access: { has: obj => "getAnchorsOnPage" in obj, get: obj => obj.getAnchorsOnPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
155
321
|
__esDecorate(this, null, _getBaseUrl_decorators, { kind: "method", name: "getBaseUrl", static: false, private: false, access: { has: obj => "getBaseUrl" in obj, get: obj => obj.getBaseUrl }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
156
322
|
__esDecorate(this, null, _getConfig_decorators, { kind: "method", name: "getConfig", static: false, private: false, access: { has: obj => "getConfig" in obj, get: obj => obj.getConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
157
323
|
__esDecorate(this, null, _getCrawlingState_decorators, { kind: "method", name: "getCrawlingState", static: false, private: false, access: { has: obj => "getCrawlingState" in obj, get: obj => obj.getCrawlingState }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
158
|
-
__esDecorate(this, null,
|
|
324
|
+
__esDecorate(this, null, _getExistingPageUrls_decorators, { kind: "method", name: "getExistingPageUrls", static: false, private: false, access: { has: obj => "getExistingPageUrls" in obj, get: obj => obj.getExistingPageUrls }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
325
|
+
__esDecorate(this, null, _getExistingResourceUrls_decorators, { kind: "method", name: "getExistingResourceUrls", static: false, private: false, access: { has: obj => "getExistingResourceUrls" in obj, get: obj => obj.getExistingResourceUrls }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
326
|
+
__esDecorate(this, null, _getHtmlOfPageById_decorators, { kind: "method", name: "getHtmlOfPageById", static: false, private: false, access: { has: obj => "getHtmlOfPageById" in obj, get: obj => obj.getHtmlOfPageById }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
327
|
+
__esDecorate(this, null, _getJsonLdOfPage_decorators, { kind: "method", name: "getJsonLdOfPage", static: false, private: false, access: { has: obj => "getJsonLdOfPage" in obj, get: obj => obj.getJsonLdOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
159
328
|
__esDecorate(this, null, _getName_decorators, { kind: "method", name: "getName", static: false, private: false, access: { has: obj => "getName" in obj, get: obj => obj.getName }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
160
329
|
__esDecorate(this, null, _getPageCount_decorators, { kind: "method", name: "getPageCount", static: false, private: false, access: { has: obj => "getPageCount" in obj, get: obj => obj.getPageCount }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
161
330
|
__esDecorate(this, null, _getPages_decorators, { kind: "method", name: "getPages", static: false, private: false, access: { has: obj => "getPages" in obj, get: obj => obj.getPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -163,11 +332,18 @@ let Database = (() => {
|
|
|
163
332
|
__esDecorate(this, null, _getRedirectsForPages_decorators, { kind: "method", name: "getRedirectsForPages", static: false, private: false, access: { has: obj => "getRedirectsForPages" in obj, get: obj => obj.getRedirectsForPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
164
333
|
__esDecorate(this, null, _getReferrersOfPage_decorators, { kind: "method", name: "getReferrersOfPage", static: false, private: false, access: { has: obj => "getReferrersOfPage" in obj, get: obj => obj.getReferrersOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
165
334
|
__esDecorate(this, null, _getReferrersOfResource_decorators, { kind: "method", name: "getReferrersOfResource", static: false, private: false, access: { has: obj => "getReferrersOfResource" in obj, get: obj => obj.getReferrersOfResource }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
335
|
+
__esDecorate(this, null, _getResourceByUrl_decorators, { kind: "method", name: "getResourceByUrl", static: false, private: false, access: { has: obj => "getResourceByUrl" in obj, get: obj => obj.getResourceByUrl }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
166
336
|
__esDecorate(this, null, _getResources_decorators, { kind: "method", name: "getResources", static: false, private: false, access: { has: obj => "getResources" in obj, get: obj => obj.getResources }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
167
337
|
__esDecorate(this, null, _getResourceUrlList_decorators, { kind: "method", name: "getResourceUrlList", static: false, private: false, access: { has: obj => "getResourceUrlList" in obj, get: obj => obj.getResourceUrlList }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
338
|
+
__esDecorate(this, null, _getScrapedHtmlPageCount_decorators, { kind: "method", name: "getScrapedHtmlPageCount", static: false, private: false, access: { has: obj => "getScrapedHtmlPageCount" in obj, get: obj => obj.getScrapedHtmlPageCount }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
339
|
+
__esDecorate(this, null, _getTagsOfPage_decorators, { kind: "method", name: "getTagsOfPage", static: false, private: false, access: { has: obj => "getTagsOfPage" in obj, get: obj => obj.getTagsOfPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
340
|
+
__esDecorate(this, null, _insertCrawlError_decorators, { kind: "method", name: "insertCrawlError", static: false, private: false, access: { has: obj => "insertCrawlError" in obj, get: obj => obj.insertCrawlError }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
341
|
+
__esDecorate(this, null, _insertPageError_decorators, { kind: "method", name: "insertPageError", static: false, private: false, access: { has: obj => "insertPageError" in obj, get: obj => obj.insertPageError }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
168
342
|
__esDecorate(this, null, _insertResource_decorators, { kind: "method", name: "insertResource", static: false, private: false, access: { has: obj => "insertResource" in obj, get: obj => obj.insertResource }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
169
343
|
__esDecorate(this, null, _insertResourceReferrers_decorators, { kind: "method", name: "insertResourceReferrers", static: false, private: false, access: { has: obj => "insertResourceReferrers" in obj, get: obj => obj.insertResourceReferrers }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
344
|
+
__esDecorate(this, null, _recordRedirect_decorators, { kind: "method", name: "recordRedirect", static: false, private: false, access: { has: obj => "recordRedirect" in obj, get: obj => obj.recordRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
170
345
|
__esDecorate(this, null, _repromoteExternalPages_decorators, { kind: "method", name: "repromoteExternalPages", static: false, private: false, access: { has: obj => "repromoteExternalPages" in obj, get: obj => obj.repromoteExternalPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
346
|
+
__esDecorate(this, null, _resetFailedPages_decorators, { kind: "method", name: "resetFailedPages", static: false, private: false, access: { has: obj => "resetFailedPages" in obj, get: obj => obj.resetFailedPages }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
171
347
|
__esDecorate(this, null, _setConfig_decorators, { kind: "method", name: "setConfig", static: false, private: false, access: { has: obj => "setConfig" in obj, get: obj => obj.setConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
172
348
|
__esDecorate(this, null, _setSkippedPage_decorators, { kind: "method", name: "setSkippedPage", static: false, private: false, access: { has: obj => "setSkippedPage" in obj, get: obj => obj.setSkippedPage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
173
349
|
__esDecorate(this, null, _updateConfig_decorators, { kind: "method", name: "updateConfig", static: false, private: false, access: { has: obj => "updateConfig" in obj, get: obj => obj.updateConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -176,12 +352,9 @@ let Database = (() => {
|
|
|
176
352
|
}
|
|
177
353
|
/** The Knex query builder instance connected to the SQLite database. */
|
|
178
354
|
#instance = __runInitializers(this, _instanceExtraInitializers);
|
|
179
|
-
/** Absolute path to the working directory, used for resolving relative snapshot paths. */
|
|
180
|
-
#workingDir;
|
|
181
355
|
// eslint-disable-next-line no-restricted-syntax
|
|
182
356
|
constructor(options) {
|
|
183
357
|
super();
|
|
184
|
-
this.#workingDir = options.workingDir;
|
|
185
358
|
this.#instance = knex({
|
|
186
359
|
client: LibsqlDialect,
|
|
187
360
|
connection: {
|
|
@@ -216,14 +389,6 @@ let Database = (() => {
|
|
|
216
389
|
async checkpoint() {
|
|
217
390
|
await this.#instance.raw('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
218
391
|
}
|
|
219
|
-
/**
|
|
220
|
-
* Clears the HTML snapshot path for a page.
|
|
221
|
-
* Used to roll back the snapshot reference when the snapshot file write fails.
|
|
222
|
-
* @param pageId - The database ID of the page whose HTML path should be cleared.
|
|
223
|
-
*/
|
|
224
|
-
async clearHtmlPath(pageId) {
|
|
225
|
-
await this.#instance('pages').where('id', pageId).update({ html: null });
|
|
226
|
-
}
|
|
227
392
|
/**
|
|
228
393
|
* Destroys the database connection, releasing all pooled resources.
|
|
229
394
|
*/
|
|
@@ -303,18 +468,106 @@ let Database = (() => {
|
|
|
303
468
|
};
|
|
304
469
|
}
|
|
305
470
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
471
|
+
* Return the subset of `urls` that already exist in the `pages` table.
|
|
472
|
+
* Chunked into batches so SQLite's `IN (?, ?, …)` parameter limit
|
|
473
|
+
* (`SQLITE_MAX_VARIABLE_NUMBER`, default 999) cannot be hit even when the
|
|
474
|
+
* inventory list contains tens of thousands of URLs.
|
|
475
|
+
*
|
|
476
|
+
* Read-only — no transaction, no lock contention with the crawler write
|
|
477
|
+
* pipeline (callers run this BEFORE the `<archive>.bak` is taken and the
|
|
478
|
+
* crawl is started).
|
|
479
|
+
* @param urls - URL strings to probe (already in `withoutHashAndAuth` form).
|
|
480
|
+
* @returns URLs found in `pages`. Order is not preserved.
|
|
309
481
|
*/
|
|
310
|
-
async
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
482
|
+
async getExistingPageUrls(urls) {
|
|
483
|
+
if (urls.length === 0) {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
const found = [];
|
|
487
|
+
await eachSplitted([...urls], 500, async (chunk) => {
|
|
488
|
+
const rows = await this.#instance
|
|
489
|
+
.select('url')
|
|
314
490
|
.from('pages')
|
|
315
|
-
.
|
|
316
|
-
|
|
491
|
+
.whereIn('url', chunk);
|
|
492
|
+
for (const row of rows) {
|
|
493
|
+
found.push(row.url);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
return found;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Return the subset of `urls` that already exist in the `resources` table.
|
|
500
|
+
* See {@link Database.getExistingPageUrls} — same chunking strategy.
|
|
501
|
+
* @param urls - URL strings to probe.
|
|
502
|
+
* @returns URLs found in `resources`.
|
|
503
|
+
*/
|
|
504
|
+
async getExistingResourceUrls(urls) {
|
|
505
|
+
if (urls.length === 0) {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
const found = [];
|
|
509
|
+
await eachSplitted([...urls], 500, async (chunk) => {
|
|
510
|
+
const rows = await this.#instance
|
|
511
|
+
.select('url')
|
|
512
|
+
.from('resources')
|
|
513
|
+
.whereIn('url', chunk);
|
|
514
|
+
for (const row of rows) {
|
|
515
|
+
found.push(row.url);
|
|
516
|
+
}
|
|
317
517
|
});
|
|
518
|
+
return found;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Reads the HTML snapshot stored as a zstd-compressed BLOB for the given page.
|
|
522
|
+
*
|
|
523
|
+
* Joins `page_html_ref` → `page_html_blobs` and decompresses inline. Returns
|
|
524
|
+
* `null` when the page has no stored body (a non-HTML resource, a redirect
|
|
525
|
+
* source, a degraded render). Read works identically on read-only / stub
|
|
526
|
+
* connections — the special-cased "do we have a loose dir vs zip?" branching
|
|
527
|
+
* the previous file-backed layout required is gone.
|
|
528
|
+
*
|
|
529
|
+
* Tables `page_html_ref` and `page_html_blobs` are created by `initSchema`.
|
|
530
|
+
* Older `.nitpicker` archives that predate this migration must be passed
|
|
531
|
+
* through `scripts/migrate-to-0.10.mjs` before they can be read.
|
|
532
|
+
* @param pageId - The database ID of the page.
|
|
533
|
+
* @returns The decompressed HTML string, or `null` if no snapshot is stored.
|
|
534
|
+
*/
|
|
535
|
+
async getHtmlOfPageById(pageId) {
|
|
536
|
+
const row = await this.#instance
|
|
537
|
+
.from('page_html_ref')
|
|
538
|
+
.join('page_html_blobs', 'page_html_ref.hash', '=', 'page_html_blobs.hash')
|
|
539
|
+
.select('page_html_blobs.body as body', 'page_html_blobs.codec as codec')
|
|
540
|
+
.where('page_html_ref.page_id', pageId)
|
|
541
|
+
.first();
|
|
542
|
+
if (!row) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
return decodeStoredBlob(row.body, row.codec);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Retrieves all `page_jsonld` rows for the given page id, parsed back into
|
|
549
|
+
* {@link JsonLdRow} shape (with `parsed` deserialised from its JSON column).
|
|
550
|
+
*
|
|
551
|
+
* Read-side counterpart to `#insertJsonLd`. Returns rows in insertion order
|
|
552
|
+
* by `id` so the order observed by `get-page-jsonld` matches the order the
|
|
553
|
+
* scraper saw them.
|
|
554
|
+
* @param pageId
|
|
555
|
+
*/
|
|
556
|
+
async getJsonLdOfPage(pageId) {
|
|
557
|
+
const rows = await this.#instance
|
|
558
|
+
.select('id', 'pageId', 'kind', 'type', 'raw', 'parsed', 'parseError')
|
|
559
|
+
.from('page_jsonld')
|
|
560
|
+
.where('pageId', pageId)
|
|
561
|
+
.orderBy('id', 'asc');
|
|
562
|
+
return rows.map((r) => ({
|
|
563
|
+
id: r.id,
|
|
564
|
+
pageId: r.pageId,
|
|
565
|
+
kind: r.kind === 'speculationrules' ? 'speculationrules' : 'ld+json',
|
|
566
|
+
type: r.type,
|
|
567
|
+
raw: r.raw,
|
|
568
|
+
parsed: r.parsed === null ? null : safeParseJson(r.parsed),
|
|
569
|
+
parseError: r.parseError,
|
|
570
|
+
}));
|
|
318
571
|
}
|
|
319
572
|
/**
|
|
320
573
|
* Returns the underlying Knex query builder instance for direct SQL access.
|
|
@@ -524,15 +777,29 @@ let Database = (() => {
|
|
|
524
777
|
}
|
|
525
778
|
/**
|
|
526
779
|
* Retrieves pages that link to a specific page (incoming links / referrers).
|
|
780
|
+
*
|
|
781
|
+
* Incoming links are resolved **through redirects**: an anchor pointing at a
|
|
782
|
+
* redirect source (e.g. `http://x` that 301s to `https://x`) counts as a
|
|
783
|
+
* referrer of the redirect's final destination, not of the source. This keeps
|
|
784
|
+
* backlinks merged on the canonical page instead of splitting them across the
|
|
785
|
+
* `http`/`https` (or any redirect source/dest) pair. The resolution mirrors
|
|
786
|
+
* `redirectTable()` — `redirectDestId` is pre-flattened to the final
|
|
787
|
+
* destination, so `COALESCE(target.redirectDestId, target.id)` is a single hop.
|
|
527
788
|
* @param pageId - The database ID of the target page.
|
|
528
789
|
* @returns An array of referrer records with URL, hash, and text content.
|
|
529
790
|
*/
|
|
530
791
|
async getReferrersOfPage(pageId) {
|
|
531
792
|
const res = await this.#instance
|
|
532
|
-
.select('
|
|
793
|
+
.select('referrer.url',
|
|
794
|
+
// `through` / `throughId` = the URL the anchor actually pointed at (the
|
|
795
|
+
// redirect source, e.g. `http://x`), mirroring `getPagesWithRels`'
|
|
796
|
+
// `redirect.from` / `redirect.fromId`. Lets report code print the
|
|
797
|
+
// "[REDIRECTED FROM]" note even on this (non-preloaded) referrer path.
|
|
798
|
+
'target.url as through', 'target.id as throughId', 'anchors.hash', 'anchors.textContent')
|
|
533
799
|
.from('anchors')
|
|
534
|
-
.join('pages', 'anchors.pageId', '=', '
|
|
535
|
-
.
|
|
800
|
+
.join('pages as referrer', 'anchors.pageId', '=', 'referrer.id')
|
|
801
|
+
.join('pages as target', 'anchors.hrefId', '=', 'target.id')
|
|
802
|
+
.whereRaw('coalesce("target"."redirectDestId", "target"."id") = ?', [pageId]);
|
|
536
803
|
return res;
|
|
537
804
|
}
|
|
538
805
|
/**
|
|
@@ -549,6 +816,29 @@ let Database = (() => {
|
|
|
549
816
|
.where('resources.id', id);
|
|
550
817
|
return res.map((r) => r.url);
|
|
551
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Retrieves a single sub-resource from the `resources` table by its URL.
|
|
821
|
+
*
|
|
822
|
+
* Accepts multiple URL candidates because the stored key is the resource's
|
|
823
|
+
* `href` while callers may only know the hash-stripped form; the first match
|
|
824
|
+
* wins.
|
|
825
|
+
*
|
|
826
|
+
* Deliberately NOT decorated with `@ErrorEmitter`: the only caller (the
|
|
827
|
+
* crawler's resource-reuse hook) has a full fallback (the HEAD pre-flight),
|
|
828
|
+
* so a read failure here must not surface as a database `error` event —
|
|
829
|
+
* the orchestrator aborts the whole crawl on that event, which is the
|
|
830
|
+
* correct reaction to write failures but not to a recoverable read.
|
|
831
|
+
* @param urls - URL candidates to match against the `url` column.
|
|
832
|
+
* @returns The raw {@link DB_Resource} row, or `null` if none match.
|
|
833
|
+
*/
|
|
834
|
+
async getResourceByUrl(urls) {
|
|
835
|
+
const res = await this.#instance
|
|
836
|
+
.select('*')
|
|
837
|
+
.from('resources')
|
|
838
|
+
.whereIn('url', [...urls])
|
|
839
|
+
.first();
|
|
840
|
+
return res ?? null;
|
|
841
|
+
}
|
|
552
842
|
/**
|
|
553
843
|
* Retrieves all sub-resources from the `resources` table.
|
|
554
844
|
* @returns An array of raw {@link DB_Resource} rows.
|
|
@@ -564,12 +854,113 @@ let Database = (() => {
|
|
|
564
854
|
const res = await this.#instance.select('url').from('resources');
|
|
565
855
|
return res.map((r) => r.url);
|
|
566
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* Counts pages that were scraped as crawl targets (full HTML render).
|
|
859
|
+
*
|
|
860
|
+
* Used by the crawler to seed its `pagesScraped` counter on resume so the
|
|
861
|
+
* progress display reflects all browser-rendered HTML pages across sessions,
|
|
862
|
+
* not just the current one.
|
|
863
|
+
*
|
|
864
|
+
* "HTML page" is guaranteed by `contentType = 'text/html'`, NOT by `isTarget`
|
|
865
|
+
* alone: `isTarget` means "in-scope crawl target" and is set for in-scope
|
|
866
|
+
* non-HTML resources too (e.g. a PDF reached via the HEAD pre-flight is
|
|
867
|
+
* `isTarget = 1`). Counting those would over-report the HTML page total, so
|
|
868
|
+
* page-ness is asserted at the read layer here rather than by trusting
|
|
869
|
+
* `isTarget`.
|
|
870
|
+
* @returns The number of `text/html` rows with `isTarget = 1` and `scraped = 1`.
|
|
871
|
+
*/
|
|
872
|
+
async getScrapedHtmlPageCount() {
|
|
873
|
+
const [row] = await this.#instance
|
|
874
|
+
.from('pages')
|
|
875
|
+
.where('isTarget', 1)
|
|
876
|
+
.andWhere('scraped', 1)
|
|
877
|
+
.andWhere('contentType', 'text/html')
|
|
878
|
+
.count('* as count');
|
|
879
|
+
return row ? Number(row.count) : 0;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Retrieves all `page_tags` rows for the given page id, parsed back into
|
|
883
|
+
* {@link TagRow} shape (with `categories` and `sources` JSON columns
|
|
884
|
+
* deserialised).
|
|
885
|
+
*
|
|
886
|
+
* Read-side counterpart to `#insertTags`.
|
|
887
|
+
* @param pageId
|
|
888
|
+
*/
|
|
889
|
+
async getTagsOfPage(pageId) {
|
|
890
|
+
const rows = await this.#instance
|
|
891
|
+
.select('id', 'pageId', 'provider', 'category', 'externalId', 'version', 'confidence', 'categories', 'sources')
|
|
892
|
+
.from('page_tags')
|
|
893
|
+
.where('pageId', pageId)
|
|
894
|
+
.orderBy('id', 'asc');
|
|
895
|
+
return rows.map((r) => ({
|
|
896
|
+
id: r.id,
|
|
897
|
+
pageId: r.pageId,
|
|
898
|
+
provider: r.provider,
|
|
899
|
+
category: r.category,
|
|
900
|
+
externalId: r.externalId,
|
|
901
|
+
version: r.version,
|
|
902
|
+
confidence: r.confidence,
|
|
903
|
+
categories: r.categories === null ? [] : (safeParseJson(r.categories) ?? []),
|
|
904
|
+
sources: r.sources === null ? [] : (safeParseJson(r.sources) ?? []),
|
|
905
|
+
}));
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Records a crawler-level (`error` channel) failure into `crawl_errors`.
|
|
909
|
+
*
|
|
910
|
+
* Unlike {@link insertPageError} this is not tied to a scraped page: `url`
|
|
911
|
+
* may be an external link that never became a page row, or `null` for a
|
|
912
|
+
* process-level error. The cause is intentionally not stored — it is derived
|
|
913
|
+
* on read so that older archives (which only have `error.log`) and freshly
|
|
914
|
+
* captured rows classify identically.
|
|
915
|
+
* @param url - The URL the error is about, or `null` for a process-level error.
|
|
916
|
+
* @param message - The error message (one line is enough for classification).
|
|
917
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
918
|
+
*/
|
|
919
|
+
async insertCrawlError(url, message, isExternal = false) {
|
|
920
|
+
await this.#instance('crawl_errors').insert({
|
|
921
|
+
url,
|
|
922
|
+
isExternal: isExternal ? 1 : 0,
|
|
923
|
+
message,
|
|
924
|
+
createdAt: Date.now(),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Records a partial scrape failure against the page identified by `url`.
|
|
929
|
+
*
|
|
930
|
+
* The page row is resolved (or inserted as a stub) via
|
|
931
|
+
* {@link Database.#getIdByUrl} so the error can be recorded even before
|
|
932
|
+
* `setPage` has run — useful when the failure fires during scraping
|
|
933
|
+
* (e.g. mid-`scrapeStart`) and the orchestrator enqueues this write
|
|
934
|
+
* before the success write for the same URL.
|
|
935
|
+
*
|
|
936
|
+
* A single page can have multiple `page_errors` rows (e.g. both
|
|
937
|
+
* `desktop-compact` and `mobile-small` viewports failing).
|
|
938
|
+
* @param url - URL of the page being scraped.
|
|
939
|
+
* @param phase - Scrape phase name (typically `'retryExhausted'`).
|
|
940
|
+
* @param message - Human-readable failure message.
|
|
941
|
+
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
942
|
+
*/
|
|
943
|
+
async insertPageError(url, phase, message, isExternal = false) {
|
|
944
|
+
const pageId = await this.#getIdByUrl(url, isExternal ? 1 : 0);
|
|
945
|
+
await this.#instance('page_errors').insert({
|
|
946
|
+
pageId,
|
|
947
|
+
phase,
|
|
948
|
+
message,
|
|
949
|
+
createdAt: Date.now(),
|
|
950
|
+
});
|
|
951
|
+
}
|
|
567
952
|
/**
|
|
568
953
|
* Inserts a sub-resource into the `resources` table.
|
|
569
954
|
* Ignores duplicate URLs (uses `ON CONFLICT IGNORE`).
|
|
955
|
+
*
|
|
956
|
+
* The `source` provenance label is written ONLY on insert; an
|
|
957
|
+
* `ON CONFLICT IGNORE` collision leaves an existing row's source untouched
|
|
958
|
+
* (this is what makes a second `crawl --inventory` non-destructive — see
|
|
959
|
+
* the inventory plan).
|
|
570
960
|
* @param resource - The resource data to insert.
|
|
961
|
+
* @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
|
|
571
962
|
*/
|
|
572
|
-
async insertResource(resource) {
|
|
963
|
+
async insertResource(resource, source) {
|
|
573
964
|
await this.#instance
|
|
574
965
|
.from('resources')
|
|
575
966
|
.insert({
|
|
@@ -577,11 +968,14 @@ let Database = (() => {
|
|
|
577
968
|
isExternal: resource.isExternal ? 1 : 0,
|
|
578
969
|
status: resource.status,
|
|
579
970
|
statusText: resource.statusText,
|
|
580
|
-
contentType
|
|
971
|
+
// Canonicalize like `pages.contentType` (see #insertPage) so resource
|
|
972
|
+
// content-type filters / dedupe keys are case- and whitespace-stable.
|
|
973
|
+
contentType: normalizeContentType(resource.contentType),
|
|
581
974
|
contentLength: resource.contentLength,
|
|
582
975
|
compress: resource.compress || 0,
|
|
583
976
|
cdn: resource.cdn || 0,
|
|
584
977
|
responseHeaders: JSON.stringify(resource.headers),
|
|
978
|
+
...(source === undefined ? {} : { source }),
|
|
585
979
|
})
|
|
586
980
|
.onConflict('url')
|
|
587
981
|
.ignore();
|
|
@@ -611,6 +1005,48 @@ let Database = (() => {
|
|
|
611
1005
|
.onConflict(['resourceId', 'pageId'])
|
|
612
1006
|
.ignore();
|
|
613
1007
|
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Records a redirect edge (source → destination) **without** re-storing the
|
|
1010
|
+
* destination's content.
|
|
1011
|
+
*
|
|
1012
|
+
* The crawler renders a many-to-one redirect destination exactly once. For
|
|
1013
|
+
* every subsequent source URL that redirects to that already-rendered
|
|
1014
|
+
* destination, it calls this instead of {@link updatePage} (#73). Routing a
|
|
1015
|
+
* content-less HEAD result through `updatePage` would funnel it into
|
|
1016
|
+
* `#insertPage` and overwrite the destination's good title / meta with empty
|
|
1017
|
+
* values, so the dedicated edge-only path is required.
|
|
1018
|
+
*
|
|
1019
|
+
* The destination row is resolved (created on demand if a concurrent in-flight
|
|
1020
|
+
* render has not committed it yet) so the edge always points at a valid id;
|
|
1021
|
+
* the single render fills in the destination's content under that same id.
|
|
1022
|
+
* The destination's existing anchors / images are never touched here.
|
|
1023
|
+
* @param page - HEAD-resolved page data carrying the redirect chain. Its
|
|
1024
|
+
* `anchorList` / `imageList` are ignored (a redirect source owns no content).
|
|
1025
|
+
*/
|
|
1026
|
+
async recordRedirect(page) {
|
|
1027
|
+
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
1028
|
+
// No redirect chain (the URL is itself the already-rendered destination,
|
|
1029
|
+
// reached both directly and via a redirect) → there is no edge to write.
|
|
1030
|
+
// Returning here avoids opening a transaction and, crucially, avoids
|
|
1031
|
+
// `#getIdByUrl` inserting a content-less placeholder row for a destination
|
|
1032
|
+
// that may not have been written yet.
|
|
1033
|
+
if (sources.length === 0) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const destUrlObject = parseUrl(destUrl);
|
|
1037
|
+
if (!destUrlObject) {
|
|
1038
|
+
// A malformed redirect target should not abort the whole crawl (this
|
|
1039
|
+
// runs inside the WriteQueue, whose rejection aborts the run). Recording
|
|
1040
|
+
// a single redirect edge is best-effort, so skip it and move on. Unlike
|
|
1041
|
+
// `updatePage`, there is no page content at stake here.
|
|
1042
|
+
dbLog('recordRedirect: skip malformed destination URL: %s', destUrl);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
await this.#instance.transaction(async (trx) => {
|
|
1046
|
+
const destId = await this.#getIdByUrl(destUrlObject.withoutHashAndAuth, undefined, trx);
|
|
1047
|
+
await this.#linkRedirectSources(trx, sources, destId, destUrlObject.withoutHashAndAuth, page.isExternal);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
614
1050
|
/**
|
|
615
1051
|
* Promote previously-external pages whose URL falls under any of the new scope
|
|
616
1052
|
* entries back to a "needs scraping" state so that the next crawl picks them up
|
|
@@ -654,28 +1090,137 @@ let Database = (() => {
|
|
|
654
1090
|
return [];
|
|
655
1091
|
}
|
|
656
1092
|
const chunkSize = 500;
|
|
1093
|
+
const metaReset = makeMetaResetPayload();
|
|
657
1094
|
for (let i = 0; i < promotedIds.length; i += chunkSize) {
|
|
658
1095
|
const chunk = promotedIds.slice(i, i + chunkSize);
|
|
659
|
-
await this.#instance('pages')
|
|
1096
|
+
await this.#instance('pages')
|
|
1097
|
+
.whereIn('id', chunk)
|
|
1098
|
+
.update({
|
|
660
1099
|
scraped: 0,
|
|
661
1100
|
isExternal: 0,
|
|
662
1101
|
isSkipped: 0,
|
|
663
1102
|
skipReason: null,
|
|
664
|
-
html: null,
|
|
665
1103
|
status: null,
|
|
666
1104
|
statusText: null,
|
|
667
1105
|
contentType: null,
|
|
668
1106
|
contentLength: null,
|
|
669
1107
|
responseHeaders: '{}',
|
|
670
1108
|
redirectDestId: null,
|
|
1109
|
+
// Null every flat meta column + denormalised aggregates +
|
|
1110
|
+
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1111
|
+
// deliberately omitted from META_NULLABLE_COLUMNS — the
|
|
1112
|
+
// last-success timestamp survives the demotion.
|
|
1113
|
+
...metaReset,
|
|
671
1114
|
});
|
|
1115
|
+
// Clear the prior crawl's data for the repromoted pages. `updatePage`
|
|
1116
|
+
// also replaces anchors/images/tags/jsonld when it re-scrapes them, but
|
|
1117
|
+
// only when the new scrape is non-empty — so this pre-clear is still
|
|
1118
|
+
// load-bearing for pages that get repromoted but then re-scrape to
|
|
1119
|
+
// nothing (or are never reached again), and it is the only place
|
|
1120
|
+
// `resources-referrers` is cleared. The HTML body ref is also cleared
|
|
1121
|
+
// so a repromoted page whose re-scrape ends up degraded does not keep
|
|
1122
|
+
// its old external-render snapshot. `page_tags` / `page_jsonld` are
|
|
1123
|
+
// cleared explicitly even though both tables also carry ON DELETE
|
|
1124
|
+
// CASCADE — we keep the existing pattern of explicit chunked DELETEs
|
|
1125
|
+
// rather than relying on CASCADE indirectly (and would not cascade
|
|
1126
|
+
// anyway: the parent `pages` row is updated, not deleted). Orphan
|
|
1127
|
+
// blobs in `page_html_blobs` are left behind; #23 will add GC.
|
|
672
1128
|
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
673
1129
|
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
674
1130
|
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1131
|
+
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1132
|
+
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1133
|
+
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
675
1134
|
}
|
|
676
1135
|
dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
|
|
677
1136
|
return promotedUrls;
|
|
678
1137
|
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Reset previously-attempted pages that ended in a recoverable failure so a
|
|
1140
|
+
* follow-up crawl can re-fetch them from scratch.
|
|
1141
|
+
*
|
|
1142
|
+
* A page qualifies as a recoverable failure when it was already scraped
|
|
1143
|
+
* (`scraped = 1`), is not a redirect source (`redirectDestId IS NULL`), was
|
|
1144
|
+
* not intentionally skipped (`isSkipped` is not `1`), and one of the
|
|
1145
|
+
* following holds:
|
|
1146
|
+
*
|
|
1147
|
+
* - `status = -1` — the sentinel a hard scrape failure (network error,
|
|
1148
|
+
* timeout, browser crash) is recorded with (see `handle-scrape-error.ts`);
|
|
1149
|
+
* - `status IS NULL` — no status was ever stored for the row;
|
|
1150
|
+
* - `contentType IS NULL` — the content type could not be determined;
|
|
1151
|
+
* - `status` is in the `5xx` range — a (frequently transient) server error.
|
|
1152
|
+
*
|
|
1153
|
+
* Definitive `4xx` responses are intentionally excluded: re-fetching a 404
|
|
1154
|
+
* almost always yields the same answer. Matching rows — internal and
|
|
1155
|
+
* external alike — are demoted back to pending (`scraped = 0`) and have their
|
|
1156
|
+
* stale scrape metadata cleared. The page row itself is kept (id preserved)
|
|
1157
|
+
* so existing `anchors.hrefId` referrers stay valid, and `isExternal` is left
|
|
1158
|
+
* untouched so the next pass re-classifies each page from the crawl scope.
|
|
1159
|
+
* Related `anchors`, `images`, `resources-referrers`, and `page_errors` rows
|
|
1160
|
+
* are deleted so the re-scrape can re-insert fresh data without duplicates.
|
|
1161
|
+
*
|
|
1162
|
+
* SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
|
|
1163
|
+
* `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
1164
|
+
* @returns The URLs of the pages that were reset to pending.
|
|
1165
|
+
*/
|
|
1166
|
+
async resetFailedPages() {
|
|
1167
|
+
const candidates = await this.#instance
|
|
1168
|
+
.select('id', 'url')
|
|
1169
|
+
.from('pages')
|
|
1170
|
+
.where('scraped', 1)
|
|
1171
|
+
.whereNull('redirectDestId')
|
|
1172
|
+
.where((qb) => {
|
|
1173
|
+
qb.where('isSkipped', 0).orWhereNull('isSkipped');
|
|
1174
|
+
})
|
|
1175
|
+
.where((qb) => {
|
|
1176
|
+
qb.whereNull('status')
|
|
1177
|
+
.orWhere('status', -1)
|
|
1178
|
+
.orWhereNull('contentType')
|
|
1179
|
+
.orWhereBetween('status', [500, 599]);
|
|
1180
|
+
});
|
|
1181
|
+
if (candidates.length === 0) {
|
|
1182
|
+
return [];
|
|
1183
|
+
}
|
|
1184
|
+
const ids = candidates.map((row) => row.id);
|
|
1185
|
+
const urls = candidates.map((row) => row.url);
|
|
1186
|
+
const chunkSize = 500;
|
|
1187
|
+
const metaReset = makeMetaResetPayload();
|
|
1188
|
+
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
1189
|
+
const chunk = ids.slice(i, i + chunkSize);
|
|
1190
|
+
await this.#instance('pages')
|
|
1191
|
+
.whereIn('id', chunk)
|
|
1192
|
+
.update({
|
|
1193
|
+
scraped: 0,
|
|
1194
|
+
status: null,
|
|
1195
|
+
statusText: null,
|
|
1196
|
+
contentType: null,
|
|
1197
|
+
contentLength: null,
|
|
1198
|
+
responseHeaders: '{}',
|
|
1199
|
+
// Null every flat meta column + denormalised aggregates +
|
|
1200
|
+
// meta_extras. `firstCrawledAt` / `lastCrawledAt` are
|
|
1201
|
+
// deliberately omitted from META_NULLABLE_COLUMNS so the
|
|
1202
|
+
// last-success timestamp records survive the demotion (the
|
|
1203
|
+
// within-archive observation axis for #11/#17/#19).
|
|
1204
|
+
...metaReset,
|
|
1205
|
+
});
|
|
1206
|
+
// Clear the prior crawl's per-page data so the re-scrape starts clean.
|
|
1207
|
+
// `updatePage` only replaces anchors/images/tags/jsonld when the new
|
|
1208
|
+
// scrape is non-empty, so this pre-clear is load-bearing for pages that
|
|
1209
|
+
// reset but then fail again (or are never reached), and it is the only
|
|
1210
|
+
// place `resources-referrers` and `page_errors` are cleared. The HTML
|
|
1211
|
+
// body ref is also cleared so a previously-rendered page that now fails
|
|
1212
|
+
// to re-scrape does not keep its old snapshot.
|
|
1213
|
+
await this.#instance('anchors').whereIn('pageId', chunk).delete();
|
|
1214
|
+
await this.#instance('images').whereIn('pageId', chunk).delete();
|
|
1215
|
+
await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
|
|
1216
|
+
await this.#instance('page_errors').whereIn('pageId', chunk).delete();
|
|
1217
|
+
await this.#instance('page_html_ref').whereIn('page_id', chunk).delete();
|
|
1218
|
+
await this.#instance('page_tags').whereIn('pageId', chunk).delete();
|
|
1219
|
+
await this.#instance('page_jsonld').whereIn('pageId', chunk).delete();
|
|
1220
|
+
}
|
|
1221
|
+
dbLog('Reset %d failed pages back to pending', urls.length);
|
|
1222
|
+
return urls;
|
|
1223
|
+
}
|
|
679
1224
|
/**
|
|
680
1225
|
* Stores the crawl configuration in the `info` table.
|
|
681
1226
|
* Only fields in {@link INFO_COLUMN_ALLOWLIST} are forwarded — any extra
|
|
@@ -774,24 +1319,26 @@ let Database = (() => {
|
|
|
774
1319
|
}
|
|
775
1320
|
/**
|
|
776
1321
|
* Inserts or updates a crawled page in the database, including its redirect chain,
|
|
777
|
-
* anchors, and
|
|
1322
|
+
* anchors, images, and (when `writeHtml`) its compressed HTML snapshot BLOB.
|
|
778
1323
|
*
|
|
779
1324
|
* Self-redirects (where the source URL equals the destination URL after normalization)
|
|
780
1325
|
* are skipped to avoid marking a page as redirected to itself — a situation caused by
|
|
781
1326
|
* authentication challenges (e.g. Basic Auth 302) that would otherwise exclude the page
|
|
782
1327
|
* from reports via the `whereNull('redirectDestId')` filter.
|
|
783
1328
|
* @param page - The page data to store.
|
|
784
|
-
* @param
|
|
1329
|
+
* @param writeHtml - When `true`, this call is allowed to insert (or clear)
|
|
1330
|
+
* the page's HTML blob. `setExternalPage` passes `false` because external
|
|
1331
|
+
* metadata-only scrapes never carry HTML and must not perturb an already
|
|
1332
|
+
* stored body.
|
|
785
1333
|
* @param isTarget - Whether this page is a crawl target.
|
|
786
|
-
* @
|
|
1334
|
+
* @param source - Provenance label written ONLY when the row is freshly
|
|
1335
|
+
* inserted. Existing rows keep their original `source` (this is why a
|
|
1336
|
+
* second `crawl --inventory` does not "demote" an `'inventory-seed'` row
|
|
1337
|
+
* that was discovered earlier).
|
|
1338
|
+
* @returns The database `pageId` of the inserted/updated row.
|
|
787
1339
|
*/
|
|
788
|
-
async updatePage(page,
|
|
789
|
-
|
|
790
|
-
const redirectPaths = [...page.redirectPaths];
|
|
791
|
-
if (redirectPaths.length > 0) {
|
|
792
|
-
destUrl = redirectPaths.pop();
|
|
793
|
-
redirectPaths.unshift(page.url.withoutHashAndAuth);
|
|
794
|
-
}
|
|
1340
|
+
async updatePage(page, writeHtml, isTarget, source) {
|
|
1341
|
+
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
795
1342
|
const destUrlObject = parseUrl(destUrl);
|
|
796
1343
|
if (!destUrlObject) {
|
|
797
1344
|
throw new Error(`Failed to parse URL: ${destUrl}`);
|
|
@@ -800,27 +1347,70 @@ let Database = (() => {
|
|
|
800
1347
|
const pageId = await this.#insertPage({
|
|
801
1348
|
...page,
|
|
802
1349
|
url: destUrlObject,
|
|
803
|
-
}, isTarget, trx);
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1350
|
+
}, isTarget, trx, source);
|
|
1351
|
+
// Wappalyzer tag detection is HTML-body independent (relies on
|
|
1352
|
+
// `<script src>` / `<iframe src>` / window globals / response
|
|
1353
|
+
// headers) so it runs for every page including external /
|
|
1354
|
+
// metadata-only. JSON-LD on the other hand lives inside the
|
|
1355
|
+
// rendered HTML body, so we only write it when there is HTML to
|
|
1356
|
+
// scrape — see the same `writeHtml` gate as `#writePageHtmlBlob`
|
|
1357
|
+
// below.
|
|
1358
|
+
await this.#insertTags(pageId, page.meta, trx);
|
|
1359
|
+
if (writeHtml) {
|
|
1360
|
+
await this.#insertJsonLd(pageId, page.meta, trx);
|
|
1361
|
+
}
|
|
1362
|
+
await this.#linkRedirectSources(trx, sources, pageId, destUrlObject.withoutHashAndAuth, page.isExternal);
|
|
1363
|
+
// Only insert a snapshot blob when there is actual HTML to write.
|
|
1364
|
+
// `page.html.length > 0` is the precise signal: the scraper returns
|
|
1365
|
+
// `html: ''` for everything that is not a rendered `text/html` document
|
|
1366
|
+
// (non-HTML responses, metadata-only, external, degraded renders), so a
|
|
1367
|
+
// non-empty `html` is exactly "a rendered HTML body exists". Gating on
|
|
1368
|
+
// `isTarget` alone would store an empty body for every internal non-HTML
|
|
1369
|
+
// resource — PDF / zip / images are isTarget=1 (#72).
|
|
1370
|
+
//
|
|
1371
|
+
// `isTarget` is intentionally NOT part of this condition: it is implied by
|
|
1372
|
+
// `html.length > 0` (only in-scope target pages are browser-rendered into a
|
|
1373
|
+
// non-empty body; metadata-only and external pages carry `html: ''`), so the
|
|
1374
|
+
// content check alone expresses the intent without a redundant term.
|
|
1375
|
+
if (writeHtml && page.html.length > 0) {
|
|
1376
|
+
await this.#writePageHtmlBlob(pageId, page.html, trx);
|
|
819
1377
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1378
|
+
else if (writeHtml &&
|
|
1379
|
+
page.contentType !== null &&
|
|
1380
|
+
!isHtmlContentType(page.contentType)) {
|
|
1381
|
+
// The page is now a *known* non-HTML type. If a previous scrape stored
|
|
1382
|
+
// an HTML body for this URL (e.g. it served HTML then was replaced by
|
|
1383
|
+
// a PDF across `crawl --resume` / `--append`), drop the stale ref so
|
|
1384
|
+
// `page_html_ref` never contradicts `contentType`. A degraded HTML
|
|
1385
|
+
// re-scrape (text/html or unknown content type with empty html) is NOT
|
|
1386
|
+
// cleared — the last good snapshot is preserved, mirroring the
|
|
1387
|
+
// anchors / images empty-guard below. Gated on `writeHtml` because a
|
|
1388
|
+
// stale ref can only have been written by a snapshot-capable call
|
|
1389
|
+
// (`setPage`); `setExternalPage` passes `writeHtml = false` and never
|
|
1390
|
+
// sets `html`, so it has nothing to clear.
|
|
1391
|
+
await trx('page_html_ref').where('page_id', pageId).delete();
|
|
823
1392
|
}
|
|
1393
|
+
// Re-scrape semantics: the same URL can be scraped more than once
|
|
1394
|
+
// (e.g. `crawl --resume`, re-visits, `--append` re-promotion). The
|
|
1395
|
+
// `anchors` / `images` tables have no uniqueness constraint, so
|
|
1396
|
+
// re-inserting without clearing would accumulate a full duplicate set
|
|
1397
|
+
// on every re-scrape (the bug fixed in #70). So we delete-then-insert
|
|
1398
|
+
// to *replace* the previous rows.
|
|
1399
|
+
//
|
|
1400
|
+
// The delete is paired with — and guarded by — a non-empty new list:
|
|
1401
|
+
// a degraded re-scrape (navigation timeout / partial render) can return
|
|
1402
|
+
// an empty `anchorList` for a page that previously had links, and
|
|
1403
|
+
// wiping the prior good data in that case would be destructive. We
|
|
1404
|
+
// cannot tell a transient empty result apart from a page that has
|
|
1405
|
+
// legitimately lost all its links, so we err on the side of keeping
|
|
1406
|
+
// what we already had. The accepted trade-off is that a page which
|
|
1407
|
+
// genuinely dropped to zero links keeps its stale rows until the next
|
|
1408
|
+
// non-empty re-scrape replaces them.
|
|
1409
|
+
//
|
|
1410
|
+
// (A DB-level unique constraint + `onConflict` would also prevent
|
|
1411
|
+
// duplication, but multiple distinct anchors can share the same
|
|
1412
|
+
// hrefId/hash/textContent legitimately, so there is no natural unique
|
|
1413
|
+
// key to enforce — replace-on-write is the correct mechanism here.)
|
|
824
1414
|
const anchors = await Promise.all(page.anchorList.map(async (anchor) => {
|
|
825
1415
|
const hrefId = await this.#getIdByUrl(anchor.href.withoutHashAndAuth, anchor.isExternal ? 1 : 0, trx);
|
|
826
1416
|
return {
|
|
@@ -832,6 +1422,7 @@ let Database = (() => {
|
|
|
832
1422
|
}));
|
|
833
1423
|
dbLog('Insert anchors.length: %d', anchors.length);
|
|
834
1424
|
if (anchors.length > 0) {
|
|
1425
|
+
await trx('anchors').where('pageId', pageId).delete();
|
|
835
1426
|
await eachSplitted(anchors, 100, async (_anchors) => {
|
|
836
1427
|
await trx('anchors').insert(_anchors);
|
|
837
1428
|
});
|
|
@@ -842,21 +1433,29 @@ let Database = (() => {
|
|
|
842
1433
|
}));
|
|
843
1434
|
dbLog('Insert images.length: %d', images.length);
|
|
844
1435
|
if (images.length > 0) {
|
|
1436
|
+
await trx('images').where('pageId', pageId).delete();
|
|
845
1437
|
await eachSplitted(images, 100, async (_images) => {
|
|
846
1438
|
await trx('images').insert(_images);
|
|
847
1439
|
});
|
|
848
1440
|
}
|
|
849
|
-
return
|
|
1441
|
+
return pageId;
|
|
850
1442
|
});
|
|
851
1443
|
}
|
|
852
1444
|
/**
|
|
853
1445
|
* Returns the database ID for a URL, creating a new page row if needed.
|
|
854
1446
|
* Uses `ON CONFLICT IGNORE` to handle race conditions in concurrent inserts.
|
|
1447
|
+
*
|
|
1448
|
+
* `source` is written ONLY on the INSERT path — when the row already
|
|
1449
|
+
* exists, we never reach the INSERT and the existing row's `source`
|
|
1450
|
+
* stays untouched. This is what keeps a second `crawl --inventory` from
|
|
1451
|
+
* "demoting" a page that was first labelled `'inventory-seed'` back to
|
|
1452
|
+
* `'inventory-discovered'` on later passes.
|
|
855
1453
|
* @param url
|
|
856
1454
|
* @param isExternal
|
|
857
1455
|
* @param trx
|
|
1456
|
+
* @param source - Provenance label to put on the newly-inserted row. `undefined` lets the DB DEFAULT (`'crawled'`) apply.
|
|
858
1457
|
*/
|
|
859
|
-
async #getIdByUrl(url, isExternal, trx) {
|
|
1458
|
+
async #getIdByUrl(url, isExternal, trx, source) {
|
|
860
1459
|
const qb = trx ?? this.#instance;
|
|
861
1460
|
const [record] = await qb.select('id').from('pages').where('url', url);
|
|
862
1461
|
// Must use `?` because it may be `undefined`
|
|
@@ -870,6 +1469,7 @@ let Database = (() => {
|
|
|
870
1469
|
scraped: 0,
|
|
871
1470
|
isTarget: 0,
|
|
872
1471
|
...(isExternal != null && { isExternal }),
|
|
1472
|
+
...(source === undefined ? {} : { source }),
|
|
873
1473
|
})
|
|
874
1474
|
.onConflict('url')
|
|
875
1475
|
.ignore();
|
|
@@ -888,22 +1488,115 @@ let Database = (() => {
|
|
|
888
1488
|
* Initializes the database schema if tables do not exist, then runs lightweight
|
|
889
1489
|
* migrations that bring older archives up to the current schema.
|
|
890
1490
|
*
|
|
891
|
-
* Migrations are idempotent and run on every {@link Database.connect}
|
|
892
|
-
*
|
|
1491
|
+
* Migrations are idempotent and run on every writer-side {@link Database.connect};
|
|
1492
|
+
* in read-only mode they are SKIPPED so the same DB can be opened safely
|
|
1493
|
+
* by a viewer attached to a live (or interrupted) crawl without rewriting
|
|
1494
|
+
* the user's tmpDir.
|
|
1495
|
+
* @param readOnly - When true, skip schema init + migrations.
|
|
893
1496
|
*/
|
|
894
|
-
async #init() {
|
|
1497
|
+
async #init(readOnly) {
|
|
1498
|
+
// Connection-level PRAGMAs (foreign_keys, mmap_size, …) must be
|
|
1499
|
+
// reapplied on every connect — they are not persisted across opens.
|
|
1500
|
+
// They are safe in read-only mode because they don't write to the
|
|
1501
|
+
// user's tmpDir, just configure the libsql connection.
|
|
1502
|
+
await applyConnectionPragmas(this.#instance);
|
|
1503
|
+
// Reject pre-0.10 archives before any further work. Runs for both
|
|
1504
|
+
// writer and read-only (stub viewer) connections so old
|
|
1505
|
+
// `._nitpicker-*` stubs surface a clear error instead of
|
|
1506
|
+
// dereferencing missing columns at query time. New archives (no
|
|
1507
|
+
// `info` table yet) pass through; the schema is filled in by
|
|
1508
|
+
// `initSchema` below.
|
|
1509
|
+
await assertCompatibleVersion(this.#instance);
|
|
1510
|
+
if (readOnly) {
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
895
1513
|
await initSchema(this.#instance);
|
|
896
1514
|
await migrateInfoRoots(this.#instance);
|
|
1515
|
+
await migratePageErrors(this.#instance);
|
|
1516
|
+
await migrateCrawlErrors(this.#instance);
|
|
1517
|
+
await migrateHtmlBlobTables(this.#instance);
|
|
1518
|
+
await migratePagesResourcesSource(this.#instance);
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Replaces the page's JSON-LD / SpeculationRules rows with the freshly
|
|
1522
|
+
* captured set. Called inside `updatePage`'s transaction.
|
|
1523
|
+
*
|
|
1524
|
+
* `writeHtml = false` branches (`setExternalPage`, metadata-only) skip
|
|
1525
|
+
* this entirely — JSON-LD lives inside the HTML body, so external pages
|
|
1526
|
+
* that are not rendered have no entries to write. An empty array on a
|
|
1527
|
+
* normally-rendered page is treated as a degraded re-scrape: prior rows
|
|
1528
|
+
* are kept (same `delete-only-when-replacing` invariant as `anchors` /
|
|
1529
|
+
* `images`).
|
|
1530
|
+
* @param pageId
|
|
1531
|
+
* @param meta
|
|
1532
|
+
* @param trx
|
|
1533
|
+
*/
|
|
1534
|
+
async #insertJsonLd(pageId, meta, trx) {
|
|
1535
|
+
// `??` guards tolerate the legacy "minimal meta" shape from older test
|
|
1536
|
+
// fixtures. Real beholder 3.0.0 always populates these required fields.
|
|
1537
|
+
const jsonLd = meta.jsonLd ?? [];
|
|
1538
|
+
const speculationRules = meta.speculationRules ?? [];
|
|
1539
|
+
const rows = [];
|
|
1540
|
+
for (const entry of jsonLd) {
|
|
1541
|
+
rows.push({
|
|
1542
|
+
pageId,
|
|
1543
|
+
kind: 'ld+json',
|
|
1544
|
+
type: classifyJsonLdType(entry),
|
|
1545
|
+
raw: entry.raw,
|
|
1546
|
+
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
1547
|
+
parseError: entry.parseError ?? null,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
for (const entry of speculationRules) {
|
|
1551
|
+
rows.push({
|
|
1552
|
+
pageId,
|
|
1553
|
+
kind: 'speculationrules',
|
|
1554
|
+
type: classifyJsonLdType(entry),
|
|
1555
|
+
raw: entry.raw,
|
|
1556
|
+
parsed: entry.parsed === undefined ? null : JSON.stringify(entry.parsed),
|
|
1557
|
+
parseError: entry.parseError ?? null,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
if (rows.length === 0)
|
|
1561
|
+
return;
|
|
1562
|
+
await trx('page_jsonld').where('pageId', pageId).delete();
|
|
1563
|
+
await eachSplitted(rows, 100, async (chunk) => {
|
|
1564
|
+
await trx('page_jsonld').insert(chunk);
|
|
1565
|
+
});
|
|
897
1566
|
}
|
|
898
1567
|
/**
|
|
899
1568
|
* Upserts page data into the `pages` table (inserts if new, updates if existing).
|
|
1569
|
+
*
|
|
1570
|
+
* `source` is intentionally NOT in the UPDATE clause — provenance is set
|
|
1571
|
+
* once at INSERT time inside `#getIdByUrl`, and existing rows keep
|
|
1572
|
+
* whatever label they were first inserted with.
|
|
900
1573
|
* @param page
|
|
901
1574
|
* @param isTarget
|
|
902
1575
|
* @param trx
|
|
1576
|
+
* @param source - Inventory provenance for the INSERT path. Ignored on UPDATE.
|
|
903
1577
|
*/
|
|
904
|
-
async #insertPage(page, isTarget, trx) {
|
|
1578
|
+
async #insertPage(page, isTarget, trx, source) {
|
|
905
1579
|
const qb = trx ?? this.#instance;
|
|
906
|
-
const pageId = await this.#getIdByUrl(page.url.withoutHashAndAuth, undefined, trx);
|
|
1580
|
+
const pageId = await this.#getIdByUrl(page.url.withoutHashAndAuth, undefined, trx, source);
|
|
1581
|
+
const flat = deriveFlatFromMeta(page.meta, page.url.href);
|
|
1582
|
+
const denorm = computePageDenormalized(page.meta);
|
|
1583
|
+
const extras = deriveMetaExtras(page.meta);
|
|
1584
|
+
const now = Date.now();
|
|
1585
|
+
// Source promotion on UPDATE: when an inventory-mode scrape lands on
|
|
1586
|
+
// a row that was created earlier as a placeholder (e.g. an anchor
|
|
1587
|
+
// from a seed page pointed at this URL and `#getIdByUrl` inserted a
|
|
1588
|
+
// row with the DB DEFAULT `'crawled'`), bump the label to the
|
|
1589
|
+
// inventory variant. But never demote an already-inventoried row —
|
|
1590
|
+
// `CASE WHEN source = 'crawled' THEN ? ELSE source END` keeps a
|
|
1591
|
+
// previously labelled `'inventory-seed'` or `'inventory-discovered'`
|
|
1592
|
+
// row intact on a second pass.
|
|
1593
|
+
const sourceUpdate = source === undefined
|
|
1594
|
+
? {}
|
|
1595
|
+
: {
|
|
1596
|
+
source: qb.raw("CASE WHEN source = 'crawled' THEN ? ELSE source END", [
|
|
1597
|
+
source,
|
|
1598
|
+
]),
|
|
1599
|
+
};
|
|
907
1600
|
await qb('pages')
|
|
908
1601
|
.where('id', pageId)
|
|
909
1602
|
.update({
|
|
@@ -912,58 +1605,185 @@ let Database = (() => {
|
|
|
912
1605
|
isExternal: page.isExternal,
|
|
913
1606
|
status: page.status,
|
|
914
1607
|
statusText: page.statusText,
|
|
915
|
-
|
|
1608
|
+
// Canonicalize so the stored value matches the exact-string page-ness
|
|
1609
|
+
// predicate (`WHERE contentType = 'text/html'`) used by the read layer
|
|
1610
|
+
// and the case-insensitive `isHtmlContentType` used in code. Responses
|
|
1611
|
+
// are recorded verbatim upstream, so `Text/HTML` / `text/html ` can
|
|
1612
|
+
// otherwise be stored and silently misclassified.
|
|
1613
|
+
contentType: normalizeContentType(page.contentType),
|
|
916
1614
|
contentLength: page.contentLength,
|
|
917
1615
|
responseHeaders: JSON.stringify(page.responseHeaders),
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1616
|
+
// Flat meta columns derived from beholder 3.0.0 nested Meta.
|
|
1617
|
+
// URL-shaped columns (canonical / og_url / og_image / amphtml / manifest /
|
|
1618
|
+
// icon_href / appleTouchIcon_href / twitter_image) are already absolutised
|
|
1619
|
+
// by `deriveFlatFromMeta` against the page URL — `find-mismatches` compares
|
|
1620
|
+
// `canonical != url` directly, so storing the raw `getAttribute('href')`
|
|
1621
|
+
// would generate false positives for sites using relative canonicals.
|
|
1622
|
+
...flat,
|
|
1623
|
+
// Denormalised aggregates: written once at scrape time so list reads
|
|
1624
|
+
// (Sheets, page-detail summary) can answer "how many JSON-LD entries?"
|
|
1625
|
+
// and "which Wappalyzer providers?" by selecting a single pages column
|
|
1626
|
+
// rather than running a GROUP BY join on every read.
|
|
1627
|
+
tag_count: denorm.tag_count,
|
|
1628
|
+
jsonld_count: denorm.jsonld_count,
|
|
1629
|
+
tags_providers_csv: denorm.tags_providers_csv,
|
|
1630
|
+
// JSON catch-all for nested Meta sub-objects not flattened above.
|
|
1631
|
+
meta_extras: JSON.stringify(extras),
|
|
1632
|
+
// Timestamps: `firstCrawledAt` is set only on first INSERT — `COALESCE`
|
|
1633
|
+
// preserves the existing value so a re-scrape (`--append`, `--retry-failed`)
|
|
1634
|
+
// does not erase the discovery time. `lastCrawledAt` is updated every
|
|
1635
|
+
// successful scrape.
|
|
1636
|
+
firstCrawledAt: qb.raw('COALESCE(firstCrawledAt, ?)', [now]),
|
|
1637
|
+
lastCrawledAt: now,
|
|
934
1638
|
isSkipped: page.isSkipped,
|
|
1639
|
+
...sourceUpdate,
|
|
935
1640
|
});
|
|
936
1641
|
return pageId;
|
|
937
1642
|
}
|
|
938
1643
|
/**
|
|
939
|
-
*
|
|
1644
|
+
* Replaces the page's Wappalyzer tag rows with the freshly captured set.
|
|
1645
|
+
* Called inside `updatePage`'s transaction unconditionally — tag
|
|
1646
|
+
* detection draws on `<script src>` / `<iframe src>` / window globals /
|
|
1647
|
+
* response headers, not the HTML body, so external pages that skip
|
|
1648
|
+
* rendering still contribute tags.
|
|
1649
|
+
*
|
|
1650
|
+
* Same empty-guard as `#insertJsonLd`: an empty array does not wipe
|
|
1651
|
+
* prior rows on a degraded re-scrape.
|
|
940
1652
|
* @param pageId
|
|
941
|
-
* @param
|
|
1653
|
+
* @param meta
|
|
942
1654
|
* @param trx
|
|
943
1655
|
*/
|
|
944
|
-
async #
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
html: snapshotRelHtmlPath,
|
|
950
|
-
});
|
|
951
|
-
return {
|
|
952
|
-
html: snapshotHtmlPath,
|
|
1656
|
+
async #insertTags(pageId, meta, trx) {
|
|
1657
|
+
const partial = extractTagsForArchive(meta.tags);
|
|
1658
|
+
if (partial.length === 0)
|
|
1659
|
+
return;
|
|
1660
|
+
const rows = partial.map((p) => ({
|
|
953
1661
|
pageId,
|
|
954
|
-
|
|
1662
|
+
provider: p.provider,
|
|
1663
|
+
category: p.category,
|
|
1664
|
+
externalId: p.externalId,
|
|
1665
|
+
version: p.version,
|
|
1666
|
+
confidence: p.confidence,
|
|
1667
|
+
categories: JSON.stringify(p.categories),
|
|
1668
|
+
sources: JSON.stringify(p.sources),
|
|
1669
|
+
}));
|
|
1670
|
+
await trx('page_tags').where('pageId', pageId).delete();
|
|
1671
|
+
await eachSplitted(rows, 100, async (chunk) => {
|
|
1672
|
+
await trx('page_tags').insert(chunk);
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Points each redirect-source URL at the destination page, marking it scraped
|
|
1677
|
+
* and clearing any content it owned in a former life.
|
|
1678
|
+
*
|
|
1679
|
+
* Shared by {@link updatePage} (which also renders and stores the destination)
|
|
1680
|
+
* and {@link recordRedirect} (which only records the edge for a destination
|
|
1681
|
+
* rendered elsewhere). Self-redirects (source equal to the destination) are
|
|
1682
|
+
* skipped so a page is never marked as redirecting to itself — that would
|
|
1683
|
+
* exclude it from reports via the `whereNull('redirectDestId')` filter.
|
|
1684
|
+
* @param trx - The active transaction.
|
|
1685
|
+
* @param sources - Redirect-source URLs (normalised): the original URL plus
|
|
1686
|
+
* any intermediate hops. Empty when the page was not redirected.
|
|
1687
|
+
* @param destId - Database id of the redirect destination page.
|
|
1688
|
+
* @param destUrlNormalized - Normalised destination URL, used to detect and
|
|
1689
|
+
* skip self-redirects.
|
|
1690
|
+
* @param isExternal - Whether the sources are external to the crawl scope.
|
|
1691
|
+
*/
|
|
1692
|
+
async #linkRedirectSources(trx, sources, destId, destUrlNormalized, isExternal) {
|
|
1693
|
+
for (const redirect of sources) {
|
|
1694
|
+
if (redirect === destUrlNormalized) {
|
|
1695
|
+
dbLog('Skip self-redirect: %s', redirect);
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
dbLog('Set redirected url: %s -> id:%d', redirect, destId);
|
|
1699
|
+
const redirectId = await this.#getIdByUrl(redirect, undefined, trx);
|
|
1700
|
+
await trx('pages')
|
|
1701
|
+
.where('id', redirectId)
|
|
1702
|
+
.update({
|
|
1703
|
+
scraped: 1,
|
|
1704
|
+
redirectDestId: destId,
|
|
1705
|
+
isExternal: isExternal ? 1 : 0,
|
|
1706
|
+
});
|
|
1707
|
+
// A page that used to be scraped as content can later turn into a
|
|
1708
|
+
// redirect source. It owns no content anymore, so drop any anchors /
|
|
1709
|
+
// images it captured in its former life — otherwise they linger and
|
|
1710
|
+
// leak into referrer / incoming-link reads (which do not filter out
|
|
1711
|
+
// redirect sources).
|
|
1712
|
+
await trx('anchors').where('pageId', redirectId).delete();
|
|
1713
|
+
await trx('images').where('pageId', redirectId).delete();
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Encodes, dedups, and persists a page's HTML snapshot.
|
|
1718
|
+
*
|
|
1719
|
+
* Computes SHA-256 over the raw UTF-8 bytes, compresses them with zstd,
|
|
1720
|
+
* inserts into `page_html_blobs` only if the hash is new (so identical
|
|
1721
|
+
* bodies — 404 templates, error pages, redirect destinations — share a
|
|
1722
|
+
* single row), and then upserts `page_html_ref(page_id → hash)` so the
|
|
1723
|
+
* latest scrape always points at the right body.
|
|
1724
|
+
*
|
|
1725
|
+
* Runs entirely inside the caller's transaction; a failure here rolls
|
|
1726
|
+
* back the rest of `updatePage`, which is the desired semantics (an
|
|
1727
|
+
* archive that lost its HTML for a page would otherwise serve stale
|
|
1728
|
+
* meta against a missing body).
|
|
1729
|
+
* @param pageId - The database id of the page.
|
|
1730
|
+
* @param html - The raw HTML string (UTF-8).
|
|
1731
|
+
* @param trx - The active transaction.
|
|
1732
|
+
*/
|
|
1733
|
+
async #writePageHtmlBlob(pageId, html, trx) {
|
|
1734
|
+
const rawBytes = Buffer.from(html, 'utf8');
|
|
1735
|
+
const hash = createHash('sha256').update(rawBytes).digest();
|
|
1736
|
+
const compressed = zstdCompressSync(rawBytes);
|
|
1737
|
+
await trx('page_html_blobs')
|
|
1738
|
+
.insert({
|
|
1739
|
+
hash,
|
|
1740
|
+
body: compressed,
|
|
1741
|
+
codec: 'zstd',
|
|
1742
|
+
size_raw: rawBytes.byteLength,
|
|
1743
|
+
size_stored: compressed.byteLength,
|
|
1744
|
+
})
|
|
1745
|
+
.onConflict('hash')
|
|
1746
|
+
.ignore();
|
|
1747
|
+
// Upsert so a re-scrape's body cleanly supersedes the prior pointer.
|
|
1748
|
+
// The old blob row is intentionally left in place — a future #23 GC
|
|
1749
|
+
// pass will sweep unreachable hashes.
|
|
1750
|
+
await trx('page_html_ref')
|
|
1751
|
+
.insert({ page_id: pageId, hash })
|
|
1752
|
+
.onConflict('page_id')
|
|
1753
|
+
.merge(['hash']);
|
|
955
1754
|
}
|
|
956
1755
|
/**
|
|
957
1756
|
* Creates and initializes a new Database instance.
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
1757
|
+
*
|
|
1758
|
+
* **Writer mode (default)**: creates the parent directory for the
|
|
1759
|
+
* database file if needed, establishes the connection, and initializes
|
|
1760
|
+
* the schema + migrations.
|
|
1761
|
+
*
|
|
1762
|
+
* **Read-only mode** (`options.readOnly`): refuses to resurrect a
|
|
1763
|
+
* missing parent directory or db file — throws if either is absent at
|
|
1764
|
+
* the time of the call. Skips schema init and migrations entirely so
|
|
1765
|
+
* the user's tmpDir is never modified. Required by viewer / MCP
|
|
1766
|
+
* stub-mode opens, where a TOCTOU window between classification and
|
|
1767
|
+
* `connect()` could otherwise leave behind a phantom empty tmpDir.
|
|
1768
|
+
* @param options - Database connection options.
|
|
961
1769
|
* @returns A fully initialized Database instance.
|
|
1770
|
+
* @throws {Error} In read-only mode, if the parent directory or db
|
|
1771
|
+
* file does not exist when `connect()` runs.
|
|
962
1772
|
*/
|
|
963
1773
|
static async connect(options) {
|
|
964
|
-
|
|
1774
|
+
if (options.readOnly) {
|
|
1775
|
+
if (!existsSync(path.dirname(options.filename))) {
|
|
1776
|
+
throw new Error(`Cannot open archive read-only: parent directory disappeared (${path.dirname(options.filename)}). The source may have been removed by another process.`);
|
|
1777
|
+
}
|
|
1778
|
+
if (!existsSync(options.filename)) {
|
|
1779
|
+
throw new Error(`Cannot open archive read-only: database file missing (${options.filename}). The source may have been removed by another process.`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
else {
|
|
1783
|
+
mkdir(options.filename);
|
|
1784
|
+
}
|
|
965
1785
|
const db = new Database(options);
|
|
966
|
-
await db.#init();
|
|
1786
|
+
await db.#init(options.readOnly ?? false);
|
|
967
1787
|
return db;
|
|
968
1788
|
}
|
|
969
1789
|
};
|