@nitpicker/crawler 0.9.0 → 0.12.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.
Files changed (148) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +262 -24
  4. package/lib/archive/archive.js +397 -86
  5. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  6. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  7. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  8. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  9. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  10. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  11. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  12. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  13. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  14. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  15. package/lib/archive/database.d.ts +411 -26
  16. package/lib/archive/database.js +1881 -540
  17. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  18. package/lib/archive/derive-lineage-from-parent.js +42 -0
  19. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  20. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  21. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  22. package/lib/archive/get-failed-page-messages.js +131 -0
  23. package/lib/archive/init-schema.d.ts +53 -2
  24. package/lib/archive/init-schema.js +399 -15
  25. package/lib/archive/is-inventory-source.d.ts +21 -0
  26. package/lib/archive/is-inventory-source.js +22 -0
  27. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  28. package/lib/archive/meta/assert-compatible-version.js +72 -0
  29. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  30. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  31. package/lib/archive/meta/compare-semver.d.ts +23 -0
  32. package/lib/archive/meta/compare-semver.js +51 -0
  33. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  34. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  35. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  36. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  37. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  38. package/lib/archive/meta/derive-meta-extras.js +23 -0
  39. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  40. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  41. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  42. package/lib/archive/meta/summarize-jsonld.js +29 -0
  43. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  44. package/lib/archive/meta/summarize-tags.js +33 -0
  45. package/lib/archive/meta/types.d.ts +207 -0
  46. package/lib/archive/meta/types.js +33 -0
  47. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  48. package/lib/archive/migrate-crawl-errors.js +38 -0
  49. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  50. package/lib/archive/migrate-html-blob-tables.js +53 -0
  51. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  52. package/lib/archive/migrate-inventory-runs.js +52 -0
  53. package/lib/archive/migrate-page-errors.d.ts +16 -0
  54. package/lib/archive/migrate-page-errors.js +35 -0
  55. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  56. package/lib/archive/migrate-pages-resources-source.js +46 -0
  57. package/lib/archive/page.d.ts +187 -49
  58. package/lib/archive/page.js +258 -63
  59. package/lib/archive/peek-archive-lock.d.ts +40 -0
  60. package/lib/archive/peek-archive-lock.js +62 -0
  61. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  62. package/lib/archive/resolve-redirect-chain.js +27 -0
  63. package/lib/archive/types.d.ts +168 -26
  64. package/lib/classify-error-kind.d.ts +19 -0
  65. package/lib/classify-error-kind.js +122 -0
  66. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  67. package/lib/crawler/build-js-redirect-edge.js +57 -0
  68. package/lib/crawler/build-redirect-event.d.ts +24 -0
  69. package/lib/crawler/build-redirect-event.js +28 -0
  70. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  71. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  72. package/lib/crawler/close-browser-safely.d.ts +64 -0
  73. package/lib/crawler/close-browser-safely.js +73 -0
  74. package/lib/crawler/crawler.d.ts +7 -2
  75. package/lib/crawler/crawler.js +919 -113
  76. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  77. package/lib/crawler/create-change-phase-handler.js +44 -0
  78. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  79. package/lib/crawler/derive-js-redirect-target.js +129 -0
  80. package/lib/crawler/derive-page-source.d.ts +23 -0
  81. package/lib/crawler/derive-page-source.js +28 -0
  82. package/lib/crawler/derive-resource-source.d.ts +33 -0
  83. package/lib/crawler/derive-resource-source.js +37 -0
  84. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  85. package/lib/crawler/dns-burned-host-cache.js +25 -0
  86. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  87. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  88. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  89. package/lib/crawler/drain-phase-errors.js +35 -0
  90. package/lib/crawler/fetch-destination.d.ts +12 -4
  91. package/lib/crawler/fetch-destination.js +132 -18
  92. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  93. package/lib/crawler/format-crawl-progress.js +14 -6
  94. package/lib/crawler/handle-browser-close.d.ts +29 -0
  95. package/lib/crawler/handle-browser-close.js +28 -0
  96. package/lib/crawler/is-html-content-type.d.ts +17 -0
  97. package/lib/crawler/is-html-content-type.js +19 -0
  98. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  99. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  100. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  101. package/lib/crawler/is-likely-html-url.js +65 -0
  102. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  103. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  104. package/lib/crawler/kill-process-tree.d.ts +94 -0
  105. package/lib/crawler/kill-process-tree.js +178 -0
  106. package/lib/crawler/link-list.d.ts +21 -1
  107. package/lib/crawler/link-list.js +25 -4
  108. package/lib/crawler/link-to-page-data.d.ts +13 -5
  109. package/lib/crawler/link-to-page-data.js +26 -5
  110. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  111. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  112. package/lib/crawler/normalize-content-type.d.ts +14 -0
  113. package/lib/crawler/normalize-content-type.js +20 -0
  114. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  115. package/lib/crawler/partition-urls-by-html.js +23 -0
  116. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  117. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  118. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  119. package/lib/crawler/preload-short-circuit-error.js +25 -0
  120. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  121. package/lib/crawler/redirect-dest-key.js +27 -0
  122. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  123. package/lib/crawler/resource-to-page-data.js +59 -0
  124. package/lib/crawler/should-burn-host.d.ts +78 -0
  125. package/lib/crawler/should-burn-host.js +61 -0
  126. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  127. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  128. package/lib/crawler/types.d.ts +229 -1
  129. package/lib/crawler-orchestrator.d.ts +105 -3
  130. package/lib/crawler-orchestrator.js +620 -20
  131. package/lib/crawler.d.ts +8 -2
  132. package/lib/crawler.js +6 -1
  133. package/lib/permanent-error-kinds.d.ts +43 -0
  134. package/lib/permanent-error-kinds.js +48 -0
  135. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  136. package/lib/resource-row-to-lookup-result.js +20 -0
  137. package/lib/types.d.ts +95 -1
  138. package/lib/utils/compute-file-sha256.d.ts +23 -0
  139. package/lib/utils/compute-file-sha256.js +55 -0
  140. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  141. package/lib/utils/error/emit-error-with-retry.js +44 -0
  142. package/lib/utils/error/emit-error.d.ts +39 -0
  143. package/lib/utils/error/emit-error.js +41 -0
  144. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  145. package/lib/utils/object/parse-response-headers.js +26 -0
  146. package/package.json +11 -11
  147. package/lib/utils/error/error-emitter.d.ts +0 -18
  148. package/lib/utils/error/error-emitter.js +0 -29
