为什么选择 NEVER_PRIMP?
| 浏览器 TLS/JA3/JA4 指纹 | ✅ 100+ 配置 | ❌ | ❌ | ✅ 有限 |
| HTTP/2 指纹(AKAMAI) | ✅ | ❌ | ❌ | ✅ |
| 请求头顺序精确控制 | ✅ | ❌ | ❌ | ❌ |
| Cookie 分割(HTTP/2 风格) | ✅ | ❌ | ❌ | ❌ |
| 跨子域名 Cookie 共享 | ✅ RFC 6265 | ✅ | ✅ | ❌ |
| Cookie 跨会话持久化 | ✅ | ❌ | ❌ | ❌ |
| 高并发无锁 Client 共享 | ✅ | ❌ | ✅ | ❌ |
| GIL 释放(真实并行) | ✅ | ❌ | ✅ | ❌ |
| 重试策略 | ✅ 内置预算 | ❌ | ❌ | ❌ |
| 文件上传(multipart) | ✅ | ✅ | ✅ | ✅ |
| 单次请求 | 646 ms | 90 ms | 122 ms | 86 ms |
| 串行 10 次 | 655 ms | 20 ms | 47 ms | 19 ms |
| 并发 100 任务 | 697 ms | 23 ms | 56 ms | 20 ms |
安装
pip install -U never-primp
平台支持:Linux (x86_64/aarch64) · Windows (x86_64) · macOS (x86_64/ARM64)
从源码构建:
pip install maturin
maturin develop --release
快速开始
import never_primp
r = never_primp.get("https://httpbin.org/get")
print(r.status_code, r.json())
client = never_primp.Client(
impersonate="chrome_147",
impersonate_os="windows",
timeout=30.0,
)
r = client.get("https://httpbin.org/headers")
print(r.json())
with never_primp.Client(impersonate="firefox_149") as client:
r = client.post("https://httpbin.org/post", json={"key": "value"})
print(r.json())
性能优化原理深度解析
这一节详细解释 never_primp 每一项性能设计的底层原理。
1. 去除 Arc<Mutex<Client>>:消灭并发瓶颈
旧版问题
pub struct RClient {
client: Arc<Mutex<wreq::Client>>,
}
let resp = client.lock().unwrap().request(...).send().await;
在 Python 的 ThreadPoolExecutor 场景下,20 个线程各自调用 client.get():
线程1 ──[lock]──── request ──── [unlock]──
线程2 [等待] ────── request ──── [等待]...
线程3 [等待] ...
串行化!
新版方案
wreq::Client 内部已用 Arc 包裹所有状态(连接池、Cookie Jar、配置),它实现了 Clone + Send + Sync:
pub struct RClient {
client: wreq::Client,
}
let client = self.client.clone();
let future = async move {
client.request(method, url).send().await
};
py.detach(|| RUNTIME.block_on(future));
20 个线程同时发请求:
线程1 ── clone(O1) ─── request ───────── 并行!
线程2 ── clone(O1) ─── request ───────── 并行!
线程3 ── clone(O1) ─── request ───────── 并行!
↑ 每个 clone 只是原子计数+1,互不干扰
性能影响:高并发场景下吞吐量从串行变为真正并行,延迟从 O(N×T) 降为 O(T)。
2. GIL 释放:Python 多线程的正确姿势
Python 的 GIL(Global Interpreter Lock)保证同一时刻只有一个线程执行 Python 字节码,但 IO 操作期间可以释放。
never_primp 的实现:
let wreq_response = py.detach(|| {
RUNTIME.block_on(future)
});
py.detach() 等价于在 C 扩展中调用 Py_BEGIN_ALLOW_THREADS,让其他 Python 线程在等待 HTTP 响应期间正常运行。
实际效果:
GIL 持有时间线(旧版无释放):
Thread1: [GIL][send HTTP][wait response][GIL] ...
Thread2: [等待GIL..............][GIL][send HTTP]...
结果:几乎串行
GIL 释放时间线(新版):
Thread1: [GIL][send HTTP─────────]→[wait resp in Tokio]→[GIL][return]
Thread2: [GIL][send HTTP─────]→[wait resp in Tokio]→[GIL][return]
Thread3: [GIL][send HTTP]→[wait resp in Tokio]→[GIL][return]
结果:真正并行
GIL 只在构建请求和处理响应这两个极短的 CPU 阶段被持有,网络 IO 等待期间全部并行。
3. Tokio 异步运行时:4 Worker 线程的选择
pub static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.thread_name("never-primp-worker")
.enable_all()
.build()
.unwrap()
});
为什么是 4 而不是更多?
Tokio 的 worker 线程处理的是 IO 事件(socket 可读/可写的通知),而不是实际等待数据。HTTP 请求的主要时间消耗在网络 RTT,不是 CPU 计算:
典型 HTTP 请求生命周期:
建立连接: ~5ms (CPU: <1ms)
发送请求: ~0.1ms (CPU: <0.1ms)
等待响应: ~50ms (CPU: 0,纯等待)
读取响应: ~1ms (CPU: <0.5ms)
4 个 worker 可以同时管理数千个 "等待响应" 状态的 socket,
因为 epoll/IOCP 一次系统调用可以轮询所有就绪事件。
对于 CPU 密集的任务应该用 spawn_blocking,对于 IO 密集的请求任务,4 个 worker 通常已经饱和。增加到 16 个 worker 对 IO bound 场景几乎无提升,反而增加线程切换开销。
4. 连接池:TCP 复用的关键参数
client_builder
.pool_max_idle_per_host(32)
.pool_max_size(512)
.pool_idle_timeout(Duration::from_secs(30))
.tcp_keepalive(Duration::from_secs(30))
.tcp_keepalive_interval(Duration::from_secs(15))
.tcp_keepalive_retries(3u32)
.tcp_nodelay(true);
三个连接池参数现在可通过 Python 层按场景调整:
client = Client(impersonate="chrome_147", cookie_store=False)
client = Client(
impersonate="chrome_147",
pool_max_idle_per_host=64,
pool_max_size=256,
pool_idle_timeout=60.0,
)
为什么这些参数很重要?
TCP 连接复用(最大收益):
建立一条 TCP+TLS 连接的开销:
TCP 三次握手: ~10ms (1 RTT)
TLS 1.3 握手: ~15ms (1 RTT)
合计: ~25ms
如果每个请求都新建连接(pool 为 0),100 个请求就浪费 2500ms 在握手上。连接池让同一主机的请求复用已建立的连接,第 2 次请求开始几乎没有建连开销。
TCP Keepalive(连接稳定性):
没有 Keepalive 的问题:
服务器/NAT/防火墙在 60-120s 内无流量会 silently drop 连接
下次使用这条"死连接"时:RST 或超时,请求失败
SO_KEEPALIVE 工作原理:
每 30s 发一个 TCP ACK 探测包(几乎无流量)
服务器响应 → 连接确认活跃,重置超时计时器
服务器无响应 → 15s 后重试,最多 3 次,才判定连接断开
效果:空闲连接保持真正可用,避免"僵尸连接"导致的请求失败
TCP_NODELAY(降低小包延迟):
Nagle 算法会把小数据包积攒到 MSS(约 1460 字节)再发送,降低延迟对 HTTP 不友好。tcp_nodelay(true) 禁用它,每次 write() 立即发送,降低请求延迟约 10-40ms。
5. Cookie Jar 的 RFC 6265 实现
wreq 的 Jar 内部结构:
HashMap<domain, HashMap<path, CookieJar>>
↑ 按 (domain, path) 二级索引存储
子域名 Cookie 共享(domain_match):
fn domain_match(host: &str, domain: &str) -> bool {
host == domain
|| (host.len() > domain.len()
&& host.ends_with(domain)
&& host.as_bytes()[host.len() - domain.len() - 1] == b'.')
}
查询 api.example.com 的 cookie 时,遍历所有 domain key 做 domain_match:
example.com → 匹配,返回其 cookie
api.example.com → 完全匹配,也返回
读写锁而非互斥锁:
store: Arc<RwLock<HashMap<...>>>
store.read().get(host)...
store.write().entry(domain).or_default()...
爬虫场景下读(发请求带 Cookie)远多于写(收到 Set-Cookie),RwLock 比 Mutex 在并发读时效率高得多。
大并发内存注意事项:
cookie_store=True(默认)时,所有 Set-Cookie 响应头都会写入 Jar,且永不自动过期。在高并发打多个不同域名的场景下:
- Jar 随域名数量持续增长(无上限)
- 每次写入(收到 Set-Cookie)都要竞争独占写锁,成为并发瓶颈
结论:无状态爬取场景使用 cookie_store=False,可显著降低内存占用和锁争用。
6. HTTP/2 连接序言合并(反检测关键)
现代风控系统会检测 HTTP/2 握手时第一次 TCP 应用数据的大小。真实浏览器(如 Safari)会将连接序言、SETTINGS、WINDOW_UPDATE、首个 HEADERS 帧合并为一次 TCP 突发:
真实 Safari 握手:
第 1 次 TCP 应用数据 [424 bytes]:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n (24 bytes, 连接序言)
SETTINGS frame (51 bytes)
WINDOW_UPDATE frame (13 bytes)
HEADERS frame (~336 bytes, 首个请求)
→ 风控检测通过 ✅
旧版实现握手(显式 flush 导致拆包):
第 1 次 TCP 应用数据 [70 bytes]:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n (24 bytes)
SETTINGS frame (51 bytes)
WINDOW_UPDATE frame (13 bytes)
第 2 次 TCP 应用数据 [~298 bytes]:
HEADERS frame
→ 风控检测失败 ❌ "[!] Safari detected but invalid SM packet (Len=70). Marking as bot."
never_primp 的底层 HTTP/2 实现移除了连接建立时的提前 flush,确保连接序言和首个请求帧在同一 TCP 段中发出,与浏览器行为完全一致。
7. 重试预算机制
let policy = RetryPolicy::default()
.max_retries_per_request(2);
wreq 的重试策略内置了预算限制(默认 20% 额外负载):
假设发出 1000 个请求:
正常请求: 1000
允许的重试: 1000 × 20% = 200
如果某段时间重试过多(>200),
预算耗尽,后续请求即使失败也不再重试,
避免雪崩效应(retry storm)导致服务器更不稳定。
这比简单的 for _ in range(3): try: request() 更智能,在大并发场景下保护目标服务器。
8. 头部顺序控制(anti-bot 核心)
现代风控系统(Cloudflare、Akamai、PerimeterX)会检测请求头的顺序作为 bot 指纹:
真实 Chrome 143 的头部顺序:
:method: GET
:authority: example.com
:scheme: https
:path: /
sec-ch-ua: ...
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0...
accept: text/html,...
sec-fetch-site: none
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
accept-encoding: gzip, deflate, br, zstd
accept-language: zh-CN,...
never_primp 用 OrigHeaderMap 记录头部插入顺序:
let mut orig_headers = OrigHeaderMap::new();
for (key, _) in client_headers.iter() {
orig_headers.insert(key.clone());
}
request_builder = request_builder.orig_headers(orig_headers);
这确保了发出去的 TCP 字节流中头部顺序与真实浏览器完全一致。
Cookie 管理
client.cookies 返回一个 RequestsCookieJar 对象,提供类 dict 接口,行为与 requests.Session.cookies 一致。内部分为两层:
- 全局 Cookie:无域名限定,随所有请求发送
- 域名 Cookie:由
Set-Cookie 响应头自动写入,或手动通过 set(domain=…) 添加,按 RFC 6265 规则匹配
自动跨子域名共享
client = never_primp.Client(impersonate="chrome_147")
client.get("https://example.com/login")
client.get("https://api.example.com/data")
client.get("https://cdn.example.com/asset")
全局 Cookie 操作(类 dict)
jar = client.cookies
jar["token"] = "abc"
val = jar["token"]
del jar["token"]
"token" in jar
jar.update({"a": "1", "b": "2"})
print(jar.get_dict())
for name, value in jar.items():
print(name, value)
jar.clear_global()
jar.clear_jar()
jar.clear()
域名 Cookie 操作
client.cookies.set("auth_token", "eyJhbGci...",
domain="example.com", path="/")
cookies = client.cookies.get_cookies_for_url("https://api.example.com/data")
val = client.cookies.find("sec_cpt")
for name, value, domain, path in client.cookies.get_jar_cookies():
print(f"[{domain}{path}] {name}={value}")
跨会话 Cookie 持久化
import json
client = never_primp.Client(impersonate="chrome_147")
client.get("https://example.com/login")
with open("session.json", "w") as f:
json.dump(client.export_cookies(), f)
client2 = never_primp.Client(impersonate="chrome_143")
with open("session.json") as f:
client2.import_cookies([tuple(c) for c in json.load(f)])
r = client2.get("https://example.com/dashboard")
浏览器指纹伪装
支持的浏览器(100+ 配置)
| Chrome | 100–147 | "chrome" → 最新 |
| Edge | 101–147 | "edge" → 最新 |
| Firefox | 109–149 | "firefox" → 最新 |
| Safari macOS | 15.3–26.2 | "safari" → 最新 |
| Safari iOS | 16.5–26.2 | "safari_ios" → 最新 |
| Safari iPad | 18–26.2 | "safari_ipad" → 最新 |
Safari 26+ TLS 说明:Safari 26.x 使用 BoringSSL 后端,相比旧版有以下变化:
- Cipher suites 顺序调整为 AES-256-GCM → ChaCha20 → AES-128-GCM
- 支持 X25519MLKEM768 后量子混合密钥交换(
supported_groups + key_share)
supported_versions 仅包含 TLS 1.2 和 TLS 1.3(不再宣告 TLS 1.0/1.1)
accept-encoding 值更新为 gzip, deflate, br, zstd(新增 zstd)
| Opera | 116–130 | "opera" → 最新 |
| OkHttp | 3.9–5 | "okhttp" → 最新 |
伪装内容
client = never_primp.Client(
impersonate="chrome_143",
impersonate_os="windows",
)
每个配置包含:
- TLS 指纹:cipher suites 顺序、TLS extensions、椭圆曲线、签名算法(JA3/JA4)
- HTTP/2 指纹:SETTINGS 帧参数、WINDOW_UPDATE、HEADERS 帧顺序(AKAMAI 指纹)
- 请求头集合:与该浏览器版本完全一致的默认头部(含正确的
accept-encoding 值与位置)
- 请求头顺序:精确匹配浏览器的头部发送顺序
- H2 连接预热合并:连接序言 + SETTINGS + WINDOW_UPDATE 与首个 HEADERS 帧合并为单次 TCP 突发,与真实浏览器行为一致
Cookie 分割(HTTP/2 浏览器行为)
client = never_primp.Client(
impersonate="chrome_143",
split_cookies=True,
)
API 参考
Client 构造参数
client = never_primp.Client(
auth=("username", "password"),
auth_bearer="token",
proxy="socks5://127.0.0.1:1080",
timeout=30.0,
verify=True,
ca_cert_file="/path/to/ca.pem",
impersonate="chrome_147",
impersonate_os="windows",
http1_only=False,
http2_only=False,
https_only=False,
follow_redirects=True,
max_redirects=20,
cookie_store=True,
split_cookies=True,
max_retries=2,
pool_max_idle_per_host=32,
pool_max_size=512,
pool_idle_timeout=30.0,
headers={"X-Custom": "value"},
params={"version": "2"},
)
请求方法
r = client.get(url,
params={"q": "python"},
headers={"Accept": "application/json"},
cookies={"session": "abc"},
timeout=10.0,
proxy="http://127.0.0.1:8080",
verify=False,
impersonate="firefox_147",
impersonate_os="linux",
)
r = client.post(url,
json={"key": "value"},
)
Response 对象
r = client.get("https://httpbin.org/get")
r.status_code
r.url
r.headers
r.cookies
r.content
r.text
r.encoding
r.json()
Cookie 管理(通过 client.cookies)
client.cookies 返回 RequestsCookieJar,提供以下接口:
jar = client.cookies
jar["name"]
jar["name"] = "value"
del jar["name"]
"name" in jar
len(jar)
iter(jar)
jar.get(name, default=None)
jar.update({"k": "v"})
jar.keys()
jar.values()
jar.items()
jar.get_dict()
jar.set(name, value,
domain=None, path=None)
jar.get_jar_cookies()
jar.get_cookies_for_url(url)
jar.find(name, default=None)
jar.clear_global()
jar.clear_jar()
jar.clear()
client.export_cookies()
client.import_cookies([(name, value, domain, path)])
请求头管理方法
client.headers = {"User-Agent": "bot"}
client.headers
client.set_header("X-Custom", "value")
client.get_header("X-Custom")
client.headers_update({"Accept": "*/*"})
client.delete_header("X-Custom")
client.clear_headers()
会话关闭
with Client(impersonate="chrome_147", cookie_store=False) as client:
r = client.get("https://example.com")
client = Client(impersonate="chrome_147")
client.close()
便利函数(无需创建 Client)
import never_primp
r = never_primp.get("https://httpbin.org/get")
r = never_primp.post("https://httpbin.org/post", json={"a": 1})
r = never_primp.put(url, data={"k": "v"})
r = never_primp.delete(url)
r = never_primp.patch(url, json={})
r = never_primp.head(url)
r = never_primp.options(url)
AsyncClient(异步接口)
import asyncio
import never_primp
async def main():
async with never_primp.AsyncClient(
impersonate="chrome_147",
max_retries=2,
) as client:
r = await client.get("https://httpbin.org/get")
print(r.json())
asyncio.run(main())
开发
pip install maturin
maturin develop
maturin develop --release
cargo check
cargo clippy
cargo fmt
python example/concurrent_requests.py
python example/cookie_management.py
python example/browser_impersonation.py
架构概览
Python 调用 client.get(url)
↓
Client.__init__.py # Python 封装层(ergonomics)
↓
RClient.request() # PyO3 Rust 类(src/client.rs)
↓
py.detach() # 释放 Python GIL
↓
RUNTIME.block_on() # 进入 Tokio 异步运行时(4 workers)
↓
wreq::Client # Rust HTTP 客户端(内部 Arc,无锁 clone)
↓
wreq 连接池 # TCP 复用 + TLS 会话复用
↓
目标服务器
↓
Response::from_wreq_response() # 懒加载转换(src/response.rs)
↓
返回 Python,GIL 重新获取
License
MIT