资讯动态

校园小程序内容自动化实战:ThinkPHP+Laravel+Scrapy爬虫架构全解析

发布时间:2026/10/4 3:46:11 来源:尧图企业网站定制
做了好几个校园信息类小程序到头来发现真正卡脖子的不是小程序本身而是内容从哪来。学校官网、教务处、团委公众号、各学院通知信息散落一地人工搬运累死人漏发还挨骂。所以这个项目从一开始就把爬虫放进了一等公民的位置ThinkPHP做业务APILaravel做后台管理和任务调度小程序端负责消费数据爬虫负责源源不断往里供料。整套东西跑通之后校园头条新闻基本实现了零人工干预的自动更新。这篇文章就把整个项目的选型思路、架构设计、爬虫实现、接口规范、联调抓包和踩坑记录完整梳理一遍适合正在做校园类小程序、新闻资讯类应用或者打算把PHP后端和爬虫结合起来的同学参考。1. 项目全貌与整体设计拆解1.1 需求不是做个小程序是内容自动进来刚开始接需求的时候对方只说要做个校园头条新闻小程序我第一反应是这有什么难的套个模板不就完了。但深入聊了才发现真正的痛点是学校的信息渠道太多了官网新闻、教务公告、各院系动态、学生组织公众号散落在十几个站点里。如果靠运营人员手动复制粘贴到后台再发出去一天光整理信息就得花三四个小时而且时效性根本跟不上。所以这个项目的硬性指标不是小程序长什么样而是内容能不能自己长出来。爬虫从立项开始就是主角而不是后期加的彩蛋。这里我得先泼一盆冷水校园类小程序做爬虫边界要比商业爬虫严格得多。数据源基本限定在学校官网和校内公开平台整理完还要标注来源涉及个人隐私的统统不碰。我在项目里专门做了一个数据源准入清单每个站点上线前人工审核一遍确认是学校官方或学生组织公开渠道并且遵守站点的robots约定抓取频率控制在分钟级不搞并发轰炸。合规和安全这条线必须在代码层面就锁死。1.2 为什么是ThinkPHP和Laravel双框架而不是只选一个这个可能是大家看到项目名最先疑惑的地方。我用双框架不是因为炫耀技术而是因为两个框架的擅长点完全不同。ThinkPHP 6.x 的优势是轻量、部署门槛低、中文文档完善Plus 学校服务器环境普遍是宝塔面板或者云主机ThinkPHP跑起来非常省心。所以我用 ThinkPHP 承担小程序端的所有业务API新闻列表、分类、详情、搜索、收藏、点赞。这些接口逻辑不算复杂追求的是响应速度和稳定。Laravel 的优势是生态成熟任务调度、队列、事件机制、Eloquent ORM 都很顺手。爬虫采集回来的数据要清洗、去重、推送、清理缓存还要定时重跑失败任务这些脏活累活放在 Laravel 里做非常舒服。我用 Laravel 做了一个后台管理端既能查看采集任务状态也能人工审核敏感内容、手动修正分类。两个框架共用一个 MySQL 数据库中间通过数据表衔接。可能有人问为什么不把业务API也统一到 Laravel我的答案很简单项目要交付不是搞技术选型辩论赛。ThinkPHP 写业务接口确实快团队上手也快这个优势在排期紧张时就是实打实的效率。1.3 整体架构三个端各自干什么整个系统分三个部分采集端Python Scrapy负责从各数据源抓取原始页面解析出标题、正文、封面图、发布时间写入中间表。服务端ThinkPHP LaravelThinkPHP 提供小程序所需的全部APILaravel 跑队列任务消费采集端写入的原始数据完成洗稿、分类打标、去重然后发布到正式内容表。小程序端uni-app首页信息流、分类Tab、搜索页、详情页通过HTTP接口与服务端交互实现了下拉刷新和触底加载更多。采集端用 Python 写是因为 Scrapy 生态太成熟了处理网页解析、请求重试、数据管道都比 PHP 顺手。但如果你不想引入 Python 技术栈用 Guzzle Symfony DomCrawler 在 PHP 里也能实现差不多的功能后面我会详细对比。数据流向是这样的数据源站点 - Scrapy采集 - MySQL中间表 - Laravel队列清洗 - 正式内容表 - Redis缓存 - ThinkPHP API - uni-app小程序2. 爬虫模块从站点分析到数据入库的完整闭环2.1 先做站点分析再写爬虫代码熟悉我风格的朋友知道我写爬虫之前从来不先写代码而是先做表格。这个项目的站点清单大概是这样的数据源页面结构更新频率需要处理的字段学校新闻网列表页详情页每天2-5条标题、正文、配图、时间教务处纯文字通知较多不定期标题、正文、附件链接团委公众号文章页每天1-3条标题、正文、封面图各学院官网结构不统一低频标题、正文、时间拿到这个表之后才去逐个分析页面结构。用 Chrome 开发者工具看列表页和后详情页的 HTML 结构确认标题在哪个标签里、正文在哪个容器里、发布时间是什么格式。这里有个很实用的经验不要依赖 CSS 类名做定位优先用标签层级和固有属性。很多校内网站的 CSS 类名是乱写的比如classa1、classcontent但h1、div的嵌套结构往往长期稳定用 XPath 找//div[classcontent]//h1这类路径比直接匹配类名靠谱得多。2.2 Scrapy 还是 PHP 内置方案我的取舍项目原方案就写的是带爬虫但没有限定语言。我权衡了两条路方案APython Scrapy 独立采集服务优点异步并发效率高、中间件丰富随机UA、代理切换、下载延迟控制、Pipeline 机制清晰。缺点引入 Python 技术栈服务器要额外维护 Python 环境和 PHP 后端是两套系统。方案BPHP Guzzle Symfony DomCrawler 写采集脚本优点技术栈统一Laravel 的调度器可以直接定时触发采集结果直接入库省一层数据中转。缺点PHP 的并发模型不如 Python 的异步框架遇到大页面密集采集时性能和健壮性都差点意思。最终选了方案A。理由很简单校园站点虽然结构简单但数量多、杂、乱有的站点HTTP响应特别慢有的偶尔飘个验证码。Scrapy 的容错机制和重试策略非常成熟断点续爬也方便这些稳定性的坑 Pyhton 社区都踩完了。而中间表 MySQL 这个设计正好把两套系统解耦了。如果你是一个人维护整个项目不想搞两套技术栈我建议你选方案B。Laravel 自带Schedule定时任务写一批命令行脚本就能完成采集虽然速度和健壮性弱一些但胜在省心。2.3 Scrapy 项目的核心代码结构Scrapy 项目里最重要的两个文件是items.py和pipelines.py。items.py定义抓取的字段pipelines.py负责数据清洗和入库。我贴一个简化的字段定义import scrapy class NewsItem(scrapy.Item): title scrapy.Field() source_url scrapy.Field() source_name scrapy.Field() content scrapy.Field() cover_image scrapy.Field() publish_time scrapy.Field() category scrapy.Field()爬虫的解析逻辑里最需要注意的坑是标题本身带着栏目名。比如很多学校官网的新闻标题是【学院动态】我院举办学术讲座我们清洗时要统一去掉【】里的栏目前缀因为这些信息会在小程序里通过分类标签展示标题里再留一份就冗余了。Pipeline 里我做了几件事去除HTML标签只保留纯文本正文里存 Markdown 格式的简化版本方便小程序渲染。时间格式化老网站的发布时间格式五花八门有的写2024-5-6 10:30有的写2024年05月06日统一转成时间戳。封面图处理有些站点图片是相对路径拼接成完整URL然后异步下载到本地OSS避免小程序里直接引用外链图片因为防盗链而加载失败。MD5去重以source_url加title计算 MD5 作为唯一键插入时先查重重复的跳过。去重这个环节特别关键。有一次学校官网把一条新闻在通知公告和新闻动态两个栏目各发了一遍URL不同但标题内容完全一样。只按 URL 去重拦不住加上标题的 MD5 之后就完美解决了。但要注意标题 MD5 碰撞的概率虽然极低正式环境我还会再比对一次正文的前50个字符双保险。2.4 合规边界和反爬应对校园站点的反爬策略一般不强但也不能掉以轻心。我的做法很朴素设置DOWNLOAD_DELAY 3每个请求间隔3秒不给目标站点增加压力。默认携带真实浏览器的User-Agent但不伪造 IP更不搞分布式。说直白点校园信息本来就是要让师生看的它就是面向公众公开的内容你慢一点正常抓它没有理由拦你。如果遇到 403先重试3次仍然失败就写一个 Error 日志交给 Laravel 后台展示。我会在后台看到某个数据源连续失败再去手工查看是不是页面改版了。这里一定要强调爬虫不是偷数据是帮你整理公开信息。在代码注释、README 和后台页面里我全部统一写了数据来源网络公开渠道如侵权请联系删除的声明。项目上线前还让法务看了一下确认没问题才放的。这事看起来繁琐但真出了问题这就是你的护身符。3. 服务端接口设计与双框架的分工落地3.1 ThinkPHP 侧小程序业务API的规划小程序端的所有请求都走 ThinkPHP 的api路由。我用了think\facade\Route做资源路由注册比如这样Route::group(api, function () { Route::get(news/list, News/list); Route::get(news/detail, News/detail); Route::get(category/list, Category/list); Route::get(search, News/search); Route::post(favorite/add, Favorite/add); Route::post(favorite/cancel, Favorite/cancel); Route::get(favorite/list, Favorite/list); });接口统一返回 JSON格式是{code: 0, msg: ok, data: {...}}业务异常用非零 code 表达HTTP 状态码一律 200。这个小设计是为了适配小程序端wx.request的行为如果 HTTP 返回非 200小程序会直接走到 fail 回调很多开发者都会在这踩坑因为后端一报错前端就只看到request:fail这行字根本不知道具体啥原因。接口文档用 Apifox 维护要求所有接口必须写请求参数说明、响应示例。这个习惯救了我很多次尤其在后期小程序端换人接手联调的时候直接甩一份文档过去比自己口头讲十遍管用。3.2 列表加载更多分页接口的正确姿势头条信息流的核心交互就是上拉加载更多。小程序端的滚动加载会不断往后翻页服务端要把分页参数设计好。我的News/list接口长这样public function list(Request $request) { $page $request-param(page, 1, intval); $size $request-param(size, 10, intval); $categoryId $request-param(category_id, 0, intval); $size min($size, 30); // 防止有人传10000把服务拖垮 $query NewsModel::where(status, 1); if ($categoryId 0) { $query-where(category_id, $categoryId); } $total $query-count(); $list $query-order(publish_time desc) -page($page, $size) -select(); $hasMore $page * $size $total; return json([ code 0, msg ok, data [ list $list, page $page, size $size, has_more $hasMore, ] ]); }我特别强调has_more这个字段。很多初级开发只返回list前端就只能自己猜有没有下一页一旦list长度刚好等于 size前端会以为还有下一页结果请求完下一页是空的造成白屏抖动。has_more用总数判断可以精准告知前端是否继续加载。另外注意$size min($size, 30)这行。小程序端看起来只是个普通参数但总有手滑或者恶意请求把 size 传成 1000如果不做限制一次全量返回数据库压力巨大。接口设计必须在入口就把参数范围锁死。3.3 Redis 缓存如何做到秒开且不脏校园头条的首页是所有学生的第一视觉如果请求一次要查一遍 MySQL遇到瞬时流量比如刚开学大家都在看迎新通知很容易把数据库打崩。我加了一层 Redis 缓存。缓存策略很简单列表接口的 MD5作为 key参数一样就命中同一份缓存。比如api:news:list:page1:size10:category0。缓存时间是5分钟。这样五分钟内所有相同参数的请求都会直接命中 RedisMySQL 的压力几乎为零。但这里有个经典问题缓存与数据的一致性。爬虫刚抓了新新闻如果缓存里还是旧数据用户就会看到新新闻没出来的错觉。我的做法是在采集端写入新数据后主动清理相关列表缓存。Laravel 的队列任务在处理清洗入库时顺便执行一条命令Redis::del(api:news:list:*);直接把列表缓存全部清掉。第一次请求重新查库然后再次缓存。这个缓存清理的逻辑虽然粗暴但胜在绝对正确而且对校园小程序这个量级来说开销完全可以接受。3.4 Laravel 侧队列、调度和后台管理Laravel 在这个项目里最核心的价值是队列和任务调度。采集端 Scrapy 写入中间表raw_news后Laravel 每5分钟跑一次任务把中间表里未处理的数据捞出来交给队列消费。队列做了这些事// 在 App\Jobs\ProcessRawNews public function handle() { $news RawNews::where(status, 0)-first(); if (!$news) return; // 去重 $exists News::where(source_md5, $news-source_md5)-exists(); if ($exists) { $news-status 2; // 已存在 $news-save(); return; } // 清洗正文存入正式表 $news-status 1; $news-save(); }Laravel 的任务调度配置文件里只需要一行$schedule-command(news:process)-everyFiveMinutes();就能保证每5分钟消费一次队列。为什么不用 Scrapy 直接写正式表因为正式表需要做分类打标、敏感词过滤、封面图下载这些逻辑都要和 PHP 业务复用放在 Laravel 里最顺。中间的raw_news表既是缓冲也是审计日志。后台管理端我用了 Laravel 简单的 Bootstrap 模板没有上 Filament 这种重型管理后台因为需求就是看采集状态、错误日志、手动修正分类。界面里有一张表展示每个数据源最近一次采集时间、采集条数、失败次数黄了红了高亮提示。这个页面虽然简单但运维的时候帮了大忙有不少站点改版之后隔几天才发现全靠这个看板兜底。4. 小程序端开发与联调实战4.1 技术栈选择uni-app 的原生体验与跨端妥协小程序端我选了 uni-app 而不是纯原生主要考量是后续可能还要出支付宝小程序和 App 端。uni-app 用 Vue 语法写编译到微信小程序开发效率确实高不少。尤其是页面结构简单的信息流用view、text就能覆盖大部分需求。但 uni-app 有几个坑要注意生命周期钩子和原生不完全一样onReachBottom在 uni-app 里也能用但onPullDownRefresh要在pages.json里设置enablePullDownRefresh: true否则页面配置没开事件根本不触发。组件的v-model在某些原生组件上会失效比如搜索框的输入我最后直接用input手动处理。富文本内容我用rich-text渲染但 uni-app 的rich-text对class选择器的支持有限所以我存的是简化版 HTML只用p、img、br这些基础标签。4.2 首页信息流与加载更多交互的实现首页的核心逻辑是滚动到底自动加载下一页。我贴一下关键代码这个是可以直接抄作业的template view classnews-list view classnews-item v-foritem in list :keyitem.id text classtitle{{ item.title }}/text text classtime{{ item.publish_time_text }}/text /view view classloading v-ifloading加载中.../view view classno-more v-if!hasMore list.length 0没有更多了/view /view /templateexport default { data() { return { list: [], page: 1, size: 10, hasMore: true, loading: false }; }, onReachBottom() { if (this.loading || !this.hasMore) return; this.loadMore(); }, methods: { loadMore() { this.loading true; request({ url: /api/news/list, data: { page: this.page, size: this.size } }).then(res { const data res.data; this.list this.list.concat(data.list); this.page 1; this.hasMore data.has_more; this.loading false; }); } } };核心点有三个concat而不是赋值保证下一页的数据追加到尾部hasMore直接控制是否还有下一次请求loading作为锁防止用户滚太快时连续触发多个重复请求。对了还有个很常见的性能问题图片多的时候信息流滚动会有明显卡顿。我后来把图片全部换成懒加载只加载视口附近的图片滚动体验一下就顺滑了。小程序端用image组件配合lazy-load属性就能实现uni-app 也支持。4.3 动态标题与导航栏高度适配导航栏这块有两个小需求但都很容易翻车。一是动态设置标题。用户从首页点进计算机学院动态这个分类导航栏上的标题应该跟着变。原生小程序里用wx.setNavigationBarTitle就行uni-app 里我用uni.setNavigationBarTitle({ title: categoryName });这个方法必须在onLoad或者onShow里调用不能在data里直接改navigationBarTitleText因为页面的导航栏标题配置是静态的想动态变化必须靠这个API这是个很容易忽略的细节。二是顶部导航栏高度。不同型号的手机状态栏高度不一样iPhone 的小刘海和安卓的挖孔屏哪怕同样尺寸沉浸式状态栏的视觉效果也完全不同。我的做法是封装了一个样式变量const { statusBarHeight } uni.getSystemInfoSync();然后在页面上把 content 区域往下顶一个statusBarHeight的高度。这个方法在 uni-app 里跨端表现一致不要再自己写死一个 44px 或者 64px机型一多必然出问题。4.4 抓包调试Charles 必备技巧小程序联调阶段抓包是每天都要用到的技能。我用的抓包工具是 Charles讲三个最关键的配置照着做基本横着走第一步开启SSL代理。在 Proxy - SSL Proxying Settings 里勾选 Enable SSL Proxying然后在 Location 里添加 Host 为你的测试环境域名Port 填 443。不做这一步抓到的一律是一串乱码。第二步安装证书。电脑端证书直接安装后信任它即可。手机端需要在 WiFi 设置里把 HTTP 代理指向电脑的 IP 和端口 8888然后访问chls.pro/ssl下载证书并安装。这是最常见的卡点很多人忘了手机证书这一环结果只能看到 HTTPS 握手失败。第三步反向代理。微信开发者工具调试时如果小程序请求的是线上域名而你想指到本地环境可以在 Charles 里配置 Map Remote 或者修改 Host 映射。我一般直接在 Charles 的 Tools - Map Remote 里把线上域名映射到本地 IP这样小程序端的代码完全不用改就能直接调本地服务非常方便。抓包时最重要的排查思路是先分前后端。看到请求失败先看 Charles 里请求有没有发出去、返回的是什么状态码、响应体是什么再决定去改前端代码还是查后端日志。我遇到过太多同学一上来就怀疑后端结果抓包一看逆极了前端压根没把参数传对。5. 常见问题与排查技巧实录5.1 小程序请求失败先看这四处这类问题出现的频率极高我按排查顺序列一下现象大概率原因处理方式一直 request:fail域名没配合法域名微信公众平台 - 开发管理 - 服务器域名加白名单请求发送了但返回404路由写错或接口不在当前环境抓包看 URL和后端路由表对照返回500后端报错看 PHP 日志大概率 SQL 或参数问题返回 code 非 0业务逻辑异常看 msg 提示用 Apifox 直接复现这里必须吐槽一句微信小程序的合法域名校验真是太坑了。开发阶段如果你没把域名加入白名单所有请求直接挂Charles 里根本看不到请求发出。所以遇到request:fail先别慌第一件事就是在开发者工具右上角详情 - 本地设置里勾选不校验合法域名等确定逻辑没问题了再去配正式域名。5.2 爬虫采集中文乱码学校老网站很多是 GB2312 编码而 Python 默认处理的是 UTF-8爬下来直接乱码。我的解决办法是请求时就让 Scrapy 知道页面编码def parse_detail(self, response): if response.encoding gb2312: response response.replace(encodingutf-8) title response.xpath(//h1/text()).get()response.replace(encodingutf-8)会重新用 utf-8 解码一次。但如果页面声明的是 GB2312实际编码却是 GBK就会出现部分繁体字的现象这是老站点的祖传毛病。更保险的做法是用charset-normalizer或者ftfy这类库自动检测编码我最后在 Pipeline 里统一转了一遍彻底解决乱码问题。再补一个细节MySQL 建表时表的字符集一定要用utf8mb4。因为utf8在 MySQL 里不是完整的 UTF-8存 emoji 或者某些生僻字会直接报错。学生发的评论里可能就有 emoji你总不能拦着用户不让人家用吧。5.3 列表加载更多出现重复数据这个问题在用户快速上拉时特别明显。aused 是前端loading锁没加好用户手速快第二次onReachBottom在上一次请求还没返回时就触发了导致同一页请求了两次。只要把loading判断放在onReachBottom入口就能解决。还有一个原因是分页数据发生了变更。比如用户在第1页后台刚插入几条新数据第2页的结果里有一部分是之前第1页的数据导致重复。这个其实很难完全避免我的处理方案是前端对item.id做一次过滤重复的就不渲染const seen new Set(); this.list this.list.filter(item { if (seen.has(item.id)) return false; seen.add(item.id); return true; });代码不复杂但能显著提升体验。头条类和资讯类的 App 都有这个问题这是页面滚动加载的固有特性不必过度设计有兜底就行。5.4 缓存穿透热点新闻导致数据库被打爆小程序端的首页如果做5分钟缓存理论上问题不大但有个极端情况某个时间段内大家都在反复刷新首页缓存刚过期一批请求同时打到 MySQL。这属于缓存击穿。我的处理是加一个简单的互斥锁if (!$data Cache::get($key)) { if (Cache::set($lockKey, 1, 5)) { $data $this-getDataFromDb(); Cache::set($key, $data, 300); Cache::delete($lockKey); } else { // 短暂等待后返回旧缓存 $data Cache::get($key, $this-getDataFromDb()); } }用Cache::set($lockKey, 1, 5)模拟一个非阻塞锁只有抢到锁的请求才去查库其他请求等一小会儿再取缓存。这个逻辑看起来简单但没有它突发流量一来数据库连接数瞬间飙满整个 API 都会卡死。后来我再看线上监控这个互斥锁确实救了好几次。5.5 接口安全防护防爬不止是爬虫的事标题里带了爬虫但很多人忽略了防爬这一面。小程序接口本身也会被各种脚本攻击、批量抓取。我的防护策略是三层第一层是 IP 限流。用 ThinkPHP 的中间件对每个 IP 的每分钟请求数做阈值超过直接返回code: 429。思路不复杂就是查 Redis 里该 IP 的计数超过阈值就拒绝。第二层是签名验证。小程序端请求时带一个sign用固定密钥加参数排序后做 MD5。服务端接到请求先校验 sign 是否合法不合法直接拒绝。这样攻击者就算拿到接口地址不能轻易伪造合法请求。第三层是对敏感操作加验证码。比如收藏、点赞、评论这些写操作如果发现异常频次会弹出验证码。这一步其实很少触发但是真遇到刷接口的脚本时能硬生生挡住一多半。这里要再多说一句校园小程序的用户量不大但接口安全性不能因为量小就放松。毕竟只要校园网里任何一个学生起了坏心思脚本一跑就能把你的整个内容库拖走费点心思做防护总比出问题后加班改代码强。6. 最后分享一点个人体会这个项目前后做了差不多两个月回想起来最有价值的不是用了多少框架而是想清楚了一件事技术选型永远要服务于内容生产的可持续性。校园头条的核心不是小程序界面多好看也不是接口写得多优雅而是能不能每天都有新鲜的内容自动进来。爬虫负责把东西搬进来Laravel 负责让搬进来的东西变干净ThinkPHP 负责把干净的东西发出去小程序负责让用户看得舒服这是一条完整的内容流水线。整个链路里最容易出问题的往往是接口对接和数据格式这些看似不起眼的环节。前后端各写各的、格式对不齐、字段对不上这些坑我都踩过。所以我在项目里强制要求接口文档先行、统一响应格式、前后端都用抓包调试来确认请求链路这些习惯看起来麻烦但在联调阶段节省的时间远超付出的那点额外成本。如果你也要做类似的项目我的建议很直接先花几天把数据源梳理清楚并确认合法合规再动手写爬虫爬虫做完了先别急着做小程序先把 API 的接口文档写好用 Apifox 自测通过最后再进入小程序开发这时候你会发现联调流畅到不可思议。另外整个项目部署时爬虫调度、队列消费、缓存清理这些最好都用日志记录下来出问题的时候可以一步步回溯不会两眼一抹黑。这个项目后续还可以扩展的方向比如接入学校一网通办的消息通知、给新闻加评论和点赞的实时推送、做一个运营后台的数据可视化面板这些在现有架构上都是比较自然的延伸有兴趣的话可以参考这个思路继续完善。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