@nitpicker/crawler 0.9.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/page.js
CHANGED
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
2
|
+
import { isHtmlContentType } from '../crawler/is-html-content-type.js';
|
|
3
|
+
import { parseResponseHeaders } from '../utils/object/parse-response-headers.js';
|
|
4
|
+
import { summarizeJsonLd } from './meta/summarize-jsonld.js';
|
|
5
|
+
import { summarizeTags } from './meta/summarize-tags.js';
|
|
6
|
+
/**
|
|
7
|
+
* Subset of {@link DB_Page} that maps to the flat meta columns derived from
|
|
8
|
+
* beholder's nested Meta. Used by {@link Page.metaFlat} so consumers can
|
|
9
|
+
* iterate every meta column without enumerating each one.
|
|
10
|
+
*
|
|
11
|
+
* Keep in sync with {@link import('./meta/types.js').FlatPageMetaColumns} —
|
|
12
|
+
* one row of `pages` has the same shape.
|
|
13
|
+
*/
|
|
14
|
+
const FLAT_META_COLUMNS = [
|
|
15
|
+
'lang',
|
|
16
|
+
'dir',
|
|
17
|
+
'charset',
|
|
18
|
+
'baseHref',
|
|
19
|
+
'viewport_raw',
|
|
20
|
+
'themeColor',
|
|
21
|
+
'applicationName',
|
|
22
|
+
'author',
|
|
23
|
+
'generator',
|
|
24
|
+
'publisher',
|
|
25
|
+
'title',
|
|
26
|
+
'description',
|
|
27
|
+
'keywords',
|
|
28
|
+
'robots_raw',
|
|
29
|
+
'robots_noindex',
|
|
30
|
+
'robots_nofollow',
|
|
31
|
+
'robots_noarchive',
|
|
32
|
+
'robots_noimageindex',
|
|
33
|
+
'googlebot',
|
|
34
|
+
'canonical',
|
|
35
|
+
'amphtml',
|
|
36
|
+
'manifest',
|
|
37
|
+
'icon_href',
|
|
38
|
+
'appleTouchIcon_href',
|
|
39
|
+
'og_type',
|
|
40
|
+
'og_title',
|
|
41
|
+
'og_url',
|
|
42
|
+
'og_site_name',
|
|
43
|
+
'og_description',
|
|
44
|
+
'og_image',
|
|
45
|
+
'og_image_alt',
|
|
46
|
+
'og_image_width',
|
|
47
|
+
'og_image_height',
|
|
48
|
+
'og_locale',
|
|
49
|
+
'og_article_published_time',
|
|
50
|
+
'og_article_modified_time',
|
|
51
|
+
'twitter_card',
|
|
52
|
+
'twitter_site',
|
|
53
|
+
'twitter_creator',
|
|
54
|
+
'twitter_title',
|
|
55
|
+
'twitter_description',
|
|
56
|
+
'twitter_image',
|
|
57
|
+
'fb_app_id',
|
|
58
|
+
'verification_google',
|
|
59
|
+
'formatDetection_telephone',
|
|
60
|
+
'tag_count',
|
|
61
|
+
'jsonld_count',
|
|
62
|
+
'tags_providers_csv',
|
|
63
|
+
];
|
|
2
64
|
/**
|
|
3
65
|
* Represents a crawled page stored in the archive.
|
|
4
66
|
*
|
|
5
|
-
* Provides
|
|
6
|
-
*
|
|
67
|
+
* Provides typed getters for the most-used meta columns (title, canonical,
|
|
68
|
+
* og:*, twitter_card, robots flags, lang), plus {@link metaFlat} as an
|
|
69
|
+
* iterable view over all ~47 flat meta columns and {@link metaExtras} for
|
|
70
|
+
* the JSON catch-all of nested sub-objects not flattened to columns.
|
|
71
|
+
*
|
|
72
|
+
* JSON-LD entries and Wappalyzer tag rows live in dedicated tables and are
|
|
73
|
+
* fetched on demand via {@link jsonLd} / {@link tags} (lazy reads, same
|
|
74
|
+
* pattern as {@link getAnchors}).
|
|
75
|
+
*
|
|
7
76
|
* Instances are created by {@link ArchiveAccessor.getPages} or
|
|
8
77
|
* {@link ArchiveAccessor.getPagesWithRefs}.
|
|
9
78
|
*/
|
|
@@ -20,16 +89,17 @@ export default class Page {
|
|
|
20
89
|
#rawAnchors;
|
|
21
90
|
#rawReferrers;
|
|
22
91
|
/**
|
|
23
|
-
* The
|
|
92
|
+
* The canonical URL from `<link rel="canonical">` (absolutised against the
|
|
93
|
+
* page URL at write time), or null if not present.
|
|
24
94
|
*/
|
|
25
|
-
get
|
|
26
|
-
return this.#raw.
|
|
95
|
+
get canonical() {
|
|
96
|
+
return this.#raw.canonical;
|
|
27
97
|
}
|
|
28
98
|
/**
|
|
29
|
-
* The
|
|
99
|
+
* The `<meta charset>` value, or null if not present.
|
|
30
100
|
*/
|
|
31
|
-
get
|
|
32
|
-
return this.#raw.
|
|
101
|
+
get charset() {
|
|
102
|
+
return this.#raw.charset;
|
|
33
103
|
}
|
|
34
104
|
/**
|
|
35
105
|
* The content length of the HTTP response in bytes, or null if unknown.
|
|
@@ -49,6 +119,14 @@ export default class Page {
|
|
|
49
119
|
get description() {
|
|
50
120
|
return this.#raw.description;
|
|
51
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* UNIX ms timestamp of the first time this page row was inserted, or
|
|
124
|
+
* null for legacy rows. Survives `resetFailedPages` so the discovery
|
|
125
|
+
* time of a page is preserved across retries.
|
|
126
|
+
*/
|
|
127
|
+
get firstCrawledAt() {
|
|
128
|
+
return this.#raw.firstCrawledAt;
|
|
129
|
+
}
|
|
52
130
|
/**
|
|
53
131
|
* Whether this page is on an external domain (outside the crawl scope).
|
|
54
132
|
*/
|
|
@@ -68,10 +146,11 @@ export default class Page {
|
|
|
68
146
|
return !!this.#raw.isTarget;
|
|
69
147
|
}
|
|
70
148
|
/**
|
|
71
|
-
*
|
|
149
|
+
* Number of JSON-LD + SpeculationRules entries detected on this page
|
|
150
|
+
* (denormalised aggregate written at scrape time).
|
|
72
151
|
*/
|
|
73
|
-
get
|
|
74
|
-
return this.#raw.
|
|
152
|
+
get jsonldCount() {
|
|
153
|
+
return this.#raw.jsonld_count;
|
|
75
154
|
}
|
|
76
155
|
/**
|
|
77
156
|
* The meta keywords content, or null if not present.
|
|
@@ -86,70 +165,126 @@ export default class Page {
|
|
|
86
165
|
return this.#raw.lang;
|
|
87
166
|
}
|
|
88
167
|
/**
|
|
89
|
-
*
|
|
168
|
+
* UNIX ms timestamp of the most recent successful scrape for this page,
|
|
169
|
+
* or null for legacy rows / never-scraped pages.
|
|
90
170
|
*/
|
|
91
|
-
get
|
|
92
|
-
return
|
|
171
|
+
get lastCrawledAt() {
|
|
172
|
+
return this.#raw.lastCrawledAt;
|
|
93
173
|
}
|
|
94
174
|
/**
|
|
95
|
-
*
|
|
175
|
+
* Iterable view over every flat meta column (~47 fields). Returns a frozen
|
|
176
|
+
* record so consumers can pick fields by name without re-enumerating
|
|
177
|
+
* typed getters.
|
|
178
|
+
*
|
|
179
|
+
* Use the typed getters for high-frequency fields (title, canonical, og_*
|
|
180
|
+
* etc.); use `metaFlat` for bulk projection (Sheets row generation,
|
|
181
|
+
* `toJSON`, debug dumps).
|
|
96
182
|
*/
|
|
97
|
-
get
|
|
98
|
-
|
|
183
|
+
get metaFlat() {
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const col of FLAT_META_COLUMNS) {
|
|
186
|
+
out[col] = this.#raw[col];
|
|
187
|
+
}
|
|
188
|
+
return Object.freeze(out);
|
|
99
189
|
}
|
|
100
190
|
/**
|
|
101
|
-
*
|
|
191
|
+
* Parsed `meta_extras` JSON: nested Meta sub-objects (referrer, viewport,
|
|
192
|
+
* httpEquiv, og.image[], twitter.*, apple.*, msapplication.*, geo,
|
|
193
|
+
* citation, link.alternateHreflang[], others.*, etc.) that were not
|
|
194
|
+
* flattened to dedicated columns.
|
|
195
|
+
*
|
|
196
|
+
* Returns an empty object when the column is null or invalid JSON.
|
|
102
197
|
*/
|
|
103
|
-
get
|
|
104
|
-
|
|
198
|
+
get metaExtras() {
|
|
199
|
+
const raw = this.#raw.meta_extras;
|
|
200
|
+
if (raw === null)
|
|
201
|
+
return {};
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(raw);
|
|
204
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
205
|
+
return parsed;
|
|
206
|
+
}
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
105
212
|
}
|
|
106
213
|
/**
|
|
107
|
-
*
|
|
214
|
+
* Open Graph description, or null if not present.
|
|
108
215
|
*/
|
|
109
216
|
get og_description() {
|
|
110
217
|
return this.#raw.og_description;
|
|
111
218
|
}
|
|
112
219
|
/**
|
|
113
|
-
*
|
|
220
|
+
* Open Graph image URL (first image when og:image is multi-valued;
|
|
221
|
+
* absolutised against the page URL at write time), or null if not present.
|
|
114
222
|
*/
|
|
115
223
|
get og_image() {
|
|
116
224
|
return this.#raw.og_image;
|
|
117
225
|
}
|
|
118
226
|
/**
|
|
119
|
-
*
|
|
227
|
+
* Open Graph site name, or null if not present.
|
|
120
228
|
*/
|
|
121
229
|
get og_site_name() {
|
|
122
230
|
return this.#raw.og_site_name;
|
|
123
231
|
}
|
|
124
232
|
/**
|
|
125
|
-
*
|
|
233
|
+
* Open Graph title, or null if not present.
|
|
126
234
|
*/
|
|
127
235
|
get og_title() {
|
|
128
236
|
return this.#raw.og_title;
|
|
129
237
|
}
|
|
130
238
|
/**
|
|
131
|
-
*
|
|
239
|
+
* Open Graph type, or null if not present.
|
|
132
240
|
*/
|
|
133
241
|
get og_type() {
|
|
134
242
|
return this.#raw.og_type;
|
|
135
243
|
}
|
|
136
244
|
/**
|
|
137
|
-
*
|
|
245
|
+
* Open Graph URL (absolutised), or null if not present.
|
|
138
246
|
*/
|
|
139
247
|
get og_url() {
|
|
140
248
|
return this.#raw.og_url;
|
|
141
249
|
}
|
|
142
250
|
/**
|
|
143
251
|
* The parsed HTTP response headers as a key-value record.
|
|
144
|
-
*
|
|
252
|
+
* Header values may be arrays for multi-value headers (e.g. `set-cookie`).
|
|
253
|
+
* Returns an empty object if headers are absent or cannot be parsed.
|
|
145
254
|
*/
|
|
146
255
|
get responseHeaders() {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
256
|
+
return parseResponseHeaders(this.#raw.responseHeaders) ?? {};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether the robots:noarchive directive is set.
|
|
260
|
+
*/
|
|
261
|
+
get robots_noarchive() {
|
|
262
|
+
return !!this.#raw.robots_noarchive;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Whether the robots:nofollow directive is set.
|
|
266
|
+
*/
|
|
267
|
+
get robots_nofollow() {
|
|
268
|
+
return !!this.#raw.robots_nofollow;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether the robots:noindex directive is set.
|
|
272
|
+
*/
|
|
273
|
+
get robots_noindex() {
|
|
274
|
+
return !!this.#raw.robots_noindex;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Raw `<meta name="robots">` content, or null if not present.
|
|
278
|
+
* Use for diagnostics; specific directive flags live on `robots_*` getters.
|
|
279
|
+
*/
|
|
280
|
+
get robots_raw() {
|
|
281
|
+
return this.#raw.robots_raw;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The reason this page was skipped during crawling, or null if it was not skipped.
|
|
285
|
+
*/
|
|
286
|
+
get skipReason() {
|
|
287
|
+
return this.#raw.skipReason;
|
|
153
288
|
}
|
|
154
289
|
/**
|
|
155
290
|
* The HTTP response status code, or null if the page has not been fetched.
|
|
@@ -163,6 +298,21 @@ export default class Page {
|
|
|
163
298
|
get statusText() {
|
|
164
299
|
return this.#raw.statusText;
|
|
165
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Number of Wappalyzer tag entries detected on this page (denormalised
|
|
303
|
+
* aggregate written at scrape time).
|
|
304
|
+
*/
|
|
305
|
+
get tagCount() {
|
|
306
|
+
return this.#raw.tag_count;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Sorted unique Wappalyzer provider names, comma-separated, empty string
|
|
310
|
+
* when no tags. Denormalised aggregate; for the structured form fetch
|
|
311
|
+
* {@link tags} (lazy).
|
|
312
|
+
*/
|
|
313
|
+
get tagsProvidersCsv() {
|
|
314
|
+
return this.#raw.tags_providers_csv ?? '';
|
|
315
|
+
}
|
|
166
316
|
/**
|
|
167
317
|
* The page title from the `<title>` element.
|
|
168
318
|
* Returns an empty string if no title is set.
|
|
@@ -171,7 +321,7 @@ export default class Page {
|
|
|
171
321
|
return this.#raw.title || '';
|
|
172
322
|
}
|
|
173
323
|
/**
|
|
174
|
-
*
|
|
324
|
+
* Twitter Card type (`twitter:card`), or null if not present.
|
|
175
325
|
*/
|
|
176
326
|
get twitter_card() {
|
|
177
327
|
return this.#raw.twitter_card;
|
|
@@ -227,11 +377,28 @@ export default class Page {
|
|
|
227
377
|
return this.#archive.getAnchorsOnPage(this.#raw.id);
|
|
228
378
|
}
|
|
229
379
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
380
|
+
* Thin wrapper that forwards this page's id to the accessor's
|
|
381
|
+
* BLOB-read path. Lets callers hold a `Page` reference and ask for its
|
|
382
|
+
* HTML without having to thread the page id through.
|
|
383
|
+
* @returns The HTML content, or `null` if no snapshot was saved.
|
|
384
|
+
* @see {@link ArchiveAccessor.getHtmlOfPage} for the resolution rules.
|
|
385
|
+
* @example
|
|
386
|
+
* for (const page of await archive.getPages()) {
|
|
387
|
+
* const html = await page.getHtml();
|
|
388
|
+
* if (html !== null) processBody(html);
|
|
389
|
+
* }
|
|
232
390
|
*/
|
|
233
391
|
async getHtml() {
|
|
234
|
-
return this.#archive.getHtmlOfPage(this.#raw.
|
|
392
|
+
return this.#archive.getHtmlOfPage(this.#raw.id);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Retrieves the JSON-LD entries for this page from `page_jsonld`.
|
|
396
|
+
* Lazy — runs a single SELECT per call. Returns entries in insertion
|
|
397
|
+
* order (matches the scraper's traversal order).
|
|
398
|
+
* @returns Ordered JSON-LD / SpeculationRules rows.
|
|
399
|
+
*/
|
|
400
|
+
async getJsonLd() {
|
|
401
|
+
return this.#archive.getJsonLdOfPage(this.#raw.id);
|
|
235
402
|
}
|
|
236
403
|
/**
|
|
237
404
|
* Retrieves the referrers (incoming links) pointing to this page.
|
|
@@ -248,7 +415,14 @@ export default class Page {
|
|
|
248
415
|
textContent: r.textContent || '',
|
|
249
416
|
}));
|
|
250
417
|
}
|
|
251
|
-
|
|
418
|
+
const refs = await this.#archive.getReferrersOfPage(this.#raw.id);
|
|
419
|
+
return refs.map((r) => ({
|
|
420
|
+
url: r.url,
|
|
421
|
+
through: r.through,
|
|
422
|
+
throughId: r.throughId,
|
|
423
|
+
hash: r.hash,
|
|
424
|
+
textContent: r.textContent || '',
|
|
425
|
+
}));
|
|
252
426
|
}
|
|
253
427
|
/**
|
|
254
428
|
* Retrieves all request referrers for this page directly from the database.
|
|
@@ -256,7 +430,22 @@ export default class Page {
|
|
|
256
430
|
* @returns An array of {@link Referrer} objects.
|
|
257
431
|
*/
|
|
258
432
|
async getRequests() {
|
|
259
|
-
|
|
433
|
+
const refs = await this.#archive.getReferrersOfPage(this.#raw.id);
|
|
434
|
+
return refs.map((r) => ({
|
|
435
|
+
url: r.url,
|
|
436
|
+
through: r.through,
|
|
437
|
+
throughId: r.throughId,
|
|
438
|
+
hash: r.hash,
|
|
439
|
+
textContent: r.textContent || '',
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Retrieves the Wappalyzer tag rows for this page from `page_tags`.
|
|
444
|
+
* Lazy — runs a single SELECT per call.
|
|
445
|
+
* @returns Ordered tag rows.
|
|
446
|
+
*/
|
|
447
|
+
async getTags() {
|
|
448
|
+
return this.#archive.getTagsOfPage(this.#raw.id);
|
|
260
449
|
}
|
|
261
450
|
/**
|
|
262
451
|
* Checks whether this page is an internal HTML page (not external and has `text/html` content type).
|
|
@@ -270,18 +459,33 @@ export default class Page {
|
|
|
270
459
|
* @returns `true` if the content type is `text/html`, `false` otherwise.
|
|
271
460
|
*/
|
|
272
461
|
isPage() {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
*
|
|
278
|
-
*
|
|
462
|
+
return isHtmlContentType(this.contentType);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Serializes the page data to a plain JSON object including the full flat
|
|
466
|
+
* meta column set, the `meta_extras` catch-all, and **summaries** of the
|
|
467
|
+
* JSON-LD and tag rows.
|
|
468
|
+
*
|
|
469
|
+
* Summaries (not raw entries) are inlined so a Page detail payload stays
|
|
470
|
+
* token-bounded for MCP / LLM consumers — the full `raw` JSON-LD payload
|
|
471
|
+
* is fetched separately via `getJsonLd()` / the dedicated CLI/MCP
|
|
472
|
+
* endpoints.
|
|
473
|
+
*
|
|
474
|
+
* Anchors and referrers are still resolved eagerly because consumers
|
|
475
|
+
* (Sheets `eachPage`, viewer detail) depend on having them inline.
|
|
279
476
|
* @returns A plain object containing all page metadata and relationships.
|
|
280
477
|
*/
|
|
281
478
|
async toJSON() {
|
|
479
|
+
const [anchors, referrers, jsonLdRows, tagRows] = await Promise.all([
|
|
480
|
+
this.getAnchors(),
|
|
481
|
+
this.getReferrers(),
|
|
482
|
+
this.getJsonLd(),
|
|
483
|
+
this.getTags(),
|
|
484
|
+
]);
|
|
485
|
+
const jsonLdSummary = summarizeJsonLd(jsonLdRows);
|
|
486
|
+
const tagsSummary = summarizeTags(tagRows);
|
|
282
487
|
return {
|
|
283
488
|
url: this.url.href,
|
|
284
|
-
title: this.title,
|
|
285
489
|
status: this.status,
|
|
286
490
|
statusText: this.statusText,
|
|
287
491
|
contentType: this.contentType,
|
|
@@ -291,26 +495,17 @@ export default class Page {
|
|
|
291
495
|
isSkipped: this.isSkipped,
|
|
292
496
|
skipReason: this.skipReason,
|
|
293
497
|
isTarget: this.isTarget,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
canonical: this.canonical,
|
|
301
|
-
alternate: this.alternate,
|
|
302
|
-
twitter_card: this.twitter_card,
|
|
303
|
-
og_site_name: this.og_site_name,
|
|
304
|
-
og_url: this.og_url,
|
|
305
|
-
og_title: this.og_title,
|
|
306
|
-
og_description: this.og_description,
|
|
307
|
-
og_type: this.og_type,
|
|
308
|
-
og_image: this.og_image,
|
|
498
|
+
firstCrawledAt: this.firstCrawledAt,
|
|
499
|
+
lastCrawledAt: this.lastCrawledAt,
|
|
500
|
+
...this.metaFlat,
|
|
501
|
+
metaExtras: this.metaExtras,
|
|
502
|
+
jsonLd: jsonLdSummary,
|
|
503
|
+
tags: tagsSummary,
|
|
309
504
|
redirectFrom: this.redirectFrom,
|
|
310
505
|
isPage: this.isPage(),
|
|
311
506
|
isInternalPage: this.isInternalPage(),
|
|
312
|
-
getAnchors:
|
|
313
|
-
getReferrers:
|
|
507
|
+
getAnchors: anchors,
|
|
508
|
+
getReferrers: referrers,
|
|
314
509
|
};
|
|
315
510
|
}
|
|
316
511
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result of probing an archive's `.lock` sibling without acquiring it.
|
|
3
|
+
*
|
|
4
|
+
* Used by read-only consumers (the viewer, MCP `open_archive`) that want
|
|
5
|
+
* to surface "a crawler is currently writing this archive" to the user
|
|
6
|
+
* without blocking or competing for the lock.
|
|
7
|
+
*/
|
|
8
|
+
export interface ArchiveLockHolder {
|
|
9
|
+
/** Path of the lock directory (`<tmpDir>.lock`) that was probed. */
|
|
10
|
+
readonly lockPath: string;
|
|
11
|
+
/** PID of the process whose lock is recorded in `pid.txt`. */
|
|
12
|
+
readonly pid: number;
|
|
13
|
+
/**
|
|
14
|
+
* Whether that PID is currently alive on the OS (signal-0 probe).
|
|
15
|
+
*
|
|
16
|
+
* **PID-recycling caveat**: this only proves *some* process owns that PID,
|
|
17
|
+
* not necessarily the original crawler. Callers should treat
|
|
18
|
+
* `{ alive: true }` as "likely a live crawler" rather than a hard
|
|
19
|
+
* guarantee.
|
|
20
|
+
*/
|
|
21
|
+
readonly alive: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Probe `<tmpDir>.lock/pid.txt` without acquiring the lock.
|
|
25
|
+
*
|
|
26
|
+
* Mirror of the alive-check inside {@link acquireArchiveLock}, exposed for
|
|
27
|
+
* read-only consumers so they can detect (and surface) a concurrent crawler
|
|
28
|
+
* without competing for the lock. Returns `null` when no lock directory is
|
|
29
|
+
* present or the pid file is missing/malformed — callers should treat that
|
|
30
|
+
* as "no detectable crawler" rather than as an error.
|
|
31
|
+
*
|
|
32
|
+
* Co-located with {@link acquireArchiveLock} so the writer side and the
|
|
33
|
+
* read-only probe stay in lockstep when the lock format evolves (e.g.
|
|
34
|
+
* adding a hostname/timestamp field).
|
|
35
|
+
* @param tmpDir - The archive's temporary working directory whose
|
|
36
|
+
* `${tmpDir}.lock` sibling will be probed.
|
|
37
|
+
* @returns Lock-holder metadata when a parseable lock exists, otherwise
|
|
38
|
+
* `null`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function peekArchiveLockHolder(tmpDir: string): ArchiveLockHolder | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Probe `<tmpDir>.lock/pid.txt` without acquiring the lock.
|
|
5
|
+
*
|
|
6
|
+
* Mirror of the alive-check inside {@link acquireArchiveLock}, exposed for
|
|
7
|
+
* read-only consumers so they can detect (and surface) a concurrent crawler
|
|
8
|
+
* without competing for the lock. Returns `null` when no lock directory is
|
|
9
|
+
* present or the pid file is missing/malformed — callers should treat that
|
|
10
|
+
* as "no detectable crawler" rather than as an error.
|
|
11
|
+
*
|
|
12
|
+
* Co-located with {@link acquireArchiveLock} so the writer side and the
|
|
13
|
+
* read-only probe stay in lockstep when the lock format evolves (e.g.
|
|
14
|
+
* adding a hostname/timestamp field).
|
|
15
|
+
* @param tmpDir - The archive's temporary working directory whose
|
|
16
|
+
* `${tmpDir}.lock` sibling will be probed.
|
|
17
|
+
* @returns Lock-holder metadata when a parseable lock exists, otherwise
|
|
18
|
+
* `null`.
|
|
19
|
+
*/
|
|
20
|
+
export function peekArchiveLockHolder(tmpDir) {
|
|
21
|
+
const lockPath = `${tmpDir}.lock`;
|
|
22
|
+
if (!existsSync(lockPath)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const pid = readPidFile(lockPath);
|
|
26
|
+
if (pid === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { lockPath, pid, alive: isProcessAlive(pid) };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the PID stored in the lock's `pid.txt`. Returns `null` for any
|
|
33
|
+
* missing/unparsable/non-positive content so the probe degrades gracefully
|
|
34
|
+
* on partially-written or corrupted lock dirs.
|
|
35
|
+
* @param lockPath - The lock directory path.
|
|
36
|
+
*/
|
|
37
|
+
function readPidFile(lockPath) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(path.join(lockPath, 'pid.txt'), 'utf8');
|
|
40
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
41
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check whether a PID is currently alive using a signal-0 probe.
|
|
49
|
+
*
|
|
50
|
+
* Treats `EPERM` as alive (the process exists but is owned by another
|
|
51
|
+
* user). Any other errno (notably `ESRCH`) means dead.
|
|
52
|
+
* @param pid - The process id to probe.
|
|
53
|
+
*/
|
|
54
|
+
function isProcessAlive(pid) {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return error.code === 'EPERM';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The destination of a redirect chain and the source URLs that point at it.
|
|
3
|
+
*/
|
|
4
|
+
export interface RedirectChain {
|
|
5
|
+
/** The final destination URL the chain lands on (normalised). */
|
|
6
|
+
destUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* The source URLs that redirect to {@link destUrl}: the originally requested
|
|
9
|
+
* URL followed by every intermediate hop. Empty when the page was not
|
|
10
|
+
* redirected at all.
|
|
11
|
+
*/
|
|
12
|
+
sources: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Splits a page's redirect chain into its final destination and the list of
|
|
16
|
+
* source URLs that point at it.
|
|
17
|
+
*
|
|
18
|
+
* The archive stores redirects as edges: every source URL carries a
|
|
19
|
+
* `redirectDestId` pointing at the destination page, and only the destination
|
|
20
|
+
* holds content. This helper applies the shared convention — the last entry of
|
|
21
|
+
* `redirectPaths` is the destination, while the originally requested URL plus
|
|
22
|
+
* every intermediate hop are the sources. When the page was not redirected,
|
|
23
|
+
* the destination is the page URL itself and there are no sources.
|
|
24
|
+
*
|
|
25
|
+
* Used by both {@link Database.updatePage} (which renders and stores the
|
|
26
|
+
* destination) and {@link Database.recordRedirect} (the #73 convergence
|
|
27
|
+
* optimisation, which records the edge for a destination already rendered
|
|
28
|
+
* elsewhere without touching its content).
|
|
29
|
+
* @param pageUrl - The originally requested URL (normalised, without hash/auth).
|
|
30
|
+
* @param redirectPaths - The redirect hop URLs captured during fetch, in order.
|
|
31
|
+
* @returns The destination URL and the source URLs pointing to it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveRedirectChain(pageUrl: string, redirectPaths: readonly string[]): RedirectChain;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a page's redirect chain into its final destination and the list of
|
|
3
|
+
* source URLs that point at it.
|
|
4
|
+
*
|
|
5
|
+
* The archive stores redirects as edges: every source URL carries a
|
|
6
|
+
* `redirectDestId` pointing at the destination page, and only the destination
|
|
7
|
+
* holds content. This helper applies the shared convention — the last entry of
|
|
8
|
+
* `redirectPaths` is the destination, while the originally requested URL plus
|
|
9
|
+
* every intermediate hop are the sources. When the page was not redirected,
|
|
10
|
+
* the destination is the page URL itself and there are no sources.
|
|
11
|
+
*
|
|
12
|
+
* Used by both {@link Database.updatePage} (which renders and stores the
|
|
13
|
+
* destination) and {@link Database.recordRedirect} (the #73 convergence
|
|
14
|
+
* optimisation, which records the edge for a destination already rendered
|
|
15
|
+
* elsewhere without touching its content).
|
|
16
|
+
* @param pageUrl - The originally requested URL (normalised, without hash/auth).
|
|
17
|
+
* @param redirectPaths - The redirect hop URLs captured during fetch, in order.
|
|
18
|
+
* @returns The destination URL and the source URLs pointing to it.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveRedirectChain(pageUrl, redirectPaths) {
|
|
21
|
+
if (redirectPaths.length === 0) {
|
|
22
|
+
return { destUrl: pageUrl, sources: [] };
|
|
23
|
+
}
|
|
24
|
+
const paths = [...redirectPaths];
|
|
25
|
+
const destUrl = paths.pop();
|
|
26
|
+
return { destUrl, sources: [pageUrl, ...paths] };
|
|
27
|
+
}
|