From 5e0dbccc2180944786375469443779fc02f10c58 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 20 Aug 2026 00:26:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E9=97=BB=E6=99=BA=E8=83=BD=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=E7=B3=BB=E7=BB=9F=20v1.0.0:=20AI=E8=B5=84=E8=AE=AF?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=87=E9=9B=86+=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=88=86=E6=9E=90+=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5+?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AE=A1=E7=90=86=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + analysis.py | 272 ++++++++++++++++++++++ app.py | 241 +++++++++++++++++++ config.py | 97 ++++++++ db.py | 483 +++++++++++++++++++++++++++++++++++++++ notifier.py | 131 +++++++++++ scheduler.py | 132 +++++++++++ simulate.py | 241 +++++++++++++++++++ start.sh | 47 ++++ static/style.css | 111 +++++++++ templates/404.html | 9 + templates/base.html | 53 +++++ templates/dashboard.html | 81 +++++++ templates/detail.html | 46 ++++ templates/logs.html | 36 +++ templates/news.html | 46 ++++ templates/profile.html | 67 ++++++ templates/settings.html | 97 ++++++++ templates/sources.html | 62 +++++ 19 files changed, 2256 insertions(+) create mode 100644 .gitignore create mode 100644 analysis.py create mode 100644 app.py create mode 100644 config.py create mode 100644 db.py create mode 100644 notifier.py create mode 100644 scheduler.py create mode 100644 simulate.py create mode 100755 start.sh create mode 100644 static/style.css create mode 100644 templates/404.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/detail.html create mode 100644 templates/logs.html create mode 100644 templates/news.html create mode 100644 templates/profile.html create mode 100644 templates/settings.html create mode 100644 templates/sources.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21e7784 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/ +logs/ +__pycache__/ +*.pyc diff --git a/analysis.py b/analysis.py new file mode 100644 index 0000000..1df11b5 --- /dev/null +++ b/analysis.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 智能分析引擎 +两级分析: +1. 规则打分(快、无需外部依赖):兴趣相关度 + 重要度启发式 → total_score +2. LLM 深度分析(准、逐条):对候选重要资讯调用 DeepSeek 输出重要度/分类/结论 +""" +import json +import threading + +import requests + +import config +import db + +# 重要度启发式信号词(命中越多分越高) +_STRONG_SIGNAL = [ + "发布", "宣布", "推出", "开源", "突破", "融资", "IPO", "上市", "收购", "并购", + "合并", "裁员", "禁令", "制裁", "管制", "监管", "起诉", "反垄断", "罚款", + "重大", "首个", "最强", "新规", "停止", "暂停", "安全事件", "数据泄露", + "里程碑", "量产", "实施细则", "生效", "世界第一", +] +_MID_SIGNAL = [ + "升级", "更新", "合作", "投资", "签署", "获批", "中标", "测试", "开放", "公测", + "翻倍", "增长", "新高", "新纪录", "接入", "部署", "扩大", "提升", +] +_WEAK_SIGNAL = ["讨论", "传闻", "预计", "可能", "或将", "消息人士", "知情人士"] + +# 大额资金/规模信号:出现强金额词且伴随大数字 → 重要度加成 +_MONEY_WORDS = ["亿美元", "亿元", "万亿", "估值", "融资", "收购", "IPO", "罚款", "大单", "募资", "基金"] +_MONEY_BIG = ["10亿", "20亿", "50亿", "百亿", "千亿", "万亿", "估值突破", "估值超", "亿美元", + "10 亿", "20 亿", "50 亿", "规模突破", "市值"] + +_DOMAIN_RULES = { + "AI模型与算法": ["模型", "GPT", "DeepSeek", "大模型", "推理", "多模态", "智能体", "Agent", + "Scaling", "思维链", "视频生成", "扩散", "检索增强", "端侧模型", "开源模型"], + "芯片与硬件": ["芯片", "GPU", "半导体", "晶圆", "制程", "数据中心", "服务器", "AI芯片", "光刻机"], + "云计算与算力": ["云计算", "算力", "数据中心", "集群", "云厂商", "推理集群", "超算"], + "政策与监管": ["监管", "政策", "法案", "法规", "管制", "禁令", "制裁", "新规", "合规", "备案", "诉讼", "审查", "治理框架", "罚款"], + "投融资": ["融资", "IPO", "投资", "收购", "并购", "估值", "募资", "上市", "风投", "D轮", "大单"], + "企业动态": ["重组", "架构", "高管", "任命", "裁员", "人事", "部门", "收购"], + "学术研究": ["论文", "arXiv", "研究", "实验", "学者", "基准", "团队发布"], + "开源生态": ["开源", "GitHub", "权重", "社区", "代码", "star", "周榜"], +} + +_IMPORTANCE_SCALE = {"strong": 32, "mid": 16, "weak": 6} + + +def _text_of(a): + return (a.get("title", "") + " " + a.get("content", "") + " " + a.get("summary", "")) + + +def classify_domain(a): + text = _text_of(a) + best, best_hits = "", 0 + for dom, kws in _DOMAIN_RULES.items(): + hits = sum(1 for kw in kws if kw.lower() in text.lower()) + if hits > best_hits: + best, best_hits = dom, hits + return best + + +def extract_entities(a): + """返回 (全部实体, 真实关注公司)""" + text = _text_of(a) + found, real = [], [] + for c in db.list_companies(): + name = c["name"] + if name and name.lower() in text.lower(): + if name not in found: + found.append(name) + if name not in real: + real.append(name) + for e in a.get("entities") or []: + if e not in found: + found.append(e) + return found, real + + +def _keyword_hits(text): + hits, score = [], 0 + for kw in db.list_keywords(): + if not kw["enabled"]: + continue + if kw["keyword"].lower() in text.lower(): + hits.append(kw["keyword"]) + score += kw["weight"] + return hits, min(50, score) + + +def _domain_score(domain): + for dom in db.list_domains(): + if dom["enabled"] and dom["name"] == domain: + return min(30, dom["weight"] * 4) + return 0 + + +def _company_match(real_companies): + """返回 (相关度加分, 重要度impact)""" + n = len(real_companies) + if n == 0: + return 0, 0 + return min(20, 10 + (n - 1) * 3), min(14, 8 + (n - 1) * 4) + + +def _signal_score(text): + strong = [w for w in _STRONG_SIGNAL if w in text] + if strong: + return min(38, _IMPORTANCE_SCALE["strong"] + 6 * (len(strong) - 1)) + mid = [w for w in _MID_SIGNAL if w in text] + if mid: + return min(22, _IMPORTANCE_SCALE["mid"] + 4 * (len(mid) - 1)) + return _IMPORTANCE_SCALE["weak"] if any(w in text for w in _WEAK_SIGNAL) else 0 + + +def _money_magnitude(text): + if any(k in text for k in _MONEY_WORDS): + if any(m in text for m in _MONEY_BIG): + return 12 + return 6 + return 0 + + +def _recency_bonus(a): + try: + from datetime import datetime + pub = datetime.strptime(a.get("published_at", ""), "%Y-%m-%d %H:%M:%S") + hours = (datetime.now() - pub).total_seconds() / 3600 + except Exception: + return 5 + if hours <= 6: + return 12 + if hours <= 24: + return 8 + if hours <= 48: + return 4 + return 0 + + +def _source_weight(a): + sid = a.get("source_id") or 0 + s = db.get_source(sid) + if not s: + return 0 + return int(round(s["weight"] * 10)) + + +def analyze_article(aid): + """规则打分(立即生效)""" + a = db.get_article(aid) + if not a: + return None + text = _text_of(a) + domain = classify_domain(a) + entities, real = extract_entities(a) + + _, kw_score = _keyword_hits(text) + dom_score = _domain_score(domain) + comp_score, comp_impact = _company_match(real) + rel = min(100, kw_score + dom_score + comp_score) + + sig = _signal_score(text) + money = _money_magnitude(text) + rec = _recency_bonus(a) + src = _source_weight(a) + imp = min(100, sig + money + rec + src + comp_impact + int(rel * 0.2)) + + total = min(100, int(round(0.4 * rel + 0.6 * imp))) + + threshold = int(db.get_setting("realtime_threshold", config.AUTO_DEFAULTS["realtime_threshold"])) + is_important = 1 if (total >= threshold or (rel >= 65 and imp >= 70)) else 0 + + db.update_article( + aid, domain=domain, entities=entities, relevance=rel, total_score=total, + is_important=is_important, + ) + return {"id": aid, "domain": domain, "entities": entities, "relevance": rel, + "importance_rule": imp, "total_score": total, "is_important": is_important} + + +def llm_analyze(aid): + """LLM 深度分析单条:重要度 1-10 + 相关度 + 结论。失败则标记 error 不阻塞。""" + a = db.get_article(aid) + if not a: + return None + profile = _profile_text() + prompt = ( + "你是一位资深科技资讯分析师,专注AI领域。\n" + f"用户兴趣画像:\n{profile}\n\n" + f"资讯标题:{a['title']}\n" + f"资讯内容:{a.get('content') or a.get('summary')}\n\n" + "请只输出一个 JSON 对象(不要任何其他文字),格式:\n" + '{"importance": 1-10的整数, "relevance": 0-100的整数, ' + '"is_important": true或false, "category": "分类名", "reason": "为什么对用户重要(40字内中文)"}' + ) + try: + resp = requests.post( + f"{config.LLM_BASE_URL}/chat/completions", + headers={"Authorization": f"Bearer {config.LLM_API_KEY}", + "Content-Type": "application/json"}, + json={ + "model": config.LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": config.LLM_TEMPERATURE, + "max_tokens": config.LLM_MAX_TOKENS, + "response_format": {"type": "json_object"}, + }, + timeout=config.LLM_TIMEOUT, + ) + data = resp.json() + content = data["choices"][0]["message"]["content"] + parsed = json.loads(content) + importance = max(1, min(10, int(parsed.get("importance", 5)))) + relevance = max(0, min(100, int(parsed.get("relevance", 50)))) + is_important = 1 if parsed.get("is_important") else 0 + category = parsed.get("category", a.get("domain", "")) + reason = parsed.get("reason", "") + + # LLM 结论与规则分融合 + total = a.get("total_score", 0) + llm_component = int(round(importance * 10 * 0.5 + relevance * 0.2)) + total = min(100, int(round(0.6 * total + 0.4 * llm_component))) + threshold = int(db.get_setting("realtime_threshold", config.AUTO_DEFAULTS["realtime_threshold"])) + if is_important == 0 and total >= threshold: + is_important = 1 + + db.update_article( + aid, importance=importance, relevance=max(relevance, a.get("relevance", 0)), + total_score=total, is_important=is_important, analysis=reason, + domain=category, llm_status="done", + ) + return {"id": aid, "importance": importance, "relevance": relevance, + "total_score": total, "is_important": is_important, "reason": reason} + except Exception as e: + db.update_article(aid, llm_status="error") + return {"id": aid, "error": str(e)} + + +def _profile_text(): + kws = "、".join(k["keyword"] for k in db.list_keywords() if k["enabled"]) + comps = "、".join(c["name"] for c in db.list_companies() if c["enabled"]) + doms = "、".join(d["name"] for d in db.list_domains() if d["enabled"]) + return f"关注关键词:{kws}\n关注公司:{comps}\n关注领域:{doms}" + + +def batch_llm_analyze(limit=10): + """后台线程:对 pending 且规则分达标的资讯做 LLM 深度分析""" + threshold = int(db.get_setting("llm_threshold", config.AUTO_DEFAULTS["llm_threshold"])) + arts = db.pending_llm_articles(limit=limit) + results = {"done": 0, "error": 0, "skipped": 0} + for a in arts: + if a.get("total_score", 0) < threshold: + db.update_article(a["id"], llm_status="skipped") + results["skipped"] += 1 + continue + r = llm_analyze(a["id"]) + if r and "error" not in r: + results["done"] += 1 + else: + results["error"] += 1 + return results + + +def run_llm_background(limit=8): + def _job(): + try: + batch_llm_analyze(limit=limit) + except Exception as e: + db.add_log("realtime", "LLM分析异常", 0, [], status="error", detail=str(e)) + t = threading.Thread(target=_job, daemon=True) + t.start() + return t diff --git a/app.py b/app.py new file mode 100644 index 0000000..9aa0c88 --- /dev/null +++ b/app.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - Flask 主应用 +网页:仪表盘 / 资讯列表 / 资讯详情 / 数据源 / 兴趣画像 / 通知日志 / 设置 +API:采集 / LLM分析 / 汇总 / 画像维护 / 设置维护 +""" +from datetime import datetime, timedelta + +from flask import Flask, render_template, request, jsonify, redirect, url_for + +import config +import db +import simulate +import analysis +import notifier +import scheduler + +app = Flask(__name__, static_folder=config.STATIC_DIR, template_folder=config.TEMPLATE_DIR) + + +# ---------------- 页面 ---------------- +@app.route("/") +def index(): + return redirect(url_for("dashboard")) + + +@app.route("/dashboard") +def dashboard(): + stats = db.article_stats() + latest = db.list_articles(limit=12, order="a.collected_at DESC") + important = db.list_articles(is_important=1, limit=12, order="a.total_score DESC") + dom_rows = db.get_conn().execute( + "SELECT domain, COUNT(*) c FROM articles GROUP BY domain ORDER BY c DESC").fetchall() + domain_stats = [{"name": r["domain"] or "未分类", "count": r["c"]} for r in dom_rows] + return render_template("dashboard.html", stats=stats, latest=latest, + important=important, domain_stats=domain_stats) + + +@app.route("/news") +def news(): + domain = request.args.get("domain", "") + important = request.args.get("important", "") + q = request.args.get("q", "") + page = max(1, int(request.args.get("page", 1))) + per = 20 + filters = {} + if domain: + filters["domain"] = domain + if important == "1": + filters["is_important"] = 1 + if q: + filters["q"] = q + total = db.count_articles(domain=filters.get("domain"), is_important=filters.get("is_important")) + if q: + total = len(db.list_articles(q=q)) + pages = max(1, (total + per - 1) // per) + articles = db.list_articles(offset=(page - 1) * per, limit=per, **filters) + domains = db.get_conn().execute( + "SELECT DISTINCT domain FROM articles WHERE domain<>'' ORDER BY domain").fetchall() + return render_template("news.html", articles=articles, domains=[d["domain"] for d in domains], + domain=domain, important=important, q=q, page=page, pages=pages, total=total) + + +@app.route("/news/") +def news_detail(aid): + art = db.get_article(aid) + if not art: + return "not found", 404 + return render_template("detail.html", art=art) + + +@app.route("/sources") +def sources(): + return render_template("sources.html", sources=db.list_sources()) + + +@app.route("/profile") +def profile(): + return render_template("profile.html", keywords=db.list_keywords(), + domains=db.list_domains(), companies=db.list_companies()) + + +@app.route("/logs") +def logs(): + return render_template("logs.html", logs=db.list_logs(limit=100)) + + +@app.route("/settings") +def settings_page(): + return render_template("settings.html", auto=db.get_all_settings()) + + +# ---------------- API ---------------- +@app.route("/api/stats") +def api_stats(): + stats = db.article_stats() + dom_rows = db.get_conn().execute( + "SELECT domain, COUNT(*) c FROM articles GROUP BY domain ORDER BY c DESC").fetchall() + stats["domains"] = [{"name": r["domain"] or "未分类", "count": r["c"]} for r in dom_rows] + trend_rows = db.get_conn().execute( + "SELECT substr(collected_at,1,10) d, COUNT(*) c FROM articles " + "GROUP BY d ORDER BY d DESC LIMIT 7").fetchall() + stats["trend"] = [{"date": r["d"], "count": r["c"]} for r in reversed(trend_rows)] + return jsonify(stats) + + +@app.route("/api/sources", methods=["POST"]) +def api_sources(): + data = request.get_json(force=True) or {} + action = data.get("action") + if action == "add": + sid = db.add_source(data.get("name", ""), data.get("type", ""), data.get("url", ""), + data.get("description", ""), float(data.get("weight", 1.0))) + return jsonify({"ok": True, "id": sid}) + if action == "update": + db.update_source(data["id"], name=data.get("name"), type=data.get("type"), + url=data.get("url"), description=data.get("description"), + weight=float(data.get("weight", 1.0)), + enabled=1 if data.get("enabled") else 0) + return jsonify({"ok": True}) + if action == "delete": + db.delete_source(data["id"]) + return jsonify({"ok": True}) + if action == "toggle": + s = db.get_source(data["id"]) + db.update_source(data["id"], enabled=0 if s["enabled"] else 1) + return jsonify({"ok": True}) + return jsonify({"ok": False, "error": "unknown action"}) + + +@app.route("/api/profile", methods=["POST"]) +def api_profile(): + data = request.get_json(force=True) or {} + action = data.get("action") + kind = data.get("kind") + if action == "add": + if kind == "keyword": + db.add_keyword(data.get("name", ""), int(data.get("weight", 5))) + elif kind == "domain": + db.add_domain(data.get("name", ""), int(data.get("weight", 5))) + elif kind == "company": + db.add_company(data.get("name", "")) + return jsonify({"ok": True}) + if action == "delete": + if kind == "keyword": + db.delete_keyword(data["id"]) + elif kind == "domain": + db.delete_domain(data["id"]) + elif kind == "company": + db.delete_company(data["id"]) + return jsonify({"ok": True}) + return jsonify({"ok": False, "error": "unknown action"}) + + +@app.route("/api/settings", methods=["POST"]) +def api_settings(): + data = request.get_json(force=True) or {} + auto = {} + for k in config.AUTO_DEFAULTS: + if k in data: + auto[k] = data[k] + if auto: + cur = db.get_all_settings() + cur.update(auto) + db.set_setting("auto", auto) + if "mail" in data and isinstance(data["mail"], dict): + cur = db.get_all_settings().get("mail", {}) + cur.update(data["mail"]) + db.set_setting("mail", cur) + return jsonify({"ok": True}) + + +@app.route("/api/actions", methods=["POST"]) +def api_actions(): + data = request.get_json(force=True) or {} + action = data.get("action") + if action == "collect": + n = scheduler.collect_once() + return jsonify({"ok": True, "added": n}) + if action == "llm": + r = analysis.batch_llm_analyze(limit=int(data.get("limit", 10))) + return jsonify({"ok": True, **r}) + if action == "summary": + n = scheduler.send_daily_summary() + return jsonify({"ok": True, "sent": n}) + if action == "seed": + n = simulate.seed_all() + return jsonify({"ok": True, "added": n}) + if action == "reanalyze": + # 重新跑规则打分(如改了兴趣画像后) + conn = db.get_conn() + ids = [r["id"] for r in conn.execute("SELECT id FROM articles").fetchall()] + conn.close() + cnt = 0 + for aid in ids: + analysis.analyze_article(aid) + cnt += 1 + return jsonify({"ok": True, "count": cnt}) + if action == "test_mail": + try: + notifier.send_email("📮 新闻智能跟踪系统测试", "

