@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.
@@ -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`, `handle-scrape-end.ts` marks EVERY anchor
635
- // metadata-only, internal or not, so excluding them here would
636
- // silently disable `--dedupe-cap` for anchor discovery whenever
637
- // `--recursive=false` is set while gate 2 (the JS-redirect
638
- // direct enqueue below) has no such exclusion and would still
639
- // cap the very same shape, an inconsistency between the two
640
- // discovery paths.
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
- const parentSource = await this.#resolveParentSource(url);
1175
- this.#handleResources(result.resources, parentSource);
1176
- this.#handleConsoleLogs(result.consoleLogs, url, result.pageData?.redirectPaths ?? []);
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. In recursive mode: enqueues internal anchors for full scraping, and
44
- * external anchors for metadata-only scraping (when `fetchExternal` is on).
45
- * 4. In non-recursive mode: enqueues every anchor for metadata-only scraping.
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
- if (options.recursive) {
73
- if (matchedScope) {
74
- addUrl(anchor.href);
75
- }
76
- else if (options.fetchExternal) {
77
- addUrl(anchor.href, { metadataOnly: true });
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
+ }
@@ -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 = ScrapeResult | RedirectEdgeResult;
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.19.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.12.0",
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": "b79b5c304820d924168a139e7148e0593c29fe1c"
54
+ "gitHead": "cbe8a4e74322386a6e07b2d4f1417dfba222f0b1"
55
55
  }