@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,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Manual bandwidth measurement (gated, NOT a test). Fetches a few image-heavy
|
|
4
|
+
* pages through the browser tier with asset-blocking ON (the normal path) vs
|
|
5
|
+
* OFF (a screenshot request disables blocking) and prints bytesTransferred for
|
|
6
|
+
* each, to confirm the metered-proxy saving is real.
|
|
7
|
+
*
|
|
8
|
+
* ASSET_BLOCK_MEASURE=1 packages/fetcher/node_modules/.bin/tsx \
|
|
9
|
+
* packages/fetcher/src/asset-block-measure.ts
|
|
10
|
+
* (source .env.local first for IPROYAL_*). Spends a little IPRoyal egress.
|
|
11
|
+
*
|
|
12
|
+
* Robots: a crude `Disallow: /` check for the `*` group skips a host that
|
|
13
|
+
* forbids it; we only target bot-permissive homepages anyway.
|
|
14
|
+
*/
|
|
15
|
+
import { tryCreatePatchrightAdapter } from './fetcher-patchright.js';
|
|
16
|
+
const URLS = [
|
|
17
|
+
'https://www.theverge.com/',
|
|
18
|
+
'https://www.bbc.com/',
|
|
19
|
+
'https://www.nationalgeographic.com/',
|
|
20
|
+
'https://www.cnn.com/',
|
|
21
|
+
];
|
|
22
|
+
const UA = 'BlueFields-Fetcher/1.0 (+https://bluefields.dev)';
|
|
23
|
+
async function rootAllowed(url) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${new URL(url).origin}/robots.txt`, {
|
|
26
|
+
headers: { 'user-agent': UA },
|
|
27
|
+
signal: AbortSignal.timeout(8_000),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return true;
|
|
31
|
+
const txt = (await res.text()).toLowerCase();
|
|
32
|
+
const star = txt.split(/user-agent:/).find((b) => b.trimStart().startsWith('*'));
|
|
33
|
+
return !(star && /\n\s*disallow:\s*\/\s*(\n|$)/.test(`\n${star}`));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const kb = (n) => n == null ? ' n/a' : `${Math.round(n / 1024)}KB`.padStart(8);
|
|
40
|
+
async function main() {
|
|
41
|
+
if (process.env.ASSET_BLOCK_MEASURE !== '1') {
|
|
42
|
+
console.log('gated: set ASSET_BLOCK_MEASURE=1 (live; spends a little IPRoyal egress).');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const adapter = tryCreatePatchrightAdapter(process.env);
|
|
46
|
+
if (!adapter) {
|
|
47
|
+
console.log('no Patchright/IPRoyal env (IPROYAL_GATEWAY + creds) — cannot measure.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(`${'url'.padEnd(38)} OFF(before) ON(after) saved`);
|
|
51
|
+
let totBefore = 0;
|
|
52
|
+
let totAfter = 0;
|
|
53
|
+
for (const url of URLS) {
|
|
54
|
+
if (!(await rootAllowed(url))) {
|
|
55
|
+
console.log(`${url.padEnd(38)} skip (robots)`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
// before: a screenshot request disables blocking → full asset download
|
|
60
|
+
const before = await adapter.fetch(url, { timeoutMs: 45_000, captureScreenshot: true });
|
|
61
|
+
// after: normal browser tier → image/media/font/stylesheet aborted
|
|
62
|
+
const after = await adapter.fetch(url, { timeoutMs: 45_000 });
|
|
63
|
+
const b = before.bytesTransferred ?? 0;
|
|
64
|
+
const a = after.bytesTransferred ?? 0;
|
|
65
|
+
totBefore += b;
|
|
66
|
+
totAfter += a;
|
|
67
|
+
const pct = b ? Math.round((100 * (b - a)) / b) : 0;
|
|
68
|
+
console.log(`${url.padEnd(38)} ${kb(b)} ${kb(a)} ${kb(b - a)} (${pct}%)`);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.log(`${url.padEnd(38)} ERROR ${err.message.slice(0, 50)}`);
|
|
72
|
+
}
|
|
73
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
74
|
+
}
|
|
75
|
+
const pct = totBefore ? Math.round((100 * (totBefore - totAfter)) / totBefore) : 0;
|
|
76
|
+
console.log(`${'TOTAL'.padEnd(38)} ${kb(totBefore)} ${kb(totAfter)} ${kb(totBefore - totAfter)} (${pct}%)`);
|
|
77
|
+
}
|
|
78
|
+
main().catch((e) => {
|
|
79
|
+
console.error('measure failed:', e);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
|
82
|
+
//# sourceMappingURL=asset-block-measure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"asset-block-measure.js","sourceRoot":"","sources":["../src/asset-block-measure.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded Chromium browser pool for the Patchright adapter.
|
|
3
|
+
*
|
|
4
|
+
* Why: launching Chromium costs ~500ms-1.5s per fetch (Patchright + IPRoyal
|
|
5
|
+
* proxy + headless boot). At sub-1-rps load that's tolerable; at 5+ rps it
|
|
6
|
+
* doubles end-to-end latency. With a pool of N browsers, only the FIRST
|
|
7
|
+
* fetch eats the launch cost; subsequent fetches just newContext (~30ms).
|
|
8
|
+
*
|
|
9
|
+
* Isolation: every fetch still gets a fresh `browser.newContext()` (own
|
|
10
|
+
* cookies, storage, fingerprint slot) so reused browsers don't leak state.
|
|
11
|
+
* The cookie jar / localStorage live on the context, not the browser.
|
|
12
|
+
*
|
|
13
|
+
* Crash resilience: if any context-level op fails, the slot is *disposed*
|
|
14
|
+
* (browser killed) and replaced on next acquire. Browsers also age out
|
|
15
|
+
* after MAX_USES contexts to avoid memory creep from a long-lived headless
|
|
16
|
+
* Chromium.
|
|
17
|
+
*
|
|
18
|
+
* Fail-open: when a launch fails, acquire throws and the caller falls
|
|
19
|
+
* back to per-fetch launch (the adapter wraps this with try/catch).
|
|
20
|
+
*/
|
|
21
|
+
import type { ChromiumLauncher } from './fetcher-patchright.js';
|
|
22
|
+
export interface BrowserPoolOptions {
|
|
23
|
+
launcher: ChromiumLauncher;
|
|
24
|
+
proxy: {
|
|
25
|
+
server: string;
|
|
26
|
+
username: string;
|
|
27
|
+
password: string;
|
|
28
|
+
};
|
|
29
|
+
/** Number of browsers held alive concurrently. Default 2. */
|
|
30
|
+
size?: number;
|
|
31
|
+
/** Max time to wait for a free slot before rejecting. Default 30s. */
|
|
32
|
+
acquireTimeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
type LaunchedBrowser = Awaited<ReturnType<ChromiumLauncher['launch']>>;
|
|
35
|
+
interface Slot {
|
|
36
|
+
browser: LaunchedBrowser;
|
|
37
|
+
usesRemaining: number;
|
|
38
|
+
inUse: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare class BrowserPool {
|
|
41
|
+
private readonly opts;
|
|
42
|
+
private readonly slots;
|
|
43
|
+
private readonly waiters;
|
|
44
|
+
private stopped;
|
|
45
|
+
constructor(opts: BrowserPoolOptions);
|
|
46
|
+
/** Pre-launch all browsers. Safe to call multiple times. */
|
|
47
|
+
warm(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Acquire a browser. Caller MUST call release(slot) or dispose(slot)
|
|
50
|
+
* exactly once when done. Throws on acquire timeout or launch failure.
|
|
51
|
+
*/
|
|
52
|
+
acquire(): Promise<Slot>;
|
|
53
|
+
/** Return a healthy slot to the pool. Decrements its remaining lifespan. */
|
|
54
|
+
release(slot: Slot): void;
|
|
55
|
+
/** Mark a slot as crashed; kill the browser + replace. */
|
|
56
|
+
dispose(slot: Slot): void;
|
|
57
|
+
/** Close all browsers. */
|
|
58
|
+
stop(): Promise<void>;
|
|
59
|
+
/** Visible for testing. */
|
|
60
|
+
get size(): number;
|
|
61
|
+
private handoff;
|
|
62
|
+
private launchOne;
|
|
63
|
+
private replaceSlot;
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Bounded Chromium browser pool for the Patchright adapter.
|
|
4
|
+
*
|
|
5
|
+
* Why: launching Chromium costs ~500ms-1.5s per fetch (Patchright + IPRoyal
|
|
6
|
+
* proxy + headless boot). At sub-1-rps load that's tolerable; at 5+ rps it
|
|
7
|
+
* doubles end-to-end latency. With a pool of N browsers, only the FIRST
|
|
8
|
+
* fetch eats the launch cost; subsequent fetches just newContext (~30ms).
|
|
9
|
+
*
|
|
10
|
+
* Isolation: every fetch still gets a fresh `browser.newContext()` (own
|
|
11
|
+
* cookies, storage, fingerprint slot) so reused browsers don't leak state.
|
|
12
|
+
* The cookie jar / localStorage live on the context, not the browser.
|
|
13
|
+
*
|
|
14
|
+
* Crash resilience: if any context-level op fails, the slot is *disposed*
|
|
15
|
+
* (browser killed) and replaced on next acquire. Browsers also age out
|
|
16
|
+
* after MAX_USES contexts to avoid memory creep from a long-lived headless
|
|
17
|
+
* Chromium.
|
|
18
|
+
*
|
|
19
|
+
* Fail-open: when a launch fails, acquire throws and the caller falls
|
|
20
|
+
* back to per-fetch launch (the adapter wraps this with try/catch).
|
|
21
|
+
*/
|
|
22
|
+
import { createLayerLogger } from '@bluefields/primitives';
|
|
23
|
+
const log = createLayerLogger('fetcher');
|
|
24
|
+
/** Replace a browser after this many contexts to bound memory. */
|
|
25
|
+
const MAX_USES_PER_BROWSER = 200;
|
|
26
|
+
/** Hard ceiling on acquire wait time. */
|
|
27
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 30_000;
|
|
28
|
+
export class BrowserPool {
|
|
29
|
+
opts;
|
|
30
|
+
slots = [];
|
|
31
|
+
waiters = [];
|
|
32
|
+
stopped = false;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
this.opts = {
|
|
35
|
+
launcher: opts.launcher,
|
|
36
|
+
proxy: opts.proxy,
|
|
37
|
+
size: opts.size ?? 2,
|
|
38
|
+
acquireTimeoutMs: opts.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Pre-launch all browsers. Safe to call multiple times. */
|
|
42
|
+
async warm() {
|
|
43
|
+
if (this.stopped)
|
|
44
|
+
return;
|
|
45
|
+
while (this.slots.length < this.opts.size) {
|
|
46
|
+
const browser = await this.launchOne();
|
|
47
|
+
this.slots.push({ browser, usesRemaining: MAX_USES_PER_BROWSER, inUse: false });
|
|
48
|
+
log.info({ outcome: 'ok', pool_size: this.slots.length }, 'browser-pool: warmed slot');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Acquire a browser. Caller MUST call release(slot) or dispose(slot)
|
|
53
|
+
* exactly once when done. Throws on acquire timeout or launch failure.
|
|
54
|
+
*/
|
|
55
|
+
async acquire() {
|
|
56
|
+
if (this.stopped)
|
|
57
|
+
throw new Error('browser-pool: stopped');
|
|
58
|
+
const free = this.slots.find((s) => !s.inUse);
|
|
59
|
+
if (free) {
|
|
60
|
+
free.inUse = true;
|
|
61
|
+
return free;
|
|
62
|
+
}
|
|
63
|
+
if (this.slots.length < this.opts.size) {
|
|
64
|
+
const browser = await this.launchOne();
|
|
65
|
+
const slot = { browser, usesRemaining: MAX_USES_PER_BROWSER, inUse: true };
|
|
66
|
+
this.slots.push(slot);
|
|
67
|
+
return slot;
|
|
68
|
+
}
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
const idx = this.waiters.findIndex((w) => w.timer === timer);
|
|
72
|
+
if (idx >= 0)
|
|
73
|
+
this.waiters.splice(idx, 1);
|
|
74
|
+
reject(new Error(`browser-pool: acquire timeout after ${this.opts.acquireTimeoutMs}ms`));
|
|
75
|
+
}, this.opts.acquireTimeoutMs);
|
|
76
|
+
this.waiters.push({ resolve, reject, timer });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Return a healthy slot to the pool. Decrements its remaining lifespan. */
|
|
80
|
+
release(slot) {
|
|
81
|
+
slot.usesRemaining -= 1;
|
|
82
|
+
if (slot.usesRemaining <= 0) {
|
|
83
|
+
void this.replaceSlot(slot);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.handoff(slot);
|
|
87
|
+
}
|
|
88
|
+
/** Mark a slot as crashed; kill the browser + replace. */
|
|
89
|
+
dispose(slot) {
|
|
90
|
+
void this.replaceSlot(slot);
|
|
91
|
+
}
|
|
92
|
+
/** Close all browsers. */
|
|
93
|
+
async stop() {
|
|
94
|
+
this.stopped = true;
|
|
95
|
+
for (const w of this.waiters) {
|
|
96
|
+
clearTimeout(w.timer);
|
|
97
|
+
w.reject(new Error('browser-pool: stopping'));
|
|
98
|
+
}
|
|
99
|
+
this.waiters.length = 0;
|
|
100
|
+
const toClose = [...this.slots];
|
|
101
|
+
this.slots.length = 0;
|
|
102
|
+
await Promise.all(toClose.map((s) => s.browser.close().catch((err) => {
|
|
103
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser-pool: close failed');
|
|
104
|
+
})));
|
|
105
|
+
}
|
|
106
|
+
/** Visible for testing. */
|
|
107
|
+
get size() {
|
|
108
|
+
return this.slots.length;
|
|
109
|
+
}
|
|
110
|
+
// ── private ────────────────────────────────────────────────────────────
|
|
111
|
+
handoff(slot) {
|
|
112
|
+
if (this.waiters.length === 0) {
|
|
113
|
+
slot.inUse = false;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const next = this.waiters.shift();
|
|
117
|
+
clearTimeout(next.timer);
|
|
118
|
+
// Slot stays inUse; just transferred to the next caller.
|
|
119
|
+
next.resolve(slot);
|
|
120
|
+
}
|
|
121
|
+
async launchOne() {
|
|
122
|
+
const browser = await this.opts.launcher.launch({
|
|
123
|
+
proxy: this.opts.proxy,
|
|
124
|
+
headless: true,
|
|
125
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
126
|
+
timeout: 20_000,
|
|
127
|
+
});
|
|
128
|
+
return browser;
|
|
129
|
+
}
|
|
130
|
+
async replaceSlot(slot) {
|
|
131
|
+
const idx = this.slots.indexOf(slot);
|
|
132
|
+
if (idx < 0)
|
|
133
|
+
return;
|
|
134
|
+
this.slots.splice(idx, 1);
|
|
135
|
+
await slot.browser.close().catch((err) => {
|
|
136
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser-pool: close-on-replace failed');
|
|
137
|
+
});
|
|
138
|
+
if (this.stopped)
|
|
139
|
+
return;
|
|
140
|
+
// Replace only if we have waiters or we're below target — otherwise
|
|
141
|
+
// let the next acquire lazily refill.
|
|
142
|
+
if (this.waiters.length > 0 || this.slots.length < this.opts.size) {
|
|
143
|
+
try {
|
|
144
|
+
const browser = await this.launchOne();
|
|
145
|
+
const fresh = { browser, usesRemaining: MAX_USES_PER_BROWSER, inUse: false };
|
|
146
|
+
this.slots.push(fresh);
|
|
147
|
+
this.handoff(fresh);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser-pool: replacement launch failed');
|
|
151
|
+
// Reject the head waiter so the caller can fall back to per-fetch launch.
|
|
152
|
+
const w = this.waiters.shift();
|
|
153
|
+
if (w) {
|
|
154
|
+
clearTimeout(w.timer);
|
|
155
|
+
w.reject(err instanceof Error ? err : new Error(String(err)));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=browser-pool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-pool.js","sourceRoot":"","sources":["../src/browser-pool.ts"],"names":[],"mappings":"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"}
|
package/dist/cost.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetcher cost recording (chunk 9, Review #11).
|
|
3
|
+
*
|
|
4
|
+
* Per Review #11, every fetch writes a cost_records row. The cost_type
|
|
5
|
+
* discriminates the source ('fetch_tier1' for Patchright; 'fetch_tier0'
|
|
6
|
+
* later for HTTP-only Tier 0); the cost_subtype refines it (proxy
|
|
7
|
+
* provider name, or 'http_only' for no-proxy fetches).
|
|
8
|
+
*
|
|
9
|
+
* v1 uses a flat $0.0014 per Tier 1 fetch (Review #4 weighted average).
|
|
10
|
+
* Later chunks will refine this from actual proxy bytes + Patchright
|
|
11
|
+
* runtime once the production fetcher ships.
|
|
12
|
+
*/
|
|
13
|
+
import { type DbClient } from '@bluefields/db';
|
|
14
|
+
/** Per-fetch cost in micro-USD (Review #4: ~$0.0014 weighted average). */
|
|
15
|
+
export declare const TIER1_FIXED_COST_MICRO_USD = 1400;
|
|
16
|
+
/** Compute-only row when a fetch is blocked (robots disallow). */
|
|
17
|
+
export declare const COMPUTE_BLOCKED_COST_MICRO_USD = 50;
|
|
18
|
+
export type FetchCostType = 'fetch_tier0' | 'fetch_tier1' | 'fetch_tier2' | 'fetch_tier3' | 'fetch_304' | 'proxy_bandwidth' | 'compute';
|
|
19
|
+
export interface RecordFetchCostArgs {
|
|
20
|
+
db: DbClient;
|
|
21
|
+
customerId: string;
|
|
22
|
+
subscriptionId: string | null;
|
|
23
|
+
urlCanonical: string;
|
|
24
|
+
costType?: FetchCostType;
|
|
25
|
+
/** Subtype refinement — e.g. proxy provider name, or 'http_only'. */
|
|
26
|
+
costSubtype?: string | null;
|
|
27
|
+
costMicroUsd?: number;
|
|
28
|
+
/** Units recorded for analytics (bytes, ms, etc.). */
|
|
29
|
+
units?: string | number | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write a cost_records row for one fetch attempt.
|
|
33
|
+
*
|
|
34
|
+
* Defaults to Tier 1's flat cost with subtype='http_only' to reflect the
|
|
35
|
+
* v1 default fetcher. Override `costType` / `costSubtype` when you know
|
|
36
|
+
* (e.g. proxy_bandwidth row after a Patchright fetch, or 'fetch_304'
|
|
37
|
+
* when conditional GET returned 304 once chunk M8-10 wires that).
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordFetchCost(args: RecordFetchCostArgs): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Write a near-zero `compute` cost_records row for a fetch that was
|
|
42
|
+
* blocked before reaching the wire (e.g. robots.txt disallow). We still
|
|
43
|
+
* record the row so the customer can see the request in usage history.
|
|
44
|
+
*/
|
|
45
|
+
export declare function recordBlockedFetch(args: {
|
|
46
|
+
db: DbClient;
|
|
47
|
+
customerId: string;
|
|
48
|
+
subscriptionId: string | null;
|
|
49
|
+
urlCanonical: string;
|
|
50
|
+
reason: string;
|
|
51
|
+
}): Promise<void>;
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Fetcher cost recording (chunk 9, Review #11).
|
|
4
|
+
*
|
|
5
|
+
* Per Review #11, every fetch writes a cost_records row. The cost_type
|
|
6
|
+
* discriminates the source ('fetch_tier1' for Patchright; 'fetch_tier0'
|
|
7
|
+
* later for HTTP-only Tier 0); the cost_subtype refines it (proxy
|
|
8
|
+
* provider name, or 'http_only' for no-proxy fetches).
|
|
9
|
+
*
|
|
10
|
+
* v1 uses a flat $0.0014 per Tier 1 fetch (Review #4 weighted average).
|
|
11
|
+
* Later chunks will refine this from actual proxy bytes + Patchright
|
|
12
|
+
* runtime once the production fetcher ships.
|
|
13
|
+
*/
|
|
14
|
+
import { costRecords } from '@bluefields/db';
|
|
15
|
+
/** Per-fetch cost in micro-USD (Review #4: ~$0.0014 weighted average). */
|
|
16
|
+
export const TIER1_FIXED_COST_MICRO_USD = 1_400;
|
|
17
|
+
/** Compute-only row when a fetch is blocked (robots disallow). */
|
|
18
|
+
export const COMPUTE_BLOCKED_COST_MICRO_USD = 50;
|
|
19
|
+
/**
|
|
20
|
+
* Write a cost_records row for one fetch attempt.
|
|
21
|
+
*
|
|
22
|
+
* Defaults to Tier 1's flat cost with subtype='http_only' to reflect the
|
|
23
|
+
* v1 default fetcher. Override `costType` / `costSubtype` when you know
|
|
24
|
+
* (e.g. proxy_bandwidth row after a Patchright fetch, or 'fetch_304'
|
|
25
|
+
* when conditional GET returned 304 once chunk M8-10 wires that).
|
|
26
|
+
*/
|
|
27
|
+
export async function recordFetchCost(args) {
|
|
28
|
+
await args.db.insert(costRecords).values({
|
|
29
|
+
customerId: args.customerId,
|
|
30
|
+
subscriptionId: args.subscriptionId,
|
|
31
|
+
urlCanonical: args.urlCanonical,
|
|
32
|
+
costType: args.costType ?? 'fetch_tier1',
|
|
33
|
+
costSubtype: args.costSubtype ?? 'http_only',
|
|
34
|
+
costMicroUsd: args.costMicroUsd ?? TIER1_FIXED_COST_MICRO_USD,
|
|
35
|
+
units: args.units !== undefined ? String(args.units) : null,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Write a near-zero `compute` cost_records row for a fetch that was
|
|
40
|
+
* blocked before reaching the wire (e.g. robots.txt disallow). We still
|
|
41
|
+
* record the row so the customer can see the request in usage history.
|
|
42
|
+
*/
|
|
43
|
+
export async function recordBlockedFetch(args) {
|
|
44
|
+
await args.db.insert(costRecords).values({
|
|
45
|
+
customerId: args.customerId,
|
|
46
|
+
subscriptionId: args.subscriptionId,
|
|
47
|
+
urlCanonical: args.urlCanonical,
|
|
48
|
+
costType: 'compute',
|
|
49
|
+
costSubtype: args.reason,
|
|
50
|
+
costMicroUsd: COMPUTE_BLOCKED_COST_MICRO_USD,
|
|
51
|
+
units: null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=cost.js.map
|
package/dist/cost.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cost.js","sourceRoot":"","sources":["../src/cost.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;GAWG;AAEH,OAAO,EAAiB,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE5D,0EAA0E;AAC1E,MAAM,CAAC,MAAM,0BAA0B,GAAG,KAAK,CAAC;AAEhD,kEAAkE;AAClE,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,CAAC;AAwBjD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAyB;IAC7D,MAAM,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;QACvC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,aAAa;QACxC,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,WAAW;QAC5C,YAAY,EAAE,IAAI,CAAC,YAAY,IAAI,0BAA0B;QAC7D,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;KAC5D,CAAC,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,IAMxC;IACC,MAAM,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;QACvC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,YAAY,EAAE,IAAI,CAAC,YAAY;QAC/B,QAAQ,EAAE,SAAS;QACnB,WAAW,EAAE,IAAI,CAAC,MAAM;QACxB,YAAY,EAAE,8BAA8B;QAC5C,KAAK,EAAE,IAAI;KACZ,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bright Data Web Unlocker fetcher adapter (Tier 2).
|
|
3
|
+
*
|
|
4
|
+
* Tier 2 of the coverage ladder: a managed anti-bot "unlocker" endpoint
|
|
5
|
+
* that solves challenges (Cloudflare, PerimeterX, DataDome, …) server-side
|
|
6
|
+
* and returns the final HTML. Roughly 10-20x the per-request cost of the
|
|
7
|
+
* Tier-1 browser path, so it is escalation-only — Tier 0 (HTTP) and Tier 1
|
|
8
|
+
* (Patchright + residential proxy) run first; we only reach for the
|
|
9
|
+
* unlocker when a page is STILL shell/challenge/blocked.
|
|
10
|
+
*
|
|
11
|
+
* Env-gated exactly like the Patchright adapter: without BRIGHT_DATA_TOKEN
|
|
12
|
+
* + BRIGHT_DATA_ZONE, `tryCreateBrightDataAdapter` returns null and callers
|
|
13
|
+
* stay on the cheaper tiers.
|
|
14
|
+
*
|
|
15
|
+
* Fetch-only by design (v1): no page actions, no screenshots. The
|
|
16
|
+
* unlocker's /request endpoint returns the final HTML body; it does not
|
|
17
|
+
* expose the interactive page surface that actions/screenshots need.
|
|
18
|
+
* Callers passing `actions` get an explicit rejection (mirrors the HTTP
|
|
19
|
+
* adapter's stance on browser-only features). The unlocker also manages
|
|
20
|
+
* its own UA / fingerprint, so a caller-supplied `userAgent` does not reach
|
|
21
|
+
* the target — we report a managed-UA sentinel rather than pretend.
|
|
22
|
+
*
|
|
23
|
+
* Security: the TARGET url is SSRF-validated (HTTPS-only, no internal /
|
|
24
|
+
* metadata hosts) before we hand it to the unlocker — the managed egress
|
|
25
|
+
* must not become an open SSRF relay. The POST to api.brightdata.com itself
|
|
26
|
+
* goes through ssrfSafeFetch like every other external call.
|
|
27
|
+
*/
|
|
28
|
+
import type { FetcherAdapter } from './types.js';
|
|
29
|
+
export declare const BRIGHT_DATA_REQUIRED_ENV: readonly ["BRIGHT_DATA_TOKEN", "BRIGHT_DATA_ZONE"];
|
|
30
|
+
export interface BrightDataAdapterConfig {
|
|
31
|
+
/** Bright Data API token, sent as `Authorization: Bearer <token>`. */
|
|
32
|
+
token: string;
|
|
33
|
+
/** Web Unlocker zone name. */
|
|
34
|
+
zone: string;
|
|
35
|
+
/** Override the API endpoint (default https://api.brightdata.com/request). */
|
|
36
|
+
endpoint?: string;
|
|
37
|
+
/** Test seam: override the HTTP fetch. */
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/** Hard cap (bytes) on the response body. Default `DEFAULT_MAX_BODY_BYTES` (5 MiB). */
|
|
40
|
+
maxBodyBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns the env-gated Bright Data adapter when BRIGHT_DATA_TOKEN +
|
|
44
|
+
* BRIGHT_DATA_ZONE are both set; null otherwise. Callers (the coverage
|
|
45
|
+
* runner) fall back to the lower tiers when this returns null.
|
|
46
|
+
*/
|
|
47
|
+
export declare function tryCreateBrightDataAdapter(env?: NodeJS.ProcessEnv): FetcherAdapter | null;
|
|
48
|
+
/**
|
|
49
|
+
* Direct constructor — bypasses the env gate. Used by the factory above and
|
|
50
|
+
* by tests (which inject `fetchImpl`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function createBrightDataAdapter(config: BrightDataAdapterConfig): FetcherAdapter;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Bright Data Web Unlocker fetcher adapter (Tier 2).
|
|
4
|
+
*
|
|
5
|
+
* Tier 2 of the coverage ladder: a managed anti-bot "unlocker" endpoint
|
|
6
|
+
* that solves challenges (Cloudflare, PerimeterX, DataDome, …) server-side
|
|
7
|
+
* and returns the final HTML. Roughly 10-20x the per-request cost of the
|
|
8
|
+
* Tier-1 browser path, so it is escalation-only — Tier 0 (HTTP) and Tier 1
|
|
9
|
+
* (Patchright + residential proxy) run first; we only reach for the
|
|
10
|
+
* unlocker when a page is STILL shell/challenge/blocked.
|
|
11
|
+
*
|
|
12
|
+
* Env-gated exactly like the Patchright adapter: without BRIGHT_DATA_TOKEN
|
|
13
|
+
* + BRIGHT_DATA_ZONE, `tryCreateBrightDataAdapter` returns null and callers
|
|
14
|
+
* stay on the cheaper tiers.
|
|
15
|
+
*
|
|
16
|
+
* Fetch-only by design (v1): no page actions, no screenshots. The
|
|
17
|
+
* unlocker's /request endpoint returns the final HTML body; it does not
|
|
18
|
+
* expose the interactive page surface that actions/screenshots need.
|
|
19
|
+
* Callers passing `actions` get an explicit rejection (mirrors the HTTP
|
|
20
|
+
* adapter's stance on browser-only features). The unlocker also manages
|
|
21
|
+
* its own UA / fingerprint, so a caller-supplied `userAgent` does not reach
|
|
22
|
+
* the target — we report a managed-UA sentinel rather than pretend.
|
|
23
|
+
*
|
|
24
|
+
* Security: the TARGET url is SSRF-validated (HTTPS-only, no internal /
|
|
25
|
+
* metadata hosts) before we hand it to the unlocker — the managed egress
|
|
26
|
+
* must not become an open SSRF relay. The POST to api.brightdata.com itself
|
|
27
|
+
* goes through ssrfSafeFetch like every other external call.
|
|
28
|
+
*/
|
|
29
|
+
import { envInt, ssrfSafeFetch, validateUrlForExternalRequest } from '@bluefields/primitives';
|
|
30
|
+
import { fetchWithRetry, readBodyCapped } from './fetcher-http.js';
|
|
31
|
+
// The unlocker may sit on a challenge for tens of seconds while it solves
|
|
32
|
+
// it server-side; 30s (the HTTP default) is too tight. Callers can still
|
|
33
|
+
// override via options.timeoutMs (the coverage runner passes a longer one).
|
|
34
|
+
const DEFAULT_TIMEOUT_MS = envInt('BLUEFIELDS_UNLOCKER_TIMEOUT_MS', 60_000);
|
|
35
|
+
const DEFAULT_ENDPOINT = 'https://api.brightdata.com/request';
|
|
36
|
+
const FETCHER_ID = 'brightdata-v1';
|
|
37
|
+
/** The unlocker fetches from its own managed egress; we never see that IP. */
|
|
38
|
+
const MANAGED_EGRESS_PLACEHOLDER_IP = '0.0.0.0';
|
|
39
|
+
/** The unlocker picks + rotates its own UA; the caller's never reaches the target. */
|
|
40
|
+
const MANAGED_UA = 'brightdata-web-unlocker (managed fingerprint)';
|
|
41
|
+
/** Hard cap on the unlocker response body buffered into memory (mirrors tier-0). */
|
|
42
|
+
const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
|
|
43
|
+
export const BRIGHT_DATA_REQUIRED_ENV = ['BRIGHT_DATA_TOKEN', 'BRIGHT_DATA_ZONE'];
|
|
44
|
+
/**
|
|
45
|
+
* Returns the env-gated Bright Data adapter when BRIGHT_DATA_TOKEN +
|
|
46
|
+
* BRIGHT_DATA_ZONE are both set; null otherwise. Callers (the coverage
|
|
47
|
+
* runner) fall back to the lower tiers when this returns null.
|
|
48
|
+
*/
|
|
49
|
+
export function tryCreateBrightDataAdapter(env = process.env) {
|
|
50
|
+
for (const key of BRIGHT_DATA_REQUIRED_ENV) {
|
|
51
|
+
if (!env[key] || env[key]?.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return createBrightDataAdapter({
|
|
55
|
+
token: env.BRIGHT_DATA_TOKEN ?? '',
|
|
56
|
+
zone: env.BRIGHT_DATA_ZONE ?? '',
|
|
57
|
+
...(env.BRIGHT_DATA_ENDPOINT ? { endpoint: env.BRIGHT_DATA_ENDPOINT } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Direct constructor — bypasses the env gate. Used by the factory above and
|
|
62
|
+
* by tests (which inject `fetchImpl`).
|
|
63
|
+
*/
|
|
64
|
+
export function createBrightDataAdapter(config) {
|
|
65
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
66
|
+
// Tests inject config.fetchImpl; production uses ssrfSafeFetch (undici's
|
|
67
|
+
// fetch with the SSRF-safe dispatcher), which also validates that
|
|
68
|
+
// api.brightdata.com resolves to a public IP.
|
|
69
|
+
const fetchImpl = (config.fetchImpl ?? ssrfSafeFetch);
|
|
70
|
+
const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
71
|
+
return {
|
|
72
|
+
async fetch(url, options = {}) {
|
|
73
|
+
if (options.actions && options.actions.length > 0) {
|
|
74
|
+
throw new Error('Bright Data unlocker is fetch-only; page actions require the Patchright fetcher');
|
|
75
|
+
}
|
|
76
|
+
// SSRF-validate the TARGET (not just our vendor endpoint) so the
|
|
77
|
+
// managed egress can't be steered at internal / metadata hosts.
|
|
78
|
+
await validateUrlForExternalRequest(url);
|
|
79
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
+
const now = options.nowFn ?? (() => new Date());
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
const fetchedAt = now();
|
|
85
|
+
// `format: 'raw'` → the response body IS the target's HTML. Bright Data
|
|
86
|
+
// proxies the upstream status through res.status (a still-blocked or
|
|
87
|
+
// unsolvable page surfaces as a non-2xx, which the block-detector maps
|
|
88
|
+
// honestly rather than counting as success).
|
|
89
|
+
const requestBody = JSON.stringify({ zone: config.zone, url, format: 'raw' });
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetchWithRetry(fetchImpl, endpoint, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
authorization: `Bearer ${config.token}`,
|
|
96
|
+
'content-type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: requestBody,
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
redirect: 'follow',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
}
|
|
106
|
+
// Bound the body read by the request deadline (the unlocker can stream a
|
|
107
|
+
// large solved page slowly; res.text() after clearTimeout was unbounded).
|
|
108
|
+
const html = await readBodyCapped(res, maxBodyBytes, { deadlineMs: startedAt + timeoutMs });
|
|
109
|
+
const fetchDurationMs = Math.max(1, Date.now() - startedAt);
|
|
110
|
+
const responseHeaders = {};
|
|
111
|
+
res.headers.forEach((value, key) => {
|
|
112
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
html,
|
|
116
|
+
responseStatus: res.status,
|
|
117
|
+
responseHeaders,
|
|
118
|
+
fetchedAt,
|
|
119
|
+
fetchDurationMs,
|
|
120
|
+
fetcherId: FETCHER_ID,
|
|
121
|
+
// Managed egress: the real exit IP belongs to Bright Data and isn't
|
|
122
|
+
// exposed to us, so we report the same placeholder as the no-proxy path.
|
|
123
|
+
proxyEgressIp: MANAGED_EGRESS_PLACEHOLDER_IP,
|
|
124
|
+
userAgent: MANAGED_UA,
|
|
125
|
+
rawHtmlSizeBytes: Buffer.byteLength(html, 'utf8'),
|
|
126
|
+
// Fetch-only: the unlocker can't render-and-screenshot in this mode.
|
|
127
|
+
screenshot: null,
|
|
128
|
+
// Raw mode doesn't return a trustworthy post-redirect URL header;
|
|
129
|
+
// report the requested URL.
|
|
130
|
+
finalUrl: url,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=fetcher-brightdata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetcher-brightdata.js","sourceRoot":"","sources":["../src/fetcher-brightdata.ts"],"names":[],"mappings":"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"}
|