@nitpicker/crawler 0.15.0 → 0.17.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.d.ts +52 -1
- package/lib/archive/archive.js +68 -0
- package/lib/archive/create-adjunct-tables.d.ts +3 -0
- package/lib/archive/create-adjunct-tables.js +43 -0
- package/lib/archive/create-entity-tables.js +10 -0
- package/lib/archive/database.d.ts +40 -1
- package/lib/archive/database.js +54 -0
- package/lib/archive/db-ops/config/get-config.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
- package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
- package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
- package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
- package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
- package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
- package/lib/archive/db-ops/lifecycle/init.js +12 -2
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
- package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
- package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
- package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
- package/lib/archive/migrate-entity-tables.d.ts +10 -0
- package/lib/archive/migrate-entity-tables.js +10 -0
- package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
- package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
- package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
- package/lib/archive/types.d.ts +16 -0
- package/lib/classify-error-kind.d.ts +1 -0
- package/lib/classify-error-kind.js +14 -0
- package/lib/crawler/assert-chrome-installed.d.ts +24 -0
- package/lib/crawler/assert-chrome-installed.js +43 -0
- package/lib/crawler/crawler.d.ts +12 -0
- package/lib/crawler/crawler.js +239 -29
- package/lib/crawler/decode-auth-credential.d.ts +29 -0
- package/lib/crawler/decode-auth-credential.js +39 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
- package/lib/crawler/dedupe/compute-shape-key.js +56 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
- package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
- package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
- package/lib/crawler/dedupe/is-shape-capped.js +12 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
- package/lib/crawler/dedupe/types.d.ts +42 -0
- package/lib/crawler/dedupe/types.js +1 -0
- package/lib/crawler/fetch-destination.js +14 -2
- package/lib/crawler/generate-predicted-urls.d.ts +12 -0
- package/lib/crawler/generate-predicted-urls.js +33 -2
- package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
- package/lib/crawler/types.d.ts +38 -0
- package/lib/crawler-orchestrator.d.ts +39 -10
- package/lib/crawler-orchestrator.js +187 -23
- package/lib/crawler.d.ts +2 -0
- package/lib/crawler.js +2 -0
- package/lib/permanent-error-kinds.d.ts +9 -4
- package/lib/permanent-error-kinds.js +10 -4
- package/lib/types.d.ts +4 -1
- package/package.json +3 -3
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { decomposeUrl } from './decompose-url.js';
|
|
2
2
|
import { reconstructUrl } from './reconstruct-url.js';
|
|
3
|
+
const DIGITS_ONLY_PATTERN = /^\d+$/;
|
|
3
4
|
/**
|
|
4
5
|
* Generates predicted URLs by extrapolating the detected pagination pattern.
|
|
5
6
|
*
|
|
6
7
|
* Starting from `currentUrl`, applies the pattern's step `count` times to produce
|
|
7
8
|
* future page URLs (e.g. if step=1 and currentNumber=2, generates page 3, 4, ...).
|
|
8
9
|
* These URLs are pushed into the crawl queue and discarded later if they 404.
|
|
10
|
+
*
|
|
11
|
+
* Generation stops at the first token that would lose its original digit-string
|
|
12
|
+
* shape, rather than skipping it and continuing: beyond `Number.MAX_SAFE_INTEGER`,
|
|
13
|
+
* `String()` renders scientific notation (e.g. `"1e+21"`), which is not a valid
|
|
14
|
+
* path/query token and — left unguarded — becomes a self-generated URL that no
|
|
15
|
+
* page on the target site ever linked to (observed in production: a
|
|
16
|
+
* `/news/date/{year}/` pager whose per-anchor `step` was miscalculated from
|
|
17
|
+
* unrelated pages, compounding across rounds until it emitted
|
|
18
|
+
* `1.7715854126052197e+120`). A token growing far beyond its original digit
|
|
19
|
+
* count is equally implausible as a next page number. Both trends are
|
|
20
|
+
* monotonic as `i` increases, so once one prediction is rejected, every later
|
|
21
|
+
* one in the same batch would be too — there is nothing to skip past.
|
|
9
22
|
* @param pattern - The detected pagination pattern from `detectPaginationPattern()`
|
|
10
23
|
* @param currentUrl - The URL to extrapolate from (protocol-agnostic, without hash/auth)
|
|
11
24
|
* @param count - Number of predicted URLs to generate (typically equals concurrency)
|
|
@@ -17,11 +30,29 @@ export function generatePredictedUrls(pattern, currentUrl, count) {
|
|
|
17
30
|
const decomposed = decomposeUrl(currentUrl);
|
|
18
31
|
if (!decomposed)
|
|
19
32
|
return [];
|
|
33
|
+
const { pathSegments, queryValues } = decomposed;
|
|
34
|
+
const originalToken = pattern.tokenIndex < pathSegments.length
|
|
35
|
+
? pathSegments[pattern.tokenIndex]
|
|
36
|
+
: queryValues[pattern.tokenIndex - pathSegments.length];
|
|
37
|
+
if (originalToken === undefined)
|
|
38
|
+
return [];
|
|
39
|
+
// A pager jumping from 4 digits to 5 (e.g. year 9999 → 10000) is plausible;
|
|
40
|
+
// jumping straight to 6+ digits within the same predicted batch is not.
|
|
41
|
+
const maxDigits = originalToken.length + 1;
|
|
20
42
|
const results = [];
|
|
21
43
|
for (let i = 1; i <= count; i++) {
|
|
22
44
|
const nextNum = pattern.currentNumber + pattern.step * i;
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
if (!Number.isSafeInteger(nextNum))
|
|
46
|
+
break;
|
|
47
|
+
const rendered = String(nextNum);
|
|
48
|
+
if (!DIGITS_ONLY_PATTERN.test(rendered))
|
|
49
|
+
break;
|
|
50
|
+
// Preserve zero-padding width (e.g. "01" → "02"), without truncating a
|
|
51
|
+
// value that has legitimately grown past the original width.
|
|
52
|
+
const padded = rendered.padStart(originalToken.length, '0');
|
|
53
|
+
if (padded.length > maxDigits)
|
|
54
|
+
break;
|
|
55
|
+
results.push(reconstructUrl(decomposed, pattern.tokenIndex, padded));
|
|
25
56
|
}
|
|
26
57
|
return results;
|
|
27
58
|
}
|
|
@@ -26,6 +26,9 @@ import { classifyErrorKind } from '../classify-error-kind.js';
|
|
|
26
26
|
* as `timeout` via the `NetTimeoutError "Timeout: <url>"` shape.
|
|
27
27
|
* - **local-network** — operator-side connectivity loss; nothing on this
|
|
28
28
|
* machine will reach the host.
|
|
29
|
+
* - **redirect-loop** — the redirect chain itself never terminates; a
|
|
30
|
+
* puppeteer navigation follows the identical Location-header chain (or
|
|
31
|
+
* hits Chromium's own `ERR_TOO_MANY_REDIRECTS`) and loops the same way.
|
|
29
32
|
* - **protocol** — puppeteer lifecycle race; bouncing back to puppeteer
|
|
30
33
|
* reproduces the same race.
|
|
31
34
|
* - **unknown** — by design. Spinning up a fresh Chromium for every
|
package/lib/crawler/types.d.ts
CHANGED
|
@@ -190,6 +190,29 @@ export interface CrawlerOptions extends Required<Pick<ParseURLOptions, 'disableQ
|
|
|
190
190
|
* without touching the real network.
|
|
191
191
|
*/
|
|
192
192
|
networkProbe: NetworkProbe | null;
|
|
193
|
+
/**
|
|
194
|
+
* Same-cluster soft-cap threshold (`--dedupe-cap`), or `null` to disable
|
|
195
|
+
* the feature entirely (the default). When set, {@link Crawler} stops
|
|
196
|
+
* enqueueing newly-discovered URLs whose shape (see `computeShapeKey`)
|
|
197
|
+
* has accumulated this many matching-signature observations (see
|
|
198
|
+
* `DedupeCapTracker`).
|
|
199
|
+
*/
|
|
200
|
+
dedupeCap: number | null;
|
|
201
|
+
/**
|
|
202
|
+
* Hard cap on the number of distinct URL shapes the same-cluster soft
|
|
203
|
+
* cap tracks at once (`--dedupe-map-cap`); the least-recently-touched
|
|
204
|
+
* shape is evicted beyond this. Only relevant when {@link dedupeCap} is
|
|
205
|
+
* non-null.
|
|
206
|
+
*/
|
|
207
|
+
dedupeMapCap: number;
|
|
208
|
+
/**
|
|
209
|
+
* Shape keys already confirmed capped in a prior session
|
|
210
|
+
* (persisted as `dedupe_cap_events.shape_key`), seeded into the
|
|
211
|
+
* tracker's sticky set so `--resume` / `--append` / `--retry-failed` /
|
|
212
|
+
* `--inventory` do not re-admit a trap this crawl already paid the cost
|
|
213
|
+
* of discovering once. Ignored when {@link dedupeCap} is `null`.
|
|
214
|
+
*/
|
|
215
|
+
preloadedStickyShapeKeys: readonly string[];
|
|
193
216
|
}
|
|
194
217
|
/**
|
|
195
218
|
* Inventory-mode runtime configuration. Passed from
|
|
@@ -436,6 +459,21 @@ export interface CrawlerEventTypes {
|
|
|
436
459
|
/** Epoch ms the recovery probe first succeeded. */
|
|
437
460
|
endedAt: number;
|
|
438
461
|
};
|
|
462
|
+
/**
|
|
463
|
+
* Emitted the instant the opt-in same-cluster soft cap
|
|
464
|
+
* ({@link CrawlerOptions.dedupeCap}) confirms a URL shape as a trap (see
|
|
465
|
+
* `DedupeCapTracker`). The orchestrator persists this via
|
|
466
|
+
* `Archive.insertDedupeCapEvent` and must remember the returned row id so
|
|
467
|
+
* `rejected_count` can be finalized once at `crawlEnd` (`Crawler` itself
|
|
468
|
+
* never touches the archive).
|
|
469
|
+
*/
|
|
470
|
+
dedupeCap: {
|
|
471
|
+
shapeKey: string;
|
|
472
|
+
sampleUrl: string;
|
|
473
|
+
bodyHash: Buffer;
|
|
474
|
+
effectiveThreshold: number;
|
|
475
|
+
observedCount: number;
|
|
476
|
+
};
|
|
439
477
|
}
|
|
440
478
|
/**
|
|
441
479
|
* Tunables for `NetworkOutageDetector`.
|
|
@@ -69,6 +69,18 @@ interface CrawlConfig extends Config {
|
|
|
69
69
|
* `options` so an E2E test can inject it via the public API.
|
|
70
70
|
*/
|
|
71
71
|
networkProbe: NetworkProbe | null;
|
|
72
|
+
/** See {@link CrawlerOptions.dedupeCap}. `null`/omitted disables the feature. */
|
|
73
|
+
dedupeCap: number | null;
|
|
74
|
+
/** See {@link CrawlerOptions.dedupeMapCap}. Omitted falls through to `Crawler`'s own default. */
|
|
75
|
+
dedupeMapCap: number;
|
|
76
|
+
/**
|
|
77
|
+
* See {@link CrawlerOptions.preloadedStickyShapeKeys}. Set internally by
|
|
78
|
+
* the four resuming-session static methods
|
|
79
|
+
* (`append`/`inventory`/`retryFailed`/`resume`), each independently
|
|
80
|
+
* calling `archive.listDedupeCapShapeKeys()`; not part of the public
|
|
81
|
+
* options a caller of those methods passes directly.
|
|
82
|
+
*/
|
|
83
|
+
preloadedStickyShapeKeys: readonly string[];
|
|
72
84
|
}
|
|
73
85
|
/**
|
|
74
86
|
* Callback invoked after the CrawlerOrchestrator instance is fully initialized
|
|
@@ -216,9 +228,10 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
|
|
|
216
228
|
* 1. Open the archive (writer mode, takes the archive lock).
|
|
217
229
|
* 2. Reject list-mode archives — they hold metadata-only rows that
|
|
218
230
|
* inventory has no business touching.
|
|
219
|
-
* 3.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
231
|
+
* 3. Warn (but proceed) on archives with unfinished `pending` URLs —
|
|
232
|
+
* crawled-wins source priority keeps their labels stable; the
|
|
233
|
+
* operator can `--resume` first if they want the prior work
|
|
234
|
+
* finalized.
|
|
222
235
|
* 4. If `source` is given, archive its exact bytes under
|
|
223
236
|
* `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
|
|
224
237
|
* Done before scope classification so even a run that discards every
|
|
@@ -231,17 +244,33 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
|
|
|
231
244
|
* 6. Subtract URLs that already exist in `pages` or `resources` so the
|
|
232
245
|
* second (and N-th) inventory pass is a no-op for known rows — keeps
|
|
233
246
|
* `'inventory-seed'` rows from being silently demoted.
|
|
234
|
-
* 7.
|
|
247
|
+
* 7. Split the remaining novel URLs on the effective `excludes` /
|
|
248
|
+
* `excludeUrls` (archived config overlaid with this run's
|
|
249
|
+
* overrides — the same inputs the crawl's fetch-time
|
|
250
|
+
* `shouldSkipUrl` gate uses). Matching URLs are recorded as
|
|
251
|
+
* terminal skipped pages (`is_skipped=1`,
|
|
252
|
+
* `skip_reason='excluded'`, `source='inventory-seed'`) instead of
|
|
253
|
+
* being imported — the same end state a link-discovered excluded
|
|
254
|
+
* URL reaches in a normal crawl — and counted as
|
|
255
|
+
* `exclude_skipped` (issue #260). Running this after step 6 keeps
|
|
256
|
+
* previously crawled rows that newly match the exclusion config
|
|
257
|
+
* untouched (crawled-wins). `excludeKeywords` does not
|
|
258
|
+
* participate here: it matches rendered page content, which a URL
|
|
259
|
+
* list does not have — HTML seeds still get it at render time via
|
|
260
|
+
* the browser verdict.
|
|
261
|
+
* 8. Make `<archive>.bak`. Anything thrown beyond this point restores
|
|
235
262
|
* from the backup.
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
263
|
+
* 9. Classify each importable novel URL by URL-extension heuristic
|
|
264
|
+
* (no probe — see the in-body rationale). HTML-looking URLs are
|
|
265
|
+
* queued as Crawler seeds (`'inventory-seed'`); everything else is
|
|
266
|
+
* recorded in `resources` directly as `'inventory-seed'` (no
|
|
267
|
+
* browser launch, no HEAD).
|
|
268
|
+
* 10. If any HTML seeds exist, start a Crawler with
|
|
240
269
|
* `inventoryMode = { seedUrls }` so the rendered page and every newly
|
|
241
270
|
* discovered downstream link is labelled correctly. `resume` is fed
|
|
242
271
|
* the existing `scraped` / `resources` sets so links into already-
|
|
243
272
|
* crawled pages stop at the seen-gate without re-rendering.
|
|
244
|
-
*
|
|
273
|
+
* 11. Drop the backup on success; restore it on any throw.
|
|
245
274
|
*
|
|
246
275
|
* Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
|
|
247
276
|
* `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
|
|
@@ -264,7 +293,7 @@ export declare class CrawlerOrchestrator extends EventEmitter<CrawlEvent> {
|
|
|
264
293
|
* `inventoryUrls` in-memory; the audit row's `source_file_sha256`
|
|
265
294
|
* column will be `NULL` and no source list is archived.
|
|
266
295
|
* @returns The orchestrator instance after a successful inventory pass.
|
|
267
|
-
* @throws {Error} When `inventoryUrls` is empty
|
|
296
|
+
* @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
|
|
268
297
|
*/
|
|
269
298
|
static inventory(archivePath: string, inventoryUrls: string[], options?: Partial<CrawlConfig>, initializedCallback?: CrawlInitializedCallback, source?: InventorySource | null): Promise<CrawlerOrchestrator>;
|
|
270
299
|
/**
|
|
@@ -16,6 +16,7 @@ import { isLikelyHtmlUrl } from './crawler/is-likely-html-url.js';
|
|
|
16
16
|
import { networkOutageSummaryCounter } from './crawler/network-outage-summary-counter.js';
|
|
17
17
|
import { PreloadShortCircuitError } from './crawler/preload-short-circuit-error.js';
|
|
18
18
|
import { protocolAgnosticKey } from './crawler/protocol-agnostic-key.js';
|
|
19
|
+
import { shouldSkipUrl } from './crawler/should-skip-url.js';
|
|
19
20
|
import { crawlerLog, log } from './debug.js';
|
|
20
21
|
import { normalizeToArray } from './normalize-to-array.js';
|
|
21
22
|
import { resolveOutputPath } from './resolve-output-path.js';
|
|
@@ -64,6 +65,15 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
64
65
|
#archive;
|
|
65
66
|
/** The crawler engine that discovers and scrapes pages. */
|
|
66
67
|
#crawler;
|
|
68
|
+
/**
|
|
69
|
+
* `dedupe_cap_events.id` for each shape confirmed capped this session, so
|
|
70
|
+
* `crawlEnd` can look up the right row to finalize with
|
|
71
|
+
* `Crawler#getDedupeCapRejections`'s counts. A `Map` (not a single
|
|
72
|
+
* scalar like {@link #openNetworkOutageId}) because, unlike a network
|
|
73
|
+
* outage, more than one shape can be capped simultaneously within one
|
|
74
|
+
* crawl.
|
|
75
|
+
*/
|
|
76
|
+
#dedupeCapEventIds = new Map();
|
|
67
77
|
/** Whether the crawl was started from a pre-defined URL list (non-recursive mode). */
|
|
68
78
|
#fromList;
|
|
69
79
|
/**
|
|
@@ -158,6 +168,13 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
158
168
|
networkOutageHostThreshold: options?.networkOutageHostThreshold,
|
|
159
169
|
networkOutageProbeIntervalMs: options?.networkOutageProbeIntervalMs,
|
|
160
170
|
networkProbe: options?.networkProbe ?? null,
|
|
171
|
+
dedupeCap: options?.dedupeCap ?? null,
|
|
172
|
+
dedupeMapCap: options?.dedupeMapCap,
|
|
173
|
+
// Only the four resuming-session static methods
|
|
174
|
+
// (`append`/`inventory`/`retryFailed`/`resume`) pass this — a
|
|
175
|
+
// fresh `crawling()` has no archive history to seed from (see
|
|
176
|
+
// `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc).
|
|
177
|
+
preloadedStickyShapeKeys: options?.preloadedStickyShapeKeys ?? [],
|
|
161
178
|
});
|
|
162
179
|
}
|
|
163
180
|
/**
|
|
@@ -298,6 +315,23 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
298
315
|
})
|
|
299
316
|
.catch((error) => reject(error));
|
|
300
317
|
});
|
|
318
|
+
this.#crawler.on('dedupeCap', ({ shapeKey, sampleUrl, bodyHash, effectiveThreshold, observedCount }) => {
|
|
319
|
+
crawlerLog('Dedupe cap reached: shapeKey=%s effectiveThreshold=%d observedCount=%d', shapeKey, effectiveThreshold, observedCount);
|
|
320
|
+
console.error(`[dedupe-cap] same-cluster trap confirmed: ${shapeKey} (sample: ${sampleUrl})`);
|
|
321
|
+
writeQueue
|
|
322
|
+
.enqueue(async () => {
|
|
323
|
+
const id = await this.#archive.insertDedupeCapEvent({
|
|
324
|
+
shapeKey,
|
|
325
|
+
sampleUrl,
|
|
326
|
+
bodyHash,
|
|
327
|
+
effectiveThreshold,
|
|
328
|
+
observedCount,
|
|
329
|
+
detectedAt: Date.now(),
|
|
330
|
+
});
|
|
331
|
+
this.#dedupeCapEventIds.set(shapeKey, id);
|
|
332
|
+
})
|
|
333
|
+
.catch((error) => reject(error));
|
|
334
|
+
});
|
|
301
335
|
this.#crawler.on('response', ({ resource, source }) => {
|
|
302
336
|
writeQueue
|
|
303
337
|
.enqueue(() => this.#archive.setResources(resource, source))
|
|
@@ -314,6 +348,51 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
314
348
|
.catch((error) => reject(error));
|
|
315
349
|
});
|
|
316
350
|
this.#crawler.on('crawlEnd', () => {
|
|
351
|
+
// Deferred to INSIDE a queued closure, not read synchronously
|
|
352
|
+
// here, for the same reason `networkOutageRecovered`'s handler
|
|
353
|
+
// defers reading `#openNetworkOutageId`: a `dedupeCap` event's
|
|
354
|
+
// INSERT closure may still be queued (not yet executed) at the
|
|
355
|
+
// instant `crawlEnd` fires. `WriteQueue` runs enqueued
|
|
356
|
+
// operations in submission order, so by the time THIS closure
|
|
357
|
+
// executes, every earlier-queued `dedupeCap` INSERT has
|
|
358
|
+
// already completed and `#dedupeCapEventIds` is reliably
|
|
359
|
+
// populated.
|
|
360
|
+
writeQueue
|
|
361
|
+
.enqueue(async () => {
|
|
362
|
+
const rejections = this.#crawler.getDedupeCapRejections();
|
|
363
|
+
// Finalize every shape capped THIS session (has an id in
|
|
364
|
+
// `#dedupeCapEventIds`), not just the ones with a nonzero
|
|
365
|
+
// rejection count — a shape that capped near the end of the
|
|
366
|
+
// crawl (or whose remaining anchors all happened to be
|
|
367
|
+
// discovered before it capped) never enters `rejections` at
|
|
368
|
+
// all, and would otherwise stay `rejected_count: NULL` forever
|
|
369
|
+
// despite the crawl completing normally, corrupting the "NULL
|
|
370
|
+
// means the crawl never reached crawlEnd" contract
|
|
371
|
+
// `list-dedupe-cap-events.ts` documents.
|
|
372
|
+
const shapeKeysToFinalize = new Set([
|
|
373
|
+
...this.#dedupeCapEventIds.keys(),
|
|
374
|
+
...rejections.keys(),
|
|
375
|
+
]);
|
|
376
|
+
await Promise.all([...shapeKeysToFinalize].map((shapeKey) => {
|
|
377
|
+
const rejectedCount = rejections.get(shapeKey) ?? 0;
|
|
378
|
+
const id = this.#dedupeCapEventIds.get(shapeKey);
|
|
379
|
+
// A shape capped THIS session has an id here (the
|
|
380
|
+
// `dedupeCap` event always enqueues an INSERT before any
|
|
381
|
+
// rejection for that shape can be counted) and is
|
|
382
|
+
// finalized once via its row id. A shape with no id was
|
|
383
|
+
// never observed this session at all — it was preloaded
|
|
384
|
+
// into `DedupeCapTracker`'s sticky set from an EARLIER
|
|
385
|
+
// session's `dedupe_cap_events` row (see
|
|
386
|
+
// `CrawlConfig.preloadedStickyShapeKeys`'s JSDoc), so gate
|
|
387
|
+
// rejections still accumulate for it but no new row (and
|
|
388
|
+
// thus no id) is ever created. That earlier row's count is
|
|
389
|
+
// accumulated onto by shape_key instead of overwritten.
|
|
390
|
+
return id === undefined
|
|
391
|
+
? this.#archive.accumulateDedupeCapRejectedCount(shapeKey, rejectedCount)
|
|
392
|
+
: this.#archive.finalizeDedupeCapEvent(id, rejectedCount);
|
|
393
|
+
}));
|
|
394
|
+
})
|
|
395
|
+
.catch((error) => reject(error));
|
|
317
396
|
writeQueue
|
|
318
397
|
.drain()
|
|
319
398
|
.then(() => resolve())
|
|
@@ -521,9 +600,14 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
521
600
|
scopeMap.set(parsed.hostname, [...existing, parsed]);
|
|
522
601
|
}
|
|
523
602
|
await archive.repromoteExternalPages(scopeMap, archived);
|
|
603
|
+
// Seed the sticky set from prior sessions' confirmed traps so
|
|
604
|
+
// `--append` does not pay the cost of re-discovering them (see
|
|
605
|
+
// `DedupeCapTracker`'s constructor JSDoc).
|
|
606
|
+
const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
|
|
524
607
|
const orchestrator = new CrawlerOrchestrator(archive, {
|
|
525
608
|
...mergedConfig,
|
|
526
609
|
roots: mergedRoots,
|
|
610
|
+
preloadedStickyShapeKeys,
|
|
527
611
|
});
|
|
528
612
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
529
613
|
const resources = await archive.getResourceUrlList();
|
|
@@ -575,9 +659,10 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
575
659
|
* 1. Open the archive (writer mode, takes the archive lock).
|
|
576
660
|
* 2. Reject list-mode archives — they hold metadata-only rows that
|
|
577
661
|
* inventory has no business touching.
|
|
578
|
-
* 3.
|
|
579
|
-
*
|
|
580
|
-
*
|
|
662
|
+
* 3. Warn (but proceed) on archives with unfinished `pending` URLs —
|
|
663
|
+
* crawled-wins source priority keeps their labels stable; the
|
|
664
|
+
* operator can `--resume` first if they want the prior work
|
|
665
|
+
* finalized.
|
|
581
666
|
* 4. If `source` is given, archive its exact bytes under
|
|
582
667
|
* `inventory/<sha256>.txt` (see {@link Archive.saveInventorySourceList}).
|
|
583
668
|
* Done before scope classification so even a run that discards every
|
|
@@ -590,17 +675,33 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
590
675
|
* 6. Subtract URLs that already exist in `pages` or `resources` so the
|
|
591
676
|
* second (and N-th) inventory pass is a no-op for known rows — keeps
|
|
592
677
|
* `'inventory-seed'` rows from being silently demoted.
|
|
593
|
-
* 7.
|
|
678
|
+
* 7. Split the remaining novel URLs on the effective `excludes` /
|
|
679
|
+
* `excludeUrls` (archived config overlaid with this run's
|
|
680
|
+
* overrides — the same inputs the crawl's fetch-time
|
|
681
|
+
* `shouldSkipUrl` gate uses). Matching URLs are recorded as
|
|
682
|
+
* terminal skipped pages (`is_skipped=1`,
|
|
683
|
+
* `skip_reason='excluded'`, `source='inventory-seed'`) instead of
|
|
684
|
+
* being imported — the same end state a link-discovered excluded
|
|
685
|
+
* URL reaches in a normal crawl — and counted as
|
|
686
|
+
* `exclude_skipped` (issue #260). Running this after step 6 keeps
|
|
687
|
+
* previously crawled rows that newly match the exclusion config
|
|
688
|
+
* untouched (crawled-wins). `excludeKeywords` does not
|
|
689
|
+
* participate here: it matches rendered page content, which a URL
|
|
690
|
+
* list does not have — HTML seeds still get it at render time via
|
|
691
|
+
* the browser verdict.
|
|
692
|
+
* 8. Make `<archive>.bak`. Anything thrown beyond this point restores
|
|
594
693
|
* from the backup.
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
694
|
+
* 9. Classify each importable novel URL by URL-extension heuristic
|
|
695
|
+
* (no probe — see the in-body rationale). HTML-looking URLs are
|
|
696
|
+
* queued as Crawler seeds (`'inventory-seed'`); everything else is
|
|
697
|
+
* recorded in `resources` directly as `'inventory-seed'` (no
|
|
698
|
+
* browser launch, no HEAD).
|
|
699
|
+
* 10. If any HTML seeds exist, start a Crawler with
|
|
599
700
|
* `inventoryMode = { seedUrls }` so the rendered page and every newly
|
|
600
701
|
* discovered downstream link is labelled correctly. `resume` is fed
|
|
601
702
|
* the existing `scraped` / `resources` sets so links into already-
|
|
602
703
|
* crawled pages stop at the seen-gate without re-rendering.
|
|
603
|
-
*
|
|
704
|
+
* 11. Drop the backup on success; restore it on any throw.
|
|
604
705
|
*
|
|
605
706
|
* Mutually exclusive with `--append` / `--retry-failed` / `--resume` /
|
|
606
707
|
* `--diff` / `--list` / `--list-file` / `--single` / `--output` — the
|
|
@@ -623,7 +724,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
623
724
|
* `inventoryUrls` in-memory; the audit row's `source_file_sha256`
|
|
624
725
|
* column will be `NULL` and no source list is archived.
|
|
625
726
|
* @returns The orchestrator instance after a successful inventory pass.
|
|
626
|
-
* @throws {Error} When `inventoryUrls` is empty
|
|
727
|
+
* @throws {Error} When `inventoryUrls` is empty or the archive is in list mode. Unresolved pending URLs from a previous crawl do NOT throw — see step 3.
|
|
627
728
|
*/
|
|
628
729
|
static async inventory(archivePath, inventoryUrls, options, initializedCallback, source = null) {
|
|
629
730
|
if (inventoryUrls.length === 0) {
|
|
@@ -712,17 +813,49 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
712
813
|
});
|
|
713
814
|
const knownCount = existingPageUrls.size + existingResourceUrls.size;
|
|
714
815
|
log('[inventory] %d in-scope, %d already in archive, %d new', inScope.length, knownCount, novelUrls.length);
|
|
816
|
+
// Split the novel URLs on the exclusion config BEFORE the
|
|
817
|
+
// HTML/non-HTML classification, so an exclude-matched URL is
|
|
818
|
+
// recorded as a terminal skipped page instead of being imported
|
|
819
|
+
// (issue #260). The inputs mirror the scrape phase's fetch-time
|
|
820
|
+
// gate (`shouldSkipUrl` in `crawler.ts` fed by the constructor's
|
|
821
|
+
// merge): archived config overlaid with this run's overrides,
|
|
822
|
+
// and `DEFAULT_EXCLUDED_EXTERNAL_URLS` merged ahead of the
|
|
823
|
+
// user's prefixes — classification and gate must never disagree
|
|
824
|
+
// about the same URL. Running this AFTER the known-URL filter is
|
|
825
|
+
// deliberate: a previously crawled row that newly matches the
|
|
826
|
+
// exclusion config stays untouched (crawled-wins), matching how
|
|
827
|
+
// `getExistingPageUrls` shields known rows from re-labelling.
|
|
828
|
+
// `excludeKeywords` is deliberately absent: it matches rendered
|
|
829
|
+
// page content, which a URL list does not have — HTML seeds
|
|
830
|
+
// still get it at render time via the browser verdict.
|
|
831
|
+
const effectiveConfig = { ...archived, ...cleanObject(options) };
|
|
832
|
+
const excludes = normalizeToArray(effectiveConfig.excludes);
|
|
833
|
+
const excludeUrls = [
|
|
834
|
+
...DEFAULT_EXCLUDED_EXTERNAL_URLS,
|
|
835
|
+
...normalizeToArray(effectiveConfig.excludeUrls),
|
|
836
|
+
];
|
|
837
|
+
const excludedNovelUrls = [];
|
|
838
|
+
const importableNovelUrls = [];
|
|
839
|
+
for (const url of novelUrls) {
|
|
840
|
+
if (shouldSkipUrl({ url, excludes, excludeUrls, options: effectiveConfig })) {
|
|
841
|
+
excludedNovelUrls.push(url);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
importableNovelUrls.push(url);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (excludedNovelUrls.length > 0) {
|
|
848
|
+
log('[inventory] %d URL(s) recorded as skipped (matched excludes / excludeUrls)', excludedNovelUrls.length);
|
|
849
|
+
}
|
|
715
850
|
if (novelUrls.length === 0) {
|
|
716
851
|
// Nothing to do — release the archive cleanly without taking a
|
|
717
852
|
// backup. The orchestrator returned here is empty; the caller
|
|
718
|
-
// should only invoke `close` on it.
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
};
|
|
723
|
-
const orchestrator = new CrawlerOrchestrator(archive, noopConfig);
|
|
853
|
+
// should only invoke `close` on it. `effectiveConfig` is the
|
|
854
|
+
// same archived-plus-overrides merge every other path in this
|
|
855
|
+
// method sees.
|
|
856
|
+
const orchestrator = new CrawlerOrchestrator(archive, effectiveConfig);
|
|
724
857
|
if (initializedCallback) {
|
|
725
|
-
await initializedCallback(orchestrator,
|
|
858
|
+
await initializedCallback(orchestrator, effectiveConfig);
|
|
726
859
|
}
|
|
727
860
|
return orchestrator;
|
|
728
861
|
}
|
|
@@ -738,7 +871,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
738
871
|
// clause). This flag steers the catch below.
|
|
739
872
|
let ingestionComplete = false;
|
|
740
873
|
try {
|
|
741
|
-
// Classify novel URLs by URL-extension heuristic (no I/O).
|
|
874
|
+
// Classify importable novel URLs by URL-extension heuristic (no I/O).
|
|
742
875
|
// Source file lists come from `ls` on the doc-root, so the
|
|
743
876
|
// extension reflects the real file type — a HEAD pre-flight
|
|
744
877
|
// here would be pure wasted I/O. Edge cases:
|
|
@@ -769,7 +902,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
769
902
|
// null as "not probed" rather than "failed".
|
|
770
903
|
const rawHtmlSeeds = [];
|
|
771
904
|
const nonHtmlSeeds = [];
|
|
772
|
-
for (const url of
|
|
905
|
+
for (const url of importableNovelUrls) {
|
|
773
906
|
if (isLikelyHtmlUrl(url)) {
|
|
774
907
|
rawHtmlSeeds.push(url);
|
|
775
908
|
}
|
|
@@ -809,7 +942,15 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
809
942
|
// these rows up on the next `--resume` via the
|
|
810
943
|
// `OR p.source != 'crawled'` clause.
|
|
811
944
|
await archive.insertInventorySeeds(htmlSeeds);
|
|
812
|
-
|
|
945
|
+
// Record exclude-matched novel URLs as terminal skipped pages
|
|
946
|
+
// (`is_skipped=1`, `skip_reason='excluded'`,
|
|
947
|
+
// `source='inventory-seed'`) — the same end state the normal
|
|
948
|
+
// crawl's fetch-time gate produces for link-discovered
|
|
949
|
+
// excluded URLs, so the archive looks identical no matter
|
|
950
|
+
// how the URL was discovered. Inside the `.bak` window for
|
|
951
|
+
// the same all-or-nothing reason as the seed inserts above.
|
|
952
|
+
await archive.insertInventorySkippedPages(excludedNovelUrls);
|
|
953
|
+
log('[inventory] %d HTML seed(s), %d non-HTML resource(s), %d skipped page(s) recorded', htmlSeeds.length, nonHtmlSeeds.length, excludedNovelUrls.length);
|
|
813
954
|
// Audit row is written *inside* the `.bak` window: a libsql
|
|
814
955
|
// hiccup or transient lock on the INSERT aborts the ingestion
|
|
815
956
|
// and the `.bak` restore wipes the pre-inserted seeds too,
|
|
@@ -823,6 +964,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
823
964
|
htmlSeedsCount: htmlSeeds.length,
|
|
824
965
|
nonHtmlCount: nonHtmlSeeds.length,
|
|
825
966
|
outOfScope,
|
|
967
|
+
excludeSkipped: excludedNovelUrls.length,
|
|
826
968
|
sourceFileSha256: source?.sha256 ?? null,
|
|
827
969
|
invalidSkipped: source?.invalidLineCount ?? null,
|
|
828
970
|
});
|
|
@@ -842,8 +984,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
842
984
|
// (matches the rest of the orchestrator's public surface —
|
|
843
985
|
// no inventory bookkeeping leaks out).
|
|
844
986
|
const baseConfig = {
|
|
845
|
-
...
|
|
846
|
-
...cleanObject(options),
|
|
987
|
+
...effectiveConfig,
|
|
847
988
|
recursive: true,
|
|
848
989
|
fromList: false,
|
|
849
990
|
};
|
|
@@ -856,6 +997,16 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
856
997
|
inventoryMode: { seedUrls: seedSet },
|
|
857
998
|
};
|
|
858
999
|
if (htmlSeeds.length > 0) {
|
|
1000
|
+
// Seed the sticky set from prior sessions' confirmed traps
|
|
1001
|
+
// so `--inventory` does not pay the cost of
|
|
1002
|
+
// re-discovering them (see `DedupeCapTracker`'s
|
|
1003
|
+
// constructor JSDoc). Scoped to this branch only,
|
|
1004
|
+
// matching `#preloadDnsBurnedHostCache`'s scoping below —
|
|
1005
|
+
// the fallback (non-HTML-only) branch never calls
|
|
1006
|
+
// `orchestrator.crawling(...)`, so the tracker is never
|
|
1007
|
+
// consulted there.
|
|
1008
|
+
orchestratorOptions.preloadedStickyShapeKeys =
|
|
1009
|
+
await archive.listDedupeCapShapeKeys();
|
|
859
1010
|
const orchestrator = new CrawlerOrchestrator(archive, orchestratorOptions);
|
|
860
1011
|
// Re-read pending *after* the pre-insert so the strict-
|
|
861
1012
|
// pending set includes the freshly inserted
|
|
@@ -1007,7 +1158,14 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
1007
1158
|
log('Start retrying failed pages');
|
|
1008
1159
|
log('Archive %s', absFilePath);
|
|
1009
1160
|
log('Reset %d failed page(s)', resetUrls.length);
|
|
1010
|
-
|
|
1161
|
+
// Seed the sticky set from prior sessions' confirmed traps so
|
|
1162
|
+
// `--retry-failed` does not pay the cost of re-discovering
|
|
1163
|
+
// them (see `DedupeCapTracker`'s constructor JSDoc).
|
|
1164
|
+
const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
|
|
1165
|
+
const orchestrator = new CrawlerOrchestrator(archive, {
|
|
1166
|
+
...config,
|
|
1167
|
+
preloadedStickyShapeKeys,
|
|
1168
|
+
});
|
|
1011
1169
|
const { scraped, pending } = await archive.getCrawlingState();
|
|
1012
1170
|
const resources = await archive.getResourceUrlList();
|
|
1013
1171
|
const pagesScrapedOffset = await archive.getScrapedHtmlPageCount();
|
|
@@ -1056,9 +1214,14 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
1056
1214
|
static async resume(stubPath, options, initializedCallback) {
|
|
1057
1215
|
const archive = await Archive.resume(stubPath);
|
|
1058
1216
|
const archivedConfig = await archive.getConfig();
|
|
1217
|
+
// Seed the sticky set from prior sessions' confirmed traps so
|
|
1218
|
+
// `--resume` does not pay the cost of re-discovering them (see
|
|
1219
|
+
// `DedupeCapTracker`'s constructor JSDoc).
|
|
1220
|
+
const preloadedStickyShapeKeys = await archive.listDedupeCapShapeKeys();
|
|
1059
1221
|
const config = {
|
|
1060
1222
|
...archivedConfig,
|
|
1061
1223
|
...cleanObject(options),
|
|
1224
|
+
preloadedStickyShapeKeys,
|
|
1062
1225
|
};
|
|
1063
1226
|
const orchestrator = new CrawlerOrchestrator(archive, config);
|
|
1064
1227
|
const _url = await archive.getUrl();
|
|
@@ -1147,6 +1310,7 @@ export class CrawlerOrchestrator extends EventEmitter {
|
|
|
1147
1310
|
new_pages: aggregates.htmlSeedsCount,
|
|
1148
1311
|
new_resources: aggregates.nonHtmlCount,
|
|
1149
1312
|
scope_skipped: aggregates.outOfScope,
|
|
1313
|
+
exclude_skipped: aggregates.excludeSkipped,
|
|
1150
1314
|
invalid_skipped: aggregates.invalidSkipped,
|
|
1151
1315
|
});
|
|
1152
1316
|
}
|
package/lib/crawler.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
|
|
|
33
33
|
export { decodeStoredBlob } from './archive/decode-html-blob.js';
|
|
34
34
|
export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
|
|
35
35
|
export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
|
|
36
|
+
export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
|
|
36
37
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
37
38
|
export * from './types.js';
|
|
38
39
|
export * from './crawler/types.js';
|
|
@@ -46,6 +47,7 @@ export type { NetworkProbe } from './crawler/probe-network.js';
|
|
|
46
47
|
export { probeNetwork } from './crawler/probe-network.js';
|
|
47
48
|
export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
|
|
48
49
|
export { chooseProbeHost } from './crawler/choose-probe-host.js';
|
|
50
|
+
export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
|
|
49
51
|
export { computeFileSha256 } from './utils/compute-file-sha256.js';
|
|
50
52
|
export { populateEntityTables } from './archive/populate-entity-tables/populate-entities.js';
|
|
51
53
|
export type { PageDomPathResolver } from './archive/populate-entity-tables/populate-image-items.js';
|
package/lib/crawler.js
CHANGED
|
@@ -31,6 +31,7 @@ export { computeBodyHash } from './archive/body-hash/compute-body-hash.js';
|
|
|
31
31
|
export { decodeStoredBlob } from './archive/decode-html-blob.js';
|
|
32
32
|
export { computeTierAAliasKey } from './archive/url-alias/compute-tier-a-alias-key.js';
|
|
33
33
|
export { computeTierBAliasKey } from './archive/url-alias/compute-tier-b-alias-key.js';
|
|
34
|
+
export { computeShapeKey } from './crawler/dedupe/compute-shape-key.js';
|
|
34
35
|
// Core
|
|
35
36
|
export { DEFAULT_EXCLUDED_EXTERNAL_URLS, CrawlerOrchestrator, } from './crawler-orchestrator.js';
|
|
36
37
|
export * from './types.js';
|
|
@@ -43,6 +44,7 @@ export { default as NetworkGate } from './crawler/network-gate.js';
|
|
|
43
44
|
export { probeNetwork } from './crawler/probe-network.js';
|
|
44
45
|
export { computeOutageClampTimestamp } from './archive/db-ops/outages/compute-outage-clamp-timestamp.js';
|
|
45
46
|
export { chooseProbeHost } from './crawler/choose-probe-host.js';
|
|
47
|
+
export { assertChromeIsInstalled } from './crawler/assert-chrome-installed.js';
|
|
46
48
|
export { computeFileSha256 } from './utils/compute-file-sha256.js';
|
|
47
49
|
// 0.13 ref-table population (issue #191, epic #103). Exposed as the
|
|
48
50
|
// public seam that the migration script (`scripts/migrate-to-0.13.mjs`)
|
|
@@ -7,11 +7,12 @@ import type { ErrorKind } from './types.js';
|
|
|
7
7
|
* Used by `resetFailedPages` to exclude pages whose latest recorded error
|
|
8
8
|
* falls in this set, so `--retry-failed` actually converges: without the
|
|
9
9
|
* exclusion, NXDOMAIN / TLS mismatch / `ERR_BLOCKED_BY_CLIENT` /
|
|
10
|
-
* `ECONNREFUSED` / HTTP parse-error pages would be reset to
|
|
11
|
-
* iteration, the crawler would re-attempt them, they would
|
|
12
|
-
* same way, and the retry-target count would stay constant
|
|
10
|
+
* `ECONNREFUSED` / HTTP parse-error / redirect-loop pages would be reset to
|
|
11
|
+
* pending on every iteration, the crawler would re-attempt them, they would
|
|
12
|
+
* fail again the same way, and the retry-target count would stay constant
|
|
13
|
+
* forever.
|
|
13
14
|
*
|
|
14
|
-
* Why these
|
|
15
|
+
* Why these six and not others:
|
|
15
16
|
* - **dns** — `ENOTFOUND` / `ERR_NAME_NOT_RESOLVED` are authoritative DNS
|
|
16
17
|
* answers; the host is gone (or never existed). EAI_AGAIN is split out as
|
|
17
18
|
* `dns-transient` precisely so it is NOT in this set.
|
|
@@ -28,6 +29,10 @@ import type { ErrorKind } from './types.js';
|
|
|
28
29
|
* the listener; either no process is listening on the port or its accept
|
|
29
30
|
* queue rejected the connection. Either way the answer is final until the
|
|
30
31
|
* server operator intervenes.
|
|
32
|
+
* - **redirect-loop** — `Maximum number of redirects exceeded` /
|
|
33
|
+
* `ERR_TOO_MANY_REDIRECTS` means the site's own redirect chain never
|
|
34
|
+
* terminates; the exact same chain is served on every future fetch until
|
|
35
|
+
* the site operator fixes it.
|
|
31
36
|
*
|
|
32
37
|
* Notably absent (intentionally retryable):
|
|
33
38
|
* - `connection-reset` / `connection-timeout` — could be middlebox or
|