测试成功

邮件通知链路正常。

") + return jsonify({"ok": True, "msg": "测试邮件已发送"}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + return jsonify({"ok": False, "error": "unknown action"}) + + +@app.errorhandler(404) +def not_found(e): + return render_template("404.html"), 404 + + +# ---------------- 启动 ---------------- +def main(): + db.init_db() + # 首次初始化:写入默认数据源 / 兴趣画像 / 默认设置 / 模拟数据 + if db.get_setting("initialized") != 1: + for s in config.DEFAULT_SOURCES: + db.add_source(s["name"], s["type"], s["url"], s["description"], s["weight"]) + for kw, w in config.DEFAULT_KEYWORDS: + db.add_keyword(kw, w) + for d, w in config.DEFAULT_DOMAINS: + db.add_domain(d, w) + for c in config.DEFAULT_COMPANIES: + db.add_company(c) + db.set_setting("initialized", 1) + db.set_setting("auto", dict(config.AUTO_DEFAULTS)) + db.set_setting("mail", dict(config.MAIL_DEFAULTS)) + simulate.seed_all() + analysis.run_llm_background() + stop_event, t = scheduler.start_scheduler() + print(f"✅ {config.SERVICE_NAME} 启动完成") + print(f" Web: http://0.0.0.0:{config.SERVICE_PORT}/") + print(f" 每日汇总: {db.get_setting('summary_time', config.AUTO_DEFAULTS['summary_time'])}" + f" | 采集间隔: {db.get_setting('scan_interval_min', 30)}分钟" + f" | 实时阈值: {db.get_setting('realtime_threshold', 80)}") + app.run(host=config.SERVICE_HOST, port=config.SERVICE_PORT, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..fb01d42 --- /dev/null +++ b/config.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 全局配置 +所有环境相关配置集中在此,便于迁移与扩展。 +后期接入真实数据源时,只需在 sources 适配层实现 fetch() 即可,其余逻辑不变。 +""" +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +LOG_DIR = os.path.join(BASE_DIR, "logs") +STATIC_DIR = os.path.join(BASE_DIR, "static") +TEMPLATE_DIR = os.path.join(BASE_DIR, "templates") +DB_PATH = os.path.join(DATA_DIR, "news_tracker.db") + +os.makedirs(DATA_DIR, exist_ok=True) +os.makedirs(LOG_DIR, exist_ok=True) + +# ---------------- 服务 ---------------- +SERVICE_PORT = 16100 +SERVICE_HOST = "0.0.0.0" +SERVICE_NAME = "新闻智能跟踪系统" + +# ---------------- 大模型(DeepSeek) ---------------- +LLM_BASE_URL = "https://api.deepseek.com" +LLM_API_KEY = "sk-edb9df58ff574f8c98df1cd6a425e97c" +LLM_MODEL = "deepseek-v4-flash" # 推理型,分析质量高 +LLM_TIMEOUT = 120 +LLM_MAX_TOKENS = 1500 +LLM_TEMPERATURE = 0.3 + +# ---------------- 邮件通知(默认值,可在设置区修改) ---------------- +MAIL_DEFAULTS = { + "smtp_host": "mail.tphai.com", + "smtp_port": 587, + "smtp_user": "hz4th_coder@tphai.com", + "smtp_pass": "hz4th_coder@!", + "smtp_mode": "plain", # plain(无加密) / starttls / ssl + "email_to": "wlq@tphai.com", + "sender_name": "新闻智能跟踪", +} + +# ---------------- 自动化(默认值,可在设置区修改) ---------------- +AUTO_DEFAULTS = { + "auto_collect": 1, # 是否自动定时采集 + "scan_interval_min": 30, # 采集扫描间隔(分钟) + "realtime_threshold": 80, # 总分 >= 该值 → 实时邮件通知 + "llm_threshold": 60, # 规则分 >= 该值 → 进入 LLM 深度分析 + "realtime_enabled": 1, # 是否启用实时重要资讯邮件 + "summary_enabled": 1, # 是否启用每日汇总 + "summary_time": "10:00", # 每日汇总时间 + "summary_window_hours": 24, # 汇总窗口(往前 N 小时) + "max_summary_items": 15, # 汇总邮件最多条目数 +} + +# ---------------- 默认兴趣画像(可在网页修改) ---------------- +DEFAULT_KEYWORDS = [ + ("大模型", 8), ("人工智能", 6), ("AI", 6), ("芯片", 7), ("GPU", 6), + ("算力", 6), ("开源", 6), ("智能体", 7), ("Agent", 6), ("多模态", 6), + ("自动驾驶", 6), ("机器人", 5), ("具身智能", 6), ("推理", 4), ("数据中心", 5), + ("融资", 5), ("IPO", 5), ("收购", 6), ("政策", 4), ("监管", 6), + ("制裁", 7), ("禁令", 8), ("安全", 4), ("量子计算", 5), ("人才", 3), + ("裁员", 5), ("突破", 5), ("新模型", 8), ("发布会", 5), +] + +DEFAULT_DOMAINS = [ + ("AI模型与算法", 9), ("芯片与硬件", 8), ("云计算与算力", 6), + ("政策与监管", 7), ("投融资", 6), ("企业动态", 5), + ("学术研究", 4), ("开源生态", 6), +] + +DEFAULT_COMPANIES = [ + "OpenAI", "Anthropic", "Google", "Microsoft", "Meta", "NVIDIA", + "AMD", "苹果", "Apple", "亚马逊", "Amazon", "Tesla", "特斯拉", + "华为", "百度", "阿里巴巴", "阿里", "腾讯", "字节跳动", "DeepSeek", + "智谱", "月之暗面", "Kimi", "MiniMax", "商汤", "科大讯飞", "阿里云", + "台积电", "TSMC", "三星", "英特尔", "Intel", "高通", "中芯国际", + "英伟达", "Mistral", "xAI", "Grok", "百度文心", "通义千问", "豆包", +] + +# 内置数据源模板(首次初始化时写入,可在网页管理) +DEFAULT_SOURCES = [ + {"name": "OpenAI 官方动态", "type": "公司动态", "url": "https://openai.com/news", + "description": "OpenAI 官方新闻与产品发布", "weight": 1.0}, + {"name": "AI 科技媒体(模拟)", "type": "科技媒体", "url": "https://example.com/ai-news", + "description": "综合 AI 领域科技媒体(模拟数据源)", "weight": 0.8}, + {"name": "arXiv 学术论文", "type": "学术论文", "url": "https://arxiv.org/list/cs.AI/recent", + "description": "人工智能领域最新论文", "weight": 0.6}, + {"name": "GitHub 开源趋势", "type": "开源社区", "url": "https://github.com/trending", + "description": "开源项目趋势与发布", "weight": 0.7}, + {"name": "政策与监管", "type": "政策法规", "url": "https://example.com/policy", + "description": "各国 AI 政策、监管与出口管制动态", "weight": 0.9}, + {"name": "行业报告与调研", "type": "行业报告", "url": "https://example.com/report", + "description": "行业数据报告、市场调研", "weight": 0.7}, + {"name": "科技投资动态", "type": "投融资", "url": "https://example.com/funding", + "description": "AI 领域融资、并购、IPO 动态", "weight": 0.9}, +] diff --git a/db.py b/db.py new file mode 100644 index 0000000..e9b4094 --- /dev/null +++ b/db.py @@ -0,0 +1,483 @@ +# -*- 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() diff --git a/notifier.py b/notifier.py new file mode 100644 index 0000000..d94450c --- /dev/null +++ b/notifier.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 邮件通知 +支持 plain / starttls / ssl 三种 SMTP 模式,发送实时重要资讯与每日汇总。 +""" +import smtplib +from email.header import Header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formataddr + +import config +import db + + +def get_mail_cfg(): + cfg = dict(config.MAIL_DEFAULTS) + cfg.update(db.get_all_settings().get("mail", {})) + return cfg + + +def send_email(subject, html, to=None): + cfg = get_mail_cfg() + to = to or cfg["email_to"] + msg = MIMEMultipart("alternative") + msg["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"])) + msg["To"] = to + msg["Subject"] = Header(subject, "utf-8") + msg.attach(MIMEText(html, "html", "utf-8")) + + mode = cfg.get("smtp_mode", "plain") + if mode == "ssl": + server = smtplib.SMTP_SSL(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20) + else: + server = smtplib.SMTP(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20) + if mode == "starttls": + server.starttls() + try: + server.login(cfg["smtp_user"], cfg["smtp_pass"]) + server.sendmail(cfg["smtp_user"], [to], msg.as_string()) + finally: + server.quit() + return True + + +def _score_color(score): + if score >= 80: + return "#e74c3c" + if score >= 60: + return "#e67e22" + return "#7f8c8d" + + +def _card(art): + ents = "、".join(art.get("entities") or []) + return f""" +
+
+ {art['title']} +
+
+ 综合分 {art.get('total_score',0)} · 相关度 {art.get('relevance',0)} · + {art.get('source_name','')} · {art.get('published_at','')} +
+
{art.get('summary') or art.get('content','')[:120]}
+
涉及:{ents if ents else '—'}
+ {('
💡 ' + art.get('analysis','') + '
') if art.get('analysis') else ''} +
""" + + +def _html_wrap(title, body): + return f""" + + +
+

{title}

+ {body} +
+ 由 新闻智能跟踪系统 自动生成 · {config.SERVICE_NAME} +
+
+""" + + +def send_realtime(articles): + """实时重要资讯通知(一次一批)""" + if not articles: + return 0 + cards = "".join(_card(a) for a in articles) + subject = f"🔥 重要AI资讯 {len(articles)}条 · {articles[0]['published_at'][:16]}" + html = _html_wrap( + "重要资讯实时提醒", + f"

以下 {len(articles)} 条资讯对您重点关注领域很重要,已自动甄别:

{cards}", + ) + send_email(subject, html) + ids = [a["id"] for a in articles] + for aid in ids: + db.update_article(aid, notified=1) + db.add_log("realtime", subject, len(articles), ids, status="ok", detail="实时通知") + return len(articles) + + +def send_daily_summary(articles, window_label): + """每日汇总(默认每天10点):昨天至今的重要/相关资讯""" + if not articles: + return 0 + top = articles[: int(db.get_setting("max_summary_items", config.AUTO_DEFAULTS["max_summary_items"]))] + cards = "".join(_card(a) for a in top) + # 分领域统计 + from collections import Counter + dom_cnt = Counter(a.get("domain") or "未分类" for a in articles) + stats = " · ".join(f"{k} {v}条" for k, v in dom_cnt.most_common(6)) + subject = f"📰 AI资讯日报 {window_label} · 共{len(articles)}条 重点{len(top)}条" + html = _html_wrap( + "AI 重要资讯日报", + f""" +

汇总时段:{window_label}

+

领域分布:{stats}

+

重点资讯(按综合分排序):

+ {cards} + """, + ) + send_email(subject, html) + ids = [a["id"] for a in articles if a["id"]] + db.add_log("summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}条") + for aid in ids: + art = db.get_article(aid) + if art and art.get("status") != "summarized": + db.update_article(aid, status="summarized") + return len(top) diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..681a133 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 后台调度器 +1. 定时采集(scan_interval_min 分钟一次):拉取 → 入库 → 规则分析 → 后台LLM深度分析 → 实时通知 +2. 每日汇总(默认 10:00):汇总昨天至今重要资讯邮件 +""" +import threading +import time +from datetime import datetime, timedelta + +import config +import db +import simulate +import analysis +import notifier + + +def collect_once(): + """执行一次采集全流程,返回新增数""" + if not int(db.get_setting("auto_collect", config.AUTO_DEFAULTS["auto_collect"])): + return 0 + sources = db.list_sources(only_enabled=True) + if not sources: + return 0 + items = simulate.fetch_simulated() + added = 0 + for it in items: + if db.article_exists(it["url"]): + continue + it["source_id"] = simulate._source_id_for_domain(it["domain"]) + aid = db.add_article(it) + analysis.analyze_article(aid) + added += 1 + # 后台 LLM 深度分析 + analysis.run_llm_background() + if added: + for s in sources: + db.update_source_fetch(s["id"], status="ok", count=added) + # 实时通知 + try: + send_realtime_if_needed() + except Exception: + pass + return added + + +def send_realtime_if_needed(): + """扫描已分析完成、重要、未通知的资讯,发实时邮件""" + if not int(db.get_setting("realtime_enabled", config.AUTO_DEFAULTS["realtime_enabled"])): + return 0 + # LLM 深度分析完成后,重新判定重要度并通知 + arts = db.list_articles(is_important=1, order="a.total_score DESC", limit=20) + pending = [a for a in arts if not a["notified"] and a["llm_status"] != "pending"] + if not pending: + return 0 + # 批量发(控制每封数量) + batch = pending[:10] + return notifier.send_realtime(batch) + + +def send_daily_summary(): + """每日汇总:昨天至今的重要/相关资讯""" + if not int(db.get_setting("summary_enabled", config.AUTO_DEFAULTS["summary_enabled"])): + return 0 + window = int(db.get_setting("summary_window_hours", config.AUTO_DEFAULTS["summary_window_hours"])) + articles = db.latest_articles_for_summary(window) + if not articles: + return 0 + start = (datetime.now() - timedelta(hours=window)).strftime("%m-%d %H:%M") + end = datetime.now().strftime("%m-%d %H:%M") + label = f"{start} ~ {end}" + return notifier.send_daily_summary(articles, label) + + +def _next_summary_run(): + """计算下一次汇总时间点(默认每天 10:00,可配置)""" + hm = str(db.get_setting("summary_time", config.AUTO_DEFAULTS["summary_time"])) + try: + hh, mm = hm.split(":") + hh, mm = int(hh), int(mm) + except Exception: + hh, mm = 10, 0 + now = datetime.now() + nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0) + if nxt <= now: + nxt = nxt + timedelta(days=1) + return nxt + + +def scheduler_loop(stop_event): + last_summary_day = None + while not stop_event.is_set(): + try: + # 每日汇总 + now = datetime.now() + day_key = now.strftime("%Y-%m-%d") + if last_summary_day != day_key: + hm = str(db.get_setting("summary_time", config.AUTO_DEFAULTS["summary_time"]))[:5] + if now.strftime("%H:%M") >= hm and now.hour >= int(hm.split(":")[0]): + try: + send_daily_summary() + last_summary_day = day_key + except Exception as e: + db.add_log("summary", "每日汇总异常", 0, [], status="error", detail=str(e)) + except Exception: + pass + + # 定时采集(以分钟为单位) + interval = int(db.get_setting("scan_interval_min", config.AUTO_DEFAULTS["scan_interval_min"])) + next_scan = time.time() + interval * 60 + # 在等待期间兼顾实时通知(LLM 分析完成后推送) + while time.time() < next_scan and not stop_event.is_set(): + try: + send_realtime_if_needed() + except Exception: + pass + stop_event.wait(min(30, max(5, interval * 60))) + + if stop_event.is_set(): + break + try: + collect_once() + except Exception as e: + db.add_log("realtime", "采集异常", 0, [], status="error", detail=str(e)) + return + + +def start_scheduler(): + stop_event = threading.Event() + t = threading.Thread(target=scheduler_loop, args=(stop_event,), daemon=True) + t.start() + return stop_event, t diff --git a/simulate.py b/simulate.py new file mode 100644 index 0000000..63b9c9a --- /dev/null +++ b/simulate.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 模拟数据源 +在真实数据源接入前,用高仿真 AI 领域资讯模拟采集,把全链路跑通。 +数据结构与真实源返回保持一致:fetch() -> list[dict](title/url/author/content/summary/published_at/domain/entities) +""" +import random +from datetime import datetime, timedelta + +import config +import db + +# 每条: (标题, 内容, 摘要, 源索引[1..7], 分类, 涉及实体, 距今小时数) +_NEWS = [ + # ---------- 源1 OpenAI 官方动态 ---------- + ("OpenAI 发布新一代推理模型 GPT-5.5,编程与多模态能力全面升级", + "OpenAI 今日正式发布新一代旗舰推理模型 GPT-5.5,官方称其在编程、数学推理与多模态理解上较前代大幅提升," + "API 已开放公测,开发者可按 token 计费接入,并同步推出配套的 Agent SDK 更新。" + "业内普遍认为这将进一步加剧大模型军备竞赛,带动推理算力需求。", + "OpenAI 发布 GPT-5.5,编程/推理/多模态能力全面升级,API 开放公测。", + 1, "AI模型与算法", ["OpenAI", "GPT-5.5"], 1.2), + ("OpenAI 调整组织架构,设立独立‘前沿安全部门’并向董事会直接汇报", + "OpenAI 宣布重组安全治理体系,新设前沿安全部门负责 AGI 风险评估与部署把关,直接向董事会汇报," + "同时开放第三方安全审计。此举被解读为应对监管压力的重要举措。", + "OpenAI 重组安全架构,设立直接向董事会汇报的前沿安全部门。", + 1, "企业动态", ["OpenAI", "AI安全"], 6.0), + ("OpenAI 与某云厂商签署 50 亿美元算力大单,扩充推理集群", + "消息人士透露 OpenAI 与国内头部云厂商达成约 50 亿美元的算力采购协议,用于扩充其推理集群," + "以满足 GPT-5.5 上线后激增的调用需求,相关数据中心建设预计明年上半年交付。", + "OpenAI 签 50 亿美元算力大单扩充推理集群,数据中心明年交付。", + 1, "云计算与算力", ["OpenAI", "算力", "数据中心"], 9.0), + + # ---------- 源2 AI 科技媒体 ---------- + ("DeepSeek 开源新一代 MoE 模型,推理成本再降 40%", + "DeepSeek 宣布开源新一代混合专家架构模型,在保持性能的同时将推理成本较上一代降低约 40%," + "模型权重与技术报告同步公开,开发者可本地部署。此举被认为将显著压低行业推理价格。", + "DeepSeek 开源新 MoE 模型,推理成本降 40%,权重与报告公开。", + 2, "AI模型与算法", ["DeepSeek", "MoE", "开源"], 2.0), + ("英伟达发布新一代数据中心 GPU,算力密度提升 2 倍", + "英伟达在年度 GTC 上发布新一代数据中心 GPU,官方称算力密度较现役产品提升约 2 倍," + "功耗下降 15%,首批样品已送主要云厂商与超算中心测试,预计明年大规模量产。", + "英伟达发布新一代数据中心 GPU,算力密度提升 2 倍,明年量产。", + 2, "芯片与硬件", ["NVIDIA", "GPU", "英伟达"], 4.0), + ("多家头部大模型厂商集体降价,推理 API 价格战再起", + "继 DeepSeek 开源降价后,国内多家大模型厂商宣布下调推理 API 价格,部分模型每百万 token 价格腰斩," + "行业分析认为开源模型普及与算力成本下降正推动推理价格快速下探。", + "大模型厂商集体降价,推理 API 价格战再起,部分价格腰斩。", + 2, "AI模型与算法", ["DeepSeek", "推理"], 7.0), + ("具身智能赛道升温:多家机器人公司完成新一轮融资", + "具身智能成为资本新宠,本月已有 5 家机器人公司完成新一轮融资,单笔最大超 10 亿元人民币," + "投资人普遍看好大模型+机器人的结合在工业与家庭场景的落地前景。", + "具身智能融资升温,本月 5 家机器人公司完成融资,单笔超 10 亿。", + 2, "投融资", ["具身智能", "机器人", "融资"], 8.0), + ("AI 换脸与深度伪造诈骗频发,监管酝酿新规", + "多地频发利用 AI 深度伪造实施诈骗的案件,监管机构表示正酝酿针对生成式 AI 内容标识的强制新规," + "要求平台对 AI 生成内容加水印并建立追溯机制。", + "深度伪造诈骗频发,监管酝酿 AI 内容强制标识新规。", + 2, "政策与监管", ["AI安全", "监管"], 10.0), + ("大模型‘幻觉’难题获突破:新方法让模型检索引用准确率提升 30%", + "多家研究机构联合提出新的检索增强方法,通过双层检索与事实校验将大模型回答的引用准确率提升约 30%," + "相关论文与代码已公开,业内认为有望缓解大模型幻觉问题。", + "新检索增强方法让大模型引用准确率提升 30%,缓解幻觉问题。", + 2, "学术研究", ["大模型", "检索增强"], 12.0), + ("AI 编程助手普及加速,开发者使用率突破 80%", + "最新开发者调查显示,超过 80% 的受访开发者已日常使用 AI 编程助手,代码补全与自动重构成为最常见场景," + "企业级 AI 编程平台正成为云厂商新的增长点。", + "开发者 AI 编程助手使用率突破 80%,成为云厂商新增长点。", + 2, "行业报告", ["AI编程"], 14.0), + ("智能体(AI Agent)商用元年:多家企业上线自主执行工作流", + "2026 年被业内称为智能体商用元年,多家企业上线可自主规划并执行任务的 AI 智能体工作流," + "覆盖客服、运营、数据分析等场景,企业采购意愿明显增强。", + "智能体商用元年,企业上线自主执行工作流,覆盖多业务场景。", + 2, "AI模型与算法", ["智能体", "Agent"], 16.0), + ("大模型训练数据告急,合成数据成新趋势", + "研究表明高质量自然语言训练数据即将接近枯竭,各大实验室开始加大合成数据投入," + "通过模型自生成-筛选-再训练的方式扩展数据规模,合成数据占比已超 30%。", + "训练数据告急,合成数据成趋势,实验室加大投入占比超 30%。", + 2, "行业报告", ["大模型", "训练数据"], 20.0), + ("端侧小模型崛起:手机本地跑大模型成为新卖点", + "多家手机厂商发布支持端侧大模型的旗舰机型,可在本地运行数十亿参数模型," + "离线完成摘要、翻译与语音助手任务,隐私与响应速度成为核心卖点。", + "端侧小模型崛起,手机本地跑大模型成为旗舰机新卖点。", + 2, "芯片与硬件", ["端侧模型", "推理"], 24.0), + + # ---------- 源3 arXiv 学术论文 ---------- + ("论文:Scaling Law 出现拐点?新研究揭示长上下文训练的幂律变化", + "一篇引发热议的 arXiv 论文通过大规模实验指出,长上下文训练下 Scaling Law 出现新的幂律拐点," + "作者提出修正后的扩展定律并给出最优数据配比建议,多位研究者已在社交平台转发讨论。", + "新论文揭示长上下文训练下 Scaling Law 幂律拐点,引发热议。", + 3, "学术研究", ["Scaling Law", "大模型"], 5.0), + ("论文:多模态推理基准刷新,开源模型逼近闭源旗舰", + "团队发布新的多模态推理基准,覆盖图表理解与视觉问答,结果显示最强开源模型在多项任务上已逼近闭源旗舰," + "为开源生态提供了可复现的评测参考。", + "新多模态基准发布,开源模型在多任务上逼近闭源旗舰。", + 3, "学术研究", ["多模态", "开源"], 11.0), + ("论文:思维链推理的‘捷径’——模型为何在某些任务上作弊", + "研究团队系统分析了思维链推理中模型的捷径行为,发现模型会在部分任务上走捷径给出看似合理实则错误的答案," + "并提出缓解方法,对推理可靠性的讨论具有重要参考价值。", + "论文系统分析思维链推理捷径行为,提出可靠性缓解方法。", + 3, "学术研究", ["思维链", "大模型"], 18.0), + ("论文:扩散模型用于视频生成的时序一致性新方案", + "作者提出一种面向视频生成的时序一致性增强方案,在保持画面质量的同时显著减少闪烁与跳变," + "开源代码与预训练权重已放出,视频生成社区反响积极。", + "视频生成时序一致性新方案发布,代码与权重已开源。", + 3, "学术研究", ["扩散模型", "视频生成"], 26.0), + + # ---------- 源4 GitHub 开源趋势 ---------- + ("GitHub 周榜:开源推理框架登顶,星标数一周破万", + "一款面向大模型推理的轻量开源框架本周登顶 GitHub Trending,一周内星标突破 1 万," + "支持主流模型量化与多卡并行,社区活跃度极高。", + "开源推理框架登顶 GitHub 周榜,一周星标破万。", + 4, "开源生态", ["开源", "推理"], 6.5), + ("开源智能体框架更新:支持多智能体协作与工具调用", + "热门开源智能体框架发布重大更新,新增多智能体协作、图式任务编排与丰富的工具调用接口," + "降低了开发者构建复杂 Agent 应用的门槛。", + "开源智能体框架重大更新,支持多智能体协作与工具调用。", + 4, "开源生态", ["智能体", "开源", "Agent"], 13.0), + ("开源社区发起‘AI 数据可追溯’倡议,多家机构响应", + "开源社区发起 AI 训练数据可追溯性倡议,呼吁模型训练数据来源透明化," + "多家研究机构与企业公开支持,被视为应对版权争议的行业自律尝试。", + "开源社区发起 AI 数据可追溯倡议,多家机构响应。", + 4, "开源生态", ["开源", "AI安全", "训练数据"], 22.0), + + # ---------- 源5 政策与监管 ---------- + ("美国发布新一轮 AI 芯片出口管制细则,高端 GPU 对华限制再收紧", + "美国政府发布新一轮 AI 芯片出口管制实施细则,进一步收紧高端 GPU 对华出口," + "同时扩大对算力设备转口的审查范围,多家中国 AI 企业表示正评估影响并寻求替代方案。", + "美国发布新一轮 AI 芯片出口管制细则,高端 GPU 对华限制再收紧。", + 5, "政策与监管", ["制裁", "禁令", "GPU", "芯片"], 1.0), + ("欧盟 AI 法案高风险条款正式生效,违规企业最高面临营业额 7% 罚款", + "欧盟《人工智能法案》高风险条款正式生效,覆盖生物识别、教育就业等领域的高风险 AI 系统," + "违规企业最高可面临全球营业额 7% 的罚款,出海企业合规压力上升。", + "欧盟 AI 法案高风险条款生效,违规最高罚全球营业额 7%。", + 5, "政策与监管", ["监管", "AI安全"], 3.0), + ("国内发布生成式 AI 服务管理新规征求意见稿,强调内容标识与备案", + "国内监管部门发布生成式 AI 服务管理新规征求意见稿,要求对 AI 生成内容进行显式标识并强化服务备案," + "同时对大模型训练数据合规提出更高要求,产业界展开密集讨论。", + "国内生成式 AI 新规征求意见稿发布,强调内容标识与备案。", + 5, "政策与监管", ["监管", "大模型", "训练数据"], 9.0), + ("多国联合声明:呼吁建立全球 AI 治理框架与安全标准互认", + "多个国家发布联合声明,呼吁建立全球 AI 治理框架,推动 AI 安全标准互认与风险分级管理," + "并计划在下一届国际会议上推进具体落地机制。", + "多国联合声明呼吁建立全球 AI 治理框架与安全标准互认。", + 5, "政策与监管", ["监管", "AI安全"], 21.0), + + # ---------- 源6 行业报告与调研 ---------- + ("IDC 报告:2026 年全球 AI 市场规模预计突破 8000 亿美元", + "IDC 最新报告预计 2026 年全球 AI 市场规模将突破 8000 亿美元,同比增长 35%," + "其中生成式 AI 与行业大模型应用是主要驱动力,中国市场份额持续提升。", + "IDC:2026 全球 AI 市场规模预计破 8000 亿美元,同比增 35%。", + 6, "行业报告", ["行业报告", "AI市场"], 6.0), + ("报告:中国 AI 大模型备案数量突破 400 个,应用加速落地", + "最新行业报告显示,中国完成备案的大模型数量已突破 400 个,覆盖金融、医疗、制造等数十个行业," + "垂直行业大模型成为竞争焦点,落地项目数量同比增长一倍。", + "中国 AI 大模型备案数破 400,垂直行业模型成竞争焦点。", + 6, "行业报告", ["大模型", "行业报告"], 13.0), + ("Gartner:到 2027 年,80% 的企业将使用智能体编排平台", + "Gartner 预测到 2027 年约 80% 的企业将使用智能体编排平台,AI 智能体将成为企业应用的新交互范式," + "建议 CIO 及早规划 Agent 基础设施与治理体系。", + "Gartner 预测 2027 年 80% 企业将使用智能体编排平台。", + 6, "行业报告", ["智能体", "Agent", "行业报告"], 19.0), + + # ---------- 源7 科技投资动态 ---------- + ("AI 芯片独角兽完成 20 亿美元 D 轮融资,估值突破 200 亿美元", + "AI 芯片初创公司宣布完成 20 亿美元 D 轮融资,估值突破 200 亿美元," + "资金将用于新一代推理芯片量产与客户拓展,多家产业资本与主权基金参投。", + "AI 芯片独角兽完成 20 亿美元 D 轮,估值破 200 亿美元。", + 7, "投融资", ["AI芯片", "融资", "芯片"], 3.5), + ("大模型公司开启 IPO 窗口:多家中概 AI 企业冲刺上市", + "随着市场回暖,多家 AI 大模型公司启动 IPO 进程,拟在港股或美股上市," + "募资主要用于研发投入与商业化扩张,机构投资者认购热情较高。", + "多家中概 AI 企业启动 IPO 进程,机构认购热情高。", + 7, "投融资", ["IPO", "融资", "大模型"], 7.0), + ("科技巨头收购 AI 人才团队,人才争夺战白热化", + "多家科技巨头通过收购初创团队的形式批量获取 AI 人才,今年已发生 20 余起相关收购," + "大模型方向的核心算法人才年薪屡创新高。", + "科技巨头收购 AI 初创团队抢人才,大模型算法人才薪酬创新高。", + 7, "投融资", ["收购", "人才"], 15.0), + ("AI 数据标注与数据服务公司完成新一轮融资,数据产业受关注", + "一家 AI 数据标注服务公司完成新一轮融资,随着高质量训练数据稀缺,数据采集、标注与合成服务赛道受资本关注。", + "AI 数据服务公司完成新一轮融资,数据产业受资本关注。", + 7, "投融资", ["融资", "训练数据"], 25.0), + ("风投机构发布 AI 投资展望:看好推理成本下降带动的应用爆发", + "头部风投机构发布 AI 投资展望报告,认为推理成本持续下降将带动 AI 应用层爆发," + "看好垂直行业 Agent、端侧 AI 与 AI 基础设施三大方向。", + "风投展望:推理成本下降带动 AI 应用层爆发,看好三大方向。", + 7, "投融资", ["推理", "智能体", "行业报告"], 28.0), +] + + +def _make_item(row): + title, content, summary, src_idx, domain, entities, hours_ago = row + published = datetime.now() - timedelta(hours=hours_ago) + # 用确定性哈希生成稳定 URL,保证跨进程去重有效 + import hashlib + url = "https://news.example.com/a/" + hashlib.md5(title.encode("utf-8")).hexdigest()[:16] + return { + "title": title, + "url": url, + "author": "模拟源", + "content": content, + "summary": summary, + "domain": domain, + "entities": entities, + "published_at": published.strftime("%Y-%m-%d %H:%M:%S"), + } + + +def fetch_simulated(): + """模拟一次采集:返回随机抽取的部分条目(模拟每天新增)""" + items = [] + for row in _NEWS: + # 模拟增量:约 70% 的条目在本次采集中出现(其余视为历史已入库) + if random.random() < 0.7: + items.append(_make_item(row)) + random.shuffle(items) + return items + + +def seed_all(): + """全量灌入(首次初始化用):把全部模拟资讯写入数据库并做基础分析""" + from analysis import analyze_article + items = [_make_item(row) for row in _NEWS] + added = 0 + for it in items: + if db.article_exists(it["url"]): + continue + it["source_id"] = _source_id_for_domain(it["domain"]) + aid = db.add_article(it) + analyze_article(aid) # 规则打分立即生效 + added += 1 + return added + + +def _source_id_for_domain(domain): + mapping = { + "AI模型与算法": 2, "芯片与硬件": 2, "云计算与算力": 2, + "政策与监管": 5, "投融资": 7, "行业报告": 6, + "学术研究": 3, "开源生态": 4, "企业动态": 1, + } + return mapping.get(domain, 2) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9df2326 --- /dev/null +++ b/start.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# 新闻智能跟踪系统 启动/停止脚本 +# 用法: ./start.sh [stop|restart|status] +cd "$(dirname "$0")" +PORT=16100 +PID_FILE="logs/app.pid" +PY=/home/hz1/miniconda3/envs/openclaw/bin/python3 + +start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "已在运行 (PID $(cat "$PID_FILE"))" + return + fi + mkdir -p logs + nohup "$PY" app.py > logs/app.log 2>&1 & + echo $! > "$PID_FILE" + sleep 2 + if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "✅ 启动成功 http://0.0.0.0:$PORT/" + else + echo "❌ 启动失败,查看 logs/app.log" + fi +} + +stop() { + if [ -f "$PID_FILE" ]; then + kill "$(cat "$PID_FILE")" 2>/dev/null && echo "已停止" || echo "进程不存在" + rm -f "$PID_FILE" + else + echo "未在运行" + fi +} + +status() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "运行中 (PID $(cat "$PID_FILE"))" + else + echo "未运行" + fi +} + +case "$1" in + stop) stop ;; + restart) stop; sleep 1; start ;; + status) status ;; + *) start ;; +esac diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..9a29e54 --- /dev/null +++ b/static/style.css @@ -0,0 +1,111 @@ +* { margin:0; padding:0; box-sizing:border-box; } +body { font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif; background:#f3f4f6; color:#1f2937; font-size:14px; } +a { color:#2563eb; text-decoration:none; } +.muted { color:#9ca3af; } +.small { font-size:12px; } + +.layout { display:flex; min-height:100vh; } +.sidebar { width:220px; background:#111827; color:#d1d5db; display:flex; flex-direction:column; position:fixed; top:0; bottom:0; } +.brand { display:flex; align-items:center; gap:10px; padding:20px 18px; border-bottom:1px solid #1f2937; } +.brand-icon { font-size:26px; } +.brand-name { font-weight:700; color:#fff; font-size:15px; } +.brand-sub { font-size:11px; color:#6b7280; } +.sidebar nav { flex:1; padding:12px 8px; } +.sidebar nav a { display:block; padding:11px 14px; border-radius:8px; color:#d1d5db; margin-bottom:4px; font-size:14px; } +.sidebar nav a:hover { background:#1f2937; color:#fff; } +.sidebar nav a.active { background:#2563eb; color:#fff; } +.sidebar-foot { padding:16px; font-size:11px; color:#6b7280; border-top:1px solid #1f2937; } + +.main { margin-left:220px; flex:1; padding:24px 28px; max-width:1200px; } +.page-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; flex-wrap:wrap; gap:10px; } +.page-head h1 { font-size:22px; } +.actions { display:flex; gap:8px; flex-wrap:wrap; } + +.btn { display:inline-block; padding:8px 14px; border-radius:8px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-size:13px; color:#374151; } +.btn:hover { border-color:#2563eb; color:#2563eb; } +.btn.accent { background:#2563eb; color:#fff; border-color:#2563eb; } +.btn.accent:hover { background:#1d4ed8; } +.btn.mini { padding:4px 10px; font-size:12px; } +.btn.danger { color:#dc2626; border-color:#fca5a5; } +.btn.danger:hover { background:#fef2f2; } + +.card { background:#fff; border-radius:12px; padding:18px 20px; margin-bottom:18px; box-shadow:0 1px 3px rgba(0,0,0,.06); } +.card h3 { font-size:15px; margin-bottom:12px; color:#111827; } + +.stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; } +.stat { background:#fff; border-radius:12px; padding:18px; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.06); } +.stat-num { font-size:30px; font-weight:700; color:#2563eb; } +.stat.hot .stat-num { color:#e74c3c; } +.stat.warn .stat-num { color:#e67e22; } +.stat-label { color:#6b7280; font-size:12px; margin-top:4px; } + +.two-col { display:grid; grid-template-columns:1fr 1fr; gap:18px; } +@media (max-width:900px){ .two-col{grid-template-columns:1fr;} } + +.item { padding:12px 0; border-bottom:1px solid #f3f4f6; } +.item:last-child { border-bottom:none; } +.item-title { font-weight:600; color:#111827; display:block; margin-bottom:4px; } +.item-title:hover { color:#2563eb; } +.item-meta { display:flex; gap:12px; align-items:center; font-size:12px; color:#6b7280; flex-wrap:wrap; } +.item-summary { color:#6b7280; font-size:13px; margin-top:4px; } +.empty { color:#9ca3af; text-align:center; padding:24px; } + +.score { display:inline-block; padding:2px 8px; border-radius:20px; font-weight:700; font-size:12px; color:#fff; } +.score.s80, .score.s81, .score.s82, .score.s83, .score.s84, .score.s85, .score.s86, .score.s87, .score.s88, .score.s89, .score.s90, .score.s91, .score.s92, .score.s93, .score.s94, .score.s95, .score.s96, .score.s97, .score.s98, .score.s99, .score.s100 { background:#e74c3c; } +.score.s60, .score.s61, .score.s62, .score.s63, .score.s64, .score.s65, .score.s66, .score.s67, .score.s68, .score.s69, .score.s70, .score.s71, .score.s72, .score.s73, .score.s74, .score.s75, .score.s76, .score.s77, .score.s78, .score.s79 { background:#e67e22; } +.score.s40, .score.s41, .score.s42, .score.s43, .score.s44, .score.s45, .score.s46, .score.s47, .score.s48, .score.s49, .score.s50, .score.s51, .score.s52, .score.s53, .score.s54, .score.s55, .score.s56, .score.s57, .score.s58, .score.s59 { background:#f59e0b; } +.score.s0, .score.s1, .score.s2, .score.s3, .score.s4, .score.s5, .score.s6, .score.s7, .score.s8, .score.s9, .score.s10, .score.s11, .score.s12, .score.s13, .score.s14, .score.s15, .score.s16, .score.s17, .score.s18, .score.s19, .score.s20, .score.s21, .score.s22, .score.s23, .score.s24, .score.s25, .score.s26, .score.s27, .score.s28, .score.s29, .score.s30, .score.s31, .score.s32, .score.s33, .score.s34, .score.s35, .score.s36, .score.s37, .score.s38, .score.s39 { background:#9ca3af; } +.score-big { font-size:44px; font-weight:800; text-align:center; padding:6px 0; border-radius:10px; color:#fff; } +.score-big.s80, .score-big.s81, .score-big.s82, .score-big.s83, .score-big.s84, .score-big.s85, .score-big.s86, .score-big.s87, .score-big.s88, .score-big.s89, .score-big.s90, .score-big.s91, .score-big.s92, .score-big.s93, .score-big.s94, .score-big.s95, .score-big.s96, .score-big.s97, .score-big.s98, .score-big.s99, .score-big.s100 { background:#e74c3c; } +.score-big.s60, .score-big.s61, .score-big.s62, .score-big.s63, .score-big.s64, .score-big.s65, .score-big.s66, .score-big.s67, .score-big.s68, .score-big.s69, .score-big.s70, .score-big.s71, .score-big.s72, .score-big.s73, .score-big.s74, .score-big.s75, .score-big.s76, .score-big.s77, .score-big.s78, .score-big.s79 { background:#e67e22; } +.score-big.s0, .score-big.s1, .score-big.s2, .score-big.s3, .score-big.s4, .score-big.s5, .score-big.s6, .score-big.s7, .score-big.s8, .score-big.s9, .score-big.s10, .score-big.s11, .score-big.s12, .score-big.s13, .score-big.s14, .score-big.s15, .score-big.s16, .score-big.s17, .score-big.s18, .score-big.s19, .score-big.s20, .score-big.s21, .score-big.s22, .score-big.s23, .score-big.s24, .score-big.s25, .score-big.s26, .score-big.s27, .score-big.s28, .score-big.s29, .score-big.s30, .score-big.s31, .score-big.s32, .score-big.s33, .score-big.s34, .score-big.s35, .score-big.s36, .score-big.s37, .score-big.s38, .score-big.s39 { background:#9ca3af; } +.score-label { text-align:center; color:#6b7280; font-size:12px; margin-top:2px; } + +.tag { display:inline-block; padding:2px 8px; border-radius:6px; font-size:11px; background:#f3f4f6; color:#6b7280; } +.tag.hot { background:#fef2f2; color:#dc2626; } +.tag.ok { background:#ecfdf5; color:#059669; } +.tag.err { background:#fef2f2; color:#dc2626; } + +.filters { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; align-items:center; } +.filters select, .filters input { padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; background:#fff; font-size:13px; } +.filters input[type=text]{ width:260px; } +.pager { display:flex; gap:12px; align-items:center; justify-content:center; margin:16px 0; } + +table { width:100%; border-collapse:collapse; } +th { text-align:left; padding:10px 8px; color:#6b7280; font-size:12px; border-bottom:2px solid #f3f4f6; } +td { padding:10px 8px; border-bottom:1px solid #f3f4f6; vertical-align:top; } + +.form-row { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; align-items:center; } +.form-row input, .form-row select, .form-row textarea { padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; flex:1; min-width:120px; } +textarea { width:100%; } +.tag-list { display:flex; flex-wrap:wrap; gap:8px; } +.chip { display:inline-flex; align-items:center; gap:6px; background:#eff6ff; color:#1d4ed8; padding:4px 10px; border-radius:20px; font-size:13px; } +.chip b { color:#93c5fd; } +.chip-x { cursor:pointer; color:#93c5fd; font-weight:700; } +.chip-x:hover { color:#dc2626; } + +.bars { display:flex; flex-direction:column; gap:8px; } +.bar-row { display:flex; align-items:center; gap:10px; } +.bar-label { width:120px; font-size:13px; color:#374151; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.bar-track { flex:1; background:#f3f4f6; border-radius:6px; height:18px; overflow:hidden; } +.bar-fill { background:#2563eb; height:100%; border-radius:6px; } +.bar-val { width:36px; text-align:right; font-size:13px; font-weight:600; color:#111827; } + +.detail-grid { display:grid; grid-template-columns:2fr 1fr; gap:18px; align-items:start; } +@media (max-width:900px){ .detail-grid{grid-template-columns:1fr;} } +.detail-content { line-height:1.8; color:#374151; } +.detail-meta { margin-top:16px; padding-top:12px; border-top:1px solid #f3f4f6; font-size:13px; color:#6b7280; display:flex; flex-direction:column; gap:6px; word-break:break-all; } +.kv { margin-top:14px; display:flex; flex-direction:column; gap:8px; } +.kv div { display:flex; justify-content:space-between; font-size:13px; } +.kv span { color:#6b7280; } +.analysis-box { background:#fef3c7; border-radius:8px; padding:10px 12px; font-size:13px; color:#92400e; margin-top:12px; line-height:1.6; } +.chips { display:flex; flex-wrap:wrap; gap:6px; } + +.setting-row { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid #f9fafb; gap:14px; } +.setting-row label { color:#374151; font-size:13px; flex:1; } +.setting-row input, .setting-row select { width:180px; padding:7px 9px; border:1px solid #d1d5db; border-radius:8px; font-size:13px; } +.setting-row input[type=checkbox]{ width:auto; } + +.toast { position:fixed; top:20px; left:50%; transform:translateX(-50%); padding:12px 22px; border-radius:10px; color:#fff; font-size:14px; box-shadow:0 4px 12px rgba(0,0,0,.2); z-index:99; } +.toast.ok { background:#059669; } +.toast.err { background:#dc2626; } diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..5385688 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}404{% endblock %} +{% block content %} +
+
🕳️
+

页面不存在

+ ← 返回仪表盘 +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2515883 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,53 @@ + + + + + +{% block title %}新闻智能跟踪系统{% endblock %} + + + +
+ +
+ {% block content %}{% endblock %} +
+
+ +{% block script %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..6f985cd --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% set active = 'dashboard' %} +{% block title %}仪表盘 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

📊 仪表盘

+
+ + + + +
+
+ +
+
{{ stats.total }}
资讯总数
+
{{ stats.important }}
重要资讯
+
{{ stats.notified }}
已通知
+
{{ stats.pending_llm }}
待深度分析
+
+ +
+
+

🔥 重要资讯 TOP

+ {% for a in important %} +
+ {{ a.title }} +
+ {{ a.total_score }} + {{ a.domain }} + {{ a.published_at }} +
+
+ {% else %} +
暂无重要资讯
+ {% endfor %} +
+
+

🗂️ 领域分布

+
+ {% for d in domain_stats %} +
+ {{ d.name }} +
+ {{ d.count }} +
+ {% endfor %} +
+
+
+ +
+

🕒 最新收录

+ {% for a in latest %} +
+ {{ a.title }} +
+ {{ a.total_score }} + {{ a.source_name }} + {{ a.collected_at }} +
+
+ {% else %} +
尚未收录资讯,点击「立即采集」
+ {% endfor %} +
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/templates/detail.html b/templates/detail.html new file mode 100644 index 0000000..6f6eda5 --- /dev/null +++ b/templates/detail.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% set active = 'news' %} +{% block title %}{{ art.title }} - 新闻智能跟踪{% endblock %} +{% block content %} +
+

{{ art.title }}

+ ← 返回列表 +
+ +
+
+

📝 资讯内容

+
{{ art.content or art.summary }}
+
+ +
来源:{{ art.source_name }} · 作者:{{ art.author or '—' }}
+
发布时间:{{ art.published_at }} · 收录时间:{{ art.collected_at }}
+
+
+ +
+{% endblock %} diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..1d31210 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% set active = 'logs' %} +{% block title %}通知日志 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

