crawlerkeeper-sdk 0.2.1__py3-none-any.whl
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.
- crawlerkeeper_sdk/README.md +243 -0
- crawlerkeeper_sdk/__init__.py +28 -0
- crawlerkeeper_sdk/env.py +29 -0
- crawlerkeeper_sdk/output.py +104 -0
- crawlerkeeper_sdk/params.py +42 -0
- crawlerkeeper_sdk/pool.py +114 -0
- crawlerkeeper_sdk/proxy.py +173 -0
- crawlerkeeper_sdk/py.typed +0 -0
- crawlerkeeper_sdk/stealth_session.py +196 -0
- crawlerkeeper_sdk-0.2.1.dist-info/METADATA +264 -0
- crawlerkeeper_sdk-0.2.1.dist-info/RECORD +13 -0
- crawlerkeeper_sdk-0.2.1.dist-info/WHEEL +4 -0
- crawlerkeeper_sdk-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# CrawlerKeeper SDK for Python
|
|
2
|
+
|
|
3
|
+
CrawlerKeeper Python SDK 提供了爬虫与 Agent 通信的封装,支持日志记录、结果写入、参数获取、代理管理和并发爬取。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install crawlerkeeper-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 快速开始
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from crawlerkeeper_sdk import params, info, result, StealthSession
|
|
15
|
+
|
|
16
|
+
# 获取任务参数
|
|
17
|
+
config = params()
|
|
18
|
+
info(f"收到参数: {config}")
|
|
19
|
+
|
|
20
|
+
# 使用 StealthSession 发送请求
|
|
21
|
+
session = StealthSession()
|
|
22
|
+
resp = session.get("https://example.com/api/data")
|
|
23
|
+
data = resp.json()
|
|
24
|
+
|
|
25
|
+
# 写入结果
|
|
26
|
+
result({"data": data, "source": "example.com"})
|
|
27
|
+
|
|
28
|
+
info("爬取完成")
|
|
29
|
+
session.close()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 核心功能
|
|
33
|
+
|
|
34
|
+
### 日志记录
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from crawlerkeeper_sdk import info, warning, error
|
|
38
|
+
|
|
39
|
+
info("信息日志")
|
|
40
|
+
warning("警告日志")
|
|
41
|
+
error("错误日志")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 结果写入
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from crawlerkeeper_sdk import result
|
|
48
|
+
|
|
49
|
+
result({"count": 100, "items": [...]})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 任务参数
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from crawlerkeeper_sdk import params
|
|
56
|
+
|
|
57
|
+
# python spider.py --params '{"keyword": "python", "page": 1}'
|
|
58
|
+
config = params() # {"keyword": "python", "page": 1}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## StealthSession
|
|
62
|
+
|
|
63
|
+
`StealthSession` 提供隐式浏览器指纹的 HTTP 请求能力,自动管理 TLS 指纹和代理。
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from crawlerkeeper_sdk import StealthSession, result
|
|
67
|
+
|
|
68
|
+
session = StealthSession()
|
|
69
|
+
|
|
70
|
+
# GET 请求
|
|
71
|
+
resp = session.get("https://example.com/api/data")
|
|
72
|
+
print(resp.status_code)
|
|
73
|
+
print(resp.json())
|
|
74
|
+
|
|
75
|
+
# POST 请求
|
|
76
|
+
resp = session.post("https://example.com/api/submit", json={"key": "value"})
|
|
77
|
+
|
|
78
|
+
session.close()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 主要方法
|
|
82
|
+
|
|
83
|
+
| 方法 | 说明 |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `get(url, **kwargs)` | 发送 GET 请求 |
|
|
86
|
+
| `post(url, **kwargs)` | 发送 POST 请求 |
|
|
87
|
+
| `close()` | 关闭会话 |
|
|
88
|
+
|
|
89
|
+
## ThreadPool 并发爬取
|
|
90
|
+
|
|
91
|
+
`ThreadPool` 提供并发爬取能力,内部管理 `StealthSession` 的创建和关闭。
|
|
92
|
+
|
|
93
|
+
### 推荐用法:使用上下文管理器
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from crawlerkeeper_sdk import ThreadPool, StealthSession, result
|
|
97
|
+
|
|
98
|
+
def crawl(session: StealthSession, keyword: str, page: int):
|
|
99
|
+
"""爬虫函数:session 会自动传入"""
|
|
100
|
+
resp = session.get(f"https://example.com/search?kw={keyword}&page={page}")
|
|
101
|
+
return resp.json()
|
|
102
|
+
|
|
103
|
+
# 使用 with 语句自动管理生命周期,不会遗漏 close()
|
|
104
|
+
with ThreadPool(max_workers=5) as pool:
|
|
105
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
106
|
+
pool.submit(crawl, keyword="java", page=1)
|
|
107
|
+
pool.submit(crawl, keyword="go", page=1)
|
|
108
|
+
|
|
109
|
+
# 阻塞等待所有任务完成
|
|
110
|
+
results = pool.wait()
|
|
111
|
+
|
|
112
|
+
# 处理结果
|
|
113
|
+
for data in results:
|
|
114
|
+
if data is not None:
|
|
115
|
+
result(data)
|
|
116
|
+
# with 块结束时自动调用 wait() 和 close()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 传统用法
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
pool = ThreadPool(max_workers=5)
|
|
123
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
124
|
+
pool.submit(crawl, keyword="java", page=1)
|
|
125
|
+
|
|
126
|
+
results = pool.wait()
|
|
127
|
+
pool.close() # 需要手动调用
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 环境变量配置并发数
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
export CONCURRENT_NUM=10
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# 不指定 max_workers 时使用 CONCURRENT_NUM 环境变量
|
|
138
|
+
with ThreadPool() as pool:
|
|
139
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### ThreadPool 方法
|
|
143
|
+
|
|
144
|
+
| 方法 | 说明 |
|
|
145
|
+
|------|------|
|
|
146
|
+
| `submit(crawl_fn, **kwargs)` | 提交任务,worker 全满时阻塞等待 |
|
|
147
|
+
| `wait()` | 阻塞等待所有任务完成,返回结果列表 |
|
|
148
|
+
| `close()` | 等待所有任务完成后关闭线程池 |
|
|
149
|
+
|
|
150
|
+
### 异常处理
|
|
151
|
+
|
|
152
|
+
爬虫函数抛出的异常会被捕获并通过 `output.error()` 记录,任务结果记为 `None`,不中断其他任务。
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
def failing_crawl(session):
|
|
156
|
+
raise ValueError("some error")
|
|
157
|
+
|
|
158
|
+
pool = ThreadPool(max_workers=2)
|
|
159
|
+
pool.submit(failing_crawl)
|
|
160
|
+
results = pool.wait() # results[0] is None
|
|
161
|
+
pool.close()
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## 代理配置
|
|
165
|
+
|
|
166
|
+
`StealthSession` 的代理行为由环境变量控制(通常由 Agent 下发),不支持按请求传入代理参数:
|
|
167
|
+
|
|
168
|
+
| 环境变量 | 说明 |
|
|
169
|
+
|---------|------|
|
|
170
|
+
| `PROXY_ENABLED` | `on` 开启代理,其他值直连 |
|
|
171
|
+
| `PROXY_MODE` | `tunnel` 静态代理 / `private` 管理端动态分配 |
|
|
172
|
+
| `PROXY_URL` | tunnel 模式的静态代理 URL |
|
|
173
|
+
| `PROXY_VENDOR` | private 模式的供应商标识 |
|
|
174
|
+
| `PROXY_TARGET_SITE` | private 模式的目标站点标识 |
|
|
175
|
+
| `ADMIN_SERVER_URL` | private 模式下管理端地址 |
|
|
176
|
+
| `AGENT_API_KEY` | private 模式下调用管理端的第三方 Token(`td_` 前缀;代理分配/上报接口仅对第三方 Token 开放,节点 `ck_` Key 无权调用) |
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# tunnel 模式示例
|
|
180
|
+
export PROXY_ENABLED=on
|
|
181
|
+
export PROXY_MODE=tunnel
|
|
182
|
+
export PROXY_URL=http://proxy:8080
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
private 模式下代理用尽或持续被目标站封禁时,`get`/`post` 抛出 `ProxyExhaustedError`。
|
|
186
|
+
|
|
187
|
+
## 运行模式
|
|
188
|
+
|
|
189
|
+
SDK 自动检测运行模式:
|
|
190
|
+
|
|
191
|
+
- **Agent 模式**: 当 `LOG_FILE_PATH` 和 `DATA_FILE_PATH` 环境变量存在时,日志和结果写入文件
|
|
192
|
+
- **开发模式**: 环境变量不存在时,输出到控制台
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# 开发模式
|
|
196
|
+
python spider.py
|
|
197
|
+
|
|
198
|
+
# Agent 模式
|
|
199
|
+
export LOG_FILE_PATH=/tmp/test.log
|
|
200
|
+
export DATA_FILE_PATH=/tmp/test.json
|
|
201
|
+
python spider.py
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## 完整示例
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
#!/usr/bin/env python3
|
|
208
|
+
from crawlerkeeper_sdk import params, info, result, StealthSession, ThreadPool
|
|
209
|
+
|
|
210
|
+
def crawl(session: StealthSession, keyword: str, page: int):
|
|
211
|
+
"""爬虫函数"""
|
|
212
|
+
info(f"爬取 {keyword} 第 {page} 页")
|
|
213
|
+
resp = session.get(f"https://example.com/api?kw={keyword}&page={page}")
|
|
214
|
+
return resp.json()
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
config = params()
|
|
218
|
+
keywords = config.get("keywords", ["python"])
|
|
219
|
+
max_workers = config.get("max_workers", 3)
|
|
220
|
+
|
|
221
|
+
with ThreadPool(max_workers=max_workers) as pool:
|
|
222
|
+
# 提交所有任务
|
|
223
|
+
for keyword in keywords:
|
|
224
|
+
for page in range(1, 4):
|
|
225
|
+
pool.submit(crawl, keyword=keyword, page=page)
|
|
226
|
+
|
|
227
|
+
# 阻塞等待所有任务完成
|
|
228
|
+
results = pool.wait()
|
|
229
|
+
|
|
230
|
+
# 输出结果
|
|
231
|
+
for data in results:
|
|
232
|
+
if data is not None:
|
|
233
|
+
result(data)
|
|
234
|
+
|
|
235
|
+
info("爬取完成")
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## 许可证
|
|
242
|
+
|
|
243
|
+
MIT License
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrawlerKeeper SDK for Python
|
|
3
|
+
|
|
4
|
+
提供爬虫与 Agent 通信的封装,支持日志记录、结果写入、参数解析和代理管理。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .output import (
|
|
8
|
+
info,
|
|
9
|
+
warning,
|
|
10
|
+
error,
|
|
11
|
+
result,
|
|
12
|
+
)
|
|
13
|
+
from .params import params
|
|
14
|
+
from .stealth_session import StealthSession
|
|
15
|
+
from .proxy import ProxyExhaustedError
|
|
16
|
+
from .pool import ThreadPool
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.1"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"info",
|
|
21
|
+
"warning",
|
|
22
|
+
"error",
|
|
23
|
+
"result",
|
|
24
|
+
"params",
|
|
25
|
+
"StealthSession",
|
|
26
|
+
"ProxyExhaustedError",
|
|
27
|
+
"ThreadPool",
|
|
28
|
+
]
|
crawlerkeeper_sdk/env.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
环境变量管理模块
|
|
3
|
+
|
|
4
|
+
统一管理所有环境变量访问。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# Agent 环境
|
|
11
|
+
LOG_FILE_PATH: Optional[str] = os.environ.get("LOG_FILE_PATH")
|
|
12
|
+
DATA_FILE_PATH: Optional[str] = os.environ.get("DATA_FILE_PATH")
|
|
13
|
+
|
|
14
|
+
# Admin Server
|
|
15
|
+
ADMIN_SERVER_URL: str = os.environ.get("ADMIN_SERVER_URL", "http://localhost:8080")
|
|
16
|
+
AGENT_API_KEY: str = os.environ.get("AGENT_API_KEY", "")
|
|
17
|
+
|
|
18
|
+
# 代理配置
|
|
19
|
+
PROXY_ENABLED: bool = os.environ.get("PROXY_ENABLED", "off").lower() == "on"
|
|
20
|
+
PROXY_MODE: str = os.environ.get("PROXY_MODE", "private").lower()
|
|
21
|
+
PROXY_URL: Optional[str] = os.environ.get("PROXY_URL")
|
|
22
|
+
PROXY_VENDOR: str = os.environ.get("PROXY_VENDOR", "default")
|
|
23
|
+
PROXY_TARGET_SITE: str = os.environ.get("PROXY_TARGET_SITE", "default")
|
|
24
|
+
|
|
25
|
+
# 并发配置
|
|
26
|
+
CONCURRENT_NUM: int = max(1, int(os.environ.get("CONCURRENT_NUM", "1")))
|
|
27
|
+
|
|
28
|
+
# 请求超时配置(秒)
|
|
29
|
+
CRAWL_TIMEOUT: int = max(1, int(os.environ.get("CRAWL_TIMEOUT", "30")))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
日志模块
|
|
3
|
+
|
|
4
|
+
提供日志记录功能,支持 Agent 模式和开发模式。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import env
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def log(message: str, level: str = "INFO") -> None:
|
|
15
|
+
"""
|
|
16
|
+
写入日志
|
|
17
|
+
|
|
18
|
+
在 Agent 环境中写入 LOG_FILE_PATH 文件,否则输出到控制台。
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
message: 日志内容
|
|
22
|
+
level: 日志级别,可选 DEBUG, INFO, WARNING, ERROR
|
|
23
|
+
"""
|
|
24
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
25
|
+
line = f"[{timestamp}] [{level}] {message}\n"
|
|
26
|
+
|
|
27
|
+
if env.LOG_FILE_PATH:
|
|
28
|
+
# Agent 模式:写入文件
|
|
29
|
+
try:
|
|
30
|
+
with open(env.LOG_FILE_PATH, "a", encoding="utf-8") as f:
|
|
31
|
+
f.write(line)
|
|
32
|
+
except Exception:
|
|
33
|
+
# 静默失败,不中断爬虫
|
|
34
|
+
pass
|
|
35
|
+
else:
|
|
36
|
+
# 开发模式:输出到控制台
|
|
37
|
+
print(line, end="")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def info(message: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
写入 INFO 级别日志
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
message: 日志内容
|
|
46
|
+
"""
|
|
47
|
+
log(message, level="INFO")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def warning(message: str) -> None:
|
|
51
|
+
"""
|
|
52
|
+
写入 WARNING 级别日志
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
message: 日志内容
|
|
56
|
+
"""
|
|
57
|
+
log(message, level="WARNING")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error(message: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
写入 ERROR 级别日志
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
message: 日志内容
|
|
66
|
+
"""
|
|
67
|
+
log(message, level="ERROR")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def result(data: Any) -> None:
|
|
71
|
+
"""
|
|
72
|
+
写入结果数据
|
|
73
|
+
|
|
74
|
+
数据自动序列化为 JSON 格式写入 DATA_FILE_PATH 文件。
|
|
75
|
+
在开发模式下,输出到控制台并格式化。
|
|
76
|
+
Agent 模式下直接写入文件,不格式化。
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
data: 任意可 JSON 序列化的数据
|
|
80
|
+
"""
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
if env.DATA_FILE_PATH:
|
|
85
|
+
# Agent 模式:不格式化,紧凑写入
|
|
86
|
+
content = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
87
|
+
else:
|
|
88
|
+
# 开发模式:格式化输出
|
|
89
|
+
content = json.dumps(data, ensure_ascii=False, indent=2)
|
|
90
|
+
|
|
91
|
+
except (TypeError, ValueError) as e:
|
|
92
|
+
log(f"结果序列化失败: {e}", level="ERROR")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if env.DATA_FILE_PATH:
|
|
96
|
+
# Agent 模式:写入文件
|
|
97
|
+
try:
|
|
98
|
+
with open(env.DATA_FILE_PATH, "w", encoding="utf-8") as f:
|
|
99
|
+
f.write(content)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log(f"写入结果失败: {e}", level="ERROR")
|
|
102
|
+
else:
|
|
103
|
+
# 开发模式:输出到控制台
|
|
104
|
+
print("RESULT:", content)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CrawlerKeeper SDK 核心实现
|
|
3
|
+
|
|
4
|
+
提供任务参数解析功能。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
# 缓存参数解析结果
|
|
12
|
+
_params_cache: Optional[Dict[str, Any]] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def params() -> Dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
获取任务参数
|
|
18
|
+
|
|
19
|
+
自动解析命令行 `--params` 参数,返回 JSON 解析后的字典。
|
|
20
|
+
结果会被缓存,多次调用只解析一次。
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
参数字典,无参数时返回空字典
|
|
24
|
+
"""
|
|
25
|
+
global _params_cache
|
|
26
|
+
|
|
27
|
+
if _params_cache is not None:
|
|
28
|
+
return _params_cache
|
|
29
|
+
|
|
30
|
+
_params_cache = {}
|
|
31
|
+
args = sys.argv[1:]
|
|
32
|
+
|
|
33
|
+
for i, arg in enumerate(args):
|
|
34
|
+
if arg == "--params" and i + 1 < len(args):
|
|
35
|
+
try:
|
|
36
|
+
_params_cache = json.loads(args[i + 1])
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
from .output import log
|
|
39
|
+
log("参数解析失败,使用空参数", level="WARNING")
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
return _params_cache
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ThreadPool 模块
|
|
3
|
+
|
|
4
|
+
提供并发爬取能力,管理 StealthSession 生命周期。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .output import error
|
|
11
|
+
from .stealth_session import StealthSession
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ThreadPool:
|
|
15
|
+
"""
|
|
16
|
+
线程池管理器
|
|
17
|
+
|
|
18
|
+
提供并发爬取能力,内部管理 StealthSession 的创建和关闭。
|
|
19
|
+
爬虫函数签名:crawl_fn(session: StealthSession, **kwargs) -> Any
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, max_workers: int | None = None):
|
|
23
|
+
"""
|
|
24
|
+
初始化线程池
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
max_workers: 最大 worker 数量,默认使用 env.CONCURRENT_NUM
|
|
28
|
+
"""
|
|
29
|
+
from . import env
|
|
30
|
+
|
|
31
|
+
if max_workers is None:
|
|
32
|
+
max_workers = env.CONCURRENT_NUM
|
|
33
|
+
self._max_workers = max(1, max_workers)
|
|
34
|
+
self._semaphore = threading.Semaphore(self._max_workers)
|
|
35
|
+
self._results: list[Any] = []
|
|
36
|
+
self._results_lock = threading.Lock()
|
|
37
|
+
self._done_cv = threading.Condition()
|
|
38
|
+
self._tasks_done = 0
|
|
39
|
+
self._tasks_total = 0
|
|
40
|
+
|
|
41
|
+
def submit(self, crawl_fn: Callable, **kwargs) -> None:
|
|
42
|
+
"""
|
|
43
|
+
提交任务。当 worker 全满时,主线程在此阻塞等待空 slot。
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
crawl_fn: 爬虫函数,签名 (session, **kwargs) -> Any
|
|
47
|
+
**kwargs: 传递给爬虫函数的关键字参数
|
|
48
|
+
"""
|
|
49
|
+
self._semaphore.acquire()
|
|
50
|
+
# 预占结果槽位,保证 wait() 的返回顺序与 submit() 调用顺序一致
|
|
51
|
+
with self._results_lock:
|
|
52
|
+
index = self._tasks_total
|
|
53
|
+
self._tasks_total += 1
|
|
54
|
+
self._results.append(None)
|
|
55
|
+
t = threading.Thread(target=self._worker, args=(crawl_fn, kwargs, index))
|
|
56
|
+
t.start()
|
|
57
|
+
|
|
58
|
+
def _worker(self, crawl_fn: Callable, kwargs: dict, index: int) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Worker 线程:创建 Session,执行爬虫,关闭 Session。
|
|
61
|
+
|
|
62
|
+
Session 创建失败(如缺少 curl-cffi)也必须走 finally 归还信号量并通知等待者,
|
|
63
|
+
否则 wait() 会永久阻塞。
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
crawl_fn: 爬虫函数
|
|
67
|
+
kwargs: 爬虫函数参数
|
|
68
|
+
index: 结果槽位下标,保证返回顺序与提交顺序一致
|
|
69
|
+
"""
|
|
70
|
+
session = None
|
|
71
|
+
try:
|
|
72
|
+
session = StealthSession()
|
|
73
|
+
data = crawl_fn(session, **kwargs)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
error(f"crawl error: {e}")
|
|
76
|
+
data = None
|
|
77
|
+
finally:
|
|
78
|
+
if session is not None:
|
|
79
|
+
session.close()
|
|
80
|
+
with self._results_lock:
|
|
81
|
+
self._results[index] = data
|
|
82
|
+
with self._done_cv:
|
|
83
|
+
self._tasks_done += 1
|
|
84
|
+
self._done_cv.notify_all()
|
|
85
|
+
self._semaphore.release()
|
|
86
|
+
|
|
87
|
+
def wait(self) -> list[Any]:
|
|
88
|
+
"""
|
|
89
|
+
阻塞等待所有任务完成,返回结果列表。
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
包含所有任务返回值的列表,顺序与 submit() 调用顺序一致
|
|
93
|
+
"""
|
|
94
|
+
with self._done_cv:
|
|
95
|
+
while self._tasks_done < self._tasks_total:
|
|
96
|
+
self._done_cv.wait()
|
|
97
|
+
with self._results_lock:
|
|
98
|
+
return list(self._results)
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
"""
|
|
102
|
+
等待所有任务完成后关闭线程池(wait() 之后调用)。
|
|
103
|
+
"""
|
|
104
|
+
self._semaphore.acquire() # 确保所有任务已提交
|
|
105
|
+
self._semaphore.release()
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> "ThreadPool":
|
|
108
|
+
"""支持上下文管理器:with ThreadPool() as pool:"""
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
112
|
+
"""上下文管理器退出时自动调用 close()"""
|
|
113
|
+
self.wait()
|
|
114
|
+
self.close()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProxyManager - 代理管理模块
|
|
3
|
+
|
|
4
|
+
统一管理代理配置和生命周期,支持两种模式:
|
|
5
|
+
- tunnel: 静态配置代理 URL
|
|
6
|
+
- private: 从管理端动态分配代理
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from . import env
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _http_post(endpoint: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
16
|
+
"""发送 POST 请求到 Admin Server"""
|
|
17
|
+
try:
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
url = env.ADMIN_SERVER_URL + endpoint
|
|
21
|
+
json_data = json.dumps(data or {}).encode("utf-8")
|
|
22
|
+
api_key = env.AGENT_API_KEY
|
|
23
|
+
headers = {"Content-Type": "application/json"}
|
|
24
|
+
if api_key:
|
|
25
|
+
headers["Authorization"] = f"ApiKey {api_key}"
|
|
26
|
+
req = urllib.request.Request(
|
|
27
|
+
url,
|
|
28
|
+
data=json_data,
|
|
29
|
+
headers=headers
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
33
|
+
response_body = response.read().decode("utf-8")
|
|
34
|
+
return json.loads(response_body)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
from .output import log
|
|
37
|
+
log(f"HTTP POST 请求失败: {endpoint}, 错误: {e}", level="ERROR")
|
|
38
|
+
return {"code": -1, "message": str(e)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def allocate_proxy(vendor: str, target_site: str) -> Dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
从管理端获取可用代理
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
vendor: 供应商标识 (由管理端代理配置统一下发)
|
|
47
|
+
target_site: 被爬取网站标识 (如 example.com)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
代理信息字典,包含 ip, port, username, password, protocol
|
|
51
|
+
如果获取失败,返回空字典
|
|
52
|
+
"""
|
|
53
|
+
from .output import log
|
|
54
|
+
|
|
55
|
+
result = _http_post("/api/proxies/allocate", {
|
|
56
|
+
"vendor": vendor,
|
|
57
|
+
"targetSite": target_site
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if result.get("code") == 200 and result.get("data"):
|
|
61
|
+
proxy_info = result["data"]
|
|
62
|
+
log(f"获取代理成功: {vendor} -> {proxy_info.get('ip')}:{proxy_info.get('port')}", level="INFO")
|
|
63
|
+
return proxy_info
|
|
64
|
+
else:
|
|
65
|
+
log(f"获取代理失败: {result.get('message', '未知错误')}", level="ERROR")
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def report_proxy_block(vendor: str, ip: str, target_site: str) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
上报代理被目标站封禁
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
vendor: 供应商标识
|
|
75
|
+
ip: 代理IP
|
|
76
|
+
target_site: 被爬取网站标识
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True-上报成功, False-上报失败
|
|
80
|
+
"""
|
|
81
|
+
from .output import log
|
|
82
|
+
|
|
83
|
+
result = _http_post("/api/proxies/block", {
|
|
84
|
+
"vendor": vendor,
|
|
85
|
+
"ip": ip,
|
|
86
|
+
"targetSite": target_site
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if result.get("code") == 200:
|
|
90
|
+
log(f"上报屏蔽成功: {vendor} {ip} -> {target_site}", level="INFO")
|
|
91
|
+
return True
|
|
92
|
+
else:
|
|
93
|
+
log(f"上报屏蔽失败: {result.get('message', '未知错误')}", level="WARNING")
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ProxyExhaustedError(Exception):
|
|
98
|
+
"""代理用尽异常"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, reason: str):
|
|
101
|
+
self.reason = reason
|
|
102
|
+
super().__init__(f"代理用尽: {reason}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ProxyManager:
|
|
106
|
+
"""
|
|
107
|
+
代理管理器,根据环境变量配置决定代理策略
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self):
|
|
111
|
+
self.enabled = env.PROXY_ENABLED
|
|
112
|
+
self.mode = env.PROXY_MODE
|
|
113
|
+
self.proxy_url = env.PROXY_URL
|
|
114
|
+
self.vendor = env.PROXY_VENDOR
|
|
115
|
+
self.target_site = env.PROXY_TARGET_SITE
|
|
116
|
+
|
|
117
|
+
def resolve(self) -> Optional[Dict[str, str]]:
|
|
118
|
+
"""
|
|
119
|
+
获取当前代理配置
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
代理 dict: {"http": url, "https": url} 或 None(直连)
|
|
123
|
+
"""
|
|
124
|
+
if not self.enabled:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
if self.mode == "tunnel":
|
|
128
|
+
return self._resolve_tunnel()
|
|
129
|
+
elif self.mode == "private":
|
|
130
|
+
return self._resolve_private()
|
|
131
|
+
else:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _resolve_tunnel(self) -> Optional[Dict[str, str]]:
|
|
135
|
+
"""Tunnel 模式: 解析静态代理 URL"""
|
|
136
|
+
if not self.proxy_url:
|
|
137
|
+
return None
|
|
138
|
+
return {"http": self.proxy_url, "https": self.proxy_url}
|
|
139
|
+
|
|
140
|
+
def _resolve_private(self) -> Optional[Dict[str, str]]:
|
|
141
|
+
"""Private 模式: 从管理端分配代理,失败重试3次"""
|
|
142
|
+
for attempt in range(1, 4):
|
|
143
|
+
proxy_info = allocate_proxy(self.vendor, self.target_site)
|
|
144
|
+
if proxy_info:
|
|
145
|
+
return self._build_proxy_url(proxy_info)
|
|
146
|
+
if attempt < 3:
|
|
147
|
+
from .output import log
|
|
148
|
+
log(f"分配代理失败,重试第{attempt + 1}次", level="WARNING")
|
|
149
|
+
raise ProxyExhaustedError("allocate_proxy 重试3次后仍失败")
|
|
150
|
+
|
|
151
|
+
def _build_proxy_url(self, proxy_info: Dict[str, Any]) -> Dict[str, str]:
|
|
152
|
+
"""将代理信息构建为 URL 格式"""
|
|
153
|
+
ip = proxy_info.get("ip", "")
|
|
154
|
+
port = proxy_info.get("port", "")
|
|
155
|
+
username = proxy_info.get("username", "")
|
|
156
|
+
password = proxy_info.get("password", "")
|
|
157
|
+
|
|
158
|
+
if username and password:
|
|
159
|
+
proxy_url = f"http://{username}:{password}@{ip}:{port}"
|
|
160
|
+
else:
|
|
161
|
+
proxy_url = f"http://{ip}:{port}"
|
|
162
|
+
|
|
163
|
+
return {"http": proxy_url, "https": proxy_url}
|
|
164
|
+
|
|
165
|
+
def report_block(self, proxy_info: Dict[str, Any]) -> None:
|
|
166
|
+
"""
|
|
167
|
+
上报代理被封
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
proxy_info: 代理信息字典
|
|
171
|
+
"""
|
|
172
|
+
ip = proxy_info.get("ip", "") if isinstance(proxy_info, dict) else str(proxy_info)
|
|
173
|
+
report_proxy_block(self.vendor, ip, self.target_site)
|
|
File without changes
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StealthSession - TLS 指纹伪装和代理注入
|
|
3
|
+
|
|
4
|
+
基于 curl-cffi 实现浏览器级 TLS 指纹,自动从 ProxyManager 读取代理配置。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from curl_cffi import requests as curl_requests
|
|
12
|
+
except ImportError:
|
|
13
|
+
curl_requests = None
|
|
14
|
+
|
|
15
|
+
from .env import CRAWL_TIMEOUT
|
|
16
|
+
from .output import error, info
|
|
17
|
+
from .proxy import ProxyExhaustedError, ProxyManager
|
|
18
|
+
|
|
19
|
+
# TLS 指纹池:由 SDK 维护,随库发版更新
|
|
20
|
+
TLS_NAMES = [
|
|
21
|
+
"edge99",
|
|
22
|
+
"edge101",
|
|
23
|
+
"chrome99",
|
|
24
|
+
"chrome100",
|
|
25
|
+
"chrome101",
|
|
26
|
+
"chrome104",
|
|
27
|
+
"chrome107",
|
|
28
|
+
"chrome110",
|
|
29
|
+
"chrome116",
|
|
30
|
+
"chrome119",
|
|
31
|
+
"chrome120",
|
|
32
|
+
"chrome123",
|
|
33
|
+
"chrome124",
|
|
34
|
+
"chrome131",
|
|
35
|
+
"chrome133a",
|
|
36
|
+
"chrome136",
|
|
37
|
+
"chrome142",
|
|
38
|
+
"chrome145",
|
|
39
|
+
"chrome146",
|
|
40
|
+
"chrome99_android",
|
|
41
|
+
"chrome131_android",
|
|
42
|
+
"safari153",
|
|
43
|
+
"safari155",
|
|
44
|
+
"safari170",
|
|
45
|
+
"safari172_ios",
|
|
46
|
+
"safari180",
|
|
47
|
+
"safari180_ios",
|
|
48
|
+
"safari184",
|
|
49
|
+
"safari184_ios",
|
|
50
|
+
"safari260",
|
|
51
|
+
"safari2601",
|
|
52
|
+
"safari260_ios",
|
|
53
|
+
"firefox133",
|
|
54
|
+
"firefox135",
|
|
55
|
+
"firefox144",
|
|
56
|
+
"firefox147",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
class StealthSession:
|
|
60
|
+
"""
|
|
61
|
+
支持 TLS 指纹伪装的 HTTP Session。
|
|
62
|
+
|
|
63
|
+
构造时随机选择 TLS 指纹和 User-Agent,通过 ProxyManager 管理代理配置。
|
|
64
|
+
默认超时时间为 CRAWL_TIMEOUT 环境变量指定(默认 30 秒)。
|
|
65
|
+
对外暴露 get/post 接口,使用方式与 requests.Session 类似。
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
if curl_requests is None:
|
|
70
|
+
raise ImportError("请先安装 curl-cffi: uv add curl-cffi")
|
|
71
|
+
|
|
72
|
+
self._tls_name = random.choice(TLS_NAMES)
|
|
73
|
+
self._proxy_manager = ProxyManager()
|
|
74
|
+
self._session = curl_requests.Session(timeout=CRAWL_TIMEOUT)
|
|
75
|
+
|
|
76
|
+
def get(self, url: str, **kwargs) -> "curl_requests.Response":
|
|
77
|
+
"""
|
|
78
|
+
发送 GET 请求,自动注入 TLS 指纹。
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
url: 请求 URL
|
|
82
|
+
**kwargs: 透传给 curl-cffi Session.get(如 headers, timeout 等)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
curl_cffi.requests.Response 对象
|
|
86
|
+
"""
|
|
87
|
+
headers = kwargs.pop("headers", {})
|
|
88
|
+
|
|
89
|
+
if not self._proxy_manager.enabled:
|
|
90
|
+
# 代理关闭: 直连,不重试
|
|
91
|
+
return self._do_request("get", url, proxies=None, headers=headers, **kwargs)
|
|
92
|
+
|
|
93
|
+
if self._proxy_manager.mode == "tunnel":
|
|
94
|
+
# Tunnel 模式: 使用固定代理,403 重试
|
|
95
|
+
return self._tunnel_request("get", url, headers=headers, **kwargs)
|
|
96
|
+
else:
|
|
97
|
+
# Private 模式: 动态代理,403 切换
|
|
98
|
+
return self._private_request("get", url, headers=headers, **kwargs)
|
|
99
|
+
|
|
100
|
+
def post(self, url: str, **kwargs) -> "curl_requests.Response":
|
|
101
|
+
"""
|
|
102
|
+
发送 POST 请求,自动注入 TLS 指纹。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
url: 请求 URL
|
|
106
|
+
**kwargs: 透传给 curl-cffi Session.post(如 data, json, timeout 等)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
curl_cffi.requests.Response 对象
|
|
110
|
+
"""
|
|
111
|
+
headers = kwargs.pop("headers", {})
|
|
112
|
+
|
|
113
|
+
if not self._proxy_manager.enabled:
|
|
114
|
+
# 代理关闭: 直连,不重试
|
|
115
|
+
return self._do_request("post", url, proxies=None, headers=headers, **kwargs)
|
|
116
|
+
|
|
117
|
+
if self._proxy_manager.mode == "tunnel":
|
|
118
|
+
# Tunnel 模式: 使用固定代理,403 重试
|
|
119
|
+
return self._tunnel_request("post", url, headers=headers, **kwargs)
|
|
120
|
+
else:
|
|
121
|
+
# Private 模式: 动态代理,403 切换
|
|
122
|
+
return self._private_request("post", url, headers=headers, **kwargs)
|
|
123
|
+
|
|
124
|
+
def _tunnel_request(self, method: str, url: str, headers: Dict, **kwargs):
|
|
125
|
+
"""Tunnel 模式请求: 固定代理,异常或403重试,共用3次重试"""
|
|
126
|
+
for attempt in range(1, 4):
|
|
127
|
+
proxy = self._proxy_manager.resolve()
|
|
128
|
+
try:
|
|
129
|
+
resp = self._do_request(method, url, proxies=proxy, headers=headers, **kwargs)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
if attempt == 3:
|
|
132
|
+
raise
|
|
133
|
+
error(f"[tunnel] 请求异常 (attempt {attempt}/3): {e}")
|
|
134
|
+
continue
|
|
135
|
+
if resp.status_code != 403:
|
|
136
|
+
info(f"[tunnel] 请求成功 (attempt {attempt}/3): status={resp.status_code}, resp={resp.text[:200] if resp.text else ''}")
|
|
137
|
+
return resp
|
|
138
|
+
info(f"[tunnel] 请求返回 403 (attempt {attempt}/3): resp={resp.text[:200] if resp.text else ''}")
|
|
139
|
+
if attempt == 3:
|
|
140
|
+
break
|
|
141
|
+
raise ProxyExhaustedError("tunnel 模式请求达到最大重试次数")
|
|
142
|
+
|
|
143
|
+
def _private_request(self, method: str, url: str, headers: Dict, **kwargs):
|
|
144
|
+
"""Private 模式请求: 动态代理,异常或403切换,共用3次重试"""
|
|
145
|
+
for attempt in range(1, 4):
|
|
146
|
+
proxy = self._proxy_manager.resolve()
|
|
147
|
+
try:
|
|
148
|
+
resp = self._do_request(method, url, proxies=proxy, headers=headers, **kwargs)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
if attempt == 3:
|
|
151
|
+
raise
|
|
152
|
+
error(f"[private] 请求异常 (attempt {attempt}/3): {e}")
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if resp.status_code != 403:
|
|
156
|
+
info(f"[private] 请求成功 (attempt {attempt}/3): status={resp.status_code}, resp={resp.text[:200] if resp.text else ''}")
|
|
157
|
+
return resp
|
|
158
|
+
|
|
159
|
+
# 403: 上报并切换代理
|
|
160
|
+
info(f"[private] 请求返回 403 (attempt {attempt}/3): resp={resp.text[:200] if resp.text else ''}")
|
|
161
|
+
proxy_info = self._extract_proxy_info(proxy)
|
|
162
|
+
if proxy_info:
|
|
163
|
+
self._proxy_manager.report_block(proxy_info)
|
|
164
|
+
|
|
165
|
+
if attempt == 3:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
raise ProxyExhaustedError("private 模式请求达到最大重试次数")
|
|
169
|
+
|
|
170
|
+
def _do_request(self, method: str, url: str, proxies: Optional[Dict[str, str]], headers: Dict, **kwargs):
|
|
171
|
+
"""执行 HTTP 请求"""
|
|
172
|
+
if method == "get":
|
|
173
|
+
return self._session.get(url, impersonate=self._tls_name, headers=headers, proxies=proxies, **kwargs)
|
|
174
|
+
else:
|
|
175
|
+
return self._session.post(url, impersonate=self._tls_name, headers=headers, proxies=proxies, **kwargs)
|
|
176
|
+
|
|
177
|
+
def _extract_proxy_info(self, proxy: Optional[Dict[str, str]]) -> Optional[Dict[str, Any]]:
|
|
178
|
+
"""从代理 URL 中提取 ip 和 port 信息"""
|
|
179
|
+
if not proxy:
|
|
180
|
+
return None
|
|
181
|
+
http_proxy = proxy.get("http", "")
|
|
182
|
+
if not http_proxy:
|
|
183
|
+
return None
|
|
184
|
+
# 格式: http://user:pass@ip:port 或 http://ip:port
|
|
185
|
+
import re
|
|
186
|
+
match = re.search(r"@([\d.]+):(\d+)", http_proxy)
|
|
187
|
+
if match:
|
|
188
|
+
return {"ip": match.group(1), "port": match.group(2)}
|
|
189
|
+
match = re.search(r"://([\d.]+):(\d+)", http_proxy)
|
|
190
|
+
if match:
|
|
191
|
+
return {"ip": match.group(1), "port": match.group(2)}
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
"""关闭底层 Session。"""
|
|
196
|
+
self._session.close()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crawlerkeeper-sdk
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: CrawlerKeeper 爬虫 SDK:日志上报、结果写入、任务参数解析、TLS 指纹请求、代理管理与并发线程池
|
|
5
|
+
Author: CrawlerKeeper Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: crawler,crawlerkeeper,scraping,spider,tls-fingerprint
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: curl-cffi>=0.7.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# CrawlerKeeper SDK for Python
|
|
23
|
+
|
|
24
|
+
CrawlerKeeper Python SDK 提供了爬虫与 Agent 通信的封装,支持日志记录、结果写入、参数获取、代理管理和并发爬取。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install crawlerkeeper-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 快速开始
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from crawlerkeeper_sdk import params, info, result, StealthSession
|
|
36
|
+
|
|
37
|
+
# 获取任务参数
|
|
38
|
+
config = params()
|
|
39
|
+
info(f"收到参数: {config}")
|
|
40
|
+
|
|
41
|
+
# 使用 StealthSession 发送请求
|
|
42
|
+
session = StealthSession()
|
|
43
|
+
resp = session.get("https://example.com/api/data")
|
|
44
|
+
data = resp.json()
|
|
45
|
+
|
|
46
|
+
# 写入结果
|
|
47
|
+
result({"data": data, "source": "example.com"})
|
|
48
|
+
|
|
49
|
+
info("爬取完成")
|
|
50
|
+
session.close()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 核心功能
|
|
54
|
+
|
|
55
|
+
### 日志记录
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from crawlerkeeper_sdk import info, warning, error
|
|
59
|
+
|
|
60
|
+
info("信息日志")
|
|
61
|
+
warning("警告日志")
|
|
62
|
+
error("错误日志")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 结果写入
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from crawlerkeeper_sdk import result
|
|
69
|
+
|
|
70
|
+
result({"count": 100, "items": [...]})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 任务参数
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from crawlerkeeper_sdk import params
|
|
77
|
+
|
|
78
|
+
# python spider.py --params '{"keyword": "python", "page": 1}'
|
|
79
|
+
config = params() # {"keyword": "python", "page": 1}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## StealthSession
|
|
83
|
+
|
|
84
|
+
`StealthSession` 提供隐式浏览器指纹的 HTTP 请求能力,自动管理 TLS 指纹和代理。
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from crawlerkeeper_sdk import StealthSession, result
|
|
88
|
+
|
|
89
|
+
session = StealthSession()
|
|
90
|
+
|
|
91
|
+
# GET 请求
|
|
92
|
+
resp = session.get("https://example.com/api/data")
|
|
93
|
+
print(resp.status_code)
|
|
94
|
+
print(resp.json())
|
|
95
|
+
|
|
96
|
+
# POST 请求
|
|
97
|
+
resp = session.post("https://example.com/api/submit", json={"key": "value"})
|
|
98
|
+
|
|
99
|
+
session.close()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 主要方法
|
|
103
|
+
|
|
104
|
+
| 方法 | 说明 |
|
|
105
|
+
|------|------|
|
|
106
|
+
| `get(url, **kwargs)` | 发送 GET 请求 |
|
|
107
|
+
| `post(url, **kwargs)` | 发送 POST 请求 |
|
|
108
|
+
| `close()` | 关闭会话 |
|
|
109
|
+
|
|
110
|
+
## ThreadPool 并发爬取
|
|
111
|
+
|
|
112
|
+
`ThreadPool` 提供并发爬取能力,内部管理 `StealthSession` 的创建和关闭。
|
|
113
|
+
|
|
114
|
+
### 推荐用法:使用上下文管理器
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from crawlerkeeper_sdk import ThreadPool, StealthSession, result
|
|
118
|
+
|
|
119
|
+
def crawl(session: StealthSession, keyword: str, page: int):
|
|
120
|
+
"""爬虫函数:session 会自动传入"""
|
|
121
|
+
resp = session.get(f"https://example.com/search?kw={keyword}&page={page}")
|
|
122
|
+
return resp.json()
|
|
123
|
+
|
|
124
|
+
# 使用 with 语句自动管理生命周期,不会遗漏 close()
|
|
125
|
+
with ThreadPool(max_workers=5) as pool:
|
|
126
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
127
|
+
pool.submit(crawl, keyword="java", page=1)
|
|
128
|
+
pool.submit(crawl, keyword="go", page=1)
|
|
129
|
+
|
|
130
|
+
# 阻塞等待所有任务完成
|
|
131
|
+
results = pool.wait()
|
|
132
|
+
|
|
133
|
+
# 处理结果
|
|
134
|
+
for data in results:
|
|
135
|
+
if data is not None:
|
|
136
|
+
result(data)
|
|
137
|
+
# with 块结束时自动调用 wait() 和 close()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 传统用法
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
pool = ThreadPool(max_workers=5)
|
|
144
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
145
|
+
pool.submit(crawl, keyword="java", page=1)
|
|
146
|
+
|
|
147
|
+
results = pool.wait()
|
|
148
|
+
pool.close() # 需要手动调用
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 环境变量配置并发数
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
export CONCURRENT_NUM=10
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# 不指定 max_workers 时使用 CONCURRENT_NUM 环境变量
|
|
159
|
+
with ThreadPool() as pool:
|
|
160
|
+
pool.submit(crawl, keyword="python", page=1)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### ThreadPool 方法
|
|
164
|
+
|
|
165
|
+
| 方法 | 说明 |
|
|
166
|
+
|------|------|
|
|
167
|
+
| `submit(crawl_fn, **kwargs)` | 提交任务,worker 全满时阻塞等待 |
|
|
168
|
+
| `wait()` | 阻塞等待所有任务完成,返回结果列表 |
|
|
169
|
+
| `close()` | 等待所有任务完成后关闭线程池 |
|
|
170
|
+
|
|
171
|
+
### 异常处理
|
|
172
|
+
|
|
173
|
+
爬虫函数抛出的异常会被捕获并通过 `output.error()` 记录,任务结果记为 `None`,不中断其他任务。
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
def failing_crawl(session):
|
|
177
|
+
raise ValueError("some error")
|
|
178
|
+
|
|
179
|
+
pool = ThreadPool(max_workers=2)
|
|
180
|
+
pool.submit(failing_crawl)
|
|
181
|
+
results = pool.wait() # results[0] is None
|
|
182
|
+
pool.close()
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 代理配置
|
|
186
|
+
|
|
187
|
+
`StealthSession` 的代理行为由环境变量控制(通常由 Agent 下发),不支持按请求传入代理参数:
|
|
188
|
+
|
|
189
|
+
| 环境变量 | 说明 |
|
|
190
|
+
|---------|------|
|
|
191
|
+
| `PROXY_ENABLED` | `on` 开启代理,其他值直连 |
|
|
192
|
+
| `PROXY_MODE` | `tunnel` 静态代理 / `private` 管理端动态分配 |
|
|
193
|
+
| `PROXY_URL` | tunnel 模式的静态代理 URL |
|
|
194
|
+
| `PROXY_VENDOR` | private 模式的供应商标识 |
|
|
195
|
+
| `PROXY_TARGET_SITE` | private 模式的目标站点标识 |
|
|
196
|
+
| `ADMIN_SERVER_URL` | private 模式下管理端地址 |
|
|
197
|
+
| `AGENT_API_KEY` | private 模式下调用管理端的第三方 Token(`td_` 前缀;代理分配/上报接口仅对第三方 Token 开放,节点 `ck_` Key 无权调用) |
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
# tunnel 模式示例
|
|
201
|
+
export PROXY_ENABLED=on
|
|
202
|
+
export PROXY_MODE=tunnel
|
|
203
|
+
export PROXY_URL=http://proxy:8080
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
private 模式下代理用尽或持续被目标站封禁时,`get`/`post` 抛出 `ProxyExhaustedError`。
|
|
207
|
+
|
|
208
|
+
## 运行模式
|
|
209
|
+
|
|
210
|
+
SDK 自动检测运行模式:
|
|
211
|
+
|
|
212
|
+
- **Agent 模式**: 当 `LOG_FILE_PATH` 和 `DATA_FILE_PATH` 环境变量存在时,日志和结果写入文件
|
|
213
|
+
- **开发模式**: 环境变量不存在时,输出到控制台
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# 开发模式
|
|
217
|
+
python spider.py
|
|
218
|
+
|
|
219
|
+
# Agent 模式
|
|
220
|
+
export LOG_FILE_PATH=/tmp/test.log
|
|
221
|
+
export DATA_FILE_PATH=/tmp/test.json
|
|
222
|
+
python spider.py
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## 完整示例
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
#!/usr/bin/env python3
|
|
229
|
+
from crawlerkeeper_sdk import params, info, result, StealthSession, ThreadPool
|
|
230
|
+
|
|
231
|
+
def crawl(session: StealthSession, keyword: str, page: int):
|
|
232
|
+
"""爬虫函数"""
|
|
233
|
+
info(f"爬取 {keyword} 第 {page} 页")
|
|
234
|
+
resp = session.get(f"https://example.com/api?kw={keyword}&page={page}")
|
|
235
|
+
return resp.json()
|
|
236
|
+
|
|
237
|
+
def main():
|
|
238
|
+
config = params()
|
|
239
|
+
keywords = config.get("keywords", ["python"])
|
|
240
|
+
max_workers = config.get("max_workers", 3)
|
|
241
|
+
|
|
242
|
+
with ThreadPool(max_workers=max_workers) as pool:
|
|
243
|
+
# 提交所有任务
|
|
244
|
+
for keyword in keywords:
|
|
245
|
+
for page in range(1, 4):
|
|
246
|
+
pool.submit(crawl, keyword=keyword, page=page)
|
|
247
|
+
|
|
248
|
+
# 阻塞等待所有任务完成
|
|
249
|
+
results = pool.wait()
|
|
250
|
+
|
|
251
|
+
# 输出结果
|
|
252
|
+
for data in results:
|
|
253
|
+
if data is not None:
|
|
254
|
+
result(data)
|
|
255
|
+
|
|
256
|
+
info("爬取完成")
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
main()
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## 许可证
|
|
263
|
+
|
|
264
|
+
MIT License
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
crawlerkeeper_sdk/README.md,sha256=HGmr25aTEbBqINiCKd4DT70IdYQd9ZyCBthP0cGkjBQ,6014
|
|
2
|
+
crawlerkeeper_sdk/__init__.py,sha256=T5gFCn8l0MfLMwcH4oeaXyJjXKxskJQO9OA6yJgSuFo,527
|
|
3
|
+
crawlerkeeper_sdk/env.py,sha256=LeZhobgb4lxAtx4lAWsNt1pQ4pFCTBPLJMFlKFfkFrc,954
|
|
4
|
+
crawlerkeeper_sdk/output.py,sha256=NmcRPkr8BiU6p-fvO-ehD3Tbfja8DZpID1arslSDTRI,2489
|
|
5
|
+
crawlerkeeper_sdk/params.py,sha256=pv3t9JrL54WZEIzKKf6Kf9Mueoc5v3By7JbgfA8Cbx8,987
|
|
6
|
+
crawlerkeeper_sdk/pool.py,sha256=LhkRnnY_yB05_r_JRqH7KcbxjlCxwU_5UxJztv2KnOM,3706
|
|
7
|
+
crawlerkeeper_sdk/proxy.py,sha256=0CG9Ot0OKiKJFxuNxFziHZmBkgVOv6BViCFQ7DfG9ZU,5384
|
|
8
|
+
crawlerkeeper_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
crawlerkeeper_sdk/stealth_session.py,sha256=XfvQCh5s9bQ9F-nPD6Zid5A0shmgEZTpktm1oE1j1F0,7003
|
|
10
|
+
crawlerkeeper_sdk-0.2.1.dist-info/METADATA,sha256=LwpTrJfiOjT0oIXRKQ-rdnuYPa2CBtAp3ud9QYDzn7E,6900
|
|
11
|
+
crawlerkeeper_sdk-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
crawlerkeeper_sdk-0.2.1.dist-info/licenses/LICENSE,sha256=QM-S3_7nggIKygDQWfaysv1PqPGsKjHYzC_Q1p_zVLo,1075
|
|
13
|
+
crawlerkeeper_sdk-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrawlerKeeper Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|