diff --git a/README.md b/README.md index 888a321..7c2a938 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。 -> 当前版本 v1.0.1(补充说明文档);当前数据源为**高仿真模拟数据**(simulate.py),全链路已跑通,接入真实源只需在 sources 适配层实现 `fetch()`。 +> 当前版本 **v1.1.0**:真实网页采集(可读正文入库)+ 大模型接口多预置一键切换 + 数据源可编辑。 --- @@ -14,16 +14,20 @@ │ sources │───▶│ scheduler │───▶│ analysis.py │───▶│ notifier.py │ │ (7个默认) │ │ (后台线程) │ │ (规则+LLM两级分析) │ │ (实时+日报) │ └──────────┘ └──────────────┘ └──────────────────┘ └─────────────────┘ - 模拟源 fetch() 每30分钟采集一次 规则打分→候选 LLM完成→重要度判定 - simulate.py (scan_interval_min) →后台LLM深度分析 →发实时邮件 - 每天10:00日报 →实时/日报邮件 + 真实URL→爬虫抓取 每30分钟采集一次 规则打分→候选 LLM完成→重要度判定 + crawler.py (scan_interval_min) →后台LLM深度分析 →发实时邮件 + 正文清洗+全文入库 每天10:00日报 →实时/日报邮件 + example.com→模拟 大模型可一键切换 ``` **完整链路:** -1. **采集**(每 `scan_interval_min` 分钟,默认 30):`scheduler.collect_once()` 调 `simulate.fetch_simulated()` 拉取新资讯 → 按 URL 去重 → 入库 `articles` 表。 +1. **采集**(每 `scan_interval_min` 分钟,默认 30):`scheduler.collect_once()` 调 `crawler.fetch_all()`—— + - 真实 URL 源:用 `requests` 抓页面 → **清洗出干净可读正文**(readability 风格,去除导航/广告/脚本)→ 提取候选资讯链接(按文章相似度排序、过滤导航词)→ 对前 5 条抓全文,**连同页面可读正文一起入库**(`articles.full_text`,详情页可查看); + - `example.com` 占位源:用 `simulate.items_for_source()` 仿真数据填充,保证链路不空; + - 真实源抓取失败:跳过并标记该源 `status=error`(数据源页可见),**不塞模拟数据冒充**。 2. **规则打分**(立即):`analysis.analyze_article()` 对每条新资讯算**兴趣相关度**(关键词命中权重 + 领域匹配 + 关注公司命中)和**重要度启发式**(发布/融资/禁令等强信号词、金额量级、时效性、数据源权重、公司影响),得到**综合分 total_score (0-100)**。 -3. **LLM 深度分析**(后台异步):规则分 ≥ `llm_threshold`(默认 60)的资讯进入 `llm_analyze()`,调用 DeepSeek 输出重要度 1-10、相关度、分类、一句话结论,并与规则分融合更新。 +3. **LLM 深度分析**(后台异步):规则分 ≥ `llm_threshold`(默认 60)的资讯进入 `llm_analyze()`,**使用网页激活的大模型接口**(默认 SiliconFlow,可一键切换;调用失败自动切换下一个可用接口),输出重要度 1-10、相关度、分类、一句话结论,并与规则分融合更新。 4. **实时通知**:扫描「重要 + 已分析完成 + 未通知」的资讯,综合分 ≥ `realtime_threshold`(默认 80)→ 发 **🔥 重要AI资讯实时提醒** 邮件,标记 `notified=1`。 5. **每日日报**(默认每天 10:00):汇总往前 `summary_window_hours`(默认 24h)内综合分 ≥50 的资讯,按分排序取前 `max_summary_items`(默认 15)条发 **📰 AI资讯日报**,含领域分布统计。 @@ -31,13 +35,14 @@ ## 功能特性 -- 📥 **自动定时采集**:数据源可增删改、可启停,每源带权重(影响重要度打分) -- 🧠 **两级智能分析**:规则打分秒级响应 + DeepSeek LLM 深度分析(重要度/相关度/分类/结论) +- 📥 **真实网页采集**:真实 URL 源自动抓取 → readability 风格清洗出干净可读正文 → **全文入库**(详情页可看),提取候选资讯链接按文章相似度排序;`example.com` 占位源走仿真数据;失败源标记 error 不造假 +- 🧠 **两级智能分析**:规则打分秒级响应 + LLM 深度分析(重要度/相关度/分类/结论) +- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口 - 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条 - 📰 **每日 AI 资讯日报**:默认每天 10:00,按领域统计 + 重点资讯卡片 - 🎯 **兴趣画像管理**:关键词(带权重)、领域、关注公司,全部可在网页维护,改完一键「重新打分」 -- 🔗 **数据源管理**:7 个内置数据源模板,启停/权重/类型自由配置 -- 📊 **网页管理台**:仪表盘(总数/重要/待分析/趋势/领域分布)、资讯列表(筛选/搜索/分页)、详情页、通知日志 +- 🔗 **数据源管理**:7 个内置数据源模板,**可编辑**(名称/类型/URL/权重/描述)、启停、删除,每源带权重 +- 📊 **网页管理台**:仪表盘(总数/重要/待分析/趋势/领域分布)、资讯列表(筛选/搜索/分页)、详情页(含全文)、通知日志 - 📧 **邮件通知**:支持 plain / starttls / ssl 三种 SMTP 模式,可测试发送 - 🛠 **手动操作台**:一键采集 / 一键 LLM 分析 / 一键发日报 / 一键造数据 / 重新打分 / 测试邮件 @@ -46,8 +51,9 @@ ## 技术栈 - **后端**:Python 3 + Flask(threaded) +- **网页采集**:requests + BeautifulSoup/lxml(readability 风格正文清洗) - **数据库**:SQLite(原生 sqlite3,WAL 模式,无 ORM) -- **大模型**:DeepSeek `deepseek-v4-flash`(OpenAI 兼容接口,`response_format=json_object`) +- **大模型**:OpenAI 兼容接口,默认 SiliconFlow `deepseek-ai/DeepSeek-V4-Flash`(可一键切换,`response_format=json_object`) - **前端**:原生 HTML/CSS/JS + Jinja2 模板(无前端框架) --- @@ -56,12 +62,13 @@ ``` news-tracker/ -├── app.py # Flask 主应用:页面路由 + API -├── config.py # 全局配置(端口/LLM/邮件/自动化默认值/默认画像/数据源模板) -├── db.py # SQLite 数据访问层(原生 sqlite3) +├── app.py # Flask 主应用:页面路由 + API(含 /api/llm 大模型接口管理) +├── config.py # 全局配置(端口/LLM/预置大模型接口/采集参数/自动化/画像/数据源模板) +├── db.py # SQLite 数据访问层(原生 sqlite3;含 llm_providers / full_text 迁移) +├── crawler.py # 真实网页采集:抓取 + readability 清洗 + 链接提取 + 全文入库 ├── scheduler.py # 后台调度器:定时采集 + 每日日报 + 实时通知扫描 -├── analysis.py # 智能分析引擎:规则打分 + LLM 深度分析 -├── simulate.py # 模拟数据源(真实源接入前跑通全链路) +├── analysis.py # 智能分析引擎:规则打分 + LLM 深度分析(多接口自动切换) +├── simulate.py # 模拟数据源(example.com 占位源 / 真实源失败时回退) ├── notifier.py # 邮件通知(实时 / 日报,plain/starttls/ssl) ├── start.sh # 启动/停止/状态脚本 ├── templates/ # Jinja2 页面模板 @@ -111,9 +118,22 @@ cd works/news-tracker `smtp_host` / `smtp_port` / `smtp_user` / `smtp_pass` / `smtp_mode`(plain|starttls|ssl) / `email_to` / `sender_name`。默认 `mail.tphai.com:587` plain,收件人 `wlq@tphai.com`。 -### 大模型配置(改 config.py) +### 大模型接口(设置页,一键切换) -`LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_TIMEOUT` / `LLM_MAX_TOKENS` / `LLM_TEMPERATURE`。 +预置 4 个接口(`config.py` 的 `LLM_PROVIDERS_DEFAULT`,首启写入 `llm_providers` 表): + +| 名称 | Base URL | 模型 | +|------|----------|------| +| **SiliconFlow(默认激活)** | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V4-Flash` | +| DeepSeek 官方 | `https://api.deepseek.com` | `deepseek-v4-flash` | +| Autodl(火山方舟中转) | `https://www.autodl.art/api/v1` | `qwen3.6-plus` | +| Local Qwen(内网) | `http://121.40.164.32:18003/v1` | `unsloth/Qwen3.8-27B-Q6_K` | + +设置页可**增删改/测试**任意接口,点「⚡ 切换为当前」一键切换即时生效;分析时当前接口失败会自动切换下一个可用接口。`config.py` 里的 `LLM_BASE_URL/KEY/MODEL` 仅作无激活记录时的回退。 + +### 采集参数(config.py) + +`CRAWL_DEFAULTS`:`per_source_links`(每源最多采集条目,默认 8)、`full_fetch_links`(其中抓全文条数,默认 5)、`crawl_timeout`(单页超时,默认 20s)、`user_agent`。 --- @@ -123,11 +143,11 @@ cd works/news-tracker |------|------| | `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 | | `/news` | 资讯列表:按领域/重要/关键词搜索,分页 | -| `/news/` | 资讯详情:规则分/LLM分/实体/分析结论 | -| `/sources` | 数据源管理:增删改、启停、权重 | +| `/news/` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** | +| `/sources` | 数据源管理:**编辑**/启停/删除/权重,真实源状态可见 | | `/profile` | 兴趣画像:关键词/领域/公司维护 | | `/logs` | 通知日志(实时/日报发送记录) | -| `/settings` | 设置:自动化参数 + 邮件配置 + 测试 | +| `/settings` | 设置:自动化参数 + 邮件配置 + **大模型接口管理(一键切换/测试)** | --- @@ -139,6 +159,7 @@ cd works/news-tracker | `/api/sources` | POST | `action=add\|update\|delete\|toggle` 数据源管理 | | `/api/profile` | POST | `action=add\|delete`,`kind=keyword\|domain\|company` 画像维护 | | `/api/settings` | POST | 更新 `auto` / `mail` 设置 | +| `/api/llm` | POST | 大模型接口:`add\|update\|delete\|switch\|toggle\|test`(switch=一键切换) | | `/api/actions` | POST | `action=collect`(采集) `llm`(LLM分析) `summary`(发日报) `seed`(造数据) `reanalyze`(重新打分) `test_mail`(测试邮件) | 请求体为 JSON,如:`{"action": "collect"}`、`{"action": "llm", "limit": 10}`。 @@ -147,9 +168,10 @@ cd works/news-tracker ## 数据模型 -- **sources**:数据源(name/type/url/weight/enabled/status/last_fetch/last_count) -- **articles**:资讯(title/url/content/summary/domain/entities/importance/relevance/total_score/is_important/analysis/llm_status/notified/status/published_at/collected_at) +- **sources**:数据源(name/type/url/weight/enabled/status/last_fetch/last_count;status 标记真实源采集 ok/error) +- **articles**:资讯(title/url/content/summary/**full_text(页面可读全文)**/domain/entities/importance/relevance/total_score/is_important/analysis/llm_status/notified/status/published_at/collected_at) - **keywords** / **domains** / **companies**:兴趣画像 +- **llm_providers**:大模型接口(name/base_url/api_key/model/active/enabled;active=当前激活) - **notification_log**:邮件通知日志 - **settings**:KV 配置(`auto` / `mail` 为 JSON) @@ -161,30 +183,22 @@ cd works/news-tracker ## 接入真实数据源 -当前用 `simulate.py` 模拟采集跑通全链路。接入真实源只需: +系统已内置真实网页采集(`crawler.py`),接入真实源**无需写代码**: -1. 新建 `sources_xxx.py`,实现 `fetch() -> list[dict]`,返回结构与模拟源一致: - ```python - # 每条 dict 至少含: - # title, url, content(或summary), published_at(YYYY-MM-DD HH:MM:SS), domain, entities(list) - ``` -2. 在 `scheduler.collect_once()` 里把 `simulate.fetch_simulated()` 换成真实 `fetch()`(可多源合并)。 -3. 其余逻辑(去重/打分/LLM/通知/网页)**无需改动**。 +1. 在数据源页「编辑」已有源或「新增」源,填真实 URL(如媒体频道/官网新闻/RSS 页); +2. 保存后下次采集会自动抓取该页 → 清洗出可读正文 → 提取资讯链接 → 对前 5 条抓全文入库; +3. 抓取失败的源会在数据源页标记 **error** 状态(网络被拒/反爬),不阻塞其他源; +4. `example.com` 开头的占位地址继续走模拟数据,方便调试。 ---- - -## 当前状态 - -- ✅ 服务运行中:端口 **16100** -- ✅ 数据库已有 **32 条**资讯(重要 12 条,LLM 已分析 12 条,已通知 12 条) -- ✅ 邮件链路已验证(`mail.tphai.com:587` plain,收件人 `wlq@tphai.com`) -- 🕐 数据源为模拟数据,等待接入真实源 +> 提示:部分站点(Next.js 重 JS 渲染 / 强反爬)服务端 HTML 拿不到正文,会提取不到链接(count=0);本机对 arXiv/GitHub/OpenAI 等站网络不稳定,建议优先添加可达的站点。 --- ## 常见问题 - **收不到邮件?** 在设置页点「测试邮件」;确认 `smtp_mode` 与端口匹配(plain 一般不加密、starttls 用 587、ssl 用 465)。 +- **LLM 分析失败?** 设置页大模型接口点「测试」;切一个更稳的接口(如 SiliconFlow);系统会自动切备用接口,仍失败则该条标 `error` 不阻塞。 +- **真实源采集不到?** 数据源页看该源状态是否 **error**(网络被拒/反爬);可换成可达的站点或 RSS 地址。 - **想立即看效果?** 数据管理页(`/api/actions` 手动)点「立即采集 / LLM 分析 / 发送日报」。 - **改了兴趣画像想重新算分?** 点「重新打分」(reanalyze)即可全量重跑规则打分。 - **日志在哪?** `logs/app.log`(服务日志)+ 网页 `/logs`(通知日志)。 @@ -194,4 +208,4 @@ cd works/news-tracker ## Git - 仓库:`hz4th_coder/news-tracker` -- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README) +- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ **`v1.1.0`**(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑) diff --git a/analysis.py b/analysis.py index 1df11b5..3247f55 100644 --- a/analysis.py +++ b/analysis.py @@ -178,6 +178,53 @@ def analyze_article(aid): "importance_rule": imp, "total_score": total, "is_important": is_important} +def get_llm_cfg(): + """当前激活的大模型接口(网页可一键切换);无则回退 config 默认""" + p = db.get_active_provider() + if p and p.get("base_url"): + return {"name": p["name"], "base_url": p["base_url"].rstrip("/"), + "api_key": p.get("api_key", ""), "model": p.get("model", "")} + return {"name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL, + "api_key": config.LLM_API_KEY, "model": config.LLM_MODEL} + + +def _llm_chat(prompt): + """调用大模型,返回 (content, provider_name)。激活接口失败自动切换下一可用接口。""" + active = get_llm_cfg() + chain = [active] + cur = db.get_active_provider() + if cur: + for p in db.enabled_providers_except(cur["id"]): + chain.append({"name": p["name"], "base_url": (p["base_url"] or "").rstrip("/"), + "api_key": p.get("api_key", ""), "model": p.get("model", "")}) + else: + chain.append({"name": "DeepSeek 官方", "base_url": config.LLM_BASE_URL, + "api_key": config.LLM_API_KEY, "model": config.LLM_MODEL}) + last_err = "" + for cfg in chain: + if not cfg.get("base_url"): + continue + try: + resp = requests.post( + f"{cfg['base_url']}/chat/completions", + headers={"Authorization": f"Bearer {cfg['api_key']}", + "Content-Type": "application/json"}, + json={"model": cfg["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"] + return content, cfg.get("name", cfg["base_url"]) + except Exception as e: + last_err = str(e) + continue + raise RuntimeError(f"所有大模型接口调用失败: {last_err}") + + def llm_analyze(aid): """LLM 深度分析单条:重要度 1-10 + 相关度 + 结论。失败则标记 error 不阻塞。""" a = db.get_article(aid) @@ -194,21 +241,7 @@ def llm_analyze(aid): '"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"] + content, _provider = _llm_chat(prompt) parsed = json.loads(content) importance = max(1, min(10, int(parsed.get("importance", 5)))) relevance = max(0, min(100, int(parsed.get("relevance", 50)))) diff --git a/app.py b/app.py index 9aa0c88..7edf9e1 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,8 @@ from datetime import datetime, timedelta from flask import Flask, render_template, request, jsonify, redirect, url_for +import requests + import config import db import simulate @@ -87,7 +89,8 @@ def logs(): @app.route("/settings") def settings_page(): - return render_template("settings.html", auto=db.get_all_settings()) + return render_template("settings.html", auto=db.get_all_settings(), + providers=db.list_providers()) # ---------------- API ---------------- @@ -170,6 +173,69 @@ def api_settings(): return jsonify({"ok": True}) +@app.route("/api/llm", methods=["POST"]) +def api_llm(): + """大模型接口管理:增删改 / 一键切换 / 测试""" + data = request.get_json(force=True) or {} + action = data.get("action") + if action == "add": + pid = db.add_provider( + data.get("name", ""), data.get("base_url", ""), data.get("api_key", ""), + data.get("model", ""), + active=1 if data.get("active") else 0, + enabled=1 if data.get("enabled", 1) else 0, + ) + if data.get("active"): + db.set_active_provider(pid) + return jsonify({"ok": True, "id": pid}) + if action == "update": + db.update_provider(data["id"], name=data.get("name"), base_url=data.get("base_url"), + api_key=data.get("api_key"), model=data.get("model"), + enabled=1 if data.get("enabled", 1) else 0) + return jsonify({"ok": True}) + if action == "delete": + db.delete_provider(data["id"]) + return jsonify({"ok": True}) + if action == "switch": + db.set_active_provider(data["id"]) + p = db.get_provider(data["id"]) + return jsonify({"ok": True, "name": p["name"] if p else ""}) + if action == "toggle": + p = db.get_provider(data["id"]) + if not p: + return jsonify({"ok": False, "error": "not found"}) + db.update_provider(data["id"], enabled=0 if p["enabled"] else 1) + return jsonify({"ok": True}) + if action == "test": + # 用指定接口(或当前激活接口)发一条测试消息 + cfg = None + if data.get("id"): + p = db.get_provider(data["id"]) + if p and p.get("base_url"): + cfg = {"name": p["name"], "base_url": p["base_url"].rstrip("/"), + "api_key": p.get("api_key", ""), "model": p.get("model", "")} + try: + if cfg is None: + import analysis + cfg = analysis.get_llm_cfg() + r = requests.post( + f"{cfg['base_url']}/chat/completions", + headers={"Authorization": f"Bearer {cfg['api_key']}", + "Content-Type": "application/json"}, + json={"model": cfg["model"], + "messages": [{"role": "user", "content": "请回复:连接正常"}], + "max_tokens": 60, "temperature": 0.3}, + timeout=60, + ) + r.raise_for_status() + content = r.json()["choices"][0]["message"]["content"] + return jsonify({"ok": True, "name": cfg["name"], "model": cfg["model"], + "reply": content}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + return jsonify({"ok": False, "error": "unknown action"}) + + @app.route("/api/actions", methods=["POST"]) def api_actions(): data = request.get_json(force=True) or {} @@ -223,6 +289,13 @@ def main(): db.add_domain(d, w) for c in config.DEFAULT_COMPANIES: db.add_company(c) + # 预置大模型接口(可一键切换) + if not db.list_providers(): + for p in config.LLM_PROVIDERS_DEFAULT: + pid = db.add_provider(p["name"], p["base_url"], p["api_key"], p["model"], + active=1 if p.get("active") else 0) + if p.get("active"): + db.set_active_provider(pid) db.set_setting("initialized", 1) db.set_setting("auto", dict(config.AUTO_DEFAULTS)) db.set_setting("mail", dict(config.MAIL_DEFAULTS)) diff --git a/config.py b/config.py index fb01d42..d52b73b 100644 --- a/config.py +++ b/config.py @@ -21,7 +21,7 @@ SERVICE_PORT = 16100 SERVICE_HOST = "0.0.0.0" SERVICE_NAME = "新闻智能跟踪系统" -# ---------------- 大模型(DeepSeek) ---------------- +# ---------------- 大模型(默认回退配置,运行时以网页里激活的 llm_providers 为准) ---------------- LLM_BASE_URL = "https://api.deepseek.com" LLM_API_KEY = "sk-edb9df58ff574f8c98df1cd6a425e97c" LLM_MODEL = "deepseek-v4-flash" # 推理型,分析质量高 @@ -29,6 +29,31 @@ LLM_TIMEOUT = 120 LLM_MAX_TOKENS = 1500 LLM_TEMPERATURE = 0.3 +# 预置大模型接口(首次初始化写入 llm_providers 表,可在网页增删改 + 一键切换) +LLM_PROVIDERS_DEFAULT = [ + {"name": "SiliconFlow(默认)", "base_url": "https://api.siliconflow.cn/v1", + "api_key": "sk-fhpoexpptvjghpnphtaxbkhjwulzovoqfffbckcfscjmwhcg", + "model": "deepseek-ai/DeepSeek-V4-Flash", "active": 1}, + {"name": "DeepSeek 官方", "base_url": "https://api.deepseek.com", + "api_key": "sk-edb9df58ff574f8c98df1cd6a425e97c", + "model": "deepseek-v4-flash", "active": 0}, + {"name": "Autodl(火山方舟中转)", "base_url": "https://www.autodl.art/api/v1", + "api_key": "F9MBfolzuapqTsD4KmUf9qen720rXvUZ3Sp3IrWiCTukqonx", + "model": "qwen3.6-plus", "active": 0}, + {"name": "Local Qwen(内网)", "base_url": "http://121.40.164.32:18003/v1", + "api_key": "sk-xxxx", + "model": "unsloth/Qwen3.8-27B-Q6_K", "active": 0}, +] + +# ---------------- 网页采集(真实源抓取) ---------------- +CRAWL_DEFAULTS = { + "per_source_links": 8, # 每源最多采集候选条目 + "full_fetch_links": 5, # 其中最多几条抓取全文(存 full_text) + "crawl_timeout": 20, # 单页请求超时(秒) + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36", +} + # ---------------- 邮件通知(默认值,可在设置区修改) ---------------- MAIL_DEFAULTS = { "smtp_host": "mail.tphai.com", @@ -81,17 +106,17 @@ DEFAULT_COMPANIES = [ # 内置数据源模板(首次初始化时写入,可在网页管理) DEFAULT_SOURCES = [ {"name": "OpenAI 官方动态", "type": "公司动态", "url": "https://openai.com/news", - "description": "OpenAI 官方新闻与产品发布", "weight": 1.0}, + "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}, + "description": "人工智能领域最新论文(真实抓取)", "weight": 0.6}, {"name": "GitHub 开源趋势", "type": "开源社区", "url": "https://github.com/trending", - "description": "开源项目趋势与发布", "weight": 0.7}, + "description": "开源项目趋势与发布(真实抓取)", "weight": 0.7}, {"name": "政策与监管", "type": "政策法规", "url": "https://example.com/policy", - "description": "各国 AI 政策、监管与出口管制动态", "weight": 0.9}, + "description": "各国 AI 政策、监管与出口管制动态(模拟源,可改真实 URL)", "weight": 0.9}, {"name": "行业报告与调研", "type": "行业报告", "url": "https://example.com/report", - "description": "行业数据报告、市场调研", "weight": 0.7}, + "description": "行业数据报告、市场调研(模拟源,可改真实 URL)", "weight": 0.7}, {"name": "科技投资动态", "type": "投融资", "url": "https://example.com/funding", - "description": "AI 领域融资、并购、IPO 动态", "weight": 0.9}, + "description": "AI 领域融资、并购、IPO 动态(模拟源,可改真实 URL)", "weight": 0.9}, ] diff --git a/crawler.py b/crawler.py new file mode 100644 index 0000000..c696eb9 --- /dev/null +++ b/crawler.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +""" +新闻智能跟踪系统 - 真实网页采集层 +职责: + 1. 抓取数据源页面(真实 URL),用 readability 风格启发式清洗出「干净可读正文」 + 2. 从页面提取候选资讯链接(标题 + 绝对 URL),按文章相似度排序 + 3. 对前 N 条候选链接抓取全文,一并入库(存 articles.full_text 便于后期查看) + 4. example.com 等模拟源 / 抓取失败源,回退到 simulate 模拟数据,保证全链路不空转 + +数据结构与 simulate 保持一致:fetch_source(source) -> list[dict] + (title/url/content/summary/published_at/domain/entities/source_id/full_text) +""" +import re +import time +from datetime import datetime +from urllib.parse import urljoin, urlparse + +import requests +from bs4 import BeautifulSoup + +import config +import db +import simulate + +# 直接丢弃的标签(导航/脚本/广告/评论等) +_BAD_TAGS = [ + "script", "style", "noscript", "iframe", "form", "button", "select", "option", + "input", "svg", "canvas", "video", "audio", "nav", "footer", "header", "aside", +] +# 出现在 class/id 中即视为噪音块的片段 +_BAD_HINTS = [ + "comment", "share", "social", "advert", "ads", "menu", "sidebar", "footer", + "nav", "related", "recommend", "promo", "newsletter", "subscribe", + "cookie", "banner", "breadcrumb", "pagination", "toolbar", "login", "signup", + "tag-list", "author", "meta", "bottom", "widget", "popup", "overlay", +] +# 不是资讯链接的后缀/前缀 +_BAD_HREFS = ("mailto:", "tel:", "javascript:", "#", "?login", "?signup") +_BAD_EXTS = (".css", ".js", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", + ".pdf", ".zip", ".xml", ".rss", ".atom", ".json") +# 常见导航词(短标题/纯导航链接,不当作资讯) +_NAV_WORDS = { + "research", "business", "developers", "about", "careers", "blog", "contact", + "sign in", "sign up", "login", "register", "privacy", "terms", "legal", "trust", + "customer stories", "partners", "docs", "api log in", "skip to main content", + "home", "news", "newsroom", "press", "company", "safety", "product", "products", + "pricing", "solutions", "learn more", "read more", "view all", "see all", + "all news", "all stories", "open a new window", "instagram", "twitter", "facebook", + "linkedin", "youtube", "github", "login", "log in", "get started", "try now", +} +# 标题尾部常见的“分类 + 日期”后缀,清洗掉(如 "Company Aug 27, 2026") +_TRAIL_DATE_RE = re.compile(r"\s+\S+\s+[A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4}$") + + +def fetch_page(url, timeout=None, retries=2): + """抓取页面 HTML,带浏览器 UA 与语言头;瞬断自动重试""" + hdr = { + "User-Agent": config.CRAWL_DEFAULTS["user_agent"], + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + } + last = None + for i in range(retries + 1): + try: + r = requests.get(url, headers=hdr, + timeout=timeout or config.CRAWL_DEFAULTS["crawl_timeout"]) + r.raise_for_status() + return r.text + except Exception as e: + last = e + if i < retries: + time.sleep(1.5 * (i + 1)) + raise last + + +# ---------------- 正文清洗(readability 风格) ---------------- + +def _is_noise(tag): + if tag is None or not hasattr(tag, "name") or tag.name is None: + return True + if tag.name in _BAD_TAGS: + return True + try: + tokens = [] + for t in tag.get("class") or []: + # Tailwind 任意值类(如 [--header-h:...])只是 CSS 变量,语义噪音,跳过 + if t.startswith("[") and t.endswith("]"): + continue + tokens.append(t.lower()) + tokens.append((tag.get("id") or "").lower()) + cls = " ".join(tokens) + except Exception: + cls = "" + return any(h in cls for h in _BAD_HINTS) + + +def _strip_noise(soup): + """删除噪音节点,返回清洗后的 body。先收集再统一删除,避免迭代中改树。""" + noise = [] + for tag in list(soup.find_all(True)): + try: + if tag is None: + continue + if _is_noise(tag): + noise.append(tag) + continue + style = (tag.get("style") or "").lower() + if "display:none" in style or "visibility:hidden" in style: + noise.append(tag) + except Exception: + continue + for tag in noise: + try: + tag.decompose() + except Exception: + pass + return soup.body or soup + + +def _text_density(tag): + """正文块得分:有效文本长度 + 段落/标题数量""" + text = tag.get_text(" ", strip=True) + if not text: + return 0 + paras = len(tag.find_all(["p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"])) + links = len(tag.find_all("a")) + # 链接占比例过高多半是导航/聚合页,降权 + link_penalty = min(1.0, links / max(1, paras) * 0.5) + return len(text) * (1 - link_penalty) + paras * 30 + + +def _best_content_node(root): + """从清洗后的文档里挑选正文块(按文本密度打分)""" + if root is None: + return None + candidates = root.find_all(["article", "main", "div", "section"]) + if not candidates: + return root + best, best_score = root, 0 + for c in candidates: + score = _text_density(c) + if score > best_score: + best, best_score = c, score + return best + + +def _node_to_lines(node): + """把正文块转成干净的按行文本(保留段落结构)""" + if node is None: + return [] + lines = [] + for el in node.find_all(["h1", "h2", "h3", "h4", "p", "li", "pre", "blockquote", "td", "th"]): + if el.find_parent("pre") is not None and el.name != "pre": + continue + t = el.get_text(" ", strip=True) + t = re.sub(r"\s+", " ", t) + if len(t) >= 2: + lines.append(t) + if not lines: + t = node.get_text(" ", strip=True) + t = re.sub(r"\s+", " ", t) + lines = [t] if t else [] + return lines + + +def clean_html(html, url=""): + """抓到的原始 HTML -> (页面标题, 干净可读正文纯文本)""" + soup = BeautifulSoup(html, "lxml") + title = (soup.title.get_text(strip=True) if soup.title else "") or url + body = _strip_noise(soup) + node = _best_content_node(body) + text = "\n".join(_node_to_lines(node)) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + return title, text + + +# ---------------- 链接提取 ---------------- + +def _is_plausible_article(a, base_url): + href = (a.get("href") or "").strip() + if not href or href.startswith(_BAD_HREFS): + return False + low = href.lower() + if any(low.endswith(e) for e in _BAD_EXTS): + return False + if urlparse(urljoin(base_url, href)).fragment: + return False + text = re.sub(r"\s+", " ", a.get_text(" ", strip=True)).strip() + if len(text) < 10: + return False + if text.lower().strip() in _NAV_WORDS: + return False + if re.fullmatch(r"[\d\s·|/\\\-_]+", text): + return False + return True + + +def extract_links(html, base_url, max_n=10): + """从页面提取候选资讯链接 -> [{'title','url','summary'}](按文章相似度排序)""" + soup = BeautifulSoup(html, "lxml") + cands, seen = [], set() + for a in soup.find_all("a", href=True): + href = urljoin(base_url, a["href"].strip()) + if href in seen or not _is_plausible_article(a, base_url): + continue + seen.add(href) + text = re.sub(r"\s+", " ", a.get_text(" ", strip=True)).strip() + text = _TRAIL_DATE_RE.sub("", text).strip() + if len(text) < 10: + continue + # 标题在标题标签/文章块内 → 更可能是资讯 + in_head = 1 if a.find_parent(["h1", "h2", "h3", "h4", "article", "main"]) else 0 + parent = a.find_parent(["li", "p", "h1", "h2", "h3", "h4", "article"]) or a.parent + summary = "" + if parent is not None and getattr(parent, "get_text", None): + summary = re.sub(r"\s+", " ", parent.get_text(" ", strip=True))[:200] + cands.append({"title": text, "url": href, "summary": summary, + "score": len(text) + in_head * 200}) + cands.sort(key=lambda x: x["score"], reverse=True) + return [{"title": c["title"], "url": c["url"], "summary": c["summary"]} + for c in cands[:max_n]] + + +# ---------------- 按源采集 ---------------- + +def _now(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def fetch_source(source): + """采集单个数据源 -> list[item]。 + example.com 占位源返回模拟数据;真实源抓取失败抛异常(由 fetch_all 捕获并标记 error,不塞模拟数据)。 + """ + url = (source.get("url") or "").strip() + # 模拟源(example.com 占位)→ 用仿真数据填充 + if not url or "example.com" in url: + return simulate.items_for_source(source["id"], source.get("type", "")) + per = config.CRAWL_DEFAULTS["per_source_links"] + full = config.CRAWL_DEFAULTS["full_fetch_links"] + html = fetch_page(url) + page_title, page_text = clean_html(html, url) + links = extract_links(html, url, max_n=per) + items = [] + for i, lk in enumerate(links[:per]): + full_text = "" + content = lk["summary"] + if i < full and lk["url"]: + try: + h2 = fetch_page(lk["url"]) + _, full_text = clean_html(h2, lk["url"]) + if not content or len(content) < len(full_text): + content = full_text + except Exception: + full_text = "" + items.append({ + "title": lk["title"], + "url": lk["url"], + "author": source.get("name", ""), + "content": (content or page_text)[:4000], + "summary": (lk["summary"] or content or page_text)[:220], + "domain": source.get("type") or "", + "entities": [], + "source_id": source["id"], + "published_at": _now(), + "full_text": full_text or "", + }) + if not items and page_text: + # 页面本身即正文(如单篇/无链接页)→ 整页作为一条 + items.append({ + "title": page_title, + "url": url, + "author": source.get("name", ""), + "content": page_text[:4000], + "summary": page_text[:220], + "domain": source.get("type") or "", + "entities": [], + "source_id": source["id"], + "published_at": _now(), + "full_text": page_text, + }) + return items + + +def fetch_all(): + """采集全部启用数据源 -> (items, {source_id: 条数}) + 真实源失败:跳过并标记 sources.status=error(网页可见),不塞模拟数据。""" + sources = db.list_sources(only_enabled=True) + items, per, failed = [], {}, [] + for s in sources: + try: + got = fetch_source(s) + per[s["id"]] = len(got) + items.extend(got) + except Exception: + failed.append(s["id"]) + per[s["id"]] = 0 + # 标记各源采集状态 + for sid in failed: + db.update_source_fetch(sid, status="error", count=0) + for sid in set(per.keys()) - set(failed): + db.update_source_fetch(sid, status="ok", count=per.get(sid, 0)) + return items, per diff --git a/db.py b/db.py index e9b4094..416a8ec 100644 --- a/db.py +++ b/db.py @@ -83,6 +83,17 @@ CREATE TABLE IF NOT EXISTS settings ( 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); @@ -100,7 +111,11 @@ def init_db(): os.makedirs(config.DATA_DIR, exist_ok=True) conn = get_conn() conn.executescript(_SCHEMA) - conn.commit() + # 迁移:旧库补充 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() @@ -221,14 +236,15 @@ def add_article(a): 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + 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("status", "new"), a.get("published_at", ""), now_str(), + a.get("full_text", "")), ) conn.commit() conn.close() @@ -237,7 +253,8 @@ def add_article(a): def update_article(aid, **fields): allowed = {"summary", "domain", "entities", "importance", "relevance", - "total_score", "is_important", "analysis", "llm_status", "notified", "status"} + "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: @@ -447,6 +464,79 @@ def delete_company(cid): 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() diff --git a/scheduler.py b/scheduler.py index 681a133..69b2e5a 100644 --- a/scheduler.py +++ b/scheduler.py @@ -11,31 +11,30 @@ from datetime import datetime, timedelta import config import db import simulate +import crawler import analysis import notifier def collect_once(): - """执行一次采集全流程,返回新增数""" + """执行一次采集全流程,返回新增数 + 真实 URL 源走网页抓取+正文清洗+全文入库;模拟源(example.com)回退仿真数据。 + """ 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() + items, per_source = crawler.fetch_all() 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() diff --git a/simulate.py b/simulate.py index 63b9c9a..a3c47b1 100644 --- a/simulate.py +++ b/simulate.py @@ -206,6 +206,18 @@ def _make_item(row): } +def items_for_source(src_id, src_type=""): + """返回指定数据源的模拟条目(真实抓取失败/模拟源时回退用)""" + items = [] + for row in _NEWS: + if row[3] == int(src_id): + it = _make_item(row) + if src_type: + it["domain"] = src_type + items.append(it) + return items + + def fetch_simulated(): """模拟一次采集:返回随机抽取的部分条目(模拟每天新增)""" items = [] diff --git a/static/style.css b/static/style.css index 9a29e54..e7aea53 100644 --- a/static/style.css +++ b/static/style.css @@ -109,3 +109,6 @@ textarea { width:100%; } .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; } + +/* 页面可读全文(干净排版) */ +.full-text { margin-top:8px; padding:14px 16px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; font-size:13px; line-height:1.9; color:#374151; white-space:pre-wrap; word-break:break-word; } diff --git a/templates/base.html b/templates/base.html index 2515883..1a647b7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -24,7 +24,7 @@ ✉️ 通知日志 ⚙️ 设置 - +
{% block content %}{% endblock %} diff --git a/templates/detail.html b/templates/detail.html index 6f6eda5..4d21c67 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -11,6 +11,10 @@

