尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

带爬虫的校园头条小程序:ThinkPHP+Laravel+微信小程序全栈实践

带爬虫的校园头条小程序:ThinkPHP+Laravel+微信小程序全栈实践 校园头条这种项目我在不同阶段见过很多团队做有的用ThinkPHP有的用Laravel最后都会殊途同归地卡在同一个问题上新闻内容从哪来。找第三方内容接口要钱让学生手动录入不现实唯一性价比高的路就是写爬虫去定时抓取所以标题里“带爬虫”三个字才是这个项目的灵魂。这篇文章我会完整拆解一个校园头条新闻小程序从后端框架选型、数据表设计、爬虫采集到小程序端加载更多、动态标题、导航栏适配的落地全过程适合正在做课程设计、毕业设计或者想练手前后端分离小程序开发的读者把每个技术选择背后的原因讲透让你能直接抄作业。1. 项目全景一个校园头条到底在做什么事1.1 需求拆解聚合、展示、阅读一个都不能少做校园头条之前先把用户场景想明白。学校里的新闻信息非常分散官网通知在信息门户学院动态在各学院网站讲座信息可能只出现在辅导员转发的公众号里。学生想了解校园发生了什么需要来回切好几个页面非常折腾。这个小程序要解决的就是把分散在各处的校园新闻抓过来统一分类、统一展示让用户在一个入口里完成全部阅读。拆解下来其实只有三件事内容聚合、信息展示、基础互动。内容聚合靠爬虫定时采集信息展示靠小程序列表页和详情页基础互动做个收藏和分享就够。技术难度都不算高但真正决定项目质量的是内容能不能持续更新。很多同类型项目死在demo阶段就是因为新闻数据是手动塞进数据库的演示完就断更了。所以爬虫不是附加功能而是整个项目持续运转的发动机。有人会问为什么不直接调用校园官网的API实际情况是大部分学校根本不会对外提供新闻接口就算有字段也未必匹配小程序展示的需求。而爬虫方案对数据源的选择更自由今天抓学校官网明天想抓教务处通知只要写一个源配置就行。这也是我后来把采集源做成配置化的重要原因。1.2 技术栈全景与数据流转链路这个项目的整体架构分成三层各司其职采集层、服务层、展示层。采集层用Python写爬虫脚本通过crontab定时执行把各数据源抓回来的新闻清洗、去重后写入MySQL。服务层用PHP框架提供RESTful API小程序端所有页面数据都通过接口获取。展示层就是微信小程序负责列表展示、下拉刷新、上拉加载更多、详情页渲染。这三个层次之间不互相侵入是我踩过坑之后刻意设计的。一开始我把爬虫写成了PHP的CLI脚本和业务代码混在一起结果采集一报错就得去翻业务日志逻辑乱成一团。后来把采集脚本独立成Python项目放在服务器不同目录下和API服务彻底解耦运维清爽太多。数据流就是这样Python定时采集 → 清洗入库 → PHP查询 → JSON输出 → 小程序渲染。一台2核4G的云服务器就能完整跑起来对学生团队来说部署成本非常友好。2. 后端框架选型ThinkPHP和Laravel在项目里怎么搭配2.1 两个框架的核心差异盘点项目标题里同时出现了ThinkPHP和Laravel最初接手的时候我也纠结过到底用哪个我把两个框架在这个项目里最相关的差异列了一遍结论就很清晰了。对比维度ThinkPHP以ThinkPHP 6为例Laravel以Laravel 11为例上手门槛中文文档齐全国内教程多入门曲线低概念较多依赖Composer生态前期学习成本稍高ORM自带Db门面操作直观Eloquent 功能强大关联模型写起来很优雅路由/中间件路由简单直白中间件也存在但用得少中间件体系完善接口鉴权、CORS这些场景很顺手生态国内中小项目多资料好找生态丰富API Resource、队列、缓存等现成组件多社区维护更新节奏尚可长期维护版本节奏稳定如果在团队里选型我常说的一句话是框架只是工具真正决定开发效率的是团队熟悉度。两个框架都能把接口写出来差别在于后续维护的顺畅程度。ThinkPHP对国内学生团队非常友好因为你遇到的几乎每个报错都能百度到中文答案Laravel则更适合想借这个项目系统学习工程化做法的开发者。2.2 我的实践方案API用Laravel管理后台用ThinkPHP这个项目我玩了个组合小程序接口部分用Laravel实现管理后台用ThinkPHP实现。为什么这么干原因很实际。小程序接口需要处理分页参数、统一响应格式、字段映射这些事Laravel的API Resource和中间件写起来非常顺手。管理后台要的是快速开发后台表格、表单ThinkPHP配上一些后台脚手架分分钟就把新闻管理和采集源管理页面搭出来了。两个项目共用同一个MySQL库各自独立部署在服务器不同端口互不干扰。这算是标题里“Thinkphp和Laravel”共存的一种解法也是在一个项目里同时熟悉两套框架的捷径。不过我得给新手提个醒这种双框架组合的前提是你已经能独立部署PHP项目明白两个项目的入口文件和虚拟主机配置逻辑。如果还在学框架基础老老实实选一个框架把前后端全做了反而更稳妥。不要为了炫技把复杂度拉高项目能跑起来并持续更新永远比技术栈多重要。2.3 接口层设计要点统一格式从第一天就定死写小程序接口时最容易出现的乱象是这个接口返回{code: 0, data: []}那个接口返回{status: 1, result: {}}前端调接口写半天适配逻辑。这种事情我在很多协作项目里都遇到过所以做这个项目时我从第一个接口开始就定了统一的响应约定。// app/Support/ApiResponse.php public static function success($data [], string $msg ok) { return response()-json([ code 0, msg $msg, data $data, ]); } public static function error(string $msg error, int $code 1) { return response()-json([ code $code, msg $msg, data new \stdClass(), ]); }所有控制器方法只负责取数据、调业务返回时统一走ApiResponse::success()或ApiResponse::error()。分页数据我会在data里带上total、current_page、last_page、data四个字段小程序端做加载更多时直接依赖这套结构不用再猜字段名。另外接口前缀直接带版本号/api/v1/...以后接口字段改了还可以开v2版本不会把老小程序打挂。这些细节看似不起眼但在项目迭代中最能替你挡掉不必要的麻烦。3. 数据模型与接口设计把新闻存清楚3.1 核心表结构一张新闻表解决80%需求校园头条这种项目表结构不需要太复杂我最终沉淀下来的是四张表新闻主表、分类表、采集源配置表、用户收藏表。新闻主表是核心设计上我做了不少取舍。CREATE TABLE news ( id bigint unsigned NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 新闻标题, summary varchar(500) DEFAULT COMMENT 摘要, content mediumtext COMMENT 正文HTML或纯文本, cover_url varchar(500) DEFAULT COMMENT 封面图地址, category_id int unsigned NOT NULL DEFAULT 0 COMMENT 分类ID, source_id int unsigned NOT NULL DEFAULT 0 COMMENT 采集源ID, source_name varchar(100) NOT NULL DEFAULT COMMENT 来源名称如学校官网/教务处, source_url varchar(500) NOT NULL COMMENT 原文链接用于溯源, title_hash char(32) NOT NULL COMMENT 标题MD5用于去重, publish_time datetime NOT NULL COMMENT 原始发布时间, fetch_time datetime NOT NULL COMMENT 采集时间, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0隐藏, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_title_hash (title_hash), KEY idx_category_status_time (category_id, status, publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT新闻主表;这里几个字段的设计都有讲究。title_hash存的是标题的MD5加唯一索引爬虫入库前先算一次插入时遇到重复直接跳过从数据库层面天然去重。source_url是原文链接一来尊重来源可点击溯源二来出问题时能快速核对原始页面。source_name是冗余字段为的是列表页直接显示来源标签不用每次连表查采集源配置。cover_url我在实际运行中改成了存本地路径后面爬虫部分会讲图片盗链的处理。正文内容用mediumtext而不是text是考虑到新闻详情页可能带多张图片的base64或完整HTML如果不控制采集长度text最多存64KB遇到长文就报错。采集脚本入库前会对正文做一次长度预估超长的直接截断避免撑爆字段。3.2 分类和采集源为什么不建议写死分类表和采集源表是新闻主表的两翼。分类表很简单就是id name sort默认分学校新闻、学院动态、教务通知、讲座活动、校园生活几类。采集源表稍微复杂一点除了源名称和基础URL还存了cron_rule采集频率、last_fetch_time上次采集时间、status启用状态这几个字段。把采集源做成配置而不是代码写死是项目跑了两个月后我体会最深的一点。学校网站的栏目结构偶尔改版代码写死的话每次改版都得动爬虫脚本而配置化之后只需要在管理后台改一下地址或规则。再结合last_fetch_time每次抓取只对比这个时间点之后新增的新闻天然支持增量采集效率比全量抓取高一个量级。3.3 列表和详情接口怎么做才顺手接口设计没有什么炫技的地方关键是贴合小程序端的需求。列表接口我用Laravel的Eloquent做了条件查询按分类筛选、按发布时间倒序、分页返回。// NewsControllerindex public function index(Request $request) { $pageSize min($request-integer(page_size, 10), 20); $list News::query() -when($request-filled(category_id), function ($query) use ($request) { $query-where(category_id, $request-integer(category_id)); }) -where(status, 1) -orderByDesc(publish_time) -paginate($pageSize); return ApiResponse::success($list); }详情接口只做一件事根据id查出一条新闻点击量加一。这里要提醒详情接口返回的字段和列表接口要区分开。列表接口返回摘要不返回正文省流量详情接口才返回完整content。很多新人一把梭把全部字段塞给列表接口结果小程序列表页渲染卡顿、耗流量这种问题在真机调试时尤其明显。另外搜索接口可以复用列表接口加一个keyword参数对标题做模糊搜索就行不用单独写一套接口逻辑。收藏表字段就存id user_id news_id created_at用户维度做收藏列表时join新闻表取标题和封面就够。4. 爬虫模块数据源怎么抓、怎么洗、怎么入库4.1 目标源筛选和合规红线先说清楚爬虫这部分的开发可以很兴奋但有些边界必须先划清楚。我只选择学校官网或二级学院官网这类公开的新闻栏目作为采集源不碰需要登录才能看的内容不采集任何个人隐私信息。每个源我都检查过对方robots.txt并且把请求频率控制在很保守的间隔基本是五分钟到十分钟一轮绝不并发怼别人的服务器。版权和合规问题同样要重视。采集到的新闻我在页面上保留了原文链接和来源名称文章只用于校园内部信息聚合展示不做商业化。运行过程中如果接到任何版权要求对应的采集源我会第一时间下线。做技术归做技术敬畏规则才能让项目活得长久。4.2 采集脚本核心requests XPath从列表到详情Python爬虫的核心代码不长我拆成两个函数一个抓列表页提取标题和链接一个抓详情页提取正文和发布时间。先看列表页部分。import time import hashlib from urllib.parse import urljoin import requests from lxml import etree HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, } def fetch_list_page(source_config): resp requests.get(source_config[list_url], headersHEADERS, timeout15) resp.encoding resp.apparent_encoding html etree.HTML(resp.text) items html.xpath(source_config[list_item_xpath]) news_list [] for item in items: try: title item.xpath(.//a/text())[0].strip() link item.xpath(.//a/href)[0].strip() link urljoin(source_config[list_url], link) news_list.append({title: title, link: link}) except Exception: continue return news_list这里面有两个新手容易翻车的细节。第一个是编码很多学校网站还在用GBK编码直接用resp.text出来全是乱码我用resp.apparent_encoding从headers和内容中自动推断实测下来比硬编码utf-8稳得多。第二个是链接拼接列表页里的href可能是相对路径必须用urljoin拼成绝对地址否则详情页请求直接404。列表页拿到链接后进入详情页解析提取正文、发布时间和封面图。XPath里有个高频坑就是嵌套标签的文本提取。很多页面结构是pstrong标题文字/strong/p如果只用//p/text()会拿到空内容正确姿势是string(.)它会把当前节点下所有子孙节点的文本拼在一起。def fetch_detail_page(url): resp requests.get(url, headersHEADERS, timeout15) resp.encoding resp.apparent_encoding html etree.HTML(resp.text) content html.xpath(string(//div[classv_news_content])).strip() time_text html.xpath(string(//span[classtimes] | //em[classtime])).strip() images html.xpath(//div[classv_news_content]//img/src) # 时间字段用正则再清洗一次 # 图片链接要做绝对化处理并决定是否下载到本地 return {content: content, publish_time: time_text, images: images}写到这里我想补充一个重要经验新闻正文的清洗比抓取更花时间。学校网站正文HTML非常不干净有大量内联样式、空格、广告链接甚至还有nbsp;满天飞的情况。我在入库前做了三层清洗用正则去掉script和style标签、去掉空段落、把不规范的标签闭合修正。清洗后的正文才能在小程序rich-text里正常渲染。4.3 增量入库与去重别让数据库里堆满垃圾入库这一步我用了一个简单的策略对每条新闻算title_hash插入前先SELECT 1 FROM news WHERE title_hash ?存在就跳过。这个策略看起来很笨但它是实测下来性能和数据纯净度最平衡的方案。运行一段时间之后库里的新闻就是干净且无重复的。正文长度控制我在入库前做了截断处理超长正文直接取前50000个字符然后在后面加省略号和原文链接。封面图处理我踩过坑一开始直接存网站的原始图片URL结果小程序端经常403因为学校网站的图片服务器做了防盗链校验只允许特定来源访问。后来我改成用Python把图片下载下来转存到自己的服务器目录数据库里存本地路径彻底解决了图片打不开的问题。4.4 定时任务与日志爬虫也要有巡检机制采集脚本写好后我用crontab跑定时任务每十五分钟执行一次增量抓取*/15 * * * * cd /var/www/spider /usr/bin/python3 run_all_sources.py logs/fetch.log 21日志这块千万别省。每次抓取我都记录了哪些源抓到了几条、哪些URL失败、失败原因是什么。没有日志的爬虫就是在黑灯瞎火开夜车源改版导致抓取为空你可能一星期之后才发现。启动脚本我用run_all_sources.py做了统一调度逐个遍历采集源配置每个源之间休眠20到30秒再开始下一个避免对目标服务器造成压力。单个源连续失败三次后脚本会把源状态标记为异常并在日志里输出告警信息方便管理后台人工确认。4.5 抓包工具在爬虫和小程序联调中的作用这个项目里抓包工具的使用场景其实有两个。第一个场景是分析目标网站的页面结构打开Charles配置好SSL Proxying在浏览器上访问学校新闻页可以直观看到页面加载了哪些资源、请求了哪些接口比直接猜HTML结构高效得多。Charles的常用要点包括电脑和手机连同一个局域网手机WiFi代理指向电脑IP和Charles默认端口8888然后在Charles里开启SSL Proxying并添加需要抓取的域名。第二个场景是小程序联调。开发时小程序请求的API到底有没有正常返回参数有没有传到后端用Charles一看便知。我一直推荐团队里的前端和后端用Charles作为联调工具出现接口报错时先抓包确认是请求没发出去、还是后端报错、还是数据被中间件拦截这能省掉大量无意义的沟通。5. 小程序端核心页面列表加载、动态标题、导航栏适配5.1 列表页与“上拉加载更多”的正确姿势小程序列表页是小程序的入口页面体验做好坏直接影响使用者去留。列表页我用的是最经典的双列瀑布流布局数据一次性请求10条滑动到底部通过onReachBottom加载下一页。这里最关键的避坑点在于防重复请求。onReachBottom在快速翻页时可能连续触发两三次如果不加保护会导致重复请求、列表数据错乱。我的做法是维护一个loading标志位请求未返回前不允许发起下一次请求。Page({ data: { newsList: [], page: 1, lastPage: 1, loading: false, }, loadNews: function() { if (this.data.loading) return; if (this.data.page this.data.lastPage) return; this.setData({ loading: true }); wx.request({ url: ${app.globalData.baseUrl}/api/v1/news, data: { page: this.data.page, page_size: 10 }, success: (res) { const resData res.data.data; this.setData({ newsList: this.data.newsList.concat(resData.data), page: resData.current_page 1, lastPage: resData.last_page, }); }, complete: () { this.setData({ loading: false }); }, }); }, onReachBottom: function() { this.loadNews(); }, });同样长的列表页还要考虑空数据占位、加载失败提示、下拉刷新。小程序enablePullDownRefresh开启后在onPullDownRefresh里重置page并请求第一页数据就好了。真机调试时建议把Network面板打开重点观察分页参数回传是否正常我见过不少项目本地模拟器正常、真机上拉加载就挂了原因大多是参数拼写不一致或接口返回结构改了没同步。5.2 详情页通过id传参用setNavigationBarTitle动态改标题列表页点击某条新闻跳详情页最直接的方式是通过URL传id/pages/detail/detail?id1详情页在onLoad的options.id里取参。这个方案最稳妥不依赖小程序全局数据存放用户从收藏列表跳进来也能正常工作。详情页顶部导航栏默认显示的页面名字是固定的比如“新闻详情”。但用户需求里明确写了“动态设置标题”我第一次实现时还以为是复杂操作其实微信小程序原生就支持wx.setNavigationBarTitle。接口返回后把路由参数里带来的标题或者接口数据里的标题设置成导航栏标题即可。onLoad(options) { const id options.id; this.fetchDetail(id); }, fetchDetail(id) { wx.request({ url: ${app.globalData.baseUrl}/api/v1/news/${id}, success: (res) { const detail res.data.data; wx.setNavigationBarTitle({ title: detail.title }); this.setData({ detail }); }, }); },详情页正文渲染我用了rich-text组件。这里要提醒后端返回的HTML必须保证是标准HTML标签rich-text对不规范的HTML容忍度有限容易渲染变形。我写了一个纯前端的小函数对后端返回的HTML做兜底清洗把空的style属性、非法标签全部剥掉。另外正文里的图片默认是原始大小在手机上可能超出屏幕宽度我会在rich-text外层用CSS限制图片宽度100%或者在返回数据时给图片标签统一加上max-width:100%的内联样式实测后者更可靠。5.3 顶部导航栏高度适配不同机型的坑校园头条小程序的用户覆盖了各种安卓机和iPhone顶部导航栏高度如果不适配自定义按钮就会错位。这个问题搜索热度极高因为它是每个小程序开发者都要过的坎。微信小程序的导航栏由两部分构成状态栏显示时间、电量和导航栏显示标题和操作按钮。不同机型的差异主要在状态栏高度和胶囊按钮位置。如果你是自定义导航栏需要动态计算导航栏高度公式是导航栏高度 (胶囊按钮上边界 - 状态栏高度) * 2 胶囊按钮高度。逻辑是基于胶囊按钮垂直居中于导航栏这个前提推导出来的。const systemInfo wx.getSystemInfoSync(); const menuButtonInfo wx.getMenuButtonBoundingClientRect(); const statusBarHeight systemInfo.statusBarHeight; const navBarHeight (menuButtonInfo.top - statusBarHeight) * 2 menuButtonInfo.height; this.setData({ statusBarHeight, navBarHeight, });这个公式我在几十台不同型号手机上测过误差非常小。如果你对自定义导航栏不熟悉我建议第一版先用微信原生导航栏等基础功能稳定了再考虑自定义导航的沉浸式体验。校园头条类项目核心是阅读导航栏样式不应该占用太多开发周期。6. 常见问题排查与避坑实录6.1 运行时问题速查表我把这个项目从开发到运行阶段遇到的高频问题整理成了速查表你直接对照排查就行。问题现象可能原因解决方案爬虫脚本跑完数据库没有新数据目标网站改版XPath失效编码识别错误打开Charles重新抓取页面结构检查resp.apparent_encoding看日志文件确认筛选结果小程序接口请求返回404路由未定义或URL拼错检查Laravel路由请求地址确认带/api/v1前缀用Charles抓包看实际请求路径新闻详情图片全部加载失败对方网站图片防盗链采集时下载图片到本地服务器数据库存本地路径必要时拼接Referer头请求原图上拉加载更多出现重复数据分页计算错误loading未加锁检查后端返回current_page逻辑前端维护page每次加1补上loading标志位rich-text正文样式变形后端HTML不规范入库前清洗HTML标签前端再做一次标签兜底清洗图片统一加max-width:100%采集到的发布时间格式不一致不同网站时间格式各不同写正则统一提取yyyy-MM-dd HH:mm解析失败时使用当前采集时间兜底PHP请求MySQL报内存超限查询数据量过大或循环中忘释放分页查询正文字段只在详情接口返回采集时控制正文长度6.2 我折腾最久的三个细节第一个是图片防盗链。这个问题排查过程很折磨详情页文字正常图片全挂打开浏览器直接访问图片又能打开。后来抓包才发现服务器返回403是因为请求头里少了Referer或者Referer被校验拒绝。解决方案正如前面说的采集时把图片下载回本地。这里补一句下载图片时请求头要带上原始页面的Referer否则下载本身也会被拦截。第二个是正文中的时间字段处理。不同采集源的发布时间格式五花八门有2025-04-01 10:30也有4月1日还有纯英文格式。我写了一个统一的时间解析函数按格式优先级逐个尝试解析解析不到就用采集时间兜底并打日志。这样列表页按时间排序时数据不会错乱。第三个是Charles抓不到小程序请求。很多人都卡在这一步手机开了代理但小程序请求就是不进Charles。最常见的原因是微信小程序在部分环境下不走系统HTTP代理解决办法是在Charles的SSL Proxying设置中把目标域名明确加上同时手机安装根证书并信任。如果是Android高版本还需要允许用户证书。实在抓不到时还有一个替代方案用小程序开发者工具自带的Network面板也能看到请求基本信息。6.3 缓存策略列表接口必须加缓存新闻数据的特点是读多写少列表接口每次去MySQL全量查询数据库压力小但响应速度不值得浪费。我给列表接口加了一层Redis缓存缓存键按category_id page区分缓存时间设10分钟。爬虫入库后主动清掉首页前几页的缓存保证用户看到的内容基本实时。$cacheKey news_list:{$categoryId}:{$page}; $data Cache::get($cacheKey); if (!$data) { // 走数据库查询 Cache::put($cacheKey, $serializedData, now()-addMinutes(10)); }加了缓存之后首页接口响应从300毫秒左右降到20毫秒以内体感提升非常明显。这里要注意的是不要缓存详情页太久否则新闻更新后用户看到的是旧内容列表页缓存时间短一些是安全的因为新闻发布频率不可能每分钟都有变化。收个尾做这个项目最大的感悟技术上真正难的不是写接口、不是写爬虫、也不是小程序页面难的是让“内容持续更新”这一整条链路稳定跑起来。爬虫写一遍很容易维护它却靠的是配置化源管理、日志巡检、异常兜底这些基本功。所以我把采集源做成了管理后台可配置的把采集脚本独立部署就是为了下次学校网站改版时我能用最小成本修好它。最后再分享一个小技巧采集脚本和PHP服务不要放在同一个进程环境下跑分开目录、分开虚拟环境、各写各的日志线上出问题时排查边界才清晰。希望这篇拆解能帮你把校园头条小程序稳稳当当跑起来少走我走过的那些弯路。
返回列表