@nitpicker/crawler 0.19.0 → 0.20.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/crawler/crawler.js
CHANGED
|
@@ -55,6 +55,7 @@ import { PreloadShortCircuitError } from './preload-short-circuit-error.js';
|
|
|
55
55
|
import { probeNetwork } from './probe-network.js';
|
|
56
56
|
import { protocolAgnosticKey } from './protocol-agnostic-key.js';
|
|
57
57
|
import { redirectDestKey } from './redirect-dest-key.js';
|
|
58
|
+
import { resolveResultWentOffHost } from './resolve-result-went-off-host.js';
|
|
58
59
|
import { resourceToPageData } from './resource-to-page-data.js';
|
|
59
60
|
import { RobotsChecker } from './robots-checker.js';
|
|
60
61
|
import { shouldBurnHost } from './should-burn-host.js';
|
|
@@ -631,13 +632,16 @@ export default class Crawler extends EventEmitter {
|
|
|
631
632
|
// below. Deliberately NOT also excluding `opts?.metadataOnly`
|
|
632
633
|
// (unlike the tracker's observation side, which does skip
|
|
633
634
|
// metadata-only pages — they carry no reliable signature): with
|
|
634
|
-
// `--recursive=false`,
|
|
635
|
-
// metadata-only
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
// discovery
|
|
635
|
+
// `--recursive=false`, every INTERNAL anchor that reaches this
|
|
636
|
+
// closure is metadata-only (`handle-scrape-end.ts` never issues
|
|
637
|
+
// a full-scrape `addUrl` call outside recursive mode; external
|
|
638
|
+
// anchors reach it too when `--fetch-external` is on, but those
|
|
639
|
+
// are already filtered by the scope check below), so excluding
|
|
640
|
+
// `opts?.metadataOnly` here would silently disable
|
|
641
|
+
// `--dedupe-cap` for anchor discovery whenever `--recursive=false`
|
|
642
|
+
// is set — while gate 2 (the JS-redirect direct enqueue below)
|
|
643
|
+
// has no such exclusion and would still cap the very same
|
|
644
|
+
// shape, an inconsistency between the two discovery paths.
|
|
641
645
|
if (this.#options.dedupeCap !== null &&
|
|
642
646
|
findScopeEntry(newUrl, this.#scope, this.#options) !== null) {
|
|
643
647
|
const gateShapeKey = computeShapeKey(newUrl.withoutHashAndAuth);
|
|
@@ -1171,9 +1175,34 @@ export default class Crawler extends EventEmitter {
|
|
|
1171
1175
|
}
|
|
1172
1176
|
log('Saving results%dots%');
|
|
1173
1177
|
this.#handleResult(result, url, enqueue, concurrency, precomputedBodyHash);
|
|
1174
|
-
|
|
1175
|
-
this
|
|
1176
|
-
|
|
1178
|
+
// Skip sub-resources / console logs for a result that turned out
|
|
1179
|
+
// external — NOT the same as this worker's own `isExternal`
|
|
1180
|
+
// (computed from `url` before navigation). Beholder decides
|
|
1181
|
+
// `isExternal: false` before navigating and only flips it to
|
|
1182
|
+
// `true` after seeing the destination's hostname, so a
|
|
1183
|
+
// same-host source that redirects cross-host still has its
|
|
1184
|
+
// request/response/console listeners attached under the
|
|
1185
|
+
// pre-navigation `isExternal: false` for the whole trip. Those
|
|
1186
|
+
// listeners keep capturing the destination's sub-resources and
|
|
1187
|
+
// console output even after the flip, so without this guard a
|
|
1188
|
+
// cross-host redirect leaks the OFF-SCOPE destination's data
|
|
1189
|
+
// into this archive: its console output would be recorded as
|
|
1190
|
+
// this page's quality signal, and its resources would leave a
|
|
1191
|
+
// `resource_ref_edges` row on the (now content-less) redirect
|
|
1192
|
+
// SOURCE — `linkRedirectSources` deletes that source's
|
|
1193
|
+
// `anchor_edges` / `image_items` but not `resource_ref_edges`.
|
|
1194
|
+
// A genuinely external URL never reaches this branch with
|
|
1195
|
+
// non-empty `resources` / `consoleLogs` in the first place —
|
|
1196
|
+
// beholder never attaches these listeners for one, per the
|
|
1197
|
+
// `isExternal` gate in `#fetchData` — so this guard is a no-op
|
|
1198
|
+
// outside the cross-host-redirect case. See
|
|
1199
|
+
// `resolveResultWentOffHost`'s JSDoc for how it answers this for
|
|
1200
|
+
// a `type: 'error'` result, which has no `pageData` to read.
|
|
1201
|
+
if (!resolveResultWentOffHost(result, url)) {
|
|
1202
|
+
const parentSource = await this.#resolveParentSource(url);
|
|
1203
|
+
this.#handleResources(result.resources, parentSource);
|
|
1204
|
+
this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
|
|
1205
|
+
}
|
|
1177
1206
|
log(formatResultSummary(result));
|
|
1178
1207
|
// Phase errors must be emitted AFTER 'page' / 'externalPage'
|
|
1179
1208
|
// so the orchestrator's WriteQueue sees `setPage` before
|
|
@@ -40,9 +40,12 @@ export function handleScrapeEnd(result, linkList, scope, options, addUrl) {
|
|
|
40
40
|
* deepest matching scope.
|
|
41
41
|
* 2. For internal anchors without credentials, inherits auth from the matched
|
|
42
42
|
* scope and rebuilds `withoutHash` with the injected auth.
|
|
43
|
-
* 3.
|
|
44
|
-
*
|
|
45
|
-
* 4. In
|
|
43
|
+
* 3. External anchors are skipped entirely when `fetchExternal` is off — this
|
|
44
|
+
* gate applies in both recursive and non-recursive mode.
|
|
45
|
+
* 4. In recursive mode: enqueues internal anchors for full scraping, and
|
|
46
|
+
* external anchors (when not skipped by 3) for metadata-only scraping.
|
|
47
|
+
* 5. In non-recursive mode: enqueues every anchor that survives gate 3 for
|
|
48
|
+
* metadata-only scraping, internal or not.
|
|
46
49
|
* @param anchors - The list of anchor data extracted from the page.
|
|
47
50
|
* @param scope - Map of hostnames to their scope URLs.
|
|
48
51
|
* @param options - Crawler configuration options.
|
|
@@ -69,13 +72,19 @@ function processAnchors(anchors, scope, options, addUrl) {
|
|
|
69
72
|
const withoutHash = `${anchor.href.protocol}//${auth}${host}${body ? `/${body}` : ''}`;
|
|
70
73
|
anchor.href.withoutHash = withoutHash;
|
|
71
74
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// `fetchExternal` gates external anchors the same way in both modes —
|
|
76
|
+
// checked once, ahead of the recursive/non-recursive split below,
|
|
77
|
+
// rather than only inside the recursive branch. Non-recursive
|
|
78
|
+
// discovery has no other point that enforces `fetchExternal`: every
|
|
79
|
+
// anchor that reaches the split falls straight into the unconditional
|
|
80
|
+
// metadata-only `addUrl` call, so scoping the check to the recursive
|
|
81
|
+
// branch alone would make `--list` / `--single` / `--no-recursive`
|
|
82
|
+
// unable to honour `--no-fetch-external` at all.
|
|
83
|
+
if (!matchedScope && !options.fetchExternal) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (options.recursive && matchedScope) {
|
|
87
|
+
addUrl(anchor.href);
|
|
79
88
|
continue;
|
|
80
89
|
}
|
|
81
90
|
addUrl(anchor.href, { metadataOnly: true });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BrowserScrapeResult } from './types.js';
|
|
2
|
+
import type { ExURL } from '@d-zero/shared/parse-url';
|
|
3
|
+
/**
|
|
4
|
+
* Determines whether a scrape result's actual destination ended up on a
|
|
5
|
+
* different host than the URL that was requested.
|
|
6
|
+
*
|
|
7
|
+
* Exists for the cross-host-redirect leak guard in `Crawler`'s worker body:
|
|
8
|
+
* beholder decides `isExternal: false` before navigating and only flips it
|
|
9
|
+
* to `true` after seeing the destination's hostname, so a same-host source
|
|
10
|
+
* that redirects cross-host still has its sub-resource / console listeners
|
|
11
|
+
* attached under the pre-navigation `isExternal: false` for the whole trip
|
|
12
|
+
* — the caller uses this function's answer to decide whether to discard
|
|
13
|
+
* what those listeners captured.
|
|
14
|
+
*
|
|
15
|
+
* `pageData` (present on `type: 'success'`) carries the authoritative
|
|
16
|
+
* post-navigation `isExternal` and is used directly. A `type: 'error'`
|
|
17
|
+
* result has no `pageData`, so `postNavigationUrl` — the puppeteer-side
|
|
18
|
+
* `page.url()` captured by the JS-redirect rescue (see `BrowserScrapeResult`
|
|
19
|
+
* JSDoc) — is used as the fallback signal instead. When neither is
|
|
20
|
+
* available (e.g. the browser context died before `page.url()` could be
|
|
21
|
+
* read, or the result is `type: 'skipped'`, which carries neither field),
|
|
22
|
+
* this returns `false` — the alternative would discard legitimate
|
|
23
|
+
* console/resource data for the overwhelmingly common same-host case on
|
|
24
|
+
* the mere possibility of a leak this function has no evidence for.
|
|
25
|
+
* @param result - The non-redirect-edge scrape outcome.
|
|
26
|
+
* @param url - The originally-requested URL.
|
|
27
|
+
* @returns `true` when the browser is known to have ended up off-host.
|
|
28
|
+
* @example
|
|
29
|
+
* const wentOffHost = resolveResultWentOffHost(result, url);
|
|
30
|
+
* if (!wentOffHost) {
|
|
31
|
+
* handleResources(result.resources, parentSource);
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveResultWentOffHost(result: BrowserScrapeResult, url: ExURL): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
|
|
2
|
+
/**
|
|
3
|
+
* Determines whether a scrape result's actual destination ended up on a
|
|
4
|
+
* different host than the URL that was requested.
|
|
5
|
+
*
|
|
6
|
+
* Exists for the cross-host-redirect leak guard in `Crawler`'s worker body:
|
|
7
|
+
* beholder decides `isExternal: false` before navigating and only flips it
|
|
8
|
+
* to `true` after seeing the destination's hostname, so a same-host source
|
|
9
|
+
* that redirects cross-host still has its sub-resource / console listeners
|
|
10
|
+
* attached under the pre-navigation `isExternal: false` for the whole trip
|
|
11
|
+
* — the caller uses this function's answer to decide whether to discard
|
|
12
|
+
* what those listeners captured.
|
|
13
|
+
*
|
|
14
|
+
* `pageData` (present on `type: 'success'`) carries the authoritative
|
|
15
|
+
* post-navigation `isExternal` and is used directly. A `type: 'error'`
|
|
16
|
+
* result has no `pageData`, so `postNavigationUrl` — the puppeteer-side
|
|
17
|
+
* `page.url()` captured by the JS-redirect rescue (see `BrowserScrapeResult`
|
|
18
|
+
* JSDoc) — is used as the fallback signal instead. When neither is
|
|
19
|
+
* available (e.g. the browser context died before `page.url()` could be
|
|
20
|
+
* read, or the result is `type: 'skipped'`, which carries neither field),
|
|
21
|
+
* this returns `false` — the alternative would discard legitimate
|
|
22
|
+
* console/resource data for the overwhelmingly common same-host case on
|
|
23
|
+
* the mere possibility of a leak this function has no evidence for.
|
|
24
|
+
* @param result - The non-redirect-edge scrape outcome.
|
|
25
|
+
* @param url - The originally-requested URL.
|
|
26
|
+
* @returns `true` when the browser is known to have ended up off-host.
|
|
27
|
+
* @example
|
|
28
|
+
* const wentOffHost = resolveResultWentOffHost(result, url);
|
|
29
|
+
* if (!wentOffHost) {
|
|
30
|
+
* handleResources(result.resources, parentSource);
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export function resolveResultWentOffHost(result, url) {
|
|
34
|
+
if (result.pageData !== undefined) {
|
|
35
|
+
return result.pageData.isExternal;
|
|
36
|
+
}
|
|
37
|
+
return (result.postNavigationUrl !== undefined &&
|
|
38
|
+
parseUrl(result.postNavigationUrl)?.hostname !== url.hostname);
|
|
39
|
+
}
|
package/lib/crawler/types.d.ts
CHANGED
|
@@ -42,8 +42,18 @@ export interface RedirectEdgeResult {
|
|
|
42
42
|
* The outcome of {@link Crawler.#scrapePage}: either a normal scrape result from
|
|
43
43
|
* the browser/HEAD pipeline, or a {@link RedirectEdgeResult} when the URL's
|
|
44
44
|
* redirect destination was already rendered and only the edge needs recording.
|
|
45
|
+
*
|
|
46
|
+
* The non-redirect-edge member is {@link BrowserScrapeResult}, not bare
|
|
47
|
+
* beholder `ScrapeResult` — every `#scrapePage` return site is either a
|
|
48
|
+
* `_launchBrowserAndScrape` call (which returns `BrowserScrapeResult`) or a
|
|
49
|
+
* plain `ScrapeResult`-shaped literal (structurally compatible, since
|
|
50
|
+
* `postNavigationUrl` is optional). Callers that reach a `type: 'error'`
|
|
51
|
+
* result and need to know whether the browser ended up off-host before
|
|
52
|
+
* failing (`pageData` is absent on error, so `pageData.isExternal` is not
|
|
53
|
+
* available) read `postNavigationUrl` for that signal — see
|
|
54
|
+
* `resolveResultWentOffHost`.
|
|
45
55
|
*/
|
|
46
|
-
export type ScrapeOutcome =
|
|
56
|
+
export type ScrapeOutcome = BrowserScrapeResult | RedirectEdgeResult;
|
|
47
57
|
/**
|
|
48
58
|
* Internal envelope returned by {@link Crawler.#launchBrowserAndScrape} that
|
|
49
59
|
* augments beholder's {@link ScrapeResult} with the puppeteer-side
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitpicker/crawler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Web crawler engine with headless browser rendering and archive storage",
|
|
5
5
|
"author": "D-ZERO",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@d-zero/beholder": "4.2.3",
|
|
34
|
-
"@d-zero/dealer": "1.
|
|
34
|
+
"@d-zero/dealer": "1.13.1",
|
|
35
35
|
"@d-zero/fs": "0.2.7",
|
|
36
36
|
"@d-zero/shared": "0.23.0",
|
|
37
37
|
"ansi-colors": "4.1.3",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"@types/tar": "7.0.87",
|
|
52
52
|
"@types/unzipper": "0.10.11"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "cbe8a4e74322386a6e07b2d4f1417dfba222f0b1"
|
|
55
55
|
}
|