Files

902 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""
定期自动爬取调度引擎 v4.0
基于 schedule 库 + 独立守护线程的定时任务引擎。
功能:
- 创建/编辑/删除定时爬取任务
- 支持频率:每N分钟、每小时、每天、每周、每月、自定义cron
- 任务持久化到 JSON,支持重启恢复
- 执行日志记录
- 任务执行后可自动触发后续动作(压缩、打包、发送)
推荐安装: pip install schedule croniter
无 schedule 时使用简易 time.sleep 循环作为 fallback。
"""
import json
import time
import threading
import logging
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable, Union
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from enum import Enum
import traceback
# schedule 库(可选)
SCHEDULE_AVAILABLE = False
try:
import schedule # type: ignore
SCHEDULE_AVAILABLE = True
except ImportError:
pass
# croniter(可选,用于 cron 表达式验证)
CRONITER_AVAILABLE = False
try:
from croniter import croniter # type: ignore
CRONITER_AVAILABLE = True
except ImportError:
pass
SKILL_DATA_DIR = Path(__file__).parent.parent / "data"
TASKS_FILE = SKILL_DATA_DIR / "scheduled_tasks.json"
LOGS_DIR = SKILL_DATA_DIR / "scheduler_logs"
SKILL_DATA_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("crawl_scheduler")
logger.setLevel(logging.INFO)
# ==================== 数据结构 ====================
class TaskFrequency(str, Enum):
"""任务频率"""
EVERY_MINUTE = "every_minute"
EVERY_5_MINUTES = "every_5_minutes"
EVERY_10_MINUTES = "every_10_minutes"
EVERY_30_MINUTES = "every_30_minutes"
HOURLY = "hourly"
EVERY_6_HOURS = "every_6_hours"
EVERY_12_HOURS = "every_12_hours"
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
CUSTOM_CRON = "custom_cron" # 需要 cron 表达式
FREQUENCY_LABELS = {
TaskFrequency.EVERY_MINUTE: "每分钟",
TaskFrequency.EVERY_5_MINUTES: "每5分钟",
TaskFrequency.EVERY_10_MINUTES: "每10分钟",
TaskFrequency.EVERY_30_MINUTES: "每30分钟",
TaskFrequency.HOURLY: "每小时",
TaskFrequency.EVERY_6_HOURS: "每6小时",
TaskFrequency.EVERY_12_HOURS: "每12小时",
TaskFrequency.DAILY: "每天",
TaskFrequency.WEEKLY: "每周",
TaskFrequency.MONTHLY: "每月",
TaskFrequency.CUSTOM_CRON: "自定义Cron",
}
class TaskStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
ERROR = "error"
class TaskAction(str, Enum):
CRAWL_ONLY = "crawl_only" # 仅爬取
CRAWL_AND_COMPRESS = "crawl_and_compress" # 爬取+压缩
CRAWL_AND_PACKAGE = "crawl_and_package" # 爬取+打包ZIP
CRAWL_COMPRESS_PACKAGE = "crawl_compress_package" # 全部
CRAWL_SENTIMENT = "crawl_sentiment" # 🆕 v4.3 全网舆情爬虫
CRAWL_SENTIMENT_EXPORT = "crawl_sentiment_export" # 🆕 舆情+导出
@dataclass
class ScheduledTask:
"""定时任务定义"""
task_id: str
name: str
frequency: TaskFrequency = TaskFrequency.DAILY
custom_cron: str = "" # cron 表达式(当 frequency=custom_cron 时)
target_urls: List[str] = field(default_factory=list)
target_keywords: List[str] = field(default_factory=list)
target_institution_types: List[str] = field(default_factory=list)
action: TaskAction = TaskAction.CRAWL_AND_COMPRESS
focus_dimension: str = "全面" # 财务/风险/行业/政策/事件/全面
output_dir: str = ""
max_runs: int = 0 # 0=无限
status: TaskStatus = TaskStatus.ACTIVE
created_at: str = ""
updated_at: str = ""
last_run_at: str = ""
next_run_at: str = ""
run_count: int = 0
error_count: int = 0
last_error: str = ""
# 🆕 v4.3 舆情相关
sentiment_targets: List[str] = field(default_factory=list)
sentiment_categories: List[str] = field(default_factory=list)
sentiment_source_categories: List[str] = field(default_factory=list)
sentiment_days: int = 7
sentiment_positive_only: bool = False
sentiment_negative_only: bool = False
sentiment_max: int = 60
sentiment_export_format: str = "all" # dialog/word/excel/csv/json/all/auto
def to_dict(self) -> Dict[str, Any]:
return {
"task_id": self.task_id,
"name": self.name,
"frequency": self.frequency.value,
"custom_cron": self.custom_cron,
"target_urls": self.target_urls,
"target_keywords": self.target_keywords,
"target_institution_types": self.target_institution_types,
"action": self.action.value,
"focus_dimension": self.focus_dimension,
"output_dir": self.output_dir,
"max_runs": self.max_runs,
"status": self.status.value,
"created_at": self.created_at,
"updated_at": self.updated_at,
"last_run_at": self.last_run_at,
"next_run_at": self.next_run_at,
"run_count": self.run_count,
"error_count": self.error_count,
"last_error": self.last_error,
"sentiment_targets": self.sentiment_targets,
"sentiment_categories": self.sentiment_categories,
"sentiment_source_categories": self.sentiment_source_categories,
"sentiment_days": self.sentiment_days,
"sentiment_positive_only": self.sentiment_positive_only,
"sentiment_negative_only": self.sentiment_negative_only,
"sentiment_max": self.sentiment_max,
"sentiment_export_format": self.sentiment_export_format,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ScheduledTask":
return cls(
task_id=data.get("task_id", ""),
name=data.get("name", ""),
frequency=TaskFrequency(data.get("frequency", "daily")),
custom_cron=data.get("custom_cron", ""),
target_urls=data.get("target_urls", []),
target_keywords=data.get("target_keywords", []),
target_institution_types=data.get("target_institution_types", []),
action=TaskAction(data.get("action", "crawl_and_compress")),
focus_dimension=data.get("focus_dimension", "全面"),
output_dir=data.get("output_dir", ""),
max_runs=data.get("max_runs", 0),
status=TaskStatus(data.get("status", "active")),
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", ""),
last_run_at=data.get("last_run_at", ""),
next_run_at=data.get("next_run_at", ""),
run_count=data.get("run_count", 0),
error_count=data.get("error_count", 0),
last_error=data.get("last_error", ""),
sentiment_targets=data.get("sentiment_targets", []),
sentiment_categories=data.get("sentiment_categories", []),
sentiment_source_categories=data.get("sentiment_source_categories", []),
sentiment_days=data.get("sentiment_days", 7),
sentiment_positive_only=data.get("sentiment_positive_only", False),
sentiment_negative_only=data.get("sentiment_negative_only", False),
sentiment_max=data.get("sentiment_max", 60),
sentiment_export_format=data.get("sentiment_export_format", "all"),
)
# ==================== 任务执行器 ====================
class TaskExecutor:
"""任务执行器 — 在独立线程中执行爬取任务。"""
def __init__(self):
self._running = False
self._current_task_id: Optional[str] = None
def execute(self, task: ScheduledTask) -> Dict[str, Any]:
"""
执行一个定时任务。
返回: {"success": bool, "output_files": [], "error": str, "summary": str}
"""
result = {
"success": False,
"task_id": task.task_id,
"task_name": task.name,
"executed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"output_files": [],
"error": "",
"summary": "",
}
try:
self._current_task_id = task.task_id
# 🆕 v4.3 舆情爬虫特殊 action
if task.action in (TaskAction.CRAWL_SENTIMENT, TaskAction.CRAWL_SENTIMENT_EXPORT):
sentiment_result = self._do_sentiment_crawl(task)
if not sentiment_result:
result["error"] = "舆情爬取未获取到数据"
return result
result["output_files"].extend(sentiment_result.get("files", []))
result["summary"] = sentiment_result.get("summary", "舆情完成")
result["success"] = sentiment_result.get("ok", False)
if not result["success"]:
result["error"] = sentiment_result.get("error", "")
return result
# 1. 执行爬取
scraped_data = self._do_crawl(task)
if not scraped_data:
result["error"] = "爬取未获取到有效数据"
return result
result["output_files"].extend(scraped_data.get("files", []))
# 2. 根据 action 执行后续操作
if task.action in (TaskAction.CRAWL_AND_COMPRESS, TaskAction.CRAWL_COMPRESS_PACKAGE):
compressed = self._do_compress(scraped_data, task)
if compressed:
result["output_files"].append(compressed)
result["summary"] = f"压缩报告已生成: {compressed}"
if task.action in (TaskAction.CRAWL_AND_PACKAGE, TaskAction.CRAWL_COMPRESS_PACKAGE):
packaged = self._do_package(scraped_data, task)
if packaged:
result["output_files"].append(packaged)
result["summary"] += f"\nZIP 包已生成: {packaged}"
if not result["summary"]:
result["summary"] = f"爬取完成,获取 {scraped_data.get('count', 0)} 条数据"
result["success"] = True
except Exception as e:
result["error"] = f"{type(e).__name__}: {str(e)}"
traceback.print_exc()
finally:
self._current_task_id = None
return result
def _do_crawl(self, task: ScheduledTask) -> Dict[str, Any]:
"""执行爬取。"""
data: Dict[str, Any] = {"files": [], "count": 0, "items": []}
# 按 URL 列表爬取
if task.target_urls:
try:
from scraper import FinancialPageScraper
scraper = FinancialPageScraper()
for url in task.target_urls:
try:
content = scraper.scrape_url(url)
if content:
data["items"].append({"url": url, "content": str(content)[:2000]})
data["count"] += 1
except Exception:
pass
except ImportError:
pass
# 按机构类型爬取
if task.target_institution_types:
try:
from batch_institution_crawler import BatchInstitutionCrawler
crawler = BatchInstitutionCrawler()
for inst_type in task.target_institution_types:
results = crawler.crawl_by_type(inst_type)
for r in results:
data["items"].append({"type": inst_type, "name": r.get("name", ""),
"content": str(r.get("content", ""))[:2000]})
data["count"] += 1
except ImportError:
pass
# 按关键词搜索公告/新闻
if task.target_keywords:
try:
from announcement_scraper import AnnouncementManager
mgr = AnnouncementManager()
for kw in task.target_keywords:
anns = mgr.search(kw, limit=10)
for a in anns:
data["items"].append({"keyword": kw, **a})
data["count"] += 1
except ImportError:
pass
# 保存爬取结果到文件
if data["items"]:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = str(SKILL_DATA_DIR / "scheduled_crawls" /
f"{task.task_id}_{timestamp}.json")
Path(output_file).parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
data["files"].append(output_file)
return data
def _do_compress(self, scraped_data: Dict[str, Any],
task: ScheduledTask) -> str:
"""执行内容压缩。"""
try:
from content_compressor import compress_content, CompressConfig
# 合并所有爬取文本
all_texts = []
for item in scraped_data.get("items", []):
if item.get("content"):
all_texts.append(item["content"])
if not all_texts:
return ""
combined = "\n\n---\n\n".join(all_texts[:20])
config = CompressConfig(focus=task.focus_dimension, max_pages=3)
result = compress_content(combined, focus=task.focus_dimension)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = str(SKILL_DATA_DIR / "scheduled_crawls" /
f"{task.task_id}_compressed_{timestamp}.md")
Path(output_file).parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(result.structured_report)
return output_file
except ImportError:
return ""
except Exception:
return ""
def _do_package(self, scraped_data: Dict[str, Any],
task: ScheduledTask) -> str:
"""执行 ZIP 打包。"""
try:
from crawl_packager import CrawlPackager
packager = CrawlPackager()
items = scraped_data.get("items", [])
if not items:
return ""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name = f"scheduled_{task.task_id}_{timestamp}"
zip_path = packager.package(items, zip_name=zip_name,
output_dir=str(SKILL_DATA_DIR / "packages"))
return zip_path
except ImportError:
return ""
except Exception:
return ""
def _do_sentiment_crawl(self, task: ScheduledTask) -> Dict[str, Any]:
"""🆕 v4.3 舆情爬虫分支。结果落盘到 data/sentiment_snapshots。
当 action=CRAWL_SENTIMENT_EXPORT 时额外按 sentiment_export_format 导出。"""
result = {"ok": False, "files": [], "summary": "", "error": ""}
try:
from sentiment_crawler import crawl_sentiment # type: ignore
snapshot = crawl_sentiment(
targets=task.sentiment_targets or None,
categories=task.sentiment_categories or None,
source_categories=task.sentiment_source_categories or None,
days=task.sentiment_days,
positive_only=task.sentiment_positive_only,
negative_only=task.sentiment_negative_only,
max_articles=task.sentiment_max,
)
if not snapshot or not snapshot.articles:
result["error"] = "无结果"
return result
result["files"].append(getattr(snapshot, "extra_path", None) or
str(SKILL_DATA_DIR / "sentiment_snapshots" / f"{snapshot.snapshot_id}.json"))
# 导出
if task.action == TaskAction.CRAWL_SENTIMENT_EXPORT:
try:
from sentiment_exporter import export as export_sentiment # type: ignore
outputs = export_sentiment(snapshot, fmt=task.sentiment_export_format)
for k, v in outputs.items():
if k != "dialog" and v:
result["files"].append(v)
dialog = outputs.get("dialog") or ""
# 也保存一份对话提示作为快速预览
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dl_path = SKILL_DATA_DIR / "sentiment_exports" / f"{snapshot.snapshot_id}_dialog.md"
dl_path.parent.mkdir(parents=True, exist_ok=True)
dl_path.write_text(dialog, encoding="utf-8")
result["files"].append(str(dl_path))
except Exception as e:
logger.warning("导出舆情快照失败: %s", e)
total = len(snapshot.articles)
pos = snapshot.positive_count()
neg = snapshot.negative_count()
result["summary"] = (
f"舆情完成 {total} 条 | 正面 {pos} | 舆情 {neg} | "
f"快照={snapshot.snapshot_id}"
)
result["ok"] = True
except ImportError as e:
result["error"] = f"模块未安装: {e}"
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
return result
# ==================== 调度引擎 ====================
class CrawlScheduler:
"""
定期自动爬取调度引擎。
作为单例运行,在守护线程中持续调度任务。
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, '_initialized'):
return
self._initialized = True
self._tasks: Dict[str, ScheduledTask] = {}
self._executor = TaskExecutor()
self._scheduler_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._running = False
self._load_tasks()
# ---------- 任务管理 ----------
def create_task(self, name: str, frequency: Union[str, TaskFrequency],
target_urls: Optional[List[str]] = None,
target_keywords: Optional[List[str]] = None,
target_institution_types: Optional[List[str]] = None,
action: Union[str, TaskAction] = TaskAction.CRAWL_AND_COMPRESS,
focus_dimension: str = "全面",
custom_cron: str = "",
max_runs: int = 0,
**kwargs) -> ScheduledTask:
"""
创建定时任务。
Args:
name: 任务名称
frequency: 频率 — 'daily'/'hourly'/'weekly'/'every_5_minutes' 等
target_urls: 目标 URL 列表
target_keywords: 目标关键词列表
target_institution_types: 目标机构类型列表
action: 执行动作
focus_dimension: 压缩关注维度
custom_cron: 自定义 cron 表达式
max_runs: 最大执行次数(0=无限)
返回: ScheduledTask
"""
if isinstance(frequency, str):
frequency = TaskFrequency(frequency)
if isinstance(action, str):
action = TaskAction(action)
task_id = f"task_{datetime.now().strftime('%Y%m%d%H%M%S')}_{len(self._tasks)}"
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
task = ScheduledTask(
task_id=task_id,
name=name,
frequency=frequency,
custom_cron=custom_cron,
target_urls=target_urls or [],
target_keywords=target_keywords or [],
target_institution_types=target_institution_types or [],
action=action,
focus_dimension=focus_dimension,
output_dir=kwargs.get("output_dir", ""),
max_runs=max_runs,
status=TaskStatus.ACTIVE,
created_at=now,
updated_at=now,
)
# 计算首次执行时间
task.next_run_at = self._calc_next_run(task)
self._tasks[task_id] = task
self._save_tasks()
self._log_task_event(task_id, "created", f"任务已创建: {name}")
return task
def update_task(self, task_id: str, **kwargs) -> Optional[ScheduledTask]:
"""更新任务配置。"""
task = self._tasks.get(task_id)
if not task:
return None
for key, val in kwargs.items():
if hasattr(task, key):
if key == "frequency" and isinstance(val, str):
val = TaskFrequency(val)
elif key == "action" and isinstance(val, str):
val = TaskAction(val)
elif key == "status" and isinstance(val, str):
val = TaskStatus(val)
setattr(task, key, val)
task.updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
task.next_run_at = self._calc_next_run(task)
self._save_tasks()
return task
def delete_task(self, task_id: str) -> bool:
"""删除任务。"""
if task_id in self._tasks:
del self._tasks[task_id]
self._save_tasks()
self._log_task_event(task_id, "deleted", "任务已删除")
return True
return False
def get_task(self, task_id: str) -> Optional[ScheduledTask]:
"""获取任务详情。"""
return self._tasks.get(task_id)
def list_tasks(self, status: Optional[str] = None) -> List[ScheduledTask]:
"""列出所有任务(可按状态筛选)。"""
tasks = list(self._tasks.values())
if status:
st = TaskStatus(status) if isinstance(status, str) else status
tasks = [t for t in tasks if t.status == st]
return sorted(tasks, key=lambda t: t.created_at, reverse=True)
def pause_task(self, task_id: str) -> bool:
"""暂停任务。"""
return self.update_task(task_id, status=TaskStatus.PAUSED) is not None
def resume_task(self, task_id: str) -> bool:
"""恢复任务。"""
return self.update_task(task_id, status=TaskStatus.ACTIVE) is not None
# ---------- 调度控制 ----------
def start(self):
"""启动调度器(守护线程)。"""
if self._running:
return
self._stop_event.clear()
self._running = True
self._scheduler_thread = threading.Thread(
target=self._scheduler_loop, daemon=True, name="CrawlScheduler"
)
self._scheduler_thread.start()
logger.info("调度器已启动")
def stop(self):
"""停止调度器。"""
if not self._running:
return
self._stop_event.set()
self._running = False
if self._scheduler_thread:
self._scheduler_thread.join(timeout=10)
logger.info("调度器已停止")
def run_once(self) -> Dict[str, Any]:
"""立即执行一次所有到期任务(不启动循环)。"""
results = {}
now = datetime.now()
active_tasks = [t for t in self._tasks.values()
if t.status == TaskStatus.ACTIVE]
for task in active_tasks:
if not task.next_run_at:
continue
try:
next_run = datetime.strptime(task.next_run_at, "%Y-%m-%d %H:%M:%S")
if next_run <= now:
results[task.task_id] = self._execute_task(task)
except ValueError:
continue
return results
def is_running(self) -> bool:
return self._running
# ---------- 内部方法 ----------
def _scheduler_loop(self):
"""调度主循环。"""
# 使用 schedule 库
if SCHEDULE_AVAILABLE:
self._setup_schedule_jobs()
while not self._stop_event.is_set():
schedule.run_pending()
time.sleep(1)
schedule.clear()
else:
# fallback: 简易轮询
while not self._stop_event.is_set():
self.run_once()
time.sleep(30) # 每30秒检查一次
def _setup_schedule_jobs(self):
"""使用 schedule 库注册任务。"""
for task in self._tasks.values():
if task.status != TaskStatus.ACTIVE:
continue
self._register_schedule_job(task)
def _register_schedule_job(self, task: ScheduledTask):
"""在 schedule 库中注册单个任务。"""
job_func = lambda t=task: self._execute_task(t)
freq_map = {
TaskFrequency.EVERY_MINUTE: lambda: schedule.every(1).minutes.do(job_func),
TaskFrequency.EVERY_5_MINUTES: lambda: schedule.every(5).minutes.do(job_func),
TaskFrequency.EVERY_10_MINUTES: lambda: schedule.every(10).minutes.do(job_func),
TaskFrequency.EVERY_30_MINUTES: lambda: schedule.every(30).minutes.do(job_func),
TaskFrequency.HOURLY: lambda: schedule.every(1).hours.do(job_func),
TaskFrequency.EVERY_6_HOURS: lambda: schedule.every(6).hours.do(job_func),
TaskFrequency.EVERY_12_HOURS: lambda: schedule.every(12).hours.do(job_func),
TaskFrequency.DAILY: lambda: schedule.every().day.at("09:00").do(job_func),
TaskFrequency.WEEKLY: lambda: schedule.every().monday.at("09:00").do(job_func),
TaskFrequency.MONTHLY: lambda: schedule.every(30).days.at("09:00").do(job_func),
}
if task.frequency in freq_map:
freq_map[task.frequency]()
elif task.frequency == TaskFrequency.CUSTOM_CRON and task.custom_cron:
# 自定义 cron 使用轮询方式
pass
def _execute_task(self, task: ScheduledTask) -> Dict[str, Any]:
"""执行单个任务并更新状态。"""
self._log_task_event(task.task_id, "running", f"开始执行: {task.name}")
result = self._executor.execute(task)
task.last_run_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
task.run_count += 1
if result["success"]:
task.error_count = 0
task.last_error = ""
self._log_task_event(task.task_id, "completed",
f"执行成功: {result.get('summary', '')}")
else:
task.error_count += 1
task.last_error = result.get("error", "未知错误")
task.status = TaskStatus.ERROR if task.error_count >= 3 else task.status
self._log_task_event(task.task_id, "error",
f"执行失败: {task.last_error}")
# 检查最大执行次数
if task.max_runs > 0 and task.run_count >= task.max_runs:
task.status = TaskStatus.COMPLETED
self._log_task_event(task.task_id, "completed",
f"已达最大执行次数 ({task.max_runs})")
task.next_run_at = self._calc_next_run(task)
task.updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._save_tasks()
return result
def _calc_next_run(self, task: ScheduledTask) -> str:
"""计算下次执行时间。"""
now = datetime.now()
deltas = {
TaskFrequency.EVERY_MINUTE: timedelta(minutes=1),
TaskFrequency.EVERY_5_MINUTES: timedelta(minutes=5),
TaskFrequency.EVERY_10_MINUTES: timedelta(minutes=10),
TaskFrequency.EVERY_30_MINUTES: timedelta(minutes=30),
TaskFrequency.HOURLY: timedelta(hours=1),
TaskFrequency.EVERY_6_HOURS: timedelta(hours=6),
TaskFrequency.EVERY_12_HOURS: timedelta(hours=12),
TaskFrequency.DAILY: timedelta(days=1),
TaskFrequency.WEEKLY: timedelta(weeks=1),
TaskFrequency.MONTHLY: timedelta(days=30),
}
delta = deltas.get(task.frequency, timedelta(days=1))
next_run = now + delta
# 对于每日/每周任务,默认在 09:00 执行
if task.frequency in (TaskFrequency.DAILY, TaskFrequency.WEEKLY, TaskFrequency.MONTHLY):
next_run = next_run.replace(hour=9, minute=0, second=0, microsecond=0)
return next_run.strftime("%Y-%m-%d %H:%M:%S")
# ---------- 持久化 ----------
def _load_tasks(self):
"""从 JSON 文件加载任务。"""
if TASKS_FILE.exists():
try:
with open(TASKS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
for task_data in data.get("tasks", []):
task = ScheduledTask.from_dict(task_data)
self._tasks[task.task_id] = task
logger.info(f"已加载 {len(self._tasks)} 个定时任务")
except Exception as e:
logger.error(f"加载任务文件失败: {e}")
def _save_tasks(self):
"""保存任务到 JSON 文件。"""
try:
data = {
"version": "4.0",
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"task_count": len(self._tasks),
"tasks": [t.to_dict() for t in self._tasks.values()],
}
with open(TASKS_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存任务文件失败: {e}")
def _log_task_event(self, task_id: str, event: str, detail: str):
"""记录任务事件日志。"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [{event.upper()}] task={task_id} {detail}"
logger.info(log_entry)
# 同时写入日志文件
try:
log_date = datetime.now().strftime("%Y%m%d")
log_file = LOGS_DIR / f"scheduler_{log_date}.log"
with open(log_file, 'a', encoding='utf-8') as f:
f.write(log_entry + '\n')
except Exception:
pass
# ==================== 便捷函数 ====================
_scheduler = CrawlScheduler()
def get_scheduler() -> CrawlScheduler:
"""获取全局调度器实例。"""
return _scheduler
def create_scheduled_task(name: str, frequency: str = "daily",
**kwargs) -> ScheduledTask:
"""快速创建定时任务。"""
return _scheduler.create_task(name=name, frequency=frequency, **kwargs)
def list_all_tasks() -> List[ScheduledTask]:
"""列出所有定时任务。"""
return _scheduler.list_tasks()
# ==================== CLI 入口 ====================
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法: python crawl_scheduler.py <命令> [参数]")
print("命令:")
print(" list — 列出所有任务")
print(" create <名称> <频率> — 创建任务")
print(" delete <task_id> — 删除任务")
print(" pause <task_id> — 暂停任务")
print(" resume <task_id> — 恢复任务")
print(" run — 立即执行所有到期任务")
print(" start — 启动调度器守护线程")
print(" stop — 停止调度器")
print()
print("频率: every_minute / every_5_minutes / hourly / daily / weekly / monthly")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "list":
tasks = _scheduler.list_tasks()
if not tasks:
print("📋 暂无定时任务")
else:
print(f"📋 定时任务列表 ({len(tasks)} 个):\n")
for t in tasks:
freq = FREQUENCY_LABELS.get(t.frequency, t.frequency.value)
status_icon = {"active": "🟢", "paused": "🟡",
"completed": "✅", "error": "🔴"}.get(t.status.value, "⚪")
print(f" {status_icon} [{t.task_id}] {t.name}")
print(f" 频率: {freq} | 动作: {t.action.value} | 已执行: {t.run_count}次")
if t.last_run_at:
print(f" 上次: {t.last_run_at}")
if t.next_run_at:
print(f" 下次: {t.next_run_at}")
print()
elif cmd == "create":
if len(sys.argv) < 4:
print("用法: python crawl_scheduler.py create <名称> <频率> [urls...]")
sys.exit(1)
name = sys.argv[2]
freq = sys.argv[3]
urls = sys.argv[4:] if len(sys.argv) > 4 else []
task = _scheduler.create_task(name=name, frequency=freq, target_urls=urls)
print(f"✅ 任务已创建: {task.task_id}")
elif cmd == "delete":
if len(sys.argv) < 3:
print("用法: python crawl_scheduler.py delete <task_id>")
sys.exit(1)
ok = _scheduler.delete_task(sys.argv[2])
print("✅ 已删除" if ok else "❌ 未找到该任务")
elif cmd == "pause":
if len(sys.argv) < 3:
print("用法: python crawl_scheduler.py pause <task_id>")
sys.exit(1)
ok = _scheduler.pause_task(sys.argv[2])
print("✅ 已暂停" if ok else "❌ 未找到该任务")
elif cmd == "resume":
if len(sys.argv) < 3:
print("用法: python crawl_scheduler.py resume <task_id>")
sys.exit(1)
ok = _scheduler.resume_task(sys.argv[2])
print("✅ 已恢复" if ok else "❌ 未找到该任务")
elif cmd == "run":
print("正在执行到期任务...")
results = _scheduler.run_once()
if results:
for tid, res in results.items():
status = "✅" if res.get("success") else "❌"
print(f" {status} {tid}: {res.get('summary', res.get('error', ''))}")
else:
print("没有到期的任务。")
elif cmd == "start":
_scheduler.start()
print("✅ 调度器已启动(守护线程)")
try:
while True:
time.sleep(10)
except KeyboardInterrupt:
_scheduler.stop()
print("\n调度器已停止")
elif cmd == "stop":
_scheduler.stop()
print("✅ 调度器已停止")
else:
print(f"未知命令: {cmd}")