2 Commits
Author SHA1 Message Date
hz4th_coder e4efd24e1c v1.1.0: 真实网页采集(可读正文全文入库) + 大模型接口多预置一键切换(SiliconFlow默认) + 数据源可编辑
- 新增 crawler.py: requests+bs4 readability风格清洗, 提取候选链接按文章相似度排序, 前5条抓全文存 articles.full_text, 详情页展示; example.com占位源走模拟, 真实源失败标记error不造假
- 大模型: 新增 llm_providers 表(预置 SiliconFlow/DeepSeek官方/Autodl/Local Qwen), 设置页增删改/测试/一键切换, 激活接口失败自动切换备用
- 数据源: 前端补编辑按钮+弹窗(后端 update 已支持), 列表显示URL与采集状态
- 数据库迁移: articles.full_text 列 + llm_providers 表
2026-08-28 16:06:07 +08:00
hz4th_coder 2b8e78a4cc docs: 补充 README 说明文档(系统运转流程/配置/API/接入真实源指南) 2026-08-28 15:31:55 +08:00
13 changed files with 914 additions and 57 deletions
+211
View File
@@ -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 + Flaskthreaded
- **网页采集**requests + BeautifulSoup/lxmlreadability 风格正文清洗)
- **数据库**SQLite(原生 sqlite3WAL 模式,无 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_countstatus 标记真实源采集 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/enabledactive=当前激活)
- **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
View File
@@ -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))))
+74 -1
View File
@@ -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))
+32 -7
View File
@@ -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
View File
@@ -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
+95 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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 = []
+3
View File
@@ -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
View File
@@ -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 %}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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;