Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4efd24e1c | ||
|
|
2b8e78a4cc |
@@ -0,0 +1,211 @@
|
||||
# 📰 新闻智能跟踪系统(news-tracker)
|
||||
|
||||
一款面向 AI 领域的**自动采集 → 智能分析 → 邮件推送**的新闻跟踪系统。后台定时从数据源采集资讯,先规则打分、再 LLM 深度分析,把与你兴趣画像相关且重要的资讯**实时发邮件提醒**,并每天 10:00 发一份**AI 资讯日报**。带完整网页管理台,兴趣画像/数据源/通知参数全部可配。
|
||||
|
||||
> 当前版本 **v1.1.0**:真实网页采集(可读正文入库)+ 大模型接口多预置一键切换 + 数据源可编辑。
|
||||
|
||||
---
|
||||
|
||||
## 系统运转流程(一张图看懂)
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ 数据源 │ │ 后台调度器 │ │ 智能分析引擎 │ │ 邮件通知 │
|
||||
│ sources │───▶│ scheduler │───▶│ analysis.py │───▶│ notifier.py │
|
||||
│ (7个默认) │ │ (后台线程) │ │ (规则+LLM两级分析) │ │ (实时+日报) │
|
||||
└──────────┘ └──────────────┘ └──────────────────┘ └─────────────────┘
|
||||
真实URL→爬虫抓取 每30分钟采集一次 规则打分→候选 LLM完成→重要度判定
|
||||
crawler.py (scan_interval_min) →后台LLM深度分析 →发实时邮件
|
||||
正文清洗+全文入库 每天10:00日报 →实时/日报邮件
|
||||
example.com→模拟 大模型可一键切换
|
||||
```
|
||||
|
||||
**完整链路:**
|
||||
|
||||
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()`,**使用网页激活的大模型接口**(默认 SiliconFlow,可一键切换;调用失败自动切换下一个可用接口),输出重要度 1-10、相关度、分类、一句话结论,并与规则分融合更新。
|
||||
4. **实时通知**:扫描「重要 + 已分析完成 + 未通知」的资讯,综合分 ≥ `realtime_threshold`(默认 80)→ 发 **🔥 重要AI资讯实时提醒** 邮件,标记 `notified=1`。
|
||||
5. **每日日报**(默认每天 10:00):汇总往前 `summary_window_hours`(默认 24h)内综合分 ≥50 的资讯,按分排序取前 `max_summary_items`(默认 15)条发 **📰 AI资讯日报**,含领域分布统计。
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📥 **真实网页采集**:真实 URL 源自动抓取 → readability 风格清洗出干净可读正文 → **全文入库**(详情页可看),提取候选资讯链接按文章相似度排序;`example.com` 占位源走仿真数据;失败源标记 error 不造假
|
||||
- 🧠 **两级智能分析**:规则打分秒级响应 + LLM 深度分析(重要度/相关度/分类/结论)
|
||||
- 🤖 **大模型接口可配置**:设置页可增删改/测试大模型接口,预置 SiliconFlow(默认)/ DeepSeek 官方 / Autodl / Local Qwen 四个,**一键切换即时生效**,激活接口失败自动切换下一个可用接口
|
||||
- 🔥 **实时重要资讯邮件**:综合分达到阈值自动推送,单批最多 10 条
|
||||
- 📰 **每日 AI 资讯日报**:默认每天 10:00,按领域统计 + 重点资讯卡片
|
||||
- 🎯 **兴趣画像管理**:关键词(带权重)、领域、关注公司,全部可在网页维护,改完一键「重新打分」
|
||||
- 🔗 **数据源管理**:7 个内置数据源模板,**可编辑**(名称/类型/URL/权重/描述)、启停、删除,每源带权重
|
||||
- 📊 **网页管理台**:仪表盘(总数/重要/待分析/趋势/领域分布)、资讯列表(筛选/搜索/分页)、详情页(含全文)、通知日志
|
||||
- 📧 **邮件通知**:支持 plain / starttls / ssl 三种 SMTP 模式,可测试发送
|
||||
- 🛠 **手动操作台**:一键采集 / 一键 LLM 分析 / 一键发日报 / 一键造数据 / 重新打分 / 测试邮件
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Python 3 + Flask(threaded)
|
||||
- **网页采集**:requests + BeautifulSoup/lxml(readability 风格正文清洗)
|
||||
- **数据库**:SQLite(原生 sqlite3,WAL 模式,无 ORM)
|
||||
- **大模型**:OpenAI 兼容接口,默认 SiliconFlow `deepseek-ai/DeepSeek-V4-Flash`(可一键切换,`response_format=json_object`)
|
||||
- **前端**:原生 HTML/CSS/JS + Jinja2 模板(无前端框架)
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
news-tracker/
|
||||
├── 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 # 模拟数据源(example.com 占位源 / 真实源失败时回退)
|
||||
├── notifier.py # 邮件通知(实时 / 日报,plain/starttls/ssl)
|
||||
├── start.sh # 启动/停止/状态脚本
|
||||
├── templates/ # Jinja2 页面模板
|
||||
├── static/ # 静态资源
|
||||
├── data/ # news_tracker.db(运行时生成)
|
||||
└── logs/ # 运行日志 + app.pid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd works/news-tracker
|
||||
./start.sh # 启动(默认端口 16100)
|
||||
./start.sh status # 查看状态
|
||||
./start.sh stop # 停止
|
||||
./start.sh restart # 重启
|
||||
```
|
||||
|
||||
访问:`http://<服务器IP>:16100/`
|
||||
|
||||
> ⚠️ 端口 16100 属于外网安全组白名单区间(16001-16100),可直接公网访问。
|
||||
> 首次启动自动初始化:写入默认数据源/兴趣画像/设置,并 `simulate.seed_all()` 造一批模拟资讯 + 后台跑一轮 LLM 分析。
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
所有默认值在 `config.py`,运行时可在网页「设置」页修改并持久化到数据库(网页优先级更高):
|
||||
|
||||
### 自动化参数(设置页)
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `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 | 汇总邮件最多条目数 |
|
||||
|
||||
### 邮件配置(设置页)
|
||||
|
||||
`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`。
|
||||
|
||||
### 大模型接口(设置页,一键切换)
|
||||
|
||||
预置 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`。
|
||||
|
||||
---
|
||||
|
||||
## 网页页面
|
||||
|
||||
| 路由 | 说明 |
|
||||
|------|------|
|
||||
| `/dashboard` | 仪表盘:总资讯/重要资讯/待分析/已通知 + 近7天趋势 + 领域分布 |
|
||||
| `/news` | 资讯列表:按领域/重要/关键词搜索,分页 |
|
||||
| `/news/<id>` | 资讯详情:规则分/LLM分/实体/分析结论 + **页面可读全文** |
|
||||
| `/sources` | 数据源管理:**编辑**/启停/删除/权重,真实源状态可见 |
|
||||
| `/profile` | 兴趣画像:关键词/领域/公司维护 |
|
||||
| `/logs` | 通知日志(实时/日报发送记录) |
|
||||
| `/settings` | 设置:自动化参数 + 邮件配置 + **大模型接口管理(一键切换/测试)** |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/stats` | GET | 统计数据(总数/重要/趋势/领域分布) |
|
||||
| `/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}`。
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
- **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)
|
||||
|
||||
`llm_status`:`pending`(待分析) → `done`(完成) / `skipped`(未达标跳过) / `error`(失败)
|
||||
|
||||
**综合分公式**:`total_score = 0.4 × 相关度 + 0.6 × 重要度`(LLM 分析后按 `0.6×规则分 + 0.4×LLM分量` 融合)
|
||||
|
||||
---
|
||||
|
||||
## 接入真实数据源
|
||||
|
||||
系统已内置真实网页采集(`crawler.py`),接入真实源**无需写代码**:
|
||||
|
||||
1. 在数据源页「编辑」已有源或「新增」源,填真实 URL(如媒体频道/官网新闻/RSS 页);
|
||||
2. 保存后下次采集会自动抓取该页 → 清洗出可读正文 → 提取资讯链接 → 对前 5 条抓全文入库;
|
||||
3. 抓取失败的源会在数据源页标记 **error** 状态(网络被拒/反爬),不阻塞其他源;
|
||||
4. `example.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`(通知日志)。
|
||||
|
||||
---
|
||||
|
||||
## Git
|
||||
|
||||
- 仓库:`hz4th_coder/news-tracker`
|
||||
- 版本:`v1.0.0`(初版)→ `v1.0.1`(补 README)→ **`v1.1.0`**(真实网页采集+全文入库 / 大模型接口多预置一键切换 / 数据源可编辑)
|
||||
+48
-15
@@ -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))))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
+302
@@ -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
|
||||
@@ -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()
|
||||
|
||||
+5
-6
@@ -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()
|
||||
|
||||
+12
@@ -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 = []
|
||||
|
||||
@@ -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; }
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
<a href="/logs" class="{{ 'active' if active=='logs' }}">✉️ 通知日志</a>
|
||||
<a href="/settings" class="{{ 'active' if active=='settings' }}">⚙️ 设置</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">由 DeepSeek 智能分析驱动</div>
|
||||
<div class="sidebar-foot">由大模型智能分析驱动 · 可一键切换</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
<section class="card">
|
||||
<h3>📝 资讯内容</h3>
|
||||
<div class="detail-content">{{ art.content or art.summary }}</div>
|
||||
{% if art.full_text %}
|
||||
<h3 style="margin-top:20px;">📄 页面可读全文(已入库)</h3>
|
||||
<div class="full-text">{{ art.full_text }}</div>
|
||||
{% endif %}
|
||||
<div class="detail-meta">
|
||||
<div>🔗 <a href="{{ art.url }}" target="_blank">{{ art.url }}</a></div>
|
||||
<div>来源:{{ art.source_name }} · 作者:{{ art.author or '—' }}</div>
|
||||
|
||||
+106
-19
@@ -12,7 +12,7 @@
|
||||
<h3>🕐 自动化</h3>
|
||||
<div class="setting-row">
|
||||
<label>自动定时采集</label>
|
||||
<input type="checkbox" id="s_auto_collect" {{ 'checked' if auto.auto.auto_collect }} onchange="setNum('auto_collect', this)">
|
||||
<input type="checkbox" id="s_auto_collect" {{ 'checked' if auto.auto.auto_collect }}>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>采集间隔(分钟)</label>
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>实时重要资讯邮件</label>
|
||||
<input type="checkbox" id="s_realtime_enabled" {{ 'checked' if auto.auto.realtime_enabled }} onchange="setNum('realtime_enabled', this)">
|
||||
<input type="checkbox" id="s_realtime_enabled" {{ 'checked' if auto.auto.realtime_enabled }}>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>实时通知阈值(综合分)</label>
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>每日汇总邮件</label>
|
||||
<input type="checkbox" id="s_summary_enabled" {{ 'checked' if auto.auto.summary_enabled }} onchange="setNum('summary_enabled', this)">
|
||||
<input type="checkbox" id="s_summary_enabled" {{ 'checked' if auto.auto.summary_enabled }}>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>汇总时间</label>
|
||||
@@ -66,32 +66,119 @@
|
||||
<div class="setting-row"><label>发件人名称</label><input id="m_name" value="{{ auto.mail.sender_name }}"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h3>🤖 大模型接口
|
||||
<span class="muted small">(切换后即时生效,智能分析将使用当前接口;失败自动切换下一个可用接口)</span>
|
||||
</h3>
|
||||
<div class="form-row" style="margin-bottom:12px;">
|
||||
<input id="lp_name" placeholder="名称(如:SiliconFlow / DeepSeek 官方)">
|
||||
<input id="lp_url" placeholder="Base URL(如 https://api.siliconflow.cn/v1)" style="min-width:280px;">
|
||||
<input id="lp_key" type="password" placeholder="API Key">
|
||||
<input id="lp_model" placeholder="模型名(如 deepseek-ai/DeepSeek-V4-Flash)">
|
||||
<label style="display:flex;align-items:center;gap:4px;white-space:nowrap;">
|
||||
<input type="checkbox" id="lp_active" checked> 设为当前
|
||||
</label>
|
||||
<button class="btn" onclick="addProvider()">+ 添加接口</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>名称</th><th>Base URL</th><th>模型</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in providers %}
|
||||
<tr data-pid="{{ p.id }}" data-name="{{ p.name }}" data-url="{{ p.base_url }}" data-model="{{ p.model }}" data-key="{{ p.api_key }}">
|
||||
<td>
|
||||
<b>{{ p.name }}</b>
|
||||
{% if p.active %}<span class="tag ok">当前</span>{% endif %}
|
||||
<div class="muted small" style="word-break:break-all;">{{ p.api_key[:6] }}…{{ p.api_key[-4:] if p.api_key|length>10 }}</div>
|
||||
</td>
|
||||
<td class="small" style="word-break:break-all;">{{ p.base_url }}</td>
|
||||
<td class="small">{{ p.model }}</td>
|
||||
<td>{% if p.enabled %}<span class="tag ok">启用</span>{% else %}<span class="tag">停用</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if not p.active %}
|
||||
<button class="btn mini" onclick="switchProvider({{ p.id }})">⚡ 切换为当前</button>
|
||||
{% endif %}
|
||||
<button class="btn mini" onclick="editProvider({{ p.id }})">编辑</button>
|
||||
<button class="btn mini" onclick="testProvider({{ p.id }})">测试</button>
|
||||
<button class="btn mini" onclick="toggleProvider({{ p.id }})">{{ '停用' if p.enabled else '启用' }}</button>
|
||||
<button class="btn mini danger" onclick="delProvider({{ p.id }})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="lp_test_result" class="muted small" style="margin-top:10px;"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function setNum(key, cb){ /* checkbox 即时记录 */
|
||||
}
|
||||
const G = id => document.getElementById(id).value;
|
||||
|
||||
async function saveAll(){
|
||||
const g = id => document.getElementById(id).value;
|
||||
const auto = {
|
||||
auto_collect: 1, realtime_enabled: 1, summary_enabled: 1,
|
||||
scan_interval_min: parseInt(g('s_scan_interval_min'))||30,
|
||||
realtime_threshold: parseInt(g('s_realtime_threshold'))||80,
|
||||
llm_threshold: parseInt(g('s_llm_threshold'))||60,
|
||||
summary_time: g('s_summary_time')||'10:00',
|
||||
summary_window_hours: parseInt(g('s_summary_window_hours'))||24,
|
||||
max_summary_items: parseInt(g('s_max_summary_items'))||15,
|
||||
auto_collect: document.getElementById('s_auto_collect').checked ? 1 : 0,
|
||||
realtime_enabled: document.getElementById('s_realtime_enabled').checked ? 1 : 0,
|
||||
summary_enabled: document.getElementById('s_summary_enabled').checked ? 1 : 0,
|
||||
scan_interval_min: parseInt(G('s_scan_interval_min'))||30,
|
||||
realtime_threshold: parseInt(G('s_realtime_threshold'))||80,
|
||||
llm_threshold: parseInt(G('s_llm_threshold'))||60,
|
||||
summary_time: G('s_summary_time')||'10:00',
|
||||
summary_window_hours: parseInt(G('s_summary_window_hours'))||24,
|
||||
max_summary_items: parseInt(G('s_max_summary_items'))||15,
|
||||
};
|
||||
auto.auto_collect = document.getElementById('s_auto_collect').checked ? 1 : 0;
|
||||
auto.realtime_enabled = document.getElementById('s_realtime_enabled').checked ? 1 : 0;
|
||||
auto.summary_enabled = document.getElementById('s_summary_enabled').checked ? 1 : 0;
|
||||
const mail = {
|
||||
smtp_host: g('m_host'), smtp_port: parseInt(g('m_port'))||587,
|
||||
smtp_user: g('m_user'), smtp_pass: g('m_pass'), smtp_mode: g('m_mode'),
|
||||
email_to: g('m_to'), sender_name: g('m_name'),
|
||||
smtp_host: G('m_host'), smtp_port: parseInt(G('m_port'))||587,
|
||||
smtp_user: G('m_user'), smtp_pass: G('m_pass'), smtp_mode: G('m_mode'),
|
||||
email_to: G('m_to'), sender_name: G('m_name'),
|
||||
};
|
||||
const r = await API.json('/api/settings', { ...auto, mail });
|
||||
toast(r.ok ? '✅ 设置已保存' : '❌ 保存失败', r.ok);
|
||||
}
|
||||
|
||||
async function addProvider(){
|
||||
const r = await API.json('/api/llm', {
|
||||
action:'add',
|
||||
name: G('lp_name')||'未命名接口',
|
||||
base_url: G('lp_url'), api_key: G('lp_key'), model: G('lp_model'),
|
||||
active: document.getElementById('lp_active').checked ? 1 : 0,
|
||||
});
|
||||
if (r.ok) { toast('✅ 接口已添加'); setTimeout(()=>location.reload(), 500); }
|
||||
else toast('❌ 添加失败', false);
|
||||
}
|
||||
async function switchProvider(id){
|
||||
const r = await API.json('/api/llm', {action:'switch', id});
|
||||
toast(r.ok ? `⚡ 已切换:${r.name}` : '❌ 切换失败', r.ok);
|
||||
if (r.ok) setTimeout(()=>location.reload(), 400);
|
||||
}
|
||||
async function editProvider(id){
|
||||
const row = document.querySelector(`tr[data-pid="${id}"]`);
|
||||
if (!row) return;
|
||||
const name = prompt('接口名称', row.dataset.name);
|
||||
if (name === null) return;
|
||||
const url = prompt('Base URL', row.dataset.url);
|
||||
if (url === null) return;
|
||||
const model = prompt('模型名', row.dataset.model);
|
||||
if (model === null) return;
|
||||
const key = prompt('API Key(留空则不变)', '') || row.dataset.key;
|
||||
const r = await API.json('/api/llm', {action:'update', id, name, base_url:url, api_key:key, model});
|
||||
toast(r.ok ? '✅ 已更新' : '❌ 更新失败', r.ok);
|
||||
if (r.ok) setTimeout(()=>location.reload(), 400);
|
||||
}
|
||||
async function testProvider(id){
|
||||
const el = document.getElementById('lp_test_result');
|
||||
el.textContent = '⏳ 测试中…';
|
||||
const r = await API.json('/api/llm', {action:'test', id});
|
||||
if (r.ok) el.textContent = `✅ ${r.name}(${r.model})连接正常,回复:${r.reply}`;
|
||||
else el.textContent = `❌ 测试失败:${r.error}`;
|
||||
}
|
||||
async function toggleProvider(id){
|
||||
await API.json('/api/llm', {action:'toggle', id}); location.reload();
|
||||
}
|
||||
async function delProvider(id){
|
||||
if (!confirm('确认删除该大模型接口?')) return;
|
||||
await API.json('/api/llm', {action:'delete', id});
|
||||
toast('✅ 已删除'); setTimeout(()=>location.reload(), 400);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+21
-3
@@ -12,7 +12,7 @@
|
||||
<div class="form-row">
|
||||
<input id="f_name" placeholder="名称(如:36氪 AI频道)">
|
||||
<input id="f_type" placeholder="类型(科技媒体/公司动态/学术/政策...)">
|
||||
<input id="f_url" placeholder="URL / RSS 地址">
|
||||
<input id="f_url" placeholder="URL / RSS 地址(example.com 开头走模拟数据)">
|
||||
<input id="f_weight" type="number" step="0.1" value="0.8" placeholder="权重">
|
||||
</div>
|
||||
<textarea id="f_desc" placeholder="描述(该源提供哪些内容)" rows="2"></textarea>
|
||||
@@ -24,15 +24,16 @@
|
||||
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>权重</th><th>状态</th><th>最近采集</th><th>新增条数</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in sources %}
|
||||
<tr>
|
||||
<tr data-sid="{{ s.id }}" data-name="{{ s.name }}" data-type="{{ s.type }}" data-url="{{ s.url }}" data-desc="{{ s.description }}" data-weight="{{ s.weight }}">
|
||||
<td>{{ s.id }}</td>
|
||||
<td><b>{{ s.name }}</b><br><span class="muted small">{{ s.description }}</span></td>
|
||||
<td><b>{{ s.name }}</b><br><span class="muted small">{{ s.description }}</span><br><span class="muted small" style="word-break:break-all;">{{ s.url }}</span></td>
|
||||
<td>{{ s.type }}</td>
|
||||
<td>{{ s.weight }}</td>
|
||||
<td>{% if s.enabled %}<span class="tag ok">启用</span>{% else %}<span class="tag">停用</span>{% endif %}</td>
|
||||
<td>{{ s.last_fetch or '—' }}</td>
|
||||
<td>{{ s.last_count }}</td>
|
||||
<td>
|
||||
<button class="btn mini" onclick="editSource({{ s.id }})">✏️ 编辑</button>
|
||||
<button class="btn mini" onclick="toggleSource({{ s.id }})">{{ '停用' if s.enabled else '启用' }}</button>
|
||||
<button class="btn mini danger" onclick="delSource({{ s.id }})">删除</button>
|
||||
</td>
|
||||
@@ -40,6 +41,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="muted small" style="margin-top:8px;">💡 真实 URL 源会自动抓取页面并清洗可读正文入库;example.com 等占位地址走模拟数据。</div>
|
||||
</div>
|
||||
{% 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;
|
||||
|
||||
Reference in New Issue
Block a user