Files
news-tracker/db.py
T

654 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,
kind TEXT DEFAULT 'normal', -- normal 普通(按权重打分) / custom 定制监控(按推送标准LLM判断)
monitor_standard TEXT DEFAULT '', -- 定制监控:推送标准说明
scan_interval_min INTEGER DEFAULT 0, -- 本源采集间隔(分钟),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 source_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
fetched_at TEXT DEFAULT '', -- 本次采样时间
count INTEGER DEFAULT 0, -- 本次采到条数
status TEXT DEFAULT 'ok', -- ok / error
detail TEXT DEFAULT '', -- 错误信息 / 备注
created_at TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_snapshots_source ON source_snapshots(source_id, id);
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 TABLE IF NOT EXISTS llm_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT DEFAULT '',
api_key TEXT DEFAULT '',
model TEXT DEFAULT '',
active INTEGER DEFAULT 0, -- 1=当前激活(网页一键切换)
enabled INTEGER DEFAULT 1,
created_at 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)
# 迁移:旧库补充 sources.kind / monitor_standard / scan_interval_min
cols = [r["name"] for r in conn.execute("PRAGMA table_info(sources)").fetchall()]
if "kind" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN kind TEXT DEFAULT 'normal'")
if "monitor_standard" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN monitor_standard TEXT DEFAULT ''")
if "scan_interval_min" not in cols:
conn.execute("ALTER TABLE sources ADD COLUMN scan_interval_min INTEGER DEFAULT 0")
# 迁移:历史采样表(每次采集留档)
conn.execute("""CREATE TABLE IF NOT EXISTS source_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
fetched_at TEXT DEFAULT '',
count INTEGER DEFAULT 0,
status TEXT DEFAULT 'ok',
detail TEXT DEFAULT '',
created_at TEXT DEFAULT ''
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_snapshots_source ON source_snapshots(source_id, id)")
# 迁移:旧库补充 full_text 列(存页面可读正文)
cols = [r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()]
if "full_text" not in cols:
conn.execute("ALTER TABLE articles ADD COLUMN full_text TEXT DEFAULT ''")
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, kind="normal", monitor_standard="", scan_interval_min=0):
conn = get_conn()
cur = conn.execute(
"INSERT INTO sources(name,type,url,description,weight,kind,monitor_standard,scan_interval_min,created_at) "
"VALUES(?,?,?,?,?,?,?,?,?)",
(name, type_, url, desc, weight, kind, monitor_standard, scan_interval_min, now_str()),
)
conn.commit()
conn.close()
return cur.lastrowid
def update_source(sid, **fields):
allowed = {"name", "type", "url", "description", "weight", "kind", "monitor_standard",
"scan_interval_min", "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()
# ---------------- 历史采样(每次采集留档,可查看/自动流程提取) ----------------
def add_source_snapshot(source_id, count=0, status="ok", detail=""):
conn = get_conn()
conn.execute(
"INSERT INTO source_snapshots(source_id,fetched_at,count,status,detail,created_at) "
"VALUES(?,?,?,?,?,?)",
(source_id, now_str(), count, status, detail, now_str()),
)
conn.commit()
conn.close()
def list_source_snapshots(source_id, limit=50):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM source_snapshots WHERE source_id=? ORDER BY id DESC LIMIT ?",
(source_id, limit),
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ---------------- 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,full_text)
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(),
a.get("full_text", "")),
)
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",
"full_text", "content", "title", "url"}
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):
"""新闻机制汇总:普通源(normal),窗口内 total_score>=50"""
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name, s.kind AS source_kind FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.published_at >= datetime('now','localtime','-%d hours') "
"AND (s.kind IS NULL OR s.kind != 'custom') "
"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 custom_articles_for_summary(window_hours=24):
"""定制监控汇总:定制源(custom),窗口内命中推送标准(is_important=1)的资讯"""
conn = get_conn()
rows = conn.execute(
"SELECT a.*, s.name AS source_name, s.kind AS source_kind FROM articles a "
"LEFT JOIN sources s ON a.source_id=s.id "
"WHERE a.published_at >= datetime('now','localtime','-%d hours') "
"AND s.kind='custom' AND a.is_important=1 "
"ORDER BY a.published_at DESC, a.id 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()
# ---------------- 大模型接口(llm_providers ----------------
def list_providers():
conn = get_conn()
rows = conn.execute("SELECT * FROM llm_providers ORDER BY active DESC, id").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_provider(pid):
conn = get_conn()
row = conn.execute("SELECT * FROM llm_providers WHERE id=?", (pid,)).fetchone()
conn.close()
return dict(row) if row else None
def add_provider(name, base_url, api_key, model, active=0, enabled=1):
conn = get_conn()
cur = conn.execute(
"INSERT INTO llm_providers(name,base_url,api_key,model,active,enabled,created_at) "
"VALUES(?,?,?,?,?,?,?)",
(name, base_url, api_key, model, active, enabled, now_str()),
)
conn.commit()
conn.close()
return cur.lastrowid
def update_provider(pid, **fields):
allowed = {"name", "base_url", "api_key", "model", "active", "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(pid)
conn = get_conn()
conn.execute(f"UPDATE llm_providers SET {','.join(sets)} WHERE id=?", vals)
conn.commit()
conn.close()
def delete_provider(pid):
conn = get_conn()
conn.execute("DELETE FROM llm_providers WHERE id=?", (pid,))
conn.commit()
conn.close()
def get_active_provider():
conn = get_conn()
row = conn.execute("SELECT * FROM llm_providers WHERE active=1 AND enabled=1 LIMIT 1").fetchone()
conn.close()
return dict(row) if row else None
def set_active_provider(pid):
conn = get_conn()
conn.execute("UPDATE llm_providers SET active=0")
conn.execute("UPDATE llm_providers SET active=1, enabled=1 WHERE id=?", (pid,))
conn.commit()
conn.close()
def enabled_providers_except(pid):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM llm_providers WHERE enabled=1 AND id!=? ORDER BY id", (pid,)).fetchall()
conn.close()
return [dict(r) for r in rows]
# ---------------- 通知日志 ----------------
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()