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.
@@ -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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.