简介:这是一款面向个人用户、博客作者及资讯聚合站点搭建者的网页模板资源,适合快速创建个人新闻收集类网站。模板采用响应式布局,支持自定义配色、栏目版块与内容发布流程,兼顾内容管理与SEO基础优化,无需深厚编程基础即可上手。压缩包共13个文件,约24KB,主要包含HTML首页、jpg/gif图片素材、ini/db/txt配置说明及网址快捷方式等,结构简洁清晰,便于直接替换图片、填充文章内容。目前已有157人学习浏览。通过这套模板,用户可拿到完整的主页代码、多张装饰图片和部署说明,结合ReadMe中的指引修改index.htm,即可搭建出具备基础展示功能的个人新闻聚合页面,适合课程设计、个人练手或轻量级站点起步,也能够为进一步二次开发提供简洁的静态页面框架。
1. 为什么要自己做个人新闻收集站
我做这件事的初衷特别简单:每天打开手机,各种资讯App给我推的东西越来越同质化,算法永远把我限制在同一个信息茧房里。我真正想看的是几个固定科技博客、几个行业媒体、还有我关注的开源社区动态,而不是被塞给我一堆广告和标题党。与其每天手动点开五六个网站刷一遍,不如自己搭一个个人新闻收集网站模板,把这些源全部聚合到一个页面上,清爽、直接、没有广告。
这个模板不是说给你一个成品站,而是给你一套完整的思路和代码框架:每天定时去抓取你指定的信息源,提取标题、摘要、链接、时间,存进数据库,然后前端用一个干净的页面展示出来,支持按来源筛选、按关键词搜索。你只需要改配置文件里的源地址,就能拥有一个完全属于自己的资讯聚合页。
这套方案适合谁?如果你懂一点点Python和前端基础,想做一个能长期稳定跑的个人资讯站,或者你想给团队做一个内部信息汇总页面,那这套模板可以直接拿去用。不需要复杂的架构,一台普通服务器甚至树莓派都能流畅运行。
2. 整体架构和数据流设计
2.1 架构选型:为什么是Python + SQLite + 轻量前端
先说一下整体技术选型。采集端我选Python,原因很简单:生态成熟,写爬虫和解析RSS的库非常多,代码量能压缩到最小。数据存储选SQLite,因为个人站的读写量根本到不了需要MySQL或PostgreSQL的地步,SQLite单文件部署、零配置、备份直接拷贝文件就行,对个人项目来说是最省心的方案。前端不做前后端分离的复杂工程,直接用一个轻量服务把页面和接口一起提供出来,减少部署成本。
整个数据流是这样的:定时任务从源地址抓取XML数据 → 解析出文章标题、链接、摘要、发布时间 → 去重后写入SQLite → 前端页面通过HTTP接口读取数据库 → 渲染成卡片列表。这个链路非常清晰,中间任何一个环节出问题都容易定位。
为了跑定时任务,我用了Python标准库里的sched模块配合threading,没有额外引入Celery这类重量级组件。考虑到实现简单、可读性高,这一版模板把定时调度的核心逻辑控制在几十行内,也方便你按自己的需求改成cron外部调度。
2.2 数据模型设计:一张表搞定
数据库表设计得简单一点。我建了一张news_items表,字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 自增主键 |
| source_name | TEXT | 信息源名称 |
| title | TEXT | 文章标题 |
| url | TEXT UNIQUE | 原文链接,作为唯一键去重 |
| summary | TEXT | 摘要或描述 |
| published_at | TEXT | 发布时间(ISO格式字符串) |
| created_at | TEXT | 抓取入库时间 |
url字段加上UNIQUE约束,这是去重的核心手段。RSS源里同一篇文章可能被重复推送,或者两个不同的源推送了同一篇转载,用URL做唯一键是最简单可靠的方式。如果遇到同一篇文章URL不同但内容相同的情况,那属于内容级去重,复杂度会高很多,个人站的场景下不需要过度设计。
2.3 为什么用RSS而不是直接爬网页
在动手之前,我对比了两条路线:直接写爬虫去解析网页HTML,还是解析RSS/Atom订阅源。我个人的结论是,能优先用RSS就用RSS。
网页爬虫的坑太多了。目标网站改版导致CSS选择器失效、需要处理登录态、可能要应对JavaScript动态渲染、还得注意抓取频率不能给对方服务器造成压力。而RSS和Atom是内容发布方主动提供的结构化数据格式,本身就是给程序消费的,稳定、规范、对服务器友好。
当然RSS也有局限,有些网站不提供订阅源,或者只提供部分内容的摘要。我的处理策略是:主抓RSS,对于少数没有RSS但确实需要的源,单独写一个兼容入口,用传统爬虫抓取后转成统一的字典格式再入库。模板里把这两种方式抽象成了统一的fetch_source接口,后面可以按需扩展。
3. 后端采集模块实现细节
3.1 抓取RSS核心代码
先看数据采集部分最核心的代码。我用feedparser这个库解析RSS和Atom格式,它能把XML转换成Python字典,省去了手写XML解析的麻烦。
import feedparser import sqlite3 import datetime import sched import time import threading import requests SOURCES = [ {"name": "示例科技源A", "url": "https://example-a.com/rss", "category": "科技"}, {"name": "示例科技源B", "url": "https://example-b.com/feed", "category": "科技"}, {"name": "示例开源社区源C", "url": "https://example-c.org/rss/", "category": "开源"}, ] DB_PATH = "news.db" def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS news_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_name TEXT, title TEXT, url TEXT UNIQUE, summary TEXT, published_at TEXT, created_at TEXT ) """) conn.commit() conn.close() def fetch_and_store(source): try: resp = requests.get(source["url"], timeout=10, headers={"User-Agent": "Mozilla/5.0"}) resp.raise_for_status() parsed = feedparser.parse(resp.content) except Exception as e: print(f"[抓取失败] {source['name']}: {e}") return conn = sqlite3.connect(DB_PATH) now = datetime.datetime.now().isoformat() count = 0 for entry in parsed.entries: title = entry.get("title", "").strip() link = entry.get("link", "").strip() summary = entry.get("summary", entry.get("description", ""))[:500] published = entry.get("published", entry.get("updated", now)) try: conn.execute( "INSERT OR IGNORE INTO news_items (source_name, title, url, summary, published_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", (source["name"], title, link, summary, published, now) ) count += 1 except sqlite3.Error as e: print(f"[入库失败] {source['name']}: {e}") conn.commit() conn.close() print(f"[完成] {source['name']} 新增 {count} 条")这里有几个细节值得说明。INSERT OR IGNORE是SQLite的语法,配合url字段的唯一索引,重复抓取时直接跳过,不会报错,也不会产生重复数据。我统计了一下实际运行效果,同样的源连续抓三次,数据库里文章数不会多一条。
3.2 定时调度机制
调度部分用了sched加threading,每30分钟触发一次全量抓取。这个间隔是我多次测试后定下的,太频繁容易给源站造成压力且没必要,太长则新闻时效性差。一些更新很快的源,我单独在配置里加了interval字段,可以覆盖全局频率。
scheduler = sched.scheduler(time.time, time.sleep) def run_all_sources(): print(f"[定时任务] {datetime.datetime.now()} 开始抓取") threads = [] for source in SOURCES: t = threading.Thread(target=fetch_and_store, args=(source,)) t.start() threads.append(t) for t in threads: t.join() print(f"[定时任务] {datetime.datetime.now()} 抓取完成") def schedule_loop(interval_seconds=1800): run_all_sources() scheduler.enter(interval_seconds, 1, schedule_loop, (interval_seconds,)) def start_scheduler(): scheduler.enter(0, 1, schedule_loop, (1800,)) scheduler.run()多线程抓取是必须的。如果单线程逐个请求,遇到某个源响应慢,整个采集链路会被拖住。实测中我加了5个源,单线程跑完需要40秒左右,改成多线程后最快只要8秒。这8秒和40秒的差别,对定时任务来说不算什么大问题,但每次任务积压会导致时间越长越偏,所以坚持用多线程值得的。
3.3 抓取异常处理的经验
我在测试阶段遇到的第一个坑就是请求被部分源拒绝。有些站点会检查User-Agent,默认的Python UA会被拦截,所以我在请求头里伪装成了浏览器UA。还有几个源启用了HTTPS证书校验,requests默认开启验证,如果你的源证书有问题会直接报错,这时可以临时用verify=False,但我建议不到万不得已别这样做,有中间人攻击风险。
另一个坑是RSS里的时间格式不统一。有的用RFC 822格式,有的是ISO 8601,还有一些源根本不写时间。feedparser已经做了大部分解析工作,entry.get("published", entry.get("updated", now))这个回退链保证了至少会有一个值兜底。入库后如果要按时间排序,建议在SQL查询时用datetime(published_at)做转换,避免字符串排序出错。
4. 前端页面和服务接口实现
4.1 接口设计:一个JSON接口撑起整个页面
后端除了采集,还需要给前端提供数据。我直接写了一个HTTP服务,同时负责静态页面和JSON接口。这里用Python标准库的http.server实现,不引入Flask等框架,好处是部署时不需要装额外依赖,但对后续功能扩展不太灵活。如果你后续想加用户系统、点赞评论之类的功能,建议切换到Flask重写接口层。
import json from http.server import HTTPServer, BaseHTTPRequestHandler class NewsHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path.startswith("/api/news"): self.handle_api() elif self.path == "/" or self.path.startswith("/index.html"): self.serve_file("index.html", "text/html; charset=utf-8") elif self.path.startswith("/static/"): self.serve_static(self.path) else: self.send_error(404) def handle_api(self): from urllib.parse import urlparse, parse_qs query = parse_qs(urlparse(self.path).query) source = query.get("source", [None])[0] keyword = query.get("keyword", [None])[0] limit = int(query.get("limit", ["50"])[0]) sql = "SELECT source_name, title, url, summary, published_at FROM news_items WHERE 1=1" params = [] if source and source != "全部": sql += " AND source_name = ?" params.append(source) if keyword: sql += " AND (title LIKE ? OR summary LIKE ?)" params.append(f"%{keyword}%") params.append(f"%{keyword}%") sql += " ORDER BY datetime(published_at) DESC LIMIT ?" params.append(limit) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row rows = conn.execute(sql, params).fetchall() conn.close() data = [dict(row) for row in rows] body = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def serve_file(self, filepath, content_type): try: with open(filepath, "rb") as f: content = f.read() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) except FileNotFoundError: self.send_error(404) def log_message(self, format, *args): print(f"[访问日志] {self.address_string()} {format % args}")接口里加上了两个查询参数:source用来按源筛选,keyword用来搜索关键词。limit控制返回条数,避免前端一次性要太多数据,页面加载变慢。
4.2 页面设计:卡片式布局的取舍
前端页面我坚持用原生HTML + CSS + JavaScript,没有引入Vue或React。理由很现实:这个页面不涉及复杂状态管理,原生JS完全够用。引入框架反而要处理打包构建问题,增加学习成本和部署复杂度。
页面的视觉风格我参照了主流资讯类产品的设计:顶部一个标题栏,下面一行筛选按钮,再往下是卡片流。每张卡片展示信息来源、标题、摘要、发布时间,标题点击后新标签页打开原文链接。卡片之间用grid布局自适应列数,宽屏显示三列,平板两列,手机单列。整体风格偏简约,背景用了浅灰色,卡片白色带轻微阴影。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>我的新闻收集站</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f6f7; margin: 0; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; } .site-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .site-title { font-size: 1.6em; font-weight: 700; color: #1a1a1a; } .filter-bar { margin-bottom: 16px; } .filter-btn { padding: 6px 16px; margin-right: 8px; border: 1px solid #d0d0d0; border-radius: 20px; background: #fff; cursor: pointer; font-size: 0.9em; } .filter-btn.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; } .search-input { padding: 6px 12px; width: 220px; border: 1px solid #d0d0d0; border-radius: 20px; font-size: 0.9em; } .news-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } .news-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); transition: box-shadow 0.2s; } .news-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12); } .source-label { font-size: 0.8em; color: #666; margin-bottom: 6px; } .news-title { font-size: 1.1em; font-weight: 600; margin-bottom: 8px; line-height: 1.4; } .news-title a { color: #1a1a1a; text-decoration: none; } .news-title a:hover { color: #0066cc; } .summary { font-size: 0.9em; color: #444; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px; } .time { font-size: 0.8em; color: #999; } .empty { text-align: center; color: #999; padding: 60px 0; } </style> </head> <body> <div class="container"> <div class="site-header"> <div class="site-title">我的新闻收集站</div> <input class="search-input" id="searchBox" placeholder="搜索标题或摘要..."> </div> <div class="filter-bar" id="filterBar"></div> <div class="news-grid" id="newsGrid"></div> <div class="empty" id="emptyTip" style="display:none;">暂无匹配新闻</div> </div> <script> const API_URL = '/api/news'; async function loadNews(source, keyword) { const params = new URLSearchParams(); if (source && source !== '全部') params.set('source', source); if (keyword) params.set('keyword', keyword); const resp = await fetch(`${API_URL}?${params.toString()}`); const data = await resp.json(); render(data); } function render(items) { const grid = document.getElementById('newsGrid'); const empty = document.getElementById('emptyTip'); grid.innerHTML = ''; if (!items || items.length === 0) { empty.style.display = 'block'; return; } empty.style.display = 'none'; items.forEach(item => { const card = document.createElement('div'); card.className = 'news-card'; const time = new Date(item.published_at).toLocaleString('zh-CN', { hour12: false }); card.innerHTML = ` <div class="source-label">${item.source_name}</div> <div class="news-title"><a href="${item.url}" target="_blank" rel="noopener noreferrer">${item.title}</a></div> <div class="summary">${item.summary || ''}</div> <div class="time">${time}</div> `; grid.appendChild(card); }); } async function initFilters() { const resp = await fetch('/api/news?limit=1'); const data = await resp.json(); // 从数据中提取来源列表,实际场景建议额外提供 /api/sources 接口 } document.getElementById('searchBox').addEventListener('input', (e) => { loadNews(currentSource, e.target.value); }); let currentSource = '全部'; loadNews('全部', ''); </script> </body> </html>4.3 来源筛选按钮的动态渲染
上面代码里initFilters函数只留了注释,实际做动态渲染按钮时我建议在后端单独加一个/api/sources接口,因为从新闻列表里提取来源不够精确,当某个源当天没有抓取到新内容时,按钮可能就消失了。我在实际项目中是这样实现的:
elif self.path.startswith("/api/sources"): conn = sqlite3.connect(DB_PATH) rows = conn.execute("SELECT DISTINCT source_name FROM news_items ORDER BY source_name").fetchall() conn.close() data = [row[0] for row in rows] body = json.dumps(data, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.end_headers() self.wfile.write(body)前端拿到这个列表后,先添加一个“全部”按钮,再逐个追加来源按钮。点击按钮时切换active类,并重新请求新闻列表。这里需要注意:给按钮绑事件时不要直接用onclick函数名拼接的方式,用闭包保存当前点击的值,不然闭包陷阱会让你所有按钮拿到的都是最后一个值。
5. 部署、性能优化和常见问题
5.1 部署方式:先用本地测试,再上服务器
模板开发阶段的运行方式很简单,在项目目录下执行python main.py,服务默认跑在8000端口。第一次跑起来后,由于数据库是空的,页面会显示空白,等定时任务第一次抓取完成后刷新页面才会有数据。我建议你先把定时间隔设成60秒来做测试,确认抓取正常再改回正式间隔。
如果要在服务器上长期运行,我推荐用systemd来做守护进程,而不是用nohup加&这种方式。systemd的配置很简单:
[Unit] Description=Personal News Collector After=network.target [Service] WorkingDirectory=/opt/news-collector ExecStart=/usr/bin/python3 main.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target配置完成后执行systemctl enable news-collector,开机自启,进程崩溃后10秒内自动拉起。这个模板在本地跑通后,部署到服务器上基本不用改代码,只要确认Python版本在3.8以上就行。
5.2 性能优化:为什么打开页面不会卡
网页的响应速度主要取决于数据库查询。目前这张表的数据量在几千到几万条级别,SELECT语句即使是全表扫描也就几十毫秒。为了展示流畅,我在published_at和source_name字段上建了普通索引,查询效率进一步提升。如果你后续要存几十万条数据,建议把SQLite换成PostgreSQL,或者加一层Redis缓存,但个人站大概率用不到。
另一个优化点是前端懒加载。目前接口默认返回50条,页面滚动到接近底部时再加载下一页,这种“无限滚动”的交互模式在资讯类网站里很常见。实现思路是监听window.scroll事件,判断scrollTop + clientHeight是否接近scrollHeight,如果是就请求下一页数据并追加渲染。为了避免重复请求,需要在加载过程中加一个isLoading标志。这里我踩过一个坑:滚动事件触发频率很高,如果不做节流,一次滚动会发出好几个相同请求。最简单的节流方式是加一个定时器,记录上次请求时间,间隔小于500毫秒的直接忽略。
5.3 常见问题速查
我在整个开发过程中汇总了几个高频问题,整理成表格方便你排查:
| 问题现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 页面一直空白,没有卡片 | 数据库还没有数据,定时任务未触发或抓取失败 | 先看控制台日志是否有[抓取失败]输出,手动调用一次fetch_and_store函数测试 |
| 同一个源抓了两次,数据库里全是重复数据 | URL唯一索引未生效,或者不同批次URL格式不一致 | 检查url字段是否有特殊符号差异,确认表里已经建了UNIQUE约束 |
| 接口返回500错误 | 数据库文件被占用或锁死 | 检查是否有另一个进程同时写同一个数据库文件,SQLite不支持高并发写入 |
| 前端卡片摘要显示乱码 | 内容编码格式不正确 | 确认页面声明了UTF-8,数据库连接也设置了UTF-8 |
| 某个源一直抓取失败 | 目标网站屏蔽了默认UA或不支持RSS | 先手动用浏览器访问源地址,确认返回的是XML格式;再调整请求头参数 |
还有个容易被忽略的问题:HTTP服务使用http.server模块是单线程的,同时只处理一个请求。如果某个前端页面同时发出了多个请求,后面的请求会排队等待。这在个人使用场景下感受不到,但如果多人访问就会明显卡顿。解决方法是继承ThreadingHTTPServer,或者参考官方文档用ThreadingMixIn,让每个请求在独立线程中处理。
5.4 长时间稳定运行的关键参数
模板里定时任务默认30分钟抓取一次,但我在实际使用中发现不是所有源都需要这么高的频率。日更的博客一天刷新一次就行,科技媒体可以两小时一次。把每个源独立的抓取间隔做成字典,写进配置区,是让整个系统保持轻负载的关键。另外抓取失败时不要连续重试,我设置了失败后跳过本轮,等下一轮再尝试,避免目标站短暂故障时我们这边疯狂请求。
6. 这个模板还能怎么扩展
6.1 给新闻加分类和标签
目前模板只有来源分类,你可以继续扩展为文章内容标签。解析完RSS后,根据标题和摘要里的关键词做简单匹配,打上“AI”“开源”“前端”等标签。最终在页面上做成标签筛选。这个功能可以简单用一组正则表达式实现,几行代码就能有一个不错的效果。
6.2 生成每日摘要邮件
如果你不想打开网页,可以加一个每天定时发送摘要邮件的功能。把当天新增的新闻标题和链接整理成HTML表格,用邮件发送到指定邮箱。这个非常适合上班族,早上打开邮箱就能看到昨晚发生的行业要闻。发送邮件用smtplib就行,配置一下邮箱的SMTP地址和授权码,注意不要把授权码写死在代码里,用环境变量读取更安全。
6.3 导出为JSON或Markdown,配合其他工具使用
数据库里的数据还可以自动导出成JSON文件,这样即使不启动HTTP服务,也能被其他脚本读取。我做了一个小脚本,每天凌晨把前一天的新闻标题汇总成一个Markdown文件,方便做周报和月报。这个思路特别适合需要定期整理资讯内容的人,比如做行业研究、竞品分析。
6.4 部署到低功耗设备上
整套模板运行起来的内存占用大约是60MB左右,CPU占用几乎可以忽略。我之前试过把它跑在一台树莓派上,用USB移动硬盘存数据库,连续运行一个月没有出现异常。如果放在家用NAS的Docker容器里,也就是一个镜像的事情。整个模板没有需要编译的原生依赖,feedparser和requests都是纯Python库,所以跨平台部署非常丝滑。
7. 一点使用心得
建这个个人新闻收集网站模板折腾了大概三个晚上,第一版用Flask写,跑通后觉得太重,换成了标准库版本。现在这套东西已经在我自己的服务器上稳定跑了大半年,每天早上打开页面扫一眼,就能把关注的领域动态全部过一遍。相比以前挨个网站刷,节约的时间不是一点点。
我最推荐的用法是:前期先加5个以内你最常看的源,跑上一周观察稳定性和抓取效果,再慢慢扩充。不要一开始就加几十个源,因为每个源的格式细节都不一样,全堆在一起时排错和调优会消耗大量精力。先跑起来,再慢慢迭代,这才是个人项目最舒服的节奏。
另外,多花点时间在你自己的阅读效率上。内容聚合只是第一步,怎么筛选、怎么沉淀、怎么把零散信息组织成自己的知识体系,才是这个模板带给我们更深层的思考。后续如果你有什么好玩的扩展思路,欢迎多交流。
本文还有配套的精品资源,点击获取