ipclick 0.2.3__tar.gz → 0.4.0__tar.gz

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.

Potentially problematic release.


This version of ipclick might be problematic. Click here for more details.

Files changed (92) hide show
  1. ipclick-0.4.0/PKG-INFO +1374 -0
  2. ipclick-0.4.0/README.md +1328 -0
  3. {ipclick-0.2.3 → ipclick-0.4.0}/pyproject.toml +38 -3
  4. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/__init__.py +12 -2
  5. ipclick-0.4.0/src/ipclick/adapters/base.py +473 -0
  6. ipclick-0.4.0/src/ipclick/adapters/browser_adapter.py +686 -0
  7. ipclick-0.4.0/src/ipclick/adapters/browser_engines.py +576 -0
  8. ipclick-0.4.0/src/ipclick/adapters/browser_settings.py +174 -0
  9. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/adapters/curl_cffi_adapter.py +76 -16
  10. ipclick-0.4.0/src/ipclick/adapters/drission_adapter.py +375 -0
  11. ipclick-0.4.0/src/ipclick/adapters/niquests_adapter.py +277 -0
  12. ipclick-0.4.0/src/ipclick/adapters/registry.py +180 -0
  13. ipclick-0.4.0/src/ipclick/adapters/settings.py +91 -0
  14. ipclick-0.4.0/src/ipclick/aio.py +321 -0
  15. ipclick-0.4.0/src/ipclick/auth.py +181 -0
  16. ipclick-0.4.0/src/ipclick/cli/main.py +290 -0
  17. ipclick-0.4.0/src/ipclick/cluster/__init__.py +47 -0
  18. ipclick-0.4.0/src/ipclick/cluster/balancer.py +108 -0
  19. ipclick-0.4.0/src/ipclick/cluster/client.py +235 -0
  20. ipclick-0.4.0/src/ipclick/cluster/discovery.py +159 -0
  21. ipclick-0.4.0/src/ipclick/cluster/forwarder.py +572 -0
  22. ipclick-0.4.0/src/ipclick/cluster/node.py +317 -0
  23. ipclick-0.4.0/src/ipclick/cluster/pool.py +267 -0
  24. ipclick-0.4.0/src/ipclick/cluster/probe.py +242 -0
  25. ipclick-0.4.0/src/ipclick/cluster/status_page.py +256 -0
  26. ipclick-0.4.0/src/ipclick/cluster/tokens.py +127 -0
  27. ipclick-0.4.0/src/ipclick/components.py +239 -0
  28. ipclick-0.4.0/src/ipclick/compression.py +210 -0
  29. ipclick-0.4.0/src/ipclick/config_loader/dotenv.py +153 -0
  30. ipclick-0.4.0/src/ipclick/config_loader/loader.py +110 -0
  31. ipclick-0.4.0/src/ipclick/config_loader/placeholders.py +81 -0
  32. ipclick-0.4.0/src/ipclick/config_loader/writer.py +271 -0
  33. ipclick-0.4.0/src/ipclick/configs/default_config.toml +390 -0
  34. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/models.py +59 -11
  35. ipclick-0.4.0/src/ipclick/dto/proto/task.proto +198 -0
  36. ipclick-0.4.0/src/ipclick/dto/proto/task_pb2.py +76 -0
  37. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/proto/task_pb2.pyi +96 -16
  38. ipclick-0.4.0/src/ipclick/dto/proto/task_pb2_grpc.py +246 -0
  39. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/response.py +7 -1
  40. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/exceptions.py +18 -0
  41. ipclick-0.4.0/src/ipclick/factory.py +169 -0
  42. ipclick-0.4.0/src/ipclick/health.py +149 -0
  43. ipclick-0.4.0/src/ipclick/limiter.py +320 -0
  44. ipclick-0.4.0/src/ipclick/resume.py +290 -0
  45. ipclick-0.4.0/src/ipclick/sdk.py +644 -0
  46. ipclick-0.4.0/src/ipclick/secrets.py +223 -0
  47. ipclick-0.4.0/src/ipclick/server.py +547 -0
  48. ipclick-0.4.0/src/ipclick/services/task_service.py +818 -0
  49. ipclick-0.4.0/src/ipclick/tls.py +183 -0
  50. ipclick-0.4.0/src/ipclick/trace.py +1179 -0
  51. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/utils/log_util.py +41 -6
  52. ipclick-0.4.0/src/ipclick/utils/module_probe.py +105 -0
  53. ipclick-0.4.0/src/ipclick/utils/path_util.py +89 -0
  54. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/utils/url_util.py +22 -2
  55. ipclick-0.4.0/src/ipclick/web/__init__.py +20 -0
  56. ipclick-0.4.0/src/ipclick/web/assets.py +657 -0
  57. ipclick-0.4.0/src/ipclick/web/auth.py +230 -0
  58. ipclick-0.4.0/src/ipclick/web/curl_parser.py +274 -0
  59. ipclick-0.4.0/src/ipclick/web/editable.py +362 -0
  60. ipclick-0.4.0/src/ipclick/web/installer.py +469 -0
  61. ipclick-0.4.0/src/ipclick/web/pages.py +852 -0
  62. ipclick-0.4.0/src/ipclick/web/server.py +460 -0
  63. ipclick-0.4.0/src/ipclick/web/templates.py +1354 -0
  64. ipclick-0.2.3/PKG-INFO +0 -421
  65. ipclick-0.2.3/README.md +0 -390
  66. ipclick-0.2.3/src/ipclick/adapters/base.py +0 -266
  67. ipclick-0.2.3/src/ipclick/adapters/httpx_adapter.py +0 -188
  68. ipclick-0.2.3/src/ipclick/adapters/registry.py +0 -49
  69. ipclick-0.2.3/src/ipclick/cli/main.py +0 -85
  70. ipclick-0.2.3/src/ipclick/config_loader/loader.py +0 -48
  71. ipclick-0.2.3/src/ipclick/configs/default_config.toml +0 -154
  72. ipclick-0.2.3/src/ipclick/dto/proto/task.proto +0 -95
  73. ipclick-0.2.3/src/ipclick/dto/proto/task_pb2.py +0 -60
  74. ipclick-0.2.3/src/ipclick/dto/proto/task_pb2_grpc.py +0 -109
  75. ipclick-0.2.3/src/ipclick/sdk.py +0 -307
  76. ipclick-0.2.3/src/ipclick/server.py +0 -175
  77. ipclick-0.2.3/src/ipclick/services/task_service.py +0 -277
  78. ipclick-0.2.3/src/ipclick/utils/path_util.py +0 -37
  79. {ipclick-0.2.3 → ipclick-0.4.0}/LICENSE +0 -0
  80. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/__main__.py +0 -0
  81. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/adapters/__init__.py +0 -0
  82. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/cli/__init__.py +0 -0
  83. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/config_loader/__init__.py +0 -0
  84. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/configs/__init__.py +0 -0
  85. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/__init__.py +0 -0
  86. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/proto/__init__.py +0 -0
  87. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/dto/proto/generate.py +0 -0
  88. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/py.typed +0 -0
  89. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/services/__init__.py +0 -0
  90. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/utils/__init__.py +0 -0
  91. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/utils/config_util.py +0 -0
  92. {ipclick-0.2.3 → ipclick-0.4.0}/src/ipclick/utils/secure_util.py +0 -0