📝 资讯内容

{{ art.content or art.summary }}
+ {% if art.full_text %} +

📄 页面可读全文(已入库)

+
{{ art.full_text }}
+ {% endif %}
来源:{{ art.source_name }} · 作者:{{ art.author or '—' }}
diff --git a/templates/settings.html b/templates/settings.html index 7f61694..21d84a6 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -12,7 +12,7 @@

🕐 自动化

- +
@@ -20,7 +20,7 @@
- +
@@ -32,7 +32,7 @@
- +
@@ -66,32 +66,119 @@
+ +
+

🤖 大模型接口 + (切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口) +

+
+ + + + + + +
+ + + + + {% for p in providers %} + + + + + + + + {% endfor %} + +
名称Base URL模型状态操作
+ {{ p.name }} + {% if p.active %}当前{% endif %} +
{{ p.api_key[:6] }}…{{ p.api_key[-4:] if p.api_key|length>10 }}
+
{{ p.base_url }}{{ p.model }}{% if p.enabled %}启用{% else %}停用{% endif %} + {% if not p.active %} + + {% endif %} + + + + +
+
+
{% endblock %} {% block script %} {% endblock %} diff --git a/templates/sources.html b/templates/sources.html index f031628..6f1386d 100644 --- a/templates/sources.html +++ b/templates/sources.html @@ -12,7 +12,7 @@
- +
@@ -24,15 +24,16 @@ ID名称类型权重状态最近采集新增条数操作 {% for s in sources %} - + {{ s.id }} - {{ s.name }}
{{ s.description }} + {{ s.name }}
{{ s.description }}
{{ s.url }} {{ s.type }} {{ s.weight }} {% if s.enabled %}启用{% else %}停用{% endif %} {{ s.last_fetch or '—' }} {{ s.last_count }} + @@ -40,6 +41,7 @@ {% endfor %} +
💡 真实 URL 源会自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。
{% endblock %} {% block script %} @@ -52,6 +54,22 @@ async function saveSource(){ }); if (r.ok) { toast('✅ 已添加'); setTimeout(()=>location.reload(), 600); } else toast('❌ 添加失败', false); } +async function editSource(id){ + const row = document.querySelector(`tr[data-sid="${id}"]`); + if (!row) return; + const name = prompt('名称', row.dataset.name); + if (name === null) return; + const type = prompt('类型', row.dataset.type); + if (type === null) return; + const url = prompt('URL / RSS 地址', row.dataset.url); + if (url === null) return; + const weight = parseFloat(prompt('权重', row.dataset.weight) || '1') || 1; + const desc = prompt('描述', row.dataset.desc); + if (desc === null) return; + const r = await API.json('/api/sources', {action:'update', id, name, type, url, weight, description:desc}); + toast(r.ok ? '✅ 已更新' : '❌ 更新失败', r.ok); + if (r.ok) setTimeout(()=>location.reload(), 400); +} async function toggleSource(id){ await API.json('/api/sources', {action:'toggle', id}); location.reload(); } async function delSource(id){ if (!confirm('确认删除该数据源?')) return;