最近在折腾一个资讯类小程序项目核心功能很简单把分散在好几个新闻源站点的头条内容通过爬虫抓下来统一清洗、去重、分类再在微信小程序里做信息流展示和阅读。整个过程做下来技术上不算难但涉及的链路很长——采集、存储、接口、小程序端渲染、合规审查每一环都有不少值得记录的坑。这篇既是给自己复盘也是给想做类似“聚合内容小程序”的朋友一份可参照的踩坑地图尤其是爬虫那块的数据清洗、接口设计和小程序上架的合规取舍应该能帮你少走不少弯路。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], headersheaders, timeout10) 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, hrefTrue) 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(categorycategory, pagepage, sizesize) 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. 个人经验总结做这个项目最大的收获不是把技术栈跑通了而是理解了“聚合类小程序”真正的产品逻辑内容质量、合规安全和用户体验这三件事的比重一点都不比“写出多少行代码”低。最想多说一句的是踩过的那个定时任务内存问题。本地开发环境一切正常上了服务器才暴露资源瓶颈这提醒我一定要做部署前的压力自测至少模拟真实运行环境跑一遍别让“本地没问题”成为一种错觉。这个项目的架构完全可以往下复用。爬虫的数据只要结构够规范换个展示层就可以变成招聘信息聚合、商品折扣监控或者行业报告订阅。真正有价值的是从采集、清洗、存储到展示的完整链路以及每一步踩过的坑沉淀下来的经验。希望这篇笔记能帮正要动手做类似项目的同学少走几条弯路把时间花在真正能提升产品体验的事情上。