@nitpicker/crawler 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/append-retry-failed-common-setup-phases.d.ts +15 -0
- package/lib/append-retry-failed-common-setup-phases.js +21 -0
- package/lib/append-setup-phases.d.ts +17 -0
- package/lib/append-setup-phases.js +22 -0
- package/lib/archive/archive-accessor.d.ts +31 -5
- package/lib/archive/archive-accessor.js +46 -24
- package/lib/archive/archive.d.ts +115 -37
- package/lib/archive/archive.js +118 -52
- package/lib/archive/cache/extract-archive-to-cache.d.ts +10 -1
- package/lib/archive/cache/extract-archive-to-cache.js +14 -4
- package/lib/archive/create-adjunct-tables.d.ts +12 -2
- package/lib/archive/create-adjunct-tables.js +91 -29
- package/lib/archive/create-entity-tables.d.ts +5 -1
- package/lib/archive/create-entity-tables.js +6 -1
- package/lib/archive/database.d.ts +39 -9
- package/lib/archive/database.js +63 -18
- package/lib/archive/db-ops/lifecycle/init.d.ts +14 -2
- package/lib/archive/db-ops/lifecycle/init.js +29 -11
- package/lib/archive/db-ops/meta/get-custom-elements-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-custom-elements-of-page.js +15 -0
- package/lib/archive/db-ops/meta/get-page-technologies-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-page-technologies-of-page.js +15 -0
- package/lib/archive/db-ops/meta/get-technology-signals-of-page.d.ts +11 -0
- package/lib/archive/db-ops/meta/get-technology-signals-of-page.js +15 -0
- package/lib/archive/db-ops/pages/order/set-url-order.d.ts +6 -1
- package/lib/archive/db-ops/pages/order/set-url-order.js +7 -1
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +6 -1
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +12 -3
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +6 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +10 -2
- package/lib/archive/db-ops/pages/write/insert-custom-elements.d.ts +27 -0
- package/lib/archive/db-ops/pages/write/insert-custom-elements.js +40 -0
- package/lib/archive/db-ops/pages/write/insert-page.d.ts +4 -4
- package/lib/archive/db-ops/pages/write/insert-page.js +3 -2
- package/lib/archive/db-ops/pages/write/insert-technologies.d.ts +25 -0
- package/lib/archive/db-ops/pages/write/insert-technologies.js +45 -0
- package/lib/archive/db-ops/pages/write/update-page.d.ts +4 -1
- package/lib/archive/db-ops/pages/write/update-page.js +38 -16
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +12 -1
- package/lib/archive/db-ops/resources/get-resource-url-list.js +43 -5
- package/lib/archive/filesystem/copy-file-with-progress.d.ts +20 -0
- package/lib/archive/filesystem/copy-file-with-progress.js +34 -0
- package/lib/archive/filesystem/parse-pax-path.d.ts +18 -0
- package/lib/archive/filesystem/parse-pax-path.js +47 -0
- package/lib/archive/filesystem/parse-tar-size-field.d.ts +13 -0
- package/lib/archive/filesystem/parse-tar-size-field.js +40 -0
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +10 -3
- package/lib/archive/filesystem/peek-tar-top-dir.js +166 -16
- package/lib/archive/filesystem/tar.d.ts +13 -1
- package/lib/archive/filesystem/tar.js +63 -7
- package/lib/archive/filesystem/untar.d.ts +13 -0
- package/lib/archive/filesystem/untar.js +68 -8
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +6 -4
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +13 -2
- package/lib/archive/meta/compute-main-contents-denormalized.js +15 -2
- package/lib/archive/meta/technologies/combine-technology-confidence.d.ts +46 -0
- package/lib/archive/meta/technologies/combine-technology-confidence.js +93 -0
- package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.d.ts +45 -0
- package/lib/archive/meta/technologies/convert-legacy-page-tags-to-inserts.js +53 -0
- package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.d.ts +33 -0
- package/lib/archive/meta/technologies/convert-tag-row-to-wappalyzer-signal.js +46 -0
- package/lib/archive/meta/technologies/extract-technologies-for-archive.d.ts +30 -0
- package/lib/archive/meta/technologies/extract-technologies-for-archive.js +27 -0
- package/lib/archive/meta/technologies/match-structural-technology-signals.d.ts +13 -0
- package/lib/archive/meta/technologies/match-structural-technology-signals.js +44 -0
- package/lib/archive/meta/technologies/normalize-wappalyzer-entries.d.ts +25 -0
- package/lib/archive/meta/technologies/normalize-wappalyzer-entries.js +35 -0
- package/lib/archive/meta/technologies/technology-signal-definitions.d.ts +43 -0
- package/lib/archive/meta/technologies/technology-signal-definitions.js +193 -0
- package/lib/archive/meta/technologies/types.d.ts +68 -0
- package/lib/archive/meta/technologies/types.js +13 -0
- package/lib/archive/meta/types.d.ts +97 -30
- package/lib/archive/migrate-content-items-alias-of-id.d.ts +6 -1
- package/lib/archive/migrate-content-items-alias-of-id.js +14 -3
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +6 -1
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +14 -3
- package/lib/archive/migrate-info-main-content-selector.d.ts +6 -1
- package/lib/archive/migrate-info-main-content-selector.js +14 -3
- package/lib/archive/migrate-info-roots.d.ts +6 -1
- package/lib/archive/migrate-info-roots.js +14 -3
- package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +6 -1
- package/lib/archive/migrate-inventory-runs-exclude-skipped.js +14 -3
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +6 -1
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +14 -3
- package/lib/archive/migrate-main-contents-columns.d.ts +6 -1
- package/lib/archive/migrate-main-contents-columns.js +14 -3
- package/lib/archive/migrate-page-meta-body-hash.d.ts +6 -1
- package/lib/archive/migrate-page-meta-body-hash.js +14 -3
- package/lib/archive/migrate-page-meta-console-error-count.d.ts +6 -1
- package/lib/archive/migrate-page-meta-console-error-count.js +14 -3
- package/lib/archive/migrate-page-meta-custom-element-count.d.ts +33 -0
- package/lib/archive/migrate-page-meta-custom-element-count.js +51 -0
- package/lib/archive/migrate-page-tags-to-page-technologies.d.ts +39 -0
- package/lib/archive/migrate-page-tags-to-page-technologies.js +79 -0
- package/lib/archive/page.d.ts +37 -13
- package/lib/archive/page.js +45 -17
- package/lib/archive/retarget-legacy-fk-tables.d.ts +27 -14
- package/lib/archive/retarget-legacy-fk-tables.js +61 -15
- package/lib/archive/types.d.ts +18 -0
- package/lib/crawler/assert-puppeteer-shared-with-beholder.d.ts +24 -0
- package/lib/crawler/assert-puppeteer-shared-with-beholder.js +36 -0
- package/lib/crawler/capture-custom-elements.d.ts +33 -0
- package/lib/crawler/capture-custom-elements.js +39 -0
- package/lib/crawler/close-browser-safely.d.ts +3 -3
- package/lib/crawler/close-browser-safely.js +8 -17
- package/lib/crawler/collect-custom-elements.d.ts +33 -0
- package/lib/crawler/collect-custom-elements.js +110 -0
- package/lib/crawler/crawler.js +101 -46
- package/lib/crawler/fetch-destination.js +6 -13
- package/lib/crawler/find-package-dir.d.ts +24 -0
- package/lib/crawler/find-package-dir.js +39 -0
- package/lib/crawler/scan-js-resource-for-license-comment.d.ts +32 -0
- package/lib/crawler/scan-js-resource-for-license-comment.js +140 -0
- package/lib/crawler/scan-js-resources-for-technology-signals.d.ts +58 -0
- package/lib/crawler/scan-js-resources-for-technology-signals.js +196 -0
- package/lib/crawler/types.d.ts +23 -0
- package/lib/crawler-orchestrator.d.ts +53 -9
- package/lib/crawler-orchestrator.js +223 -46
- package/lib/crawler.d.ts +11 -1
- package/lib/crawler.js +9 -0
- package/lib/inventory-setup-phases.d.ts +32 -0
- package/lib/inventory-setup-phases.js +45 -0
- package/lib/resume-setup-phases.d.ts +16 -0
- package/lib/resume-setup-phases.js +24 -0
- package/lib/retry-failed-setup-phases.d.ts +19 -0
- package/lib/retry-failed-setup-phases.js +24 -0
- package/lib/setup-recovery-phase-labels.d.ts +21 -0
- package/lib/setup-recovery-phase-labels.js +24 -0
- package/lib/types.d.ts +145 -0
- package/package.json +11 -8
- package/lib/archive/db-ops/meta/get-tags-of-page.d.ts +0 -12
- package/lib/archive/db-ops/meta/get-tags-of-page.js +0 -28
- package/lib/archive/db-ops/pages/write/insert-tags.d.ts +0 -16
- package/lib/archive/db-ops/pages/write/insert-tags.js +0 -34
- package/lib/archive/meta/summarize-tags.d.ts +0 -16
- package/lib/archive/meta/summarize-tags.js +0 -33
|
@@ -62,9 +62,14 @@ import { listNetworkOutages } from '../../outages/list-network-outages.js';
|
|
|
62
62
|
* SELECT and UPDATE/DELETE statements are chunked to stay below SQLite's
|
|
63
63
|
* `SQLITE_LIMIT_VARIABLE_NUMBER`.
|
|
64
64
|
* @param knex - Knex query builder connected to the archive DB.
|
|
65
|
+
* @param onProgress - Called after each chunk's DELETE/UPDATE statements
|
|
66
|
+
* complete, with the pages processed so far and the total to reset (issue
|
|
67
|
+
* #294: a large `--retry-failed` can reset thousands of pages across 13
|
|
68
|
+
* tables, running for seconds to minutes with no other signal it hasn't
|
|
69
|
+
* hung). Omit for no reporting (the default; e.g. tests).
|
|
65
70
|
* @returns The URLs of the pages that were reset to pending.
|
|
66
71
|
*/
|
|
67
|
-
export async function resetFailedPages(knex) {
|
|
72
|
+
export async function resetFailedPages(knex, onProgress) {
|
|
68
73
|
const candidates = await knex('content_items')
|
|
69
74
|
.join('url_refs', 'content_items.url_id', 'url_refs.id')
|
|
70
75
|
.select('content_items.id as id', 'url_refs.url as url')
|
|
@@ -146,7 +151,8 @@ export async function resetFailedPages(knex) {
|
|
|
146
151
|
await knex('resource_ref_edges').whereIn('page_id', chunk).delete();
|
|
147
152
|
await knex('page_errors').whereIn('pageId', chunk).delete();
|
|
148
153
|
await knex('page_html_ref').whereIn('page_id', chunk).delete();
|
|
149
|
-
await knex('
|
|
154
|
+
await knex('technology_signals').whereIn('pageId', chunk).delete();
|
|
155
|
+
await knex('page_technologies').whereIn('pageId', chunk).delete();
|
|
150
156
|
await knex('page_jsonld').whereIn('pageId', chunk).delete();
|
|
151
157
|
await knex('page_main_content_headings').whereIn('pageId', chunk).delete();
|
|
152
158
|
await knex('page_main_content_images').whereIn('pageId', chunk).delete();
|
|
@@ -156,6 +162,8 @@ export async function resetFailedPages(knex) {
|
|
|
156
162
|
await knex('page_main_content_videos').whereIn('pageId', chunk).delete();
|
|
157
163
|
await knex('page_main_content_audios').whereIn('pageId', chunk).delete();
|
|
158
164
|
await knex('page_main_content_canvases').whereIn('pageId', chunk).delete();
|
|
165
|
+
await knex('page_main_content_custom_elements').whereIn('pageId', chunk).delete();
|
|
166
|
+
onProgress?.(Math.min(i + chunkSize, ids.length), ids.length);
|
|
159
167
|
}
|
|
160
168
|
dbLog('Reset %d failed pages back to pending', urls.length);
|
|
161
169
|
return urls;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MainContentCustomElementCandidate } from '../../../../crawler/types.js';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
/**
|
|
4
|
+
* Replaces the page's `page_main_content_custom_elements` rows with the
|
|
5
|
+
* freshly captured set. Called inside `updatePage`'s transaction, gated on
|
|
6
|
+
* `page.mainContentCustomElements !== undefined` by the caller — the caller
|
|
7
|
+
* must NOT call this with a defaulted `[]` when the capture itself failed
|
|
8
|
+
* (`undefined`), since this function cannot tell "capture found nothing"
|
|
9
|
+
* apart from "capture didn't run" once it receives a plain array.
|
|
10
|
+
*
|
|
11
|
+
* Unlike `insertButtons` / `insertHeadings` etc. (whose empty-array case is
|
|
12
|
+
* genuinely ambiguous — beholder's `MainContentsData` gives no way to tell
|
|
13
|
+
* "successfully found zero" from "degraded scrape" — this function always
|
|
14
|
+
* deletes existing rows once called, including for an empty array: because
|
|
15
|
+
* the caller already resolved that ambiguity via the `!== undefined` gate,
|
|
16
|
+
* an empty array here unambiguously means "capture succeeded, found none,"
|
|
17
|
+
* so stale rows from a previous crawl must be cleared.
|
|
18
|
+
*
|
|
19
|
+
* Unlike `insertButtons`, the input is nitpicker's own
|
|
20
|
+
* `MainContentCustomElementCandidate[]` (from `capture-custom-elements.ts`),
|
|
21
|
+
* not a slice of beholder's `MainContentsData` — there is no such category
|
|
22
|
+
* in `MainContentsData`.
|
|
23
|
+
* @param pageId - The owning `content_items.id`.
|
|
24
|
+
* @param customElements - The captured custom elements, in document order.
|
|
25
|
+
* @param trx - The active transaction.
|
|
26
|
+
*/
|
|
27
|
+
export declare function insertCustomElements(pageId: number, customElements: readonly MainContentCustomElementCandidate[], trx: Knex.Transaction): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { eachSplitted } from '../../../../utils/array/each-splitted.js';
|
|
2
|
+
/**
|
|
3
|
+
* Replaces the page's `page_main_content_custom_elements` rows with the
|
|
4
|
+
* freshly captured set. Called inside `updatePage`'s transaction, gated on
|
|
5
|
+
* `page.mainContentCustomElements !== undefined` by the caller — the caller
|
|
6
|
+
* must NOT call this with a defaulted `[]` when the capture itself failed
|
|
7
|
+
* (`undefined`), since this function cannot tell "capture found nothing"
|
|
8
|
+
* apart from "capture didn't run" once it receives a plain array.
|
|
9
|
+
*
|
|
10
|
+
* Unlike `insertButtons` / `insertHeadings` etc. (whose empty-array case is
|
|
11
|
+
* genuinely ambiguous — beholder's `MainContentsData` gives no way to tell
|
|
12
|
+
* "successfully found zero" from "degraded scrape" — this function always
|
|
13
|
+
* deletes existing rows once called, including for an empty array: because
|
|
14
|
+
* the caller already resolved that ambiguity via the `!== undefined` gate,
|
|
15
|
+
* an empty array here unambiguously means "capture succeeded, found none,"
|
|
16
|
+
* so stale rows from a previous crawl must be cleared.
|
|
17
|
+
*
|
|
18
|
+
* Unlike `insertButtons`, the input is nitpicker's own
|
|
19
|
+
* `MainContentCustomElementCandidate[]` (from `capture-custom-elements.ts`),
|
|
20
|
+
* not a slice of beholder's `MainContentsData` — there is no such category
|
|
21
|
+
* in `MainContentsData`.
|
|
22
|
+
* @param pageId - The owning `content_items.id`.
|
|
23
|
+
* @param customElements - The captured custom elements, in document order.
|
|
24
|
+
* @param trx - The active transaction.
|
|
25
|
+
*/
|
|
26
|
+
export async function insertCustomElements(pageId, customElements, trx) {
|
|
27
|
+
await trx('page_main_content_custom_elements').where('pageId', pageId).delete();
|
|
28
|
+
if (customElements.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const rows = customElements.map((el, order) => ({
|
|
31
|
+
pageId,
|
|
32
|
+
order,
|
|
33
|
+
nodeName: el.nodeName,
|
|
34
|
+
elementId: el.elementId,
|
|
35
|
+
classList: JSON.stringify(el.classList),
|
|
36
|
+
}));
|
|
37
|
+
await eachSplitted(rows, 100, async (chunk) => {
|
|
38
|
+
await trx('page_main_content_custom_elements').insert(chunk);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { PageSource } from '../../../types.js';
|
|
1
|
+
import type { PageDataWithDomPaths, PageSource } from '../../../types.js';
|
|
3
2
|
import type { WriteRefCaches } from '../../_shared/types.js';
|
|
4
3
|
import type { Knex } from 'knex';
|
|
5
4
|
/**
|
|
@@ -46,11 +45,12 @@ import type { Knex } from 'knex';
|
|
|
46
45
|
* @param knex - Knex query builder connected to the archive DB. Used as the
|
|
47
46
|
* fallback when `trx` is not provided.
|
|
48
47
|
* @param caches - The connection's write-side id caches.
|
|
49
|
-
* @param page - The scraped page data
|
|
48
|
+
* @param page - The scraped page data, optionally carrying nitpicker's own
|
|
49
|
+
* in-browser custom-element capture (see {@link PageDataWithDomPaths}).
|
|
50
50
|
* @param isTarget - Whether this page is a crawl target.
|
|
51
51
|
* @param trx - Optional transaction all statements run through.
|
|
52
52
|
* @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
|
|
53
53
|
* except through the priority lattice described above.
|
|
54
54
|
* @returns The `content_items.id` of the inserted or updated row.
|
|
55
55
|
*/
|
|
56
|
-
export declare function insertPage(knex: Knex, caches: WriteRefCaches, page:
|
|
56
|
+
export declare function insertPage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, isTarget: boolean, trx?: Knex.Transaction, source?: PageSource): Promise<number>;
|
|
@@ -55,7 +55,8 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
|
|
|
55
55
|
* @param knex - Knex query builder connected to the archive DB. Used as the
|
|
56
56
|
* fallback when `trx` is not provided.
|
|
57
57
|
* @param caches - The connection's write-side id caches.
|
|
58
|
-
* @param page - The scraped page data
|
|
58
|
+
* @param page - The scraped page data, optionally carrying nitpicker's own
|
|
59
|
+
* in-browser custom-element capture (see {@link PageDataWithDomPaths}).
|
|
59
60
|
* @param isTarget - Whether this page is a crawl target.
|
|
60
61
|
* @param trx - Optional transaction all statements run through.
|
|
61
62
|
* @param source - Inventory provenance for the INSERT path. Ignored on UPDATE
|
|
@@ -67,7 +68,7 @@ export async function insertPage(knex, caches, page, isTarget, trx, source) {
|
|
|
67
68
|
const pageId = await resolveContentItemId(qb, caches, page.url.withoutHashAndAuth, undefined, source);
|
|
68
69
|
const flat = deriveFlatFromMeta(page.meta, page.url.href);
|
|
69
70
|
const denorm = computePageDenormalized(page.meta);
|
|
70
|
-
const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight);
|
|
71
|
+
const mainContentsDenorm = computeMainContentsDenormalized(page.mainContents, page.scrollHeight, page.mainContentCustomElements?.length);
|
|
71
72
|
const extras = deriveMetaExtras(page.meta);
|
|
72
73
|
const now = Date.now();
|
|
73
74
|
// Canonicalize so the stored dictionary value matches the exact-string
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PageData } from '../../../../utils/types/types.js';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
/**
|
|
4
|
+
* Replaces the page's `technology_signals` + `page_technologies` rows with
|
|
5
|
+
* the freshly extracted set. Called inside `updatePage`'s transaction
|
|
6
|
+
* unconditionally — the `wappalyzer` signal source draws on `<script src>` /
|
|
7
|
+
* `<iframe src>` / window globals / response headers, not the HTML body, so
|
|
8
|
+
* external pages that skip rendering still contribute technology signals
|
|
9
|
+
* (structural signals naturally find nothing against an empty `html`
|
|
10
|
+
* string, which is exactly what a skipped render leaves it as).
|
|
11
|
+
*
|
|
12
|
+
* Direct replacement for `insertTags` in the live crawl write path (that
|
|
13
|
+
* file is untouched — `scripts/migrate-to-0.10.mjs` still depends on it).
|
|
14
|
+
*
|
|
15
|
+
* Same empty-guard as `insertTags` / `insertJsonLd`: an empty array does
|
|
16
|
+
* not wipe prior rows on a degraded re-scrape. Both tables are always
|
|
17
|
+
* written together in this one call — see ARCHITECTURE.md's invariant
|
|
18
|
+
* that `technology_signals` and `page_technologies` are never updated
|
|
19
|
+
* independently.
|
|
20
|
+
* @param pageId
|
|
21
|
+
* @param html - The page's raw HTML string (empty for external/metadata-only scrapes).
|
|
22
|
+
* @param meta
|
|
23
|
+
* @param trx
|
|
24
|
+
*/
|
|
25
|
+
export declare function insertTechnologies(pageId: number, html: string, meta: PageData['meta'], trx: Knex.Transaction): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { eachSplitted } from '../../../../utils/array/each-splitted.js';
|
|
2
|
+
import { extractTechnologiesForArchive } from '../../../meta/technologies/extract-technologies-for-archive.js';
|
|
3
|
+
/**
|
|
4
|
+
* Replaces the page's `technology_signals` + `page_technologies` rows with
|
|
5
|
+
* the freshly extracted set. Called inside `updatePage`'s transaction
|
|
6
|
+
* unconditionally — the `wappalyzer` signal source draws on `<script src>` /
|
|
7
|
+
* `<iframe src>` / window globals / response headers, not the HTML body, so
|
|
8
|
+
* external pages that skip rendering still contribute technology signals
|
|
9
|
+
* (structural signals naturally find nothing against an empty `html`
|
|
10
|
+
* string, which is exactly what a skipped render leaves it as).
|
|
11
|
+
*
|
|
12
|
+
* Direct replacement for `insertTags` in the live crawl write path (that
|
|
13
|
+
* file is untouched — `scripts/migrate-to-0.10.mjs` still depends on it).
|
|
14
|
+
*
|
|
15
|
+
* Same empty-guard as `insertTags` / `insertJsonLd`: an empty array does
|
|
16
|
+
* not wipe prior rows on a degraded re-scrape. Both tables are always
|
|
17
|
+
* written together in this one call — see ARCHITECTURE.md's invariant
|
|
18
|
+
* that `technology_signals` and `page_technologies` are never updated
|
|
19
|
+
* independently.
|
|
20
|
+
* @param pageId
|
|
21
|
+
* @param html - The page's raw HTML string (empty for external/metadata-only scrapes).
|
|
22
|
+
* @param meta
|
|
23
|
+
* @param trx
|
|
24
|
+
*/
|
|
25
|
+
export async function insertTechnologies(pageId, html, meta, trx) {
|
|
26
|
+
const { signals, technologies } = extractTechnologiesForArchive(html, meta);
|
|
27
|
+
if (signals.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
await trx('technology_signals').where('pageId', pageId).delete();
|
|
30
|
+
await trx('page_technologies').where('pageId', pageId).delete();
|
|
31
|
+
await eachSplitted(signals, 100, async (chunk) => {
|
|
32
|
+
await trx('technology_signals').insert(chunk.map((s) => ({
|
|
33
|
+
pageId,
|
|
34
|
+
technology: s.technology,
|
|
35
|
+
signalType: s.signalType,
|
|
36
|
+
evidence: s.evidence,
|
|
37
|
+
weight: s.weight,
|
|
38
|
+
})));
|
|
39
|
+
});
|
|
40
|
+
if (technologies.length > 0) {
|
|
41
|
+
await eachSplitted(technologies, 100, async (chunk) => {
|
|
42
|
+
await trx('page_technologies').insert(chunk.map((t) => ({ pageId, ...t })));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -24,6 +24,9 @@ import type { Knex } from 'knex';
|
|
|
24
24
|
* inserted. Existing rows keep their original `source` (this is why a
|
|
25
25
|
* second `crawl --inventory` does not "demote" an `'inventory-seed'` row
|
|
26
26
|
* that was discovered earlier).
|
|
27
|
+
* @param bodyHash - Precomputed body hash for the page's HTML (see
|
|
28
|
+
* `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
|
|
29
|
+
* computing it from the HTML instead.
|
|
27
30
|
* @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
|
|
28
31
|
*/
|
|
29
|
-
export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource): Promise<number>;
|
|
32
|
+
export declare function updatePage(knex: Knex, caches: WriteRefCaches, page: PageDataWithDomPaths, writeHtml: boolean, isTarget: boolean, source?: PageSource, bodyHash?: Buffer | null): Promise<number>;
|
|
@@ -13,13 +13,14 @@ import { resolveUrlOrBlob } from '../../_shared/resolve-url-or-blob.js';
|
|
|
13
13
|
import { insertAudios } from './insert-audios.js';
|
|
14
14
|
import { insertButtons } from './insert-buttons.js';
|
|
15
15
|
import { insertCanvases } from './insert-canvases.js';
|
|
16
|
+
import { insertCustomElements } from './insert-custom-elements.js';
|
|
16
17
|
import { insertHeadings } from './insert-headings.js';
|
|
17
18
|
import { insertIframes } from './insert-iframes.js';
|
|
18
19
|
import { insertJsonLd } from './insert-jsonld.js';
|
|
19
20
|
import { insertMainContentImages } from './insert-main-content-images.js';
|
|
20
21
|
import { insertMainContentTables } from './insert-main-content-tables.js';
|
|
21
22
|
import { insertPage } from './insert-page.js';
|
|
22
|
-
import {
|
|
23
|
+
import { insertTechnologies } from './insert-technologies.js';
|
|
23
24
|
import { insertVideos } from './insert-videos.js';
|
|
24
25
|
import { linkRedirectSources } from './link-redirect-sources.js';
|
|
25
26
|
import { writePageHtmlBlob } from './write-page-html-blob.js';
|
|
@@ -46,9 +47,12 @@ import { writePageHtmlBlob } from './write-page-html-blob.js';
|
|
|
46
47
|
* inserted. Existing rows keep their original `source` (this is why a
|
|
47
48
|
* second `crawl --inventory` does not "demote" an `'inventory-seed'` row
|
|
48
49
|
* that was discovered earlier).
|
|
50
|
+
* @param bodyHash - Precomputed body hash for the page's HTML (see
|
|
51
|
+
* `CrawlerEventTypes.page.bodyHash`). `undefined`/`null` falls back to
|
|
52
|
+
* computing it from the HTML instead.
|
|
49
53
|
* @returns The database `pageId` (`content_items.id`) of the inserted or updated row.
|
|
50
54
|
*/
|
|
51
|
-
export async function updatePage(knex, caches, page, writeHtml, isTarget, source) {
|
|
55
|
+
export async function updatePage(knex, caches, page, writeHtml, isTarget, source, bodyHash) {
|
|
52
56
|
const { destUrl, sources } = resolveRedirectChain(page.url.withoutHashAndAuth, page.redirectPaths);
|
|
53
57
|
const destUrlObject = parseUrl(destUrl);
|
|
54
58
|
if (!destUrlObject) {
|
|
@@ -56,7 +60,7 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
|
|
|
56
60
|
}
|
|
57
61
|
try {
|
|
58
62
|
return await knex.transaction(async (trx) => {
|
|
59
|
-
return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source);
|
|
63
|
+
return await updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash);
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
catch (error) {
|
|
@@ -83,21 +87,24 @@ export async function updatePage(knex, caches, page, writeHtml, isTarget, source
|
|
|
83
87
|
* @param writeHtml - See {@link updatePage}.
|
|
84
88
|
* @param isTarget - See {@link updatePage}.
|
|
85
89
|
* @param source - See {@link updatePage}.
|
|
90
|
+
* @param bodyHash - See {@link updatePage}.
|
|
86
91
|
* @returns The `content_items.id` of the inserted or updated row.
|
|
87
92
|
*/
|
|
88
|
-
async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source) {
|
|
93
|
+
async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, sources, writeHtml, isTarget, source, bodyHash) {
|
|
89
94
|
const pageId = await insertPage(knex, caches, {
|
|
90
95
|
...page,
|
|
91
96
|
url: destUrlObject,
|
|
92
97
|
}, isTarget, trx, source);
|
|
93
|
-
// Wappalyzer
|
|
94
|
-
// `<script src>` / `<iframe src>` / window
|
|
95
|
-
// headers
|
|
96
|
-
// metadata-only
|
|
97
|
-
//
|
|
98
|
-
// scrape
|
|
99
|
-
// below
|
|
100
|
-
|
|
98
|
+
// Technology detection combines a Wappalyzer signal source (HTML-body
|
|
99
|
+
// independent — relies on `<script src>` / `<iframe src>` / window
|
|
100
|
+
// globals / response headers, so it fires for every page including
|
|
101
|
+
// external / metadata-only) with structural signals that scan
|
|
102
|
+
// `page.html` (naturally a no-op against the empty string an
|
|
103
|
+
// external/metadata-only scrape leaves it as). Both run unconditionally,
|
|
104
|
+
// unlike JSON-LD below, which lives inside the rendered HTML body and
|
|
105
|
+
// is only written when there is HTML to scrape — see the same
|
|
106
|
+
// `writeHtml` gate as `writePageHtmlBlob` below.
|
|
107
|
+
await insertTechnologies(pageId, page.html, page.meta, trx);
|
|
101
108
|
if (writeHtml) {
|
|
102
109
|
await insertJsonLd(pageId, page.meta, trx);
|
|
103
110
|
}
|
|
@@ -113,6 +120,16 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
|
|
|
113
120
|
await insertVideos(pageId, page.mainContents, trx);
|
|
114
121
|
await insertAudios(pageId, page.mainContents, trx);
|
|
115
122
|
await insertCanvases(pageId, page.mainContents, trx);
|
|
123
|
+
// Unlike the eight calls above, the source here is nitpicker's own
|
|
124
|
+
// `capture-custom-elements.ts` capture, not `page.mainContents` — see
|
|
125
|
+
// `insert-custom-elements.ts`. That capture can fail independently
|
|
126
|
+
// of `page.mainContents` (a separate `page.evaluate()` call), so
|
|
127
|
+
// `undefined` (capture failed) must skip the call entirely — passing
|
|
128
|
+
// a defaulted `[]` would look identical to "found zero" and, per
|
|
129
|
+
// `insertCustomElements`'s docs, wipe stale-but-still-good rows.
|
|
130
|
+
if (page.mainContentCustomElements !== undefined) {
|
|
131
|
+
await insertCustomElements(pageId, page.mainContentCustomElements, trx);
|
|
132
|
+
}
|
|
116
133
|
}
|
|
117
134
|
// Chain lineage propagates FROM the originating URL
|
|
118
135
|
// (`page.url`), NOT from the destination. See the matching
|
|
@@ -142,12 +159,17 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
|
|
|
142
159
|
// content check alone expresses the intent without a redundant term.
|
|
143
160
|
if (writeHtml && page.html.length > 0) {
|
|
144
161
|
await writePageHtmlBlob(pageId, page.html, trx);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
162
|
+
// Prefer the caller's precomputed hash (the crawler already hashed
|
|
163
|
+
// this exact `page.html` once, before this write path ever runs) —
|
|
164
|
+
// falling back to computing it here only when the caller has not
|
|
165
|
+
// (direct callers such as tests, or a future write path that has not
|
|
166
|
+
// adopted the precompute). Either way this stays derived from the
|
|
167
|
+
// same `page.html` written above, in the same transaction, so
|
|
168
|
+
// `page_meta.body_hash` never observably lags the snapshot it was
|
|
169
|
+
// derived from.
|
|
148
170
|
await trx('page_meta')
|
|
149
171
|
.where('page_id', pageId)
|
|
150
|
-
.update({ body_hash: computeBodyHash(page.html) });
|
|
172
|
+
.update({ body_hash: bodyHash ?? computeBodyHash(page.html) });
|
|
151
173
|
}
|
|
152
174
|
else if (writeHtml &&
|
|
153
175
|
page.contentType !== null &&
|
|
@@ -3,7 +3,18 @@ import type { Knex } from 'knex';
|
|
|
3
3
|
* Retrieves a flat list of all resource URLs from the `resource_items`
|
|
4
4
|
* table. URL text is normalised into `url_refs`, so the read joins the
|
|
5
5
|
* two tables and returns the resolved strings.
|
|
6
|
+
*
|
|
7
|
+
* Read in `resource_items.id`-keyset chunks rather than a single SELECT
|
|
8
|
+
* (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
|
|
9
|
+
* hundreds of thousands) this was one unbounded, multi-second-to-minutes
|
|
10
|
+
* query with no way to report progress mid-scan. The accumulated result is
|
|
11
|
+
* identical to the previous single-SELECT read — chunking exists purely to
|
|
12
|
+
* make the scan observable, not to bound memory (the flat URL list is
|
|
13
|
+
* already fully materialised for the caller either way).
|
|
6
14
|
* @param knex - Knex query builder connected to the archive DB.
|
|
15
|
+
* @param onProgress - Called after each chunk with the `resource_items.id`
|
|
16
|
+
* scanned up to so far and the max id. Omit for no reporting (the
|
|
17
|
+
* default; e.g. tests).
|
|
7
18
|
* @returns An array of resource URL strings.
|
|
8
19
|
*/
|
|
9
|
-
export declare function getResourceUrlList(knex: Knex): Promise<string[]>;
|
|
20
|
+
export declare function getResourceUrlList(knex: Knex, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<string[]>;
|
|
@@ -1,13 +1,51 @@
|
|
|
1
|
+
/** Rows read per `resource_items.id` keyset chunk. */
|
|
2
|
+
const READ_CHUNK_SIZE = 2000;
|
|
1
3
|
/**
|
|
2
4
|
* Retrieves a flat list of all resource URLs from the `resource_items`
|
|
3
5
|
* table. URL text is normalised into `url_refs`, so the read joins the
|
|
4
6
|
* two tables and returns the resolved strings.
|
|
7
|
+
*
|
|
8
|
+
* Read in `resource_items.id`-keyset chunks rather than a single SELECT
|
|
9
|
+
* (issue #294): on a resource-heavy archive (images/JS/CSS in the tens or
|
|
10
|
+
* hundreds of thousands) this was one unbounded, multi-second-to-minutes
|
|
11
|
+
* query with no way to report progress mid-scan. The accumulated result is
|
|
12
|
+
* identical to the previous single-SELECT read — chunking exists purely to
|
|
13
|
+
* make the scan observable, not to bound memory (the flat URL list is
|
|
14
|
+
* already fully materialised for the caller either way).
|
|
5
15
|
* @param knex - Knex query builder connected to the archive DB.
|
|
16
|
+
* @param onProgress - Called after each chunk with the `resource_items.id`
|
|
17
|
+
* scanned up to so far and the max id. Omit for no reporting (the
|
|
18
|
+
* default; e.g. tests).
|
|
6
19
|
* @returns An array of resource URL strings.
|
|
7
20
|
*/
|
|
8
|
-
export async function getResourceUrlList(knex) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
export async function getResourceUrlList(knex, onProgress) {
|
|
22
|
+
// MAX() over the keyset column is an O(1) index-tail read; only fetched
|
|
23
|
+
// when someone is listening.
|
|
24
|
+
let maxId = 0;
|
|
25
|
+
if (onProgress) {
|
|
26
|
+
const [maxRow] = await knex('resource_items').max({
|
|
27
|
+
max: 'id',
|
|
28
|
+
});
|
|
29
|
+
maxId = maxRow?.max ?? 0;
|
|
30
|
+
}
|
|
31
|
+
const urls = [];
|
|
32
|
+
let lastId = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const rows = (await knex('resource_items')
|
|
35
|
+
.join('url_refs', 'url_refs.id', 'resource_items.url_id')
|
|
36
|
+
.where('resource_items.id', '>', lastId)
|
|
37
|
+
.orderBy('resource_items.id', 'asc')
|
|
38
|
+
.limit(READ_CHUNK_SIZE)
|
|
39
|
+
.select('resource_items.id as id', 'url_refs.url as url'));
|
|
40
|
+
if (rows.length === 0) {
|
|
41
|
+
onProgress?.(maxId, maxId);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
lastId = rows.at(-1).id;
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
urls.push(row.url);
|
|
47
|
+
}
|
|
48
|
+
onProgress?.(Math.min(lastId, maxId), maxId);
|
|
49
|
+
}
|
|
50
|
+
return urls;
|
|
13
51
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies a file while reporting byte progress — `fs.copyFile` offers no
|
|
3
|
+
* observability, and a `.nitpicker` archive's `.bak` safety copy (taken
|
|
4
|
+
* before every mutating `viewer-build`/`crawl --append`/`--inventory`/
|
|
5
|
+
* `--retry-failed` run, and copied back on failure) can be 15 GB+, running
|
|
6
|
+
* for tens of seconds with nothing on screen (issue #294). Byte granularity
|
|
7
|
+
* is the read-stream chunk size (~64 KB); callers wanting coarser updates
|
|
8
|
+
* throttle in their own callback.
|
|
9
|
+
* @param src - The file to copy.
|
|
10
|
+
* @param dest - The destination path, overwritten if present.
|
|
11
|
+
* @param onProgress - Called as bytes are copied, with the bytes copied so
|
|
12
|
+
* far and the source file's total size. Omit for a silent copy.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* await copyFileWithProgress(archivePath, `${archivePath}.bak`, (copied, total) => {
|
|
16
|
+
* console.error(`${copied}/${total}`);
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function copyFileWithProgress(src: string, dest: string, onProgress?: (copiedBytes: number, totalBytes: number) => void): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
|
+
/**
|
|
5
|
+
* Copies a file while reporting byte progress — `fs.copyFile` offers no
|
|
6
|
+
* observability, and a `.nitpicker` archive's `.bak` safety copy (taken
|
|
7
|
+
* before every mutating `viewer-build`/`crawl --append`/`--inventory`/
|
|
8
|
+
* `--retry-failed` run, and copied back on failure) can be 15 GB+, running
|
|
9
|
+
* for tens of seconds with nothing on screen (issue #294). Byte granularity
|
|
10
|
+
* is the read-stream chunk size (~64 KB); callers wanting coarser updates
|
|
11
|
+
* throttle in their own callback.
|
|
12
|
+
* @param src - The file to copy.
|
|
13
|
+
* @param dest - The destination path, overwritten if present.
|
|
14
|
+
* @param onProgress - Called as bytes are copied, with the bytes copied so
|
|
15
|
+
* far and the source file's total size. Omit for a silent copy.
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* await copyFileWithProgress(archivePath, `${archivePath}.bak`, (copied, total) => {
|
|
19
|
+
* console.error(`${copied}/${total}`);
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export async function copyFileWithProgress(src, dest, onProgress) {
|
|
24
|
+
const { size: totalBytes } = await stat(src);
|
|
25
|
+
const source = createReadStream(src);
|
|
26
|
+
let copiedBytes = 0;
|
|
27
|
+
if (onProgress) {
|
|
28
|
+
source.on('data', (chunk) => {
|
|
29
|
+
copiedBytes += chunk.length;
|
|
30
|
+
onProgress(copiedBytes, totalBytes);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
await pipeline(source, createWriteStream(dest));
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a PAX extended header record block for the `path` key.
|
|
3
|
+
*
|
|
4
|
+
* Format: a sequence of `"<record-length> <key>=<value>\n"` records, where
|
|
5
|
+
* `<record-length>` is the decimal byte length of the ENTIRE record
|
|
6
|
+
* (including the length prefix itself and the trailing newline). `tar`
|
|
7
|
+
* (node-tar) emits one of these ahead of any entry whose name doesn't fit
|
|
8
|
+
* the ustar format's 100-byte field (UTF-8 multi-byte names, or names
|
|
9
|
+
* longer than 100 bytes) — verified empirically against this package's own
|
|
10
|
+
* `tar()`/`create()` output.
|
|
11
|
+
* @param data - The PAX header entry's raw data payload.
|
|
12
|
+
* @returns The `path` value if present, otherwise `null`.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* parsePaxPath(Buffer.from('20 path=日本語/\n', 'utf8')); // '日本語/'
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function parsePaxPath(data: Buffer): string | null;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a PAX extended header record block for the `path` key.
|
|
3
|
+
*
|
|
4
|
+
* Format: a sequence of `"<record-length> <key>=<value>\n"` records, where
|
|
5
|
+
* `<record-length>` is the decimal byte length of the ENTIRE record
|
|
6
|
+
* (including the length prefix itself and the trailing newline). `tar`
|
|
7
|
+
* (node-tar) emits one of these ahead of any entry whose name doesn't fit
|
|
8
|
+
* the ustar format's 100-byte field (UTF-8 multi-byte names, or names
|
|
9
|
+
* longer than 100 bytes) — verified empirically against this package's own
|
|
10
|
+
* `tar()`/`create()` output.
|
|
11
|
+
* @param data - The PAX header entry's raw data payload.
|
|
12
|
+
* @returns The `path` value if present, otherwise `null`.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* parsePaxPath(Buffer.from('20 path=日本語/\n', 'utf8')); // '日本語/'
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function parsePaxPath(data) {
|
|
19
|
+
let offset = 0;
|
|
20
|
+
while (offset < data.length) {
|
|
21
|
+
const spaceIndex = data.indexOf(0x20 /* ' ' */, offset);
|
|
22
|
+
if (spaceIndex === -1) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const lengthText = data.subarray(offset, spaceIndex).toString('latin1');
|
|
26
|
+
const recordLength = Number.parseInt(lengthText, 10);
|
|
27
|
+
if (!Number.isFinite(recordLength) || recordLength <= 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const recordEnd = offset + recordLength;
|
|
31
|
+
if (recordEnd > data.length) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
// Decimal (not `0x3D`) to sidestep the Prettier/eslint hex-case
|
|
35
|
+
// conflict — see `parse-tar-size-field.ts` for the full explanation.
|
|
36
|
+
const equalsIndex = data.indexOf(61 /* '=' */, spaceIndex);
|
|
37
|
+
if (equalsIndex !== -1 && equalsIndex < recordEnd) {
|
|
38
|
+
const key = data.subarray(spaceIndex + 1, equalsIndex).toString('utf8');
|
|
39
|
+
if (key === 'path') {
|
|
40
|
+
// Value runs from after '=' to just before the trailing '\n'.
|
|
41
|
+
return data.subarray(equalsIndex + 1, recordEnd - 1).toString('utf8');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
offset = recordEnd;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a tar header's size field (offset 124, 12 bytes), which is either
|
|
3
|
+
* a NUL/space-terminated octal ASCII string (standard) or, when the
|
|
4
|
+
* high bit of the first byte is set, a GNU base-256 big-endian binary
|
|
5
|
+
* encoding (used for sizes too large for the 11-digit octal field).
|
|
6
|
+
* @param field - The 12-byte size field.
|
|
7
|
+
* @returns The size in bytes, or `null` if the field cannot be parsed.
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* parseTarSizeField(Buffer.from('00000000004\0', 'latin1')); // 4
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseTarSizeField(field: Buffer): number | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a tar header's size field (offset 124, 12 bytes), which is either
|
|
3
|
+
* a NUL/space-terminated octal ASCII string (standard) or, when the
|
|
4
|
+
* high bit of the first byte is set, a GNU base-256 big-endian binary
|
|
5
|
+
* encoding (used for sizes too large for the 11-digit octal field).
|
|
6
|
+
* @param field - The 12-byte size field.
|
|
7
|
+
* @returns The size in bytes, or `null` if the field cannot be parsed.
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* parseTarSizeField(Buffer.from('00000000004\0', 'latin1')); // 4
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export function parseTarSizeField(field) {
|
|
14
|
+
const first = field[0];
|
|
15
|
+
if (first === undefined) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if ((first & 0x80) !== 0) {
|
|
19
|
+
// GNU base-256: the remaining bits of the first byte plus all
|
|
20
|
+
// following bytes form a big-endian unsigned integer. `.nitpicker`
|
|
21
|
+
// tars never approach sizes needing this (single-digit-terabyte
|
|
22
|
+
// range before it would even matter), but a correct read here still
|
|
23
|
+
// costs nothing.
|
|
24
|
+
// `0b0111_1111` (not `0x7F`) to sidestep the Prettier/eslint hex-case
|
|
25
|
+
// conflict (Prettier always lowercases hex digits; this repo's
|
|
26
|
+
// `unicorn/number-literal-case` requires uppercase) — a binary literal
|
|
27
|
+
// has no letter digits to disagree about.
|
|
28
|
+
let value = BigInt(first & 0b0111_1111);
|
|
29
|
+
for (let i = 1; i < field.length; i++) {
|
|
30
|
+
value = (value << 8n) | BigInt(field[i] ?? 0);
|
|
31
|
+
}
|
|
32
|
+
return Number(value);
|
|
33
|
+
}
|
|
34
|
+
const text = field.toString('latin1').replaceAll('\0', ' ').trim();
|
|
35
|
+
if (text === '') {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const parsed = Number.parseInt(text, 8);
|
|
39
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
40
|
+
}
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
* directory it sees, so callers (`Archive.open`, the migration script) can
|
|
13
13
|
* use the actual inner name regardless of what the outer file is called.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Parses raw tar headers directly (ustar fixed fields, PAX extended headers,
|
|
16
|
+
* GNU longname/longlink) instead of using the `tar` package's `list()` —
|
|
17
|
+
* `list()` reads the entire archive stream to completion even after
|
|
18
|
+
* `onReadEntry` has found what it needs (no internal early-abort), so on a
|
|
19
|
+
* 15 GB+ archive it was a second full read on top of `untar()`'s own read
|
|
20
|
+
* (issue #294). The fast path here stops at the first top-level directory
|
|
21
|
+
* entry, typically reading well under 1 KB. Falls back to the original
|
|
22
|
+
* `list()`-based scan on anything it doesn't recognize (unknown typeflag
|
|
23
|
+
* sequence, truncated read, non-tar content) — safety over speed for
|
|
24
|
+
* archives this function has never been exercised against.
|
|
18
25
|
* @param tarFilePath - Path to the `.nitpicker` tar file.
|
|
19
26
|
* @returns The first top-level directory name found in the archive.
|
|
20
27
|
* @throws {Error} When the tar contains no top-level directory entry
|