Files
sbnews/docker/crawler/src/core/alert_manager.py
T
2026-06-12 22:27:18 +08:00

184 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from datetime import datetime
from typing import Any, Dict, Optional
import aiohttp
from loguru import logger
from src.core.config import get_config
from src.storage.database import Database
class CrontabAlertManager:
def __init__(self, db: Optional[Database] = None):
self._cfg = get_config().alert
self._db = db or Database()
@staticmethod
def build_dedupe_key(crontab_id: int, alert_type: str) -> str:
return f"crontab:{int(crontab_id)}:{alert_type}"
@staticmethod
def _pick_http_status(severe_http_statuses: list[int]) -> int:
statuses = [int(item) for item in severe_http_statuses if item is not None]
if not statuses:
return 0
return sorted(statuses)[0]
async def record_crawler_result(
self,
action: str,
crontab_id: int,
crontab_log_id: int,
task_result: Dict[str, Any],
output: str,
):
await self._db.ensure_alert_tables()
if action == "error_report":
return
policy = task_result.get("policy", "saved_required")
candidate_count = int(task_result.get("candidate_count") or 0)
saved_count = int(task_result.get("saved_count") or 0)
http_error_count = int(task_result.get("http_error_count") or 0)
severe_http_statuses = list(task_result.get("severe_http_statuses") or [])
execution_success = bool(task_result.get("execution_success", True))
success = bool(task_result.get("success"))
error_message = str(task_result.get("error_message") or "")
summary_text = str(task_result.get("summary_text") or "")
alert_type = ""
http_status = 0
if http_error_count > 0:
alert_type = "http_error"
http_status = self._pick_http_status(severe_http_statuses)
elif not execution_success or (not success and error_message):
alert_type = "task_exception"
elif policy == "saved_required" and saved_count <= 0:
alert_type = "no_data"
elif policy == "saved_if_candidates" and candidate_count > 0 and saved_count <= 0:
alert_type = "no_data"
if not alert_type:
await self._db.resolve_crontab_alerts(crontab_id)
return
detail = {
"summary_text": summary_text[:1000],
"candidate_count": candidate_count,
"saved_count": saved_count,
"http_error_count": http_error_count,
"severe_http_statuses": severe_http_statuses,
"error_message": error_message[:1000],
"output_excerpt": output[:2000],
}
await self._db.upsert_crontab_alert(
crontab_id=crontab_id,
crontab_log_id=crontab_log_id,
task_name=str(task_result.get("task_name") or action),
command=str(task_result.get("command") or "crawler"),
params=str(task_result.get("params") or action),
alert_type=alert_type,
http_status=http_status,
detail=detail,
dedupe_key=self.build_dedupe_key(crontab_id, alert_type),
)
async def dispatch_open_alerts(self, limit: int = 50) -> Dict[str, Any]:
await self._db.ensure_alert_tables()
alerts = await self._db.get_dispatchable_crontab_alerts(self._cfg.cooldown_seconds, limit=limit)
if not alerts:
return {
"success": True,
"count": 0,
"candidate_count": 0,
"saved_count": 0,
"summary_text": "无待发送告警",
}
if not self._cfg.dispatch_enabled or not self._cfg.wecom_webhook:
logger.warning(f"[alert] 企业微信告警未启用或 webhook 未配置,当前待发送 {len(alerts)} 条")
return {
"success": True,
"count": 0,
"candidate_count": len(alerts),
"saved_count": 0,
"summary_text": f"企业微信告警未启用或 webhook 未配置,待发送 {len(alerts)} 条",
}
sent = 0
failed = 0
for alert in alerts:
ok = await self._send_wecom_alert(alert)
if ok:
await self._db.mark_crontab_alert_notified(int(alert["id"]))
sent += 1
else:
failed += 1
return {
"success": failed == 0,
"count": sent,
"candidate_count": len(alerts),
"saved_count": sent,
"failed": failed,
"summary_text": f"企业微信告警发送完成:成功 {sent} 条,失败 {failed} 条,待发送 {len(alerts)} 条",
}
async def _send_wecom_alert(self, alert: Dict[str, Any]) -> bool:
message = self._build_markdown(alert)
payload = {
"msgtype": "markdown",
"markdown": {"content": message},
}
timeout = aiohttp.ClientTimeout(total=max(3, int(self._cfg.request_timeout)))
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(self._cfg.wecom_webhook, json=payload) as resp:
body = await resp.text()
if resp.status != 200:
logger.warning(f"[alert] 企业微信发送失败 HTTP {resp.status}: {body[:300]}")
return False
data = json.loads(body)
if int(data.get("errcode", -1)) != 0:
logger.warning(f"[alert] 企业微信返回失败: {body[:300]}")
return False
return True
except Exception as e:
logger.warning(f"[alert] 企业微信发送异常: {e}")
return False
@staticmethod
def _build_markdown(alert: Dict[str, Any]) -> str:
detail_raw = alert.get("detail") or ""
try:
detail = json.loads(detail_raw) if isinstance(detail_raw, str) else (detail_raw or {})
except Exception:
detail = {"summary_text": str(detail_raw)}
def fmt_ts(value: Any) -> str:
try:
ts = int(value or 0)
if ts <= 0:
return "-"
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return "-"
severe = detail.get("severe_http_statuses") or []
http_text = "/".join(str(item) for item in severe) if severe else (str(alert.get("http_status") or "-"))
summary = str(detail.get("summary_text") or detail.get("error_message") or "无摘要").replace("\n", " ")
return "\n".join([
"### Sport Era 定时任务告警",
f"> 任务:`{alert.get('task_name', '')}` / #{alert.get('crontab_id', 0)}",
f"> 命令:`{alert.get('command', '')} {alert.get('params', '')}`",
f"> 规则:`{alert.get('expression') or '-'}`",
f"> 类型:`{alert.get('alert_type', '')}`",
f"> HTTP`{http_text}`",
f"> 保存:`{detail.get('saved_count', 0)}` / 候选:`{detail.get('candidate_count', 0)}` / 请求错误:`{detail.get('http_error_count', 0)}`",
f"> 首次发生:`{fmt_ts(alert.get('first_seen_at'))}`",
f"> 最近发生:`{fmt_ts(alert.get('last_seen_at'))}`",
f"> 摘要:{summary[:1000]}",
])