@webcrawl/curl-impersonate 0.0.0-stage → 1.3.1

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/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # @webcrawl/curl-impersonate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Zero-dependency curl-impersonate fetch for Node.js.
4
+
5
+ One GET hop through a pinned [lexiforest/curl-impersonate](https://github.com/lexiforest/curl-impersonate) binary with a **real-Chrome TLS/HTTP2 fingerprint** — the layer anti-bot WAFs (AWS WAF, Cloudflare Bot Management) judge before any HTML is served. Verified live against AWS-WAF-protected sites where Chrome-136-era fingerprints (`impit`, plain Node `fetch`) get 403/429.
6
+
7
+ ## Why
8
+
9
+ Anti-bot WAFs fingerprint the TLS ClientHello (JA3/JA4) and HTTP/2 SETTINGS frame — Node's TLS stack matches none of them. curl-impersonate patches BoringSSL (Chrome's TLS library) so the handshake is byte-for-byte a real Chrome. We validated this against live AWS WAF targets: `chrome150` passes where `chrome136` is blocked.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { impersonateFetch } from "@webcrawl/curl-impersonate";
15
+
16
+ const res = await impersonateFetch("https://www.stubhub.com/concert-tickets/", {
17
+ profile: "chrome150", // default
18
+ timeoutMs: 12_000,
19
+ });
20
+ // { status: 200, headers, contentType: "text/html", body: Uint8Array }
21
+ ```
22
+
23
+ Redirects are **manual** (not followed): one call = one hop, so you keep SSRF-guarding every redirect the way the webcrawl engine does.
24
+
25
+ ### Profiles
26
+
27
+ | Profile | Notes |
28
+ |---|---|
29
+ | `chrome150` | default — newest profile verified to pass both stubhub + expedia AWS WAF |
30
+ | `chrome142` | older fallback |
31
+ | `chrome151_macos`, `chrome152` | newer presets from the binary |
32
+
33
+ Headers (`User-Agent`, `sec-ch-ua`, `Sec-Fetch-*`, `Accept-Language`, `Priority`) are generated per-profile so they stay fingerprint-coherent — a Chrome 150 handshake never carries Chrome 136 client hints.
34
+
35
+ ## Binary acquisition (zero deps)
36
+
37
+ The binary is downloaded on first use into `~/.cache/webcrawl-impersonate/v2.3.2/<platform>/` (or `os.tmpdir()` when HOME isn't writable — serverless):
38
+
39
+ 1. `WEBCRAWL_IMPERSONATE_BIN=/path/to/curl-impersonate` env override — checked first, skips download.
40
+ 2. Cached binary — no network.
41
+ 3. Pinned GitHub release asset (`lexiforest/curl-impersonate` v2.3.2), sha256-verified per asset before extraction. Downloads fail soft: the tier reports unavailable and callers fall back.
42
+
43
+ macOS (arm64/x64), Linux (glibc/musl, arm64/x64). `ensureImpersonate()` pre-warms for CLI/MCP startup; `isImpersonateAvailable()` probes without network.
44
+
45
+ ## License
46
+
47
+ MIT. The vendored binary is [curl-impersonate](https://github.com/lexiforest/curl-impersonate) (curl + BoringSSL under their own licenses).
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Binary acquisition for @webcrawl/curl-impersonate.
3
+ *
4
+ * Resolves a pinned lexiforest/curl-impersonate binary for the current
5
+ * platform, downloading it on first use into a cache dir. All failures
6
+ * degrade to `null` (tier unavailable, caller falls back) — never throw
7
+ * into the fetch path.
8
+ *
9
+ * Resolution order:
10
+ * 1. WEBCRAWL_IMPERSONATE_BIN env var (path to a curl-impersonate binary)
11
+ * 2. Cached binary under <cacheRoot>/webcrawl-impersonate/v2.3.2/<asset>/
12
+ * 3. First-use download from the pinned GitHub release asset (sha256-verified)
13
+ */
14
+ export declare const IMPERSONATE_VERSION = "v2.3.2";
15
+ /** Asset name (without `curl-impersonate-v2.3.2.` prefix) -> sha256. */
16
+ export declare const ASSET_SHA256: Record<string, string>;
17
+ /** Node platform/arch -> release asset name. Null when unsupported. */
18
+ export declare function assetForPlatform(platform?: string, arch?: string): string | null;
19
+ export declare function binaryCachePath(asset: string): string;
20
+ /**
21
+ * Resolve the impersonate binary path, downloading on first use.
22
+ * Returns null when unavailable — never throws.
23
+ */
24
+ export declare function ensureBinary(): Promise<string | null>;
25
+ /** Synchronous availability probe (CLI startup, no network). */
26
+ export declare function isBinaryCached(): boolean;
27
+ /** Best-effort liveness check: binary runs and reports a version. */
28
+ export declare function isBinaryHealthy(path: string): boolean;
package/dist/binary.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Binary acquisition for @webcrawl/curl-impersonate.
3
+ *
4
+ * Resolves a pinned lexiforest/curl-impersonate binary for the current
5
+ * platform, downloading it on first use into a cache dir. All failures
6
+ * degrade to `null` (tier unavailable, caller falls back) — never throw
7
+ * into the fetch path.
8
+ *
9
+ * Resolution order:
10
+ * 1. WEBCRAWL_IMPERSONATE_BIN env var (path to a curl-impersonate binary)
11
+ * 2. Cached binary under <cacheRoot>/webcrawl-impersonate/v2.3.2/<asset>/
12
+ * 3. First-use download from the pinned GitHub release asset (sha256-verified)
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, chmodSync, renameSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import * as https from "node:https";
20
+ export const IMPERSONATE_VERSION = "v2.3.2";
21
+ const RELEASE_BASE = `https://github.com/lexiforest/curl-impersonate/releases/download/${IMPERSONATE_VERSION}`;
22
+ /** Asset name (without `curl-impersonate-v2.3.2.` prefix) -> sha256. */
23
+ export const ASSET_SHA256 = {
24
+ "arm64-macos": "638ed43cc93cb5da91f794ff23fce1735fec3dc5c09ee87cc499d802c25d2d35",
25
+ "x86_64-macos": "4e9fe855a34d011de88bf216c196f6957624526678b99b4f2135acc9ffa8c156",
26
+ "x86_64-linux-gnu": "bc9bd2522fe1850dd54c0fff189c8dd606df3f222ab2a905f2dd255123c3f495",
27
+ "aarch64-linux-gnu": "35a351db883f5ce82945bd8280c727b08e27aa48594d4eb9e091d8c80e936d52",
28
+ "x86_64-linux-musl": "ce79849f3be2e15d06364f89aeebb901b6be7b48947e2af8dae0616bbc87731f",
29
+ "aarch64-linux-musl": "1519bcee11f9c93a7ed95026bd951d53e8c7cd093f3ac53651b143e5a1e2098f",
30
+ };
31
+ /** Node platform/arch -> release asset name. Null when unsupported. */
32
+ export function assetForPlatform(platform = process.platform, arch = process.arch) {
33
+ if (platform === "darwin") {
34
+ if (arch === "arm64")
35
+ return "arm64-macos";
36
+ if (arch === "x64")
37
+ return "x86_64-macos";
38
+ return null;
39
+ }
40
+ if (platform === "linux") {
41
+ const isMusl = detectMusl();
42
+ if (arch === "arm64")
43
+ return isMusl ? "aarch64-linux-musl" : "aarch64-linux-gnu";
44
+ if (arch === "x64")
45
+ return isMusl ? "x86_64-linux-musl" : "x86_64-linux-gnu";
46
+ return null;
47
+ }
48
+ return null;
49
+ }
50
+ function detectMusl() {
51
+ // `ldd --version` prints "musl" on musl hosts; glibc prints "glibc"/"GNU".
52
+ // ldd missing or failing → assume glibc (the serverless default).
53
+ try {
54
+ const out = execFileSync("ldd", ["--version"], {
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ });
58
+ return /musl/i.test(out);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ function cacheRoot() {
65
+ const home = process.env.HOME || process.env.USERPROFILE || "";
66
+ const base = home ? join(home, ".cache") : tmpdir();
67
+ const root = join(base, "webcrawl-impersonate");
68
+ // Writable probe — HOME caches can be root-owned/EPERM (serverless, managed
69
+ // Macs); fall back to tmpdir() so the tier stays available.
70
+ try {
71
+ mkdirSync(root, { recursive: true });
72
+ return root;
73
+ }
74
+ catch {
75
+ return join(tmpdir(), "webcrawl-impersonate");
76
+ }
77
+ }
78
+ export function binaryCachePath(asset) {
79
+ return join(cacheRoot(), IMPERSONATE_VERSION, asset, "curl-impersonate");
80
+ }
81
+ function httpsGet(url, redirects = 0) {
82
+ return new Promise((resolve, reject) => {
83
+ if (redirects > 5) {
84
+ reject(new Error("too many redirects"));
85
+ return;
86
+ }
87
+ const req = https.get(url, (res) => {
88
+ const status = res.statusCode ?? 0;
89
+ const location = res.headers.location;
90
+ if (status >= 300 && status < 400 && location) {
91
+ res.resume();
92
+ resolve(httpsGet(new URL(location, url).href, redirects + 1));
93
+ return;
94
+ }
95
+ if (status !== 200) {
96
+ res.resume();
97
+ reject(new Error(`HTTP ${status} for ${url}`));
98
+ return;
99
+ }
100
+ const chunks = [];
101
+ res.on("data", (c) => chunks.push(c));
102
+ res.on("end", () => resolve(Buffer.concat(chunks)));
103
+ res.on("error", reject);
104
+ });
105
+ req.on("error", reject);
106
+ });
107
+ }
108
+ /**
109
+ * Resolve the impersonate binary path, downloading on first use.
110
+ * Returns null when unavailable — never throws.
111
+ */
112
+ export async function ensureBinary() {
113
+ const override = process.env.WEBCRAWL_IMPERSONATE_BIN;
114
+ if (override) {
115
+ return existsSync(override) ? override : null;
116
+ }
117
+ const asset = assetForPlatform();
118
+ if (!asset)
119
+ return null;
120
+ const cached = binaryCachePath(asset);
121
+ if (existsSync(cached))
122
+ return cached;
123
+ try {
124
+ await downloadAndExtract(asset, cached);
125
+ return cached;
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ async function downloadAndExtract(asset, destPath) {
132
+ const url = `${RELEASE_BASE}/curl-impersonate-${IMPERSONATE_VERSION}.${asset}.tar.gz`;
133
+ const expected = ASSET_SHA256[asset];
134
+ if (!expected)
135
+ throw new Error(`no pinned sha256 for ${asset}`);
136
+ const tarball = await httpsGet(url);
137
+ const actual = createHash("sha256").update(tarball).digest("hex");
138
+ if (actual !== expected) {
139
+ throw new Error(`sha256 mismatch for ${asset}: ${actual}`);
140
+ }
141
+ const dir = dirname(destPath);
142
+ mkdirSync(dir, { recursive: true });
143
+ // Extract only the binary entry into a scratch dir, then move into place.
144
+ const scratch = join(dir, ".extract");
145
+ rmSync(scratch, { recursive: true, force: true });
146
+ mkdirSync(scratch, { recursive: true });
147
+ await new Promise((resolve, reject) => {
148
+ const tar = spawn("tar", ["-xzf", "-", "-C", scratch, "curl-impersonate"], {
149
+ stdio: ["pipe", "ignore", "pipe"],
150
+ });
151
+ tar.on("error", reject);
152
+ tar.on("close", (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}`)));
153
+ tar.stdin.on("error", reject);
154
+ tar.stdin.end(tarball);
155
+ tar.stderr.on("data", () => { }); // drain
156
+ });
157
+ const extracted = join(scratch, "curl-impersonate");
158
+ chmodSync(extracted, 0o755);
159
+ renameSync(extracted, destPath);
160
+ rmSync(scratch, { recursive: true, force: true });
161
+ }
162
+ /** Synchronous availability probe (CLI startup, no network). */
163
+ export function isBinaryCached() {
164
+ const override = process.env.WEBCRAWL_IMPERSONATE_BIN;
165
+ if (override)
166
+ return existsSync(override);
167
+ const asset = assetForPlatform();
168
+ if (!asset)
169
+ return false;
170
+ return existsSync(binaryCachePath(asset));
171
+ }
172
+ /** Best-effort liveness check: binary runs and reports a version. */
173
+ export function isBinaryHealthy(path) {
174
+ try {
175
+ const r = spawnSync(path, ["--version"], { encoding: "utf8", timeout: 5000 });
176
+ return r.status === 0 && /curl/i.test(String(r.stdout));
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Spawn-based impersonate fetch: one GET hop through the pinned
3
+ * lexiforest curl-impersonate binary with a Chrome TLS/HTTP2 fingerprint.
4
+ *
5
+ * Redirects are NOT followed (-L off) so every hop goes through the
6
+ * caller's SSRF guard — same contract as `rawGet` in the engine fetcher.
7
+ */
8
+ import { ImpersonateProfile } from "./profiles.js";
9
+ export interface ImpersonateFetchResult {
10
+ status: number;
11
+ headers: Record<string, string>;
12
+ contentType: string;
13
+ /** Decompressed body bytes (the binary handles gzip/br/zstd itself). */
14
+ body: Uint8Array;
15
+ }
16
+ export declare class ResourceTooLargeError extends Error {
17
+ readonly bytes: number;
18
+ readonly maxBytes: number;
19
+ constructor(bytes: number, maxBytes: number);
20
+ }
21
+ export declare class ImpersonateUnavailableError extends Error {
22
+ constructor(message: string);
23
+ }
24
+ export declare class ImpersonateTimeoutError extends Error {
25
+ constructor(message: string);
26
+ }
27
+ export interface ImpersonateFetchOptions {
28
+ /** Impersonation preset (default chrome150). */
29
+ profile?: ImpersonateProfile;
30
+ /** Per-request headers merged over the profile navigation set. */
31
+ headers?: Record<string, string>;
32
+ /** Total timeout for the spawn (default 12s). */
33
+ timeoutMs?: number;
34
+ /** Declared-length guard before the body is accepted. */
35
+ maxBytes?: number;
36
+ }
37
+ /** Resolved binary path or null when the tier is unavailable. Memoized. */
38
+ export declare function resolveBinary(): Promise<string | null>;
39
+ /** Pre-warm the binary (download if missing) for CLI/MCP startup. */
40
+ export declare function ensureImpersonate(): Promise<string | null>;
41
+ /** True when the binary resolved (cached or downloaded). */
42
+ export declare function isImpersonateAvailable(): Promise<boolean>;
43
+ /** Parse a `-D` header dump (all blocks; the final block wins per header). */
44
+ export declare function parseHeaderDump(dump: string): {
45
+ status: number;
46
+ headers: Record<string, string>;
47
+ };
48
+ /** One GET hop through curl-impersonate. Throws only on setup/size/timeout errors. */
49
+ export declare function impersonateFetch(url: string, options?: ImpersonateFetchOptions): Promise<ImpersonateFetchResult>;
package/dist/fetch.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Spawn-based impersonate fetch: one GET hop through the pinned
3
+ * lexiforest curl-impersonate binary with a Chrome TLS/HTTP2 fingerprint.
4
+ *
5
+ * Redirects are NOT followed (-L off) so every hop goes through the
6
+ * caller's SSRF guard — same contract as `rawGet` in the engine fetcher.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { DEFAULT_IMPERSONATE_PROFILE, profileHeaders, } from "./profiles.js";
13
+ import { ensureBinary } from "./binary.js";
14
+ export class ResourceTooLargeError extends Error {
15
+ bytes;
16
+ maxBytes;
17
+ constructor(bytes, maxBytes) {
18
+ super(`File is ${(bytes / 1e6).toFixed(1)} MB; the limit is ${(maxBytes / 1e6).toFixed(0)} MB`);
19
+ this.bytes = bytes;
20
+ this.maxBytes = maxBytes;
21
+ this.name = "ResourceTooLargeError";
22
+ }
23
+ }
24
+ export class ImpersonateUnavailableError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "ImpersonateUnavailableError";
28
+ }
29
+ }
30
+ export class ImpersonateTimeoutError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ImpersonateTimeoutError";
34
+ }
35
+ }
36
+ let cachedBinary;
37
+ let binaryProbe = null;
38
+ /** Resolved binary path or null when the tier is unavailable. Memoized. */
39
+ export async function resolveBinary() {
40
+ if (cachedBinary !== undefined)
41
+ return cachedBinary;
42
+ if (!binaryProbe) {
43
+ binaryProbe = ensureBinary().finally(() => {
44
+ binaryProbe = null;
45
+ });
46
+ }
47
+ return (cachedBinary = await binaryProbe);
48
+ }
49
+ /** Pre-warm the binary (download if missing) for CLI/MCP startup. */
50
+ export async function ensureImpersonate() {
51
+ cachedBinary = undefined;
52
+ binaryProbe = null;
53
+ return resolveBinary();
54
+ }
55
+ /** True when the binary resolved (cached or downloaded). */
56
+ export async function isImpersonateAvailable() {
57
+ return (await resolveBinary()) !== null;
58
+ }
59
+ /** Parse a `-D` header dump (all blocks; the final block wins per header). */
60
+ export function parseHeaderDump(dump) {
61
+ const headers = {};
62
+ let status = 0;
63
+ const lines = String(dump ?? "").split(/\r?\n/);
64
+ for (const line of lines) {
65
+ const statusMatch = line.match(/^HTTP\/[\d.]+\s+(\d{3})/i);
66
+ if (statusMatch) {
67
+ status = Number(statusMatch[1]);
68
+ continue;
69
+ }
70
+ const idx = line.indexOf(":");
71
+ if (idx <= 0)
72
+ continue;
73
+ const key = line.slice(0, idx).trim().toLowerCase();
74
+ const val = line.slice(idx + 1).trim();
75
+ if (key === "set-cookie") {
76
+ // keep the first cookie of the final block; callers replay per-domain
77
+ if (!(key in headers))
78
+ headers[key] = val.split(";")[0];
79
+ }
80
+ else {
81
+ headers[key] = val;
82
+ }
83
+ }
84
+ return { status, headers };
85
+ }
86
+ /** One GET hop through curl-impersonate. Throws only on setup/size/timeout errors. */
87
+ export async function impersonateFetch(url, options = {}) {
88
+ const bin = await resolveBinary();
89
+ if (!bin) {
90
+ throw new ImpersonateUnavailableError("curl-impersonate binary unavailable");
91
+ }
92
+ const { profile = DEFAULT_IMPERSONATE_PROFILE, headers = {}, timeoutMs = 12_000, maxBytes, } = options;
93
+ const navHeaders = profileHeaders(profile, headers);
94
+ const headerArgs = Object.entries(navHeaders).flatMap(([k, v]) => ["-H", `${k}: ${v}`]);
95
+ const dir = mkdtempSync(join(tmpdir(), "webcrawl-imp-"));
96
+ const headerFile = join(dir, "headers");
97
+ const bodyFile = join(dir, "body");
98
+ try {
99
+ const { exitCode, stdout, stderr } = await new Promise((resolve, reject) => {
100
+ const child = spawn(bin, [
101
+ "-sS",
102
+ "--compressed",
103
+ "--impersonate", profile,
104
+ "-m", String(Math.ceil(timeoutMs / 1000)),
105
+ "-D", headerFile,
106
+ "-o", bodyFile,
107
+ "-w", "%{http_code}",
108
+ url,
109
+ ...headerArgs,
110
+ ], { stdio: ["ignore", "pipe", "pipe"] });
111
+ let stdout = "";
112
+ let stderr = "";
113
+ let settled = false;
114
+ const timer = setTimeout(() => {
115
+ settled = true;
116
+ child.kill("SIGKILL");
117
+ reject(new ImpersonateTimeoutError(`timed out after ${timeoutMs}ms`));
118
+ }, timeoutMs + 2000);
119
+ child.stdout.on("data", (c) => (stdout += c.toString()));
120
+ child.stderr.on("data", (c) => (stderr += c.toString()));
121
+ child.on("error", (err) => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ clearTimeout(timer);
126
+ reject(err);
127
+ });
128
+ child.on("close", (code) => {
129
+ if (settled)
130
+ return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ // 0 = clean exit. Partial fetches (timeouts) still parse best-effort.
134
+ resolve({ exitCode: code ?? -1, stdout, stderr });
135
+ });
136
+ });
137
+ if (exitCode !== 0 && stderr.trim() && !existsFile(headerFile)) {
138
+ throw new Error(`curl-impersonate exited ${exitCode}: ${stderr.slice(0, 400)}`);
139
+ }
140
+ void stdout; // %{http_code} — kept for debugging; status comes from the dump
141
+ const { status: parsedStatus, headers: responseHeaders } = parseHeaderDump(safeRead(headerFile));
142
+ const status = parsedStatus || Number(stdout.trim()) || 0;
143
+ const body = safeReadBytes(bodyFile);
144
+ if (maxBytes !== undefined) {
145
+ const declared = Number(responseHeaders["content-length"]);
146
+ if (Number.isFinite(declared) && declared > maxBytes) {
147
+ throw new ResourceTooLargeError(declared, maxBytes);
148
+ }
149
+ if (body.byteLength > maxBytes) {
150
+ throw new ResourceTooLargeError(body.byteLength, maxBytes);
151
+ }
152
+ }
153
+ return {
154
+ status,
155
+ headers: responseHeaders,
156
+ contentType: (responseHeaders["content-type"] || "")
157
+ .split(";")[0]
158
+ .trim()
159
+ .toLowerCase(),
160
+ body,
161
+ };
162
+ }
163
+ finally {
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ }
167
+ function existsFile(path) {
168
+ try {
169
+ readFileSync(path);
170
+ return true;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ function safeRead(path) {
177
+ try {
178
+ return readFileSync(path, "utf8");
179
+ }
180
+ catch {
181
+ return "";
182
+ }
183
+ }
184
+ function safeReadBytes(path) {
185
+ try {
186
+ return new Uint8Array(readFileSync(path));
187
+ }
188
+ catch {
189
+ return new Uint8Array(0);
190
+ }
191
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @webcrawl/curl-impersonate — zero-dependency curl-impersonate fetch.
3
+ *
4
+ * One GET hop through a pinned lexiforest curl-impersonate binary with a
5
+ * Chrome TLS/HTTP2 fingerprint. Redirects are manual so callers keep
6
+ * SSRF-guarding each hop. Pairs with the engine fetch ladder as the tier
7
+ * between impit (Chrome 136) and browser/sandbox escalation.
8
+ */
9
+ export { impersonateFetch, ensureImpersonate, isImpersonateAvailable, resolveBinary, parseHeaderDump, ImpersonateUnavailableError, ImpersonateTimeoutError, ResourceTooLargeError, } from "./fetch.js";
10
+ export type { ImpersonateFetchOptions, ImpersonateFetchResult, } from "./fetch.js";
11
+ export { DEFAULT_IMPERSONATE_PROFILE, profileHeaders, profileUserAgent, type ImpersonateProfile, } from "./profiles.js";
12
+ export { ensureBinary, assetForPlatform, binaryCachePath, isBinaryCached, isBinaryHealthy, IMPERSONATE_VERSION, ASSET_SHA256, } from "./binary.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @webcrawl/curl-impersonate — zero-dependency curl-impersonate fetch.
3
+ *
4
+ * One GET hop through a pinned lexiforest curl-impersonate binary with a
5
+ * Chrome TLS/HTTP2 fingerprint. Redirects are manual so callers keep
6
+ * SSRF-guarding each hop. Pairs with the engine fetch ladder as the tier
7
+ * between impit (Chrome 136) and browser/sandbox escalation.
8
+ */
9
+ export { impersonateFetch, ensureImpersonate, isImpersonateAvailable, resolveBinary, parseHeaderDump, ImpersonateUnavailableError, ImpersonateTimeoutError, ResourceTooLargeError, } from "./fetch.js";
10
+ export { DEFAULT_IMPERSONATE_PROFILE, profileHeaders, profileUserAgent, } from "./profiles.js";
11
+ export { ensureBinary, assetForPlatform, binaryCachePath, isBinaryCached, isBinaryHealthy, IMPERSONATE_VERSION, ASSET_SHA256, } from "./binary.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Browser impersonation profiles for the lexiforest curl-impersonate binary.
3
+ *
4
+ * The binary ships presets as `--impersonate <target>`; the target names here
5
+ * map 1:1 to the wrapper scripts in the release tarball (curl_chrome150 etc.).
6
+ * Presets bundle TLS ciphers/curves/extensions, HTTP/2 settings + pseudo-header
7
+ * order, and a default navigation header set — we override the UA/sec-ch-ua
8
+ * headers ourselves so they stay coherent with `sec-ch-ua-platform`.
9
+ */
10
+ export type ImpersonateProfile = "chrome142" | "chrome150" | "chrome151_macos" | "chrome152";
11
+ export declare const DEFAULT_IMPERSONATE_PROFILE: ImpersonateProfile;
12
+ /**
13
+ * Fingerprint-coherent navigation headers for a profile: the UA, the
14
+ * client hints, and the Sec-Fetch navigation set a real Chrome tab sends.
15
+ * The binary replaces the TLS/HTTP2 layer; these headers keep the
16
+ * application layer consistent with it.
17
+ */
18
+ export declare function profileHeaders(profile?: ImpersonateProfile, extra?: Record<string, string>): Record<string, string>;
19
+ export declare function profileUserAgent(profile?: ImpersonateProfile): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Browser impersonation profiles for the lexiforest curl-impersonate binary.
3
+ *
4
+ * The binary ships presets as `--impersonate <target>`; the target names here
5
+ * map 1:1 to the wrapper scripts in the release tarball (curl_chrome150 etc.).
6
+ * Presets bundle TLS ciphers/curves/extensions, HTTP/2 settings + pseudo-header
7
+ * order, and a default navigation header set — we override the UA/sec-ch-ua
8
+ * headers ourselves so they stay coherent with `sec-ch-ua-platform`.
9
+ */
10
+ export const DEFAULT_IMPERSONATE_PROFILE = "chrome150";
11
+ const PROFILE_DEFS = {
12
+ chrome142: { chromeMajor: "142", platform: "macOS" },
13
+ chrome150: { chromeMajor: "150", platform: "macOS" },
14
+ chrome151_macos: { chromeMajor: "151", platform: "macOS" },
15
+ chrome152: { chromeMajor: "152", platform: "macOS" },
16
+ };
17
+ const MAC_UA = (v) => `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${v}.0.0.0 Safari/537.36`;
18
+ const WIN_UA = (v) => `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${v}.0.0.0 Safari/537.36`;
19
+ /**
20
+ * Fingerprint-coherent navigation headers for a profile: the UA, the
21
+ * client hints, and the Sec-Fetch navigation set a real Chrome tab sends.
22
+ * The binary replaces the TLS/HTTP2 layer; these headers keep the
23
+ * application layer consistent with it.
24
+ */
25
+ export function profileHeaders(profile = DEFAULT_IMPERSONATE_PROFILE, extra) {
26
+ const def = PROFILE_DEFS[profile];
27
+ const v = def.chromeMajor;
28
+ return {
29
+ "User-Agent": def.platform === "macOS" ? MAC_UA(v) : WIN_UA(v),
30
+ "sec-ch-ua": `"Chromium";v="${v}", "Google Chrome";v="${v}", "Not.A/Brand";v="99"`,
31
+ "sec-ch-ua-mobile": "?0",
32
+ "sec-ch-ua-platform": `"${def.platform}"`,
33
+ "Upgrade-Insecure-Requests": "1",
34
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
35
+ "Sec-Fetch-Site": "none",
36
+ "Sec-Fetch-Mode": "navigate",
37
+ "Sec-Fetch-User": "?1",
38
+ "Sec-Fetch-Dest": "document",
39
+ "Accept-Language": "en-US,en;q=0.9",
40
+ Priority: "u=0, i",
41
+ ...(extra ?? {}),
42
+ };
43
+ }
44
+ export function profileUserAgent(profile = DEFAULT_IMPERSONATE_PROFILE) {
45
+ return profileHeaders(profile)["User-Agent"];
46
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@webcrawl/curl-impersonate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.3.1",
4
+ "description": "Zero-dependency curl-impersonate fetch for Node.js — pinned lexiforest binary, Chrome TLS/HTTP2 fingerprint impersonation (chrome150 default), automatic per-platform download with sha256 verification",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Brainfish AI <support@webcrawl.app> (https://webcrawl.app)",
8
+ "homepage": "https://webcrawl.app",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/brainfish-ai/webcrawl-anything.git",
12
+ "directory": "packages/curl-impersonate"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/brainfish-ai/webcrawl-anything/issues"
16
+ },
17
+ "keywords": [
18
+ "curl-impersonate",
19
+ "tls-fingerprint",
20
+ "ja3",
21
+ "ja4",
22
+ "http2-fingerprint",
23
+ "web-scraping",
24
+ "anti-bot",
25
+ "waf-bypass",
26
+ "webcrawl"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "development": "./src/index.ts",
40
+ "types": "./dist/index.d.ts",
41
+ "default": "./dist/index.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "prepack": "npm run build",
47
+ "test": "node --conditions=development --import tsx --test test/*.test.ts"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ }
52
+ }