✉️ 通知日志

+ +
+
+ + + + {% for l in logs %} + + + + + + + + + {% else %} + + {% endfor %} + +
ID类型主题条数状态发送时间
{{ l.id }}{% if l.type=='realtime' %}实时{% else %}汇总{% endif %}{{ l.subject }}{{ l.count }}{% if l.status=='ok' %}成功{% else %}失败{% endif %}{{ l.sent_at }}
暂无通知记录
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/templates/news.html b/templates/news.html new file mode 100644 index 0000000..4dc1eb6 --- /dev/null +++ b/templates/news.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% set active = 'news' %} +{% block title %}资讯列表 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

📰 资讯列表 共 {{ total }} 条

+
+
+ + + + + 重置 +
+ +
+ {% for a in articles %} +
+ {{ a.title }} +
+ {{ a.total_score }} + {% if a.is_important %}重要{% endif %} + {{ a.domain }} + {{ a.source_name }} + {{ a.published_at }} + {% if a.llm_status=='done' %}已深析{% elif a.llm_status=='pending' %}分析中{% endif %} +
+
{{ a.summary or (a.content[:100] if a.content else '') }}
+
+ {% else %} +
没有匹配的资讯
+ {% endfor %} +
+ +
+ {% if page > 1 %}上一页{% endif %} + 第 {{ page }} / {{ pages }} 页 + {% if page < pages %}下一页{% endif %} +
+{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..0697d76 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% set active = 'profile' %} +{% block title %}兴趣画像 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

