@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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the `scraper.on('changePhase', ...)` listener used by
|
|
3
|
+
* {@link Crawler.#launchBrowserAndScrape}.
|
|
4
|
+
*
|
|
5
|
+
* Three responsibilities:
|
|
6
|
+
* 1. Render the phase log via the injected `formatLog` and pipe it to `update`.
|
|
7
|
+
* 2. Forward the raw event so external consumers (CLI progress UI etc.) see
|
|
8
|
+
* every transition.
|
|
9
|
+
* 3. Buffer `retryExhausted` events into the per-URL phase-error map so they
|
|
10
|
+
* can be drained as `pageError` events AFTER the `page` event fires.
|
|
11
|
+
*
|
|
12
|
+
* WHY a factory: the listener captures per-scrape state (`buffer`, `urlHref`,
|
|
13
|
+
* `update`). Extracting the factory makes the wiring directly unit-testable
|
|
14
|
+
* with plain stubs, instead of requiring a mocked Puppeteer + beholder + dealer
|
|
15
|
+
* stack to drive the worker.
|
|
16
|
+
*
|
|
17
|
+
* **Caller contract**: register the returned handler at most once per
|
|
18
|
+
* `scraper` instance. The Crawler creates a fresh Scraper per URL so this
|
|
19
|
+
* holds today; if scraper pooling is ever introduced, register exactly one
|
|
20
|
+
* handler per scrape and unregister it on completion to avoid duplicate
|
|
21
|
+
* buffer entries.
|
|
22
|
+
* @param options - Wiring dependencies for the handler.
|
|
23
|
+
* @returns A function suitable for `scraper.on('changePhase', ...)`.
|
|
24
|
+
*/
|
|
25
|
+
export function createChangePhaseHandler(options) {
|
|
26
|
+
const { emit, update, formatLog, buffer, urlHref } = options;
|
|
27
|
+
return (event) => {
|
|
28
|
+
const msg = formatLog(event);
|
|
29
|
+
if (msg) {
|
|
30
|
+
update(msg);
|
|
31
|
+
}
|
|
32
|
+
emit(event);
|
|
33
|
+
// retryExhausted fires when beholder's @retryable gives up on a
|
|
34
|
+
// secondary scrape step (e.g. a viewport switch detaching the frame
|
|
35
|
+
// in #fetchImages). The page itself still completes, so we buffer
|
|
36
|
+
// the failure here and emit it as a pageError after the page event
|
|
37
|
+
// has been emitted.
|
|
38
|
+
if (event.name === 'retryExhausted') {
|
|
39
|
+
const list = buffer.get(urlHref) ?? [];
|
|
40
|
+
list.push({ phase: event.name, message: event.message });
|
|
41
|
+
buffer.set(urlHref, list);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InventoryMode } from './types.js';
|
|
2
|
+
import type { PageSource } from '../archive/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Decide which {@link PageSource} label a newly-scraped page row should carry.
|
|
5
|
+
*
|
|
6
|
+
* When the crawler is NOT in inventory mode (`inventoryMode === null`),
|
|
7
|
+
* returns `undefined` — the caller emits no `source` and the DB DEFAULT
|
|
8
|
+
* `'crawled'` ends up on the row. This keeps the normal crawl path
|
|
9
|
+
* untouched.
|
|
10
|
+
*
|
|
11
|
+
* When inventory mode is active, the URL is matched against
|
|
12
|
+
* `inventoryMode.seedUrls`. A hit means the URL came straight from the
|
|
13
|
+
* user-supplied list (`'inventory-seed'`); a miss means the URL was found
|
|
14
|
+
* by following links from a seed page (`'inventory-discovered'`).
|
|
15
|
+
*
|
|
16
|
+
* Sub-resources captured by puppeteer during inventory-mode rendering are
|
|
17
|
+
* NEVER seeds — the caller for those events always passes
|
|
18
|
+
* `'inventory-discovered'` directly without consulting this helper.
|
|
19
|
+
* @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
|
|
20
|
+
* @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
|
|
21
|
+
* @returns The label to write to `pages.source`, or `undefined` for the DB default.
|
|
22
|
+
*/
|
|
23
|
+
export declare function derivePageSource(inventoryMode: InventoryMode | null, pageUrlWithoutHashAndAuth: string): PageSource | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide which {@link PageSource} label a newly-scraped page row should carry.
|
|
3
|
+
*
|
|
4
|
+
* When the crawler is NOT in inventory mode (`inventoryMode === null`),
|
|
5
|
+
* returns `undefined` — the caller emits no `source` and the DB DEFAULT
|
|
6
|
+
* `'crawled'` ends up on the row. This keeps the normal crawl path
|
|
7
|
+
* untouched.
|
|
8
|
+
*
|
|
9
|
+
* When inventory mode is active, the URL is matched against
|
|
10
|
+
* `inventoryMode.seedUrls`. A hit means the URL came straight from the
|
|
11
|
+
* user-supplied list (`'inventory-seed'`); a miss means the URL was found
|
|
12
|
+
* by following links from a seed page (`'inventory-discovered'`).
|
|
13
|
+
*
|
|
14
|
+
* Sub-resources captured by puppeteer during inventory-mode rendering are
|
|
15
|
+
* NEVER seeds — the caller for those events always passes
|
|
16
|
+
* `'inventory-discovered'` directly without consulting this helper.
|
|
17
|
+
* @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
|
|
18
|
+
* @param pageUrlWithoutHashAndAuth - The page URL keyed by `withoutHashAndAuth` (auth credentials stripped, hash dropped).
|
|
19
|
+
* @returns The label to write to `pages.source`, or `undefined` for the DB default.
|
|
20
|
+
*/
|
|
21
|
+
export function derivePageSource(inventoryMode, pageUrlWithoutHashAndAuth) {
|
|
22
|
+
if (inventoryMode === null) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return inventoryMode.seedUrls.has(pageUrlWithoutHashAndAuth)
|
|
26
|
+
? 'inventory-seed'
|
|
27
|
+
: 'inventory-discovered';
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InventoryMode } from './types.js';
|
|
2
|
+
import type { PageSource } from '../archive/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Decide which {@link PageSource} label a newly-captured sub-resource row
|
|
5
|
+
* (CSS / JS / image / fetch response) should carry.
|
|
6
|
+
*
|
|
7
|
+
* Sub-resources are NEVER themselves seeds — even when puppeteer is
|
|
8
|
+
* rendering a page that *is* an inventory seed, the assets it pulls in
|
|
9
|
+
* are downstream and must be labelled `'inventory-discovered'`. The seed
|
|
10
|
+
* label is reserved for URLs that were explicitly handed in by the user
|
|
11
|
+
* via the `--inventory` file.
|
|
12
|
+
*
|
|
13
|
+
* Outside inventory mode (`inventoryMode === null`) this returns
|
|
14
|
+
* `undefined` so the caller emits no `source` and the DB DEFAULT
|
|
15
|
+
* (`'crawled'`) lands on the row — keeps the normal crawl path
|
|
16
|
+
* untouched. This is the sub-resource counterpart of
|
|
17
|
+
* {@link import('./derive-page-source.js').derivePageSource}; the two
|
|
18
|
+
* helpers exist as a pair so a future addition to {@link PageSource}
|
|
19
|
+
* forces a parallel update.
|
|
20
|
+
* @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
|
|
21
|
+
* @returns The label to write to `resources.source`, or `undefined` for the DB default.
|
|
22
|
+
*/
|
|
23
|
+
export declare function deriveResourceSource(inventoryMode: InventoryMode | null): PageSource | undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide which {@link PageSource} label a newly-captured sub-resource row
|
|
3
|
+
* (CSS / JS / image / fetch response) should carry.
|
|
4
|
+
*
|
|
5
|
+
* Sub-resources are NEVER themselves seeds — even when puppeteer is
|
|
6
|
+
* rendering a page that *is* an inventory seed, the assets it pulls in
|
|
7
|
+
* are downstream and must be labelled `'inventory-discovered'`. The seed
|
|
8
|
+
* label is reserved for URLs that were explicitly handed in by the user
|
|
9
|
+
* via the `--inventory` file.
|
|
10
|
+
*
|
|
11
|
+
* Outside inventory mode (`inventoryMode === null`) this returns
|
|
12
|
+
* `undefined` so the caller emits no `source` and the DB DEFAULT
|
|
13
|
+
* (`'crawled'`) lands on the row — keeps the normal crawl path
|
|
14
|
+
* untouched. This is the sub-resource counterpart of
|
|
15
|
+
* {@link import('./derive-page-source.js').derivePageSource}; the two
|
|
16
|
+
* helpers exist as a pair so a future addition to {@link PageSource}
|
|
17
|
+
* forces a parallel update.
|
|
18
|
+
* @param inventoryMode - Inventory-mode config from `CrawlerOptions.inventoryMode`, or `null` outside `--inventory`.
|
|
19
|
+
* @returns The label to write to `resources.source`, or `undefined` for the DB default.
|
|
20
|
+
*/
|
|
21
|
+
export function deriveResourceSource(inventoryMode) {
|
|
22
|
+
if (inventoryMode === null) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return 'inventory-discovered';
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A buffered phase-error record awaiting emission as a `pageError` event.
|
|
3
|
+
*
|
|
4
|
+
* `phase` is the beholder phase name (typically `'retryExhausted'`) and
|
|
5
|
+
* `message` is the human-readable failure text.
|
|
6
|
+
*/
|
|
7
|
+
export interface BufferedPhaseError {
|
|
8
|
+
/** Scrape phase name. */
|
|
9
|
+
phase: string;
|
|
10
|
+
/** Human-readable failure message. */
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Emitter signature accepted by {@link drainPhaseErrors} for the `pageError`
|
|
15
|
+
* event. Declared structurally so the Crawler's typed event emitter can be
|
|
16
|
+
* adapted with a thin closure at the call site without leaking through here.
|
|
17
|
+
*/
|
|
18
|
+
export type DrainPhaseErrorsEmit = (payload: {
|
|
19
|
+
/** URL of the affected page. */
|
|
20
|
+
url: string;
|
|
21
|
+
/** Scrape phase name. */
|
|
22
|
+
phase: string;
|
|
23
|
+
/** Human-readable failure message. */
|
|
24
|
+
message: string;
|
|
25
|
+
/** Whether the URL is external to the crawl scope. */
|
|
26
|
+
isExternal: boolean;
|
|
27
|
+
}) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Drains the buffered phase errors for a URL: removes the entry from
|
|
30
|
+
* `buffer` and invokes `emit` once per buffered record.
|
|
31
|
+
*
|
|
32
|
+
* WHY a standalone function: the Crawler buffers `retryExhausted` events
|
|
33
|
+
* keyed by `url.href` during scrapeStart, then flushes them as
|
|
34
|
+
* `pageError` events AFTER `page` / `externalPage` has been emitted so the
|
|
35
|
+
* orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
|
|
36
|
+
* Extracting the drain step here makes the flush + delete contract
|
|
37
|
+
* directly unit-testable without spinning up a real Crawler.
|
|
38
|
+
*
|
|
39
|
+
* Idempotent: calling twice for the same `urlHref` is safe — the second
|
|
40
|
+
* call sees an empty buffer and is a no-op.
|
|
41
|
+
* @param buffer - The pending-phase-errors map, keyed by URL href.
|
|
42
|
+
* @param urlHref - URL whose buffered errors should be drained.
|
|
43
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
44
|
+
* @param emit - Callback invoked once per buffered phase-error record.
|
|
45
|
+
* @returns The number of phase-error events emitted (0 when the buffer
|
|
46
|
+
* had no entry for `urlHref`).
|
|
47
|
+
*/
|
|
48
|
+
export declare function drainPhaseErrors(buffer: Map<string, BufferedPhaseError[]>, urlHref: string, isExternal: boolean, emit: DrainPhaseErrorsEmit): number;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drains the buffered phase errors for a URL: removes the entry from
|
|
3
|
+
* `buffer` and invokes `emit` once per buffered record.
|
|
4
|
+
*
|
|
5
|
+
* WHY a standalone function: the Crawler buffers `retryExhausted` events
|
|
6
|
+
* keyed by `url.href` during scrapeStart, then flushes them as
|
|
7
|
+
* `pageError` events AFTER `page` / `externalPage` has been emitted so the
|
|
8
|
+
* orchestrator's WriteQueue serialises `setPage` before `insertPageError`.
|
|
9
|
+
* Extracting the drain step here makes the flush + delete contract
|
|
10
|
+
* directly unit-testable without spinning up a real Crawler.
|
|
11
|
+
*
|
|
12
|
+
* Idempotent: calling twice for the same `urlHref` is safe — the second
|
|
13
|
+
* call sees an empty buffer and is a no-op.
|
|
14
|
+
* @param buffer - The pending-phase-errors map, keyed by URL href.
|
|
15
|
+
* @param urlHref - URL whose buffered errors should be drained.
|
|
16
|
+
* @param isExternal - Whether the URL is external to the crawl scope.
|
|
17
|
+
* @param emit - Callback invoked once per buffered phase-error record.
|
|
18
|
+
* @returns The number of phase-error events emitted (0 when the buffer
|
|
19
|
+
* had no entry for `urlHref`).
|
|
20
|
+
*/
|
|
21
|
+
export function drainPhaseErrors(buffer, urlHref, isExternal, emit) {
|
|
22
|
+
const errors = buffer.get(urlHref);
|
|
23
|
+
if (!errors || errors.length === 0)
|
|
24
|
+
return 0;
|
|
25
|
+
buffer.delete(urlHref);
|
|
26
|
+
for (const err of errors) {
|
|
27
|
+
emit({
|
|
28
|
+
url: urlHref,
|
|
29
|
+
phase: err.phase,
|
|
30
|
+
message: err.message,
|
|
31
|
+
isExternal,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return errors.length;
|
|
35
|
+
}
|
|
@@ -63,12 +63,19 @@ export async function fetchDestination(params) {
|
|
|
63
63
|
async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
|
|
64
64
|
return new Promise((resolve, reject) => {
|
|
65
65
|
const hostHeader = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
66
|
+
// `trackRedirects` makes follow-redirects populate `res.redirects` with the
|
|
67
|
+
// chain of followed URLs. Without it that array stays empty and the
|
|
68
|
+
// pre-flight cannot tell where a URL lands — required for the redirect
|
|
69
|
+
// chain in `redirectPaths` and for the #73 convergence dedup, which decides
|
|
70
|
+
// whether a redirect destination was already rendered *before* launching
|
|
71
|
+
// the browser.
|
|
66
72
|
const request = {
|
|
67
73
|
protocol: url.protocol,
|
|
68
74
|
hostname: url.hostname,
|
|
69
75
|
port: url.port || undefined,
|
|
70
76
|
path: url.pathname,
|
|
71
77
|
method,
|
|
78
|
+
trackRedirects: true,
|
|
72
79
|
headers: {
|
|
73
80
|
host: hostHeader,
|
|
74
81
|
...(userAgent ? { 'User-Agent': userAgent } : {}),
|
|
@@ -92,7 +99,17 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
|
|
|
92
99
|
let totalBytes = 0;
|
|
93
100
|
let settled = false;
|
|
94
101
|
const buildPageData = (title) => {
|
|
95
|
-
|
|
102
|
+
// `res.redirects` (populated by trackRedirects) ALWAYS starts with the
|
|
103
|
+
// originally requested URL, then each followed hop. We drop that first
|
|
104
|
+
// entry so `redirectPaths` keeps its established contract: empty when the
|
|
105
|
+
// URL did not redirect, and `[...intermediate, finalDest]` when it did
|
|
106
|
+
// (the original URL is NOT included — callers like `resolveRedirectChain`
|
|
107
|
+
// and `updatePage` re-add it). Keeping the original here would (a) make
|
|
108
|
+
// `redirectPaths` non-empty for every page, so a direct page looks like a
|
|
109
|
+
// self-redirect, and (b) leak the query-stripped request-target (the HEAD
|
|
110
|
+
// request uses `url.pathname`), collapsing query-distinguished pages.
|
|
111
|
+
// Redirect *targets* come from Location headers and keep their query.
|
|
112
|
+
const redirectPaths = res.redirects.map((r) => r.url).slice(1);
|
|
96
113
|
const _contentLength = Number.parseInt(res.headers['content-length'] || '');
|
|
97
114
|
const contentLength = Number.isFinite(_contentLength) ? _contentLength : null;
|
|
98
115
|
return {
|
|
@@ -105,7 +122,26 @@ async function _fetchHead(url, isExternal, method, titleBytesLimit, userAgent) {
|
|
|
105
122
|
contentType: res.headers['content-type']?.split(';')[0] || null,
|
|
106
123
|
contentLength,
|
|
107
124
|
responseHeaders: res.headers,
|
|
108
|
-
|
|
125
|
+
// beholder 3.0.0 made jsonLd / speculationRules / tags /
|
|
126
|
+
// others / originTrial required Meta fields. Even this
|
|
127
|
+
// HEAD-only fallback path must populate every slot so
|
|
128
|
+
// downstream insert/derive helpers iterate without crashing.
|
|
129
|
+
meta: {
|
|
130
|
+
title,
|
|
131
|
+
jsonLd: [],
|
|
132
|
+
speculationRules: [],
|
|
133
|
+
tags: { detected: {}, entries: [] },
|
|
134
|
+
others: {
|
|
135
|
+
meta: {},
|
|
136
|
+
property: {},
|
|
137
|
+
httpEquiv: {},
|
|
138
|
+
itemprop: {},
|
|
139
|
+
link: [],
|
|
140
|
+
script: [],
|
|
141
|
+
iframe: [],
|
|
142
|
+
},
|
|
143
|
+
originTrial: [],
|
|
144
|
+
},
|
|
109
145
|
imageList: [],
|
|
110
146
|
anchorList: [],
|
|
111
147
|
html: '',
|
|
@@ -12,22 +12,31 @@ interface FormatCrawlProgressParams {
|
|
|
12
12
|
readonly externalTotal: number;
|
|
13
13
|
/** Number of external URLs completed */
|
|
14
14
|
readonly externalDone: number;
|
|
15
|
+
/**
|
|
16
|
+
* Number of HTML pages actually rendered by the browser in the current session.
|
|
17
|
+
* Excludes HEAD-only resources, title-only metadata fetches, and skipped URLs.
|
|
18
|
+
*/
|
|
19
|
+
readonly pagesScraped: number;
|
|
15
20
|
/** Number of parallel workers */
|
|
16
21
|
readonly limit: number;
|
|
17
22
|
}
|
|
18
23
|
/**
|
|
19
24
|
* Formats the crawl progress header for the deal() progress display.
|
|
20
25
|
*
|
|
21
|
-
* Shows "done / found (remaining)" format instead of "done/total"
|
|
22
|
-
* to make it clearer that the total is expected to grow during crawling
|
|
26
|
+
* Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
|
|
27
|
+
* to make it clearer that the total is expected to grow during crawling,
|
|
28
|
+
* that the counts are processed URLs (not resulting pages), and how many of
|
|
29
|
+
* those URLs were actually rendered by the browser as HTML pages.
|
|
30
|
+
* Counts are formatted with thousands separators (e.g. `1,234,567`).
|
|
23
31
|
* @param params - The crawl progress parameters.
|
|
24
32
|
* @param params.done - Number of URLs completed by the deal queue.
|
|
25
33
|
* @param params.total - Total number of URLs in the deal queue (including completed).
|
|
26
34
|
* @param params.resumeOffset - Offset from a previous resumed session.
|
|
27
35
|
* @param params.externalTotal - Number of external URLs discovered.
|
|
28
36
|
* @param params.externalDone - Number of external URLs completed.
|
|
37
|
+
* @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
|
|
29
38
|
* @param params.limit - Number of parallel workers.
|
|
30
39
|
* @returns The formatted progress string with ANSI color codes.
|
|
31
40
|
*/
|
|
32
|
-
export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }: FormatCrawlProgressParams): string;
|
|
41
|
+
export declare function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }: FormatCrawlProgressParams): string;
|
|
33
42
|
export {};
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import c from 'ansi-colors';
|
|
2
|
+
/**
|
|
3
|
+
* Number formatter for thousands-separated count display (e.g. `1,234,567`).
|
|
4
|
+
*/
|
|
5
|
+
const countFormat = new Intl.NumberFormat('en-US');
|
|
2
6
|
/**
|
|
3
7
|
* Formats the crawl progress header for the deal() progress display.
|
|
4
8
|
*
|
|
5
|
-
* Shows "done / found (remaining)" format instead of "done/total"
|
|
6
|
-
* to make it clearer that the total is expected to grow during crawling
|
|
9
|
+
* Shows "done(pages) / found URLs (remaining)" format instead of "done/total"
|
|
10
|
+
* to make it clearer that the total is expected to grow during crawling,
|
|
11
|
+
* that the counts are processed URLs (not resulting pages), and how many of
|
|
12
|
+
* those URLs were actually rendered by the browser as HTML pages.
|
|
13
|
+
* Counts are formatted with thousands separators (e.g. `1,234,567`).
|
|
7
14
|
* @param params - The crawl progress parameters.
|
|
8
15
|
* @param params.done - Number of URLs completed by the deal queue.
|
|
9
16
|
* @param params.total - Total number of URLs in the deal queue (including completed).
|
|
10
17
|
* @param params.resumeOffset - Offset from a previous resumed session.
|
|
11
18
|
* @param params.externalTotal - Number of external URLs discovered.
|
|
12
19
|
* @param params.externalDone - Number of external URLs completed.
|
|
20
|
+
* @param params.pagesScraped - Number of HTML pages rendered by the browser in this session.
|
|
13
21
|
* @param params.limit - Number of parallel workers.
|
|
14
22
|
* @returns The formatted progress string with ANSI color codes.
|
|
15
23
|
*/
|
|
16
|
-
export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, limit, }) {
|
|
24
|
+
export function formatCrawlProgress({ done, total, resumeOffset, externalTotal, externalDone, pagesScraped, limit, }) {
|
|
17
25
|
const allDone = done + resumeOffset;
|
|
18
26
|
const allTotal = total + resumeOffset;
|
|
19
27
|
const internalDone = allDone - externalDone;
|
|
@@ -22,8 +30,8 @@ export function formatCrawlProgress({ done, total, resumeOffset, externalTotal,
|
|
|
22
30
|
const externalRemaining = externalTotal - externalDone;
|
|
23
31
|
const totalRemaining = internalRemaining + externalRemaining;
|
|
24
32
|
const pct = allTotal > 0 ? Math.round((allDone / allTotal) * 100) : 0;
|
|
25
|
-
return (c.bold(`Crawling: ${internalDone} done / ${internalTotal} found`) +
|
|
26
|
-
c.dim(` (+${externalDone}/${externalTotal} ext)`) +
|
|
27
|
-
c.bold(` (${pct}%) [${totalRemaining} remaining]`) +
|
|
33
|
+
return (c.bold(`Crawling: ${countFormat.format(internalDone)}(${countFormat.format(pagesScraped)}) done / ${countFormat.format(internalTotal)} found URLs`) +
|
|
34
|
+
c.dim(` (+${countFormat.format(externalDone)}/${countFormat.format(externalTotal)} ext)`) +
|
|
35
|
+
c.bold(` (${pct}%) [${countFormat.format(totalRemaining)} remaining]`) +
|
|
28
36
|
c.dim(` [${limit} parallel]`));
|
|
29
37
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ClosableBrowser } from './close-browser-safely.js';
|
|
2
|
+
/**
|
|
3
|
+
* Debug-style logger compatible with the `debug` package's printf-style API.
|
|
4
|
+
*
|
|
5
|
+
* Declared structurally so the function stays unit-testable with a `vi.fn()`
|
|
6
|
+
* stub and free of a `debug` import.
|
|
7
|
+
*/
|
|
8
|
+
export type BrowserCloseLogger = (
|
|
9
|
+
/** printf-style format string (e.g. `'%s'`, `'%O'`). */
|
|
10
|
+
formatter: string,
|
|
11
|
+
/** Arguments interpolated into the format string. */
|
|
12
|
+
...args: readonly unknown[]) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Closes a Puppeteer browser used to scrape a single URL, recording any
|
|
15
|
+
* timeout fallback or unexpected cleanup error to the supplied logger.
|
|
16
|
+
*
|
|
17
|
+
* WHY a dedicated function: the prior inline `finally` in
|
|
18
|
+
* {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
|
|
19
|
+
* production log formatting. Splitting it out makes the two observable
|
|
20
|
+
* branches (force-kill notice and unexpected-error notice) directly testable
|
|
21
|
+
* without spawning a real browser, and keeps the rule that a finally block
|
|
22
|
+
* never throws: any error from {@link closeBrowserSafely} is logged here,
|
|
23
|
+
* never re-thrown.
|
|
24
|
+
* @param browser - The browser to close.
|
|
25
|
+
* @param urlHref - URL string included in the log messages for diagnostic
|
|
26
|
+
* context.
|
|
27
|
+
* @param log - Logger used to record timeout and error events.
|
|
28
|
+
*/
|
|
29
|
+
export declare function handleBrowserClose(browser: ClosableBrowser, urlHref: string, log: BrowserCloseLogger): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { closeBrowserSafely } from './close-browser-safely.js';
|
|
2
|
+
/**
|
|
3
|
+
* Closes a Puppeteer browser used to scrape a single URL, recording any
|
|
4
|
+
* timeout fallback or unexpected cleanup error to the supplied logger.
|
|
5
|
+
*
|
|
6
|
+
* WHY a dedicated function: the prior inline `finally` in
|
|
7
|
+
* {@link Crawler.#launchBrowserAndScrape} mixed cleanup orchestration with
|
|
8
|
+
* production log formatting. Splitting it out makes the two observable
|
|
9
|
+
* branches (force-kill notice and unexpected-error notice) directly testable
|
|
10
|
+
* without spawning a real browser, and keeps the rule that a finally block
|
|
11
|
+
* never throws: any error from {@link closeBrowserSafely} is logged here,
|
|
12
|
+
* never re-thrown.
|
|
13
|
+
* @param browser - The browser to close.
|
|
14
|
+
* @param urlHref - URL string included in the log messages for diagnostic
|
|
15
|
+
* context.
|
|
16
|
+
* @param log - Logger used to record timeout and error events.
|
|
17
|
+
*/
|
|
18
|
+
export async function handleBrowserClose(browser, urlHref, log) {
|
|
19
|
+
try {
|
|
20
|
+
const timedOut = await closeBrowserSafely(browser);
|
|
21
|
+
if (timedOut) {
|
|
22
|
+
log('Force-killed wedged Chromium browser for %s (close() timed out)', urlHref);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
log('closeBrowserSafely failed for %s: %O', urlHref, error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine whether a Content-Type media type is HTML.
|
|
3
|
+
*
|
|
4
|
+
* MIME types are case-insensitive (RFC 2045), and values captured from
|
|
5
|
+
* Puppeteer responses preserve the server's original casing, so the
|
|
6
|
+
* comparison must normalize case — `text/HTML` is HTML. Surrounding
|
|
7
|
+
* whitespace (e.g. `text/html ` left over after parameter stripping)
|
|
8
|
+
* is also tolerated.
|
|
9
|
+
*
|
|
10
|
+
* This is the single source of truth for HTML detection — `Page.isPage()`
|
|
11
|
+
* and the link list delegate here so the classification never diverges
|
|
12
|
+
* between code paths.
|
|
13
|
+
* @param contentType - The media type portion of a Content-Type header
|
|
14
|
+
* (parameters already stripped), or `null` when unknown.
|
|
15
|
+
* @returns `true` when the media type is `text/html` in any letter case.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isHtmlContentType(contentType: string | null): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine whether a Content-Type media type is HTML.
|
|
3
|
+
*
|
|
4
|
+
* MIME types are case-insensitive (RFC 2045), and values captured from
|
|
5
|
+
* Puppeteer responses preserve the server's original casing, so the
|
|
6
|
+
* comparison must normalize case — `text/HTML` is HTML. Surrounding
|
|
7
|
+
* whitespace (e.g. `text/html ` left over after parameter stripping)
|
|
8
|
+
* is also tolerated.
|
|
9
|
+
*
|
|
10
|
+
* This is the single source of truth for HTML detection — `Page.isPage()`
|
|
11
|
+
* and the link list delegate here so the classification never diverges
|
|
12
|
+
* between code paths.
|
|
13
|
+
* @param contentType - The media type portion of a Content-Type header
|
|
14
|
+
* (parameters already stripped), or `null` when unknown.
|
|
15
|
+
* @returns `true` when the media type is `text/html` in any letter case.
|
|
16
|
+
*/
|
|
17
|
+
export function isHtmlContentType(contentType) {
|
|
18
|
+
return contentType !== null && contentType.trim().toLowerCase() === 'text/html';
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
2
|
+
/**
|
|
3
|
+
* Heuristically decide whether a discovered URL is likely to resolve to an
|
|
4
|
+
* HTML page, based solely on the URL itself.
|
|
5
|
+
*
|
|
6
|
+
* WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
|
|
7
|
+
* `Content-Type` is unknown. The crawler uses the result to prioritise the
|
|
8
|
+
* dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
|
|
9
|
+
* advances ahead of asset/document fetches), so a heuristic is acceptable: a
|
|
10
|
+
* misclassification only changes fetch order, never correctness.
|
|
11
|
+
*
|
|
12
|
+
* Classification rules:
|
|
13
|
+
* - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
|
|
14
|
+
* - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
|
|
15
|
+
* URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
|
|
16
|
+
* the overwhelmingly common shape for navigable pages.
|
|
17
|
+
* - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
|
|
18
|
+
* extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
|
|
19
|
+
* @param url - The parsed URL to classify.
|
|
20
|
+
* @returns `true` when the URL is likely an HTML page.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isLikelyHtmlUrl(url: ExURL): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File extensions (lowercased, leading dot included) that typically denote a
|
|
3
|
+
* document served as `text/html`. Both static pages (`.html`) and the common
|
|
4
|
+
* server-side template / handler extensions (`.php`, `.aspx`, `.jsp`, `.ashx`,
|
|
5
|
+
* `.jsf` …) are listed because they usually serve an HTML page.
|
|
6
|
+
*
|
|
7
|
+
* A few entries (`.cgi`, `.do`, `.action`) are ambiguous — they sometimes
|
|
8
|
+
* return JSON or binary — but are kept here because a misclassification only
|
|
9
|
+
* changes fetch order, never correctness (see {@link isLikelyHtmlUrl}).
|
|
10
|
+
*
|
|
11
|
+
* Keys keep the leading dot so they can be compared directly against
|
|
12
|
+
* `ExURL.extname` (Node `Path.extname` output) without stripping it.
|
|
13
|
+
*/
|
|
14
|
+
const HTML_EXTENSIONS = new Set([
|
|
15
|
+
'.html',
|
|
16
|
+
'.htm',
|
|
17
|
+
'.xhtml',
|
|
18
|
+
'.shtml',
|
|
19
|
+
'.mhtml',
|
|
20
|
+
'.php',
|
|
21
|
+
'.php3',
|
|
22
|
+
'.php4',
|
|
23
|
+
'.php5',
|
|
24
|
+
'.phtml',
|
|
25
|
+
'.asp',
|
|
26
|
+
'.aspx',
|
|
27
|
+
'.ashx',
|
|
28
|
+
'.jsp',
|
|
29
|
+
'.jspx',
|
|
30
|
+
'.jsf',
|
|
31
|
+
'.cfm',
|
|
32
|
+
'.cgi',
|
|
33
|
+
'.do',
|
|
34
|
+
'.action',
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Heuristically decide whether a discovered URL is likely to resolve to an
|
|
38
|
+
* HTML page, based solely on the URL itself.
|
|
39
|
+
*
|
|
40
|
+
* WHY URL-only: this runs at enqueue time — before any HEAD/GET — so the actual
|
|
41
|
+
* `Content-Type` is unknown. The crawler uses the result to prioritise the
|
|
42
|
+
* dealer queue (likely-HTML URLs are `unshift`ed to the front so page crawling
|
|
43
|
+
* advances ahead of asset/document fetches), so a heuristic is acceptable: a
|
|
44
|
+
* misclassification only changes fetch order, never correctness.
|
|
45
|
+
*
|
|
46
|
+
* Classification rules:
|
|
47
|
+
* - Non-HTTP URLs (`mailto:`, `tel:`, …) are never HTML pages.
|
|
48
|
+
* - Extensionless / directory-style URLs (`/`, `/about/`), and bare trailing-dot
|
|
49
|
+
* URLs (`/index.`, whose `extname` is `"."`), are treated as HTML — these are
|
|
50
|
+
* the overwhelmingly common shape for navigable pages.
|
|
51
|
+
* - URLs whose extension is in {@link HTML_EXTENSIONS} are HTML; every other
|
|
52
|
+
* extension (`.jpg`, `.pdf`, `.css`, `.js`, …) is treated as non-HTML.
|
|
53
|
+
* @param url - The parsed URL to classify.
|
|
54
|
+
* @returns `true` when the URL is likely an HTML page.
|
|
55
|
+
*/
|
|
56
|
+
export function isLikelyHtmlUrl(url) {
|
|
57
|
+
if (!url.isHTTP) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const extname = url.extname;
|
|
61
|
+
if (!extname || extname === '.') {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return HTML_EXTENSIONS.has(extname.toLowerCase());
|
|
65
|
+
}
|