简介:这是一套轻量级个人新闻收集网站网页模板,面向零基础或入门级前端学习者、个人博客爱好者及内容聚合需求者,帮助用户无需编程即可快速搭建美观实用的资讯展示站点。资源共13个文件,含1个核心HTML主页(index.htm)、5个图像资源(4张JPG+1张GIF用于装饰元素)、3个GIF动效图标、2个INI系统配置文件、1个URL快捷方式及1个TXT说明文档,整体仅24KB,便于下载部署与本地调试。已有157人学习下载,适合希望快速上手响应式布局、理解静态网站结构、实践内容替换与主题微调的学习者。用户可直接编辑index.htm更新新闻条目,通过images文件夹更换配图,依据ReadMe.txt完成基础配置;模板已预设SEO元信息、适配多端的CSS样式及简洁图文混排结构,开箱即用,是入门网页定制与信息聚合实践的理想范例。1. 个人新闻收集网站模板:不是博客,也不是 RSS 聚合器,而是你自己的「信息过滤中枢」“个人新闻收集网站模板”这个标题常被误读成「做个带新闻栏的个人主页」或「用 WordPress 搭个资讯站」——但真正落地时你会发现:它既不依赖第三方 API 频繁失效,也不靠人工点开几十个链接再复制粘贴;它的核心价值,是把散落在不同信源(政府公报、行业白皮书、开源项目更新页、技术社区公告、学术预印本平台)里的结构化增量信息,以你定义的关键词、时间窗口、可信度权重和去重粒度,自动抓取、清洗、归类、存档,并生成可订阅、可检索、可回溯的静态站点。它不是替代新闻 App,而是解决「我关心的 A 领域政策变动」「B 技术栈的 CVE 更新」「C 行业协会季度报告」这类长尾、低频、高精度需求的最小可行闭环。适合某高校科研组跟踪政策动向、某公司技术委员会做竞品动态归档、或独立开发者构建自己的知识基座。它不追求实时性,但要求可验证、可审计、可离线导出——这才是“模板”的真实分量。2. 为什么选静态生成 + 定制爬虫 + 本地索引:避开云服务陷阱与 API 黑箱2.1 不用 CMS 或 SaaS 平台的三个硬理由很多新手第一反应是“用 Ghost / Hexo / Notion 搭一个”,但很快会撞墙:内容主权失控:Notion 的 RSS 导出只含摘要,且无法控制字段映射(比如把「发布机构」强制映射为 YAML front matter 中的source_org);更新不可审计:WordPress 插件拉取的 RSS 源一旦变更格式(如<pubDate>改成<date>),全站历史条目时间戳集体错乱,却无日志可查;长期存档失效:SaaS 平台若关闭服务(如某知名聚合工具 2023 年下线),你积累两年的元数据、分类标签、阅读状态全部清零。提示:所谓“模板”,本质是把「信息采集逻辑」和「呈现逻辑」彻底解耦——采集脚本输出标准 JSONL(每行一个新闻条目),渲染模板只消费该 JSONL。二者可独立升级、测试、备份。2.2 静态站点生成器(SSG)的不可替代性我们最终选定Hugo(而非 Jekyll 或 Next.js)作为渲染层,原因直击痛点:零运行时依赖:生成的纯 HTML/CSS/JS 可直接扔进任何对象存储(如阿里云 OSS、腾讯云 COS)或 Git 仓库 Pages,无需 Node.js 环境或数据库;毫秒级全文搜索:Hugo 内置lunr.js支持客户端搜索,且可导出search-index.json供自定义前端调用,避免 Algolia 等第三方搜索服务的隐私与成本问题;原生支持多语言内容路由:当需要按「中文政策原文」和「英文技术公告」分频道展示时,Hugo 的multilingual模块只需配置languageCode = "zh"和contentDir = "content/zh",无需改代码。2.3 爬虫层必须自己写:为什么 Requests + BeautifulSoup 是当前最稳组合有人提议用 Scrapy,但对个人场景是过度设计:Scrapy 需维护settings.py、pipelines.py、middlewares.py三套配置,而实际需求只是「从 5 个固定 URL 解析<h1>标题、<time>时间、<p>首段」;当目标网站反爬升级(如增加 JS 渲染),Scrapy 的Splash集成复杂度陡增,而 Requests +fake-useragent+time.sleep()组合,配合retrying库重试,反而更易调试。我们采用「单文件爬虫脚本」(fetch_news.py),核心逻辑仅 87 行,关键设计如下:# fetch_news.py import requests from bs4 import BeautifulSoup from datetime import datetime, timedelta import json import time from fake_useragent import UserAgent def fetch_from_source(url: str, selector_map: dict) -> list: """从单个源抓取,返回标准化字典列表""" headers = {"User-Agent": UserAgent().random} try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") items = [] for item in soup.select(selector_map["item"]): # 强制提取字段,缺失则设为 None(后续清洗阶段统一处理) title = item.select_one(selector_map["title"]).get_text(strip=True) if item.select_one(selector_map["title"]) else None pub_time = item.select_one(selector_map["time"]).get_text(strip=True) if item.select_one(selector_map["time"]) else None link = item.select_one(selector_map["link"])["href"] if item.select_one(selector_map["link"]) and item.select_one(selector_map["link"]).has_attr("href") else None items.append({ "title": title, "pub_time": pub_time, "link": link, "source_url": url, "fetched_at": datetime.now().isoformat(), "id": f"{url.split('//')[1].split('/')[0]}_{hash(link or title)[:8]}" # 基于域名+链接哈希生成稳定 ID }) return items except Exception as e: print(f"[ERROR] Failed to fetch {url}: {e}") return [] # 主流程:遍历配置好的信源列表 sources = [ { "url": "https://www.xxx.gov.cn/xxgk/tzgg/", "selector": { "item": ".list-item", "title": "h4 a", "time": ".date", "link": "h4 a" } }, { "url": "https://github.com/tensorflow/tensorflow/releases", "selector": { "item": ".Box-row", "title": "h2 a", "time": "relative-time", "link": "h2 a" } } ] all_news = [] for src in sources: print(f"Fetching from {src['url']}...") all_news.extend(fetch_from_source(src["url"], src["selector"])) time.sleep(2) # 礼貌性间隔,避免触发风控 # 去重:按 id 字段保留最新一条(因同一新闻可能在多个源出现) seen_ids = set() deduped = [] for item in sorted(all_news, key=lambda x: x.get("fetched_at", ""), reverse=True): if item["id"] not in seen_ids: seen_ids.add(item["id"]) deduped.append(item) # 输出为 JSONL(每行一个 JSON 对象),供 Hugo 消费 with open("data/news.jsonl", "w", encoding="utf-8") as f: for item in deduped: f.write(json.dumps(item, ensure_ascii=False) + "\n")这段代码的关键设计说明:id字段用f"{domain}_{hash(link)[:8]}"生成,确保同一新闻在不同源重复抓取时能被精准去重,且不依赖外部数据库;fetched_at记录抓取时间,而非发布时间,用于判断「本次抓取是否覆盖了上次遗漏的条目」;selector_map将 CSS 选择器与字段绑定,新增信源只需在sources列表里加一项,无需改函数逻辑;所有异常捕获后继续执行,单个源失败不影响其他源,符合「个人项目容错优先」原则。3. Hugo 模板如何把 JSONL 渲染成可搜索、可归档、可 RSS 订阅的静态站3.1 数据层:用 Hugo 的data目录桥接爬虫与渲染Hugo 默认不直接读取 JSONL,但我们通过一个轻量转换脚本jsonl_to_hugo_data.py,将data/news.jsonl转为data/news.json(标准 JSON 数组):# jsonl_to_hugo_data.py import json with open("data/news.jsonl", "r", encoding="utf-8") as f: lines = [json.loads(line.strip()) for line in f if line.strip()] # 按日期分组,生成 Hugo data 结构 grouped = {} for item in lines: # 提取年月,如 "2024-03-15" → "2024/03" date_str = item.get("pub_time", "") if not date_str: continue try: dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) if "T" in date_str else datetime.strptime(date_str, "%Y-%m-%d") year_month = f"{dt.year}/{dt.month:02d}" except: year_month = "unknown" if year_month not in grouped: grouped[year_month] = [] grouped[year_month].append(item) # 写入 data/news.json,Hugo 可直接 .Site.Data.news 访问 with open("data/news.json", "w", encoding="utf-8") as f: json.dump(grouped, f, ensure_ascii=False, indent=2)注意:此脚本在每次fetch_news.py运行后自动执行(可用 Makefile 或 GitHub Actions 触发),确保 Hugo 构建时data/news.json总是最新的。3.2 页面结构:按「时间轴 + 分类 + 全文搜索」三维组织我们在layouts/_default/list.html中定义主页面逻辑:<!-- layouts/_default/list.html --> {{ define "main" }} <div class="container"> <h1>我的新闻中枢</h1> <!-- 搜索框(调用 lunr.js) --> <div class="search-box"> <input type="text" id="search-input" placeholder="搜索标题、来源、关键词..." /> <div id="search-results"></div> </div> <!-- 时间轴归档(按年月折叠) --> <div class="archive"> {{ range $year_month, $items := .Site.Data.news }} <details> <summary>{{ $year_month }} ({{ len $items }} 条)</summary> <ul> {{ range $items }} <li> <a href="{{ .link }}" target="_blank">{{ .title | truncate 60 }}</a> <small class="source">[{{ .source_url | replaceRE "https?://([^/]+).*" "$1" }}]</small> <small class="time">{{ .pub_time | time.Format ":date_long" }}</small> </li> {{ end }} </ul> </details> {{ end }} </div> </div> <!-- lunr.js 搜索初始化 --> <script> // 从 /index.json 加载全文索引(Hugo 自动生成) fetch('/index.json').then(r => r.json()).then(indexData => { const idx = lunr(function () { this.ref('id') this.field('title', { boost: 10 }) this.field('source_url') this.field('pub_time') indexData.forEach(function (doc) { this.add(doc) }, this) }) const input = document.getElementById('search-input') const results = document.getElementById('search-results') input.addEventListener('input', function () { const query = this.value.trim() if (!query) return const res = idx.search(query) results.innerHTML = res.map(r => { const doc = indexData.find(d => d.id === r.ref) return `<div class="search-result"><a href="${doc.link}" target="_blank">${doc.title}</a><br><small>${doc.source_url}</small></div>` }).join('') }) }) </script> {{ end }}关键参数说明:index.json是 Hugo 在构建时自动生成的全文索引文件(需在config.toml中启用buildSearchIndex = true);lunr.js的boost: 10给标题字段更高权重,确保搜「Transformer」时,标题含该词的条目排在前面;details/summary实现可折叠时间轴,避免页面过长,移动端友好。3.3 RSS 订阅:用 Hugo 的内置 RSS 模板生成合规 Feed在layouts/_default/rss.xml中:{{ printf "<?xml version=\"1.0\" encoding=\"utf-8\"?>" | safeHTML }} <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>{{ .Site.Title }}</title> <link>{{ .Site.BaseURL }}</link> <description>{{ .Site.Params.description }}</description> <generator>Hugo {{ .Hugo.Version }}</generator> <lastBuildDate>{{ now.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate> <atom:link href="{{ .Site.BaseURL }}index.xml" rel="self" type="application/rss+xml" /> {{ range first 20 .Site.Data.news }} {{ range . }} <item> <title>{{ .title | htmlUnescape }}</title> <link>{{ .link }}</link> <guid>{{ .link }}</guid> <pubDate>{{ .pub_time | time.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate> <description>{{ .title | plainify | truncate 200 }}</description> </item> {{ end }} {{ end }} </channel> </rss>提示:RSS 的guid必须全局唯一且不变,我们直接复用爬虫生成的id字段(但此处用.link更稳妥,因部分源无稳定 ID);pubDate严格按 RFC 822 格式,避免某些 RSS 阅读器解析失败。4. 避坑:爬虫失效、时间解析错乱、搜索结果为空的 5 个血泪现场4.1 现象:爬虫跑着跑着突然全源返回空列表,日志只显示[ERROR] Failed to fetch xxx: ReadTimeout原因:目标网站启用了 Cloudflare 的「Under Attack Mode」,对非浏览器请求返回 503,而fake-useragent生成的 UA 已被大量爬虫滥用,被 Cloudflare 列入黑名单。解决:不用fake-useragent,改用一组手动维护的、经测试有效的 UA 字符串池,并加入Accept-Language和Accept-Encoding头:VALID_UAS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15" ] headers = { "User-Agent": random.choice(VALID_UAS), "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate" }4.2 现象:某政府网站的时间字段解析出来是「2024年3月15日」,但 Hugo 的time.Format报错parsing time "2024年3月15日"原因:Hugo 的time.Format只支持 Go 的标准时间格式(如2006-01-02),不识别中文日期。爬虫层未做标准化。解决:在fetch_news.py中增加日期归一化函数,统一转为 ISO 格式:def parse_chinese_date(date_str: str) -> str: """将 '2024年3月15日' 转为 '2024-03-15'""" import re match = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", date_str) if match: y, m, d = match.groups() return f"{y}-{int(m):02d}-{int(d):02d}" return date_str # 原样返回,交由后续逻辑处理 # 在 fetch_from_source 中调用: pub_time = parse_chinese_date(pub_time_raw)4.3 现象:搜索框输入关键词,search-results区域始终空白,控制台无报错原因:index.json文件未生成,或路径错误。Hugo 默认只对content/下的 Markdown 文件生成索引,而我们的数据来自data/目录。解决:在config.toml中显式配置buildSearchIndex = true,并确保index.json路径正确:[outputs] home = ["HTML", "RSS", "JSON"] # 关键!必须包含 "JSON" [outputFormats.JSON] mediaType = "application/json" baseName = "index" isPlainText = true notAlternative = true这样 Hugo 会在根目录生成/index.json,与模板中fetch('/index.json')路径一致。4.4 现象:GitHub Releases 页面抓取到的<time>标签内容是相对时间(如about 2 hours ago),无法解析为绝对时间原因:GitHub 使用relative-time自定义元素,其datetime属性才是 ISO 时间,但BeautifulSoup默认不读取属性值。解决:修改选择器,优先读取datetime属性:# 原 selector_map["time"] = "relative-time" # 改为: time_elem = item.select_one("relative-time") or item.select_one("time") pub_time = time_elem.get("datetime") or time_elem.get_text(strip=True) if time_elem else None4.5 现象:Hugo 构建成功,但访问页面时时间显示为0001-01-01原因:.pub_time字段在 JSON 中为null,Hugo 的time.Format遇到空值会回退到零值时间。解决:在模板中增加空值保护:{{ if .pub_time }} <small class="time">{{ .pub_time | time.Format ":date_long" }}</small> {{ else }} <small class="time">时间未知</small> {{ end }}5. 进阶技巧:用 Git 版本控制实现新闻溯源、变更比对与一键回滚5.1 为什么必须用 Git 管理data/news.jsonl?新闻数据不是「一次生成永久有效」,而是持续演进的:某条政策原文链接失效,下次抓取时link字段变为空,但你仍需保留旧版记录;某技术文档更新后,标题微调(如「v2.3.0 发布」→「v2.3.0 正式版发布」),你需知道何时发生变更;同事想查看「上个月我漏掉了哪些条目」,需对比两个时间点的数据快照。Git 天然提供这些能力,且无需额外数据库。5.2 自动化工作流:每次抓取后提交带语义的 Commit我们用git_commit_after_fetch.sh脚本封装提交逻辑:#!/bin/bash # git_commit_after_fetch.sh set -e # 生成本次抓取的摘要 NEW_COUNT=$(wc -l < data/news.jsonl | awk '{print $1}') OLD_COUNT=$(git ls-tree -r HEAD -- data/news.jsonl | awk '{print $3}' | xargs git show | wc -l | awk '{print $1}') # 提交消息:包含新增/删除数,及抓取时间 git add data/news.jsonl git commit -m "news: update $(date '+%Y-%m-%d %H:%M') | +$((NEW_COUNT-OLD_COUNT)) entries"提示:git ls-tree -r HEAD -- data/news.jsonl获取上一版news.jsonl的 blob hash,再用git show输出内容行数,从而计算净增量。5.3 用 Git Diff 快速定位新闻变更当需要排查「为什么某条新闻消失了」,直接运行:# 查看最近 3 次提交中 news.jsonl 的变化 git log -3 --pretty=format:"%h %ad %s" --date=short -- data/news.jsonl # 对比当前版与上一版,只显示新增和删除的 ID(即新闻条目标识) git diff HEAD~1 HEAD -- data/news.jsonl | grep -E '^\+|^-'输出类似:-{"id":"gov_cn_abc123","title":"关于XXX的通知","pub_time":"2024-03-10",...} +{"id":"gov_cn_def456","title":"关于YYY的补充通知","pub_time":"2024-03-15",...}一眼可知:旧条目gov_cn_abc123被移除(可能源已下线),新条目gov_cn_def456被加入。5.4 一键回滚到任意历史状态若某次抓取引入了大量垃圾数据(如某个信源被黑,页面注入广告链接),可立即恢复:# 查看所有包含 "news:" 的提交 git log --grep="news:" --oneline # 回滚到指定 commit(假设为 abc123) git checkout abc123 -- data/news.jsonl git commit -m "revert: rollback to news state at abc123" # 重新生成 Hugo 站点 hugo --minify整个过程不到 30 秒,且所有中间状态均可追溯。5.5 进阶:用 GitHub Actions 实现全自动每日抓取与部署在.github/workflows/daily-fetch.yml中:name: Daily News Fetch & Deploy on: schedule: - cron: '0 3 * * *' # 每天 UTC 3 点(北京时间 11 点)执行 workflow_dispatch: jobs: fetch-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: token: ${{ secrets.PERSONAL_TOKEN }} # 用于提交 persist-credentials: false - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install dependencies run: | pip install requests beautifulsoup4 fake-useragent - name: Run fetch script run: python fetch_news.py - name: Convert JSONL to Hugo data run: python jsonl_to_hugo_data.py - name: Commit changes run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" chmod +x git_commit_after_fetch.sh ./git_commit_after_fetch.sh - name: Build and deploy uses: peaceiris/actions-hugo@v2 with: hugo-version: 'latest' # Hugo 构建后自动推送到 gh-pages 分支注意:PERSONAL_TOKEN需在仓库 Settings → Secrets 中配置,权限至少包含contents: write。这套机制让「个人新闻中枢」真正变成一个活的系统:你设定好信源和规则,剩下的交给 Git 和 CI,每天醒来就能看到结构清晰、可验证、可回溯的新鲜信息切片。我坚持用这个模板三年,最大的体会是:信息过载不是因为新闻太多,而是因为没有建立属于自己的过滤、存档、回溯闭环。每次手动点开十几个网站查更新的疲惫感,被每天早上一杯咖啡时间里自动刷新的静态页面取代——那不是偷懒,是把认知带宽省下来,去做真正需要人脑判断的事。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一套轻量级个人新闻收集网站网页模板,面向零基础或入门级前端学习者、个人博客爱好者及内容聚合需求者,帮助用户无需编程即可快速搭建美观实用的资讯展示站点。资源共13个文件,含1个核心HTML主页(index.htm)、5个图像资源(4张JPG+1张GIF用于装饰元素)、3个GIF动效图标、2个INI系统配置文件、1个URL快捷方式及1个TXT说明文档,整体仅24KB,便于下载部署与本地调试。已有157人学习下载,适合希望快速上手响应式布局、理解静态网站结构、实践内容替换与主题微调的学习者。用户可直接编辑index.htm更新新闻条目,通过images文件夹更换配图,依据ReadMe.txt完成基础配置;模板已预设SEO元信息、适配多端的CSS样式及简洁图文混排结构,开箱即用,是入门网页定制与信息聚合实践的理想范例。1. 个人新闻收集网站模板:不是博客,也不是 RSS 聚合器,而是你自己的「信息过滤中枢」“个人新闻收集网站模板”这个标题常被误读成「做个带新闻栏的个人主页」或「用 WordPress 搭个资讯站」——但真正落地时你会发现:它既不依赖第三方 API 频繁失效,也不靠人工点开几十个链接再复制粘贴;它的核心价值,是把散落在不同信源(政府公报、行业白皮书、开源项目更新页、技术社区公告、学术预印本平台)里的结构化增量信息,以你定义的关键词、时间窗口、可信度权重和去重粒度,自动抓取、清洗、归类、存档,并生成可订阅、可检索、可回溯的静态站点。它不是替代新闻 App,而是解决「我关心的 A 领域政策变动」「B 技术栈的 CVE 更新」「C 行业协会季度报告」这类长尾、低频、高精度需求的最小可行闭环。适合某高校科研组跟踪政策动向、某公司技术委员会做竞品动态归档、或独立开发者构建自己的知识基座。它不追求实时性,但要求可验证、可审计、可离线导出——这才是“模板”的真实分量。2. 为什么选静态生成 + 定制爬虫 + 本地索引:避开云服务陷阱与 API 黑箱2.1 不用 CMS 或 SaaS 平台的三个硬理由很多新手第一反应是“用 Ghost / Hexo / Notion 搭一个”,但很快会撞墙:内容主权失控:Notion 的 RSS 导出只含摘要,且无法控制字段映射(比如把「发布机构」强制映射为 YAML front matter 中的source_org);更新不可审计:WordPress 插件拉取的 RSS 源一旦变更格式(如<pubDate>改成<date>),全站历史条目时间戳集体错乱,却无日志可查;长期存档失效:SaaS 平台若关闭服务(如某知名聚合工具 2023 年下线),你积累两年的元数据、分类标签、阅读状态全部清零。提示:所谓“模板”,本质是把「信息采集逻辑」和「呈现逻辑」彻底解耦——采集脚本输出标准 JSONL(每行一个新闻条目),渲染模板只消费该 JSONL。二者可独立升级、测试、备份。2.2 静态站点生成器(SSG)的不可替代性我们最终选定Hugo(而非 Jekyll 或 Next.js)作为渲染层,原因直击痛点:零运行时依赖:生成的纯 HTML/CSS/JS 可直接扔进任何对象存储(如阿里云 OSS、腾讯云 COS)或 Git 仓库 Pages,无需 Node.js 环境或数据库;毫秒级全文搜索:Hugo 内置lunr.js支持客户端搜索,且可导出search-index.json供自定义前端调用,避免 Algolia 等第三方搜索服务的隐私与成本问题;原生支持多语言内容路由:当需要按「中文政策原文」和「英文技术公告」分频道展示时,Hugo 的multilingual模块只需配置languageCode = "zh"和contentDir = "content/zh",无需改代码。2.3 爬虫层必须自己写:为什么 Requests + BeautifulSoup 是当前最稳组合有人提议用 Scrapy,但对个人场景是过度设计:Scrapy 需维护settings.py、pipelines.py、middlewares.py三套配置,而实际需求只是「从 5 个固定 URL 解析<h1>标题、<time>时间、<p>首段」;当目标网站反爬升级(如增加 JS 渲染),Scrapy 的Splash集成复杂度陡增,而 Requests +fake-useragent+time.sleep()组合,配合retrying库重试,反而更易调试。我们采用「单文件爬虫脚本」(fetch_news.py),核心逻辑仅 87 行,关键设计如下:# fetch_news.py import requests from bs4 import BeautifulSoup from datetime import datetime, timedelta import json import time from fake_useragent import UserAgent def fetch_from_source(url: str, selector_map: dict) -> list: """从单个源抓取,返回标准化字典列表""" headers = {"User-Agent": UserAgent().random} try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") items = [] for item in soup.select(selector_map["item"]): # 强制提取字段,缺失则设为 None(后续清洗阶段统一处理) title = item.select_one(selector_map["title"]).get_text(strip=True) if item.select_one(selector_map["title"]) else None pub_time = item.select_one(selector_map["time"]).get_text(strip=True) if item.select_one(selector_map["time"]) else None link = item.select_one(selector_map["link"])["href"] if item.select_one(selector_map["link"]) and item.select_one(selector_map["link"]).has_attr("href") else None items.append({ "title": title, "pub_time": pub_time, "link": link, "source_url": url, "fetched_at": datetime.now().isoformat(), "id": f"{url.split('//')[1].split('/')[0]}_{hash(link or title)[:8]}" # 基于域名+链接哈希生成稳定 ID }) return items except Exception as e: print(f"[ERROR] Failed to fetch {url}: {e}") return [] # 主流程:遍历配置好的信源列表 sources = [ { "url": "https://www.xxx.gov.cn/xxgk/tzgg/", "selector": { "item": ".list-item", "title": "h4 a", "time": ".date", "link": "h4 a" } }, { "url": "https://github.com/tensorflow/tensorflow/releases", "selector": { "item": ".Box-row", "title": "h2 a", "time": "relative-time", "link": "h2 a" } } ] all_news = [] for src in sources: print(f"Fetching from {src['url']}...") all_news.extend(fetch_from_source(src["url"], src["selector"])) time.sleep(2) # 礼貌性间隔,避免触发风控 # 去重:按 id 字段保留最新一条(因同一新闻可能在多个源出现) seen_ids = set() deduped = [] for item in sorted(all_news, key=lambda x: x.get("fetched_at", ""), reverse=True): if item["id"] not in seen_ids: seen_ids.add(item["id"]) deduped.append(item) # 输出为 JSONL(每行一个 JSON 对象),供 Hugo 消费 with open("data/news.jsonl", "w", encoding="utf-8") as f: for item in deduped: f.write(json.dumps(item, ensure_ascii=False) + "\n")这段代码的关键设计说明:id字段用f"{domain}_{hash(link)[:8]}"生成,确保同一新闻在不同源重复抓取时能被精准去重,且不依赖外部数据库;fetched_at记录抓取时间,而非发布时间,用于判断「本次抓取是否覆盖了上次遗漏的条目」;selector_map将 CSS 选择器与字段绑定,新增信源只需在sources列表里加一项,无需改函数逻辑;所有异常捕获后继续执行,单个源失败不影响其他源,符合「个人项目容错优先」原则。3. Hugo 模板如何把 JSONL 渲染成可搜索、可归档、可 RSS 订阅的静态站3.1 数据层:用 Hugo 的data目录桥接爬虫与渲染Hugo 默认不直接读取 JSONL,但我们通过一个轻量转换脚本jsonl_to_hugo_data.py,将data/news.jsonl转为data/news.json(标准 JSON 数组):# jsonl_to_hugo_data.py import json with open("data/news.jsonl", "r", encoding="utf-8") as f: lines = [json.loads(line.strip()) for line in f if line.strip()] # 按日期分组,生成 Hugo data 结构 grouped = {} for item in lines: # 提取年月,如 "2024-03-15" → "2024/03" date_str = item.get("pub_time", "") if not date_str: continue try: dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) if "T" in date_str else datetime.strptime(date_str, "%Y-%m-%d") year_month = f"{dt.year}/{dt.month:02d}" except: year_month = "unknown" if year_month not in grouped: grouped[year_month] = [] grouped[year_month].append(item) # 写入 data/news.json,Hugo 可直接 .Site.Data.news 访问 with open("data/news.json", "w", encoding="utf-8") as f: json.dump(grouped, f, ensure_ascii=False, indent=2)注意:此脚本在每次fetch_news.py运行后自动执行(可用 Makefile 或 GitHub Actions 触发),确保 Hugo 构建时data/news.json总是最新的。3.2 页面结构:按「时间轴 + 分类 + 全文搜索」三维组织我们在layouts/_default/list.html中定义主页面逻辑:<!-- layouts/_default/list.html --> {{ define "main" }} <div class="container"> <h1>我的新闻中枢</h1> <!-- 搜索框(调用 lunr.js) --> <div class="search-box"> <input type="text" id="search-input" placeholder="搜索标题、来源、关键词..." /> <div id="search-results"></div> </div> <!-- 时间轴归档(按年月折叠) --> <div class="archive"> {{ range $year_month, $items := .Site.Data.news }} <details> <summary>{{ $year_month }} ({{ len $items }} 条)</summary> <ul> {{ range $items }} <li> <a href="{{ .link }}" target="_blank">{{ .title | truncate 60 }}</a> <small class="source">[{{ .source_url | replaceRE "https?://([^/]+).*" "$1" }}]</small> <small class="time">{{ .pub_time | time.Format ":date_long" }}</small> </li> {{ end }} </ul> </details> {{ end }} </div> </div> <!-- lunr.js 搜索初始化 --> <script> // 从 /index.json 加载全文索引(Hugo 自动生成) fetch('/index.json').then(r => r.json()).then(indexData => { const idx = lunr(function () { this.ref('id') this.field('title', { boost: 10 }) this.field('source_url') this.field('pub_time') indexData.forEach(function (doc) { this.add(doc) }, this) }) const input = document.getElementById('search-input') const results = document.getElementById('search-results') input.addEventListener('input', function () { const query = this.value.trim() if (!query) return const res = idx.search(query) results.innerHTML = res.map(r => { const doc = indexData.find(d => d.id === r.ref) return `<div class="search-result"><a href="${doc.link}" target="_blank">${doc.title}</a><br><small>${doc.source_url}</small></div>` }).join('') }) }) </script> {{ end }}关键参数说明:index.json是 Hugo 在构建时自动生成的全文索引文件(需在config.toml中启用buildSearchIndex = true);lunr.js的boost: 10给标题字段更高权重,确保搜「Transformer」时,标题含该词的条目排在前面;details/summary实现可折叠时间轴,避免页面过长,移动端友好。3.3 RSS 订阅:用 Hugo 的内置 RSS 模板生成合规 Feed在layouts/_default/rss.xml中:{{ printf "<?xml version=\"1.0\" encoding=\"utf-8\"?>" | safeHTML }} <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>{{ .Site.Title }}</title> <link>{{ .Site.BaseURL }}</link> <description>{{ .Site.Params.description }}</description> <generator>Hugo {{ .Hugo.Version }}</generator> <lastBuildDate>{{ now.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate> <atom:link href="{{ .Site.BaseURL }}index.xml" rel="self" type="application/rss+xml" /> {{ range first 20 .Site.Data.news }} {{ range . }} <item> <title>{{ .title | htmlUnescape }}</title> <link>{{ .link }}</link> <guid>{{ .link }}</guid> <pubDate>{{ .pub_time | time.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate> <description>{{ .title | plainify | truncate 200 }}</description> </item> {{ end }} {{ end }} </channel> </rss>提示:RSS 的guid必须全局唯一且不变,我们直接复用爬虫生成的id字段(但此处用.link更稳妥,因部分源无稳定 ID);pubDate严格按 RFC 822 格式,避免某些 RSS 阅读器解析失败。4. 避坑:爬虫失效、时间解析错乱、搜索结果为空的 5 个血泪现场4.1 现象:爬虫跑着跑着突然全源返回空列表,日志只显示[ERROR] Failed to fetch xxx: ReadTimeout原因:目标网站启用了 Cloudflare 的「Under Attack Mode」,对非浏览器请求返回 503,而fake-useragent生成的 UA 已被大量爬虫滥用,被 Cloudflare 列入黑名单。解决:不用fake-useragent,改用一组手动维护的、经测试有效的 UA 字符串池,并加入Accept-Language和Accept-Encoding头:VALID_UAS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15" ] headers = { "User-Agent": random.choice(VALID_UAS), "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate" }4.2 现象:某政府网站的时间字段解析出来是「2024年3月15日」,但 Hugo 的time.Format报错parsing time "2024年3月15日"原因:Hugo 的time.Format只支持 Go 的标准时间格式(如2006-01-02),不识别中文日期。爬虫层未做标准化。解决:在fetch_news.py中增加日期归一化函数,统一转为 ISO 格式:def parse_chinese_date(date_str: str) -> str: """将 '2024年3月15日' 转为 '2024-03-15'""" import re match = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", date_str) if match: y, m, d = match.groups() return f"{y}-{int(m):02d}-{int(d):02d}" return date_str # 原样返回,交由后续逻辑处理 # 在 fetch_from_source 中调用: pub_time = parse_chinese_date(pub_time_raw)4.3 现象:搜索框输入关键词,search-results区域始终空白,控制台无报错原因:index.json文件未生成,或路径错误。Hugo 默认只对content/下的 Markdown 文件生成索引,而我们的数据来自data/目录。解决:在config.toml中显式配置buildSearchIndex = true,并确保index.json路径正确:[outputs] home = ["HTML", "RSS", "JSON"] # 关键!必须包含 "JSON" [outputFormats.JSON] mediaType = "application/json" baseName = "index" isPlainText = true notAlternative = true这样 Hugo 会在根目录生成/index.json,与模板中fetch('/index.json')路径一致。4.4 现象:GitHub Releases 页面抓取到的<time>标签内容是相对时间(如about 2 hours ago),无法解析为绝对时间原因:GitHub 使用relative-time自定义元素,其datetime属性才是 ISO 时间,但BeautifulSoup默认不读取属性值。解决:修改选择器,优先读取datetime属性:# 原 selector_map["time"] = "relative-time" # 改为: time_elem = item.select_one("relative-time") or item.select_one("time") pub_time = time_elem.get("datetime") or time_elem.get_text(strip=True) if time_elem else None4.5 现象:Hugo 构建成功,但访问页面时时间显示为0001-01-01原因:.pub_time字段在 JSON 中为null,Hugo 的time.Format遇到空值会回退到零值时间。解决:在模板中增加空值保护:{{ if .pub_time }} <small class="time">{{ .pub_time | time.Format ":date_long" }}</small> {{ else }} <small class="time">时间未知</small> {{ end }}5. 进阶技巧:用 Git 版本控制实现新闻溯源、变更比对与一键回滚5.1 为什么必须用 Git 管理data/news.jsonl?新闻数据不是「一次生成永久有效」,而是持续演进的:某条政策原文链接失效,下次抓取时link字段变为空,但你仍需保留旧版记录;某技术文档更新后,标题微调(如「v2.3.0 发布」→「v2.3.0 正式版发布」),你需知道何时发生变更;同事想查看「上个月我漏掉了哪些条目」,需对比两个时间点的数据快照。Git 天然提供这些能力,且无需额外数据库。5.2 自动化工作流:每次抓取后提交带语义的 Commit我们用git_commit_after_fetch.sh脚本封装提交逻辑:#!/bin/bash # git_commit_after_fetch.sh set -e # 生成本次抓取的摘要 NEW_COUNT=$(wc -l < data/news.jsonl | awk '{print $1}') OLD_COUNT=$(git ls-tree -r HEAD -- data/news.jsonl | awk '{print $3}' | xargs git show | wc -l | awk '{print $1}') # 提交消息:包含新增/删除数,及抓取时间 git add data/news.jsonl git commit -m "news: update $(date '+%Y-%m-%d %H:%M') | +$((NEW_COUNT-OLD_COUNT)) entries"提示:git ls-tree -r HEAD -- data/news.jsonl获取上一版news.jsonl的 blob hash,再用git show输出内容行数,从而计算净增量。5.3 用 Git Diff 快速定位新闻变更当需要排查「为什么某条新闻消失了」,直接运行:# 查看最近 3 次提交中 news.jsonl 的变化 git log -3 --pretty=format:"%h %ad %s" --date=short -- data/news.jsonl # 对比当前版与上一版,只显示新增和删除的 ID(即新闻条目标识) git diff HEAD~1 HEAD -- data/news.jsonl | grep -E '^\+|^-'输出类似:-{"id":"gov_cn_abc123","title":"关于XXX的通知","pub_time":"2024-03-10",...} +{"id":"gov_cn_def456","title":"关于YYY的补充通知","pub_time":"2024-03-15",...}一眼可知:旧条目gov_cn_abc123被移除(可能源已下线),新条目gov_cn_def456被加入。5.4 一键回滚到任意历史状态若某次抓取引入了大量垃圾数据(如某个信源被黑,页面注入广告链接),可立即恢复:# 查看所有包含 "news:" 的提交 git log --grep="news:" --oneline # 回滚到指定 commit(假设为 abc123) git checkout abc123 -- data/news.jsonl git commit -m "revert: rollback to news state at abc123" # 重新生成 Hugo 站点 hugo --minify整个过程不到 30 秒,且所有中间状态均可追溯。5.5 进阶:用 GitHub Actions 实现全自动每日抓取与部署在.github/workflows/daily-fetch.yml中:name: Daily News Fetch & Deploy on: schedule: - cron: '0 3 * * *' # 每天 UTC 3 点(北京时间 11 点)执行 workflow_dispatch: jobs: fetch-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: token: ${{ secrets.PERSONAL_TOKEN }} # 用于提交 persist-credentials: false - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install dependencies run: | pip install requests beautifulsoup4 fake-useragent - name: Run fetch script run: python fetch_news.py - name: Convert JSONL to Hugo data run: python jsonl_to_hugo_data.py - name: Commit changes run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" chmod +x git_commit_after_fetch.sh ./git_commit_after_fetch.sh - name: Build and deploy uses: peaceiris/actions-hugo@v2 with: hugo-version: 'latest' # Hugo 构建后自动推送到 gh-pages 分支注意:PERSONAL_TOKEN需在仓库 Settings → Secrets 中配置,权限至少包含contents: write。这套机制让「个人新闻中枢」真正变成一个活的系统:你设定好信源和规则,剩下的交给 Git 和 CI,每天醒来就能看到结构清晰、可验证、可回溯的新鲜信息切片。我坚持用这个模板三年,最大的体会是:信息过载不是因为新闻太多,而是因为没有建立属于自己的过滤、存档、回溯闭环。每次手动点开十几个网站查更新的疲惫感,被每天早上一杯咖啡时间里自动刷新的静态页面取代——那不是偷懒,是把认知带宽省下来,去做真正需要人脑判断的事。希望帮到你。本文还有配套的精品资源,点击获取