迁移目录

This commit is contained in:
hajimi
2026-06-12 22:27:18 +08:00
parent 7381097582
commit cd44cd6e47
92 changed files with 1839 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
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]}",
])