hazo_scrape 1.0.1 → 1.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/CHANGE_LOG.md +19 -0
- package/README.md +105 -0
- package/dist/fetch/cache.d.ts +39 -0
- package/dist/fetch/cache.d.ts.map +1 -0
- package/dist/fetch/cache.js +82 -0
- package/dist/fetch/config.d.ts +44 -0
- package/dist/fetch/config.d.ts.map +1 -0
- package/dist/fetch/config.js +174 -0
- package/dist/fetch/headers.d.ts +19 -0
- package/dist/fetch/headers.d.ts.map +1 -0
- package/dist/fetch/headers.js +85 -0
- package/dist/fetch/index.d.ts +10 -0
- package/dist/fetch/index.d.ts.map +1 -0
- package/dist/fetch/index.js +326 -0
- package/dist/fetch/proxy.d.ts +43 -0
- package/dist/fetch/proxy.d.ts.map +1 -0
- package/dist/fetch/proxy.js +96 -0
- package/dist/fetch/ratelimit.d.ts +15 -0
- package/dist/fetch/ratelimit.d.ts.map +1 -0
- package/dist/fetch/ratelimit.js +35 -0
- package/dist/fetch/robots.d.ts +30 -0
- package/dist/fetch/robots.d.ts.map +1 -0
- package/dist/fetch/robots.js +84 -0
- package/dist/fetch/scrape.d.ts +29 -0
- package/dist/fetch/scrape.d.ts.map +1 -0
- package/dist/fetch/scrape.js +27 -0
- package/dist/fetch/types.d.ts +55 -0
- package/dist/fetch/types.d.ts.map +1 -0
- package/dist/fetch/types.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/package.json +10 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/index.ts — polite/resilient HTTP core (fetchDocument)
|
|
2
|
+
//
|
|
3
|
+
// Phase 2 scope: safeFetch wrapping, retry + backoff + jitter, Retry-After
|
|
4
|
+
// handling, timeout, and the UA-pool/header builder. Phase 3 adds the
|
|
5
|
+
// robots.txt gate and per-host rate limiter, wired in below. Phase 4 adds
|
|
6
|
+
// proxy rotation (see ./proxy.js — pure ProxyPool + undici ProxyAgent
|
|
7
|
+
// wiring), also wired in below. Phase 5 adds a content-addressed on-disk
|
|
8
|
+
// response cache (see ./cache.js) — the cache check runs after the robots
|
|
9
|
+
// gate but BEFORE the rate-limit wait, so a cache hit never pays the
|
|
10
|
+
// per-host throttle; see the `[Phase 5]` comments below for the key
|
|
11
|
+
// derivation rationale.
|
|
12
|
+
//
|
|
13
|
+
// Phase 6 adds the scrapeTable orchestrator (see ./scrape.js — wires
|
|
14
|
+
// fetchDocument to the parse layer's extractTable/mapColumns/mapRows) and
|
|
15
|
+
// re-exports this whole fetch layer from `src/index.ts` plus a `./fetch`
|
|
16
|
+
// package export map entry, so `hazo_scrape` and `hazo_scrape/fetch` both
|
|
17
|
+
// expose fetchDocument/scrapeTable to consumers.
|
|
18
|
+
import { safeFetch, SafeFetchError } from 'hazo_secure/fetch';
|
|
19
|
+
import { HazoUnavailableError, HazoRateLimitError, sleep } from 'hazo_core';
|
|
20
|
+
import { resolveFetchConfig } from './config.js';
|
|
21
|
+
import { buildHeaders, UA_POOL } from './headers.js';
|
|
22
|
+
import { checkRobots, RobotsDisallowedError } from './robots.js';
|
|
23
|
+
import { rateLimitWait } from './ratelimit.js';
|
|
24
|
+
import { ProxyPool, buildProxyFetchImpl } from './proxy.js';
|
|
25
|
+
import { computeCacheKey, readCache, writeCache } from './cache.js';
|
|
26
|
+
export { RobotsDisallowedError, resetRobotsCache } from './robots.js';
|
|
27
|
+
export { resetRateLimiter } from './ratelimit.js';
|
|
28
|
+
export { ProxyPool, buildProxyFetchImpl, resetProxyAgents } from './proxy.js';
|
|
29
|
+
export { scrapeTable } from './scrape.js';
|
|
30
|
+
// One ProxyPool per distinct (urls, cooldownMs) combination, so rotation
|
|
31
|
+
// state (cursor + cooldowns) survives across calls that share the same
|
|
32
|
+
// configured proxy list, but different configs don't cross-contaminate.
|
|
33
|
+
const proxyPools = new Map();
|
|
34
|
+
/** Clears all cached ProxyPools. For tests. */
|
|
35
|
+
export function resetProxyPools() {
|
|
36
|
+
proxyPools.clear();
|
|
37
|
+
}
|
|
38
|
+
function getProxyPool(urls, cooldownMs) {
|
|
39
|
+
const key = `${urls.join('|')}@${cooldownMs}`;
|
|
40
|
+
const existing = proxyPools.get(key);
|
|
41
|
+
if (existing)
|
|
42
|
+
return existing;
|
|
43
|
+
const pool = new ProxyPool(urls, cooldownMs);
|
|
44
|
+
proxyPools.set(key, pool);
|
|
45
|
+
return pool;
|
|
46
|
+
}
|
|
47
|
+
// SafeFetchError codes that indicate the request itself is invalid/forbidden
|
|
48
|
+
// and will never succeed on retry — rethrow immediately, no backoff.
|
|
49
|
+
const NON_RETRYABLE_SAFE_FETCH_CODES = [
|
|
50
|
+
'invalid_url',
|
|
51
|
+
'protocol_not_allowed',
|
|
52
|
+
'host_not_allowed',
|
|
53
|
+
'private_ip_blocked',
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Resolves defaults + optional INI config (`opts.configPath`), then overlays
|
|
57
|
+
* per-call `opts` fields on top — the caller's explicit per-call values
|
|
58
|
+
* always win over both defaults and the INI file.
|
|
59
|
+
*/
|
|
60
|
+
function resolveRequestConfig(opts) {
|
|
61
|
+
const base = resolveFetchConfig(opts.configPath ? { configPath: opts.configPath } : undefined);
|
|
62
|
+
const http = {
|
|
63
|
+
...base.http,
|
|
64
|
+
...(opts.userAgent !== undefined ? { userAgent: opts.userAgent } : {}),
|
|
65
|
+
...(opts.acceptLanguage !== undefined ? { acceptLanguage: opts.acceptLanguage } : {}),
|
|
66
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
67
|
+
...(opts.maxRedirects !== undefined ? { maxRedirects: opts.maxRedirects } : {}),
|
|
68
|
+
};
|
|
69
|
+
const retry = {
|
|
70
|
+
...base.retry,
|
|
71
|
+
...(opts.retry?.maxAttempts !== undefined ? { maxAttempts: opts.retry.maxAttempts } : {}),
|
|
72
|
+
...(opts.retry?.backoffMs !== undefined ? { backoffMs: opts.retry.backoffMs } : {}),
|
|
73
|
+
...(opts.retry?.backoffFactor !== undefined ? { backoffFactor: opts.retry.backoffFactor } : {}),
|
|
74
|
+
...(opts.retry?.jitter !== undefined ? { jitter: opts.retry.jitter } : {}),
|
|
75
|
+
...(opts.retry?.retryOn !== undefined ? { retryOn: opts.retry.retryOn } : {}),
|
|
76
|
+
};
|
|
77
|
+
const rateLimit = {
|
|
78
|
+
...base.rateLimit,
|
|
79
|
+
...(opts.rateLimit?.minIntervalMs !== undefined ? { minIntervalMs: opts.rateLimit.minIntervalMs } : {}),
|
|
80
|
+
...(opts.rateLimit?.perHost !== undefined ? { perHost: opts.rateLimit.perHost } : {}),
|
|
81
|
+
};
|
|
82
|
+
const proxy = {
|
|
83
|
+
...base.proxy,
|
|
84
|
+
...(opts.proxy?.urls !== undefined ? { urls: opts.proxy.urls } : {}),
|
|
85
|
+
...(opts.proxy?.username !== undefined ? { username: opts.proxy.username } : {}),
|
|
86
|
+
...(opts.proxy?.password !== undefined ? { password: opts.proxy.password } : {}),
|
|
87
|
+
...(opts.proxy?.rotation !== undefined ? { rotation: opts.proxy.rotation } : {}),
|
|
88
|
+
...(opts.proxy?.cooldownMs !== undefined ? { cooldownMs: opts.proxy.cooldownMs } : {}),
|
|
89
|
+
};
|
|
90
|
+
const cache = {
|
|
91
|
+
...base.cache,
|
|
92
|
+
...(opts.cache?.enabled !== undefined ? { enabled: opts.cache.enabled } : {}),
|
|
93
|
+
...(opts.cache?.dir !== undefined ? { dir: opts.cache.dir } : {}),
|
|
94
|
+
...(opts.cache?.ttlSeconds !== undefined ? { ttlSeconds: opts.cache.ttlSeconds } : {}),
|
|
95
|
+
};
|
|
96
|
+
const robots = {
|
|
97
|
+
...base.robots,
|
|
98
|
+
...(opts.robots?.respect !== undefined ? { respect: opts.robots.respect } : {}),
|
|
99
|
+
};
|
|
100
|
+
return { http, retry, rateLimit, proxy, cache, robots };
|
|
101
|
+
}
|
|
102
|
+
/** `backoffMs * backoffFactor ** (attempt - 1)`, plus optional jitter. */
|
|
103
|
+
function computeBackoffDelayMs(cfg, attempt) {
|
|
104
|
+
const base = cfg.retry.backoffMs * cfg.retry.backoffFactor ** (attempt - 1);
|
|
105
|
+
if (!cfg.retry.jitter)
|
|
106
|
+
return base;
|
|
107
|
+
// Equal-jitter: keeps delay in [base, 1.5*base) so tests that disable
|
|
108
|
+
// jitter (`jitter:false`) can assert the exact base delay.
|
|
109
|
+
return base + Math.random() * base * 0.5;
|
|
110
|
+
}
|
|
111
|
+
/** Parses a `Retry-After` header value (delta-seconds or an HTTP-date) into ms. */
|
|
112
|
+
function parseRetryAfterMs(value) {
|
|
113
|
+
if (!value)
|
|
114
|
+
return undefined;
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
if (/^\d+$/.test(trimmed)) {
|
|
117
|
+
return Number(trimmed) * 1000;
|
|
118
|
+
}
|
|
119
|
+
const dateMs = Date.parse(trimmed);
|
|
120
|
+
if (!Number.isNaN(dateMs)) {
|
|
121
|
+
return Math.max(0, dateMs - Date.now());
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
function collectHeaders(res) {
|
|
126
|
+
const out = {};
|
|
127
|
+
res.headers.forEach((value, key) => {
|
|
128
|
+
out[key] = value;
|
|
129
|
+
});
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
export async function fetchDocument(url, opts = {}) {
|
|
133
|
+
const startedAt = Date.now();
|
|
134
|
+
// 1. Resolve config: defaults + optional INI, overlaid by per-call opts.
|
|
135
|
+
const cfg = resolveRequestConfig(opts);
|
|
136
|
+
// Representative UA for robots matching + logging. Rotation still applies
|
|
137
|
+
// to the actual outgoing request headers via buildHeaders/pickUserAgent.
|
|
138
|
+
const effectiveUa = opts.userAgent ?? cfg.http.userAgent ?? UA_POOL[0];
|
|
139
|
+
// 2. Robots gate. `fetchRobotsTxt` recurses into fetchDocument with the
|
|
140
|
+
// robots gate disabled (recursion guard) and rate-limiting disabled (a
|
|
141
|
+
// robots.txt fetch must not itself wait behind the same host's throttle).
|
|
142
|
+
const robotsFetchOpts = {
|
|
143
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
144
|
+
...(opts.configPath ? { configPath: opts.configPath } : {}),
|
|
145
|
+
robots: { respect: false },
|
|
146
|
+
rateLimit: { minIntervalMs: 0 },
|
|
147
|
+
};
|
|
148
|
+
const fetchRobotsTxt = async (robotsUrl) => {
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetchDocument(robotsUrl, robotsFetchOpts);
|
|
151
|
+
return { status: res.status, body: res.body };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const { allowed, crawlDelayMs } = await checkRobots({
|
|
158
|
+
url,
|
|
159
|
+
effectiveUa,
|
|
160
|
+
respect: cfg.robots.respect,
|
|
161
|
+
cacheTtlSeconds: cfg.robots.cacheTtlSeconds,
|
|
162
|
+
fetchRobotsTxt,
|
|
163
|
+
});
|
|
164
|
+
if (!allowed) {
|
|
165
|
+
throw new RobotsDisallowedError(url);
|
|
166
|
+
}
|
|
167
|
+
// 3. [Phase 5] Cache check — deliberately placed BEFORE the rate-limit
|
|
168
|
+
// wait (step 4) so a cache hit never pays the per-host throttle. Keyed on
|
|
169
|
+
// the pre-redirect request `url` (not the eventual `finalUrl`, which
|
|
170
|
+
// isn't known until after the network fetch) — see cache.ts for the full
|
|
171
|
+
// rationale. Only GET is cached/looked up.
|
|
172
|
+
const acceptLanguage = opts.acceptLanguage ?? cfg.http.acceptLanguage;
|
|
173
|
+
const cacheKey = cfg.cache.enabled ? await computeCacheKey('GET', url, acceptLanguage) : null;
|
|
174
|
+
if (cfg.cache.enabled && cacheKey) {
|
|
175
|
+
const hit = await readCache(cfg.cache, cacheKey);
|
|
176
|
+
if (hit) {
|
|
177
|
+
return {
|
|
178
|
+
url,
|
|
179
|
+
finalUrl: hit.finalUrl,
|
|
180
|
+
status: hit.status,
|
|
181
|
+
statusText: hit.statusText,
|
|
182
|
+
ok: hit.status >= 200 && hit.status < 300,
|
|
183
|
+
headers: hit.headers,
|
|
184
|
+
body: hit.body,
|
|
185
|
+
contentType: hit.contentType,
|
|
186
|
+
fromCache: true,
|
|
187
|
+
attempts: 0,
|
|
188
|
+
timingMs: Date.now() - startedAt,
|
|
189
|
+
usedProxy: undefined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 4. Rate-limit wait — honors the configured minimum interval, or the
|
|
194
|
+
// robots.txt Crawl-delay for this host, whichever is larger.
|
|
195
|
+
const host = new URL(url).host;
|
|
196
|
+
const effectiveIntervalMs = Math.max(cfg.rateLimit.minIntervalMs, crawlDelayMs ?? 0);
|
|
197
|
+
await rateLimitWait(host, effectiveIntervalMs, { perHost: cfg.rateLimit.perHost });
|
|
198
|
+
// 5. Retry loop.
|
|
199
|
+
const maxAttempts = Math.max(1, cfg.retry.maxAttempts);
|
|
200
|
+
let lastStatus;
|
|
201
|
+
let lastError;
|
|
202
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
203
|
+
const headers = buildHeaders(cfg, opts, attempt);
|
|
204
|
+
// Proxy dispatcher selection. An injected test `fetchImpl` always wins —
|
|
205
|
+
// we never proxy on top of a caller-supplied fetch seam.
|
|
206
|
+
let attemptFetchImpl;
|
|
207
|
+
let usedProxyUrl;
|
|
208
|
+
let proxyPool;
|
|
209
|
+
if (cfg.proxy.urls.length > 0 && !opts.fetchImpl) {
|
|
210
|
+
proxyPool = getProxyPool(cfg.proxy.urls, cfg.proxy.cooldownMs);
|
|
211
|
+
const proxyUrl = proxyPool.acquire();
|
|
212
|
+
if (proxyUrl === null) {
|
|
213
|
+
throw new HazoUnavailableError({
|
|
214
|
+
code: 'HAZO_SCRAPE_PROXY_EXHAUSTED',
|
|
215
|
+
pkg: 'hazo_scrape',
|
|
216
|
+
message: `All ${cfg.proxy.urls.length} configured proxy(ies) are cooling down for ${url}`,
|
|
217
|
+
context: { url, proxies: cfg.proxy.urls.length },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
attemptFetchImpl = buildProxyFetchImpl(proxyUrl, { username: cfg.proxy.username, password: cfg.proxy.password });
|
|
221
|
+
usedProxyUrl = proxyUrl;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
attemptFetchImpl = opts.fetchImpl;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const res = await safeFetch(url, {
|
|
228
|
+
policy: {
|
|
229
|
+
timeoutMs: cfg.http.timeoutMs,
|
|
230
|
+
maxRedirects: cfg.http.maxRedirects,
|
|
231
|
+
blockPrivateIps: true,
|
|
232
|
+
allowedProtocols: ['http:', 'https:'],
|
|
233
|
+
},
|
|
234
|
+
deps: attemptFetchImpl ? { fetchImpl: attemptFetchImpl } : undefined,
|
|
235
|
+
method: 'GET',
|
|
236
|
+
headers,
|
|
237
|
+
});
|
|
238
|
+
const isRetryableStatus = cfg.retry.retryOn.includes(res.status);
|
|
239
|
+
if (isRetryableStatus) {
|
|
240
|
+
lastStatus = res.status;
|
|
241
|
+
if (attempt < maxAttempts) {
|
|
242
|
+
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
|
|
243
|
+
const backoffDelay = computeBackoffDelayMs(cfg, attempt);
|
|
244
|
+
const delay = retryAfterMs !== undefined ? Math.max(backoffDelay, retryAfterMs) : backoffDelay;
|
|
245
|
+
await sleep(delay);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Last attempt exhausted on a retryable status — fall through to
|
|
249
|
+
// step 6, which classifies and throws.
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
// Terminal: 2xx/3xx, or a terminal 4xx not in retryOn. Returned as a
|
|
253
|
+
// FetchResult (ok:false for 4xx) — never thrown.
|
|
254
|
+
const body = await res.text();
|
|
255
|
+
const finalUrl = res.url || url;
|
|
256
|
+
const responseHeaders = collectHeaders(res);
|
|
257
|
+
const contentType = res.headers.get('content-type') ?? undefined;
|
|
258
|
+
// [Phase 5] Only 2xx responses are cache-worthy — terminal 4xx/etc are
|
|
259
|
+
// never stored. Written under the same pre-redirect `url`-derived key
|
|
260
|
+
// used for the lookup in step 3.
|
|
261
|
+
if (res.ok && cfg.cache.enabled && cacheKey) {
|
|
262
|
+
await writeCache(cfg.cache, cacheKey, {
|
|
263
|
+
finalUrl,
|
|
264
|
+
status: res.status,
|
|
265
|
+
statusText: res.statusText,
|
|
266
|
+
headers: responseHeaders,
|
|
267
|
+
body,
|
|
268
|
+
contentType,
|
|
269
|
+
storedAt: Date.now(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
url,
|
|
274
|
+
finalUrl,
|
|
275
|
+
status: res.status,
|
|
276
|
+
statusText: res.statusText,
|
|
277
|
+
ok: res.ok,
|
|
278
|
+
headers: responseHeaders,
|
|
279
|
+
body,
|
|
280
|
+
contentType,
|
|
281
|
+
fromCache: false,
|
|
282
|
+
attempts: attempt,
|
|
283
|
+
timingMs: Date.now() - startedAt,
|
|
284
|
+
usedProxy: usedProxyUrl,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (err instanceof SafeFetchError) {
|
|
289
|
+
if (NON_RETRYABLE_SAFE_FETCH_CODES.includes(err.code)) {
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
// Transient (timeout, dns_failed, redirect_limit, redirect_loop):
|
|
293
|
+
// retry if attempts remain. If this attempt went through a proxy,
|
|
294
|
+
// cool it down so the next attempt rotates to a different one.
|
|
295
|
+
if (usedProxyUrl && proxyPool) {
|
|
296
|
+
proxyPool.reportFailure(usedProxyUrl);
|
|
297
|
+
}
|
|
298
|
+
lastError = err;
|
|
299
|
+
if (attempt < maxAttempts) {
|
|
300
|
+
const delay = computeBackoffDelayMs(cfg, attempt);
|
|
301
|
+
await sleep(delay);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
// Not a SafeFetchError — unexpected failure, propagate as-is.
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 6. Retries exhausted via repeated retryable statuses or transient errors.
|
|
311
|
+
if (lastStatus === 429) {
|
|
312
|
+
throw new HazoRateLimitError({
|
|
313
|
+
code: 'HAZO_SCRAPE_RATE_LIMITED',
|
|
314
|
+
pkg: 'hazo_scrape',
|
|
315
|
+
message: `Rate limited fetching ${url} after ${maxAttempts} attempt(s)`,
|
|
316
|
+
context: { url, attempts: maxAttempts },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
throw new HazoUnavailableError({
|
|
320
|
+
code: 'HAZO_SCRAPE_UNAVAILABLE',
|
|
321
|
+
pkg: 'hazo_scrape',
|
|
322
|
+
message: `Failed to fetch ${url} after ${maxAttempts} attempt(s)`,
|
|
323
|
+
context: { url, attempts: maxAttempts },
|
|
324
|
+
...(lastError !== undefined ? { cause: lastError } : {}),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-robin proxy rotation with per-proxy cooldowns. Pure and
|
|
3
|
+
* synchronous — no I/O, no undici — so it can be unit-tested without any
|
|
4
|
+
* real proxy or network access.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ProxyPool {
|
|
7
|
+
private readonly urls;
|
|
8
|
+
private readonly cooldownMs;
|
|
9
|
+
private cursor;
|
|
10
|
+
private readonly cooledUntil;
|
|
11
|
+
constructor(urls: string[], cooldownMs: number);
|
|
12
|
+
/**
|
|
13
|
+
* Returns the next non-cooled proxy URL in round-robin order, advancing
|
|
14
|
+
* the cursor past it. Returns `null` if the pool is empty or every proxy
|
|
15
|
+
* is currently cooled.
|
|
16
|
+
*/
|
|
17
|
+
acquire(now?: number): string | null;
|
|
18
|
+
/** Marks `url` cooled until `now + cooldownMs`; `acquire()` skips it until then. */
|
|
19
|
+
reportFailure(url: string, now?: number): void;
|
|
20
|
+
/** Resets the cursor and clears all cooldowns. For tests. */
|
|
21
|
+
reset(): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Builds a `deps.fetchImpl`-shaped function that routes through the given
|
|
25
|
+
* proxy via an undici `ProxyAgent`.
|
|
26
|
+
*
|
|
27
|
+
* TRADEOFF: this bypasses `safeFetch`'s SSRF connect-IP dispatcher —
|
|
28
|
+
* `createSecureDispatcher()` validates the resolved IP at connect time, but
|
|
29
|
+
* that dispatcher is undici-specific and proxied requests need a
|
|
30
|
+
* `ProxyAgent` dispatcher instead. There is no supported way to compose the
|
|
31
|
+
* two (a dispatcher that both proxies AND re-validates the post-DNS IP), so
|
|
32
|
+
* proxied requests intentionally lose the private-IP/DNS-rebinding guard.
|
|
33
|
+
* Accepted for Phase 4: proxy URLs are operator-configured, not
|
|
34
|
+
* user-supplied, so the SSRF surface this guard protects against isn't
|
|
35
|
+
* reachable via the proxy path.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildProxyFetchImpl(proxyUrl: string, auth?: {
|
|
38
|
+
username?: string;
|
|
39
|
+
password?: string;
|
|
40
|
+
}): typeof fetch;
|
|
41
|
+
/** Clears the cached ProxyAgents. For tests/cleanup. */
|
|
42
|
+
export declare function resetProxyAgents(): void;
|
|
43
|
+
//# sourceMappingURL=proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/fetch/proxy.ts"],"names":[],"mappings":"AAaA;;;;GAIG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAW;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;gBAE7C,IAAI,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM;IAK9C;;;;OAIG;IACH,OAAO,CAAC,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI;IAkBhD,oFAAoF;IACpF,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,IAAI;IAI1D,6DAA6D;IAC7D,KAAK,IAAI,IAAI;CAId;AAsBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,KAAK,CASnH;AAED,wDAAwD;AACxD,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/proxy.ts — proxy rotation pool + undici wiring
|
|
2
|
+
//
|
|
3
|
+
// Two halves, deliberately kept apart:
|
|
4
|
+
// 1. `ProxyPool` — pure round-robin rotation + cooldown bookkeeping. No
|
|
5
|
+
// undici import, no network access. Fully unit-testable with a fake
|
|
6
|
+
// clock (the optional `now` param on every method).
|
|
7
|
+
// 2. `buildProxyFetchImpl` / `resetProxyAgents` — the undici-specific
|
|
8
|
+
// wiring that turns a proxy URL into a `deps.fetchImpl`-shaped function
|
|
9
|
+
// for `safeFetch`. This is where the SSRF-bypass tradeoff lives (see
|
|
10
|
+
// comment below).
|
|
11
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
12
|
+
/**
|
|
13
|
+
* Round-robin proxy rotation with per-proxy cooldowns. Pure and
|
|
14
|
+
* synchronous — no I/O, no undici — so it can be unit-tested without any
|
|
15
|
+
* real proxy or network access.
|
|
16
|
+
*/
|
|
17
|
+
export class ProxyPool {
|
|
18
|
+
constructor(urls, cooldownMs) {
|
|
19
|
+
this.cursor = 0;
|
|
20
|
+
this.cooledUntil = new Map();
|
|
21
|
+
this.urls = urls;
|
|
22
|
+
this.cooldownMs = cooldownMs;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns the next non-cooled proxy URL in round-robin order, advancing
|
|
26
|
+
* the cursor past it. Returns `null` if the pool is empty or every proxy
|
|
27
|
+
* is currently cooled.
|
|
28
|
+
*/
|
|
29
|
+
acquire(now = Date.now()) {
|
|
30
|
+
const count = this.urls.length;
|
|
31
|
+
if (count === 0)
|
|
32
|
+
return null;
|
|
33
|
+
for (let i = 0; i < count; i++) {
|
|
34
|
+
const index = (this.cursor + i) % count;
|
|
35
|
+
const url = this.urls[index];
|
|
36
|
+
const cooledUntil = this.cooledUntil.get(url);
|
|
37
|
+
if (cooledUntil === undefined || cooledUntil <= now) {
|
|
38
|
+
this.cursor = (index + 1) % count;
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// All proxies cooled — leave the cursor where it is.
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Marks `url` cooled until `now + cooldownMs`; `acquire()` skips it until then. */
|
|
46
|
+
reportFailure(url, now = Date.now()) {
|
|
47
|
+
this.cooledUntil.set(url, now + this.cooldownMs);
|
|
48
|
+
}
|
|
49
|
+
/** Resets the cursor and clears all cooldowns. For tests. */
|
|
50
|
+
reset() {
|
|
51
|
+
this.cursor = 0;
|
|
52
|
+
this.cooledUntil.clear();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// --- undici wiring -----------------------------------------------------
|
|
56
|
+
// Cached ProxyAgents, keyed by proxy URL, so repeated requests through the
|
|
57
|
+
// same proxy reuse one agent (and its connection pool) instead of building a
|
|
58
|
+
// fresh one per attempt.
|
|
59
|
+
const proxyAgents = new Map();
|
|
60
|
+
function getOrCreateProxyAgent(proxyUrl, auth) {
|
|
61
|
+
const cached = proxyAgents.get(proxyUrl);
|
|
62
|
+
if (cached)
|
|
63
|
+
return cached;
|
|
64
|
+
const token = auth?.username
|
|
65
|
+
? `Basic ${Buffer.from(`${auth.username}:${auth.password ?? ''}`).toString('base64')}`
|
|
66
|
+
: undefined;
|
|
67
|
+
const agent = new ProxyAgent(token ? { uri: proxyUrl, token } : { uri: proxyUrl });
|
|
68
|
+
proxyAgents.set(proxyUrl, agent);
|
|
69
|
+
return agent;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds a `deps.fetchImpl`-shaped function that routes through the given
|
|
73
|
+
* proxy via an undici `ProxyAgent`.
|
|
74
|
+
*
|
|
75
|
+
* TRADEOFF: this bypasses `safeFetch`'s SSRF connect-IP dispatcher —
|
|
76
|
+
* `createSecureDispatcher()` validates the resolved IP at connect time, but
|
|
77
|
+
* that dispatcher is undici-specific and proxied requests need a
|
|
78
|
+
* `ProxyAgent` dispatcher instead. There is no supported way to compose the
|
|
79
|
+
* two (a dispatcher that both proxies AND re-validates the post-DNS IP), so
|
|
80
|
+
* proxied requests intentionally lose the private-IP/DNS-rebinding guard.
|
|
81
|
+
* Accepted for Phase 4: proxy URLs are operator-configured, not
|
|
82
|
+
* user-supplied, so the SSRF surface this guard protects against isn't
|
|
83
|
+
* reachable via the proxy path.
|
|
84
|
+
*/
|
|
85
|
+
export function buildProxyFetchImpl(proxyUrl, auth) {
|
|
86
|
+
const dispatcher = getOrCreateProxyAgent(proxyUrl, auth);
|
|
87
|
+
// undici's `fetch`/`Response` are structurally compatible with the global
|
|
88
|
+
// (DOM lib) `fetch` type for our purposes, but their type declarations
|
|
89
|
+
// differ enough that TS won't accept a direct assignment. Bridge with a
|
|
90
|
+
// thin wrapper and a single documented `any` cast.
|
|
91
|
+
return ((input, init) => undiciFetch(input, { ...init, dispatcher }));
|
|
92
|
+
}
|
|
93
|
+
/** Clears the cached ProxyAgents. For tests/cleanup. */
|
|
94
|
+
export function resetProxyAgents() {
|
|
95
|
+
proxyAgents.clear();
|
|
96
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits until a slot for `host` (or the shared `'*'` key, when
|
|
3
|
+
* `opts.perHost === false`) is available. The slot granted to this call is
|
|
4
|
+
* `max(now, key's current next-available time)`; the key's next-available
|
|
5
|
+
* time is then advanced to `slot + intervalMs`. Because both steps happen
|
|
6
|
+
* before the `await sleep(...)`, they run atomically with respect to other
|
|
7
|
+
* concurrent callers (JS has no preemption between awaits), which is what
|
|
8
|
+
* makes back-to-back/concurrent calls space out correctly instead of racing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rateLimitWait(host: string, intervalMs: number, opts?: {
|
|
11
|
+
perHost?: boolean;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
/** Clears all recorded rate-limit state. Call between test cases for isolation. */
|
|
14
|
+
export declare function resetRateLimiter(): void;
|
|
15
|
+
//# sourceMappingURL=ratelimit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ratelimit.d.ts","sourceRoot":"","sources":["../../src/fetch/ratelimit.ts"],"names":[],"mappings":"AAcA;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAYjH;AAED,mFAAmF;AACnF,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/ratelimit.ts — per-host rate limiter
|
|
2
|
+
//
|
|
3
|
+
// Phase 3: enforces a minimum interval between requests sharing a key (the
|
|
4
|
+
// target host, or a single shared key when `perHost:false`). Concurrent
|
|
5
|
+
// callers for the same key are serialized into a queue — each caller's
|
|
6
|
+
// granted slot is computed and reserved synchronously (before any `await`),
|
|
7
|
+
// so overlapping calls never race past the same slot; N concurrent calls
|
|
8
|
+
// end up spaced exactly `intervalMs` apart.
|
|
9
|
+
import { sleep } from 'hazo_core';
|
|
10
|
+
// Module-level map of rate-limit key -> next time (ms epoch) a slot may be granted.
|
|
11
|
+
const nextAvailable = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Waits until a slot for `host` (or the shared `'*'` key, when
|
|
14
|
+
* `opts.perHost === false`) is available. The slot granted to this call is
|
|
15
|
+
* `max(now, key's current next-available time)`; the key's next-available
|
|
16
|
+
* time is then advanced to `slot + intervalMs`. Because both steps happen
|
|
17
|
+
* before the `await sleep(...)`, they run atomically with respect to other
|
|
18
|
+
* concurrent callers (JS has no preemption between awaits), which is what
|
|
19
|
+
* makes back-to-back/concurrent calls space out correctly instead of racing.
|
|
20
|
+
*/
|
|
21
|
+
export async function rateLimitWait(host, intervalMs, opts) {
|
|
22
|
+
const key = opts?.perHost === false ? '*' : host;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const prevNextAvailable = nextAvailable.get(key) ?? 0;
|
|
25
|
+
const slot = Math.max(now, prevNextAvailable);
|
|
26
|
+
nextAvailable.set(key, slot + intervalMs);
|
|
27
|
+
const waitMs = slot - now;
|
|
28
|
+
if (waitMs > 0) {
|
|
29
|
+
await sleep(waitMs);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Clears all recorded rate-limit state. Call between test cases for isolation. */
|
|
33
|
+
export function resetRateLimiter() {
|
|
34
|
+
nextAvailable.clear();
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
/** Thrown by the fetch pipeline when `checkRobots` reports `allowed:false`. */
|
|
3
|
+
export declare class RobotsDisallowedError extends HazoError {
|
|
4
|
+
constructor(url: string);
|
|
5
|
+
}
|
|
6
|
+
export interface CheckRobotsParams {
|
|
7
|
+
url: string;
|
|
8
|
+
effectiveUa: string;
|
|
9
|
+
respect: boolean;
|
|
10
|
+
cacheTtlSeconds: number;
|
|
11
|
+
fetchRobotsTxt: (robotsUrl: string) => Promise<{
|
|
12
|
+
status: number;
|
|
13
|
+
body: string;
|
|
14
|
+
} | null>;
|
|
15
|
+
}
|
|
16
|
+
export interface CheckRobotsResult {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
crawlDelayMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Evaluates `url` against its origin's robots.txt for `effectiveUa`. Caches
|
|
22
|
+
* per origin for `cacheTtlSeconds`. Fails open (`allowed:true`, cached
|
|
23
|
+
* permissively for the TTL) whenever the robots.txt fetch throws, returns
|
|
24
|
+
* null, or comes back with a non-2xx status — so a broken robots.txt never
|
|
25
|
+
* blocks scraping and never causes a refetch on every single request.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkRobots(params: CheckRobotsParams): Promise<CheckRobotsResult>;
|
|
28
|
+
/** Clears the robots.txt cache. Call between test cases for isolation. */
|
|
29
|
+
export declare function resetRobotsCache(): void;
|
|
30
|
+
//# sourceMappingURL=robots.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"robots.d.ts","sourceRoot":"","sources":["../../src/fetch/robots.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAWtC,+EAA+E;AAC/E,qBAAa,qBAAsB,SAAQ,SAAS;gBACtC,GAAG,EAAE,MAAM;CAQxB;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACzF;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA8CD;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAsBvF;AAED,0EAA0E;AAC1E,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/robots.ts — robots.txt gate
|
|
2
|
+
//
|
|
3
|
+
// Phase 3: fetches and caches robots.txt per origin, evaluates whether a
|
|
4
|
+
// given URL is allowed for the effective UA, and surfaces any `Crawl-delay`
|
|
5
|
+
// directive so the rate limiter (ratelimit.ts) can honor it. Fails open
|
|
6
|
+
// (treats the fetch as allowed) whenever robots.txt cannot be retrieved or
|
|
7
|
+
// parsed — a broken/unreachable robots.txt must never brick scraping.
|
|
8
|
+
import robotsParser from 'robots-parser';
|
|
9
|
+
import { HazoError } from 'hazo_core';
|
|
10
|
+
// Module-level cache of origin -> parsed robots.txt (+ crawl-delay + TTL).
|
|
11
|
+
const cache = new Map();
|
|
12
|
+
/** Thrown by the fetch pipeline when `checkRobots` reports `allowed:false`. */
|
|
13
|
+
export class RobotsDisallowedError extends HazoError {
|
|
14
|
+
constructor(url) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'HAZO_SCRAPE_ROBOTS_DISALLOWED',
|
|
17
|
+
pkg: 'hazo_scrape',
|
|
18
|
+
message: `Fetching disallowed by robots.txt: ${url}`,
|
|
19
|
+
context: { url },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A parsed-empty robots.txt: every path is allowed, no crawl-delay. */
|
|
24
|
+
function permissiveEntry(robotsUrl, expiresAt) {
|
|
25
|
+
return { robot: robotsParser(robotsUrl, ''), expiresAt };
|
|
26
|
+
}
|
|
27
|
+
async function fetchAndCache(params) {
|
|
28
|
+
const { origin, robotsUrl, url, effectiveUa, cacheTtlSeconds, fetchRobotsTxt } = params;
|
|
29
|
+
const expiresAt = Date.now() + cacheTtlSeconds * 1000;
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = await fetchRobotsTxt(robotsUrl);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.warn(`[hazo_scrape] robots.txt fetch failed, proceeding: ${url}`, err);
|
|
36
|
+
const entry = permissiveEntry(robotsUrl, expiresAt);
|
|
37
|
+
cache.set(origin, entry);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
if (result === null || result.status < 200 || result.status >= 300) {
|
|
41
|
+
console.warn(`[hazo_scrape] robots.txt fetch failed, proceeding: ${url}`);
|
|
42
|
+
const entry = permissiveEntry(robotsUrl, expiresAt);
|
|
43
|
+
cache.set(origin, entry);
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
const robot = robotsParser(robotsUrl, result.body);
|
|
47
|
+
const crawlDelaySeconds = robot.getCrawlDelay(effectiveUa);
|
|
48
|
+
const entry = {
|
|
49
|
+
robot,
|
|
50
|
+
...(crawlDelaySeconds !== undefined ? { crawlDelayMs: crawlDelaySeconds * 1000 } : {}),
|
|
51
|
+
expiresAt,
|
|
52
|
+
};
|
|
53
|
+
cache.set(origin, entry);
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Evaluates `url` against its origin's robots.txt for `effectiveUa`. Caches
|
|
58
|
+
* per origin for `cacheTtlSeconds`. Fails open (`allowed:true`, cached
|
|
59
|
+
* permissively for the TTL) whenever the robots.txt fetch throws, returns
|
|
60
|
+
* null, or comes back with a non-2xx status — so a broken robots.txt never
|
|
61
|
+
* blocks scraping and never causes a refetch on every single request.
|
|
62
|
+
*/
|
|
63
|
+
export async function checkRobots(params) {
|
|
64
|
+
const { url, effectiveUa, respect, cacheTtlSeconds, fetchRobotsTxt } = params;
|
|
65
|
+
if (!respect) {
|
|
66
|
+
return { allowed: true };
|
|
67
|
+
}
|
|
68
|
+
const origin = new URL(url).origin;
|
|
69
|
+
const robotsUrl = `${origin}/robots.txt`;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
let entry = cache.get(origin);
|
|
72
|
+
if (!entry || now >= entry.expiresAt) {
|
|
73
|
+
entry = await fetchAndCache({ origin, robotsUrl, url, effectiveUa, cacheTtlSeconds, fetchRobotsTxt });
|
|
74
|
+
}
|
|
75
|
+
// isAllowed returns boolean|undefined — undefined (no matching rule, or
|
|
76
|
+
// URL not valid for this robots.txt) is treated as allowed; only an
|
|
77
|
+
// explicit `false` disallows.
|
|
78
|
+
const allowed = entry.robot.isAllowed(url, effectiveUa) !== false;
|
|
79
|
+
return { allowed, ...(entry.crawlDelayMs !== undefined ? { crawlDelayMs: entry.crawlDelayMs } : {}) };
|
|
80
|
+
}
|
|
81
|
+
/** Clears the robots.txt cache. Call between test cases for isolation. */
|
|
82
|
+
export function resetRobotsCache() {
|
|
83
|
+
cache.clear();
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FetchOptions } from './types.js';
|
|
2
|
+
import type { ColumnKeywords, ColumnMap, MappedRow } from '../parse/index.js';
|
|
3
|
+
export interface ScrapeTableOptions extends FetchOptions {
|
|
4
|
+
/** CSS selector identifying the table element, passed to extractTable. */
|
|
5
|
+
select?: string;
|
|
6
|
+
/** Passed to mapColumns — guards date-shaped headers from non-date keys. */
|
|
7
|
+
dateGuard?: boolean;
|
|
8
|
+
/** Passed to mapColumns — keys that must have >=1 candidate for ok:true. */
|
|
9
|
+
required?: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface TableResult {
|
|
12
|
+
headers: string[];
|
|
13
|
+
columnMap: ColumnMap;
|
|
14
|
+
/** Convenience mirror of columnMap.unmatched. */
|
|
15
|
+
unmatched: string[];
|
|
16
|
+
rows: MappedRow[];
|
|
17
|
+
/** = !columnMap.ok — true when a required key found no column. */
|
|
18
|
+
partial: boolean;
|
|
19
|
+
/** Surfaced from extractTable (e.g. no table found on the page). */
|
|
20
|
+
warning?: string;
|
|
21
|
+
source: {
|
|
22
|
+
url: string;
|
|
23
|
+
finalUrl: string;
|
|
24
|
+
fromCache: boolean;
|
|
25
|
+
usedLlm: false;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function scrapeTable(url: string, keywords: ColumnKeywords, opts?: ScrapeTableOptions): Promise<TableResult>;
|
|
29
|
+
//# sourceMappingURL=scrape.d.ts.map
|