Files
news-tracker/db.py
T

484 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""
新闻智能跟踪系统 - SQLite 数据访问层
原生 sqlite3,无 ORM,结构与其它项目保持一致。
"""
import sqlite3
import json
import os
from datetime import datetime
import config
_SCHEMA = """
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT DEFAULT '',
url TEXT DEFAULT '',
description TEXT DEFAULT '',
weight REAL DEFAULT 1.0,
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'ok', -- ok / error
last_fetch TEXT DEFAULT '',
last_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER DEFAULT 0,
title TEXT NOT NULL,
url TEXT DEFAULT '',
author TEXT DEFAULT '',
content TEXT DEFAULT '',
summary TEXT DEFAULT '',
domain TEXT DEFAULT '', -- 分类:AI模型与算法 / 芯片与硬件 ...
entities TEXT DEFAULT '[]', -- JSON 数组:涉及的公司/实体
importance INTEGER DEFAULT 0, -- LLM 深度评分 1-10
relevance INTEGER DEFAULT 0, -- 兴趣相关度 0-100
total_score INTEGER DEFAULT 0, -- 综合分 0-100
is_important INTEGER DEFAULT 0,
analysis TEXT DEFAULT '', -- LLM 分析结论(为什么重要)
llm_status TEXT DEFAULT 'pending', -- pending / done / skipped / error
notified INTEGER DEFAULT 0, -- 是否已实时邮件通知
status TEXT DEFAULT 'new', -- new / summarized
published_at TEXT DEFAULT '',
collected_at TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
weight INTEGER DEFAULT 5,
enabled INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
weight INTEGER DEFAULT 5,
enabled INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT DEFAULT 'realtime', -- realtime / summary
subject TEXT DEFAULT '',
count INTEGER DEFAULT 0,
article_ids TEXT DEFAULT '[]',
status TEXT DEFAULT 'ok',
detail TEXT DEFAULT '',
sent_at TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_articles_collected ON articles(collected_at);
CREATE INDEX IF NOT EXISTS idx_articles_score ON articles(total_score);
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(llm_status);
"""
def get_conn():
conn = sqlite3.connect(config.DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
os.makedirs(config.DATA_DIR, exist_ok=True)
conn = get_conn()
conn.executescript(_SCHEMA)
conn.commit()
conn.close()
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ---------------- settings ----------------
def get_setting(key, default=None):
conn = get_conn()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
if row is None:
return default
try:
return json.loads(row["value"])
except Exception:
return row["value"]
def set_setting(key, value):
conn = get_conn()
conn.execute(
"INSERT INTO settings(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(value, ensure_ascii=False)),
)
conn.commit()
conn.close()
def get_all_settings():
conn = get_conn()
rows = conn.execute("SELECT key,value FROM settings").fetchall()
conn.close()
out = {}
for r in rows:
try:
out[r["key"]] = json.loads(r["value"])
except Exception:
out[r["key"]] = r["value"]
return out
# ---------------- sources ----------------
def list_sources(only_enabled=False):
conn = get_conn()
sql = "SELECT * FROM sources"
if only_enabled:
sql += " WHERE enabled=1"
sql += " ORDER BY id"
rows = conn.execute(sql).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_source(sid):
conn = get_conn()
row = conn.execute("SELECT * FROM sources WHERE id=?", (sid,)).fetchone()
conn.close()
return dict(row) if row else None
def add_source(name, type_, url, desc, weight=1.0):
conn = get_conn()
cur = conn.execute(
"INSERT INTO sources(name,type,url,description,weight,created_at) VALUES(?,?,?,?,?,?)",
(name, type_, url, desc, weight, now_str()),
)
conn.commit()
conn.close()
return cur.lastrowid
def update_source(sid, **fields):
allowed = {"name", "type", "url", "description", "weight", "enabled"}
sets, vals = [], []
for k, v in fields.items():
if k in allowed:
sets.append(f"{k}=?")
vals.append(v)
if not sets:
return
vals.append(sid)
conn = get_conn()
conn.execute(f"UPDATE sources SET {','.join(sets)} WHERE id=?", vals)
conn.commit()
conn.close()
def delete_source(sid):
conn = get_conn()
conn.execute("DELETE FROM sources WHERE id=?", (sid,))
conn.commit()
conn.close()
def update_source_fetch(sid, status="ok", count=0):
conn = get_conn()
conn.execute(
"UPDATE sources SET status=?, last_fetch=?, last_count=? WHERE id=?",
(status, now_str(), count, sid),
)
conn.commit()
conn.close()
# ---------------- articles ----------------
def article_exists(url):
conn = get_conn()
row = conn.execute("SELECT id FROM articles WHERE url=?", (url,)).fetchone()
conn.close()
return row is not None
def add_article(a):
conn = get_conn()
cur = conn.execute(
"""INSERT INTO articles(source_id,title,url,author,content,summary,domain,entities,
importance,relevance,total_score,is_important,analysis,llm_status,status,
published_at,collected_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(a.get("source_id", 0), a["title"], a.get("url", ""), a.get("author", ""),
a.get("content", ""), a.get("summary", ""), a.get("domain", ""),
json.dumps(a.get("entities", []), ensure_ascii=False),
a.get("importance", 0), a.get("relevance", 0), a.get("total_score", 0),
a.get("is_important", 0), a.get("analysis", ""), a.get("llm_status", "pending"),
a.get("status", "new"), a.get("published_at", ""), now_str()),
)
conn.commit()
conn.close()
return cur.lastrowid
def update_article(aid, **fields):
allowed = {"summary", "domain", "entities", "importance", "relevance",
"total_score", "is_important", "analysis", "llm_status", "notified", "status"}
sets, vals = [], []
for k, v in fields.items():
if k in allowed:
if k == "entities":
v = json.dumps(v, ensure_ascii=False)
sets.append(f"{k}=?")
vals.append(v)
if not sets:
return
vals.append(aid)
conn = get_conn()
conn.execute(f"UPDATE articles SET {','.join(sets)} WHERE id=?", vals)
conn.commit()
conn.close()
def get_article(aid):
conn = get_conn()
row = conn.execute(
"SELECT a.*, s.name AS source_name FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id WHERE a.id=?", (aid,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
return d
def list_articles(**filters):
"""支持 source_id/domain/is_important/llm_status/min_score/q/date_from/order/limit/offset"""
sql = ("SELECT a.*, s.name AS source_name FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id WHERE 1=1")
args = []
if filters.get("source_id"):
sql += " AND a.source_id=?"
args.append(filters["source_id"])
if filters.get("domain"):
sql += " AND a.domain=?"
args.append(filters["domain"])
if filters.get("is_important") is not None:
sql += " AND a.is_important=?"
args.append(1 if filters["is_important"] else 0)
if filters.get("llm_status"):
sql += " AND a.llm_status=?"
args.append(filters["llm_status"])
if filters.get("min_score"):
sql += " AND a.total_score>=?"
args.append(filters["min_score"])
if filters.get("q"):
sql += " AND (a.title LIKE ? OR a.content LIKE ?)"
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
if filters.get("date_from"):
sql += " AND a.published_at>=?"
args.append(filters["date_from"])
order = filters.get("order", "a.collected_at DESC")
sql += f" ORDER BY {order}"
if filters.get("limit"):
sql += " LIMIT ?"
args.append(filters["limit"])
if filters.get("offset"):
sql += " OFFSET ?"
args.append(filters["offset"])
conn = get_conn()
rows = conn.execute(sql, args).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
out.append(d)
return out
def count_articles(**filters):
sql = "SELECT COUNT(*) c FROM articles a WHERE 1=1"
args = []
if filters.get("domain"):
sql += " AND a.domain=?"
args.append(filters["domain"])
if filters.get("is_important") is not None:
sql += " AND a.is_important=?"
args.append(1 if filters["is_important"] else 0)
conn = get_conn()
c = conn.execute(sql, args).fetchone()["c"]
conn.close()
return c
def article_stats():
conn = get_conn()
total = conn.execute("SELECT COUNT(*) c FROM articles").fetchone()["c"]
important = conn.execute("SELECT COUNT(*) c FROM articles WHERE is_important=1").fetchone()["c"]
pending_llm = conn.execute("SELECT COUNT(*) c FROM articles WHERE llm_status='pending'").fetchone()["c"]
notified = conn.execute("SELECT COUNT(*) c FROM articles WHERE notified=1").fetchone()["c"]
conn.close()
return {"total": total, "important": important, "pending_llm": pending_llm, "notified": notified}
def latest_articles_for_summary(window_hours=24):
"""汇总窗口内(含未通知、未汇总的)资讯"""
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.published_at >= datetime('now','localtime','-%d hours') "
"AND a.total_score>=50 ORDER BY a.total_score DESC LIMIT 200" % int(window_hours)
).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
out.append(d)
return out
def pending_llm_articles(limit=10):
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.llm_status='pending' ORDER BY a.total_score DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["entities"] = json.loads(d["entities"] or "[]")
except Exception:
d["entities"] = []
out.append(d)
return out
# ---------------- 兴趣画像 ----------------
def list_keywords():
conn = get_conn()
rows = conn.execute("SELECT * FROM keywords ORDER BY weight DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def add_keyword(kw, weight=5):
conn = get_conn()
conn.execute("INSERT OR IGNORE INTO keywords(keyword,weight) VALUES(?,?)", (kw, weight))
conn.commit()
conn.close()
def delete_keyword(kid):
conn = get_conn()
conn.execute("DELETE FROM keywords WHERE id=?", (kid,))
conn.commit()
conn.close()
def list_domains():
conn = get_conn()
rows = conn.execute("SELECT * FROM domains ORDER BY weight DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
def add_domain(name, weight=5):
conn = get_conn()
conn.execute("INSERT OR IGNORE INTO domains(name,weight) VALUES(?,?)", (name, weight))
conn.commit()
conn.close()
def delete_domain(did):
conn = get_conn()
conn.execute("DELETE FROM domains WHERE id=?", (did,))
conn.commit()
conn.close()
def list_companies():
conn = get_conn()
rows = conn.execute("SELECT * FROM companies ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def add_company(name):
conn = get_conn()
conn.execute("INSERT OR IGNORE INTO companies(name) VALUES(?)", (name,))
conn.commit()
conn.close()
def delete_company(cid):
conn = get_conn()
conn.execute("DELETE FROM companies WHERE id=?", (cid,))
conn.commit()
conn.close()
# ---------------- 通知日志 ----------------
def add_log(type_, subject, count, article_ids, status="ok", detail=""):
conn = get_conn()
conn.execute(
"INSERT INTO notification_log(type,subject,count,article_ids,status,detail,sent_at) "
"VALUES(?,?,?,?,?,?,?)",
(type_, subject, count, json.dumps(article_ids, ensure_ascii=False), status, detail, now_str()),
)
conn.commit()
conn.close()
def list_logs(limit=50):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM notification_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["article_ids"] = json.loads(d["article_ids"] or "[]")
except Exception:
d["article_ids"] = []
out.append(d)
return out
def clear_old_articles(days=30):
conn = get_conn()
conn.execute(
"DELETE FROM articles WHERE collected_at < datetime('now','localtime','-%d days')" % days)
conn.commit()
conn.close()