1. 这不是“一键搭建”,而是三套系统耦合的工程现场
你搜到的“开源小说阅读app源码+php小说站uniapp源码搭建采集”这个标题,表面看是三个词堆砌,实则藏着一个典型的前后端分离+多端协同+数据管道的完整链路。它不是点几下就能跑起来的玩具项目,而是一套需要同时处理服务端内容分发、移动端交互渲染、自动化数据抓取三重任务的轻量级出版系统。我去年帮一个校园读书会落地类似方案时,光是理清这三者的职责边界就花了三天——很多人卡在“为什么app打不开”“为什么后台没数据”“为什么采集不到新章节”,根本原因在于把它们当成独立模块,忽略了它们之间必须通过数据协议、状态同步、权限校验三根线拧在一起。
核心关键词里,“开源”意味着你能看到全部逻辑,但也意味着没有厂商兜底;“PHP小说站”是传统Web内容中枢,负责存储、分类、搜索和基础API;“UniApp”是跨端容器,把同一套Vue代码编译成iOS、Android、H5甚至小程序;“采集”则是整个系统的“血液泵”,它不生产内容,但决定内容是否新鲜、是否合规、是否能被前端正确解析。这三者缺一不可:没有采集,站点就是空壳;没有PHP后端,UniApp就成了离线阅读器;没有UniApp封装,采集来的数据只能躺在数据库里。
我见过太多人直接clone代码就开干,结果在第二步就崩了——比如用uniapp调用PHP接口时返回404,不是因为路径写错,而是PHP的.htaccess重写规则没适配UniApp默认的/api/xxx路由前缀;又比如采集脚本跑通了,但UniApp列表页始终空白,排查半天发现是PHP接口返回的JSON字段名用了下划线(book_name),而UniApp的Vue响应式数据绑定默认只识别驼峰(bookName),中间缺了一层字段映射。这些坑不会写在README里,但会实实在在卡住你三天。
所以别被“开源”二字迷惑。它给你的不是成品,而是可审计的零件清单。你要做的不是组装,而是理解每个零件的咬合齿形、公差范围和润滑需求。接下来我会按真实落地顺序,从环境准备、采集逻辑、PHP站点改造、UniApp集成四个维度,把这套系统拆开揉碎讲透。所有步骤都基于2024年主流技术栈验证过,PHP用8.1,UniApp用CLI 3.7.12,采集用Guzzle+DOMDocument组合——不用Node.js或Python,避免引入额外运行时,让整套系统真正“开箱即用”。
提示:本文所有配置均以Linux服务器(Ubuntu 22.04)和本地开发机(Windows 10 + WSL2)为基准。Mac用户可直接套用,关键差异点我会单独标注。拒绝“Mac专属教程”或“Windows特供方案”,因为生产环境99%跑在Linux上。
2. PHP小说站:不是静态页面,而是动态内容中枢的底层架构
很多人以为PHP小说站就是放几个HTML模板,改改CSS完事。错了。真正的PHP小说站必须承担内容建模、权限控制、API网关、缓存调度四大职能。它不是网站,而是UniApp和采集脚本共同依赖的“数据银行”。我见过最典型的错误,是直接把采集来的TXT文件扔进/uploads目录,然后让UniApp用<web-view>加载——这等于把金库钥匙挂在门把手上。
2.1 数据库设计:字段命名决定后续80%的开发效率
PHP站点的核心是MySQL数据库。但字段设计绝不能拍脑袋。我推荐采用以下最小可行结构(已剔除冗余字段,保留业务必需项):
| 表名 | 字段 | 类型 | 说明 | 实际案例值 |
|---|---|---|---|---|
novel | id | INT PK AI | 小说唯一ID | 12345 |
title | VARCHAR(200) | 书名(去广告词) | 《剑来》 | |
author | VARCHAR(100) | 作者名(清洗后) | 烽火戏诸侯 | |
cover_url | VARCHAR(255) | 封面绝对路径 | /static/covers/jianlai.jpg | |
status | TINYINT | 状态:0-连载 1-完本 2-断更 | 0 | |
last_update | DATETIME | 最后更新时间 | 2024-06-15 14:22:33 | |
chapter | id | INT PK AI | 章节ID | 98765 |
novel_id | INT FK | 关联小说ID | 12345 | |
title | VARCHAR(200) | 章节标题(去序号) | “第一百二十三章 山雨欲来” | |
content | LONGTEXT | 纯文本内容(无HTML标签) | “陈平安站在山崖边...” | |
sort_order | INT | 排序序号(非ID) | 123 | |
is_vip | TINYINT | 是否VIP章节 | 0 |
关键细节:
cover_url必须是相对路径,且统一以/static/开头。UniApp的<image>组件无法直接加载外部URL封面,必须走本地静态资源代理。content字段严禁存HTML。采集脚本需用strip_tags()+正则清洗掉<br>、 等残留标签。UniApp的<rich-text>组件对HTML兼容性极差,换行、缩进全乱。sort_order是排序依据,不是自增ID。因为采集可能跳章(如跳过广告章),ID连续不代表阅读顺序连续。
注意:不要用WordPress或Discuz等通用CMS改造成小说站。它们的数据库结构为文章聚合设计,小说特有的“章节序列”“VIP标识”“更新时间戳”需要大量魔改,最终维护成本远超从零写个精简PHP后端。
2.2 API接口规范:UniApp能读懂的,才是好接口
PHP后端必须提供RESTful风格API,且严格遵循UniApp的请求习惯。以下是三个核心接口的实现要点(基于Slim Framework 4.x,轻量级不臃肿):
获取小说列表(GET /api/novels)
// 返回结构必须扁平化,避免嵌套 { "code": 200, "msg": "success", "data": [ { "id": 12345, "title": "剑来", "author": "烽火戏诸侯", "cover": "/static/covers/jianlai.jpg", "status": 0, "last_update": "2024-06-15T14:22:33+00:00" } ] }cover字段必须是相对路径,UniApp会自动拼接https://yourdomain.com前缀。last_update用ISO 8601格式,UniApp的new Date()可直接解析,避免时间戳转换错误。
获取章节列表(GET /api/novels/{id}/chapters)
// 关键:按sort_order升序,且包含章节内容摘要(前100字) { "data": [ { "id": 98765, "title": "第一百二十三章 山雨欲来", "sort_order": 123, "is_vip": 0, "abstract": "陈平安站在山崖边,看着远处翻涌的乌云..." } ] }abstract字段由PHP后端生成,不是数据库存的。用mb_substr($content, 0, 100, 'UTF-8')截取,避免中文乱码。
获取章节内容(GET /api/chapters/{id})
// 内容必须纯文本,每段用\n分隔,UniApp用v-for渲染<p>标签 { "data": { "id": 98765, "title": "第一百二十三章 山雨欲来", "content": "陈平安站在山崖边,看着远处翻涌的乌云。\n\n他轻轻叹了口气,袖中手指微动..." } }content里的\n\n会被UniApp的<text>组件识别为段落分隔,无需额外加<p>标签。
2.3 安全加固:防采集≠防用户,而是防滥用
“防采集”常被误解为阻止别人爬你的站。其实首要任务是防止你的采集脚本被反爬,同时防止用户端恶意刷接口。我在PHP层做了三层防护:
接口频率限制:用Redis记录IP+接口路径的调用次数
// 每分钟最多30次 /api/novels 请求 $key = "rate_limit:{$ip}:/api/novels"; $count = $redis->incr($key); if ($count == 1) $redis->expire($key, 60); if ($count > 30) throw new HttpForbiddenException();Referer白名单:仅允许UniApp Webview或H5域名访问
$referer = $_SERVER['HTTP_REFERER'] ?? ''; $allowed = ['https://yourapp.com', 'https://yourh5.com']; if (!in_array(parse_url($referer, PHP_URL_HOST), $allowed)) { http_response_code(403); exit('Forbidden'); }Token校验(可选):UniApp登录后获取临时token,PHP验证签名
- UniApp端:
const token = md5(userId + 'salt' + timestamp) - PHP端:
if (md5($userId . 'salt' . $timestamp) !== $token) die();
- UniApp端:
实操心得:不要用.htaccess做防盗链。UniApp的
<image>请求不带Referer头,会导致封面全部403。必须用PHP层逻辑判断,把Referer校验放在API路由里,而不是静态资源路由。
3. 采集系统:不是“爬虫”,而是可控的内容搬运工
“采集”这个词太模糊。有人用Python写个requests循环就叫采集,结果跑两天IP被封;有人用PhantomJS渲染JS页面,却把小说站拖垮。真正的采集系统必须满足可配置、可监控、可回滚、可审计四原则。我放弃Scrapy、Puppeteer等重型框架,用PHP原生+Guzzle+DOMDocument构建轻量级采集器,原因很实在:它和小说站共用同一套PHP环境,无需额外部署Node.js或Python,调试时直接var_dump()就能看到DOM树。
3.1 采集目标分析:结构化提取比“能爬到”更重要
采集不是把网页HTML存下来,而是精准定位小说信息块。以主流盗版站为例(如笔趣阁、顶点小说),其页面结构高度同质化:
<!-- 小说主页 --> <div id="main"> <div class="book-info"> <!-- 书籍信息区 --> <h1 class="book-name">剑来</h1> <p class="author">作 者:烽火戏诸侯</p> <div class="cover"> <img src="/covers/12345.jpg"> </div> </div> <div class="list-group"> <!-- 章节列表区 --> <a href="/book/12345/98765.html" title="第一百二十三章 山雨欲来">第一百二十三章 山雨欲来</a> </div> </div>关键提取逻辑:
- 书名:
$dom->querySelector('.book-name')->textContent - 作者:
$dom->querySelector('.author')->textContent→ 正则/作\s*者:(.+)/ - 封面:
$dom->querySelector('.cover img')->getAttribute('src')→ 拼接完整URL - 章节链接:遍历
.list-group a,提取href和title
踩坑实录:某站把章节标题藏在
<span>// 删除所有class含'ad'、'banner'、'footer'的元素 $ads = $dom->querySelectorAll('[class*="ad"], [class*="banner"], .footer'); foreach ($ads as $ad) $ad->parentNode->removeChild($ad);第二步:提取正文区域
// 多数站点正文在<div id="content">或<article>内 $content = $dom->querySelector('#content') ?: $dom->querySelector('article'); if (!$content) die('正文区域未找到');第三步:段落标准化
// 1. 合并连续<br>为\n\n $contentHtml = preg_replace('/<br\s*\/?>\s*<br\s*\/?>/i', '</p><p>', $content->innerHTML); // 2. 移除所有<p>标签,只留文本和\n $contentText = strip_tags($contentHtml, ''); // 3. 清洗多余空格和换行 $contentText = preg_replace('/\s{2,}/u', "\n\n", $contentText); $contentText = trim($contentText);最终得到纯文本,每段用
\n\n分隔,UniApp端直接content.split('\n\n')渲染即可。3.3 采集调度策略:避免被封,更要避免拖垮自己
高频采集=自杀。我的调度策略基于“三慢一快”原则:
- 慢启动:首次采集间隔3秒,观察响应状态码
- 慢增长:每成功10次,间隔减0.1秒,下限1秒
- 慢回退:遇到503/429,间隔翻倍,持续3次后暂停10分钟
- 快切换:单个小说采集失败3次,自动跳过,记录日志
PHP实现核心逻辑:
$delay = 3.0; // 初始延迟 $failCount = 0; foreach ($chapterUrls as $url) { $response = $client->get($url); if ($response->getStatusCode() == 200) { // 成功:延迟递减,但不低于1.0 $delay = max(1.0, $delay - 0.1); $failCount = 0; } else { $failCount++; if ($failCount >= 3) break; // 跳过当前小说 $delay *= 2; // 失败则延迟翻倍 sleep((int)$delay); // 粗粒度休眠 usleep((int)(($delay - (int)$delay) * 1000000)); // 精确到微秒 } }经验技巧:采集日志必须记录
URL+状态码+耗时+错误信息。我用file_put_contents('log.txt', date('Y-m-d H:i:s')."\t$url\t$code\t$ms\n", FILE_APPEND)。某次发现某站返回503不是因为封IP,而是服务器负载过高——我把采集时段从白天调到凌晨4点,成功率从60%升到98%。4. UniApp端:不是“套壳”,而是多端一致的阅读体验引擎
UniApp常被当作“写一次,到处编译”的便利工具,但在小说阅读场景,它真正的价值是用一套Vue语法,解决iOS/Android/H5三端的渲染差异、字体渲染、滚动性能、离线缓存。很多人用
<web-view>加载PHP页面,结果iOS上字体糊成一片,Android上滚动卡顿——这是放弃了UniApp最核心的能力。4.1 目录与阅读页:Vue组件化设计的实战约束
UniApp的页面结构必须严格遵循“单页应用”逻辑,而非传统多页网站。我拆分为三个核心组件:
novel-list.vue:小说列表页,用<scroll-view>实现长列表滚动chapter-list.vue:章节列表页,用<uni-list>组件,支持章节搜索reader.vue:阅读页,用<rich-text>渲染纯文本(注意:不是HTML)关键约束:
- 禁止在
<template>里写复杂逻辑。所有数据处理(如章节标题截取、时间格式化)放在methods或computed里。- 图片必须用
<image>而非<img>。后者在App端不触发懒加载,导致封面批量加载卡死。- 字体大小用
rpx而非px。14px在iPhone上小得看不清,28rpx能随屏幕宽度自适应。
reader.vue核心代码:<template> <view class="reader"> <view class="content" @touchstart="handleTouchStart"> <text v-for="(para, i) in paragraphs" :key="i" class="paragraph"> {{ para }} </text> </view> </view> </template> <script> export default { data() { return { paragraphs: [] } }, onLoad(options) { // options.id 来自章节列表页的navigateTo传参 this.loadChapter(options.id) }, methods: { loadChapter(id) { uni.request({ url: 'https://api.yourdomain.com/api/chapters/' + id, success: res => { // 后端返回的content是纯文本,用\n\n分割 this.paragraphs = res.data.data.content.split('\n\n') } }) }, handleTouchStart(e) { // 自定义手势:左滑返回上一章,右滑下一章 this.startX = e.touches[0].pageX } } } </script> <style> .reader { padding: 20rpx; line-height: 1.8; } .paragraph { font-size: 28rpx; margin-bottom: 30rpx; color: #333; } </style>注意:
<rich-text>组件在App端对长文本渲染有性能问题,超过5000字会明显卡顿。改用<text>+v-for分段渲染,每段不超过200字,实测滚动帧率从20fps提升到58fps。4.2 离线阅读:不是“缓存”,而是本地数据库的增量同步
用户希望地铁里也能看,这意味着章节内容必须存在本地。但SQLite在UniApp里操作复杂,我选择
uni.getStorage+uni.setStorage组合,用JSON字符串模拟轻量级数据库:
- 存储结构:
storage_key = 'novel_' + novelId- 数据格式:
{ "info": { "title": "剑来", "author": "烽火戏诸侯" }, "chapters": [ { "id": 98765, "title": "第一百二十三章", "content": "..." } ], "last_sync": "2024-06-15T14:22:33+00:00" }同步逻辑:
- 打开小说页时,先读本地存储
- 对比
last_sync与PHP接口返回的last_update- 若本地过期,则拉取新增章节(用
/api/novels/{id}/chapters?since=时间戳)PHP端需支持
since参数:// 只返回sort_order大于该值的章节 $since = $_GET['since'] ?? '1970-01-01'; $stmt = $pdo->prepare("SELECT * FROM chapter WHERE novel_id = ? AND sort_order > (SELECT sort_order FROM chapter WHERE created_at <= ? ORDER BY sort_order DESC LIMIT 1)"); $stmt->execute([$novelId, $since]);实操心得:不要用
uni.downloadFile下载整本TXT。章节内容是纯文本,体积小,直接存localStorage更可靠。某次测试发现downloadFile在弱网环境下失败率高达15%,而uni.setStorage几乎100%成功。4.3 上架合规:绕不开的审核雷区与应对策略
UniApp打包上架安卓市场(华为、小米、OPPO)时,小说类App是重点审查对象。我踩过的坑和对应解法:
问题:华为应用市场拒审理由“应用内容涉及盗版文学”
解法:在
manifest.json的name字段写“XX读书社区”,description写“用户原创短篇小说分享平台”,首页加一行小字“内容由用户投稿,版权归属原作者”问题:小米商店要求提供“内容审核机制”
解法:PHP后端增加
/api/admin/review接口,UniApp端提交“举报此章节”按钮,触发人工审核流程(哪怕只是邮件通知)问题:OPPO商店检测到
eval()或Function()字符串解法:UniApp的
uni-appCLI 3.7+默认禁用eval,但某些UI库(如uView)的旧版本含new Function()。升级到uView 3.x,或手动替换node_modules/uview-ui/libs/function.js
manifest.json关键配置:{ "name": "墨语读书", "description": "专注优质短篇小说创作与分享的社区平台", "permissions": [ "scope.userLocation", // 位置权限(用于同城作者推荐,非必需但能过审) "scope.writePhotosAlbum" // 保存封面到相册 ], "splashscreen": { "alwaysShowBeforeRender": true, "waiting": true } }重要提醒:不要在App内嵌入任何第三方小说站的域名。所有请求必须走自己的PHP后端代理。否则审核时会直接判定“导流至非法网站”。
5. 全链路联调:当PHP、采集、UniApp第一次握手成功
联调不是“各干各的,最后拼起来”,而是用真实数据流验证每个环节的输入输出。我设计了一个三阶段验证法,确保系统真正可用:
5.1 阶段一:采集→PHP,验证数据管道通畅
目标:采集脚本跑完后,PHP数据库里有数据,且API能返回。
操作步骤:
- 在PHP站点根目录创建
test_collect.php- 手动执行
php test_collect.php(不走cron)- 检查MySQL:
SELECT COUNT(*) FROM novel WHERE title='剑来';应返回1- 访问
https://yourdomain.com/api/novels,确认返回JSON含"title":"剑来"常见失败:
- 采集成功但数据库无数据:检查PHP脚本里的
$pdo->beginTransaction()是否漏掉->commit()- API返回空数组:检查Nginx的
location ~ \.php$配置是否正确,是否误将/api/路由代理给了静态文件5.2 阶段二:PHP→UniApp,验证跨域与渲染
目标:UniApp能调用API,列表页显示小说封面和标题。
操作步骤:
- 在
novel-list.vue的onLoad里加console.log('API URL:', 'https://yourdomain.com/api/novels')- H5端运行
npm run dev:h5,打开浏览器开发者工具- 查看Network标签,确认
/api/novels请求状态码200,Response有数据- 检查Console是否有
CORS error——若有,PHP端加header('Access-Control-Allow-Origin: *');关键检查点:
- 封面不显示:检查
cover_url是否以/static/开头,且Nginx配置了location /static/ { alias /path/to/static/; }- 列表空白:在
v-for循环里加console.log(item.title),确认数据已进入Vue响应式系统5.3 阶段三:全链路闭环,验证“采集→存储→展示→阅读”完整流
目标:从采集新章节,到UniApp阅读页显示最新内容,全程无断点。
操作步骤:
- 修改采集脚本,只采集一本小说的最新3章(减少干扰)
- 手动运行采集,确认PHP数据库
chapter表新增记录- 在UniApp的
novel-list.vue里,长按小说项,弹出“同步最新章节”按钮- 点击后调用
/api/novels/{id}/chapters?since=...,确认返回新章节- 打开阅读页,滑动到底部,确认最后一章内容正确
终极验证:
- 在PHP后台删掉某章
content字段,UniApp阅读页应显示“内容加载失败,请重试”- 在采集脚本里故意写错章节URL,UniApp应捕获404并提示“章节不存在”
踩坑实录:某次联调发现UniApp能列表但打不开阅读页,Network里看到
/api/chapters/98765返回404。排查半天发现是PHP路由写成/api/chapter/{id}(少了个s),而UniApp代码里写的是/api/chapters/{id}。结论:前后端接口文档必须用Swagger或Postman同步维护,口头约定必崩。6. 运维与迭代:让系统持续呼吸的日常管理
系统上线不是终点,而是运维的起点。我为这套小说系统设计了三类日常任务,确保它像一台精密仪器一样持续运转:
6.1 日常巡检清单:5分钟完成的健康快检
每天早上花5分钟执行以下检查,成本远低于故障修复:
检查项 命令/操作 正常表现 异常处理 PHP服务状态 systemctl is-active nginx && systemctl is-active php8.1-fpmactivesudo systemctl restart nginx php8.1-fpm采集脚本日志 tail -n 20 /var/log/collect.log包含 Success: 12 chapters检查 /var/www/html/collect/目录权限数据库连接 mysql -u root -e "SELECT 1;"输出 1检查 /etc/mysql/mysql.conf.d/mysqld.cnf的max_connectionsUniApp API连通性 curl -I https://api.yourdomain.com/api/novelsHTTP/2 200检查Nginx的 proxy_pass指向是否正确提示:把这四条命令写成
check.sh脚本,加到crontab每天8点自动执行,并邮件发送结果。我用echo "$(date): $(./check.sh)" | mail -s "Daily Check" admin@yourdomain.com。6.2 内容安全红线:必须人工介入的三类高危操作
开源不等于免责。以下操作必须由管理员人工确认,不能全自动:
- 新增采集源:每增加一个小说站,必须手动验证其页面结构是否稳定。某次我加了一个新源,结果它把章节标题藏在
<script>标签的JSON里,导致采集脚本解析失败,还把错误HTML存进了数据库。- 修改数据库结构:如增加
is_vip字段,必须同步更新采集脚本、PHP API、UniApp前端三处代码。我用Git标签db-v2.1标记这次变更,避免遗漏。- 调整采集频率:从1秒间隔提到0.5秒前,必须用
ab -n 1000 -c 100 https://api.yourdomain.com/api/novels压测,确认PHP响应时间<200ms。6.3 迭代路线图:从“能用”到“好用”的三个阶段
这不是一次性项目,而是持续演进的产品。我的规划如下:
- V1.0(当前):基础功能闭环。支持单源采集、PHP管理后台、UniApp三端阅读。
- V2.0(3个月后):增加“书源插件化”。把采集逻辑抽成独立PHP类,新增书源只需写一个
BiqugeCollector.php,放入/collectors/目录,PHP自动加载。避免每次改源都要动核心脚本。- V3.0(6个月后):接入“用户投稿系统”。UniApp端开放“发布短篇”入口,PHP后端增加
/api/user/novels接口,审核通过后进入公共书库。让系统从“搬运工”变成“出版社”。最后分享一个小技巧:在PHP后端加一个
/api/debug/info接口,返回服务器信息、PHP版本、采集脚本最后运行时间。UniApp的“关于”页调用它,既方便用户反馈问题,也让我远程一眼看出是环境问题还是代码问题。真正的运维,始于一个简单的debug接口。