最近在折腾一个资讯类小程序项目,核心功能很简单:把分散在好几个新闻源站点的头条内容,通过爬虫抓下来,统一清洗、去重、分类,再在微信小程序里做信息流展示和阅读。整个过程做下来,技术上不算难,但涉及的链路很长——采集、存储、接口、小程序端渲染、合规审查,每一环都有不少值得记录的坑。这篇既是给自己复盘,也是给想做类似“聚合内容小程序”的朋友一份可参照的踩坑地图,尤其是爬虫那块的数据清洗、接口设计和小程序上架的合规取舍,应该能帮你少走不少弯路。
1. 项目背景与整体设计思路
1.1 这个项目到底解决了什么问题
小程序生态里,资讯类产品其实是个很尴尬的存在。大厂有版权库、有采编团队、有内容审核流程,普通开发者和中小团队想独立做一个“新闻客户端”,最大的门槛不是技术,而是没有内容源。自己做原创不现实,找合作转载门槛高,那就只剩一条相对务实的路:把网上的公开新闻内容聚合起来,做二次整理和展示。
爬虫新闻小程序这个项目的定位,本质上就是给自己做一个“内容供给管道”。通过定时运行的爬虫任务,从授权允许转载或仅做摘要展示的公开站点抓取结构化信息,存进自己的数据库,再写一套 REST 接口供小程序调用。整个过程相当于把“编辑分发”这件人力密集的事,用技术手段半自动化了。
这个项目比较适合三类人参考:一是想入门爬虫但不想只做单机脚本的开发者,这个小程序链路能让爬虫真正跑在业务里;二是准备做垂直内容聚合产品、但暂时没有内容团队的创业者,可以用这套方案快速搭建 MVP;三是对小程序全栈开发感兴趣的初学者,从爬虫到接口再到前端渲染一个闭环下来,能学到的东西比刷一百个教程都实在。
1.2 技术选型背后的取舍
选型这件事,很多新手容易犯一个毛病——一上来就上重型框架。爬虫还没采集几篇文章,就先把 Scrapy + Redis + Docker 编排整上了,看着唬人,实则维护成本直接把自己压垮。我这次刻意做了减法。
爬虫部分用 Python 的 requests + BeautifulSoup,没有上 Scrapy。原因不复杂:我规划的新闻源只有三四家,属于轻量多源场景,Scrapy 的并发调度、下载中间件、信号系统在这个体量下全都是在用弹簧刀切豆腐。requests 够简单、够直观,出了问题也好定位。配合简单的时间间隔控制,完全够用。
服务端用 Flask 写接口,不是因为它最强,而是因为它薄。我只需要向外暴露新闻列表、新闻详情、分类列表这几个接口,逻辑都在数据库操作和响应序列化上,Django 的 admin 和 ORM 在这里反而显得臃肿。Flask 的路由灵活、启动快、部署方便,配合 Gunicorn 一点压力都没有。
小程序端用微信原生框架,没上 uni-app 或者 Taro。原因更直接:项目涉及的原生能力不多,所有页面都是标准的列表页和详情页,用不了几个组件。原生框架开发速度快,调试工具稳定,文档也多,出了问题搜起来方便。真要以后推 App 端再考虑跨端框架,现阶段不为未必到来的需求付技术债。
存储用 MySQL。新闻内容虽然是典型的非结构化文本,但它有明确的标题、来源、时间、分类字段,结构化存起来方便后来加后台管理功能。SQLite 其实也能跑,但我预留了后续要做聚合查询和内容量上涨的空间,MySQL 更稳。
提示:技术选型没有绝对的对错,目的是匹配你当前最核心的业务诉求。对于这种个人项目,选自己最熟的技术栈往往比选“技术上最先进”的更重要。“能快速跑起来 + 出了问题你搞得定”,这两条是黄金标准。
2. 核心模块拆解与关键技术点
2.1 新闻采集层:多源配置化爬虫
爬虫模块是整套系统的起点,也是最容易出问题的位置。我第一版写得比较粗暴:每个新闻源写一套抓取方法,遇到什么坑就补什么逻辑,结果代码里塞满了 if 分支,简直像个要拆的毛线团。后来重构的时候,我把架构换成了“调度器 + 解析器”分离的模式。
调度器负责统一管理各源的任务列表、运行频率、代理配置、失败重试逻辑;解析器则针对每一个站点实现独立的解析类,统一接口,返回标准化的 NewsItem 结构。这样做的好处是,新增一个新闻源只需要写一个解析器,完全不会动到其他代码。
标准字段我定义为:标题 title、摘要 summary、正文 content、来源 source、作者 author、发布时间 publish_time、封面图 cover_image、原文链接 link、分类 category。采集到的原始内容必须先经过字段规整才能入库,这一步不能省,宁可多花几分钟写清洗逻辑,也不能把脏数据直接丢给前端。
抓取流程上,我分成两步:先抓列表页拿到详情页链接集合,再逐条进详情页解析正文和相关字段。列表页解析只需要找到卡片链接的 DOM 结构,用 BeautifulSoup 提取 a 标签和基础信息就够了;正文解析才是重头——需要去掉导航栏、侧边栏、推荐阅读、脚本标签等无关内容。
文本去重用的方法是标题 MD5 加内容相似度比对。标题 MD5 能秒杀八成重复内容,剩下的两成靠两两比对正文的编辑距离,这个策略简单但效果很稳。
2.2 数据服务层:接口设计与缓存策略
服务端接口的设计,我遵循“对外简单、对内精细”的原则。对外只暴露三个核心接口:新闻列表、新闻详情、分类枚举。小程序端不需要知道爬虫的存在,也不必关心你的数据是从哪来的。接口返回统一 JSON 格式,内容层包裹一层通用结构,前端拿到数据就能直接渲染。
但这层不能只做数据透传,一个重要的点是缓存。新闻数据的实时性要求不是特别极端,用户能接受的刷新延迟通常在几分钟级别。如果每次都从 MySQL 里裸查,数据库压力在高并发下会很难看。我在 Flask 里给列表接口和详情接口各加了一层 application-level cache,列表缓存 60 秒,详情缓存 300 秒。响应速度快很多,数据库也轻松不少。
另一个细节是图片处理。新闻源站点的图片存在各自的 CDN 上,直接“借用”有几个风险:一是域名变了图片就裂,二是源站可能做防盗链,三是给你自己的接口链路增加了不稳定因素。我在采集时做了图片域名替换,用自己的服务器域名做前缀,实际文件通过代理拉取,小程序端展示承诺更稳。这里注意,如果你不只是做摘要展示而是全量采集,图片的服务器占用和带宽成本要提前算好。
2.3 小程序展示层:信息流与详情页构建
小程序端我分成三个页面:首页信息流、分类 Tab 页、新闻详情页。首页用 scroll-view 做长列表,下拉触发最新数据刷新,上拉触底加载历史数据。这个交互是资讯类产品的刚需,实现不算难,但要注意小程序端 setData 的性能开销——列表数据一次不要塞太多条,每次加载 10 到 15 条最合适,数据量太大容易让 webview 渲染卡顿。
列表项的卡片我采用了“左图右文”的经典排版。数据源里没有封面图的,就显示默认占位图,避免代码里图片地址为空的时小程序渲染出破图。
详情页用 rich-text 组件渲染富文本正文。这里有个我踩过的坑:直接覆值爬虫拿到的 HTML 会存在样式错乱的问题,部分站点夹杂着 onload 事件或内联样式,在微信 webview 里执行容易触发 DOM 安全限制。所以我在服务端做了一个白名单过滤,只保留 p、img、h2、h3、blockquote 这几个标签,img 标签强制全部替换成不带 script 事件的原生标签。
分类页我直接调用接口的分组参数,没有做本地状态管理,天然支持多分类切换。所有网络请求都封装在一个 request 工具类里,统一处理 baseURL、异常码、登录失效逻辑——虽然资讯类项目不强制用户体系,但统一的封装让后续接用户画像、个性化推荐时不用大规模改动前端代码。
3. 实操过程与核心实现
3.1 爬虫脚本的编写与调度配置
开始写爬虫之前,我先梳理了需要的基础库:requests 负责网络请求,BeautifulSoup 负责静态页解析,lxml 作为解析器引擎,MySQLdb 负责入库。请求头我做了两条规则:User-Agent 必须伪装成最新版 Chrome 桌面模式,Referer 带上前一页地址。这两点能避开相当一部分比较初级的反爬拦截。
我用的框架是 Flask + 定时任务。定时这块没有用 Celery,一是因为重,二是我的调度周期比较简单——每 20 分钟全量抓一次列表。用 threading.Timer 递归调用就能实现一个够用的调度器,省一个中间件,服务部署时少清一份依赖。
import requests import threading from bs4 import BeautifulSoup def fetch_list_page(source_config): """抓取列表页,返回详情页链接列表""" headers = { "User-Agent": source_config["user_agent"], "Referer": source_config["homepage"], } try: resp = requests.get(source_config["list_url"], headers=headers, timeout=10) resp.raise_for_status() except (requests.ConnectionError, requests.HTTPError) as err: # 日志记录并触发重试机制 return [] soup = BeautifulSoup(resp.text, "lxml") detail_links = [] cards = soup.select(source_config["list_card_selector"]) for card in cards[:int(source_config["max_items"])]: link_tag = card.find("a", href=True) if link_tag and "http" in link_tag["href"]: detail_links.append(link_tag["href"]) return detail_links def scheduler(interval_seconds, target_func): """极简定时任务:递归自调用""" target_func() timer = threading.Timer(interval_seconds, scheduler, args=(interval_seconds, target_func)) timer.daemon = True timer.start() if __name__ == "__main__": scheduler(20 * 60, run_all_spiders)调度周期为什么是 20 分钟,不是 10 分钟也不是 1 小时?因为新闻类站点的更新高峰集中在整点前后的 30 分钟内,每 20 分钟拉一次,既不会漏掉上升期的新内容,也不会因为太勤快触发源站访问频率限制。太短的周期还会让你的请求集中在整点造成源站压力,容易被拉黑名单。
在详情页解析这一步,正文提取我尝试过直接拿 article 标签的 get_text(),但实测很多站点把推荐阅读、相关文章也塞进 article 里。后来改了几个站点的整理逻辑,清洗函数里加了一个规则:砍掉正文中密度最高的链接块区域——推荐阅读区一般出现高于 10 个 a 标签,而正文段落里没有这么密集的链接。这个判断准确率还不错,是一个低成本且容易验证的实现路径。
3.2 服务端接口的搭建与数据格式化
Flask 接口部分我写得很轻。数据库操作统一走一个通用查询函数,返回前做一次数据清洗和序列化。所有接口都加统一的响应包装:状态码、业务码、消息和数据四段。
from flask import Flask, jsonify, request from db import get_article_page, get_article_detail app = Flask(__name__) @app.route("/api/news") def news_list(): category = request.args.get("category", "") page = int(request.args.get("page", 1)) size = int(request.args.get("size", 10)) data = get_article_page(category=category, page=page, size=size) return jsonify({ "code": 0, "msg": "ok", "data": data }) @app.route("/api/news/<int:news_id>") def news_detail(news_id): data = get_article_detail(news_id) if not data: return jsonify({"code": 404, "msg": "not found", "data": None}) return jsonify({"code": 0, "msg": "ok", "data": data})这里有一个容易忽略的细节:detail 接口返回正文时,除了存储的原始 HTML,我还做了一次“可读化”处理。把正文中的图片地址统一转成兼容小程序要求的相对路径,再把段落分隔符统一成<p>包裹。
页数翻页的逻辑我调整了很多次。最初的方案是直接从第 20 条开始向后取 10 条,当数据不够时客户端无法感知是否有更多内容。后来改为前端先请求一次获取 total 数量,再 pid 递增翻页,这样上拉加载的状态切换更准确,也不容易重复数据。这个改动不用改数据库,只在接口层做一次 count 查询即可,性能开销在缓存加持下可忽略。
3.3 小程序端渲染的实现细节
小程序端核心代码不算多,但每一处都有讲究。列表页的 onPullDownRefresh 和 onReachBottom 必须同时处理好,否则下拉刷新和上拉加载会互抢事件。
Page({ data: { newsList: [], page: 1, hasMore: true, loading: false }, onLoad() { this.fetchNews(true); }, fetchNews(reset) { if (this.data.loading) return; this.setData({ loading: true }); const page = reset ? 1 : this.data.page; wx.request({ url: `${BASE_URL}/api/news`, data: { page, size: 10 }, success: (res) => { const list = res.data.data.list; this.setData({ newsList: reset ? list : this.data.newsList.concat(list), page: page + 1, hasMore: res.data.data.total > list.length }); }, complete: () => this.setData({ loading: false }) }); }, onPullDownRefresh() { this.fetchNews(true); wx.stopPullDownRefresh(); }, onReachBottom() { if (this.data.hasMore) this.fetchNews(false); } });首页的骨架不能一开始就是空页面。我在 onLoad 后立刻请求最新列表,如果一个源突然变慢,页面上得有加载动画,不然用户会以为是白屏 bug。这个体验对资讯小程序影响非常大,加载动效必须做。
详情页渲染富文本时,我把rich-text的 nodes 属性绑定了后端返回 content。如果直接绑定原始 HTML 字符串,微信官方文档明确提示不支持所有 HTML 标签,所以最好只保留白名单那些标签。我一直到上线跑了两周,才发现有个站点正文中的 table 标签导致整个页面报错。总结下来就是:爬虫的清洗规则必须覆盖后端接口层,两层都做白名单过滤才最稳。
4. 典型问题与排查实录
4.1 爬虫请求被拦截和封禁
这类问题做爬虫几乎必然遇到。我踩过最典型的两个场景:一个源是访问频率太高触发验证码,另一个是请求头不完整被判定为“非常规浏览器”。
解决路径是给请求频率加上限速队列:每个源独立一个请求间隔配置,最短 3 秒,最长可到 8 秒。高频的批量抓取改为错峰执行,这样能规避 90% 以上的拦截。
如果遇到 IP 被限,最简单的方案是在本地拓宽出口 IP 池,比如企业内部或家庭宽带通常有多个出口 IP,配合随机切换的 User-Agent 轮换,就能把单源每日采集量支撑到位。这里插一句:网上很多人一遇上反爬就给项目塞代理池,对小项目完全没必要,维护代理的质量比使用代理的规模更费劲。先用限速,再用多 IP UA 轮换,最后才考虑引入更重的代理方案。
注意:应对反爬时,所有手段都要以“不影响目标站点的正常运行”为前提。频率太高不仅不道德,也可能给自己带来法律风险。如果你发现拿不到内容,先想想是不是对方根本不希望被采集,而不是一昧硬来。
4.2 小程序合规限制:域名、类目与审核
小程序上线有一道绕不过去的坎:合法域名校验。所有请求的 URL 必须备案,并强制 HTTPS。刚开始开发时可以用开发者工具的“不校验合法域名”来绕过,但真机预览和线上版本都会直接拦截。我为此专门买了一个域名,配了免费的 HTTPS 证书,把接口统一部署到了这个域名下。
比 HTTPS 更麻烦的是类目审核。微信的类目规范里,对新闻资讯类小程序有明确的资质要求,个人主体基本没法直接上架“新闻”类目。这个事真的建议在动手开发之前就想清楚。我的处理方式是将产品定位调整为“行业信息聚合工具”——展示的内容限定在垂直领域的技术资讯、产品动态,不涉及时政、社会、民生类新闻,类目选择更容易通过。如果本身没有相应的资质背景,那就别在标题和描述里出现“新闻”字样,踏踏实实做垂直信息聚合。
但这绝对不能变成“上传内容逃避审核”的理由。小程序上线时要遵守平台的运营规范,不能因为做的是垂直资讯就去碰灰产或打擦边球,平台一旦排查到,锁禁比下架更难受。
4.3 内容质量与版权风险控制
爬虫拿到的是原始 HTML,正文里经常夹杂着推广链接、广告位脚本、作者栏的营销信息。只做标签白名单还不够。我写了一段专门的社会化清洗逻辑:统计每个<p>标签内的链接密度,密度超过阈值的内容块直接删除。
内容版权的问题,比技术清洗麻烦得多。新闻稿件的转载权不完全等同于公开抓取权,很多源站虽然在网页上没有明确禁止转载,但“复制”和“转载”在法律上的界定不一样。我的规避策略是“摘要 + 原文链接跳转”,列表页展示标题、摘要、封面图,详情页正文最多展示 200 字的内容,读完点击链接跳回原始网站。这样既保留了小程序的信息流体验,又把版权的归属问题处理得比较干净,不会给自己的产品埋雷。做全量正文采集和展示的开发者,务必先拿到源头站点或版权方的正式转载授权。
4.4 定时任务抖动与数据缺失
跑了两周后我发现,偶尔会有某个时间段的新闻列表里出现大量旧新闻重复。排查后发现,问题出在去重逻辑上。有些新闻源在更新时间不规律,同一事件会发多条相似标题的稿件,MD5 去重只能过滤完全相同的标题,相似度不够的重复内容就漏进去了。
我的解决方法是把标题规范化的级别提高:对标题做全角转半角、去停用词、去空格后,再用编辑距离阈值进行重复检测。0.85 相似度以上的标题直接视为重复,只保留最早入库的那一条。这个方案比 MD5 复杂,但有效。
定时任务抖动的另一个原因是服务器内存不够导致进程被系统杀掉。我在生产环境跑的是 MySQL + Flask 双服务,总共 1G 内存,爬虫运行高峰很容易触发 OOM。后面给进程加了 systemd 的服务自动重启和内存监控,这个坑才算真正填平。
5. 合规底线、性能优化与扩展思路
5.1 爬虫合规的边界在哪里
合规是这类项目可能最容易被忽略、但代价却是最高的一块。做技术的人容易把注意力放在“能不能爬到”而不是“该不该爬”,这个认知偏差风险不小。
我自己的三条底线:一是遵守目标站点在 robots.txt 里明确提出的抓取约束,明示禁止的区域一定不碰;二是针对来源、作者、发布时间做规范和保留展示,做到内容出处可溯源;三是不抓取任何涉及用户隐私和未公开评论的数据,采集范围严格限定在“已发布的新闻内容本身”。
对于只做聚合展示的小程序,摘要加原文链接方式是最省心态的方案。如果做全文转载,务必先取得授权,否则后续纠纷单纯靠下架内容是收拾不了的。
5.2 性能优化空间与功能扩展方向
目前性能瓶颈主要集中在图片转发和正文清洗这两个 CPU 密集环节。后续如果想扩内容源规模,我可以引入 Redis 做消息队列把采集和清洗解耦,也可以用更轻的负载均衡方案。不过现阶段这套架构完全够用,不建议为了“更先进”而特意重构。
功能扩展方向有很多:可以基于用户的滑动轨迹做个性化排序,也可以增加关键词订阅,还可以接一个后台管理系统统一管理新闻源启停。从长期来看,把小程序的形态从“信息流工具”升级成“垂直行业资讯助手”更有想象空间,比如加报价筛选、期货行情联动、行业报告下载,靠内容和服务的垂直深度提升用户价值。
“爬虫 + 小程序”这套组合,做资讯聚合是其中非常成熟的一个方向。所有技术点都不算黑科技,难的是把每个环节都打磨到不掉链子。数据采集稳定,接口响应快,前端体验顺,合规风险可控,这四条都过关,产品就已经跑在了多数同品类项目前面。
6. 个人经验总结
做这个项目最大的收获不是把技术栈跑通了,而是理解了“聚合类小程序”真正的产品逻辑:内容质量、合规安全和用户体验,这三件事的比重一点都不比“写出多少行代码”低。
最想多说一句的,是踩过的那个定时任务内存问题。本地开发环境一切正常,上了服务器才暴露资源瓶颈,这提醒我一定要做部署前的压力自测,至少模拟真实运行环境跑一遍,别让“本地没问题”成为一种错觉。
这个项目的架构完全可以往下复用。爬虫的数据只要结构够规范,换个展示层就可以变成招聘信息聚合、商品折扣监控或者行业报告订阅。真正有价值的是从采集、清洗、存储到展示的完整链路,以及每一步踩过的坑沉淀下来的经验。希望这篇笔记能帮正要动手做类似项目的同学少走几条弯路,把时间花在真正能提升产品体验的事情上。