🎯 兴趣画像

+

系统据此判断资讯对您的重要度与相关度。修改后建议在仪表盘点「重新打分」。

+
+ +
+
+

🔑 关注关键词

+
+ + + +
+
+ {% for k in keywords %} + {{ k.keyword }} {{ k.weight }} + {% endfor %} +
+
+ +
+

🏷️ 关注公司/实体

+
+ + +
+
+ {% for c in companies %} + {{ c.name }} + {% endfor %} +
+
+
+ +
+

🗂️ 关注领域

+
+ + + +
+
+ {% for d in domains %} + {{ d.name }} {{ d.weight }} + {% endfor %} +
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..7f61694 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% set active = 'settings' %} +{% block title %}设置 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

⚙️ 设置

+ +
+ +
+
+

🕐 自动化

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

📮 邮件配置

+
+
+
+
+
+ + +
+
+
+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/templates/sources.html b/templates/sources.html new file mode 100644 index 0000000..f031628 --- /dev/null +++ b/templates/sources.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% set active = 'sources' %} +{% block title %}数据源 - 新闻智能跟踪{% endblock %} +{% block content %} +
+

📡 数据源管理

+ +
+ + + +
+ + + + {% for s in sources %} + + + + + + + + + + + {% endfor %} + +
ID名称类型权重状态最近采集新增条数操作
{{ s.id }}{{ s.name }}
{{ s.description }}
{{ s.type }}{{ s.weight }}{% if s.enabled %}启用{% else %}停用{% endif %}{{ s.last_fetch or '—' }}{{ s.last_count }} + + +
+
+{% endblock %} +{% block script %} + +{% endblock %}