Files
CIBank/backend/daily_pipeline.py
2026-07-20 19:49:27 +08:00

199 lines
7.1 KiB
Python

#!/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())