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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # hazo_scrape — Change Log
2
2
 
3
+ ## 1.2.0 — 2026-07-22
4
+
5
+ ### Added — `mapRows` optional `opts.dateFormats`
6
+ - `mapRows(table, map, keywords, opts?)` — new optional 4th parameter, `{ dateFormats?: string[] }`, forwarded verbatim to `parseDate`'s `opts.formats` for every date-typed cell. Purely additive: omitting `opts` preserves the exact prior behavior (a genuinely ambiguous slash date, e.g. `"09/11/2012"`, still resolves to `null` — see the pinned "never guess" test in `__tests__/map_rows.test.ts`).
7
+ - Motivation: a caller that KNOWS a source's date convention (e.g. `extractor_asx`'s `SourceRegistryEntry.nuances.date_formats`, which existed in that consumer's schema but had nothing to plug into) can now pass a `DD/MM/YYYY`/`MM/DD/YYYY` hint through `mapRows` to resolve what would otherwise be an unresolvable ambiguity, without this engine ever guessing on its own.
8
+
9
+ ## 1.1.0 — 2026-07-19
10
+
11
+ ### Added — fetch layer (`hazo_scrape/fetch`, server-only)
12
+ - `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).
13
+ - `scrapeTable(url, keywords, opts?)` — one-call bridge chaining `fetchDocument` → `extractTable` → `mapColumns` → `mapRows`, returning `{ headers, columnMap, unmatched, rows, partial, warning?, source }`.
14
+ - 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.
15
+ - 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`.
16
+
17
+ ### Notes
18
+ - 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.
19
+ - Proxied requests route through undici's `ProxyAgent` and therefore bypass `safeFetch`'s SSRF connect-IP guard (accepted: operator-configured proxies).
20
+ - The parse core (`hazo_scrape/parse`) is unchanged and remains fully network-free.
21
+
3
22
  ## 1.0.0 — 2026-07-17
4
23
 
5
24
  - 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"}