@@ -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 access to the page's metadata (title, status, SEO tags, etc.),
6
- * its relationships (anchors, referrers, redirects), and its HTML snapshot.
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 alternate URL from the `<link rel="alternate">` tag, or null if not present.
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 alternate() {
26
- return this.#raw.alternate;
95
+ get canonical() {
96
+ return this.#raw.canonical;
27
97
  }
28
98
  /**
29
- * The canonical URL from the `<link rel="canonical">` tag, or null if not present.
99
+ * The `<meta charset>` value, or null if not present.
30
100
  */
31
- get canonical() {
32
- return this.#raw.canonical;
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
- * The reason this page was skipped during crawling, or null if it was not skipped.
149
+ * Number of JSON-LD + SpeculationRules entries detected on this page
150
+ * (denormalised aggregate written at scrape time).
72
151
  */
73
- get skipReason() {
74
- return this.#raw.skipReason;
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
- * Whether the noarchive robots directive is set.
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 noarchive() {
92
- return !!this.#raw.noarchive;
171
+ get lastCrawledAt() {
172
+ return this.#raw.lastCrawledAt;
93
173
  }
94
174
  /**
95
- * Whether the nofollow robots directive is set.
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 nofollow() {
98
- return !!this.#raw.nofollow;
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
- * Whether the noindex robots directive is set.
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 noindex() {
104
- return !!this.#raw.noindex;
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
- * The Open Graph description (`og:description`), or null if not present.
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
- * The Open Graph image URL (`og:image`), or null if not present.
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
- * The Open Graph site name (`og:site_name`), or null if not present.
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
- * The Open Graph title (`og:title`), or null if not present.
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
- * The Open Graph type (`og:type`), or null if not present.
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
- * The Open Graph URL (`og:url`), or null if not present.
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
- * Returns an empty object if headers cannot be parsed.
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
- try {
148
- return JSON.parse(this.#raw.responseHeaders);
149
- }
150
- catch {
151
- return {};
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
- * The Twitter Card type (`twitter:card`), or null if not present.
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
- * Reads the HTML snapshot content of this page from the archive.
231
- * @returns The HTML content as a string, or null if no snapshot was saved.
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.html);
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
- return this.#archive.getReferrersOfPage(this.#raw.id);
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
- return this.#archive.getReferrersOfPage(this.#raw.id);
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
- const type = this.contentType || '';
274
- return type.toLowerCase().trim() === 'text/html';
275
- }
276
- /**
277
- * Serializes the page data to a plain JSON object,
278
- * including resolved anchors and referrers.
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
- lang: this.lang,
295
- description: this.description,
296
- keywords: this.keywords,
297
- noindex: this.noindex,
298
- nofollow: this.nofollow,
299
- noarchive: this.noarchive,
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: await this.getAnchors(),
313
- getReferrers: await this.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
+ }