#!/usr/bin/env python3 from __future__ import annotations import argparse import fcntl import json import os import sqlite3 import subprocess import sys import time from datetime import datetime from pathlib import Path from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent load_dotenv(BASE_DIR / ".env") DATA_DIR = BASE_DIR / "data" DB_PATH = Path(os.getenv("CIBANK_DB", DATA_DIR / "cibank.db")) LOCK_PATH = DATA_DIR / "pipeline.lock" LOG_DIR = DATA_DIR / "logs" BACKUP_DIR = DATA_DIR / "backups" SCHEMA_PATH = BASE_DIR / "schema.sql" def now_iso() -> str: return datetime.now().astimezone().isoformat(timespec="seconds") def open_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8")) return conn def acquire_lock(): DATA_DIR.mkdir(parents=True, exist_ok=True) lock_file = LOCK_PATH.open("a+") try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: lock_file.close() raise RuntimeError("已有流水线任务正在运行") from exc lock_file.seek(0) lock_file.truncate() lock_file.write(f"{os.getpid()}\n{now_iso()}\n") lock_file.flush() return lock_file def backup_database() -> str: BACKUP_DIR.mkdir(parents=True, exist_ok=True) backup_path = BACKUP_DIR / f"cibank-{datetime.now().strftime('%Y%m%d-%H%M%S')}.db" source = sqlite3.connect(DB_PATH) target = sqlite3.connect(backup_path) try: source.backup(target) finally: target.close() source.close() backups = sorted(BACKUP_DIR.glob("cibank-*.db"), key=lambda path: path.stat().st_mtime, reverse=True) for expired in backups[14:]: expired.unlink() return str(backup_path) def create_run(run_type: str) -> int: timestamp = now_iso() with open_db() as conn: cursor = conn.execute( """ INSERT INTO pipeline_runs (run_type, started_at, status, current_step, steps_json, created_at) VALUES (?, ?, 'running', 'preflight', '{}', ?) """, (run_type, timestamp, timestamp), ) conn.commit() return cursor.lastrowid def update_run(run_id: int, **values) -> None: allowed = {"finished_at", "status", "current_step", "steps_json", "error_message"} fields = [(key, value) for key, value in values.items() if key in allowed] if not fields: return assignments = ", ".join(f"{key}=?" for key, _ in fields) with open_db() as conn: conn.execute( f"UPDATE pipeline_runs SET {assignments} WHERE id=?", [value for _, value in fields] + [run_id], ) conn.commit() def run_step(run_id: int, name: str, arguments: list[str], log_file, steps: dict) -> bool: update_run(run_id, current_step=name) started = time.monotonic() log_file.write(f"\n[{now_iso()}] START {name}: {' '.join(arguments)}\n") log_file.flush() env = os.environ.copy() env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + env.get("PATH", "") result = subprocess.run( [sys.executable, *arguments], cwd=BASE_DIR, env=env, stdout=log_file, stderr=subprocess.STDOUT, text=True, check=False, ) duration = round(time.monotonic() - started, 2) steps[name] = {"status": "success" if result.returncode == 0 else "failed", "returncode": result.returncode, "duration": duration} update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False)) log_file.write(f"[{now_iso()}] END {name}: code={result.returncode}, duration={duration}s\n") log_file.flush() return result.returncode == 0 def validate_environment() -> None: if not DB_PATH.exists(): raise RuntimeError(f"数据库不存在:{DB_PATH}") api_key = os.getenv("DASHSCOPE_API_KEY", "").strip() if not api_key or api_key == "replace_with_your_key": raise RuntimeError("DASHSCOPE_API_KEY 未配置") with sqlite3.connect(DB_PATH) as conn: result = conn.execute("PRAGMA integrity_check").fetchone()[0] if result != "ok": raise RuntimeError(f"数据库完整性检查失败:{result}") def show_status() -> int: with open_db() as conn: row = conn.execute("SELECT * FROM pipeline_runs ORDER BY id DESC LIMIT 1").fetchone() print(json.dumps(dict(row) if row else {"status": "never_run"}, ensure_ascii=False, indent=2)) return 0 def run_pipeline(run_type: str, skip_crawl: bool) -> int: lock_file = acquire_lock() run_id = None LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = LOG_DIR / f"pipeline-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log" try: validate_environment() run_id = create_run(run_type) steps = {"backup": {"status": "success", "path": backup_database()}} update_run(run_id, steps_json=json.dumps(steps, ensure_ascii=False)) commands = [] if not skip_crawl: commands.extend([ ("crawl_mp", ["crawler.py", "--source-type", "公众号"]), ("crawl_video", ["crawler.py", "--source-type", "视频号"]), ]) analyze_args = ["structurer.py", "analyze", "--pause", "0.6", "--retries", "3"] embed_args = ["structurer.py", "embed"] if run_type == "full": analyze_args.append("--force") embed_args.append("--force") commands.extend([ ("analyze", analyze_args), ("embed", embed_args), ("signals", ["structurer.py", "signals", "--days", "30"]), ]) all_success = True with log_path.open("a", encoding="utf-8") as log_file: log_file.write(f"[{now_iso()}] pipeline run_id={run_id}, type={run_type}\n") for name, arguments in commands: if not run_step(run_id, name, arguments, log_file, steps): all_success = False status = "success" if all_success else "partial" update_run(run_id, finished_at=now_iso(), status=status, current_step="completed", steps_json=json.dumps(steps, ensure_ascii=False)) print(json.dumps({"run_id": run_id, "status": status, "log": str(log_path), "steps": steps}, ensure_ascii=False, indent=2)) return 0 if all_success else 1 except Exception as exc: if run_id is not None: update_run(run_id, finished_at=now_iso(), status="failed", current_step="failed", error_message=str(exc)) print(json.dumps({"status": "failed", "error": str(exc), "log": str(log_path)}, ensure_ascii=False), file=sys.stderr) return 1 finally: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) lock_file.close() def main() -> int: parser = argparse.ArgumentParser(description="CIBank 自动增量处理流水线") parser.add_argument("command", choices=["daily", "full", "status"]) parser.add_argument("--skip-crawl", action="store_true") args = parser.parse_args() if args.command == "status": return show_status() return run_pipeline(args.command, args.skip_crawl) if __name__ == "__main__": raise SystemExit(main())