hazo_scrape 1.0.1 → 1.1.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 +13 -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
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# hazo_scrape — Change Log
|
|
2
2
|
|
|
3
|
+
## 1.1.0 — 2026-07-19
|
|
4
|
+
|
|
5
|
+
### Added — fetch layer (`hazo_scrape/fetch`, server-only)
|
|
6
|
+
- `fetchDocument(url, opts?)` — polite/resilient HTTP built on `hazo_secure`'s `safeFetch` (SSRF guard, undici, timeout, correlation-id): retry with exponential backoff + jitter, `Retry-After` handling, per-host rate limiting, rotating browser-User-Agent pool (+ `sec-ch-ua`), full-RFC `robots.txt` gate (fail-open on robots fetch error; honors `Crawl-delay`), optional content-addressed on-disk response cache with TTL, and an optional undici `ProxyAgent` rotation pool (round-robin + failure cooldown).
|
|
7
|
+
- `scrapeTable(url, keywords, opts?)` — one-call bridge chaining `fetchDocument` → `extractTable` → `mapColumns` → `mapRows`, returning `{ headers, columnMap, unmatched, rows, partial, warning?, source }`.
|
|
8
|
+
- New `./fetch` subpath export; the root `.` and `./fetch` entries are **server-only** (they pull in undici / hazo_secure / node:fs / node:crypto). `./parse` remains the client-safe, network-free entry.
|
|
9
|
+
- New INI config sections `[ratelimit]` and `[robots]`, plus `[http]` (`ua_pool_enabled`, `accept_language`), `[retry]` (`jitter`), and `[proxy]` (multi-URL rotation, `cooldown_ms`) additions. See `config/hazo_scrape_config.ini.sample`.
|
|
10
|
+
|
|
11
|
+
### Notes
|
|
12
|
+
- The content-addressed cache keys responses with a `node:crypto` SHA-256 hash (16 hex chars) over `method|url|accept-language` — no external hashing dependency.
|
|
13
|
+
- Proxied requests route through undici's `ProxyAgent` and therefore bypass `safeFetch`'s SSRF connect-IP guard (accepted: operator-configured proxies).
|
|
14
|
+
- The parse core (`hazo_scrape/parse`) is unchanged and remains fully network-free.
|
|
15
|
+
|
|
3
16
|
## 1.0.0 — 2026-07-17
|
|
4
17
|
|
|
5
18
|
- Initial release.
|
package/README.md
CHANGED
|
@@ -132,6 +132,111 @@ type MappedRow = Record<string, Cell[]>;
|
|
|
132
132
|
deliberately outside this engine. `parseNumber` and `parseDate` return the
|
|
133
133
|
literal parsed value (or `null`) and nothing else.
|
|
134
134
|
|
|
135
|
+
## Fetch layer (`hazo_scrape/fetch`)
|
|
136
|
+
|
|
137
|
+
Everything above is `hazo_scrape/parse` — network-free and safe to bundle for
|
|
138
|
+
the client. The fetch layer is the opposite: it's **server-only**. Import it
|
|
139
|
+
from `hazo_scrape/fetch` (or the root `hazo_scrape` entry, which re-exports
|
|
140
|
+
it) inside server code only — it pulls in `undici`, `hazo_secure`, `node:fs`,
|
|
141
|
+
and `node:crypto`, and will break a client bundle if imported there.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { scrapeTable, type ColumnKeywords } from 'hazo_scrape/fetch';
|
|
145
|
+
|
|
146
|
+
const keywords: ColumnKeywords = {
|
|
147
|
+
amount: { keywords: ['amount', 'dividend'], type: 'number' },
|
|
148
|
+
exDate: { keywords: ['ex date', 'ex-dividend'], type: 'date' },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const result = await scrapeTable('https://example.com/dividends', keywords, {
|
|
152
|
+
dateGuard: true,
|
|
153
|
+
required: ['amount'],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (result.partial) console.warn('missing required column(s):', result.unmatched);
|
|
157
|
+
console.log(result.rows[0]?.amount?.[0]?.value, result.source.finalUrl);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### `fetchDocument(url, opts?)`
|
|
161
|
+
|
|
162
|
+
Polite, resilient HTTP built on `hazo_secure`'s `safeFetch` (SSRF guard,
|
|
163
|
+
undici, timeout, correlation-id). Resolves an optional INI config
|
|
164
|
+
(`opts.configPath`) and overlays per-call `opts` on top before each request,
|
|
165
|
+
then: checks `robots.txt` (fail-open on a robots fetch error), checks the
|
|
166
|
+
on-disk cache, waits out the per-host rate limit, and runs the retry loop
|
|
167
|
+
(exponential backoff + jitter, honoring `Retry-After`) with a rotating UA on
|
|
168
|
+
each attempt. Returns a `FetchResult`, never throws for a terminal HTTP
|
|
169
|
+
status (4xx/2xx/3xx all resolve normally) — it only throws for robots
|
|
170
|
+
disallow, exhausted retries, or a non-retryable `safeFetch` error (bad URL,
|
|
171
|
+
disallowed protocol/host, private-IP block).
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
interface FetchResult {
|
|
175
|
+
url: string; // requested URL
|
|
176
|
+
finalUrl: string; // URL after following redirects
|
|
177
|
+
status: number;
|
|
178
|
+
statusText: string;
|
|
179
|
+
ok: boolean;
|
|
180
|
+
headers: Record<string, string>;
|
|
181
|
+
body: string;
|
|
182
|
+
contentType?: string;
|
|
183
|
+
fromCache: boolean;
|
|
184
|
+
attempts: number; // 0 when served from cache
|
|
185
|
+
timingMs: number;
|
|
186
|
+
usedProxy?: string; // set when a configured proxy served this attempt
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Notable `opts` (all optional):
|
|
191
|
+
|
|
192
|
+
- `retry: { maxAttempts, backoffMs, backoffFactor, jitter, retryOn }` — attempt
|
|
193
|
+
count, exponential backoff base/factor, jitter toggle, and which HTTP
|
|
194
|
+
statuses are retried (default `[429, 500, 502, 503, 504]`).
|
|
195
|
+
- `rateLimit: { minIntervalMs, perHost }` — minimum spacing between requests;
|
|
196
|
+
the actual wait is `max(minIntervalMs, robots Crawl-delay)`.
|
|
197
|
+
- `robots: { respect }` — set `false` to skip the robots.txt gate entirely.
|
|
198
|
+
- `cache: { enabled, dir, ttlSeconds }` — content-addressed on-disk response
|
|
199
|
+
cache, off by default.
|
|
200
|
+
- `proxy: { urls, username, password, rotation, cooldownMs }` — round-robin
|
|
201
|
+
`ProxyAgent` pool with failure cooldown; proxied requests bypass
|
|
202
|
+
`safeFetch`'s SSRF connect-IP guard (accepted trade-off for
|
|
203
|
+
operator-configured proxies).
|
|
204
|
+
- `userAgent` / `uaPool` — pin a single UA (also disables rotation for that
|
|
205
|
+
call) or supply/override the rotation pool.
|
|
206
|
+
- `fetchImpl` — test seam, passed straight through to `safeFetch`'s
|
|
207
|
+
`deps.fetchImpl`.
|
|
208
|
+
|
|
209
|
+
### `scrapeTable(url, keywords, opts?)`
|
|
210
|
+
|
|
211
|
+
One-call bridge from the fetch layer to the network-free parse layer:
|
|
212
|
+
`fetchDocument` → `extractTable` → `mapColumns` → `mapRows`. Takes every
|
|
213
|
+
`FetchOptions` field plus `select` (CSS selector passed to `extractTable`),
|
|
214
|
+
`dateGuard`, and `required` (both passed straight through to `mapColumns`).
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
interface TableResult {
|
|
218
|
+
headers: string[];
|
|
219
|
+
columnMap: ColumnMap;
|
|
220
|
+
unmatched: string[]; // mirrors columnMap.unmatched
|
|
221
|
+
rows: MappedRow[];
|
|
222
|
+
partial: boolean; // = !columnMap.ok
|
|
223
|
+
warning?: string; // surfaced from extractTable, e.g. no table found
|
|
224
|
+
source: { url: string; finalUrl: string; fromCache: boolean; usedLlm: false };
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Politeness & configuration
|
|
229
|
+
|
|
230
|
+
`robots.txt` is respected by default and fails **open** (a robots.txt fetch
|
|
231
|
+
error is treated as "allowed", never as "blocked"); it honors `Crawl-delay`
|
|
232
|
+
when present. Requests are rate-limited per host by default. Each attempt
|
|
233
|
+
rotates through a browser User-Agent pool (with matching `sec-ch-ua`) unless
|
|
234
|
+
you pass `userAgent` for a single identifiable UA or set
|
|
235
|
+
`ua_pool_enabled = false`. The on-disk response cache is **off** by default.
|
|
236
|
+
All of this is configurable via an INI file — see
|
|
237
|
+
`config/hazo_scrape_config.ini.sample` for the full set of `[http]`,
|
|
238
|
+
`[retry]`, `[ratelimit]`, `[robots]`, `[cache]`, and `[proxy]` keys.
|
|
239
|
+
|
|
135
240
|
## Tailwind v4 (`@source` required)
|
|
136
241
|
|
|
137
242
|
If this package renders UI, add the following to your app's CSS entry:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CachedRecord {
|
|
2
|
+
finalUrl: string;
|
|
3
|
+
status: number;
|
|
4
|
+
statusText: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
body: string;
|
|
7
|
+
contentType?: string;
|
|
8
|
+
storedAt: number;
|
|
9
|
+
}
|
|
10
|
+
interface CacheConfig {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
dir: string;
|
|
13
|
+
ttlSeconds: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Computes the content-address cache key for a request. Hashes the
|
|
17
|
+
* canonical string `${method}|${requestUrl}|${acceptLanguage}` — see the
|
|
18
|
+
* module-level comment above for why `requestUrl` is the pre-redirect URL.
|
|
19
|
+
*
|
|
20
|
+
* Returns a 16-hex-char (64-bit) truncated sha256 — ample to avoid
|
|
21
|
+
* collisions across a single host's cached pages while keeping filenames
|
|
22
|
+
* short. Kept `async` so callers need no change if the hash source ever
|
|
23
|
+
* moves back to an async implementation.
|
|
24
|
+
*/
|
|
25
|
+
export declare function computeCacheKey(method: string, requestUrl: string, acceptLanguage: string): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Reads a cached record for `key`, if present and not expired. Returns
|
|
28
|
+
* `null` on a miss, on expiry (best-effort deletes the stale file), or on
|
|
29
|
+
* any read/parse error — the cache never throws.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readCache(cacheCfg: CacheConfig, key: string): Promise<CachedRecord | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Writes `record` under `key`. No-op when caching is disabled. Write
|
|
34
|
+
* failures are swallowed (with a `console.warn`) — a cache-write failure
|
|
35
|
+
* must never break an otherwise-successful fetch.
|
|
36
|
+
*/
|
|
37
|
+
export declare function writeCache(cacheCfg: CacheConfig, key: string, record: CachedRecord): Promise<void>;
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/fetch/cache.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,WAAW;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGjH;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAuBhG;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAWxG"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/cache.ts — content-addressed on-disk response cache
|
|
2
|
+
//
|
|
3
|
+
// Phase 5: a best-effort local cache for fetchDocument's GET responses,
|
|
4
|
+
// keyed on a content-address hash of `method|requestUrl|acceptLanguage`.
|
|
5
|
+
// Hashing uses Node's built-in `node:crypto` (sha256, truncated) — chosen
|
|
6
|
+
// over an external hashing library so the cache path pulls in zero extra
|
|
7
|
+
// dependencies and always loads cleanly. See the trade-off ledger.
|
|
8
|
+
//
|
|
9
|
+
// Deliberate deviation from the original plan: the plan's content address is
|
|
10
|
+
// `method|finalUrl|varyHeaders`. But the cache must be CHECKED *before* the
|
|
11
|
+
// network fetch happens, at which point the post-redirect `finalUrl` isn't
|
|
12
|
+
// known yet — keying on it would make a pre-fetch lookup impossible. So we
|
|
13
|
+
// key on the REQUESTED url (pre-redirect) for both read and write, and store
|
|
14
|
+
// the actual `finalUrl` inside the cached record instead. The canonical key
|
|
15
|
+
// string is `${method}|${requestUrl}|${acceptLanguage}` (acceptLanguage is
|
|
16
|
+
// the single vary header tracked so far).
|
|
17
|
+
//
|
|
18
|
+
// The cache is best-effort: any read/write failure (missing file, corrupt
|
|
19
|
+
// JSON, disk error) degrades to a miss / silent no-op rather than throwing —
|
|
20
|
+
// a broken cache must never break a fetch that would otherwise succeed.
|
|
21
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
/**
|
|
25
|
+
* Computes the content-address cache key for a request. Hashes the
|
|
26
|
+
* canonical string `${method}|${requestUrl}|${acceptLanguage}` — see the
|
|
27
|
+
* module-level comment above for why `requestUrl` is the pre-redirect URL.
|
|
28
|
+
*
|
|
29
|
+
* Returns a 16-hex-char (64-bit) truncated sha256 — ample to avoid
|
|
30
|
+
* collisions across a single host's cached pages while keeping filenames
|
|
31
|
+
* short. Kept `async` so callers need no change if the hash source ever
|
|
32
|
+
* moves back to an async implementation.
|
|
33
|
+
*/
|
|
34
|
+
export async function computeCacheKey(method, requestUrl, acceptLanguage) {
|
|
35
|
+
const canonical = `${method}|${requestUrl}|${acceptLanguage}`;
|
|
36
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads a cached record for `key`, if present and not expired. Returns
|
|
40
|
+
* `null` on a miss, on expiry (best-effort deletes the stale file), or on
|
|
41
|
+
* any read/parse error — the cache never throws.
|
|
42
|
+
*/
|
|
43
|
+
export async function readCache(cacheCfg, key) {
|
|
44
|
+
if (!cacheCfg.enabled)
|
|
45
|
+
return null;
|
|
46
|
+
const filePath = join(cacheCfg.dir, `${key}.json`);
|
|
47
|
+
try {
|
|
48
|
+
const raw = await readFile(filePath, 'utf8');
|
|
49
|
+
const record = JSON.parse(raw);
|
|
50
|
+
if (Date.now() - record.storedAt > cacheCfg.ttlSeconds * 1000) {
|
|
51
|
+
// Expired — best-effort cleanup, but a failure here is not fatal.
|
|
52
|
+
try {
|
|
53
|
+
await unlink(filePath);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Writes `record` under `key`. No-op when caching is disabled. Write
|
|
68
|
+
* failures are swallowed (with a `console.warn`) — a cache-write failure
|
|
69
|
+
* must never break an otherwise-successful fetch.
|
|
70
|
+
*/
|
|
71
|
+
export async function writeCache(cacheCfg, key, record) {
|
|
72
|
+
if (!cacheCfg.enabled)
|
|
73
|
+
return;
|
|
74
|
+
const filePath = join(cacheCfg.dir, `${key}.json`);
|
|
75
|
+
try {
|
|
76
|
+
await mkdir(cacheCfg.dir, { recursive: true });
|
|
77
|
+
await writeFile(filePath, JSON.stringify(record), 'utf8');
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.warn(`hazo_scrape: failed to write cache entry ${filePath}:`, err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ConfigProvider } from 'hazo_config';
|
|
2
|
+
export interface ResolvedFetchConfig {
|
|
3
|
+
http: {
|
|
4
|
+
userAgent?: string;
|
|
5
|
+
uaPoolEnabled: boolean;
|
|
6
|
+
acceptLanguage: string;
|
|
7
|
+
accept: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
maxRedirects: number;
|
|
10
|
+
};
|
|
11
|
+
retry: {
|
|
12
|
+
maxAttempts: number;
|
|
13
|
+
backoffMs: number;
|
|
14
|
+
backoffFactor: number;
|
|
15
|
+
retryOn: number[];
|
|
16
|
+
jitter: boolean;
|
|
17
|
+
};
|
|
18
|
+
rateLimit: {
|
|
19
|
+
minIntervalMs: number;
|
|
20
|
+
perHost: boolean;
|
|
21
|
+
};
|
|
22
|
+
proxy: {
|
|
23
|
+
urls: string[];
|
|
24
|
+
username?: string;
|
|
25
|
+
password?: string;
|
|
26
|
+
rotation: 'round_robin';
|
|
27
|
+
cooldownMs: number;
|
|
28
|
+
};
|
|
29
|
+
cache: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
dir: string;
|
|
32
|
+
ttlSeconds: number;
|
|
33
|
+
};
|
|
34
|
+
robots: {
|
|
35
|
+
respect: boolean;
|
|
36
|
+
cacheTtlSeconds: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_FETCH_CONFIG: ResolvedFetchConfig;
|
|
40
|
+
export declare function resolveFetchConfig(source?: {
|
|
41
|
+
configPath?: string;
|
|
42
|
+
provider?: ConfigProvider;
|
|
43
|
+
}): ResolvedFetchConfig;
|
|
44
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/fetch/config.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE;QACJ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,aAAa,EAAE,OAAO,CAAC;QACvB,cAAc,EAAE,MAAM,CAAC;QACvB,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,CAAC;QAClB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,KAAK,EAAE;QACL,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,aAAa,EAAE,MAAM,CAAC;QACtB,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,EAAE,OAAO,CAAC;KACjB,CAAC;IACF,SAAS,EAAE;QACT,aAAa,EAAE,MAAM,CAAC;QACtB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC;IACF,KAAK,EAAE;QACL,IAAI,EAAE,MAAM,EAAE,CAAC;QACf,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,aAAa,CAAC;QACxB,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,KAAK,EAAE;QACL,OAAO,EAAE,OAAO,CAAC;QACjB,GAAG,EAAE,MAAM,CAAC;QACZ,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,MAAM,EAAE;QACN,OAAO,EAAE,OAAO,CAAC;QACjB,eAAe,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,oBAAoB,EAAE,mBAiClC,CAAC;AAmIF,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GAAG,mBAAmB,CAkBnH"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/config.ts — typed config resolver for the fetch layer
|
|
2
|
+
//
|
|
3
|
+
// Reads `hazo_scrape_config.ini` (see config/hazo_scrape_config.ini.sample)
|
|
4
|
+
// via `HazoConfig` from `hazo_config/server` and produces a fully-typed,
|
|
5
|
+
// fully-defaulted `ResolvedFetchConfig`. This module does NO network or
|
|
6
|
+
// filesystem access beyond an optional `existsSync` check and, when a
|
|
7
|
+
// `configPath` is supplied, constructing a `HazoConfig` (which itself reads
|
|
8
|
+
// the INI file synchronously). There is no fetch logic here — that lands in
|
|
9
|
+
// a later phase.
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { HazoConfig } from 'hazo_config/server';
|
|
12
|
+
export const DEFAULT_FETCH_CONFIG = {
|
|
13
|
+
http: {
|
|
14
|
+
uaPoolEnabled: true,
|
|
15
|
+
acceptLanguage: 'en-AU,en;q=0.9',
|
|
16
|
+
accept: 'text/html,application/xhtml+xml',
|
|
17
|
+
timeoutMs: 15000,
|
|
18
|
+
maxRedirects: 5,
|
|
19
|
+
},
|
|
20
|
+
retry: {
|
|
21
|
+
maxAttempts: 3,
|
|
22
|
+
backoffMs: 500,
|
|
23
|
+
backoffFactor: 2,
|
|
24
|
+
retryOn: [429, 500, 502, 503, 504],
|
|
25
|
+
jitter: true,
|
|
26
|
+
},
|
|
27
|
+
rateLimit: {
|
|
28
|
+
minIntervalMs: 1000,
|
|
29
|
+
perHost: true,
|
|
30
|
+
},
|
|
31
|
+
proxy: {
|
|
32
|
+
urls: [],
|
|
33
|
+
rotation: 'round_robin',
|
|
34
|
+
cooldownMs: 60000,
|
|
35
|
+
},
|
|
36
|
+
cache: {
|
|
37
|
+
enabled: false,
|
|
38
|
+
dir: '.cache/hazo_scrape',
|
|
39
|
+
ttlSeconds: 86400,
|
|
40
|
+
},
|
|
41
|
+
robots: {
|
|
42
|
+
respect: true,
|
|
43
|
+
cacheTtlSeconds: 86400,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
// --- small local coercion helpers -----------------------------------------
|
|
47
|
+
function bool(raw, fallback) {
|
|
48
|
+
if (raw === undefined)
|
|
49
|
+
return fallback;
|
|
50
|
+
const v = raw.trim().toLowerCase();
|
|
51
|
+
if (v === '')
|
|
52
|
+
return fallback;
|
|
53
|
+
if (v === 'true' || v === '1' || v === 'yes')
|
|
54
|
+
return true;
|
|
55
|
+
if (v === 'false' || v === '0' || v === 'no')
|
|
56
|
+
return false;
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
function num(raw, fallback) {
|
|
60
|
+
if (raw === undefined)
|
|
61
|
+
return fallback;
|
|
62
|
+
const trimmed = raw.trim();
|
|
63
|
+
if (trimmed === '')
|
|
64
|
+
return fallback;
|
|
65
|
+
const n = trimmed.includes('.') ? parseFloat(trimmed) : parseInt(trimmed, 10);
|
|
66
|
+
return Number.isNaN(n) ? fallback : n;
|
|
67
|
+
}
|
|
68
|
+
function str(raw, fallback) {
|
|
69
|
+
if (raw === undefined)
|
|
70
|
+
return fallback;
|
|
71
|
+
const trimmed = raw.trim();
|
|
72
|
+
return trimmed === '' ? fallback : raw;
|
|
73
|
+
}
|
|
74
|
+
function csv(raw) {
|
|
75
|
+
if (raw === undefined)
|
|
76
|
+
return [];
|
|
77
|
+
return raw
|
|
78
|
+
.split(',')
|
|
79
|
+
.map((s) => s.trim())
|
|
80
|
+
.filter((s) => s.length > 0);
|
|
81
|
+
}
|
|
82
|
+
function csvNums(raw) {
|
|
83
|
+
return csv(raw)
|
|
84
|
+
.map((s) => Number(s))
|
|
85
|
+
.filter((n) => !Number.isNaN(n));
|
|
86
|
+
}
|
|
87
|
+
// --- deep clone of the defaults (never mutate DEFAULT_FETCH_CONFIG) -------
|
|
88
|
+
function cloneDefaults() {
|
|
89
|
+
const d = DEFAULT_FETCH_CONFIG;
|
|
90
|
+
return {
|
|
91
|
+
http: { ...d.http },
|
|
92
|
+
retry: { ...d.retry, retryOn: [...d.retry.retryOn] },
|
|
93
|
+
rateLimit: { ...d.rateLimit },
|
|
94
|
+
proxy: { ...d.proxy, urls: [...d.proxy.urls] },
|
|
95
|
+
cache: { ...d.cache },
|
|
96
|
+
robots: { ...d.robots },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// --- section builders -------------------------------------------------------
|
|
100
|
+
function buildHttp(provider, defaults) {
|
|
101
|
+
const userAgentRaw = provider.get('http', 'user_agent');
|
|
102
|
+
const userAgent = userAgentRaw !== undefined && userAgentRaw.trim() !== '' ? userAgentRaw : undefined;
|
|
103
|
+
return {
|
|
104
|
+
...(userAgent !== undefined ? { userAgent } : {}),
|
|
105
|
+
uaPoolEnabled: bool(provider.get('http', 'ua_pool_enabled'), defaults.uaPoolEnabled),
|
|
106
|
+
acceptLanguage: str(provider.get('http', 'accept_language'), defaults.acceptLanguage),
|
|
107
|
+
accept: str(provider.get('http', 'accept'), defaults.accept),
|
|
108
|
+
timeoutMs: num(provider.get('http', 'timeout_ms'), defaults.timeoutMs),
|
|
109
|
+
maxRedirects: num(provider.get('http', 'max_redirects'), defaults.maxRedirects),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function buildRetry(provider, defaults) {
|
|
113
|
+
const retryOnParsed = csvNums(provider.get('retry', 'retry_on'));
|
|
114
|
+
return {
|
|
115
|
+
maxAttempts: num(provider.get('retry', 'max_attempts'), defaults.maxAttempts),
|
|
116
|
+
backoffMs: num(provider.get('retry', 'backoff_ms'), defaults.backoffMs),
|
|
117
|
+
backoffFactor: num(provider.get('retry', 'backoff_factor'), defaults.backoffFactor),
|
|
118
|
+
retryOn: retryOnParsed.length > 0 ? retryOnParsed : [...defaults.retryOn],
|
|
119
|
+
jitter: bool(provider.get('retry', 'jitter'), defaults.jitter),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function buildRateLimit(provider, defaults) {
|
|
123
|
+
return {
|
|
124
|
+
minIntervalMs: num(provider.get('ratelimit', 'min_interval_ms'), defaults.minIntervalMs),
|
|
125
|
+
perHost: bool(provider.get('ratelimit', 'per_host'), defaults.perHost),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function buildProxy(provider, defaults) {
|
|
129
|
+
const urlsParsed = csv(provider.get('proxy', 'url'));
|
|
130
|
+
const usernameRaw = provider.get('proxy', 'username');
|
|
131
|
+
const passwordRaw = provider.get('proxy', 'password');
|
|
132
|
+
const username = usernameRaw !== undefined && usernameRaw.trim() !== '' ? usernameRaw : undefined;
|
|
133
|
+
const password = passwordRaw !== undefined && passwordRaw.trim() !== '' ? passwordRaw : undefined;
|
|
134
|
+
const rotationRaw = provider.get('proxy', 'rotation');
|
|
135
|
+
const rotation = rotationRaw === 'round_robin' ? 'round_robin' : defaults.rotation;
|
|
136
|
+
return {
|
|
137
|
+
urls: urlsParsed.length > 0 ? urlsParsed : [...defaults.urls],
|
|
138
|
+
...(username !== undefined ? { username } : {}),
|
|
139
|
+
...(password !== undefined ? { password } : {}),
|
|
140
|
+
rotation,
|
|
141
|
+
cooldownMs: num(provider.get('proxy', 'cooldown_ms'), defaults.cooldownMs),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function buildCache(provider, defaults) {
|
|
145
|
+
return {
|
|
146
|
+
enabled: bool(provider.get('cache', 'enabled'), defaults.enabled),
|
|
147
|
+
dir: str(provider.get('cache', 'dir'), defaults.dir),
|
|
148
|
+
ttlSeconds: num(provider.get('cache', 'ttl_seconds'), defaults.ttlSeconds),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function buildRobots(provider, defaults) {
|
|
152
|
+
return {
|
|
153
|
+
respect: bool(provider.get('robots', 'respect'), defaults.respect),
|
|
154
|
+
cacheTtlSeconds: num(provider.get('robots', 'cache_ttl_seconds'), defaults.cacheTtlSeconds),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// --- public resolver ---------------------------------------------------------
|
|
158
|
+
export function resolveFetchConfig(source) {
|
|
159
|
+
const defaults = cloneDefaults();
|
|
160
|
+
let provider = source?.provider;
|
|
161
|
+
if (!provider && source?.configPath && existsSync(source.configPath)) {
|
|
162
|
+
provider = new HazoConfig({ filePath: source.configPath });
|
|
163
|
+
}
|
|
164
|
+
if (!provider)
|
|
165
|
+
return defaults;
|
|
166
|
+
return {
|
|
167
|
+
http: buildHttp(provider, defaults.http),
|
|
168
|
+
retry: buildRetry(provider, defaults.retry),
|
|
169
|
+
rateLimit: buildRateLimit(provider, defaults.rateLimit),
|
|
170
|
+
proxy: buildProxy(provider, defaults.proxy),
|
|
171
|
+
cache: buildCache(provider, defaults.cache),
|
|
172
|
+
robots: buildRobots(provider, defaults.robots),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResolvedFetchConfig } from './config.js';
|
|
2
|
+
import type { FetchOptions } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A small pool of realistic, current desktop browser User-Agent strings
|
|
5
|
+
* (Chrome/Edge on Windows, Chrome on macOS/Linux, Firefox, Safari). Rotated
|
|
6
|
+
* round-robin by `buildHeaders` — never `Math.random()`, so rotation stays
|
|
7
|
+
* deterministic and testable.
|
|
8
|
+
*/
|
|
9
|
+
export declare const UA_POOL: string[];
|
|
10
|
+
/** Resets the round-robin cursor to 0. Call between test cases for determinism. */
|
|
11
|
+
export declare function resetUaRotation(): void;
|
|
12
|
+
/**
|
|
13
|
+
* Builds the outgoing request headers for one fetch attempt. `attempt` is
|
|
14
|
+
* accepted for signature stability / future use (e.g. attempt-aware header
|
|
15
|
+
* variation) but does not currently affect UA selection — rotation is a
|
|
16
|
+
* pure module-level round-robin, independent of retry attempt number.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildHeaders(cfg: ResolvedFetchConfig, opts: FetchOptions, attempt: number): Record<string, string>;
|
|
19
|
+
//# sourceMappingURL=headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../../src/fetch/headers.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,OAAO,EAAE,MAAM,EAO3B,CAAC;AAMF,mFAAmF;AACnF,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAgDD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,mBAAmB,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqBlH"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// hazo_scrape/src/fetch/headers.ts — UA pool + outgoing header builder
|
|
2
|
+
/**
|
|
3
|
+
* A small pool of realistic, current desktop browser User-Agent strings
|
|
4
|
+
* (Chrome/Edge on Windows, Chrome on macOS/Linux, Firefox, Safari). Rotated
|
|
5
|
+
* round-robin by `buildHeaders` — never `Math.random()`, so rotation stays
|
|
6
|
+
* deterministic and testable.
|
|
7
|
+
*/
|
|
8
|
+
export const UA_POOL = [
|
|
9
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
10
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
11
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
12
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
|
|
13
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15',
|
|
14
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
|
|
15
|
+
];
|
|
16
|
+
// Module-level round-robin cursor. Deliberately NOT Math.random() so tests
|
|
17
|
+
// can assert exact UA selection across successive calls/attempts.
|
|
18
|
+
let uaRotationCounter = 0;
|
|
19
|
+
/** Resets the round-robin cursor to 0. Call between test cases for determinism. */
|
|
20
|
+
export function resetUaRotation() {
|
|
21
|
+
uaRotationCounter = 0;
|
|
22
|
+
}
|
|
23
|
+
const CHROMIUM_VERSION_RE = /Chrome\/(\d+)/;
|
|
24
|
+
function isChromiumUa(ua) {
|
|
25
|
+
return CHROMIUM_VERSION_RE.test(ua);
|
|
26
|
+
}
|
|
27
|
+
function chromiumMajorVersion(ua) {
|
|
28
|
+
const match = CHROMIUM_VERSION_RE.exec(ua);
|
|
29
|
+
return match?.[1];
|
|
30
|
+
}
|
|
31
|
+
function platformFor(ua) {
|
|
32
|
+
if (ua.includes('Windows'))
|
|
33
|
+
return 'Windows';
|
|
34
|
+
if (ua.includes('Macintosh') || ua.includes('Mac OS X'))
|
|
35
|
+
return 'macOS';
|
|
36
|
+
if (ua.includes('Linux'))
|
|
37
|
+
return 'Linux';
|
|
38
|
+
return 'Unknown';
|
|
39
|
+
}
|
|
40
|
+
function isEdgeUa(ua) {
|
|
41
|
+
return ua.includes('Edg/');
|
|
42
|
+
}
|
|
43
|
+
function pickUserAgent(cfg, opts) {
|
|
44
|
+
if (opts.userAgent)
|
|
45
|
+
return opts.userAgent;
|
|
46
|
+
const poolDisabledByOpts = opts.uaPool === false;
|
|
47
|
+
const poolArrayFromOpts = Array.isArray(opts.uaPool) ? opts.uaPool : undefined;
|
|
48
|
+
const poolEnabled = poolDisabledByOpts ? false : (poolArrayFromOpts ? true : cfg.http.uaPoolEnabled);
|
|
49
|
+
if (cfg.http.userAgent && !poolEnabled) {
|
|
50
|
+
return cfg.http.userAgent;
|
|
51
|
+
}
|
|
52
|
+
if (poolEnabled) {
|
|
53
|
+
const pool = poolArrayFromOpts ?? UA_POOL;
|
|
54
|
+
const chosen = pool[uaRotationCounter % pool.length];
|
|
55
|
+
uaRotationCounter += 1;
|
|
56
|
+
return chosen;
|
|
57
|
+
}
|
|
58
|
+
// Pool disabled, no configured/caller UA — fall back to configured UA if
|
|
59
|
+
// present, else the first pool entry.
|
|
60
|
+
return cfg.http.userAgent ?? UA_POOL[0];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Builds the outgoing request headers for one fetch attempt. `attempt` is
|
|
64
|
+
* accepted for signature stability / future use (e.g. attempt-aware header
|
|
65
|
+
* variation) but does not currently affect UA selection — rotation is a
|
|
66
|
+
* pure module-level round-robin, independent of retry attempt number.
|
|
67
|
+
*/
|
|
68
|
+
export function buildHeaders(cfg, opts, attempt) {
|
|
69
|
+
void attempt;
|
|
70
|
+
const userAgent = pickUserAgent(cfg, opts);
|
|
71
|
+
const headers = {
|
|
72
|
+
'User-Agent': userAgent,
|
|
73
|
+
Accept: cfg.http.accept,
|
|
74
|
+
'Accept-Language': opts.acceptLanguage ?? cfg.http.acceptLanguage,
|
|
75
|
+
};
|
|
76
|
+
if (isChromiumUa(userAgent)) {
|
|
77
|
+
const version = chromiumMajorVersion(userAgent) ?? '131';
|
|
78
|
+
const brand = isEdgeUa(userAgent) ? 'Microsoft Edge' : 'Google Chrome';
|
|
79
|
+
headers['sec-ch-ua'] = `"Chromium";v="${version}", "${brand}";v="${version}", "Not?A_Brand";v="99"`;
|
|
80
|
+
headers['sec-ch-ua-mobile'] = '?0';
|
|
81
|
+
headers['sec-ch-ua-platform'] = `"${platformFor(userAgent)}"`;
|
|
82
|
+
}
|
|
83
|
+
// Caller-supplied headers always win.
|
|
84
|
+
return { ...headers, ...(opts.headers ?? {}) };
|
|
85
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FetchOptions, FetchResult } from './types.js';
|
|
2
|
+
export { RobotsDisallowedError, resetRobotsCache } from './robots.js';
|
|
3
|
+
export { resetRateLimiter } from './ratelimit.js';
|
|
4
|
+
export { ProxyPool, buildProxyFetchImpl, resetProxyAgents } from './proxy.js';
|
|
5
|
+
export { scrapeTable } from './scrape.js';
|
|
6
|
+
export type { ScrapeTableOptions, TableResult } from './scrape.js';
|
|
7
|
+
/** Clears all cached ProxyPools. For tests. */
|
|
8
|
+
export declare function resetProxyPools(): void;
|
|
9
|
+
export declare function fetchDocument(url: string, opts?: FetchOptions): Promise<FetchResult>;
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetch/index.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC9E,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,YAAY,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAOnE,+CAA+C;AAC/C,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AA0GD,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAiN9F"}
|