crawlerkeeper-agent 1.0.0__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/__init__.py +2 -0
- crawlerkeeper/__main__.py +65 -0
- crawlerkeeper/agent.py +281 -0
- crawlerkeeper/client.py +112 -0
- crawlerkeeper/code_package.py +45 -0
- crawlerkeeper/config.py +175 -0
- crawlerkeeper/deploy_server.py +286 -0
- crawlerkeeper/deployment.py +310 -0
- crawlerkeeper/executor.py +531 -0
- crawlerkeeper/heartbeat.py +101 -0
- crawlerkeeper/log_monitor.py +120 -0
- crawlerkeeper/logger.py +80 -0
- crawlerkeeper/metrics.py +119 -0
- crawlerkeeper/runtime_scanner.py +113 -0
- crawlerkeeper/task.py +534 -0
- crawlerkeeper_agent-1.0.0.dist-info/METADATA +377 -0
- crawlerkeeper_agent-1.0.0.dist-info/RECORD +20 -0
- crawlerkeeper_agent-1.0.0.dist-info/WHEEL +4 -0
- crawlerkeeper_agent-1.0.0.dist-info/entry_points.txt +2 -0
- crawlerkeeper_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CrawlerKeeper Agent 命令行入口。
|
|
2
|
+
|
|
3
|
+
提供 pip 安装后的 ``crawlerkeeper-agent`` 命令与 ``python -m crawlerkeeper`` 两种启动方式。
|
|
4
|
+
本地源码运行走 ``src/main.py``(薄委托到本模块)。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from crawlerkeeper import __version__
|
|
12
|
+
from crawlerkeeper.agent import Agent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""主函数"""
|
|
17
|
+
# 版本查询:只打印版本号,不启动 Agent
|
|
18
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("-V", "--version"):
|
|
19
|
+
print(f"crawlerkeeper-agent {__version__}")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
print("正在启动 CrawlerKeeper Agent...", flush=True)
|
|
23
|
+
|
|
24
|
+
# 解析命令行参数
|
|
25
|
+
config_file: Optional[str] = None
|
|
26
|
+
extra_config: Dict[str, Any] = {"cleanup_temp_files": True} # 默认开启清理
|
|
27
|
+
i = 1
|
|
28
|
+
while i < len(sys.argv):
|
|
29
|
+
if sys.argv[i] in ['-c', '--config'] and i + 1 < len(sys.argv):
|
|
30
|
+
config_file = sys.argv[i + 1]
|
|
31
|
+
i += 2
|
|
32
|
+
elif sys.argv[i].startswith('--cleanup_temp_files='):
|
|
33
|
+
_, value = sys.argv[i].split('=', 1)
|
|
34
|
+
extra_config['cleanup_temp_files'] = value.lower() == 'on'
|
|
35
|
+
i += 1
|
|
36
|
+
elif sys.argv[i] == '--cleanup_temp_files':
|
|
37
|
+
# 单独出现默认为 on,或取下一个参数值
|
|
38
|
+
if i + 1 < len(sys.argv) and sys.argv[i + 1] in ('on', 'off'):
|
|
39
|
+
extra_config['cleanup_temp_files'] = sys.argv[i + 1].lower() == 'on'
|
|
40
|
+
i += 2
|
|
41
|
+
else:
|
|
42
|
+
extra_config['cleanup_temp_files'] = True
|
|
43
|
+
i += 1
|
|
44
|
+
else:
|
|
45
|
+
i += 1
|
|
46
|
+
|
|
47
|
+
# 如果没有指定配置文件,使用当前工作目录下的默认路径
|
|
48
|
+
if not config_file:
|
|
49
|
+
config_file = str(Path.cwd() / "config" / "config.yaml")
|
|
50
|
+
|
|
51
|
+
print(f"配置文件: {config_file}", flush=True)
|
|
52
|
+
print(f"额外配置: {extra_config}", flush=True)
|
|
53
|
+
|
|
54
|
+
# 创建并启动 Agent
|
|
55
|
+
agent = Agent(config_file if os.path.exists(config_file) else None, extra_config=extra_config)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
agent.start()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"Agent 启动失败: {e}", flush=True)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
main()
|
crawlerkeeper/agent.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Agent 主程序模块"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import signal
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .client import HttpClient
|
|
13
|
+
from .heartbeat import HeartbeatManager
|
|
14
|
+
from .task import TaskManager
|
|
15
|
+
from .executor import SpiderExecutor
|
|
16
|
+
from .deployment import DeploymentManager
|
|
17
|
+
from .logger import setup_logger
|
|
18
|
+
from .metrics import get_metrics_server
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("crawlerkeeper-agent")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Agent:
|
|
24
|
+
"""CrawlerKeeper Agent 主类"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config_file: str = None, extra_config: Optional[Dict[str, Any]] = None):
|
|
27
|
+
self.config = Config(config_file)
|
|
28
|
+
self.extra_config = extra_config or {}
|
|
29
|
+
self.client: Optional[HttpClient] = None
|
|
30
|
+
self.heartbeat_manager: Optional[HeartbeatManager] = None
|
|
31
|
+
self.task_manager: Optional[TaskManager] = None
|
|
32
|
+
self.executor: Optional[SpiderExecutor] = None
|
|
33
|
+
self.deployment_manager: Optional[DeploymentManager] = None
|
|
34
|
+
self.running = False
|
|
35
|
+
self.metrics_server = None
|
|
36
|
+
self._http_server = None
|
|
37
|
+
self._http_thread = None
|
|
38
|
+
|
|
39
|
+
def _start_http_server(self):
|
|
40
|
+
"""启动 HTTP 服务器用于 /metrics 端点"""
|
|
41
|
+
metrics_server = get_metrics_server()
|
|
42
|
+
metrics_server.set_thread_pool_size(self.task_manager.max_workers if self.task_manager else 4)
|
|
43
|
+
|
|
44
|
+
MetricsHandler = type('MetricsHandler', (BaseHTTPRequestHandler,), {
|
|
45
|
+
'do_GET': lambda self: (
|
|
46
|
+
self.send_response(200),
|
|
47
|
+
self.send_header('Content-Type', metrics_server.get_content_type()),
|
|
48
|
+
self.end_headers(),
|
|
49
|
+
self.wfile.write(metrics_server.get_metrics())
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
server_address = ('', self.config.agent_port)
|
|
54
|
+
self._http_server = HTTPServer(server_address, MetricsHandler)
|
|
55
|
+
self._http_thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
|
56
|
+
self._http_thread.start()
|
|
57
|
+
logger.info(f"指标 HTTP 服务器已启动,端口: {self.config.agent_port}")
|
|
58
|
+
|
|
59
|
+
def start(self):
|
|
60
|
+
"""启动 Agent"""
|
|
61
|
+
# 设置日志
|
|
62
|
+
setup_logger(
|
|
63
|
+
level=self.config.log_level,
|
|
64
|
+
log_file=self.config.log_file
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger.info("=" * 50)
|
|
68
|
+
logger.info("CrawlerKeeper Agent 启动")
|
|
69
|
+
logger.info("=" * 50)
|
|
70
|
+
logger.info(f"配置: {self.config}")
|
|
71
|
+
|
|
72
|
+
# 创建 HTTP 客户端
|
|
73
|
+
self.client = HttpClient(
|
|
74
|
+
base_url=self.config.server_url,
|
|
75
|
+
api_key=self.config.api_key,
|
|
76
|
+
timeout=self.config.heartbeat_timeout
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 注册节点
|
|
80
|
+
if not self._register_node():
|
|
81
|
+
logger.error("节点注册失败,Agent 退出")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
# 初始化组件
|
|
85
|
+
self._init_components()
|
|
86
|
+
|
|
87
|
+
# 初始化指标服务器
|
|
88
|
+
self.metrics_server = get_metrics_server()
|
|
89
|
+
|
|
90
|
+
# 启动 HTTP 服务器(用于 /metrics)
|
|
91
|
+
self._start_http_server()
|
|
92
|
+
|
|
93
|
+
# 注册信号处理
|
|
94
|
+
self._register_signal_handlers()
|
|
95
|
+
|
|
96
|
+
# 启动组件
|
|
97
|
+
self._start_components()
|
|
98
|
+
|
|
99
|
+
# 主循环
|
|
100
|
+
self._main_loop()
|
|
101
|
+
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def _register_node(self) -> bool:
|
|
105
|
+
"""注册节点"""
|
|
106
|
+
# 检查是否已有节点 ID
|
|
107
|
+
if self.config.node_id and self.config.api_key:
|
|
108
|
+
logger.info(f"使用已有节点配置: node_id={self.config.node_id}")
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# 注册新节点
|
|
112
|
+
logger.info("开始注册节点...")
|
|
113
|
+
logger.info(f"注册配置: name={self.config.agent_name}, ip={self.config.agent_ip}, port={self.config.agent_port}")
|
|
114
|
+
|
|
115
|
+
url = "/api/nodes/register"
|
|
116
|
+
data = {
|
|
117
|
+
"name": self.config.agent_name,
|
|
118
|
+
"ip": self.config.agent_ip,
|
|
119
|
+
"port": self.config.agent_port,
|
|
120
|
+
"description": f"CrawlerKeeper Agent - {self.config.agent_name}"
|
|
121
|
+
}
|
|
122
|
+
logger.debug(f"注册请求数据: {data}")
|
|
123
|
+
|
|
124
|
+
response = self.client.post(url, data)
|
|
125
|
+
|
|
126
|
+
if not response:
|
|
127
|
+
logger.error("节点注册请求失败")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
if response.get("code") != 200:
|
|
131
|
+
logger.error(f"节点注册失败: {response.get('message')}")
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
# 保存节点信息
|
|
135
|
+
data = response.get("data", {})
|
|
136
|
+
self.config.node_id = data.get("id")
|
|
137
|
+
|
|
138
|
+
if not self.config.node_id:
|
|
139
|
+
logger.error("节点注册响应数据不完整")
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
logger.info(f"节点注册成功: id={self.config.node_id}")
|
|
143
|
+
|
|
144
|
+
# 保存配置
|
|
145
|
+
config_file = os.getenv("AGENT_CONFIG_FILE", "./config/config.yaml")
|
|
146
|
+
self.config.save_to_file(config_file)
|
|
147
|
+
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def _init_components(self):
|
|
151
|
+
"""初始化组件"""
|
|
152
|
+
# 心跳管理器
|
|
153
|
+
self.heartbeat_manager = HeartbeatManager(
|
|
154
|
+
client=self.client,
|
|
155
|
+
node_id=self.config.node_id,
|
|
156
|
+
interval=self.config.heartbeat_interval,
|
|
157
|
+
timeout=self.config.heartbeat_timeout
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 任务执行器
|
|
161
|
+
self.executor = SpiderExecutor(
|
|
162
|
+
client=self.client,
|
|
163
|
+
work_dir=self.config.work_dir,
|
|
164
|
+
task_execute_timeout=self.config.task_execute_timeout
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 任务管理器
|
|
168
|
+
self.task_manager = TaskManager(
|
|
169
|
+
client=self.client,
|
|
170
|
+
node_id=self.config.node_id,
|
|
171
|
+
poll_interval=self.config.task_poll_interval,
|
|
172
|
+
work_dir=self.config.work_dir,
|
|
173
|
+
extra_config=self.extra_config
|
|
174
|
+
)
|
|
175
|
+
self.task_manager.set_executor(self.executor)
|
|
176
|
+
self.executor.set_task_manager(self.task_manager)
|
|
177
|
+
|
|
178
|
+
# 部署管理器
|
|
179
|
+
self.deployment_manager = DeploymentManager(
|
|
180
|
+
client=self.client,
|
|
181
|
+
node_id=self.config.node_id,
|
|
182
|
+
poll_interval=self.config.deploy_poll_interval,
|
|
183
|
+
work_dir=self.config.work_dir
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.info("组件初始化完成")
|
|
187
|
+
|
|
188
|
+
def _start_components(self):
|
|
189
|
+
"""启动组件"""
|
|
190
|
+
# 启动心跳
|
|
191
|
+
self.heartbeat_manager.start()
|
|
192
|
+
|
|
193
|
+
# 启动任务拉取
|
|
194
|
+
self.task_manager.start()
|
|
195
|
+
|
|
196
|
+
# 启动部署拉取
|
|
197
|
+
self.deployment_manager.start()
|
|
198
|
+
|
|
199
|
+
self.running = True
|
|
200
|
+
logger.info("所有组件已启动")
|
|
201
|
+
|
|
202
|
+
def _stop_components(self):
|
|
203
|
+
"""停止组件"""
|
|
204
|
+
logger.info("正在停止组件...")
|
|
205
|
+
|
|
206
|
+
if self.task_manager:
|
|
207
|
+
self.task_manager.stop()
|
|
208
|
+
|
|
209
|
+
if self.heartbeat_manager:
|
|
210
|
+
self.heartbeat_manager.stop()
|
|
211
|
+
|
|
212
|
+
if self.deployment_manager:
|
|
213
|
+
self.deployment_manager.stop()
|
|
214
|
+
|
|
215
|
+
logger.info("所有组件已停止")
|
|
216
|
+
|
|
217
|
+
def _register_signal_handlers(self):
|
|
218
|
+
"""注册信号处理器"""
|
|
219
|
+
def sigterm_handler(signum, frame):
|
|
220
|
+
"""SIGTERM 信号处理器 - 优雅关闭"""
|
|
221
|
+
logger.info(f"收到 SIGTERM 信号: {signum},将进行优雅关闭")
|
|
222
|
+
self.stop(graceful=True)
|
|
223
|
+
|
|
224
|
+
def sigint_handler(signum, frame):
|
|
225
|
+
"""SIGINT 信号处理器 - 强制关闭"""
|
|
226
|
+
logger.info(f"收到 SIGINT 信号: {signum},将强制关闭")
|
|
227
|
+
self.stop(graceful=False)
|
|
228
|
+
|
|
229
|
+
signal.signal(signal.SIGINT, sigint_handler)
|
|
230
|
+
signal.signal(signal.SIGTERM, sigterm_handler)
|
|
231
|
+
|
|
232
|
+
def _main_loop(self):
|
|
233
|
+
"""主循环"""
|
|
234
|
+
logger.info("Agent 运行中,按 Ctrl+C 停止")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
while self.running:
|
|
238
|
+
# 检查心跳状态
|
|
239
|
+
if self.heartbeat_manager and not self.heartbeat_manager.is_alive():
|
|
240
|
+
logger.warning("心跳检测失败,尝试重新注册...")
|
|
241
|
+
if not self._register_node():
|
|
242
|
+
logger.error("重新注册失败")
|
|
243
|
+
|
|
244
|
+
time.sleep(10)
|
|
245
|
+
|
|
246
|
+
except KeyboardInterrupt:
|
|
247
|
+
logger.info("收到键盘中断")
|
|
248
|
+
|
|
249
|
+
def stop(self, graceful: bool = True):
|
|
250
|
+
"""停止 Agent
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
graceful: True 则优雅关闭(等待任务完成),False 则强制关闭
|
|
254
|
+
"""
|
|
255
|
+
logger.info(f"正在停止 Agent (graceful={graceful})...")
|
|
256
|
+
self.running = False
|
|
257
|
+
|
|
258
|
+
# 停止 HTTP 服务器
|
|
259
|
+
if self._http_server:
|
|
260
|
+
self._http_server.shutdown()
|
|
261
|
+
logger.info("指标 HTTP 服务器已关闭")
|
|
262
|
+
|
|
263
|
+
# 停止任务管理器(传入优雅关闭参数)
|
|
264
|
+
if self.task_manager:
|
|
265
|
+
self.task_manager.stop(graceful=graceful)
|
|
266
|
+
|
|
267
|
+
# 停止其他组件
|
|
268
|
+
if self.heartbeat_manager:
|
|
269
|
+
self.heartbeat_manager.stop()
|
|
270
|
+
|
|
271
|
+
if self.deployment_manager:
|
|
272
|
+
self.deployment_manager.stop()
|
|
273
|
+
|
|
274
|
+
logger.info("Agent 已停止")
|
|
275
|
+
|
|
276
|
+
def restart(self):
|
|
277
|
+
"""重启 Agent"""
|
|
278
|
+
logger.info("正在重启 Agent...")
|
|
279
|
+
self.stop()
|
|
280
|
+
time.sleep(2)
|
|
281
|
+
self.start()
|
crawlerkeeper/client.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""HTTP 客户端模块"""
|
|
2
|
+
import logging
|
|
3
|
+
import requests
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
|
|
7
|
+
# 使用根 logger,确保能继承全局配置
|
|
8
|
+
logger = logging.getLogger("crawlerkeeper-agent")
|
|
9
|
+
logger.setLevel(logging.DEBUG)
|
|
10
|
+
logger.propagate = True
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HttpClient:
|
|
14
|
+
"""HTTP 客户端封装"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url: str, api_key: str = None, timeout: int = 30):
|
|
17
|
+
self.base_url = base_url.rstrip('/')
|
|
18
|
+
self.api_key = api_key
|
|
19
|
+
self.timeout = timeout
|
|
20
|
+
self.session = requests.Session()
|
|
21
|
+
|
|
22
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
23
|
+
"""获取请求头"""
|
|
24
|
+
headers = {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"Accept": "application/json"
|
|
27
|
+
}
|
|
28
|
+
if self.api_key:
|
|
29
|
+
headers["Authorization"] = f"ApiKey {self.api_key}"
|
|
30
|
+
return headers
|
|
31
|
+
|
|
32
|
+
def _build_url(self, path: str) -> str:
|
|
33
|
+
"""构建完整 URL"""
|
|
34
|
+
if path.startswith('http'):
|
|
35
|
+
return path
|
|
36
|
+
return f"{self.base_url}{path}"
|
|
37
|
+
|
|
38
|
+
def get(self, path: str, params: Optional[Dict] = None, retry: int = 3) -> Optional[Dict]:
|
|
39
|
+
"""发送 GET 请求"""
|
|
40
|
+
url = self._build_url(path)
|
|
41
|
+
headers = self._get_headers()
|
|
42
|
+
|
|
43
|
+
for attempt in range(retry):
|
|
44
|
+
try:
|
|
45
|
+
response = self.session.get(
|
|
46
|
+
url,
|
|
47
|
+
params=params,
|
|
48
|
+
headers=headers,
|
|
49
|
+
timeout=self.timeout
|
|
50
|
+
)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
result = response.json()
|
|
53
|
+
return result
|
|
54
|
+
except requests.exceptions.RequestException as e:
|
|
55
|
+
logger.warning(f"GET 请求失败 (尝试 {attempt + 1}/{retry}): {url}, 错误: {e}")
|
|
56
|
+
if attempt < retry - 1:
|
|
57
|
+
time.sleep(2 ** attempt) # 指数退避
|
|
58
|
+
else:
|
|
59
|
+
logger.error(f"GET 请求最终失败: {url}")
|
|
60
|
+
return None
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def post(self, path: str, data: Optional[Dict] = None, retry: int = 3) -> Optional[Dict]:
|
|
64
|
+
"""发送 POST 请求"""
|
|
65
|
+
url = self._build_url(path)
|
|
66
|
+
headers = self._get_headers()
|
|
67
|
+
|
|
68
|
+
for attempt in range(retry):
|
|
69
|
+
try:
|
|
70
|
+
response = self.session.post(
|
|
71
|
+
url,
|
|
72
|
+
json=data,
|
|
73
|
+
headers=headers,
|
|
74
|
+
timeout=self.timeout
|
|
75
|
+
)
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
result = response.json()
|
|
78
|
+
return result
|
|
79
|
+
except requests.exceptions.RequestException as e:
|
|
80
|
+
logger.warning(f"POST 请求失败 (尝试 {attempt + 1}/{retry}): {url}, 错误: {e}")
|
|
81
|
+
if attempt < retry - 1:
|
|
82
|
+
time.sleep(2 ** attempt)
|
|
83
|
+
else:
|
|
84
|
+
logger.error(f"POST 请求最终失败: {url}")
|
|
85
|
+
return None
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def get_raw(self, path: str, params: Optional[Dict] = None, retry: int = 3):
|
|
89
|
+
"""发送 GET 请求并返回原始响应(用于下载文件)"""
|
|
90
|
+
url = self._build_url(path)
|
|
91
|
+
headers = self._get_headers()
|
|
92
|
+
# 下载文件时不需要 Accept: application/json
|
|
93
|
+
headers.pop("Accept", None)
|
|
94
|
+
|
|
95
|
+
for attempt in range(retry):
|
|
96
|
+
try:
|
|
97
|
+
response = self.session.get(
|
|
98
|
+
url,
|
|
99
|
+
params=params,
|
|
100
|
+
headers=headers,
|
|
101
|
+
timeout=self.timeout
|
|
102
|
+
)
|
|
103
|
+
response.raise_for_status()
|
|
104
|
+
return response
|
|
105
|
+
except requests.exceptions.RequestException as e:
|
|
106
|
+
logger.warning(f"GET raw 请求失败 (尝试 {attempt + 1}/{retry}): {url}, 错误: {e}")
|
|
107
|
+
if attempt < retry - 1:
|
|
108
|
+
time.sleep(2 ** attempt)
|
|
109
|
+
else:
|
|
110
|
+
logger.error(f"GET raw 请求最终失败: {url}")
|
|
111
|
+
return None
|
|
112
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""爬虫代码包(ZIP)下载校验工具。
|
|
2
|
+
|
|
3
|
+
后端业务异常(如 Git 克隆失败、归属校验失败)会以 HTTP 200 + JSON 响应体返回,
|
|
4
|
+
Agent 若直接把响应体当 ZIP 解压,真实失败原因会被掩盖成「解压失败」。
|
|
5
|
+
下载后先按 ZIP 魔数校验响应体,非 ZIP 时输出可定位的错误提示并按下载失败处理。
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("crawlerkeeper-agent")
|
|
11
|
+
|
|
12
|
+
# ZIP 本地文件头魔数(PK\x03\x04)
|
|
13
|
+
ZIP_MAGIC = b"PK\x03\x04"
|
|
14
|
+
|
|
15
|
+
# 错误响应体日志摘录长度上限
|
|
16
|
+
SNIPPET_LIMIT = 200
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_zip_content(content: Optional[bytes]) -> bool:
|
|
20
|
+
"""判断响应体是否为 ZIP 包
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: 响应体字节
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True 表示响应体以 ZIP 魔数开头
|
|
27
|
+
"""
|
|
28
|
+
if not content:
|
|
29
|
+
return False
|
|
30
|
+
return bool(content.startswith(ZIP_MAGIC))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def body_snippet(content: Optional[bytes]) -> str:
|
|
34
|
+
"""提取响应体文本摘录(用于非 ZIP 响应时的错误定位)
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
content: 响应体字节
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
可读文本摘录(最多 200 字符),空响应体返回占位说明
|
|
41
|
+
"""
|
|
42
|
+
if not content:
|
|
43
|
+
return "<空响应体>"
|
|
44
|
+
text = content.decode("utf-8", errors="replace").strip()
|
|
45
|
+
return text[:SNIPPET_LIMIT]
|
crawlerkeeper/config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""配置管理模块"""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import yaml
|
|
7
|
+
import socket
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("crawlerkeeper-agent")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
"""Agent 配置类"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config_file: str = None):
|
|
17
|
+
# 默认配置
|
|
18
|
+
self.server_url: str = os.getenv("AGENT_SERVER_URL", "http://localhost:8080")
|
|
19
|
+
self.api_key: str = os.getenv("AGENT_API_KEY", "")
|
|
20
|
+
|
|
21
|
+
self.agent_name: str = os.getenv("AGENT_NAME", socket.gethostname())
|
|
22
|
+
self.agent_ip: str = os.getenv("AGENT_IP", self._get_local_ip())
|
|
23
|
+
self.agent_port: int = int(os.getenv("AGENT_PORT", "9000"))
|
|
24
|
+
|
|
25
|
+
self.heartbeat_interval: int = int(os.getenv("AGENT_HEARTBEAT_INTERVAL", "30"))
|
|
26
|
+
self.heartbeat_timeout: int = int(os.getenv("AGENT_HEARTBEAT_TIMEOUT", "90"))
|
|
27
|
+
|
|
28
|
+
self.task_poll_interval: int = int(os.getenv("AGENT_TASK_POLL_INTERVAL", "10"))
|
|
29
|
+
self.deploy_poll_interval: int = int(os.getenv("AGENT_DEPLOY_POLL_INTERVAL", "30"))
|
|
30
|
+
# 任务执行超时(秒):0 表示不限时;负值视为配置错误,按 0 处理(避免立即超时误杀任务)
|
|
31
|
+
self.task_execute_timeout: int = int(os.getenv("AGENT_TASK_EXECUTE_TIMEOUT", "3600"))
|
|
32
|
+
if self.task_execute_timeout < 0:
|
|
33
|
+
logger.warning(f"AGENT_TASK_EXECUTE_TIMEOUT 配置为负数({self.task_execute_timeout}),按 0(不限时)处理")
|
|
34
|
+
self.task_execute_timeout = 0
|
|
35
|
+
self.work_dir: str = os.getenv("AGENT_WORK_DIR", "./data/spiders")
|
|
36
|
+
# 注意:最大并行任务数不通过配置文件设置,由 TaskManager 根据
|
|
37
|
+
# AGENT_MAX_WORKERS 环境变量或容器 CPU 限制自动计算
|
|
38
|
+
|
|
39
|
+
self.log_level: str = os.getenv("AGENT_LOG_LEVEL", "INFO")
|
|
40
|
+
self.log_file: str = os.getenv("AGENT_LOG_FILE", "./logs/agent.log")
|
|
41
|
+
|
|
42
|
+
self.node_id: Optional[int] = None
|
|
43
|
+
self.server_url_from_register: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
# 加载配置文件
|
|
46
|
+
if config_file:
|
|
47
|
+
self._load_from_file(config_file)
|
|
48
|
+
|
|
49
|
+
def _get_local_ip(self) -> str:
|
|
50
|
+
"""获取本机 IP 地址"""
|
|
51
|
+
try:
|
|
52
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
53
|
+
s.settimeout(3) # 3秒超时
|
|
54
|
+
s.connect(("8.8.8.8", 80))
|
|
55
|
+
ip = s.getsockname()[0]
|
|
56
|
+
s.close()
|
|
57
|
+
return ip
|
|
58
|
+
except Exception:
|
|
59
|
+
return "127.0.0.1"
|
|
60
|
+
|
|
61
|
+
def _load_from_file(self, config_file: str):
|
|
62
|
+
"""从 YAML 配置文件加载配置"""
|
|
63
|
+
config_path = Path(config_file)
|
|
64
|
+
if not config_path.exists():
|
|
65
|
+
logger.warning(f"配置文件不存在: {config_file}")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
70
|
+
config = yaml.safe_load(f)
|
|
71
|
+
|
|
72
|
+
if not config:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# 主控系统配置
|
|
76
|
+
if 'server' in config:
|
|
77
|
+
server = config['server']
|
|
78
|
+
if 'url' in server and not os.getenv("AGENT_SERVER_URL"):
|
|
79
|
+
self.server_url = server['url']
|
|
80
|
+
|
|
81
|
+
# Agent 配置
|
|
82
|
+
if 'agent' in config:
|
|
83
|
+
agent = config['agent']
|
|
84
|
+
# 只有当环境变量未设置且配置文件值不为空时才使用配置文件的值
|
|
85
|
+
if 'name' in agent and agent['name'] and not os.getenv("AGENT_NAME"):
|
|
86
|
+
self.agent_name = agent['name']
|
|
87
|
+
if 'ip' in agent and agent.get('ip') and not os.getenv("AGENT_IP"):
|
|
88
|
+
self.agent_ip = agent.get('ip')
|
|
89
|
+
if 'port' in agent and agent.get('port') and not os.getenv("AGENT_PORT"):
|
|
90
|
+
try:
|
|
91
|
+
self.agent_port = int(agent['port'])
|
|
92
|
+
except (ValueError, TypeError):
|
|
93
|
+
pass # 保持默认值
|
|
94
|
+
if 'api_key' in agent and agent['api_key'] and not os.getenv("AGENT_API_KEY"):
|
|
95
|
+
self.api_key = agent['api_key']
|
|
96
|
+
|
|
97
|
+
# 心跳配置
|
|
98
|
+
if 'heartbeat' in config:
|
|
99
|
+
hb = config['heartbeat']
|
|
100
|
+
if 'interval' in hb and not os.getenv("AGENT_HEARTBEAT_INTERVAL"):
|
|
101
|
+
self.heartbeat_interval = hb['interval']
|
|
102
|
+
if 'timeout' in hb and not os.getenv("AGENT_HEARTBEAT_TIMEOUT"):
|
|
103
|
+
self.heartbeat_timeout = hb['timeout']
|
|
104
|
+
|
|
105
|
+
# 任务配置
|
|
106
|
+
if 'task' in config:
|
|
107
|
+
task = config['task']
|
|
108
|
+
if 'poll_interval' in task and not os.getenv("AGENT_TASK_POLL_INTERVAL"):
|
|
109
|
+
self.task_poll_interval = task['poll_interval']
|
|
110
|
+
if 'deploy_poll_interval' in task and not os.getenv("AGENT_DEPLOY_POLL_INTERVAL"):
|
|
111
|
+
self.deploy_poll_interval = task['deploy_poll_interval']
|
|
112
|
+
if 'work_dir' in task and not os.getenv("AGENT_WORK_DIR"):
|
|
113
|
+
self.work_dir = task['work_dir']
|
|
114
|
+
|
|
115
|
+
# 日志配置
|
|
116
|
+
if 'logging' in config:
|
|
117
|
+
log = config['logging']
|
|
118
|
+
if 'level' in log and not os.getenv("AGENT_LOG_LEVEL"):
|
|
119
|
+
self.log_level = log['level']
|
|
120
|
+
if 'file' in log and not os.getenv("AGENT_LOG_FILE"):
|
|
121
|
+
self.log_file = log['file']
|
|
122
|
+
|
|
123
|
+
if self.api_key and not os.getenv("AGENT_API_KEY"):
|
|
124
|
+
os.environ["AGENT_API_KEY"] = self.api_key
|
|
125
|
+
|
|
126
|
+
logger.info(f"已从配置文件加载配置: {config_file}")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"加载配置文件失败: {e}")
|
|
129
|
+
|
|
130
|
+
def save_to_file(self, config_file: str):
|
|
131
|
+
"""保存配置到文件"""
|
|
132
|
+
config_path = Path(config_file)
|
|
133
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
config = {
|
|
136
|
+
'server': {
|
|
137
|
+
'url': self.server_url
|
|
138
|
+
},
|
|
139
|
+
'agent': {
|
|
140
|
+
'name': self.agent_name,
|
|
141
|
+
'ip': self.agent_ip,
|
|
142
|
+
'port': self.agent_port,
|
|
143
|
+
'api_key': self.api_key
|
|
144
|
+
},
|
|
145
|
+
'heartbeat': {
|
|
146
|
+
'interval': self.heartbeat_interval,
|
|
147
|
+
'timeout': self.heartbeat_timeout
|
|
148
|
+
},
|
|
149
|
+
'task': {
|
|
150
|
+
'poll_interval': self.task_poll_interval,
|
|
151
|
+
'deploy_poll_interval': self.deploy_poll_interval,
|
|
152
|
+
'work_dir': self.work_dir
|
|
153
|
+
},
|
|
154
|
+
'logging': {
|
|
155
|
+
'level': self.log_level,
|
|
156
|
+
'file': self.log_file
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
162
|
+
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
|
|
163
|
+
logger.info(f"配置已保存到: {config_file}")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error(f"保存配置文件失败: {e}")
|
|
166
|
+
|
|
167
|
+
def get_auth_headers(self) -> dict:
|
|
168
|
+
"""获取认证 Header"""
|
|
169
|
+
if self.api_key:
|
|
170
|
+
return {"Authorization": f"ApiKey {self.api_key}"}
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
def __repr__(self):
|
|
174
|
+
return (f"Config(server_url={self.server_url}, agent_name={self.agent_name}, "
|
|
175
|
+
f"agent_ip={self.agent_ip}, node_id={self.node_id})")
|