7e953afbd2
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式) - 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级) - 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包) - 发布闭环:publishing 放宽至 editor + records/mark-published 接口 - 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底) - 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立) - 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
726 lines
36 KiB
Python
726 lines
36 KiB
Python
"""
|
|
定时任务调度器
|
|
基于 APScheduler,支持在 FastAPI 生命周期内运行定时任务
|
|
"""
|
|
import os, sys, logging, json
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT / 'scripts'))
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
from prompt_loader import get_prompt, get_prompt_params
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from .generator import run_creator_blocking
|
|
from .optimizer import run_optimizer_blocking
|
|
from .collector import run_collector_blocking
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def _set_task_llm_provider(module_id: str):
|
|
"""从 TaskConfig 读取 llm_provider 和 llm_model 并设为环境变量,供子进程和 call_llm 读取"""
|
|
try:
|
|
from ..database import SessionLocal
|
|
from ..models import TaskConfig
|
|
db = SessionLocal()
|
|
cfg = db.query(TaskConfig).filter(TaskConfig.module_id == module_id).first()
|
|
db.close()
|
|
if cfg and cfg.params:
|
|
provider = cfg.params.get("llm_provider")
|
|
if provider:
|
|
os.environ["LLM_TASK_PROVIDER"] = provider
|
|
logger.debug("[%s] LLM provider set to %s", module_id, provider)
|
|
else:
|
|
os.environ.pop("LLM_TASK_PROVIDER", None)
|
|
|
|
model = cfg.params.get("llm_model")
|
|
if model:
|
|
os.environ["LLM_TASK_MODEL"] = model
|
|
logger.debug("[%s] LLM model set to %s", module_id, model)
|
|
else:
|
|
os.environ.pop("LLM_TASK_MODEL", None)
|
|
return
|
|
except Exception:
|
|
pass
|
|
os.environ.pop("LLM_TASK_PROVIDER", None)
|
|
os.environ.pop("LLM_TASK_MODEL", None)
|
|
|
|
MODULES = {
|
|
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
|
|
"scheduled_collect": {"name": "📡 内容采集", "cron": "01:30"},
|
|
"scheduled_generate": {"name": "🤖 内容创作", "cron": "02:00"},
|
|
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"},
|
|
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"},
|
|
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
|
|
"scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"},
|
|
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
|
|
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
|
|
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
|
|
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
|
|
}
|
|
|
|
LOG_FILE_MAP = {
|
|
"scheduled_fetch_trends": "trends",
|
|
"scheduled_collect": "collector",
|
|
"scheduled_generate": "creator",
|
|
"scheduled_optimize": "optimizer",
|
|
"scheduled_optimize_sources": "optimizer_sources",
|
|
"scheduled_metrics_sync": "metrics_sync",
|
|
"scheduled_reset_search_usage": "reset_search_usage",
|
|
"scheduled_task_monitor": "task_monitor",
|
|
"scheduled_rank_tracker": "rank_tracker",
|
|
"scheduled_geo_tracker": "geo_tracker",
|
|
"scheduled_shortvideo": "shortvideo",
|
|
}
|
|
|
|
def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None):
|
|
log_name = LOG_FILE_MAP.get(module_id, module_id)
|
|
log_dir = PROJECT_ROOT / "automation" / "logs"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
log_file = log_dir / f"{log_name}_{datetime.now().strftime('%Y-%m-%d')}.log"
|
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
line = f"[{ts}] [{status.upper()}] {message or ''}"
|
|
if error_trace:
|
|
line += f"\n{error_trace[:500]}"
|
|
try:
|
|
with open(log_file, 'a', encoding='utf-8') as f:
|
|
f.write(line + "\n")
|
|
except Exception:
|
|
pass
|
|
|
|
def _log_task(module_id: str, status: str, message: str = None,
|
|
error_trace: str = None, result_data: dict = None,
|
|
started_at: datetime = None, finished_at: datetime = None,
|
|
triggered_by: str = "scheduler", next_run_time: datetime = None,
|
|
log_id: int = None):
|
|
"""写入/更新 TaskLog。如果 log_id 不为 None 则 UPDATE 已有行,否则 INSERT。返回 log_id。"""
|
|
_log_to_file(module_id, status, message, error_trace)
|
|
try:
|
|
from ..database import SessionLocal
|
|
from ..models import TaskLog
|
|
db = SessionLocal()
|
|
try:
|
|
duration = None
|
|
if started_at and finished_at:
|
|
duration = int((finished_at - started_at).total_seconds())
|
|
if log_id:
|
|
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
|
|
if log:
|
|
log.status = status
|
|
log.message = message
|
|
if error_trace:
|
|
log.error_trace = error_trace
|
|
if result_data is not None:
|
|
log.result_data = result_data
|
|
if finished_at:
|
|
log.finished_at = finished_at
|
|
if duration is not None:
|
|
log.duration = duration
|
|
db.commit()
|
|
return log_id
|
|
log = TaskLog(
|
|
module_id=module_id,
|
|
task_name=MODULES.get(module_id, {}).get("name", module_id),
|
|
status=status,
|
|
message=message,
|
|
error_trace=error_trace,
|
|
triggered_by=triggered_by,
|
|
result_data=result_data or {},
|
|
started_at=started_at or datetime.now(timezone.utc),
|
|
finished_at=finished_at,
|
|
duration=duration,
|
|
next_run_time=next_run_time,
|
|
)
|
|
db.add(log)
|
|
db.commit()
|
|
db.refresh(log)
|
|
return log.id
|
|
finally:
|
|
db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def _wrap_task(module_id: str, target_fn, *args, **kwargs):
|
|
started = datetime.now(timezone.utc)
|
|
status = "running"
|
|
error_trace = None
|
|
result_data = None
|
|
try:
|
|
result = target_fn(*args, **kwargs)
|
|
status = "success"
|
|
if isinstance(result, dict):
|
|
result_data = {k: v for k, v in result.items() if isinstance(v, (str, int, float, bool, list, dict)) and k not in ("stdout", "stderr")}
|
|
return result
|
|
except Exception as e:
|
|
status = "failed"
|
|
import traceback
|
|
error_trace = traceback.format_exc()
|
|
raise
|
|
finally:
|
|
_log_task(module_id, status=status, message=None, error_trace=error_trace,
|
|
result_data=result_data, started_at=started,
|
|
finished_at=datetime.now(timezone.utc))
|
|
|
|
class TaskScheduler:
|
|
def __init__(self):
|
|
self.scheduler = BackgroundScheduler()
|
|
self._started = False
|
|
|
|
def start(self):
|
|
if self._started:
|
|
logger.warning("Scheduler already started")
|
|
return
|
|
from ..database import SessionLocal
|
|
from ..models import TaskConfig
|
|
db = SessionLocal()
|
|
try:
|
|
configs = {c.module_id: c for c in db.query(TaskConfig).all()}
|
|
finally:
|
|
db.close()
|
|
|
|
MODULE_JOBS = [
|
|
("scheduled_fetch_trends", self._run_fetch_trends, "热点趋势"),
|
|
("scheduled_collect", self._run_collect, "内容采集"),
|
|
("scheduled_generate", self._run_generate, "内容创作"),
|
|
("scheduled_optimize", self._run_optimize, "合规审查"),
|
|
("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"),
|
|
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
|
|
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
|
|
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
|
|
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
|
|
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
|
|
]
|
|
|
|
for module_id, fn, name in MODULE_JOBS:
|
|
cfg = configs.get(module_id)
|
|
if cfg and not cfg.enabled:
|
|
logger.info(f"跳过禁用任务: {module_id}")
|
|
continue
|
|
schedule = (cfg.schedule if cfg else None) or MODULES.get(module_id, {}).get("cron", "01:00")
|
|
try:
|
|
hour, minute = map(int, schedule.split(":"))
|
|
except (ValueError, AttributeError):
|
|
hour, minute = 1, 0
|
|
self.scheduler.add_job(
|
|
fn,
|
|
CronTrigger(hour=hour, minute=minute),
|
|
id=module_id,
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True
|
|
)
|
|
logger.info(f"调度任务: {module_id} -> {schedule}")
|
|
|
|
# 每小时运行的任务监控:检测卡死/中断任务
|
|
self.scheduler.add_job(
|
|
self._run_task_monitor,
|
|
CronTrigger(hour='*/1'),
|
|
id='scheduled_task_monitor',
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True
|
|
)
|
|
logger.info("调度任务: scheduled_task_monitor -> 每小时")
|
|
|
|
self.scheduler.start()
|
|
self._started = True
|
|
logger.info("Scheduler started with dynamic schedule from TaskConfig")
|
|
def shutdown(self):
|
|
if self.scheduler.running:
|
|
self.scheduler.shutdown()
|
|
logger.info("Scheduler shut down")
|
|
|
|
def _run_fetch_trends(self):
|
|
"""定时刷新热点趋势(百度/微博/知乎实时热搜 + LLM补充)"""
|
|
_set_task_llm_provider("scheduled_fetch_trends")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_fetch_trends", "running", started_at=started)
|
|
try:
|
|
logger.info("[Scheduled] Fetching hot trends...")
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, str(PROJECT_ROOT / "scripts" / "trends.py")],
|
|
capture_output=True, text=True, timeout=120
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line.strip():
|
|
logger.info("[Trends] %s", line.strip())
|
|
_log_task("scheduled_fetch_trends", "success", log_id=log_id,
|
|
message="趋势刷新成功",
|
|
result_data={"output_lines": len(result.stdout.splitlines())},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
else:
|
|
_log_task("scheduled_fetch_trends", "failed", log_id=log_id,
|
|
message=f"返回码 {result.returncode}",
|
|
error_trace=result.stderr[-500:],
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
except Exception as e:
|
|
_log_task("scheduled_fetch_trends", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[Scheduled] Trends refresh error: %s", e)
|
|
|
|
def _run_generate(self):
|
|
_set_task_llm_provider("scheduled_generate")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_generate", "running", started_at=started)
|
|
try:
|
|
logger.info("[Scheduled] Starting content generation...")
|
|
result = run_creator_blocking()
|
|
logger.info("[Scheduled] Generation completed: %s", result)
|
|
created_id = result.get("topic_id") if isinstance(result, dict) else None
|
|
review_result = None
|
|
if created_id:
|
|
logger.info("[Scheduled] Running compliance review on %s...", created_id)
|
|
review_result = run_optimizer_blocking([created_id])
|
|
if review_result.get("ok"):
|
|
logger.info("[Scheduled] Review completed for %s", created_id)
|
|
_log_task("scheduled_generate", "success", log_id=log_id,
|
|
message=f"创作完成" + (f", 选题 {created_id}" if created_id else ""),
|
|
result_data={"topic_id": created_id} if created_id else {"note": "无待生成的选题"},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
except Exception as e:
|
|
_log_task("scheduled_generate", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[Scheduled] Generation pipeline failed: %s", e)
|
|
|
|
def _run_optimize(self):
|
|
_set_task_llm_provider("scheduled_optimize")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_optimize", "running", started_at=started)
|
|
try:
|
|
logger.info("[Scheduled] Starting compliance review...")
|
|
result = run_optimizer_blocking()
|
|
_log_task("scheduled_optimize", "success", log_id=log_id,
|
|
message="合规审查完成",
|
|
result_data={"processed": result.get("processed", 0), "passed": result.get("passed", 0)},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[Scheduled] Review completed: %s", result)
|
|
except Exception as e:
|
|
_log_task("scheduled_optimize", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[Scheduled] Review failed: %s", e)
|
|
|
|
def _run_collect(self):
|
|
_set_task_llm_provider("scheduled_collect")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_collect", "running", started_at=started)
|
|
try:
|
|
logger.info("[Scheduled] Starting topic collection...")
|
|
result = run_collector_blocking()
|
|
topics_count = result.get("topics_count", 0)
|
|
_log_task("scheduled_collect", "success", log_id=log_id,
|
|
message=f"采集完成,找到 {topics_count} 个选题",
|
|
result_data={"topics_count": topics_count, "output": str(result.get("output", ""))[:200]},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[Scheduled] Collection completed: %s", result.get("output", "")[-200:])
|
|
except Exception as e:
|
|
_log_task("scheduled_collect", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[Scheduled] Collection failed: %s", e)
|
|
|
|
def _run_optimize_sources(self, triggered_by="scheduler"):
|
|
"""AI自动优化采集类别与信息源:对比市场热点和当前配置,给出调整建议"""
|
|
_set_task_llm_provider("scheduled_optimize_sources")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_optimize_sources", "running", started_at=started, triggered_by=triggered_by)
|
|
try:
|
|
logger.info("[Scheduled] Starting source optimization with AI...")
|
|
from .nvidia_client import call_llm
|
|
from ..database import SessionLocal
|
|
from ..models import CollectorCategory, CollectorSource
|
|
from datetime import date
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).all()
|
|
sources = db.query(CollectorSource).filter(CollectorSource.is_active == True).all()
|
|
except Exception:
|
|
logger.warning("[Scheduled] DB not ready for source optimization")
|
|
db.close()
|
|
_log_task("scheduled_optimize_sources", "failed", log_id=log_id,
|
|
message="数据库未就绪", started_at=started, finished_at=datetime.now(timezone.utc))
|
|
return
|
|
|
|
cat_names = [c.name for c in cats]
|
|
src_summary = "\n".join(f"- [{s.source_type}] {s.name}: {s.query or s.url or ''}" for s in sources)
|
|
|
|
prompt = get_prompt("sources_optimization",
|
|
n=len(cat_names),
|
|
cat_names="\n".join(f"- {n}" for n in cat_names),
|
|
n2=len(sources),
|
|
src_summary=src_summary,
|
|
year=datetime.now().year,
|
|
)
|
|
|
|
params = get_prompt_params("sources_optimization")
|
|
resp = call_llm(prompt, temperature=params.get("temperature", 0.5), max_tokens=params.get("max_tokens", 3000))
|
|
if resp.startswith("```"):
|
|
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
|
result = json.loads(resp)
|
|
|
|
# 将AI建议写入系统配置(供运营参考,不自动执行)
|
|
from ..models import SystemConfig
|
|
sc = db.query(SystemConfig).filter(SystemConfig.key == "collector_ai_advice").first()
|
|
if sc:
|
|
sc.value = json.dumps(result, ensure_ascii=False)
|
|
else:
|
|
db.add(SystemConfig(key="collector_ai_advice", value=json.dumps(result, ensure_ascii=False), description="AI每日采集优化建议"))
|
|
db.commit()
|
|
logger.info("[Scheduled] Source AI optimization completed: %s", result.get("summary", ""))
|
|
_log_task("scheduled_optimize_sources", "success", log_id=log_id,
|
|
message=result.get("summary", "优化完成"),
|
|
result_data={"categories_assessed": len(result.get("category_assessment", [])),
|
|
"sources_assessed": len(result.get("source_assessment", [])),
|
|
"suggested_cats": len(result.get("suggested_new_categories", [])),
|
|
"suggested_srcs": len(result.get("suggested_new_sources", []))},
|
|
started_at=started, finished_at=datetime.now(timezone.utc), triggered_by=triggered_by)
|
|
db.close()
|
|
except Exception as e:
|
|
db.close()
|
|
_log_task("scheduled_optimize_sources", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc), triggered_by=triggered_by)
|
|
logger.exception("[Scheduled] Source AI optimization failed: %s", e)
|
|
|
|
def _run_metrics_sync(self, triggered_by="scheduler"):
|
|
"""定时从各平台公开API获取发布文章的效果数据(当前仅支持知乎)"""
|
|
_set_task_llm_provider("scheduled_metrics_sync")
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_metrics_sync", "running", started_at=started, triggered_by=triggered_by)
|
|
try:
|
|
logger.info("[Scheduled] Starting metrics sync (zhihu auto-fetch)...")
|
|
from ..database import SessionLocal
|
|
from ..models import Topic, ContentMetrics
|
|
import re, requests as http_requests
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topics = db.query(Topic).filter(
|
|
Topic.status.in_(["published", "已发布"])
|
|
).all()
|
|
except Exception:
|
|
logger.warning("[Scheduled] DB not ready for metrics sync")
|
|
db.close()
|
|
_log_task("scheduled_metrics_sync", "failed", log_id=log_id,
|
|
message="数据库未就绪", started_at=started, finished_at=datetime.now(timezone.utc))
|
|
return
|
|
|
|
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
count = 0
|
|
for topic in topics:
|
|
platform_urls = topic.platform_urls or {}
|
|
for platform, url in platform_urls.items():
|
|
if not url:
|
|
continue
|
|
if platform == "zhihu":
|
|
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
|
|
if not m:
|
|
continue
|
|
post_id = m.group(1)
|
|
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
|
|
try:
|
|
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
|
|
if resp.status_code != 200:
|
|
continue
|
|
raw = resp.json()
|
|
existing = db.query(ContentMetrics).filter(
|
|
ContentMetrics.topic_id == topic.id,
|
|
ContentMetrics.platform == "zhihu"
|
|
).first()
|
|
metric_data = {
|
|
"views": raw.get("voteup_count", raw.get("views_count", 0)),
|
|
"likes": raw.get("voteup_count", 0),
|
|
"favorites": raw.get("favorite_count", 0),
|
|
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
|
|
"shares": raw.get("share_count", 0),
|
|
"last_fetched": datetime.now(),
|
|
"publish_url": url,
|
|
"data_snapshot": raw,
|
|
}
|
|
if existing:
|
|
for k, v in metric_data.items():
|
|
setattr(existing, k, v)
|
|
else:
|
|
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
|
|
count += 1
|
|
except Exception:
|
|
continue
|
|
elif platform in ("wechat_mp", "xiaohongshu"):
|
|
logger.info("平台 %s 无公开指标 API,跳过", platform)
|
|
continue
|
|
else:
|
|
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
|
|
continue
|
|
if count:
|
|
db.commit()
|
|
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
|
|
_log_task("scheduled_metrics_sync", "success", log_id=log_id,
|
|
message=f"同步完成,{count} 篇知乎文章",
|
|
result_data={"articles_synced": count},
|
|
started_at=started, finished_at=datetime.now(timezone.utc), triggered_by=triggered_by)
|
|
# 生成指标反馈:按 field 聚合表现,写入 metrics_feedback.json 供 collector 读取
|
|
try:
|
|
import json as json_mod
|
|
from sqlalchemy import func as sql_func
|
|
feedback = db.query(
|
|
Topic.field,
|
|
sql_func.avg(ContentMetrics.likes).label("avg_likes"),
|
|
sql_func.avg(ContentMetrics.views).label("avg_views"),
|
|
sql_func.avg(ContentMetrics.comments).label("avg_comments"),
|
|
sql_func.count(ContentMetrics.id).label("article_count"),
|
|
).join(ContentMetrics, ContentMetrics.topic_id == Topic.id
|
|
).filter(Topic.field.isnot(None), Topic.field != ""
|
|
).group_by(Topic.field).all()
|
|
if feedback:
|
|
scored = []
|
|
for row in feedback:
|
|
score = (row.avg_likes or 0) + (row.avg_views or 0) * 0.01 + (row.avg_comments or 0) * 2
|
|
scored.append((row.field, round(score, 1), int(row.article_count)))
|
|
scored.sort(key=lambda x: x[1], reverse=True)
|
|
feedback_data = {
|
|
"updated_at": datetime.now().isoformat(),
|
|
"top_domains": [(f, s) for f, s, _ in scored[:5]],
|
|
"detail": [{"field": f, "score": s, "articles": c} for f, s, c in scored],
|
|
}
|
|
feedback_file = PROJECT_ROOT / "automation" / "data" / "metrics_feedback.json"
|
|
feedback_file.parent.mkdir(parents=True, exist_ok=True)
|
|
feedback_file.write_text(json_mod.dumps(feedback_data, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
logger.info("[Scheduled] Metrics feedback written: top domain %s (score %.1f)", scored[0][0], scored[0][1])
|
|
except Exception as e_fb:
|
|
logger.warning("[Scheduled] Metrics feedback generation failed: %s", e_fb)
|
|
else:
|
|
_log_task("scheduled_metrics_sync", "success", log_id=log_id,
|
|
message="无已发布的知乎文章",
|
|
started_at=started, finished_at=datetime.now(timezone.utc), triggered_by=triggered_by)
|
|
logger.info("[Scheduled] Metrics sync: no zhihu articles to sync")
|
|
db.close()
|
|
except Exception as e:
|
|
db.close()
|
|
_log_task("scheduled_metrics_sync", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc), triggered_by=triggered_by)
|
|
logger.exception("[Scheduled] Metrics sync failed: %s", e)
|
|
|
|
def _run_reset_search_usage(self):
|
|
"""每日凌晨重置搜索 API 提供商用量计数"""
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_reset_search_usage", "running", started_at=started)
|
|
try:
|
|
from ..database import SessionLocal
|
|
from ..models import SearchProvider
|
|
db = SessionLocal()
|
|
try:
|
|
total = db.query(SearchProvider).update({SearchProvider.usage_today: 0, SearchProvider.last_used_at: None})
|
|
db.commit()
|
|
_log_task("scheduled_reset_search_usage", "success", log_id=log_id,
|
|
message=f"已重置 {total} 个提供商用量",
|
|
result_data={"reset_count": total},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[Scheduled] Reset %d search providers usage", total)
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
_log_task("scheduled_reset_search_usage", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[Scheduled] Reset search usage failed: %s", e)
|
|
|
|
def _run_task_monitor(self):
|
|
"""每小时检查卡死/中断的任务,标记为失败"""
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_task_monitor", "running", started_at=started)
|
|
stuck_tasklog_timeout = 7200 # 超过2小时视为卡死
|
|
stuck_contenttask_timeout = 10800 # 超过3小时视为卡死
|
|
try:
|
|
from ..database import SessionLocal
|
|
from ..models import TaskLog, ContentTask
|
|
db = SessionLocal()
|
|
try:
|
|
now = datetime.now(timezone.utc)
|
|
cutoff_tasklog = now.timestamp() - stuck_tasklog_timeout
|
|
cutoff_content = now.timestamp() - stuck_contenttask_timeout
|
|
marked = 0
|
|
|
|
# 检查 TaskLog 中卡死的 running 记录
|
|
stuck_logs = db.query(TaskLog).filter(
|
|
TaskLog.status == "running",
|
|
TaskLog.started_at.isnot(None)
|
|
).all()
|
|
for log in stuck_logs:
|
|
if log.started_at.timestamp() < cutoff_tasklog:
|
|
log.status = "failed"
|
|
log.finished_at = now
|
|
log.error_trace = "系统监控:任务运行超时(超过2小时)或进程中断,已自动标记为失败"
|
|
if log.started_at:
|
|
log.duration = int((now - log.started_at).total_seconds())
|
|
marked += 1
|
|
logger.warning("[TaskMonitor] 标记 TaskLog %d (%s) 为失败(超时)", log.id, log.module_id)
|
|
|
|
# 检查 ContentTask 中卡死的 running 记录
|
|
stuck_tasks = db.query(ContentTask).filter(
|
|
ContentTask.status == "running",
|
|
ContentTask.started_at.isnot(None)
|
|
).all()
|
|
for task in stuck_tasks:
|
|
if task.started_at.timestamp() < cutoff_content:
|
|
task.status = "failed"
|
|
task.finished_at = now
|
|
task.error_msg = "系统监控:任务运行超时(超过3小时)或进程中断,已自动标记为失败"
|
|
if task.started_at:
|
|
task.duration = int((now - task.started_at).total_seconds())
|
|
marked += 1
|
|
logger.warning("[TaskMonitor] 标记 ContentTask %s (%s) 为失败(超时)", task.task_id, task.stage)
|
|
|
|
if marked:
|
|
db.commit()
|
|
logger.info("[TaskMonitor] 已标记 %d 个卡死任务为失败", marked)
|
|
|
|
_log_task("scheduled_task_monitor", "success", log_id=log_id,
|
|
message=f"检查完成,标记 {marked} 个卡死任务",
|
|
result_data={"marked_failed": marked},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
import traceback
|
|
_log_task("scheduled_task_monitor", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[TaskMonitor] 监控检查失败: %s", e)
|
|
|
|
def _run_rank_tracker(self):
|
|
"""每日搜索排名追踪(Bing 查询关键词排名)"""
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_rank_tracker", "running", started_at=started)
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, str(PROJECT_ROOT / "scripts" / "rank_tracker.py"), "--engine", "bing"],
|
|
capture_output=True, text=True, timeout=300
|
|
)
|
|
if result.returncode == 0:
|
|
try:
|
|
data = json.loads(result.stdout.strip())
|
|
except json.JSONDecodeError:
|
|
data = {}
|
|
_log_task("scheduled_rank_tracker", "success", log_id=log_id,
|
|
message=f"追踪完成: {data.get('keywords_checked', 0)} 关键词, "
|
|
f"{data.get('on_page', 0)} 条有排名",
|
|
result_data=data,
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[RankTracker] 完成: %s", result.stdout.strip()[:200])
|
|
else:
|
|
_log_task("scheduled_rank_tracker", "failed", log_id=log_id,
|
|
message=f"返回码 {result.returncode}",
|
|
error_trace=result.stderr[-500:],
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
except Exception as e:
|
|
import traceback
|
|
_log_task("scheduled_rank_tracker", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[RankTracker] 排名追踪失败: %s", e)
|
|
|
|
def _run_geo_tracker(self):
|
|
"""每日 AI 搜索引用追踪"""
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_geo_tracker", "running", started_at=started)
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, str(PROJECT_ROOT / "scripts" / "geo_tracker.py")],
|
|
capture_output=True, text=True, timeout=600
|
|
)
|
|
if result.returncode == 0:
|
|
try:
|
|
data = json.loads(result.stdout.strip())
|
|
except json.JSONDecodeError:
|
|
data = {"raw": result.stdout.strip()[:200]}
|
|
_log_task("scheduled_geo_tracker", "success", log_id=log_id,
|
|
message=f"GEO 追踪完成: {data.get('articles_checked', 0)} 篇, "
|
|
f"AI 引用 {data.get('ai_citations', 0)} 条",
|
|
result_data=data,
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[GeoTracker] 完成: %s", result.stdout.strip()[:200])
|
|
else:
|
|
_log_task("scheduled_geo_tracker", "failed", log_id=log_id,
|
|
message=f"返回码 {result.returncode}",
|
|
error_trace=result.stderr[-500:],
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
except Exception as e:
|
|
import traceback
|
|
_log_task("scheduled_geo_tracker", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e)
|
|
|
|
def _run_shortvideo(self):
|
|
"""每日将前一日「待发布」选题的长文拆条为短视频脚本"""
|
|
started = datetime.now(timezone.utc)
|
|
log_id = _log_task("scheduled_shortvideo", "running", started_at=started)
|
|
try:
|
|
from ..database import SessionLocal
|
|
from ..models import Topic
|
|
db = SessionLocal()
|
|
try:
|
|
topics = db.query(Topic).filter(
|
|
Topic.status.in_(["ready", "待发布", "pending_publish"])
|
|
).order_by(Topic.ready_at.desc().nullslast()).limit(3).all()
|
|
finally:
|
|
db.close()
|
|
if not topics:
|
|
_log_task("scheduled_shortvideo", "success", log_id=log_id,
|
|
message="无待拆条选题", started_at=started, finished_at=datetime.now(timezone.utc))
|
|
return
|
|
import subprocess
|
|
total = 0
|
|
for t in topics:
|
|
res = subprocess.run(
|
|
[sys.executable, str(PROJECT_ROOT / "scripts" / "shortvideo.py"), "--topic-id", t.id, "--count", "3"],
|
|
capture_output=True, text=True, timeout=600
|
|
)
|
|
if res.returncode == 0:
|
|
total += 1
|
|
_log_task("scheduled_shortvideo", "success", log_id=log_id,
|
|
message=f"拆条完成: {total}/{len(topics)} 选题",
|
|
result_data={"topics": len(topics), "done": total},
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.info("[ShortVideo] 拆条完成: %d/%d", total, len(topics))
|
|
except Exception as e:
|
|
import traceback
|
|
_log_task("scheduled_shortvideo", "failed", log_id=log_id,
|
|
message=str(e),
|
|
error_trace=traceback.format_exc(),
|
|
started_at=started, finished_at=datetime.now(timezone.utc))
|
|
logger.exception("[ShortVideo] 拆条失败: %s", e)
|
|
|
|
def get_jobs(self):
|
|
"""返回当前所有定时任务的状态"""
|
|
jobs = []
|
|
for job in self.scheduler.get_jobs():
|
|
jobs.append({
|
|
"id": job.id,
|
|
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
|
|
"trigger": str(job.trigger),
|
|
})
|
|
return jobs
|
|
|
|
# 全局单例
|
|
scheduler = TaskScheduler() |