@bluefields/fetcher 0.2.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/LICENSE +661 -0
- package/dist/asset-block-measure.d.ts +14 -0
- package/dist/asset-block-measure.js +82 -0
- package/dist/asset-block-measure.js.map +1 -0
- package/dist/browser-pool.d.ts +65 -0
- package/dist/browser-pool.js +161 -0
- package/dist/browser-pool.js.map +1 -0
- package/dist/cost.d.ts +51 -0
- package/dist/cost.js +54 -0
- package/dist/cost.js.map +1 -0
- package/dist/fetcher-brightdata.d.ts +52 -0
- package/dist/fetcher-brightdata.js +135 -0
- package/dist/fetcher-brightdata.js.map +1 -0
- package/dist/fetcher-escalating.d.ts +100 -0
- package/dist/fetcher-escalating.js +489 -0
- package/dist/fetcher-escalating.js.map +1 -0
- package/dist/fetcher-http.d.ts +81 -0
- package/dist/fetcher-http.js +395 -0
- package/dist/fetcher-http.js.map +1 -0
- package/dist/fetcher-patchright.d.ts +212 -0
- package/dist/fetcher-patchright.js +505 -0
- package/dist/fetcher-patchright.js.map +1 -0
- package/dist/fetcher-robots-gate.d.ts +60 -0
- package/dist/fetcher-robots-gate.js +87 -0
- package/dist/fetcher-robots-gate.js.map +1 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +219 -0
- package/dist/index.js.map +1 -0
- package/dist/internal-options.d.ts +59 -0
- package/dist/internal-options.js +16 -0
- package/dist/internal-options.js.map +1 -0
- package/dist/perf-harness.d.ts +105 -0
- package/dist/perf-harness.js +176 -0
- package/dist/perf-harness.js.map +1 -0
- package/dist/storage-memory.d.ts +18 -0
- package/dist/storage-memory.js +45 -0
- package/dist/storage-memory.js.map +1 -0
- package/dist/storage-r2.d.ts +44 -0
- package/dist/storage-r2.js +209 -0
- package/dist/storage-r2.js.map +1 -0
- package/dist/types.d.ts +231 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Robots-gated fetcher — the central robots.txt choke point (see ROBOTS_POLICY.md).
|
|
4
|
+
*
|
|
5
|
+
* Wraps any FetcherAdapter so every route that fetches through it gets uniform
|
|
6
|
+
* robots handling (instead of the per-path drift the audit found). When
|
|
7
|
+
* robots-respect is enabled it:
|
|
8
|
+
* - refuses URLs the target's robots.txt disallows for our product token →
|
|
9
|
+
* throws `RobotsDisallowedError` (routes map it to 403 `robots_disallowed`);
|
|
10
|
+
* - paces same-host requests by `max(Crawl-delay, politeness floor)`.
|
|
11
|
+
*
|
|
12
|
+
* Authorized opt-out: when the request carries the internal
|
|
13
|
+
* `__robotsBypassAuthorized` flag (resolved UPSTREAM at the API layer from the
|
|
14
|
+
* customer's `ignoreRobots` + `robotsAttestation` AND an account capability), the
|
|
15
|
+
* Disallow check is skipped and the bypass is logged for audit. The gate never
|
|
16
|
+
* decides authorization itself — that's an API/control concern (four-layer rule).
|
|
17
|
+
*
|
|
18
|
+
* Phase-in: respect is OFF by default; enable per-environment via
|
|
19
|
+
* `ROBOTS_RESPECT=1` or the explicit `respectRobots` option. When off, the gate
|
|
20
|
+
* is a transparent pass-through (zero behavior change). robots.txt evaluation is
|
|
21
|
+
* fail-open (a target's robots outage never blocks the fetch) — see
|
|
22
|
+
* `evaluateRobots`. robots.txt URLs are SSRF-validated inside `evaluateRobots`;
|
|
23
|
+
* this gate adds no new trust-boundary crossing.
|
|
24
|
+
*/
|
|
25
|
+
import { RobotsDisallowedError, createLayerLogger, evaluateRobots } from '@bluefields/primitives';
|
|
26
|
+
const log = createLayerLogger('fetcher');
|
|
27
|
+
/** The product token we match robots.txt against — NOT the (possibly spoofed) fetch UA. */
|
|
28
|
+
const ROBOTS_USER_AGENT = 'BlueFields-Fetcher';
|
|
29
|
+
/** Minimum spacing between requests to the same host, even absent a Crawl-delay. */
|
|
30
|
+
const DEFAULT_POLITENESS_FLOOR_MS = 1_000;
|
|
31
|
+
/**
|
|
32
|
+
* Build a robots-gated fetcher wrapping `opts.inner`. Drop-in `FetcherAdapter`,
|
|
33
|
+
* so adopting it is just wrapping the fetcher a route already constructs.
|
|
34
|
+
*/
|
|
35
|
+
export function createRobotsGatedFetcher(opts) {
|
|
36
|
+
const env = opts.env ?? process.env;
|
|
37
|
+
const respectRobots = opts.respectRobots ?? env.ROBOTS_RESPECT === '1';
|
|
38
|
+
const floorMs = opts.politenessFloorMs ?? DEFAULT_POLITENESS_FLOOR_MS;
|
|
39
|
+
const evaluate = opts.evaluate ?? evaluateRobots;
|
|
40
|
+
const now = opts.nowMsFn ?? Date.now;
|
|
41
|
+
const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
42
|
+
/** Per-instance last-request time per host, for politeness pacing. */
|
|
43
|
+
const lastFetchByHost = new Map();
|
|
44
|
+
return {
|
|
45
|
+
async fetch(url, options) {
|
|
46
|
+
// Phase-in off → transparent pass-through (current behavior, no robots fetch).
|
|
47
|
+
if (!respectRobots)
|
|
48
|
+
return opts.inner.fetch(url, options);
|
|
49
|
+
const host = hostOf(url);
|
|
50
|
+
// Authorized opt-out: skip the Disallow check, log for audit. Resolved
|
|
51
|
+
// upstream — we only honor a resolved boolean here.
|
|
52
|
+
if (options?.__robotsBypassAuthorized === true) {
|
|
53
|
+
const audit = { url, host: host ?? '', atMs: now() };
|
|
54
|
+
opts.onBypass?.(audit);
|
|
55
|
+
log.info({ outcome: 'ok', url, host, robots_bypass: true }, 'robots opt-out honored (authorized)');
|
|
56
|
+
return opts.inner.fetch(url, options);
|
|
57
|
+
}
|
|
58
|
+
const { allowed, crawlDelaySeconds } = await evaluate(url, ROBOTS_USER_AGENT, {
|
|
59
|
+
...(opts.robotsFetchImpl ? { fetchImpl: opts.robotsFetchImpl } : {}),
|
|
60
|
+
});
|
|
61
|
+
if (!allowed) {
|
|
62
|
+
log.info({ outcome: 'blocked', url, host, reason: 'robots_disallowed' }, 'robots.txt disallows fetch');
|
|
63
|
+
throw new RobotsDisallowedError(url);
|
|
64
|
+
}
|
|
65
|
+
// Politeness: space same-host requests by max(Crawl-delay, floor). For a
|
|
66
|
+
// one-shot scrape the host is unseen → no wait; binds on crawl/batch.
|
|
67
|
+
if (host) {
|
|
68
|
+
const delayMs = Math.max(floorMs, (crawlDelaySeconds ?? 0) * 1_000);
|
|
69
|
+
const last = lastFetchByHost.get(host);
|
|
70
|
+
const wait = last === undefined ? 0 : delayMs - (now() - last);
|
|
71
|
+
if (wait > 0)
|
|
72
|
+
await sleep(wait);
|
|
73
|
+
lastFetchByHost.set(host, now());
|
|
74
|
+
}
|
|
75
|
+
return opts.inner.fetch(url, options);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function hostOf(url) {
|
|
80
|
+
try {
|
|
81
|
+
return new URL(url).host.toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=fetcher-robots-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetcher-robots-gate.js","sourceRoot":"","sources":["../src/fetcher-robots-gate.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bluefields/fetcher — public surface (chunks 8 + 9).
|
|
3
|
+
*
|
|
4
|
+
* Per the four-layer rule:
|
|
5
|
+
* - Fetcher returns raw HTML + metadata.
|
|
6
|
+
* - It does NOT extract, diff, or notify.
|
|
7
|
+
*
|
|
8
|
+
* Production wiring:
|
|
9
|
+
* - Fetcher: HTTP (default) → Patchright (when IPROYAL_* env set + dep
|
|
10
|
+
* installed).
|
|
11
|
+
* - Storage: in-memory (default) → R2 (when R2_* env set).
|
|
12
|
+
*
|
|
13
|
+
* `fetchAndStore` orchestrates one end-to-end fetch: robots-check →
|
|
14
|
+
* fetch via adapter → store content-addressed → sign the attestation
|
|
15
|
+
* manifest (when a signer is configured — A3, Review #3) → insert
|
|
16
|
+
* snapshot row → write cost_records.
|
|
17
|
+
*
|
|
18
|
+
* The return is a discriminated outcome — chunk 21's poll handler
|
|
19
|
+
* switches on `outcome.ok` to decide whether to feed the extractor or
|
|
20
|
+
* record a blocked-poll event.
|
|
21
|
+
*/
|
|
22
|
+
import { type Signer } from '@bluefields/attest';
|
|
23
|
+
import { type DbClient } from '@bluefields/db';
|
|
24
|
+
import type { FetchOptions, FetchResult, FetcherAdapter, StorageAdapter } from './types.js';
|
|
25
|
+
export { type FetchOptions, type FetchResult, type FetcherAdapter, type PageAction, type RequestCookie, type StorageAdapter, type StoredSnapshot, type WaitCondition, buildR2Key, } from './types.js';
|
|
26
|
+
export { createHttpFetcherAdapter } from './fetcher-http.js';
|
|
27
|
+
export { PATCHRIGHT_REQUIRED_ENV, tryCreatePatchrightAdapter } from './fetcher-patchright.js';
|
|
28
|
+
export { type EscalatingFetcherOptions, type FetchOutcome, classifyFetchResult, createEscalatingFetcherAdapter, } from './fetcher-escalating.js';
|
|
29
|
+
export { type RobotsBypassAudit, type RobotsGateOptions, createRobotsGatedFetcher, } from './fetcher-robots-gate.js';
|
|
30
|
+
export { createInMemoryStorageAdapter } from './storage-memory.js';
|
|
31
|
+
export { R2_REQUIRED_ENV, type R2Config, createR2StorageAdapter, tryCreateR2StorageAdapter, } from './storage-r2.js';
|
|
32
|
+
export { COMPUTE_BLOCKED_COST_MICRO_USD, type FetchCostType, type RecordFetchCostArgs, TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost, } from './cost.js';
|
|
33
|
+
/**
|
|
34
|
+
* Default fetcher factory — Patchright when env-configured, otherwise
|
|
35
|
+
* the plain HTTP fetcher.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getDefaultFetcher(env?: NodeJS.ProcessEnv): FetcherAdapter;
|
|
38
|
+
/**
|
|
39
|
+
* Default storage factory — R2 when env-configured, otherwise in-memory.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getDefaultStorage(env?: NodeJS.ProcessEnv): StorageAdapter;
|
|
42
|
+
export interface FetchAndStoreArgs {
|
|
43
|
+
db: DbClient;
|
|
44
|
+
fetcher: FetcherAdapter;
|
|
45
|
+
storage: StorageAdapter;
|
|
46
|
+
url: string;
|
|
47
|
+
customerId: string;
|
|
48
|
+
subscriptionId: string | null;
|
|
49
|
+
fetchOptions?: FetchOptions;
|
|
50
|
+
/**
|
|
51
|
+
* When true (default), check robots.txt before fetching. Set false in
|
|
52
|
+
* tests that don't want to mock robots fetches; production callers
|
|
53
|
+
* should leave it on.
|
|
54
|
+
*/
|
|
55
|
+
respectRobotsTxt?: boolean;
|
|
56
|
+
/** Test seam: override the robots.txt fetch. */
|
|
57
|
+
robotsFetchImpl?: typeof fetch;
|
|
58
|
+
/**
|
|
59
|
+
* Attestation signer (Review #3) — KMS-backed in production via
|
|
60
|
+
* `tryCreateKmsSigner`. When set, every snapshot row carries a signature
|
|
61
|
+
* over the JCS-canonical v1 manifest, and a signing failure fails the
|
|
62
|
+
* whole fetch: a configured signer is a provenance promise, and silently
|
|
63
|
+
* persisting unsigned rows would fake it. When null/absent, rows keep the
|
|
64
|
+
* explicit no-attestation placeholders (pre-KMS deployments, tests).
|
|
65
|
+
*/
|
|
66
|
+
signer?: Signer | null;
|
|
67
|
+
}
|
|
68
|
+
export type FetchAndStoreOutcome = {
|
|
69
|
+
ok: true;
|
|
70
|
+
snapshotId: string;
|
|
71
|
+
contentHash: string;
|
|
72
|
+
r2Key: string;
|
|
73
|
+
fetchResult: FetchResult;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
reason: 'robots_disallowed';
|
|
77
|
+
urlCanonical: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Orchestrate one full fetch with robots-check + cost recording.
|
|
81
|
+
*
|
|
82
|
+
* 1. Canonicalize URL
|
|
83
|
+
* 2. Check robots.txt (skipped when respectRobotsTxt=false)
|
|
84
|
+
* - Disallowed: update host_records.robots_status='disallowed',
|
|
85
|
+
* write a compute cost row, return ok=false
|
|
86
|
+
* 3. Fetch via the adapter (SSRF validation lives in the HTTP adapter)
|
|
87
|
+
* 4. Store content-addressed in R2 / memory
|
|
88
|
+
* 5. Insert a snapshot row
|
|
89
|
+
* 6. Write a cost_records row (fetch_tier1, http_only)
|
|
90
|
+
*
|
|
91
|
+
* Returns a discriminated outcome — callers switch on `outcome.ok`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function fetchAndStore(args: FetchAndStoreArgs): Promise<FetchAndStoreOutcome>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* @bluefields/fetcher — public surface (chunks 8 + 9).
|
|
4
|
+
*
|
|
5
|
+
* Per the four-layer rule:
|
|
6
|
+
* - Fetcher returns raw HTML + metadata.
|
|
7
|
+
* - It does NOT extract, diff, or notify.
|
|
8
|
+
*
|
|
9
|
+
* Production wiring:
|
|
10
|
+
* - Fetcher: HTTP (default) → Patchright (when IPROYAL_* env set + dep
|
|
11
|
+
* installed).
|
|
12
|
+
* - Storage: in-memory (default) → R2 (when R2_* env set).
|
|
13
|
+
*
|
|
14
|
+
* `fetchAndStore` orchestrates one end-to-end fetch: robots-check →
|
|
15
|
+
* fetch via adapter → store content-addressed → sign the attestation
|
|
16
|
+
* manifest (when a signer is configured — A3, Review #3) → insert
|
|
17
|
+
* snapshot row → write cost_records.
|
|
18
|
+
*
|
|
19
|
+
* The return is a discriminated outcome — chunk 21's poll handler
|
|
20
|
+
* switches on `outcome.ok` to decide whether to feed the extractor or
|
|
21
|
+
* record a blocked-poll event.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { MANIFEST_VERSION, buildManifest, canonicalize, signManifest, } from '@bluefields/attest';
|
|
25
|
+
import { hostRecords, snapshots } from '@bluefields/db';
|
|
26
|
+
import { canonicalizeUrl, createLayerLogger, isAllowedByRobotsTxt } from '@bluefields/primitives';
|
|
27
|
+
import { eq } from 'drizzle-orm';
|
|
28
|
+
const log = createLayerLogger('fetcher');
|
|
29
|
+
import { TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost } from './cost.js';
|
|
30
|
+
import { createHttpFetcherAdapter } from './fetcher-http.js';
|
|
31
|
+
import { tryCreatePatchrightAdapter } from './fetcher-patchright.js';
|
|
32
|
+
import { createInMemoryStorageAdapter } from './storage-memory.js';
|
|
33
|
+
import { tryCreateR2StorageAdapter } from './storage-r2.js';
|
|
34
|
+
export { buildR2Key, } from './types.js';
|
|
35
|
+
export { createHttpFetcherAdapter } from './fetcher-http.js';
|
|
36
|
+
export { PATCHRIGHT_REQUIRED_ENV, tryCreatePatchrightAdapter } from './fetcher-patchright.js';
|
|
37
|
+
export { classifyFetchResult, createEscalatingFetcherAdapter, } from './fetcher-escalating.js';
|
|
38
|
+
export { createRobotsGatedFetcher, } from './fetcher-robots-gate.js';
|
|
39
|
+
export { createInMemoryStorageAdapter } from './storage-memory.js';
|
|
40
|
+
export { R2_REQUIRED_ENV, createR2StorageAdapter, tryCreateR2StorageAdapter, } from './storage-r2.js';
|
|
41
|
+
export { COMPUTE_BLOCKED_COST_MICRO_USD, TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost, } from './cost.js';
|
|
42
|
+
/**
|
|
43
|
+
* Default fetcher factory — Patchright when env-configured, otherwise
|
|
44
|
+
* the plain HTTP fetcher.
|
|
45
|
+
*/
|
|
46
|
+
export function getDefaultFetcher(env = process.env) {
|
|
47
|
+
return tryCreatePatchrightAdapter(env) ?? createHttpFetcherAdapter();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Default storage factory — R2 when env-configured, otherwise in-memory.
|
|
51
|
+
*/
|
|
52
|
+
export function getDefaultStorage(env = process.env) {
|
|
53
|
+
return tryCreateR2StorageAdapter(env) ?? createInMemoryStorageAdapter();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Orchestrate one full fetch with robots-check + cost recording.
|
|
57
|
+
*
|
|
58
|
+
* 1. Canonicalize URL
|
|
59
|
+
* 2. Check robots.txt (skipped when respectRobotsTxt=false)
|
|
60
|
+
* - Disallowed: update host_records.robots_status='disallowed',
|
|
61
|
+
* write a compute cost row, return ok=false
|
|
62
|
+
* 3. Fetch via the adapter (SSRF validation lives in the HTTP adapter)
|
|
63
|
+
* 4. Store content-addressed in R2 / memory
|
|
64
|
+
* 5. Insert a snapshot row
|
|
65
|
+
* 6. Write a cost_records row (fetch_tier1, http_only)
|
|
66
|
+
*
|
|
67
|
+
* Returns a discriminated outcome — callers switch on `outcome.ok`.
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchAndStore(args) {
|
|
70
|
+
const canonical = canonicalizeUrl(args.url);
|
|
71
|
+
const respectRobots = args.respectRobotsTxt ?? true;
|
|
72
|
+
const userAgent = args.fetchOptions?.userAgent ?? 'BlueFields-Fetcher/1.0';
|
|
73
|
+
// ── Robots-check ──
|
|
74
|
+
if (respectRobots) {
|
|
75
|
+
const allowed = await isAllowedByRobotsTxt(args.url, userAgent, {
|
|
76
|
+
...(args.robotsFetchImpl ? { fetchImpl: args.robotsFetchImpl } : {}),
|
|
77
|
+
});
|
|
78
|
+
if (!allowed) {
|
|
79
|
+
const host = hostnameOf(args.url);
|
|
80
|
+
if (host) {
|
|
81
|
+
// Mark the host as disallowed so dashboards / future polls can
|
|
82
|
+
// short-circuit. Don't fail the request if this UPDATE doesn't
|
|
83
|
+
// affect a row — host_records may not exist yet (first time we
|
|
84
|
+
// see this host); the chunk-7 host-records repo will populate
|
|
85
|
+
// on the next call.
|
|
86
|
+
const updated = await args.db
|
|
87
|
+
.update(hostRecords)
|
|
88
|
+
.set({ robotsStatus: 'disallowed' })
|
|
89
|
+
.where(eq(hostRecords.host, host))
|
|
90
|
+
.returning({ host: hostRecords.host });
|
|
91
|
+
if (updated.length === 0) {
|
|
92
|
+
log.debug({
|
|
93
|
+
outcome: 'partial',
|
|
94
|
+
subscription_id: args.subscriptionId,
|
|
95
|
+
customer_id: args.customerId,
|
|
96
|
+
host,
|
|
97
|
+
}, 'robots_disallowed flag not set: host_records row missing (first-seen host)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await recordBlockedFetch({
|
|
101
|
+
db: args.db,
|
|
102
|
+
customerId: args.customerId,
|
|
103
|
+
subscriptionId: args.subscriptionId,
|
|
104
|
+
urlCanonical: canonical.url_canonical,
|
|
105
|
+
reason: 'robots_disallowed',
|
|
106
|
+
});
|
|
107
|
+
log.info({
|
|
108
|
+
outcome: 'blocked',
|
|
109
|
+
subscription_id: args.subscriptionId,
|
|
110
|
+
customer_id: args.customerId,
|
|
111
|
+
url_canonical: canonical.url_canonical,
|
|
112
|
+
}, 'fetch blocked by robots.txt');
|
|
113
|
+
return { ok: false, reason: 'robots_disallowed', urlCanonical: canonical.url_canonical };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── Fetch + store + sign + snapshot ──
|
|
117
|
+
const fetchResult = await args.fetcher.fetch(args.url, args.fetchOptions);
|
|
118
|
+
const stored = await args.storage.storeSnapshot(fetchResult.html);
|
|
119
|
+
// Sign BEFORE persisting (spec §8 order) so a row never exists in a
|
|
120
|
+
// configured-but-unsigned state. The manifest carries a HASH of the
|
|
121
|
+
// JCS-canonicalized response headers, never the headers themselves —
|
|
122
|
+
// Set-Cookie and friends must not enter the permanent manifest (Review #3).
|
|
123
|
+
const attested = args.signer
|
|
124
|
+
? await signManifest(buildManifest({
|
|
125
|
+
urlCanonical: canonical.url_canonical,
|
|
126
|
+
canonVersion: canonical.canon_version,
|
|
127
|
+
contentHash: stored.contentHash,
|
|
128
|
+
rawHtmlSizeBytes: fetchResult.rawHtmlSizeBytes,
|
|
129
|
+
fetchedAt: fetchResult.fetchedAt.toISOString(),
|
|
130
|
+
fetchDurationMs: fetchResult.fetchDurationMs,
|
|
131
|
+
fetcherId: fetchResult.fetcherId,
|
|
132
|
+
proxyEgressIp: fetchResult.proxyEgressIp,
|
|
133
|
+
userAgent: fetchResult.userAgent,
|
|
134
|
+
responseStatus: fetchResult.responseStatus,
|
|
135
|
+
responseHeadersHash: createHash('sha256')
|
|
136
|
+
.update(canonicalize(fetchResult.responseHeaders))
|
|
137
|
+
.digest('hex'),
|
|
138
|
+
}), args.signer)
|
|
139
|
+
: null;
|
|
140
|
+
const inserted = await args.db
|
|
141
|
+
.insert(snapshots)
|
|
142
|
+
.values({
|
|
143
|
+
urlCanonical: canonical.url_canonical,
|
|
144
|
+
urlSurt: canonical.url_surt,
|
|
145
|
+
canonVersion: canonical.canon_version,
|
|
146
|
+
contentHash: stored.contentHash,
|
|
147
|
+
fetchedAt: fetchResult.fetchedAt,
|
|
148
|
+
fetcherId: fetchResult.fetcherId,
|
|
149
|
+
proxyEgressIp: fetchResult.proxyEgressIp,
|
|
150
|
+
userAgent: fetchResult.userAgent,
|
|
151
|
+
responseStatus: fetchResult.responseStatus,
|
|
152
|
+
responseHeaders: fetchResult.responseHeaders,
|
|
153
|
+
rawHtmlR2Key: stored.r2Key,
|
|
154
|
+
rawHtmlSizeBytes: fetchResult.rawHtmlSizeBytes,
|
|
155
|
+
fetchDurationMs: fetchResult.fetchDurationMs,
|
|
156
|
+
// Signed when a signer is configured; explicit placeholders otherwise.
|
|
157
|
+
attestationSignature: attested?.signature.signature ?? '',
|
|
158
|
+
attestationKeyId: attested?.signature.keyId ?? 'v0-no-attestation',
|
|
159
|
+
attestationAlgorithm: attested?.signature.algorithm ?? 'EdDSA',
|
|
160
|
+
manifestVersion: MANIFEST_VERSION,
|
|
161
|
+
// Per-snapshot cost = the flat Tier 1 cost so the snapshot row is
|
|
162
|
+
// self-describing; cost_records is the authoritative billing row.
|
|
163
|
+
costMicroUsd: TIER1_FIXED_COST_MICRO_USD,
|
|
164
|
+
})
|
|
165
|
+
.returning({ id: snapshots.id });
|
|
166
|
+
const row = inserted[0];
|
|
167
|
+
if (!row)
|
|
168
|
+
throw new Error('snapshot insert returned no row');
|
|
169
|
+
// ── cost_records (chunk 9) ──
|
|
170
|
+
// Failure here means the snapshot is in DB but no billing row was
|
|
171
|
+
// written — partial success. Log audibly so a runaway billing gap
|
|
172
|
+
// is visible; don't fail the request (caller already has a valid
|
|
173
|
+
// snapshot to extract from).
|
|
174
|
+
try {
|
|
175
|
+
await recordFetchCost({
|
|
176
|
+
db: args.db,
|
|
177
|
+
customerId: args.customerId,
|
|
178
|
+
subscriptionId: args.subscriptionId,
|
|
179
|
+
urlCanonical: canonical.url_canonical,
|
|
180
|
+
units: fetchResult.rawHtmlSizeBytes,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
log.error({
|
|
185
|
+
outcome: 'partial',
|
|
186
|
+
subscription_id: args.subscriptionId,
|
|
187
|
+
customer_id: args.customerId,
|
|
188
|
+
snapshot_id: row.id,
|
|
189
|
+
error: err.message,
|
|
190
|
+
}, 'snapshot inserted but cost_records write failed — billing gap');
|
|
191
|
+
}
|
|
192
|
+
log.info({
|
|
193
|
+
outcome: 'ok',
|
|
194
|
+
subscription_id: args.subscriptionId,
|
|
195
|
+
customer_id: args.customerId,
|
|
196
|
+
snapshot_id: row.id,
|
|
197
|
+
content_hash: stored.contentHash,
|
|
198
|
+
response_status: fetchResult.responseStatus,
|
|
199
|
+
raw_html_size_bytes: fetchResult.rawHtmlSizeBytes,
|
|
200
|
+
duration_ms: fetchResult.fetchDurationMs,
|
|
201
|
+
attestation_key_id: attested?.signature.keyId ?? null,
|
|
202
|
+
}, 'fetch ok');
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
snapshotId: row.id,
|
|
206
|
+
contentHash: stored.contentHash,
|
|
207
|
+
r2Key: stored.r2Key,
|
|
208
|
+
fetchResult,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function hostnameOf(url) {
|
|
212
|
+
try {
|
|
213
|
+
return new URL(url).hostname.toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetcher-internal escalation hints.
|
|
3
|
+
*
|
|
4
|
+
* These are NOT customer-facing. The API route and the SDKs build a public
|
|
5
|
+
* `FetchOptions` (see `types.ts`); they never set or expose anything here.
|
|
6
|
+
* `InternalFetchOptions` exists only inside `packages/fetcher` so the
|
|
7
|
+
* escalating fetcher (`fetcher-escalating.ts`) can tune how a higher tier
|
|
8
|
+
* behaves on a re-fetch, without changing the public `FetcherAdapter` contract
|
|
9
|
+
* (`fetch(url, options?)`) or the customer-visible option shape.
|
|
10
|
+
*
|
|
11
|
+
* Adapters read these defensively (a missing field means "behave as before"),
|
|
12
|
+
* so passing them to an adapter that doesn't understand them is a no-op.
|
|
13
|
+
*/
|
|
14
|
+
import type { FetchOptions } from './types.js';
|
|
15
|
+
export interface InternalFetchOptions extends FetchOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Fully render a JS app before reading content: best-effort `networkidle`,
|
|
18
|
+
* then a bounded autoscroll + content-settle loop (see `fullyRenderPage` in
|
|
19
|
+
* `fetcher-patchright.ts`). Honored only by browser tiers; the HTTP and
|
|
20
|
+
* unlocker tiers ignore it.
|
|
21
|
+
*
|
|
22
|
+
* Set by `createEscalatingFetcherAdapter` when it escalates a JS shell /
|
|
23
|
+
* anti-bot block to a higher tier AND the customer gave no explicit
|
|
24
|
+
* `waitFor` (an explicit `waitFor` always wins). The whole settle sequence
|
|
25
|
+
* is clamped to `timeoutMs`, so it never extends a request past its deadline.
|
|
26
|
+
*/
|
|
27
|
+
__fullRender?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Authorized robots.txt opt-out. When true, the robots-gated fetcher skips the
|
|
30
|
+
* Disallow check (and logs an audit entry). Resolved UPSTREAM at the API layer
|
|
31
|
+
* from the customer's `ignoreRobots` + `robotsAttestation` AND an account-level
|
|
32
|
+
* capability — the fetcher never decides authorization itself (four-layer rule).
|
|
33
|
+
* Absent/false → robots is enforced normally. See `ROBOTS_POLICY.md`.
|
|
34
|
+
*/
|
|
35
|
+
__robotsBypassAuthorized?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Abandonment signal. `createEscalatingFetcherAdapter` aborts this when a tier
|
|
38
|
+
* exceeds its wall-clock budget (`raceTierBudget`) so the abandoned tier can
|
|
39
|
+
* release resources instead of running on. The browser tier (Patchright)
|
|
40
|
+
* honors it by closing its context — without this, an abandoned browser fetch
|
|
41
|
+
* leaks headless Chromium. The HTTP / unlocker tiers ignore it (they leak at
|
|
42
|
+
* most a socket, bounded by undici's own timeouts).
|
|
43
|
+
*/
|
|
44
|
+
__abortSignal?: AbortSignal;
|
|
45
|
+
/**
|
|
46
|
+
* Tier-0 early-block escalation (perf round, L1). Set ONLY by the escalating
|
|
47
|
+
* fetcher on its tier-0 stage. When true, the HTTP adapter — on a DECISIVE block
|
|
48
|
+
* status (403/429/503, the statuses classifyFetchResult always maps to
|
|
49
|
+
* challenge|blocked, both `needsEscalation`) — skips draining the doomed response
|
|
50
|
+
* body and returns an empty-body result immediately, so the escalating layer
|
|
51
|
+
* escalates at ~time-to-headers instead of after the full (often slow-rolled) block
|
|
52
|
+
* body or the tier-0 fast-fail wall. Coverage-SAFE by construction: it never emits
|
|
53
|
+
* an early `success` and fires only on statuses that already escalate regardless of
|
|
54
|
+
* body. Absent/false → today's full-drain behavior, so a standalone HTTP-adapter
|
|
55
|
+
* caller (which never sets this) is byte-for-byte unchanged. Browser/unlocker tiers
|
|
56
|
+
* ignore it.
|
|
57
|
+
*/
|
|
58
|
+
__earlyClassify?: boolean;
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Fetcher-internal escalation hints.
|
|
4
|
+
*
|
|
5
|
+
* These are NOT customer-facing. The API route and the SDKs build a public
|
|
6
|
+
* `FetchOptions` (see `types.ts`); they never set or expose anything here.
|
|
7
|
+
* `InternalFetchOptions` exists only inside `packages/fetcher` so the
|
|
8
|
+
* escalating fetcher (`fetcher-escalating.ts`) can tune how a higher tier
|
|
9
|
+
* behaves on a re-fetch, without changing the public `FetcherAdapter` contract
|
|
10
|
+
* (`fetch(url, options?)`) or the customer-visible option shape.
|
|
11
|
+
*
|
|
12
|
+
* Adapters read these defensively (a missing field means "behave as before"),
|
|
13
|
+
* so passing them to an adapter that doesn't understand them is a no-op.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=internal-options.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-options.js","sourceRoot":"","sources":["../src/internal-options.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;;GAYG"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic fixture perf harness for the escalating fetcher (perf round).
|
|
3
|
+
*
|
|
4
|
+
* The mission requires a before/after p50/p95/cost/coverage number PER LEVER on a
|
|
5
|
+
* FIXTURE corpus (never live sites). This library scripts each tier's latency +
|
|
6
|
+
* outcome per fixture URL, drives the real `createEscalatingFetcherAdapter` against
|
|
7
|
+
* those mocks, and reports the distribution. It is deliberately dependency-free and
|
|
8
|
+
* vitest-free: the CALLER injects the clock (a fake-timer test drives `now` +
|
|
9
|
+
* `advanceAndFlush`), so the whole run is deterministic and fast — no real waiting,
|
|
10
|
+
* no network. The runnable entrypoint is `fetcher-escalating.perf.test.ts`
|
|
11
|
+
* (FETCH_PERF=1-gated, inert in the normal suite); `perf-harness.test.ts` guards the
|
|
12
|
+
* metrics math in CI.
|
|
13
|
+
*
|
|
14
|
+
* Fetcher-layer only: it exercises tier selection + timing, nothing else.
|
|
15
|
+
*/
|
|
16
|
+
import type { EscalatingFetcherOptions } from './fetcher-escalating.js';
|
|
17
|
+
import type { FetcherAdapter } from './types.js';
|
|
18
|
+
/** Billed credits per tier (mirrors billing: tier-0 HTTP=1, tier-1 browser=3, tier-2 unlocker=8). */
|
|
19
|
+
export declare const TIER_CREDITS: Readonly<Record<number, number>>;
|
|
20
|
+
/** fetcherId each tier's mock reports — matches the real adapters so we can map result→tier. */
|
|
21
|
+
export declare const TIER_FETCHER_ID: Readonly<Record<number, string>>;
|
|
22
|
+
/** Scripted behavior of one tier for one fixture. */
|
|
23
|
+
export type TierScript =
|
|
24
|
+
/** Resolves with `status`+`html` after `latencyMs` of (fake) time. */
|
|
25
|
+
{
|
|
26
|
+
latencyMs: number;
|
|
27
|
+
status: number;
|
|
28
|
+
html: string;
|
|
29
|
+
}
|
|
30
|
+
/** Never resolves on its own — models a slow-roll/hung tier bounded only by the budget/deadline. */
|
|
31
|
+
| {
|
|
32
|
+
latencyMs?: number;
|
|
33
|
+
kind: 'hang';
|
|
34
|
+
}
|
|
35
|
+
/** Tier not configured (adapter is null). */
|
|
36
|
+
| {
|
|
37
|
+
kind: 'absent';
|
|
38
|
+
};
|
|
39
|
+
export interface FixtureSpec {
|
|
40
|
+
url: string;
|
|
41
|
+
/** Short human label for reports (e.g. 'fast-success', 'slow-block', 'shell→browser'). */
|
|
42
|
+
label: string;
|
|
43
|
+
tier0: TierScript;
|
|
44
|
+
tier1?: TierScript;
|
|
45
|
+
tier2?: TierScript;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A mock tier adapter honoring the abort seam the escalating fetcher uses: a hung
|
|
49
|
+
* tier stays pending until aborted (then rejects, like a real abandoned tier); a
|
|
50
|
+
* scripted tier resolves after `latencyMs` via setTimeout (fake-clock driven). Returns
|
|
51
|
+
* null for an absent tier so the ladder omits it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function mockTierAdapter(tier: number, script: TierScript): FetcherAdapter | null;
|
|
54
|
+
export interface PerRequest {
|
|
55
|
+
url: string;
|
|
56
|
+
label: string;
|
|
57
|
+
elapsedMs: number;
|
|
58
|
+
/** Winning tier (0/1/2) or -1 if the fetch threw. */
|
|
59
|
+
tier: number;
|
|
60
|
+
credits: number;
|
|
61
|
+
outcome: string;
|
|
62
|
+
covered: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface CorpusReport {
|
|
65
|
+
n: number;
|
|
66
|
+
/** Latency percentiles (ms) over end-to-end fake-clock elapsed. */
|
|
67
|
+
p50: number;
|
|
68
|
+
p95: number;
|
|
69
|
+
max: number;
|
|
70
|
+
/** Coverage = fraction whose final outcome is `success`. This is the HARD FLOOR metric. */
|
|
71
|
+
successRate: number;
|
|
72
|
+
/** Cost per successful scrape = total credits billed / number covered. */
|
|
73
|
+
creditsPerSuccess: number;
|
|
74
|
+
/** Mean credits per request (blended tier mix). */
|
|
75
|
+
meanCredits: number;
|
|
76
|
+
/** Count of requests won by each tier. */
|
|
77
|
+
perTier: Record<number, number>;
|
|
78
|
+
requests: PerRequest[];
|
|
79
|
+
}
|
|
80
|
+
/** Nearest-rank percentile (no interpolation) — same convention as the SLA harness. */
|
|
81
|
+
export declare function percentile(sorted: number[], q: number): number;
|
|
82
|
+
export interface RunOptions {
|
|
83
|
+
/** Escalating-fetcher config under test (tier0TimeoutMs, hedgeDelayMs, …). nowMsFn is injected below. */
|
|
84
|
+
escalatingOpts?: Partial<Omit<EscalatingFetcherOptions, 'tier0' | 'tier1' | 'tier2' | 'nowMsFn'>>;
|
|
85
|
+
/** Repeat the corpus this many times (default 1). */
|
|
86
|
+
iterations?: number;
|
|
87
|
+
/** Read the current (fake) clock — pass the test's faked Date.now. */
|
|
88
|
+
now: () => number;
|
|
89
|
+
/** Advance the (fake) clock by `ms` AND flush microtasks/timers. Pass `vi.advanceTimersByTimeAsync`. */
|
|
90
|
+
advanceAndFlush: (ms: number) => Promise<void>;
|
|
91
|
+
/** Advance step (ms). Smaller = finer latency resolution, more iterations. Default 25. */
|
|
92
|
+
stepMs?: number;
|
|
93
|
+
/** Safety cap on total advance per request (ms). Default 120000. */
|
|
94
|
+
capMs?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run the fixture corpus through the real escalating fetcher under the caller's fake
|
|
98
|
+
* clock, returning the latency/cost/coverage distribution. For each fixture it builds
|
|
99
|
+
* `createEscalatingFetcherAdapter` with the scripted mock tiers + the injected
|
|
100
|
+
* `nowMsFn`, starts the fetch, and drives `advanceAndFlush(stepMs)` until it settles —
|
|
101
|
+
* measuring end-to-end elapsed on the fake clock (quantized to stepMs).
|
|
102
|
+
*/
|
|
103
|
+
export declare function runCorpus(fixtures: FixtureSpec[], o: RunOptions): Promise<CorpusReport>;
|
|
104
|
+
/** Pretty one-line summary for a config's report (for the perf entrypoint's console output). */
|
|
105
|
+
export declare function formatReport(name: string, r: CorpusReport): string;
|