h2-fingerprint-client 1.0.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/README.md +152 -0
- package/examples/basic.js +16 -0
- package/examples/compare-profiles.js +23 -0
- package/package.json +27 -0
- package/src/client.js +109 -0
- package/src/fingerprints.js +130 -0
- package/src/index.js +5 -0
- package/src/profiles.js +111 -0
- package/src/session.js +115 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# h2-fingerprint-client
|
|
2
|
+
|
|
3
|
+
> HTTP/2 fingerprint-aware request library for Node.js — mimics real browser SETTINGS frames, pseudo-header order, and header casing for research and educational purposes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Background
|
|
8
|
+
|
|
9
|
+
Modern bot detection systems don't just inspect your IP or User-Agent. They fingerprint the structure of your HTTP/2 connection at the transport layer — analyzing:
|
|
10
|
+
|
|
11
|
+
- **SETTINGS frames** — every browser sends unique `HEADER_TABLE_SIZE`, `INITIAL_WINDOW_SIZE`, `MAX_HEADER_LIST_SIZE` values on session open
|
|
12
|
+
- **Pseudo-header order** — Chrome sends `:method :authority :scheme :path`, Firefox sends `:method :path :authority :scheme`
|
|
13
|
+
- **Header casing and order** — real browsers send headers in a consistent, deterministic sequence
|
|
14
|
+
- **WINDOW_UPDATE size** — the initial flow control window increment differs per browser
|
|
15
|
+
|
|
16
|
+
A standard Node.js `http2` or `axios` request is immediately identifiable because it sends none of these signals correctly.
|
|
17
|
+
|
|
18
|
+
This library lets you make HTTP/2 requests that structurally match a real browser's fingerprint.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install h2-fingerprint-client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Basic GET request
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const { get } = require("h2-fingerprint-client");
|
|
36
|
+
|
|
37
|
+
const res = await get("https://example.com", {
|
|
38
|
+
profile: "chrome120", // chrome120 | firefox121 | safari17
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.log(res.status); // 200
|
|
42
|
+
console.log(res.body); // HTML response
|
|
43
|
+
console.log(res.timings); // { connect: 120, total: 340 }
|
|
44
|
+
console.log(res.profile); // "Chrome 120 / Windows 11"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### POST request
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
const { post } = require("h2-fingerprint-client");
|
|
51
|
+
|
|
52
|
+
const res = await post("https://example.com/api/data", {
|
|
53
|
+
profile: "firefox121",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ key: "value" }),
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Custom headers (merged with profile)
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const { request } = require("h2-fingerprint-client");
|
|
63
|
+
|
|
64
|
+
const res = await request("https://example.com", {
|
|
65
|
+
profile: "safari17",
|
|
66
|
+
method: "GET",
|
|
67
|
+
headers: {
|
|
68
|
+
"accept-language": "fr-FR,fr;q=0.9",
|
|
69
|
+
"cookie": "session=abc123",
|
|
70
|
+
},
|
|
71
|
+
timeout: 10000,
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Profiles
|
|
78
|
+
|
|
79
|
+
| Profile | Browser | OS | SETTINGS |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `chrome120` | Chrome 120 | Windows 11 | `HEADER_TABLE_SIZE=65536, ENABLE_PUSH=0, INITIAL_WINDOW_SIZE=6291456, MAX_HEADER_LIST_SIZE=262144` |
|
|
82
|
+
| `firefox121` | Firefox 121 | Windows 11 | `HEADER_TABLE_SIZE=65536, INITIAL_WINDOW_SIZE=131072, MAX_FRAME_SIZE=16384, ENABLE_PUSH=0` |
|
|
83
|
+
| `safari17` | Safari 17 | macOS Sonoma | `HEADER_TABLE_SIZE=4096, ENABLE_PUSH=0, INITIAL_WINDOW_SIZE=4194304, MAX_FRAME_SIZE=16384` |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Response Object
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
{
|
|
91
|
+
status: 200, // HTTP status code
|
|
92
|
+
headers: { ... }, // Response headers (pseudo-headers stripped)
|
|
93
|
+
body: "...", // Response body as string
|
|
94
|
+
profile: "Chrome 120 / Windows 11",
|
|
95
|
+
timings: {
|
|
96
|
+
connect: 112, // ms to establish HTTP/2 session
|
|
97
|
+
total: 348, // ms total
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
### 1. SETTINGS Frame
|
|
107
|
+
On session open, Node.js's `http2.connect()` accepts a `settings` object. This library passes the exact settings values that each real browser sends, rather than the Node.js defaults.
|
|
108
|
+
|
|
109
|
+
### 2. Pseudo-Header Order
|
|
110
|
+
HTTP/2 uses pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) that must appear before regular headers. The **order** of these pseudo-headers differs between browsers and is a key fingerprinting signal. This library replicates the correct order per profile.
|
|
111
|
+
|
|
112
|
+
### 3. Header Order
|
|
113
|
+
Regular headers are ordered to match the real browser's typical output — not alphabetically or randomly as most HTTP clients do.
|
|
114
|
+
|
|
115
|
+
### 4. Header Values
|
|
116
|
+
Each profile ships with the correct `user-agent`, `accept`, `sec-ch-ua`, `sec-fetch-*` and other headers matching that browser version.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Examples
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Basic request
|
|
124
|
+
node examples/basic.js
|
|
125
|
+
|
|
126
|
+
# Compare all profiles side by side
|
|
127
|
+
node examples/compare-profiles.js
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Limitations
|
|
133
|
+
|
|
134
|
+
- HTTP/2 requires **HTTPS**. HTTP/1.1 sites are not supported.
|
|
135
|
+
- TLS fingerprinting (JA3/JA4) is a separate layer — this library does not spoof TLS ClientHello. For that, look into solutions using BoringSSL or `curl-impersonate`.
|
|
136
|
+
- WINDOW_UPDATE frame timing is not yet controllable.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Research References
|
|
141
|
+
|
|
142
|
+
- [RFC 7540 — HTTP/2](https://www.rfc-editor.org/rfc/rfc7540)
|
|
143
|
+
- [RFC 7541 — HPACK Header Compression](https://www.rfc-editor.org/rfc/rfc7541)
|
|
144
|
+
- [HTTP/2 Fingerprinting — BrowserLeaks](https://browserleaks.com/http2)
|
|
145
|
+
- [TLS + HTTP/2 fingerprinting — tls.peet.ws](https://tls.peet.ws/)
|
|
146
|
+
- [Akamai HTTP/2 fingerprinting research](https://www.blackhat.com/docs/eu-17/materials/eu-17-Shuster-Passive-Fingerprinting-Of-HTTP2-Clients-wp.pdf)
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — for research and educational use.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { get } = require("../src");
|
|
2
|
+
|
|
3
|
+
(async () => {
|
|
4
|
+
try {
|
|
5
|
+
console.log("Testing Chrome 120 fingerprint...");
|
|
6
|
+
const res = await get("https://httpbin.org/headers", { profile: "chrome120" });
|
|
7
|
+
console.log("Status:", res.status);
|
|
8
|
+
console.log("Profile used:", res.profile);
|
|
9
|
+
console.log("Timings:", res.timings);
|
|
10
|
+
console.log("Response headers sent (as seen by server):");
|
|
11
|
+
const parsed = JSON.parse(res.body);
|
|
12
|
+
console.log(JSON.stringify(parsed.headers, null, 2));
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error("Error:", err.message);
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare how different browser fingerprint profiles appear to a server.
|
|
3
|
+
* Run: node examples/compare-profiles.js
|
|
4
|
+
*/
|
|
5
|
+
const { get, PROFILES } = require("../src");
|
|
6
|
+
|
|
7
|
+
(async () => {
|
|
8
|
+
const profileNames = Object.keys(PROFILES);
|
|
9
|
+
console.log(`Comparing ${profileNames.length} profiles against httpbin.org/headers\n`);
|
|
10
|
+
|
|
11
|
+
for (const profileName of profileNames) {
|
|
12
|
+
try {
|
|
13
|
+
console.log(`\n--- ${PROFILES[profileName].name} ---`);
|
|
14
|
+
const res = await get("https://httpbin.org/headers", { profile: profileName });
|
|
15
|
+
const parsed = JSON.parse(res.body);
|
|
16
|
+
console.log("Status:", res.status);
|
|
17
|
+
console.log("User-Agent:", parsed.headers["User-Agent"]);
|
|
18
|
+
console.log("Total time:", res.timings.total + "ms");
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(` Error: ${err.message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "h2-fingerprint-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "HTTP/2 fingerprint-aware request library for Node.js — mimics real browser SETTINGS frames, pseudo-header order, and header ordering for research purposes.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"example": "node examples/basic.js",
|
|
8
|
+
"compare": "node examples/compare-profiles.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"http2",
|
|
12
|
+
"fingerprint",
|
|
13
|
+
"h2",
|
|
14
|
+
"browser",
|
|
15
|
+
"tls",
|
|
16
|
+
"anti-bot",
|
|
17
|
+
"scraping",
|
|
18
|
+
"research",
|
|
19
|
+
"settings-frame",
|
|
20
|
+
"header-order"
|
|
21
|
+
],
|
|
22
|
+
"author": "Israze",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=14.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http2 = require("http2");
|
|
4
|
+
const { PROFILES } = require("./profiles");
|
|
5
|
+
|
|
6
|
+
function buildOrderedHeaders(profile, method, urlObj, extraHeaders = {}) {
|
|
7
|
+
const merged = { ...profile.headers, ...extraHeaders };
|
|
8
|
+
const ordered = {};
|
|
9
|
+
|
|
10
|
+
for (const pseudo of profile.pseudoHeaderOrder) {
|
|
11
|
+
if (pseudo === ":method") ordered[":method"] = method.toUpperCase();
|
|
12
|
+
if (pseudo === ":authority") ordered[":authority"] = urlObj.host;
|
|
13
|
+
if (pseudo === ":scheme") ordered[":scheme"] = urlObj.protocol.replace(":", "");
|
|
14
|
+
if (pseudo === ":path") ordered[":path"] = urlObj.pathname + urlObj.search;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const key of profile.headerOrder) {
|
|
18
|
+
if (merged[key] !== undefined) ordered[key] = merged[key];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
22
|
+
if (!ordered[key]) ordered[key] = value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return ordered;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function request(url, options = {}) {
|
|
29
|
+
const {
|
|
30
|
+
profile: profileName = "chrome120",
|
|
31
|
+
method = "GET",
|
|
32
|
+
headers: extraHeaders = {},
|
|
33
|
+
body = null,
|
|
34
|
+
timeout = 15000,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
const profile = PROFILES[profileName];
|
|
38
|
+
if (!profile) {
|
|
39
|
+
return Promise.reject(
|
|
40
|
+
new Error(`Unknown profile "${profileName}". Available: ${Object.keys(PROFILES).join(", ")}`)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const urlObj = new URL(url);
|
|
45
|
+
if (urlObj.protocol !== "https:") {
|
|
46
|
+
return Promise.reject(new Error("HTTP/2 requires HTTPS. Use an https:// URL."));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
let connectTime = null;
|
|
52
|
+
|
|
53
|
+
const client = http2.connect(urlObj.origin, {
|
|
54
|
+
settings: profile.settings,
|
|
55
|
+
ALPNProtocols: ["h2"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
client.on("connect", () => { connectTime = Date.now() - startTime; });
|
|
59
|
+
client.on("error", (err) => { client.destroy(); reject(err); });
|
|
60
|
+
|
|
61
|
+
const orderedHeaders = buildOrderedHeaders(profile, method, urlObj, extraHeaders);
|
|
62
|
+
const req = client.request(orderedHeaders);
|
|
63
|
+
|
|
64
|
+
if (body) req.write(body);
|
|
65
|
+
req.end();
|
|
66
|
+
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
req.destroy();
|
|
69
|
+
client.destroy();
|
|
70
|
+
reject(new Error(`Request timed out after ${timeout}ms`));
|
|
71
|
+
}, timeout);
|
|
72
|
+
|
|
73
|
+
let responseHeaders = {};
|
|
74
|
+
let status = null;
|
|
75
|
+
|
|
76
|
+
req.on("response", (headers) => {
|
|
77
|
+
status = headers[":status"];
|
|
78
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
79
|
+
if (!k.startsWith(":")) responseHeaders[k] = v;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const chunks = [];
|
|
84
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
85
|
+
|
|
86
|
+
req.on("end", () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
client.close();
|
|
89
|
+
resolve({
|
|
90
|
+
status,
|
|
91
|
+
headers: responseHeaders,
|
|
92
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
93
|
+
profile: profile.name,
|
|
94
|
+
timings: { connect: connectTime, total: Date.now() - startTime },
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
req.on("error", (err) => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
client.destroy();
|
|
101
|
+
reject(err);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const get = (url, options = {}) => request(url, { ...options, method: "GET" });
|
|
107
|
+
const post = (url, options = {}) => request(url, { ...options, method: "POST" });
|
|
108
|
+
|
|
109
|
+
module.exports = { request, get, post, PROFILES };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP/2 SETTINGS frame values and pseudo-header ordering
|
|
5
|
+
* based on real browser captures.
|
|
6
|
+
*
|
|
7
|
+
* References:
|
|
8
|
+
* - https://www.rfc-editor.org/rfc/rfc7540
|
|
9
|
+
* - https://browserleaks.com/http2
|
|
10
|
+
* - https://tls.peet.ws/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const FINGERPRINTS = {
|
|
14
|
+
chrome120: {
|
|
15
|
+
name: 'Chrome 120',
|
|
16
|
+
settings: {
|
|
17
|
+
HEADER_TABLE_SIZE: 65536,
|
|
18
|
+
ENABLE_PUSH: 0,
|
|
19
|
+
INITIAL_WINDOW_SIZE: 6291456,
|
|
20
|
+
MAX_HEADER_LIST_SIZE: 262144,
|
|
21
|
+
},
|
|
22
|
+
windowUpdate: 15663105,
|
|
23
|
+
pseudoHeaderOrder: [':method', ':authority', ':scheme', ':path'],
|
|
24
|
+
headerOrder: [
|
|
25
|
+
'cache-control',
|
|
26
|
+
'sec-ch-ua',
|
|
27
|
+
'sec-ch-ua-mobile',
|
|
28
|
+
'sec-ch-ua-platform',
|
|
29
|
+
'upgrade-insecure-requests',
|
|
30
|
+
'user-agent',
|
|
31
|
+
'accept',
|
|
32
|
+
'sec-fetch-site',
|
|
33
|
+
'sec-fetch-mode',
|
|
34
|
+
'sec-fetch-user',
|
|
35
|
+
'sec-fetch-dest',
|
|
36
|
+
'accept-encoding',
|
|
37
|
+
'accept-language',
|
|
38
|
+
],
|
|
39
|
+
headers: {
|
|
40
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
41
|
+
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
42
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
43
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
44
|
+
'cache-control': 'max-age=0',
|
|
45
|
+
'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
46
|
+
'sec-ch-ua-mobile': '?0',
|
|
47
|
+
'sec-ch-ua-platform': '"Windows"',
|
|
48
|
+
'sec-fetch-dest': 'document',
|
|
49
|
+
'sec-fetch-mode': 'navigate',
|
|
50
|
+
'sec-fetch-site': 'none',
|
|
51
|
+
'sec-fetch-user': '?1',
|
|
52
|
+
'upgrade-insecure-requests': '1',
|
|
53
|
+
},
|
|
54
|
+
priority: {
|
|
55
|
+
exclusive: true,
|
|
56
|
+
streamDependency: 0,
|
|
57
|
+
weight: 255,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
firefox121: {
|
|
62
|
+
name: 'Firefox 121',
|
|
63
|
+
settings: {
|
|
64
|
+
HEADER_TABLE_SIZE: 65536,
|
|
65
|
+
INITIAL_WINDOW_SIZE: 131072,
|
|
66
|
+
MAX_FRAME_SIZE: 16384,
|
|
67
|
+
},
|
|
68
|
+
windowUpdate: 12517377,
|
|
69
|
+
pseudoHeaderOrder: [':method', ':path', ':authority', ':scheme'],
|
|
70
|
+
headerOrder: [
|
|
71
|
+
'user-agent',
|
|
72
|
+
'accept',
|
|
73
|
+
'accept-language',
|
|
74
|
+
'accept-encoding',
|
|
75
|
+
'connection',
|
|
76
|
+
],
|
|
77
|
+
headers: {
|
|
78
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
|
79
|
+
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
|
80
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
81
|
+
'accept-language': 'en-US,en;q=0.5',
|
|
82
|
+
'connection': 'keep-alive',
|
|
83
|
+
'upgrade-insecure-requests': '1',
|
|
84
|
+
},
|
|
85
|
+
priority: {
|
|
86
|
+
exclusive: false,
|
|
87
|
+
streamDependency: 0,
|
|
88
|
+
weight: 42,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
safari17: {
|
|
93
|
+
name: 'Safari 17',
|
|
94
|
+
settings: {
|
|
95
|
+
INITIAL_WINDOW_SIZE: 4194304,
|
|
96
|
+
MAX_CONCURRENT_STREAMS: 100,
|
|
97
|
+
},
|
|
98
|
+
windowUpdate: 10485760,
|
|
99
|
+
pseudoHeaderOrder: [':method', ':scheme', ':path', ':authority'],
|
|
100
|
+
headerOrder: [
|
|
101
|
+
'user-agent',
|
|
102
|
+
'accept',
|
|
103
|
+
'accept-language',
|
|
104
|
+
'accept-encoding',
|
|
105
|
+
],
|
|
106
|
+
headers: {
|
|
107
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
|
108
|
+
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
109
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
110
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
111
|
+
},
|
|
112
|
+
priority: null,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function getFingerprint(name) {
|
|
117
|
+
const fp = FINGERPRINTS[name];
|
|
118
|
+
if (!fp) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unknown fingerprint: "${name}". Available: ${Object.keys(FINGERPRINTS).join(', ')}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return fp;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function listFingerprints() {
|
|
127
|
+
return Object.keys(FINGERPRINTS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { getFingerprint, listFingerprints, FINGERPRINTS };
|
package/src/index.js
ADDED
package/src/profiles.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser HTTP/2 fingerprint profiles
|
|
3
|
+
* Based on public research into browser HTTP/2 SETTINGS frames and header ordering.
|
|
4
|
+
*
|
|
5
|
+
* References:
|
|
6
|
+
* - https://www.rfc-editor.org/rfc/rfc7540 (HTTP/2 spec)
|
|
7
|
+
* - https://www.rfc-editor.org/rfc/rfc7541 (HPACK spec)
|
|
8
|
+
* - https://browserleaks.com/http2
|
|
9
|
+
* - https://tls.peet.ws/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PROFILES = {
|
|
13
|
+
chrome120: {
|
|
14
|
+
name: "Chrome 120 / Windows 11",
|
|
15
|
+
settings: {
|
|
16
|
+
HEADER_TABLE_SIZE: 65536,
|
|
17
|
+
ENABLE_PUSH: 0,
|
|
18
|
+
INITIAL_WINDOW_SIZE: 6291456,
|
|
19
|
+
MAX_HEADER_LIST_SIZE: 262144,
|
|
20
|
+
},
|
|
21
|
+
windowUpdate: 15663105,
|
|
22
|
+
pseudoHeaderOrder: [":method", ":authority", ":scheme", ":path"],
|
|
23
|
+
headerOrder: [
|
|
24
|
+
"cache-control",
|
|
25
|
+
"sec-ch-ua",
|
|
26
|
+
"sec-ch-ua-mobile",
|
|
27
|
+
"sec-ch-ua-platform",
|
|
28
|
+
"upgrade-insecure-requests",
|
|
29
|
+
"user-agent",
|
|
30
|
+
"accept",
|
|
31
|
+
"sec-fetch-site",
|
|
32
|
+
"sec-fetch-mode",
|
|
33
|
+
"sec-fetch-user",
|
|
34
|
+
"sec-fetch-dest",
|
|
35
|
+
"accept-encoding",
|
|
36
|
+
"accept-language",
|
|
37
|
+
],
|
|
38
|
+
headers: {
|
|
39
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
40
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
41
|
+
"accept-language": "en-US,en;q=0.9",
|
|
42
|
+
"accept-encoding": "gzip, deflate, br",
|
|
43
|
+
"cache-control": "max-age=0",
|
|
44
|
+
"sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
45
|
+
"sec-ch-ua-mobile": "?0",
|
|
46
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
47
|
+
"sec-fetch-dest": "document",
|
|
48
|
+
"sec-fetch-mode": "navigate",
|
|
49
|
+
"sec-fetch-site": "none",
|
|
50
|
+
"sec-fetch-user": "?1",
|
|
51
|
+
"upgrade-insecure-requests": "1",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
firefox121: {
|
|
56
|
+
name: "Firefox 121 / Windows 11",
|
|
57
|
+
settings: {
|
|
58
|
+
HEADER_TABLE_SIZE: 65536,
|
|
59
|
+
INITIAL_WINDOW_SIZE: 131072,
|
|
60
|
+
MAX_FRAME_SIZE: 16384,
|
|
61
|
+
ENABLE_PUSH: 0,
|
|
62
|
+
},
|
|
63
|
+
windowUpdate: 12517377,
|
|
64
|
+
pseudoHeaderOrder: [":method", ":path", ":authority", ":scheme"],
|
|
65
|
+
headerOrder: [
|
|
66
|
+
"user-agent",
|
|
67
|
+
"accept",
|
|
68
|
+
"accept-language",
|
|
69
|
+
"accept-encoding",
|
|
70
|
+
"upgrade-insecure-requests",
|
|
71
|
+
],
|
|
72
|
+
headers: {
|
|
73
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
|
74
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
75
|
+
"accept-language": "en-US,en;q=0.5",
|
|
76
|
+
"accept-encoding": "gzip, deflate, br",
|
|
77
|
+
"upgrade-insecure-requests": "1",
|
|
78
|
+
"sec-fetch-dest": "document",
|
|
79
|
+
"sec-fetch-mode": "navigate",
|
|
80
|
+
"sec-fetch-site": "none",
|
|
81
|
+
"sec-fetch-user": "?1",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
safari17: {
|
|
86
|
+
name: "Safari 17 / macOS Sonoma",
|
|
87
|
+
settings: {
|
|
88
|
+
HEADER_TABLE_SIZE: 4096,
|
|
89
|
+
ENABLE_PUSH: 0,
|
|
90
|
+
INITIAL_WINDOW_SIZE: 4194304,
|
|
91
|
+
MAX_FRAME_SIZE: 16384,
|
|
92
|
+
MAX_HEADER_LIST_SIZE: 16384,
|
|
93
|
+
},
|
|
94
|
+
windowUpdate: 10420225,
|
|
95
|
+
pseudoHeaderOrder: [":method", ":scheme", ":path", ":authority"],
|
|
96
|
+
headerOrder: [
|
|
97
|
+
"accept",
|
|
98
|
+
"user-agent",
|
|
99
|
+
"accept-language",
|
|
100
|
+
"accept-encoding",
|
|
101
|
+
],
|
|
102
|
+
headers: {
|
|
103
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
|
104
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
105
|
+
"accept-language": "en-US,en;q=0.9",
|
|
106
|
+
"accept-encoding": "gzip, deflate, br",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
module.exports = { PROFILES };
|
package/src/session.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { request } = require('./client');
|
|
4
|
+
const { getFingerprint, listFingerprints } = require('./fingerprints');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* H2Session - maintains a consistent fingerprint identity
|
|
8
|
+
* across multiple requests (same headers, same settings).
|
|
9
|
+
*
|
|
10
|
+
* Useful for multi-step scraping flows where consistency matters.
|
|
11
|
+
*/
|
|
12
|
+
class H2Session {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string} [options.fingerprint='chrome120']
|
|
16
|
+
* @param {object} [options.headers={}] - extra headers applied to every request
|
|
17
|
+
* @param {number} [options.timeout=15000]
|
|
18
|
+
*/
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.fingerprint = options.fingerprint || 'chrome120';
|
|
21
|
+
this.baseHeaders = options.headers || {};
|
|
22
|
+
this.timeout = options.timeout || 15000;
|
|
23
|
+
this.cookieJar = {};
|
|
24
|
+
|
|
25
|
+
// Validate fingerprint exists on construction
|
|
26
|
+
getFingerprint(this.fingerprint);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merges stored cookies into request headers.
|
|
31
|
+
* @param {string} host
|
|
32
|
+
* @returns {object}
|
|
33
|
+
*/
|
|
34
|
+
_getCookieHeader(host) {
|
|
35
|
+
const cookies = this.cookieJar[host];
|
|
36
|
+
if (!cookies || Object.keys(cookies).length === 0) return {};
|
|
37
|
+
const cookieStr = Object.entries(cookies)
|
|
38
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
39
|
+
.join('; ');
|
|
40
|
+
return { cookie: cookieStr };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parses and stores set-cookie headers from a response.
|
|
45
|
+
* @param {string} host
|
|
46
|
+
* @param {object} responseHeaders
|
|
47
|
+
*/
|
|
48
|
+
_storeCookies(host, responseHeaders) {
|
|
49
|
+
const raw = responseHeaders['set-cookie'];
|
|
50
|
+
if (!raw) return;
|
|
51
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
52
|
+
if (!this.cookieJar[host]) this.cookieJar[host] = {};
|
|
53
|
+
for (const entry of list) {
|
|
54
|
+
const [pair] = entry.split(';');
|
|
55
|
+
const [name, ...rest] = pair.split('=');
|
|
56
|
+
if (name) this.cookieJar[host][name.trim()] = rest.join('=').trim();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Makes a request using this session's fingerprint + cookies.
|
|
62
|
+
* @param {string} url
|
|
63
|
+
* @param {object} [options]
|
|
64
|
+
* @returns {Promise<{ status, headers, body }>}
|
|
65
|
+
*/
|
|
66
|
+
async request(url, options = {}) {
|
|
67
|
+
const parsed = new URL(url);
|
|
68
|
+
const cookieHeaders = this._getCookieHeader(parsed.host);
|
|
69
|
+
|
|
70
|
+
const res = await request(url, {
|
|
71
|
+
fingerprint: this.fingerprint,
|
|
72
|
+
timeout: this.timeout,
|
|
73
|
+
...options,
|
|
74
|
+
headers: {
|
|
75
|
+
...this.baseHeaders,
|
|
76
|
+
...cookieHeaders,
|
|
77
|
+
...(options.headers || {}),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this._storeCookies(parsed.host, res.headers);
|
|
82
|
+
return res;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get(url, options = {}) {
|
|
86
|
+
return this.session(url, { ...options, method: 'GET' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
post(url, options = {}) {
|
|
90
|
+
return this.request(url, { ...options, method: 'POST' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns current fingerprint profile info.
|
|
95
|
+
*/
|
|
96
|
+
inspect() {
|
|
97
|
+
const fp = getFingerprint(this.fingerprint);
|
|
98
|
+
return {
|
|
99
|
+
fingerprint: this.fingerprint,
|
|
100
|
+
name: fp.name,
|
|
101
|
+
settings: fp.settings,
|
|
102
|
+
pseudoHeaderOrder: fp.pseudoHeaderOrder,
|
|
103
|
+
cookieJar: this.cookieJar,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clears stored cookies.
|
|
109
|
+
*/
|
|
110
|
+
clearCookies() {
|
|
111
|
+
this.cookieJar = {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { H2Session };
|