# -*- 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()