ipclick-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,1374 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipclick
3
+ Version: 0.4.0
4
+ Summary: IPClick Tool
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: 元气码农少女酱
8
+ Author-email: 1276162429@qq.com
9
+ Requires-Python: >=3.11
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Provides-Extra: camoufox
17
+ Provides-Extra: drissionpage
18
+ Provides-Extra: linux
19
+ Provides-Extra: niquests
20
+ Provides-Extra: patchright
21
+ Provides-Extra: playwright
22
+ Provides-Extra: win
23
+ Requires-Dist: camoufox (>=0.5.4,<1.0.0) ; extra == "camoufox"
24
+ Requires-Dist: camoufox (>=0.5.4,<1.0.0) ; extra == "linux"
25
+ Requires-Dist: click (>=8.4.2,<9.0.0)
26
+ Requires-Dist: curl-cffi (>=0.16.0,<0.17.0)
27
+ Requires-Dist: drissionpage (>=4.1.1,<5.0.0) ; extra == "drissionpage"
28
+ Requires-Dist: drissionpage (>=4.1.1,<5.0.0) ; extra == "win"
29
+ Requires-Dist: fake-useragent (>=2.2.0,<3.0.0)
30
+ Requires-Dist: grpcio (>=1.83.0,<2.0.0)
31
+ Requires-Dist: grpcio-health-checking (>=1.83.0,<2.0.0)
32
+ Requires-Dist: loguru (>=0.7.3,<0.8.0)
33
+ Requires-Dist: niquests (>=3.21.0,<4.0.0) ; extra == "niquests"
34
+ Requires-Dist: patchright (>=1.61.2,<2.0.0) ; extra == "linux"
35
+ Requires-Dist: patchright (>=1.61.2,<2.0.0) ; extra == "patchright"
36
+ Requires-Dist: playwright (>=1.60.0,<2.0.0) ; extra == "playwright"
37
+ Requires-Dist: protobuf (>=6.33.2,<8.0.0)
38
+ Requires-Dist: python-box[all] (>=7.4.1,<8.0.0)
39
+ Requires-Dist: typing-extensions (>=4.16.0,<5.0.0)
40
+ Requires-Dist: uuid-utils (>=0.17.0,<0.18.0)
41
+ Project-URL: Bug Tracker, https://github.com/yuanqimanong/IPClick/issues
42
+ Project-URL: Homepage, https://github.com/yuanqimanong/IPClick
43
+ Project-URL: Repository, https://github.com/yuanqimanong/IPClick
44
+ Description-Content-Type: text/markdown
45
+
46
+ # IPClick
47
+
48
+ ![IPClick Logo](https://raw.githubusercontent.com/yuanqimanong/IPClick/master/docs/logo.png)
49
+
50
+ > IPClick 名字灵感来源于动画《Link Click》(时光代理人)。正如时光代理人穿梭于不同的时空执行任务,IPClick 帮助您将 HTTP 请求分发到不同的节点高效执行。
51
+
52
+ ## 📖 简介
53
+
54
+ IPClick 是一个轻量级、高性能的分布式 HTTP 请求代理工具,基于 gRPC 协议构建。它提供了统一的请求接口,支持多种 HTTP
55
+ 客户端适配器,帮助开发者更高效地处理网络请求。
56
+
57
+ > 📚 **完整文档在 [Wiki](https://github.com/yuanqimanong/IPClick/wiki)** ——
58
+ > [安装](https://github.com/yuanqimanong/IPClick/wiki/Installation) ·
59
+ > [快速开始](https://github.com/yuanqimanong/IPClick/wiki/Quick-Start) ·
60
+ > [配置体系](https://github.com/yuanqimanong/IPClick/wiki/Configuration) ·
61
+ > [集群](https://github.com/yuanqimanong/IPClick/wiki/Cluster) ·
62
+ > [浏览器渲染](https://github.com/yuanqimanong/IPClick/wiki/Browser-Rendering) ·
63
+ > [故障排查](https://github.com/yuanqimanong/IPClick/wiki/Troubleshooting)
64
+
65
+ ## ✨ 特性
66
+
67
+ - **多适配器支持**:默认 `curl_cffi`,按需加装 `niquests` / 浏览器渲染,并可注册自定义适配器
68
+ - **轻量安装**:`pip install ipclick` 只带 curl_cffi,其余全部走 extras
69
+ - **浏览器指纹伪装**:基于 `curl_cffi` 实现浏览器指纹模拟,有效绕过反爬检测
70
+ - **浏览器渲染**:起真实浏览器执行 JS,四个引擎可选(camoufox / patchright /
71
+ playwright / DrissionPage),默认按平台挑
72
+ - **集群与故障转移**:多节点客户端,支持轮询 / 随机 / 加权均衡、健康探测与自动换节点
73
+ - **gRPC 通信**:使用 gRPC 协议进行高效的客户端-服务端通信
74
+ - **连接复用**:客户端复用 gRPC channel,服务端复用适配器与 HTTP 连接池
75
+ - **代理支持**:灵活的代理配置,支持 HTTP/HTTPS 代理
76
+ - **自动重试**:适配器内重试(指数退避 + 抖动、按状态码)+ 客户端到服务端这一跳的重试
77
+ - **按 host 限流**:服务端按目标域名严格限制并发数与 QPS,避免把单个站点打爆
78
+ - **流式下载**:大文件分片传输,服务端与客户端都不需要把整个响应体驻留内存
79
+ - **断点续传**:中断后用 HTTP Range 接着下,`If-Range` 保证不会拼接两个版本
80
+ - **批量请求**:一次 RPC 处理多个任务,结果按完成顺序流式返回
81
+ - **异步客户端**:基于 `grpc.aio` 的 `AsyncDownloader`,与同步版接口对应
82
+ - **令牌鉴权**:gRPC 标准 Bearer 令牌,支持环境变量注入与多令牌轮换
83
+ - **TLS / mTLS**:链路加密与双向证书认证,客户端、服务端、集群探活全通路覆盖
84
+ - **健康检查**:实现 `grpc.health.v1` 标准协议,K8s 探针与服务网格开箱即用
85
+ - **链路记录**:每个请求记一条(谁执行的、重试几次、排队多久),内存环形缓冲 + 可选 SQLite,零第三方依赖
86
+ - **SSRF 防护**:服务端对目标 URL 做协议白名单与内网/元数据地址拦截
87
+ - **Web 管理端**:`ipclick run --web` 起一个带登录的界面——总览看板、实时请求流、
88
+ 「试一试」就地发请求看源码(支持粘贴 curl、可点名目标节点)、配置改完写回
89
+ `ipclick.toml`、集群节点增删(保存即生效)与就地测连通性 / 鉴权、可选组件的
90
+ 装卸。左中右三栏布局 + 明暗主题切换,前端零构建链路
91
+ - **命令行工具**:提供便捷的 CLI 工具,支持快速启动服务和查看配置
92
+ - **Docker 支持**:多阶段构建、非 root 运行的镜像
93
+ - **完整类型标注**:随包提供 `py.typed`,下游可直接享受类型检查
94
+
95
+ ## 📦 安装
96
+
97
+ ### 从 PyPI 安装
98
+
99
+ ```bash
100
+ pip install ipclick
101
+ ```
102
+
103
+ `pip install ipclick` 是**轻量安装**——只带默认的 curl_cffi 适配器。其余按需加:
104
+
105
+ ```bash
106
+ pip install "ipclick[niquests]" # niquests 适配器(requests 的 drop-in,支持 HTTP/2、HTTP/3)
107
+ pip install "ipclick[camoufox]" # 浏览器渲染:Firefox 反检测(Linux/macOS 默认)
108
+ pip install "ipclick[drissionpage]" # 浏览器渲染:CDP 直连(Windows 默认)
109
+ pip install "ipclick[patchright]" # 浏览器渲染:Chromium 反检测
110
+ pip install "ipclick[playwright]" # 浏览器渲染:原版 playwright
111
+
112
+ # 按平台打包好的组合
113
+ pip install "ipclick[win]" # curl_cffi + DrissionPage
114
+ pip install "ipclick[linux]" # curl_cffi + camoufox + patchright
115
+ ```
116
+
117
+ **没有 `[all]`**,这是刻意的:四个浏览器内核全装是 70+ 个包和上 G 的浏览器本体,
118
+ 而一台机器只会用其中一个。也**没有 `[redis]` / `[metrics]`**——集群不再需要中间件
119
+ (见[集群](#集群)),统计改成内置的[链路记录](#链路记录与统计)。
120
+
121
+ 浏览器引擎是**两步**安装 —— `pip install` 只装 Python 包(几 MB),浏览器本体要单独准备:
122
+
123
+ ```bash
124
+ python -m camoufox fetch # camoufox:下载它自己那份 Firefox(约 1 GB,装到 ~/.cache/camoufox)
125
+ patchright install chromium # patchright
126
+ playwright install chromium # playwright
127
+ # DrissionPage 用本机已装的 Chrome/Chromium,不用额外下载
128
+ ```
129
+
130
+ 只做第一步的话,`ipclick config-info` 与 Web 端会明确显示 **「包已装,浏览器本体未就绪」**,
131
+ 请求会直接报 `FAILED_PRECONDITION` 并告诉你该跑哪条命令。
132
+
133
+ 上面这些也可以**直接在 Web 端点按钮完成**(0.4 起):`/components` 页列出全部五个
134
+ 可选组件的两级安装状态,就地装 / 卸、下载浏览器本体,装完不用重启进程。
135
+
136
+ > **刻意不让它在请求里自动下载。** camoufox 的 API 默认行为是"缺本体就当场下"
137
+ > (`camoufox_path(download_if_missing=True)`),而那一刻已经在 gRPC 的请求处理线程上:
138
+ > 第一个请求会卡着下 1 GB 然后超时,超时返回后下载还在后台跑,并发的多个首请求
139
+ > 还可能各自触发一次。所以本项目自己解析路径并显式传给它,缺了就报错。
140
+ > **连"查一下装没装"也不能走它的接口** —— `launch_path()` 内部同样会触发下载,
141
+ > 光渲染一次 Web 端总览页就够了。
142
+
143
+ playwright / patchright 也可以复用系统已有的浏览器(省掉约 150MB 下载):
144
+
145
+ ```toml
146
+ [BROWSER]
147
+ executable_path = "/usr/bin/chromium"
148
+ ```
149
+
150
+ ### 从源码安装
151
+
152
+ 本项目使用 [uv](https://docs.astral.sh/uv/) 管理依赖:
153
+
154
+ ```bash
155
+ git clone https://github.com/yuanqimanong/IPClick.git
156
+ cd IPClick
157
+ uv sync
158
+ ```
159
+
160
+ 也可以直接用 pip 以可编辑方式安装:
161
+
162
+ ```bash
163
+ pip install -e .
164
+ ```
165
+
166
+ ## 🔧 系统要求
167
+
168
+ - Python >= 3.11
169
+ - 核心依赖(`pip install ipclick` 会装这些,共 17 个包):
170
+ - curl-cffi >= 0.16.0(默认适配器)
171
+ - grpcio >= 1.83.0 / grpcio-health-checking / protobuf >= 6.33.2
172
+ - click >= 8.4.2
173
+ - fake-useragent >= 2.2.0
174
+ - loguru >= 0.7.3
175
+ - python-box >= 7.4.1
176
+ - uuid-utils >= 0.17.0
177
+ - niquests、四个浏览器引擎全部走 extras,不装就不引入
178
+
179
+ ## 🚀 快速开始
180
+
181
+ ### 启动服务端
182
+
183
+ ```bash
184
+ # 使用默认配置启动
185
+ ipclick run
186
+
187
+ # 指定端口和地址
188
+ ipclick run --host 0.0.0.0 --port 9527
189
+
190
+ # 使用自定义配置文件(TOML 格式)
191
+ ipclick run --config /path/to/ipclick.toml
192
+
193
+ # 显示详细日志
194
+ ipclick run --verbose
195
+
196
+ # 同一目录起第二个实例:gRPC 与 Web 两个端口都要岔开
197
+ ipclick run --port 9528 --web-port 9531 -w
198
+ ```
199
+
200
+ 初始化配置(推荐):
201
+
202
+ ```bash
203
+ ipclick init
204
+ ```
205
+
206
+ 一次生成两份文件:`ipclick.toml`(行为配置)和 `.env`(机密)。`.env` 用 **600**
207
+ 权限创建、预填一个随机 Web 密码、自动追加进 `.gitignore`;文件已存在时拒绝覆盖
208
+ (要覆盖加 `--force`)。
209
+
210
+ 也可以只要模板:
211
+
212
+ ```bash
213
+ ipclick -e > ipclick.toml # 配置模板(-e 等价于 -e toml)
214
+ ipclick -e env > .env # 机密模板(注意自己 chmod 600)
215
+ ```
216
+
217
+ ### 什么放哪儿
218
+
219
+ | | 放什么 | 进版本库? |
220
+ |---|---|---|
221
+ | **`ipclick.toml`** | 行为配置:超时、重试、限流、浏览器引擎、SSRF 策略 | ✅ 应该 |
222
+ | **`.env`** | **只放机密**:令牌、密码、带凭据的连接串 | ❌ 绝不 |
223
+
224
+ `.env` 里只有这 6 项:
225
+
226
+ ```
227
+ IPCLICK_AUTH_TOKEN gRPC 鉴权令牌
228
+ IPCLICK_WEB_USER Web 管理端用户名
229
+ IPCLICK_WEB_PASSWORD Web 管理端密码
230
+ IPCLICK_PROXY_AUTH_KEY 代理账号
231
+ IPCLICK_PROXY_AUTH_PASSWORD 代理密码
232
+ IPCLICK_CLUSTER_SECRET 集群共享密钥(所有节点一致;每台机器的令牌由它派生)
233
+ ```
234
+
235
+ **机密写在 `ipclick.toml` 里仍然生效**——受信环境里图省事是合理的。只是启动时会
236
+ 点名提醒(`ipclick.toml` 通常要进版本库,机密会跟着进 git、备份、CI 日志)。
237
+ 确实想这么放就设 `[SECURITY].allow_secrets_in_config = true` 关掉提醒。
238
+ 两边都写时**环境变量优先**。
239
+
240
+ `ipclick config-info` 会逐项显示每个机密**来自哪里**(环境变量 / 配置文件 / 未配置)
241
+ ——配错了地方是最难自己发现的一类问题。
242
+
243
+ 查看当前**实际生效**的配置——TLS、鉴权、限流、浏览器引擎、运行模式一目了然:
244
+
245
+ ```bash
246
+ ipclick config-info
247
+ ```
248
+
249
+ ### Web 管理端
250
+
251
+ ```bash
252
+ ipclick run --web # 或 -w
253
+ ```
254
+
255
+ 启动时会把访问地址和登录信息打到控制台:
256
+
257
+ ```
258
+ ==============================================================
259
+ IPClick Web 管理端: http://127.0.0.1:9530/
260
+ 用户名: admin
261
+ 密码: N8mSKkdPbpiGzB128At3
262
+
263
+ ⚠️ 该密码为本次启动随机生成,重启后失效。
264
+ ==============================================================
265
+ ```
266
+
267
+ **没配密码就随机生成**,而不是给个 admin/admin 之类的默认值——默认弱口令是这类
268
+ 管理界面被打穿的头号原因。要固定下来用环境变量(推荐,密码不该进版本库):
269
+
270
+ ```bash
271
+ IPCLICK_WEB_USER=ops IPCLICK_WEB_PASSWORD=... ipclick run -w
272
+ ```
273
+
274
+ 或写进 `.env`,或配 `[WEB].username` / `password`。
275
+
276
+ #### 六个页面
277
+
278
+ 0.4 布局大改版:**左侧导航 + 主内容 + 右侧状态栏**的 CSS Grid,右上角有
279
+ **跟随系统 / 亮 / 暗** 三态主题切换(记在 `localStorage`,刷新不丢)。
280
+
281
+ | 页面 | 干什么 |
282
+ |---|---|
283
+ | **总览** `/` | 吞吐、成功率、在途与峰值、状态码分布、各适配器耗时与流量、集群拓扑、最近请求;右栏常驻服务端 / 安全 / 链路 / 限流 / 组件状态 |
284
+ | **请求流** `/trace` | **实时**看请求打进来。按状态 / 适配器 / URL 过滤,看目标站点排行与按天趋势 |
285
+ | **试一试** `/test` | 填一个网址(或直接**粘一条 curl**)就地发一次请求,看链路信息与返回的**源码**;可**点名**打到某个节点 |
286
+ | **组件** `/components` | 五个可选 extras 的两级安装状态,可以就地**安装 / 卸载 / 下载浏览器本体** |
287
+ | **配置** `/config` | 白名单内的行为配置可改,**写回 `ipclick.toml`**;可一键**生成凭据** |
288
+ | **节点** `/nodes` | 集群节点的增删改(**保存即生效**,不用重启)+ 就地**测试连接** |
289
+
290
+ 「试一试」走的是本进程 `TaskService` 的**同一条**代码路径——SSRF 准入、限流、
291
+ 以及开了转发时的分发全都照常生效,请求也会像真实请求一样出现在请求流里。
292
+ 另写一条只在页面上成立的路径毫无意义:那验证的就不是线上行为了。
293
+
294
+ **从 curl 导入**:浏览器 DevTools 里对着请求「复制为 cURL」,粘进输入框就能自动
295
+ 填好 URL / 方法 / 请求头 / 请求体。认不出的参数会**明确列出来**——静默丢掉一个
296
+ `-F` 比不支持它更糟。
297
+
298
+ **指定目标节点**:开了服务端转发时,「试一试」多一个"目标节点"下拉,选中后跳过
299
+ 负载均衡强制打到那一台。验证新加的机器配对没有,不用再反复点、靠轮询碰运气。
300
+
301
+ **测试连接**:`/nodes` 每行一个按钮,只验**连通性**与**集群内部鉴权**,不发业务
302
+ 请求。失败时区分"连不上"(查进程和网络)和"鉴权不通过"(核对各节点 `.env` 里的
303
+ `IPCLICK_CLUSTER_SECRET`)——这两种的排查方向完全相反。底层是 0.4 新增的
304
+ `Ping` RPC;对着 0.3 的节点调会收到 `UNIMPLEMENTED`,那恰恰说明鉴权是通的,
305
+ 页面会如实这么说,不会误报成鉴权失败。
306
+
307
+ **生成凭据**:`/config` 可以为鉴权令牌、Web 密码、集群共享密钥各生成一个随机值,
308
+ **只显示一次**(服务端不保存、不写进任何文件,取完即弃),并明确区分"本机独有"
309
+ 和"必须复制到所有其他节点的 `.env`"——集群共享密钥每台各自生成一个就全对不上了。
310
+
311
+ #### 能改什么、不能改什么
312
+
313
+ - **能改**(写回 toml,保留注释与格式,改动前留 `.bak`):超时、重试、按 host
314
+ 限流、日志级别、浏览器引擎与页面上限、链路记录、集群策略与节点列表、压缩策略。
315
+ - **不能改**:`[SECURITY]` 全部(令牌、TLS、SSRF 三个开关)、Web 自己的登录凭据、
316
+ 集群共享密钥与各节点 token、`[BROWSER].allow_scripts`。这些只展示,要改请编辑
317
+ 文件 / `.env` 后重启。
318
+ - **能装依赖**(0.4 放开,此前刻意不给):`/components` 可以装 / 卸 IPClick 自己
319
+ 声明的那五个 extras,以及下载浏览器本体。包名走**白名单常量**(表单里传来的
320
+ extra 必须能在清单里查到),命令以列表形式交给 `subprocess`(`shell=False`),
321
+ 并**绑定当前解释器**——不依赖 PATH 上的 `pip`,装到别的环境去比装不上更糟。
322
+ `pip` 和 `uv pip` 两条路自动探测(uv 建的 venv 默认不装 pip),两者都没有时
323
+ 明确报错并给出手动命令。长任务(`camoufox fetch` 要下约 1 GB)跑在后台,
324
+ 页面轮询状态。**卸载只卸 Python 包**,浏览器本体不动——界面会把它的路径和
325
+ 体积摆出来,让人自己决定,从网页递归删一个 GB 级目录风险和收益不成比例。
326
+
327
+ 这条线是刻意划的:这个服务能代任意 URL 发请求,一个能从网页关掉内网拦截、改掉
328
+ 令牌的管理端,等于给自己装了个跳板。而调超时、加节点、装一个自己声明过的可选
329
+ 依赖,这类操作改错了也只是性能或可用性问题,可逆、可见。
330
+
331
+ 写回是**定点文本替换**而不是"读成 dict 再整体 dump":那份配置里几乎每一项都带着
332
+ 解释(为什么默认是这个值、配错了什么症状),整体 dump 会把它们全抹掉。所以只换
333
+ 被改动那一行等号右边的值,行尾注释都保留。写入用临时文件 + `os.replace`,
334
+ 断电不会留下半个配置文件。
335
+
336
+ 前端**没有构建链路**:没有模板引擎、框架、打包工具,也没有任何外部资源。
337
+ 布局是纯 CSS Grid,交互是几十行原生 JS——0.3 时页面里一行 JS 都没有,0.4 加了
338
+ 三件没有 JS 就做不好的事:手动切主题、装依赖的状态轮询、请求流的**局部刷新**
339
+ (`<meta refresh>` 每 3 秒重载整页,滚动位置丢失、正在填的过滤条件被冲掉、
340
+ 页面白闪;现在只换那一块的 HTML,而且仍然由服务端渲染,不在 JS 里复制一份逻辑)。
341
+
342
+ CSP 里 `script-src` 用的是那两段内联脚本的 **sha256 哈希**,不是
343
+ `'unsafe-inline'`——脚本是源码里的常量,哈希能精确放行它们,同时让任何注入进来的
344
+ `<script>` 执行不了。
345
+
346
+ 安全措施:会话 cookie 带 `HttpOnly` + `SameSite=Strict`,所有写操作校验 CSRF
347
+ token,登录失败按来源 IP 限速(5 次后锁定 5 分钟,锁定期间正确密码也不放行),
348
+ 密码用常量时间比对,`X-Frame-Options: DENY` + 严格 CSP。
349
+
350
+ > ⚠️ 界面本身是**明文 HTTP**。默认只监听 `127.0.0.1`;要远程访问请用 SSH 隧道,
351
+ > 或放在做了 TLS 终止的反向代理之后。直接把 `[WEB].host` 改成 `0.0.0.0` 会让
352
+ > 登录密码在网络上裸奔(启动时会打告警)。
353
+
354
+ ### 配置优先级
355
+
356
+ 从高到低:
357
+
358
+ 1. 命令行参数 / 构造函数参数
359
+ 2. 环境变量
360
+ 3. 当前目录的 `.env`(**不覆盖**已存在的环境变量)
361
+ 4. `ipclick.toml` / `.ipclick.toml`,或 `--config` 指定的文件
362
+ 5. `~/.ipclick/config.toml`
363
+ 6. 包内默认配置
364
+
365
+ `.env` 排在真实环境变量之后是有意的:容器编排、CI、systemd 注入的变量必须能压过
366
+ 仓库里那个用于本地开发的 `.env`,否则部署环境会被开发默认值悄悄改掉。
367
+
368
+ **部署参数**(非机密)也可以用环境变量覆盖。它们**刻意不进 `.env` 模板**——那是
369
+ 放密钥的文件,这些是给容器编排 / K8s / systemd 注入的:
370
+
371
+ | 变量 | 覆盖 |
372
+ |---|---|
373
+ | `IPCLICK_HOST` / `IPCLICK_PORT` | `[SERVER].host` / `port` |
374
+ | `IPCLICK_MAX_WORKERS` | `[SERVER].max_workers` |
375
+ | `IPCLICK_MODE` | `[GENERAL].mode` |
376
+ | `IPCLICK_LOG_LEVEL` | `[LOG].level` |
377
+ | `IPCLICK_CLUSTER_SELF_ID` | `[CLUSTER].self_id` —— 多台机器共用一份配置时靠它区分身份 |
378
+
379
+ `.env` 支持 `KEY=VALUE`、`export KEY=VALUE`、`#` 注释、单双引号(双引号内可转义)。
380
+ 不支持多行值和 `${VAR}` 插值——需要那些请直接用环境变量。
381
+
382
+ ### 客户端使用
383
+
384
+ ```python
385
+ from ipclick import Downloader, HttpMethod
386
+
387
+ # 创建下载器实例(建议用 with,退出时自动关闭 gRPC 连接)
388
+ with Downloader() as downloader:
389
+ # 发送 GET 请求
390
+ response = downloader.get("https://httpbin.org/ip")
391
+ print(response.text)
392
+ print(response.json())
393
+
394
+ # 发送带参数的请求
395
+ response = downloader.request(
396
+ method=HttpMethod.GET,
397
+ url="https://httpbin.org/get",
398
+ headers={"User-Agent": "IPClick/1.0"},
399
+ params={"key": "value"},
400
+ timeout=30,
401
+ )
402
+
403
+ # 检查请求是否成功
404
+ if response.is_success():
405
+ print(f"请求成功,耗时: {response.elapsed_ms}ms")
406
+ else:
407
+ print(f"请求失败: {response.error}")
408
+ ```
409
+
410
+ `request()` 及各便捷方法不会因为网络问题抛异常,失败时返回 `status_code == -1`
411
+ 且 `error` 非空的 `DownloadResponse`。参数本身非法(如 URL 为空)会抛 `ValidationError`。
412
+
413
+ ### 使用代理
414
+
415
+ ```python
416
+ from ipclick import Downloader, ProxyConfig, HttpMethod
417
+
418
+ downloader = Downloader()
419
+
420
+ # 方式一:使用代理配置对象
421
+ proxy = ProxyConfig(
422
+ scheme="http",
423
+ host="proxy.example.com",
424
+ port=8080,
425
+ auth_key="username",
426
+ auth_password="password",
427
+ )
428
+
429
+ response = downloader.request(
430
+ method=HttpMethod.GET,
431
+ url="https://httpbin.org/ip",
432
+ proxy=proxy,
433
+ )
434
+
435
+ # 方式二:使用代理 URL 字符串
436
+ response = downloader.request(
437
+ method=HttpMethod.GET,
438
+ url="https://httpbin.org/ip",
439
+ proxy="http://user:pass@proxy.example.com:8080",
440
+ )
441
+
442
+ # 方式三:使用配置文件 [PROXY] 中的代理(设置 proxy=True)
443
+ response = downloader.request(
444
+ method=HttpMethod.GET,
445
+ url="https://httpbin.org/ip",
446
+ proxy=True,
447
+ )
448
+ ```
449
+
450
+ > 适配器默认**不读取**环境变量里的 `HTTP_PROXY` / `ALL_PROXY`。代理必须由调用方显式指定,
451
+ > 以免请求意外走到服务端所在机器的环境代理出口。
452
+
453
+ ### 流式下载(大文件)
454
+
455
+ `get()` 会把整个响应体读进内存;大文件请用 `stream()`,响应体分片到达,
456
+ 状态码和响应头在收到第一个字节前就可用:
457
+
458
+ ```python
459
+ with downloader.stream("https://example.com/big.zip") as resp:
460
+ print(resp.status_code, resp.content_length)
461
+ if not resp.is_success():
462
+ raise SystemExit(resp.error)
463
+
464
+ with open("big.zip", "wb") as f:
465
+ for chunk in resp: # 每片默认 64KB
466
+ f.write(chunk)
467
+
468
+ print(f"共 {resp.total_bytes} 字节,耗时 {resp.elapsed_ms}ms")
469
+ ```
470
+
471
+ 提前退出 `with` 会 cancel 掉这条 gRPC 流,服务端也就不再继续下载。
472
+
473
+ > 流式路径**不做重试** —— 重试要么得缓存已发出的分片、要么让调用方看到重复数据,
474
+ > 两者都不可接受。流式请求失败就是失败,由调用方决定是否重来。
475
+
476
+ ### 断点续传
477
+
478
+ `stream()` 中途断了要从头再来。大文件抓取里这最疼——下到 90% 断线,前面全白费。
479
+ `download_to_file()` 在流式通路上做 Range 续传:
480
+
481
+ ```python
482
+ from ipclick import Downloader
483
+ from ipclick.resume import download_to_file
484
+
485
+ with Downloader() as d:
486
+ result = download_to_file(
487
+ d, "https://example.com/big.zip", "big.zip",
488
+ max_attempts=5,
489
+ chunk_callback=lambda done, total: print(f"{done}/{total}"),
490
+ )
491
+ print(result.total_bytes, f"(发了 {result.attempts} 次请求)")
492
+ ```
493
+
494
+ 不落盘的版本是 `iter_resumable()`,直接产出分片。
495
+
496
+ **它为什么不只是"断了带 Range 重来"**:如果目标文件在两次请求之间变了,那样会把
497
+ 新版本的后半段接到旧版本的前半段上,拼出一个既不是旧版也不是新版、而且**校验不
498
+ 出来**的文件——比下载失败严重得多。所以每次续传都带 `If-Range`(ETag,没有就用
499
+ Last-Modified):资源没变服务端回 206 接着写,变了回 200 就丢掉重来。
500
+
501
+ 服务端不支持 Range(没有 `Accept-Ranges: bytes`)时自动退化成整体重下,不会悄悄
502
+ 产出损坏文件。`iter_resumable()` 遇到资源变化会直接抛错——分片已经交给调用方了
503
+ 收不回来,继续下去只会让它拿到两个版本的混合体。
504
+
505
+ ### 批量请求
506
+
507
+ 一次 RPC 处理多个任务,省掉逐个调用的往返开销:
508
+
509
+ ```python
510
+ from ipclick import DownloadTask
511
+
512
+ tasks = [DownloadTask(uuid=url, url=url) for url in urls]
513
+ for resp in downloader.batch(tasks):
514
+ print(resp.request_uuid, resp.status_code)
515
+ ```
516
+
517
+ 结果按**完成顺序**产出(不是提交顺序),所以要靠 `request_uuid` 对应回请求。
518
+ 单个任务失败不影响其他任务,失败信息在各自响应的 `error` 里。
519
+ 并发度受服务端 `[SERVER].max_workers` 约束。
520
+
521
+ ### 异步客户端
522
+
523
+ ```python
524
+ from ipclick.aio import AsyncDownloader
525
+
526
+ async with AsyncDownloader() as d:
527
+ resp = await d.get("https://example.com")
528
+
529
+ stream = await d.stream("https://example.com/big.zip")
530
+ async with stream:
531
+ async for chunk in stream:
532
+ ...
533
+
534
+ async for resp in d.batch(tasks):
535
+ ...
536
+ ```
537
+
538
+ 参数含义、默认值、异常类型与同步版完全一致(两者共用同一套配置与任务组装逻辑)。
539
+
540
+ ### 单机还是集群,由配置决定
541
+
542
+ `create_client()` 按 `[GENERAL].mode` 返回 `Downloader` 或 `ClusterDownloader`,
543
+ 两者的请求接口一致,调用方通常不用关心:
544
+
545
+ ```python
546
+ from ipclick import create_client
547
+
548
+ with create_client() as d: # mode = standalone / cluster / auto
549
+ resp = d.get("https://example.com")
550
+ ```
551
+
552
+ `mode = "cluster"` 却没配任何节点会**直接报错**,不会静默退回单机——那会让你
553
+ 以为集群生效了,实际所有流量都打在一个节点上、也没有故障转移。
554
+
555
+ ### 客户端重试
556
+
557
+ 适配器内部的重试解决的是"目标站点抖了";`[CLIENT]` 这组解决的是"我们自己的
558
+ 服务端抖了":
559
+
560
+ ```toml
561
+ [CLIENT]
562
+ rpc_max_retries = 2
563
+ rpc_retry_backoff = 0.5
564
+ ```
565
+
566
+ **只有 `UNAVAILABLE` 会重试**——那意味着连接压根没建起来,请求没到过服务端,
567
+ 重发是安全的。`DEADLINE_EXCEEDED` 刻意不重试:请求可能已经在服务端执行了,
568
+ 只是回复没赶上,这时重发一个 POST 就是重复下单。
569
+
570
+ ### 使用全局下载器
571
+
572
+ ```python
573
+ from ipclick import downloader
574
+
575
+ # 使用默认的全局下载器实例(首次使用时才真正建立连接)
576
+ response = downloader.get("https://httpbin.org/ip")
577
+ print(response.text)
578
+ ```
579
+
580
+ ### 指定适配器
581
+
582
+ ```python
583
+ from ipclick import IPClickAdapter, downloader
584
+
585
+ response = downloader.get("https://httpbin.org/get", adapter=IPClickAdapter.NIQUESTS)
586
+ ```
587
+
588
+ | 适配器 | 反检测 | HTTP/2 · 3 | JS 渲染 | 安装 |
589
+ |---|---|---|---|---|
590
+ | `curl_cffi`(默认) | TLS 指纹伪装 | HTTP/2 | ❌ | 内置 |
591
+ | `niquests` | ❌ | **HTTP/3** | ❌ | `ipclick[niquests]` |
592
+ | `browser` | 由服务端引擎决定 | — | ✅ | 见下 |
593
+ | `camoufox` | Firefox + 完整指纹伪装 | — | ✅ | `ipclick[camoufox]` |
594
+ | `patchright` | Chromium,Playwright 反检测分支 | — | ✅ | `ipclick[patchright]` |
595
+ | `playwright` | ❌(原版,最稳) | — | ✅ | `ipclick[playwright]` |
596
+ | `DrissionPage` | Chromium,CDP 直连 | — | ✅ | `ipclick[drissionpage]` |
597
+
598
+ > `requests` 适配器在 0.2.3 里有过,之后被 `niquests` 取代——后者 API 完全一致
599
+ > 但支持 HTTP/2 和 HTTP/3。请求 `requests` 会报错并给出迁移提示。
600
+
601
+ ### 浏览器渲染
602
+
603
+ 页面内容全靠 JS 生成时,HTTP 适配器拿到的 HTML 里什么都没有。浏览器适配器会起
604
+ 一个真实浏览器把页面跑完,返回渲染后的 DOM:
605
+
606
+ ```python
607
+ from ipclick import Downloader, IPClickAdapter
608
+
609
+ with Downloader() as d:
610
+ resp = d.get(
611
+ "https://example.com/spa",
612
+ # BROWSER = "渲染就行,引擎由服务端定"
613
+ adapter=IPClickAdapter.BROWSER,
614
+ # 声明式的等待与渲染选项
615
+ automation_config='{"wait_for_selector": "#content", "scroll_to_bottom": true}',
616
+ )
617
+ print(resp.text) # 渲染后的 DOM
618
+ ```
619
+
620
+ #### 引擎选择
621
+
622
+ 四个引擎。前三个都走 Playwright API,共用同一套渲染代码;DrissionPage 走 CDP
623
+ 直连,是另一套实现,但对外契约一致。
624
+
625
+ | 引擎 | 内核 | 特点 | 额外准备 |
626
+ |---|---|---|---|
627
+ | `camoufox` | Firefox | 反检测最彻底,自带完整指纹伪装 | `python -m camoufox fetch` |
628
+ | `patchright` | Chromium | Playwright 反检测分支,API 全兼容 | `patchright install chromium` |
629
+ | `playwright` | 三种内核 | 原版,最稳、行为最可预期 | `playwright install chromium` |
630
+ | `DrissionPage` | Chromium | CDP 直连本机 Chrome,不额外下浏览器 | 本机已装 Chrome/Chromium |
631
+
632
+ `[BROWSER].engine` 决定 `adapter=BROWSER` 用哪个,默认 `auto` **按平台选**:
633
+
634
+ - **Windows** → `DrissionPage`(基本都装了 Chrome,最省事)
635
+ - **Linux / macOS** → `camoufox`(自带 Firefox,无头服务器上更好伺候)
636
+
637
+ ```toml
638
+ [BROWSER]
639
+ engine = "auto" # 或 camoufox / patchright / playwright / drissionpage
640
+ ```
641
+
642
+ 客户端也可以直接点名某个引擎(`adapter=IPClickAdapter.CAMOUFOX` 等),这时
643
+ `[BROWSER].engine` 不生效——点名就是点名。
644
+
645
+ 各引擎的差异,挑要紧的说:
646
+
647
+ - **camoufox 自己管指纹**。它会生成一整套自洽的 UA / 屏幕 / 字体 / WebGL 指纹,
648
+ 所以 `[BROWSER].user_agent` 和 `viewport` 对它无效(强行覆盖只会和它给的指纹
649
+ 自相矛盾,反而更容易被认出来)。要调就用它认识的 `locale` / `humanize` / `geoip`。
650
+ - **camoufox 更吃内存**。Firefox 加一整套扩展,单个 context 的开销明显高于
651
+ Chromium 系。内存 ≤4GB 的机器建议把 `max_pages` 设成 1~2,否则会开始换页,
652
+ 请求从几秒变成几分钟。
653
+ - **DrissionPage 不支持按请求指定代理**。它的代理是浏览器进程级的,启动后改不了,
654
+ 所以请求里带 `proxy=` 会直接抛 `ValidationError` 而不是被默默忽略——对一个代理
655
+ 服务来说,从错误的出口 IP 发出去比报错严重得多。要用代理请配
656
+ `[BROWSER.proxy].gateway`,或换 camoufox / patchright / playwright。
657
+ - **DrissionPage 的隔离弱一些**。它没有 BrowserContext,同一浏览器里所有 tab 共享
658
+ profile。这里用 `--incognito` 启动、每请求一个用完即关的 tab、并在关闭前清一次
659
+ cookie,但仍不如 Playwright 的 context 干净。
660
+
661
+ `automation_config` 支持的键:
662
+
663
+ | 键 | 说明 |
664
+ |---|---|
665
+ | `wait_until` | `load` / `domcontentloaded` / `networkidle` / `commit` |
666
+ | `wait_for_selector` | 等某个元素出现 |
667
+ | `wait_for_timeout` | 额外固定等待(毫秒) |
668
+ | `scroll_to_bottom` | 滚到底触发懒加载(高度不再变化即停,最多 20 轮) |
669
+ | `screenshot` | 返回整页 PNG(此时 `content` 是图片字节,`text` 为空) |
670
+ | `block_resources` | 覆盖默认拦截的资源类型 |
671
+
672
+ 浏览器本身带来的限制(不是没写,是做不到):
673
+
674
+ - **只支持 GET**:浏览器导航就是 GET。带请求体或用其他方法会抛 `ValidationError`。
675
+ - **不能禁用重定向**:`allow_redirects=False` 会抛 `ValidationError`,而不是被静默忽略。
676
+ - **流式无意义**:渲染必须等页面跑完才有结果。
677
+
678
+ > ⚠️ `automation_script`(在页面里执行任意 JS)默认**关闭**。页面内的 JS 会自己
679
+ > 发请求,`[SECURITY]` 那套 URL 策略对它完全不起作用——放开它等于把 SSRF 防线
680
+ > 让开一整条。确认调用方全部可信后,再设 `[BROWSER].allow_scripts = true`。
681
+ > 脚本返回值通过响应头 `x-ipclick-script-result` 带回(JSON 编码)。
682
+ >
683
+ > 另外,渲染本身就会加载页面引用的子资源,同样不经过 URL 策略。介意的话把
684
+ > `block_resources` 配严一些,并打开 `[SECURITY].block_private_networks`。
685
+
686
+ ### TLS / mTLS
687
+
688
+ gRPC 链路默认是**明文**的。令牌鉴权解决的是"谁能用",但令牌本身在不受信任的
689
+ 网络上会被同网段原样嗅探到——鉴权、限流、SSRF 防护全都建在这条明文通道上。
690
+ 公网或跨机房部署务必打开 TLS。
691
+
692
+ ```toml
693
+ [SECURITY.tls]
694
+ enabled = true
695
+ cert_file = "/etc/ipclick/server.crt" # 服务端证书
696
+ key_file = "/etc/ipclick/server.key"
697
+ # 下面两项一起构成 mTLS:服务端反过来验证客户端证书
698
+ ca_file = "/etc/ipclick/ca.crt"
699
+ require_client_cert = true
700
+ ```
701
+
702
+ 客户端用同一个配置节,各取所需:
703
+
704
+ ```toml
705
+ [SECURITY.tls]
706
+ enabled = true
707
+ ca_file = "/etc/ipclick/ca.crt" # 验证服务端;留空则用系统信任库
708
+ cert_file = "/etc/ipclick/client.crt" # 服务端要求 mTLS 时才需要
709
+ key_file = "/etc/ipclick/client.key"
710
+ ```
711
+
712
+ 也可以在代码里直接给:
713
+
714
+ ```python
715
+ from ipclick import Downloader
716
+ from ipclick.tls import TLSSettings
717
+
718
+ tls = TLSSettings(enabled=True, ca_file="ca.crt", cert_file="client.crt", key_file="client.key")
719
+ with Downloader(tls=tls) as d:
720
+ resp = d.get("https://example.com")
721
+ ```
722
+
723
+ 几点值得说明:
724
+
725
+ - **TLS 和令牌是两回事,互不替代**。证书回答"这条连接可不可信",令牌回答"这个
726
+ 调用方是谁"。两者可以同时开。
727
+ - **`require_client_cert = true` 必须同时配 `ca_file`**,否则任何自签名证书都能
728
+ 通过,mTLS 形同虚设。这种组合会在启动时直接报错,不会静默降级。
729
+ - **集群探活也走 TLS**。服务端开了 TLS 而探活还是明文的话,集群会把健康节点
730
+ 全判成挂了。
731
+ - **证书签给域名、却用 IP 连**时,用 `server_name_override` 对上名字。
732
+ 它会跳过主机名匹配这道校验,只应在自签名证书的内网环境里用。
733
+ - 服务端监听非回环地址却没开 TLS 时,启动日志会打显著告警。
734
+
735
+ 自签名证书(内网自用)可以这样生成:
736
+
737
+ ```bash
738
+ openssl req -x509 -newkey rsa:4096 -nodes -keyout ca.key -out ca.crt -days 3650 -subj "/CN=ipclick-ca"
739
+ openssl req -newkey rsa:4096 -nodes -keyout server.key -out server.csr -subj "/CN=ipclick"
740
+ openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 825 \
741
+ -extfile <(printf "subjectAltName=DNS:ipclick,IP:10.0.0.5")
742
+ ```
743
+
744
+ > gRPC 校验的是证书里的 **SAN**,不是 CN——只写 `-subj "/CN=..."` 而没有
745
+ > `subjectAltName` 的证书,在现代 TLS 栈里一律会被拒。
746
+
747
+ ### 按 host 限流
748
+
749
+ 服务端会代替调用方向外发请求。不加约束的话,一个批量任务就能对同一个站点瞬间
750
+ 打出几十上百个并发连接——对方大概率先限速再封 IP。两道闸门,都是**按 host 独立**
751
+ 计数,默认都关闭:
752
+
753
+ ```toml
754
+ [DOWNLOADER.concurrency]
755
+ # 单个 host 同时最多几个在途请求(0 = 不限制)。超出的在服务端排队。
756
+ per_host_max_concurrent = 4
757
+ # 等不到额度的上限(秒),超时返回 RESOURCE_EXHAUSTED
758
+ per_host_wait_timeout = 30
759
+
760
+ [DOWNLOADER.rate_limit]
761
+ # 单个 host 每秒最多发起几个请求(0 = 不限制)。可以是小数:0.5 = 两秒一个。
762
+ per_host_qps = 2
763
+ # 突发额度(令牌桶容量)。0 = 取 ceil(per_host_qps)
764
+ per_host_burst = 0
765
+ ```
766
+
767
+ 并发用信号量(硬保证),速率用令牌桶(允许突发)。取额度的顺序是**先并发槽、
768
+ 后令牌**:并发槽是要保证的上限,先拿住;令牌紧挨着真正的 HTTP 请求再取,这样
769
+ "每秒 N 个"限的是真实发出去的请求,而不是进入排队的请求。
770
+
771
+ 限流对 `Send` / `SendStream` / `SendBatch` 都生效。流式请求的额度**持有到整条流
772
+ 结束**——只在建流时占额度等于没限住。
773
+
774
+ **集群里的限额**:额度在**收到任务的那个节点**上计算。用服务端转发模式时所有
775
+ 任务都从入口节点进来,那一台算出来的就是全局额度,因此**不需要 Redis 之类的
776
+ 中间件**(0.3 起已移除 `backend = "redis"`)。用客户端分发模式时每个节点各算
777
+ 各的,N 个节点就是 N 倍实际并发——需要全局限额就改用服务端转发。
778
+
779
+ > ⚠️ **这会占用 worker 线程。** 服务端是一请求一线程,排队等额度会占着那个
780
+ > 线程。`per_host_max_concurrent` 设得很小、同时又有大量请求打向同一个 host 时,
781
+ > 线程池会被排队的请求占满,其他 host 的请求也跟着饿死。所以等待有硬性上限
782
+ > (`per_host_wait_timeout`),并且 `[SERVER].max_workers` 要留够余量。
783
+ > 真要做到"排队不占线程",得把服务端改成 `grpc.aio`,那是另一件事。
784
+
785
+ 超时会抛 `HostLimitTimeout`,而不是返回 `status_code == -1`:这是本机的限流策略
786
+ 生效了,不是网络故障,返回 -1 会让人去排查目标站点。
787
+
788
+ ### 集群
789
+
790
+ 有**两种**形态,用 `[CLUSTER].forward` 选。两种都保留,因为它们适合的场景不同。
791
+
792
+ #### 形态一:客户端分发(默认,`forward = "off"`)
793
+
794
+ 调用方自己持有全部节点地址,直连每一台。少一跳、不占入口带宽,前提是调用方能连
795
+ 到所有节点。
796
+
797
+ ```python
798
+ from ipclick.cluster import ClusterDownloader
799
+
800
+ with ClusterDownloader() as d: # 节点取自 [CLUSTER].nodes
801
+ resp = d.get("https://example.com")
802
+ print(d.snapshot()) # 各节点健康状态
803
+ ```
804
+
805
+ #### 形态二:服务端转发(`forward = "on"`)
806
+
807
+ 调用方只需要知道**一个**地址。入口节点按策略挑节点:挑到自己就本地干,挑到别人
808
+ 就把请求原样转过去,拿到结果再回给调用方。适合调用方在集群外,或者不想让调用方
809
+ 感知拓扑。**不需要 Redis 之类的中间件**——任务从哪进来,分发和限流就在那一台算。
810
+
811
+ ```toml
812
+ [CLUSTER]
813
+ forward = "on"
814
+ self_id = "node-a" # 留空则按 [SERVER] 的监听端口 + 本机地址自动识别
815
+ load_balancer = "round_robin" # round_robin / random / weight
816
+ max_failover = 2 # 转发失败最多换几个节点
817
+ forward_timeout = 0 # 0 = 按任务超时 ×(重试次数+1) + 15s 余量推算
818
+
819
+ nodes = [
820
+ { id = "node-a", address = "10.0.0.1:9527" }, # 本机也要列进来才会分到活
821
+ { id = "node-b", address = "10.0.0.2:9527" },
822
+ { id = "node-c", address = "10.0.0.3:9527" },
823
+ ]
824
+ ```
825
+
826
+ 五台机器可以用**完全相同**的这份 `ipclick.toml` 和**完全相同**的 `.env`,只靠
827
+ 一个环境变量区分身份:
828
+
829
+ ```bash
830
+ IPCLICK_CLUSTER_SELF_ID=node-b ipclick run
831
+ ```
832
+
833
+ 四条值得知道的性质:
834
+
835
+ 1. **只转一跳。** 转发时带 `ipclick-forwarded` metadata,收到带这个标记的请求
836
+ 一律本地执行,所以环路在协议层面就不可能出现。
837
+ 2. **任意节点都能当入口。** 配置对等,谁被访问谁就是入口。平时只把流量打给 A,
838
+ A 挂了直接把客户端指向 B 即可,不用改任何配置。
839
+ 3. **入口自己也干活**(只要它在 `nodes` 里)。子节点全挂时入口会自己兜底执行,
840
+ 而不是让请求失败。
841
+ 4. **流式下载不转发**,永远由收到请求的节点自己执行——把每个分片再经入口中转一次
842
+ 会让入口带宽翻倍。要让子节点出流量就用客户端分发模式直连它。
843
+
844
+ 批量(`SendBatch`)是转发模式最划算的场景:一次调用会被摊到多台机器上。
845
+
846
+ #### 节点间鉴权:一个共享密钥,派生出各不相同的令牌
847
+
848
+ 最直觉的做法是给每台机器手写一个令牌、再在每台机器的节点列表里把其他机器的令牌
849
+ 抄一遍——5 台机器就是 20 份抄写,加一台要改 6 个文件,改漏一处的症状是运行时
850
+ `UNAUTHENTICATED`,很难定位。
851
+
852
+ 所以改成派生:
853
+
854
+ ```
855
+ node_token = base64url( HMAC-SHA256(cluster_secret, "ipclick-node:" + node_id) )
856
+ ```
857
+
858
+ 所有机器的 `.env` 里放**同一个** `IPCLICK_CLUSTER_SECRET`:
859
+
860
+ ```
861
+ IPCLICK_CLUSTER_SECRET=(用 openssl rand -base64 32 生成,复制到每台机器)
862
+ ```
863
+
864
+ * 每个节点用自己的 `self_id` 算出自己该接受哪个令牌。
865
+ * 转发方用目标的 `node_id` 算出该带哪个令牌。两边算的必然一致。
866
+ * 令牌**各不相同**——拿到子节点 B 的令牌不等于能调 C,也不等于拿到共享密钥
867
+ (HMAC 单向)。这是"每节点独立令牌"和"全集群一个口令"的差别所在。
868
+ * 加一台机器只需要在节点列表里加一行,不需要发放任何新凭据。
869
+
870
+ 需要对接令牌不由这里管理的节点时,在那一项上写 `token = "..."` 覆盖派生值。
871
+ 没配共享密钥也能跑(内网全互信是合法选择),但启动时会明确警告。
872
+
873
+ #### 共通部分
874
+
875
+ ```toml
876
+ [CLUSTER]
877
+ probe_interval = 10 # 健康探测间隔(秒)
878
+ failure_threshold = 3 # 连续失败多少次判定摘除
879
+ recovery_threshold = 2 # 连续成功多少次判定恢复
880
+ ```
881
+
882
+ 摘除与恢复都用**连续计数阈值**而不是单次结果:一次网络抖动就摘节点会让流量反复
883
+ 横跳。探测走 `grpc.health.v1`,也就是 P2 那套标准健康检查。
884
+
885
+ 故障转移只在节点级故障(`UNAVAILABLE` / 超时 / 过载)时换节点。参数错误、鉴权
886
+ 失败换个节点还是一样的结果,直接把错误还给调用方,不浪费尝试次数。
887
+
888
+ **节点发现**(可选):节点列表默认写死在配置里,扩缩容要改配置重启每一个客户端。
889
+ 换成 DNS 发现就不用了:
890
+
891
+ ```toml
892
+ [CLUSTER.discovery]
893
+ mode = "dns"
894
+ dns_name = "ipclick.default.svc.cluster.local" # K8s headless Service / Consul DNS
895
+ port = 9527
896
+ refresh_interval = 30
897
+ ```
898
+
899
+ 解析出的每条 A/AAAA 记录就是一个节点,后台随探活一起定期重解析。刷新时按地址
900
+ 复用已有节点的健康状态——直接重建会把连续计数清零,那样"连续 2 次失败才摘除"
901
+ 永远达不到,熔断和恢复双双失效。DNS 解析失败时沿用上一次的列表,不会把集群摘空。
902
+
903
+ **只读状态页**(可选):
904
+
905
+ ```python
906
+ from ipclick.cluster import ClusterDownloader
907
+ from ipclick.cluster.status_page import StatusPageServer
908
+
909
+ with ClusterDownloader() as d:
910
+ StatusPageServer(d.snapshot).start(port=8080) # 默认只监听 127.0.0.1
911
+ ```
912
+
913
+ `/` 是人看的 HTML 表格,`/api/nodes` 是机器读的 JSON。**刻意做成只读**:这个
914
+ 服务已经能代任意 URL 发请求了,再给它一个能改配置的网页等于又开一个高价值攻击
915
+ 面,而状态页通常跑在比 gRPC 端口设防更少的地方。运维变更走配置文件。
916
+
917
+ ## 📂 项目结构
918
+
919
+ ```
920
+ IPClick/
921
+ ├── src/
922
+ │ └── ipclick/
923
+ │ ├── __init__.py # 包入口,导出公共 API
924
+ │ ├── __main__.py # 模块入口
925
+ │ ├── sdk.py # 同步 SDK 客户端
926
+ │ ├── aio.py # 异步 SDK 客户端(grpc.aio)
927
+ │ ├── server.py # gRPC 服务端实现
928
+ │ ├── auth.py # 服务端令牌鉴权拦截器
929
+ │ ├── health.py # grpc.health.v1 健康检查
930
+ │ ├── trace.py # 链路记录(内存环形缓冲 + 可选 SQLite)
931
+ │ ├── compression.py # 请求压缩策略(自动化脚本压缩收益最大)
932
+ │ ├── components.py # 五个可选 extras 的清单、两级安装状态、分类
933
+ │ ├── limiter.py # 按 host 的并发与 QPS 闸门
934
+ │ ├── exceptions.py # 异常层次
935
+ │ ├── py.typed # 类型标注标记
936
+ │ ├── adapters/ # 下载器适配器
937
+ │ │ ├── base.py # 适配器基类、重试装饰器、脚本与导航错误分类
938
+ │ │ ├── settings.py # [DOWNLOADER] 配置
939
+ │ │ ├── browser_settings.py # [BROWSER] 配置
940
+ │ │ ├── browser_engines.py # 引擎选择、两级安装检测、启动
941
+ │ │ ├── curl_cffi_adapter.py # 默认适配器(唯一有指纹伪装的)
942
+ │ │ ├── niquests_adapter.py # HTTP/2 + HTTP/3
943
+ │ │ ├── browser_adapter.py # playwright / patchright / camoufox
944
+ │ │ ├── drission_adapter.py # DrissionPage(CDP 直连)
945
+ │ │ └── registry.py # 适配器注册表(含已移除适配器的指引)
946
+ │ ├── cluster/ # 集群
947
+ │ │ ├── node.py # 节点模型与 [CLUSTER] 配置
948
+ │ │ ├── balancer.py # 轮询 / 随机 / 加权
949
+ │ │ ├── pool.py # 节点池与健康探测
950
+ │ │ ├── client.py # ClusterDownloader(客户端分发 + 故障转移)
951
+ │ │ ├── forwarder.py # ForwardingTaskService(服务端转发)
952
+ │ │ ├── tokens.py # 由共享密钥派生每节点独立令牌
953
+ │ │ ├── discovery.py # static / dns 节点发现
954
+ │ │ ├── probe.py # 节点探测:连得上吗、鉴权配对吗
955
+ │ │ └── status_page.py # 只读状态页
956
+ │ ├── web/ # Web 管理端(仅标准库,无前端构建链路)
957
+ │ │ ├── server.py # HTTP 服务、会话、CSRF、路由、CSP
958
+ │ │ ├── pages.py # 各页面的数据与操作
959
+ │ │ ├── templates.py # HTML 渲染(每处插值都要过 esc)
960
+ │ │ ├── assets.py # CSS 与 JS(CSP 用脚本哈希放行)
961
+ │ │ ├── installer.py # 装 / 卸可选组件(白名单 + 后台任务)
962
+ │ │ ├── curl_parser.py # 把 curl 命令解析成「试一试」表单
963
+ │ │ ├── editable.py # 可编辑配置项白名单
964
+ │ │ └── auth.py # 凭据、会话、登录限速
965
+ │ ├── cli/ # 命令行工具
966
+ │ ├── config_loader/ # 配置加载
967
+ │ │ ├── loader.py # 优先级与环境变量覆盖
968
+ │ │ ├── dotenv.py # .env 解析
969
+ │ │ ├── placeholders.py # 路径里的 {port} 占位符
970
+ │ │ └── writer.py # 写回 toml(定点替换,保留注释)
971
+ │ ├── configs/ # 默认配置文件
972
+ │ ├── dto/ # 数据传输对象
973
+ │ │ ├── models.py # 数据模型定义
974
+ │ │ ├── response.py # 统一响应对象
975
+ │ │ └── proto/ # Protobuf 定义与生成脚本
976
+ │ ├── services/ # gRPC 服务实现
977
+ │ └── utils/ # 工具模块(日志、配置、URL 安全校验等)
978
+ │ └── module_probe.py # 不执行模块代码的"装没装"探测(find_spec)
979
+ ├── docker/ # Docker 相关文件
980
+ ├── examples/ # 示例代码
981
+ ├── tests/ # 测试代码
982
+ ├── pyproject.toml # 项目配置
983
+ └── README.md
984
+ ```
985
+
986
+ ## ⚙️ 配置说明
987
+
988
+ 配置文件为 TOML 格式,加载顺序(后者覆盖前者):
989
+
990
+ 1. 包内默认配置 `src/ipclick/configs/default_config.toml`
991
+ 2. 用户目录配置 `~/.ipclick/config.toml`
992
+ 3. `--config` 指定的文件,或当前目录下的 `ipclick.toml` / `.ipclick.toml`
993
+ 4. 环境变量 `IPCLICK_HOST`、`IPCLICK_PORT`(优先级最高)
994
+
995
+ ### 服务端配置
996
+
997
+ | 配置项 | 说明 | 默认值 |
998
+ |----------------------|-------------------|---------|
999
+ | `SERVER.host` | 服务绑定地址 | `[::]` |
1000
+ | `SERVER.port` | 服务端口 | `9527` |
1001
+ | `SERVER.max_workers` | 最大工作线程数(每请求占用一个) | `100` |
1002
+
1003
+ ### 鉴权
1004
+
1005
+ 服务端默认**不鉴权** —— 任何能连到端口的调用方都能使用本服务,启动时会打一条告警。
1006
+ 生产部署请务必配置令牌。
1007
+
1008
+ 推荐用环境变量提供,不要把密钥写进配置文件:
1009
+
1010
+ ```bash
1011
+ # 服务端
1012
+ IPCLICK_AUTH_TOKEN='用 openssl rand -hex 32 生成的令牌' ipclick run
1013
+ ```
1014
+
1015
+ ```python
1016
+ # 客户端:三种方式,优先级从高到低
1017
+ Downloader(token="...") # 1. 显式传参
1018
+ # 2. 环境变量 IPCLICK_AUTH_TOKEN
1019
+ # 3. 配置文件 [SECURITY].auth_token
1020
+ ```
1021
+
1022
+ 轮换令牌时可以配置多个,新旧并存,不必停机:
1023
+
1024
+ ```toml
1025
+ [SECURITY]
1026
+ auth_token = ["新令牌", "旧令牌"]
1027
+ ```
1028
+
1029
+ 令牌通过 gRPC 标准的 `authorization: Bearer <token>` metadata 头传输,
1030
+ 任何语言的 gRPC 客户端都能对接。校验使用常量时间比较;令牌不会出现在任何日志里。
1031
+
1032
+ 鉴权失败抛 `AuthenticationError`(**不是** `TransportError`)——
1033
+ 令牌错了重试多少次都没用,属于配置问题,不该被当成一次网络失败吞掉。
1034
+
1035
+ > 鉴权解决的是"**谁**能用",下面的 SSRF 防护解决的是"能打到**哪儿**"。
1036
+ > 两者互相不能替代,公网部署两个都要开。
1037
+
1038
+ ### 健康检查
1039
+
1040
+ 服务端实现了 gRPC 标准的 `grpc.health.v1` 协议,Kubernetes 探针、
1041
+ `grpc_health_probe`、服务网格都能直接对接。**该接口免鉴权**。
1042
+
1043
+ ```bash
1044
+ ipclick health # 查总体状态
1045
+ ipclick health --port 9527
1046
+ ipclick health --service task.TaskService
1047
+ ```
1048
+
1049
+ 健康时退出码 `0`,否则 `1` —— 可直接用于 Docker `HEALTHCHECK` 或就绪探针。
1050
+
1051
+ Kubernetes:
1052
+
1053
+ ```yaml
1054
+ readinessProbe:
1055
+ grpc: { port: 9527 }
1056
+ livenessProbe:
1057
+ grpc: { port: 9527 }
1058
+ ```
1059
+
1060
+ **优雅停机会先把状态置为 `NOT_SERVING` 再停服务**,此时端口仍在监听、在途请求
1061
+ 继续跑完,但负载均衡器已经可以据此摘除本节点。反过来做的话是先掐连接、
1062
+ 上游才后知后觉。
1063
+
1064
+ 可通过 `[MONITOR].health_check = false` 关闭(关闭后该服务不注册,
1065
+ 探活会收到 `UNIMPLEMENTED`)。
1066
+
1067
+ 在代码里探活(P4 集群的节点探活也用这个):
1068
+
1069
+ ```python
1070
+ from ipclick.health import check_health
1071
+
1072
+ healthy, status = check_health("10.0.0.1:9527", timeout=3)
1073
+ ```
1074
+
1075
+ ### 链路记录与统计
1076
+
1077
+ 每个请求处理完记一条:谁处理的、用了哪个适配器、状态码、耗时、重试几次、
1078
+ 在限流闸门里排了多久。不依赖任何第三方服务。
1079
+
1080
+ ```toml
1081
+ [TRACE]
1082
+ memory_size = 500 # 内存环形缓冲,始终开启,零磁盘
1083
+ sqlite_enabled = false # 落盘(默认关)。开了 Web 端才能查历史与跨天统计
1084
+ sqlite_path = "ipclick-trace.{port}.db" # {port} = 运行时实际监听端口
1085
+ retention_days = 30 # 超期的每小时清一次;0 = 永久
1086
+ only_errors = false # 只记失败请求(成功量极大时省磁盘)
1087
+ record_url = true # 关掉后只记 host
1088
+ ```
1089
+
1090
+ 两层结构是刻意的:
1091
+
1092
+ * **内存环形缓冲**始终开着,回答"刚才发生了什么"。上限固定、零磁盘、进程重启即丢。
1093
+ * **SQLite** 默认关,回答"上周三那批任务成功率多少"。WAL 模式下读不阻塞写。
1094
+
1095
+ 写盘走**单写线程 + 有界队列**:SQLite 同一时刻只允许一个写者,让 N 个 gRPC
1096
+ worker 各自去写等于在热路径上抢锁。请求线程只做一次 `put_nowait`,队列满了就丢
1097
+ 并计数——可观测性数据绝不能反压业务请求。**丢弃条数在 Web 端显眼地显示**,
1098
+ 静默丢弃会让"没有记录"和"没发生过"混为一谈。
1099
+
1100
+ 响应里也带一份链路信息(`TaskResp.trace`):
1101
+
1102
+ ```python
1103
+ resp = downloader.get("https://example.com")
1104
+ print(resp.raw.trace.node_id) # 真正执行的节点(集群转发时是关键信息)
1105
+ print(resp.raw.trace.adapter) # 实际用的适配器(browser 会解析成具体引擎名)
1106
+ print(resp.raw.trace.attempts) # 内部重试了几次
1107
+ print(resp.raw.trace.forwarded) # 是否经由其他节点转发
1108
+ print(resp.raw.trace.queued_ms) # 在限流闸门里排了多久
1109
+ ```
1110
+
1111
+ 它刻意**不含**请求头、cookie、请求体、代理串——那些里面有机密,而这些记录是要
1112
+ 在 Web 端展示的。(0.3 之前这里回传的是整个原始请求 `original_request`,代理账号
1113
+ 密码会随之泄漏,且响应体积翻倍。该字段已移除,编号保留不复用。)
1114
+
1115
+ > 为什么不用 Prometheus:它按设计不保留单条记录(把 URL 放进标签会造成基数
1116
+ > 爆炸),所以回答不了"我刚才那个请求为什么 403"。而这个库的使用场景恰恰是后者。
1117
+ > 聚合数字这边也有——进程内计数器实时累加,代价只是重启归零。
1118
+
1119
+ #### 同一目录起多个实例:`{port}` 占位符
1120
+
1121
+ `sqlite_path` 与 `[LOG].output` 的路径值支持 `{port}` 占位符,替换成**运行时实际
1122
+ 生效**的监听端口(`--port` 覆盖之后的那个,不是配置文件里的原始值):
1123
+
1124
+ ```toml
1125
+ [TRACE]
1126
+ sqlite_path = "ipclick-trace.{port}.db" # -> ipclick-trace.9528.db
1127
+
1128
+ [LOG]
1129
+ output = "logs/{port}/app.log" # 占位符在路径中间也行
1130
+ ```
1131
+
1132
+ 不这么做的话,同一目录用 `--port` 起的多个实例会**静默**写同一个库、抢同一个日志
1133
+ 文件:不报错、不提示,用户以为在看单个实例的数据,实际是多进程合并的结果。
1134
+ (真撞上了也会打一条明确的告警,但那是兜底,不是解法。)
1135
+
1136
+ 用占位符而不是在代码里"自动加端口后缀"是刻意的:新用户从模板拿到的默认值就带
1137
+ `{port}`,天然按端口分离;已有部署里自己写死的路径**一个字符都不会被改**,否则
1138
+ 旧日志和 trace 数据看起来就像丢了。而且这条规则写在 toml 里是**看得见的**。
1139
+
1140
+ Web 端口也有了对应的命令行覆盖(gRPC 端口一直有 `--port`,Web 端此前没有):
1141
+
1142
+ ```bash
1143
+ ipclick run --port 9528 --web-port 9531 -w
1144
+ ```
1145
+
1146
+ #### `[LOG].output` 的两种写法
1147
+
1148
+ ```toml
1149
+ [LOG]
1150
+ output = "stdout" # 只打控制台(默认)
1151
+ output = "logs/app.log" # 写这个文件(没有扩展名时自动补 .log)
1152
+ output = "logs/" # 以斜杠结尾 = 写进这个**目录**,文件名用 ipclick.log
1153
+ ```
1154
+
1155
+ 最后一种在 0.3 会被静默改写成同级的 `logs.log`(目录名被当成缺扩展名的文件名),
1156
+ 0.4 修了。
1157
+
1158
+ ### 安全配置
1159
+
1160
+ 服务端会代替调用方请求任意 URL。**若监听在公网或不可信网络,请开启内网拦截**,
1161
+ 否则本服务可被当作内网跳板(SSRF)。
1162
+
1163
+ | 配置项 | 说明 | 默认值 |
1164
+ |-------------------------------------|--------------------------|----------------------|
1165
+ | `SECURITY.auth_token` | 鉴权令牌,留空 = 不鉴权;可为列表以支持轮换 | `""` |
1166
+ | `SECURITY.allowed_schemes` | 允许的 URL 协议 | `["http", "https"]` |
1167
+ | `SECURITY.block_metadata_endpoints` | 拦截云元数据地址(169.254.169.254 等) | `true` |
1168
+ | `SECURITY.block_private_networks` | 拦截回环 / 私网 / 保留地址 | `false` |
1169
+ | `SECURITY.allowlist` | 即便开启拦截也放行的主机名或 IP | `[]` |
1170
+
1171
+ ### 下载器配置
1172
+
1173
+ 这些是**请求未显式传参时**的默认值;单次请求传的 `timeout` / `max_retries` /
1174
+ `retry_backoff` 优先级更高。
1175
+
1176
+ | 配置项 | 说明 | 默认值 |
1177
+ |----------------------------------------|-----------------------------|---------------------------|
1178
+ | `DOWNLOADER.connect_timeout` | 连接超时(秒) | `10` |
1179
+ | `DOWNLOADER.download_timeout` | 下载超时(秒) | `300` |
1180
+ | `DOWNLOADER.trust_env` | 是否读取环境变量里的代理(`HTTP_PROXY` 等) | `false` |
1181
+ | `DOWNLOADER.retry.max_attempts` | 最大重试次数(`0` = 不重试) | `3` |
1182
+ | `DOWNLOADER.retry.backoff_exponent` | 退避指数 | `2.0` |
1183
+ | `DOWNLOADER.retry.initial_backoff` | 初始等待(秒) | `1` |
1184
+ | `DOWNLOADER.retry.max_backoff` | 单次等待上限(秒,硬上限 300) | `30` |
1185
+ | `DOWNLOADER.retry.retry_codes` | 触发重试的状态码;连接层异常总是会重试 | `[429,500,502,503,504]` |
1186
+ | `DOWNLOADER.concurrency.max_connections` | 连接池总上限 | `100` |
1187
+ | `DOWNLOADER.concurrency.max_keepalive_connections` | 长连接上限 | `20` |
1188
+
1189
+ 等待时间 = `initial_backoff × backoff_exponent^已重试次数`,封顶到 `max_backoff`,
1190
+ 再乘一个 0.8~1.2 的抖动因子(避免并发任务集体重试造成惊群)。
1191
+
1192
+ ### 代理配置
1193
+
1194
+ | 配置项 | 说明 |
1195
+ |------------------------|-------------|
1196
+ | `PROXY.scheme` | 协议,默认 `http` |
1197
+ | `PROXY.host` / `.port` | 代理地址与端口 |
1198
+ | `PROXY.auth_key` | 认证用户名 |
1199
+ | `PROXY.auth_password` | 认证密码 |
1200
+ | `PROXY.tunnel_server` | 隧道服务器(覆盖 host:port) |
1201
+
1202
+ ## 🐳 Docker 部署
1203
+
1204
+ 构建上下文是**仓库根目录**,不是 `docker/` 目录:
1205
+
1206
+ ```bash
1207
+ docker build -f docker/Dockerfile -t ipclick:latest .
1208
+ ```
1209
+
1210
+ 运行:
1211
+
1212
+ ```bash
1213
+ docker run -d -p 9527:9527 --name ipclick ipclick:latest
1214
+ ```
1215
+
1216
+ 挂载自定义配置:
1217
+
1218
+ ```bash
1219
+ docker run -d -p 9527:9527 --name ipclick -v /你的路径/:/home/ipclick/.ipclick/ ipclick:latest
1220
+ ```
1221
+
1222
+ ## 📚 API 参考
1223
+
1224
+ ### DownloadTask / request() 参数
1225
+
1226
+ | 参数 | 类型 | 默认值 | 说明 |
1227
+ |---------------------|------------------------|--------------|-------------|
1228
+ | `url` | `str` | — | 请求 URL(必填) |
1229
+ | `method` | `HttpMethod` | `GET` | HTTP 方法 |
1230
+ | `adapter` | `IPClickAdapter / str` | `CURL_CFFI` | 使用的适配器 |
1231
+ | `headers` | `dict` | `None` | 请求头 |
1232
+ | `cookies` | `dict / str` | `None` | Cookies |
1233
+ | `params` | `dict` | `None` | URL 查询参数 |
1234
+ | `data` | `Any` | `None` | 表单数据 |
1235
+ | `json` | `dict` | `None` | JSON 数据 |
1236
+ | `proxy` | `ProxyConfig/str/bool` | `None` | 代理配置 |
1237
+ | `timeout` | `float` | `60` | 超时时间(秒) |
1238
+ | `max_retries` | `int` | `3` | 最大重试次数 |
1239
+ | `retry_backoff` | `float` | `2.0` | 重试退避基数(秒) |
1240
+ | `verify` | `bool` | `True` | 是否验证 SSL 证书 |
1241
+ | `allow_redirects` | `bool` | `True` | 是否跟随重定向 |
1242
+ | `impersonate` | `str` | `chrome` | 浏览器指纹伪装 |
1243
+ | `allowed_status_codes` | `list[int]` | `[200, 404]` | 不触发重试的状态码 |
1244
+
1245
+ ### DownloadResponse 属性
1246
+
1247
+ | 属性 | 类型 | 说明 |
1248
+ |----------------|---------|----------------|
1249
+ | `status_code` | `int` | HTTP 状态码,`-1` 表示本地/传输失败 |
1250
+ | `headers` | `dict` | 响应头 |
1251
+ | `content` | `bytes` | 响应内容(二进制) |
1252
+ | `text` | `str` | 响应内容(文本) |
1253
+ | `url` | `str` | 最终 URL |
1254
+ | `elapsed_ms` | `int` | 请求耗时(毫秒) |
1255
+ | `adapter_type` | `str` | 实际使用的适配器名称 |
1256
+ | `error` | `str` | 错误信息 |
1257
+
1258
+ ### DownloadResponse 方法
1259
+
1260
+ - `json()` - 解析 JSON 响应
1261
+ - `is_success()` / `ok` - 判断请求是否成功
1262
+ - `raise_for_status()` - 状态码异常时抛出 `RequestError`
1263
+
1264
+ ### 异常
1265
+
1266
+ 所有异常都继承自 `IPClickError`。**注意抛出位置** —— 服务端抛的异常不会原样
1267
+ 传到客户端(gRPC 之间只传状态码和文本):
1268
+
1269
+ | 异常 | 场景 | 客户端能否 `except` 到 |
1270
+ |---------------------|-----------------------|---------------|
1271
+ | `ValidationError` | 任务参数校验失败,如 URL 为空、适配器名拼错(同时继承 `ValueError`) | ✅ 会抛出 |
1272
+ | `TransportError` | 与服务端的 gRPC 通信失败 | ✅ 由 `download()` 抛出 |
1273
+ | `AuthenticationError` | 鉴权令牌缺失或不正确 | ✅ 会抛出(非 TransportError) |
1274
+ | `ClientClosedError` | 在已关闭的客户端上继续发请求 | ✅ 会抛出(非 TransportError) |
1275
+ | `RequestError` | 目标站点返回错误,由 `raise_for_status()` 抛出 | ✅ 需自行调用 |
1276
+ | `ConfigError` | 配置缺失或非法 | ✅ 加载配置时抛出 |
1277
+ | `AdapterError` | 适配器不存在或依赖未安装 | ❌ 仅服务端 |
1278
+ | `URLNotAllowedError`| 目标 URL 被安全策略拒绝 | ❌ 仅服务端 |
1279
+
1280
+ 标 ❌ 的两个只在**服务端**抛出,客户端看到的是
1281
+ `status_code == -1` 且 `error` 含说明文字的 `DownloadResponse`,
1282
+ 写 `except AdapterError:` 永远不会命中。
1283
+
1284
+ ```python
1285
+ resp = downloader.get(url) # 网络失败不抛异常
1286
+ if not resp.is_success():
1287
+ print(resp.error) # 服务端拒绝的原因在这里
1288
+ ```
1289
+
1290
+ 而参数错误是会抛的:
1291
+
1292
+ ```python
1293
+ downloader.get(url, adapter="htttpx") # ValidationError: 未知的适配器名称
1294
+ ```
1295
+
1296
+ ## 🚧 尚未实现 / 已知限制
1297
+
1298
+ 为免误导,这里如实列出**当前还没有实现**的部分。配置文件里出现但落在本节的项,
1299
+ 改了不会有任何效果。
1300
+
1301
+ ### 配置节生效情况
1302
+
1303
+ | 配置节 | 状态 |
1304
+ |---|---|
1305
+ | `[SERVER]` `[CLIENT]` `[PROXY]` `[LOG]` `[SECURITY]` `[DOWNLOADER]` `[MONITOR]` `[BROWSER]` `[TRACE]` `[WEB]` | ✅ 全部生效 |
1306
+ | `[GENERAL]` | ✅ `debug` 与 `mode` 都生效 |
1307
+ | `[CLUSTER]` | ⚠️ 除 `db_uri`(预留给 etcd/Consul)之外全部生效 |
1308
+
1309
+ > `[DOWNLOADER]` 的分块下载(`chunk`)、临时存储(`storage`)尚未实现,已从默认
1310
+ > 配置移除;`rate_limit` 的 `redis` 后端已在 0.3.0 移除(集群限流由入口节点计算)。
1311
+ > `[LOG].format` 在 0.3.0 起真正生效(此前从未被读取)。
1312
+
1313
+ ### 功能
1314
+
1315
+ - **服务发现只支持 DNS**:`[CLUSTER.discovery]` 支持 `static` 与 `dns`。
1316
+ etcd / Consul 的原生 API 没接(Consul 可以用它的 DNS 接口),
1317
+ `[CLUSTER].db_uri` 仍是预留配置。
1318
+ - **`undetected_chromedriver` 未实现**:它基于 selenium + chromedriver,能力与
1319
+ patchright / camoufox 高度重叠,收益不足以抵消维护成本。请求到会抛 `AdapterError`。
1320
+ - **分块下载与临时存储**:`[DOWNLOADER]` 里的 `chunk` / `storage` 尚未实现。
1321
+ 流式通路已经就绪,这两项可以在其上实现,但目前还没做。
1322
+ - **集群流式的中途重连**:`ClusterDownloader.stream()` 只有**建流**这一步会故障
1323
+ 转移。流建立之后中途断掉不会自动重连——那需要断点续传(Range 请求)才能不重复
1324
+ 数据。批量则是整批发给同一个节点,不跨节点拆分。
1325
+ - **文件上传**:协议里没有 multipart 字段(0.3.0 起 `files` 参数已从 API 移除,
1326
+ 因为它一直是抛 `NotImplementedError` 的)。要上传文件请自己拼好 multipart 体,
1327
+ 用 `data=<bytes>` 加上 `Content-Type: multipart/form-data; boundary=...` 发出去——
1328
+ `data` 现在是 bytes 字段,任意二进制都能原样送达。
1329
+ - **Cookie 持久化**:请求之间不共享 cookie jar,每次请求相互独立。
1330
+
1331
+
1332
+ ## 🗺️ 路线图
1333
+
1334
+ | 阶段 | 内容 | 状态 |
1335
+ |---|---|---|
1336
+ | **P1** 打通存量 | `[DOWNLOADER]` 生效、参数错误不再伪装成网络失败、`NO_PROXY` 处理、`[GENERAL].debug` | ✅ 已完成 |
1337
+ | **P2** 安全与可运维 | 服务端 token 鉴权 ✅、gRPC 标准健康检查 ✅、链路记录与统计 ✅ | ✅ 已完成 |
1338
+ | **P3** 能力扩展 | 异步客户端 ✅、批量 RPC ✅、真流式下载 ✅(限速 / 分块仍待做) | ✅ 已完成 |
1339
+ | **P4** 集群 | 节点池 ✅、负载均衡 ✅、健康探测 ✅、故障转移 ✅、只读状态页 ✅ | ✅ 已完成 |
1340
+ | **P5** 适配器 | `niquests` ✅、`playwright` ✅(连带 `[BROWSER]` 与浏览器渲染) | ✅ 已完成 |
1341
+ | **P6** 限流与引擎 | 按 host 并发 / QPS 限制 ✅、浏览器引擎可插拔(camoufox / patchright / DrissionPage)✅ | ✅ 已完成 |
1342
+ | **P7** 生产化 | TLS/mTLS ✅、断点续传 ✅、DNS 服务发现 ✅、客户端重试 ✅、`[GENERAL].mode` ✅ | ✅ 已完成 |
1343
+ | **P8** 打磨 | 轻量安装 ✅、niquests ✅、`--example` ✅、`.env` ✅、Web 管理端 ✅ | ✅ 已完成 |
1344
+ | **P9** 分布式与可观测 | 服务端转发集群 ✅、派生式节点鉴权 ✅、链路记录 ✅、请求压缩 ✅、Web 端配置写回 / 请求流 / 试一试 ✅ | ✅ 已完成 |
1345
+ | **P10** 待定 | 异步服务端、multipart 文件上传、Cookie 持久化、etcd/Consul 原生发现 | 计划中 |
1346
+
1347
+ ## 🛠️ 开发
1348
+
1349
+ ```bash
1350
+ uv sync --all-groups # 安装含开发依赖
1351
+ uv run pytest # 运行测试
1352
+ uv run pytest --cov=ipclick # 带覆盖率
1353
+ uv run ruff check src/ tests/ # 代码检查
1354
+ uv run ruff format src/ tests/# 代码格式化
1355
+ uv run basedpyright src/ # 类型检查
1356
+ ```
1357
+
1358
+ 修改 `src/ipclick/dto/proto/task.proto` 后需重新生成代码:
1359
+
1360
+ ```bash
1361
+ uv run python src/ipclick/dto/proto/generate.py
1362
+ ```
1363
+
1364
+ ## 🤝 贡献
1365
+
1366
+ 欢迎贡献代码、报告问题或提出建议!请通过 [GitHub Issues](https://github.com/yuanqimanong/IPClick/issues)
1367
+ 或 [Pull Requests](https://github.com/yuanqimanong/IPClick/pulls) 参与项目开发。
1368
+
1369
+ ## 📄 许可证
1370
+
1371
+ 本项目采用 [MIT License](https://github.com/yuanqimanong/IPClick/blob/master/LICENSE) 开源许可证。
1372
+
1373
+ Copyright (c) 2025 元气码农少女酱
1374
+