资讯详情 自建小说阅读器实战:书源解析、正文清洗与翻页交互设计
📅 2026/10/15 5:06:49
说实话市面上的小说阅读器App我前前后后装了不少结果都逃不开那几件事开屏广告越来越长、书源说崩就崩、想调整行距和背景色却只能在全屏翻页和弹窗之间反复横跳。我日常用手机看网络小说的时间不比刷短视频少与其一直妥协不如自己动手写一个顺手的小说阅读器。这篇文章我会把整个项目的落地过程拆开讲清楚包括书源解析层怎么做、正文清洗逻辑、前端分页和翻页交互、书架缓存设计以及我在实际开发中踩过的各种坑适合有一点Python和前端基础、想做个人小工具练手的朋友参考。1. 为什么我不装现成的阅读App而要自己写一个1.1 现成阅读器最受不了的几个点先别急着写代码说清楚动机很重要因为技术选型完全取决于你要解决什么问题。我自己的使用场景是在碎片时间追几本连载小说偶尔切换设备继续读不希望阅读记录被某个账号体系绑架也不想每次打开App先看30秒广告。现成的阅读App问题很典型一是书源维护成本都在服务端哪天热门书源被处理掉整个App就废了大半二是阅读界面的自定义程度远远不够比如我想要段间距比默认值大、章节过渡页去掉、翻页动画关闭很多App根本不给你这个选项三是本地缓存和云端同步策略不透明我甚至遇到过清理缓存把书架进度一起清掉的惨案。所以自建阅读器最核心的诉求不是“做个完美产品”而是“自己掌控整个阅读链路”。从抓取书源内容到本地存储、从翻页交互到进度记忆每一层代码都是我自己的出问题我能定位想改功能我随时能改。这个掌控感是现成App给不了的。1.2 总体架构怎么设计、选什么技术栈这个项目我拆成了三层数据抓取层、缓存与服务层、前端阅读层。数据抓取层负责从书源站获取目录和正文核心是适配不同网站的结构差异缓存与服务层负责把抓下来的内容持久化避免反复请求前端阅读层负责排版、翻页、设置和阅读进度管理。技术栈我选的是Python FastAPI做后端服务前端用纯HTML/JS/CSS写一个移动端优先的阅读页。没有用Flutter或者原生Android因为我想先跑通一套“后端解析——前端渲染”的模式后续要换壳或者接到不同终端上都很容易。FastAPI的好处是写接口很快还能直接提供静态文件服务一个进程就能把前后端都跑起来调试非常方便。Python后端(FastAPI) ├── 书源规则解析引擎请求网页 - 提取目录/正文 - 清洗 ├── SQLite缓存层书架表 章节表 └── 静态前端阅读页、书架页、设置面板前后端通过几个简单的JSON接口通信获取目录、获取章节正文、保存阅读进度、管理书架。把解析逻辑全部隔离在后端前端就只管展示这个职责划分让后面换UI或者加功能都省事。2. 书源层架构把“找书、抓正文”变成可配置规则2.1 书源规则的数据结构设计写抓取逻辑前我先把所有书源站的页面结构观察了一遍。不同网站差异很大但抽象出来其实就几类信息目录列表的链接选择器、正文内容的选择器、章节标题的选择器以及正文里需要过滤的噪声文本。所以我把每个书源定义成一份JSON规则而不是把解析逻辑写死。{ id: book_source_a, name: 书源A, charset: utf-8, catalog_url_pattern: /list/{book_id}/, catalog_item: ul.chapter-list li a, title_selector: div.reader h1.bookname, content_selector: div#content, content_filters: [最新网址, 推荐阅读, 请记住本站域名], split_pattern: }这里有几个字段需要解释一下。catalog_item是CSS选择器用来从目录页抓出所有章节链接content_selector是正文所在节点content_filters是正文中需要剔除的广告固位文本每家站点的套路字段不太一样把这些字符串收集起来放在规则里比在代码里维护一堆if分支要清晰得多。书源规则让解析层变成了一个通用引擎你给我一份规则JSON我给你解析结果。新增一个书源站只是往规则库加一份配置的事不需要动任何代码。2.2 目录解析拿到全本书的章节列表目录解析是后面的基础因为有章节列表才能做缓存预取和进度计算。我实现了一个通用的目录抓取函数核心逻辑是拿到目录页HTML根据规则里的选择器取出所有章节节点再拼接出完整链接。from fastapi import FastAPI, Query import requests from bs4 import BeautifulSoup import json app FastAPI() HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } RULES load_json(rules.json) def fetch_catalog(book_url: str, rule: dict): resp requests.get(book_url, headersHEADERS, timeout15) resp.encoding rule.get(charset, utf-8) soup BeautifulSoup(resp.text, html.parser) items soup.select(rule[catalog_item]) chapters [] for item in items: href item.get(href) title item.get_text(stripTrue) if not href or not title: continue if href.startswith(/): href book_url.split(/list/)[0] href chapters.append({title: title, url: href}) return chapters app.get(/api/catalog) def api_catalog(book_url: str Query(...)): rule find_rule_for_url(book_url, RULES) chapters fetch_catalog(book_url, rule) return {total: len(chapters), chapters: chapters}注意resp.encoding这一步有些老书源站返回GBK编码requests默认可能识别错手动指定编码能避免目录页乱码。章节链接拼接也需要处理相对路径的情况很多站点目录页里的href是相对路径必须补全成绝对地址才能继续用。find_rule_for_url做的就是根据URL特征匹配对应的书源规则这也暴露了一个自建方案的代价书源结构变了规则需要手动更新。但养成定期更新规则的习惯后这个维护成本其实可以接受。2.3 正文抓取与页面内分页处理正文抓取比目录麻烦一些因为除了解析正文节点还要处理两类特殊情况一类是正文内容被拆成多页需要识别“下一页”按钮并循环抓取另一类是正文节点里混入了脚本、广告节点需要先清理再提取文本。def fetch_content(chapter_url: str, rule: dict) - str: resp requests.get(chapter_url, headersHEADERS, timeout15) resp.encoding rule.get(charset, utf-8) soup BeautifulSoup(resp.text, html.parser) content_node soup.select_one(rule[content_selector]) if content_node is None: return for tag in content_node.select(script, style, .ad, .tips): tag.decompose() text_lines [] for p in content_node.find_all([p, br]): line p.get_text(stripTrue) if line: text_lines.append(line) text \n.join(text_lines) for noise in rule.get(content_filters, []): text text.replace(noise, ) return text我在提取正文时用了find_all([p, br])按行切分而不是直接在节点上拿所有文本因为小说正文天然是分段的按行切分后前端做分页排版时可以直接以段落为单位处理不会被一整块长文本难住。若章节有分页判断依据是目标选择器是否还存在“下一页”链接如果存在就用递归或循环继续抓取下一页再把文本按顺序拼起来。这个判断逻辑得单独封装因为各站的翻页按钮样式不统一我的做法是在规则里多配一个next_page_selector字段抓完当页后检查该选择器是否存在。3. 正文清洗与分页把网页文本变成像样的书页3.1 噪声清理规则和正则过滤解析出来的正文并不干净实际网页里会有很多干扰项站内公告、上一章下一章的链接文字、随机插入的推荐语、还有某些平台自己加的推广段落。这些噪声如果不处理直接扔给前端阅读体验会非常糟糕。我的清洗策略分两层第一层是节点过滤在BeautifulSoup阶段删除脚本、样式、广告容器第二层是文本规则过滤用一个规则列表把常见噪声字符串直接替换成空。此外还要做空行压缩把连续超过两个的换行合并成一个保持分段清晰。import re def clean_text(raw_text: str, rule: dict) - str: text raw_text for noise in rule.get(content_filters, []): text text.replace(noise, ) text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t], , text) return text.strip()文本过滤阶段最需要长期维护。初期用固定字符串替换就够了后来我发现有些广告文案带随机字符固定字符串不顶用就用正则模糊匹配匹配“手机用户请访问”这类固定前缀加通配符。我个人的经验是清洗规则宁可保守也不要过猛过猛容易误删正文里的关键句尤其是某些章节里本来就包含“推荐阅读”这类字眼的情况。3.2 分页算法以段落为单位切分页面拿到清洗后的文本下一步是分页。我一开始图省事按字符数硬切结果就是一行文字被拦腰截断换行位置乱七八糟。后来改成按段落为单位分页设定一个目标字数比如每页600字把段落逐个累积到当前页累积超过目标字数就开新页。function buildPages(contentText, charsPerPage) { const paragraphs contentText.split(\n); const pages []; let pageText ; let pageChars 0; for (const para of paragraphs) { const len para.length; if (pageChars 0 pageChars len charsPerPage) { pages.push(pageText); pageText ; pageChars 0; } pageText para \n; pageChars len; } if (pageText.trim().length 0) { pages.push(pageText); } return pages; }为什么要用段落为单位因为小说阅读的本质是逐段阅读一段文字被劈成两页翻页时视线要来回跳非常难受。牺牲一点分页精度的均匀度完全值得。分页参数我建议做成可调节项因为不同人屏幕大小和阅读习惯不同固定600字就不合理。我在前端设置面板里提供了三档每页约500字、700字、1000字对应密集阅读、舒适阅读和宽屏阅读场景。切换选项后直接重新分页并回到本页开头进度按百分比记录切换后也能定位到正确位置。3.3 章节缓冲策略为什么不要一次性抓全本解析层最容易犯的错误是启动就把整本书的正文全部抓下来。全本书可能几百万字且不说网络请求量大、缓存占空间光是触发书源的访问频率限制就够头疼了。我用的策略是“按需抓取预取下一章”用户打开某章正文时只抓当前章并把紧随其后的3章加入后台预取队列。预取队列串行执行并且每次请求间隔保留1秒以上既保证阅读连续性又不会把站点打得太狠。当前章缓存命中时直接返回本地内容不发起网络请求。这个设计对断网阅读非常友好缓存里的章节照常可以读。4. 前端阅读界面翻页方式、字号护眼与阅读记忆4.1 阅读页的整体结构与状态管理前端虽然只有一个页面但状态管理不能马虎。我把阅读页的状态拆成三个维度书籍状态当前书的基本信息、阅读状态当前章节索引、当前页索引、页内滚动位置、界面状态主题、字号、翻页模式。这三个维度的状态都持久化到localStorage刷新页面后能精确恢复到上次书页。阅读页的DOM结构非常简单我没有用复杂的框架一个滚动容器、一个内容容器、一个底部控制栏就够了。关键在于翻页交互的实现方式这直接决定阅读体验。div idreader div idpage-container div idpage-content/div /div div idreader-topbar span idbook-title/span button idbtn-back书架/button /div div idreader-toolbar button idbtn-prev上一章/button span idpage-info0 / 0/span button idbtn-next下一章/button button idbtn-settings设置/button /div /div4.2 滑动翻页、滚动翻页与仿真翻页的选择翻页方式我做了两种滚动模式和分页模式。滚动模式最直观整章内容纵向排布像网页一样滑动阅读实现成本低且不会丢位置分页模式则把每章切分成多屏左右点击或滑动切换模拟很多阅读App的默认体验。两种模式的核心差异在于内容排布。滚动模式直接渲染一整章内容字号调整只需要重新设置容器样式不会破坏阅读位置分页模式渲染一页内容字号调整后需要重新计算分页并定位到原来的进度百分比处理不好就会跳页。实现左侧点击上一页、右侧点击下一页是最简单的监听点击坐标以容器宽度1/3为界划分点击区域。滑动翻页在分页模式下其实是个伪需求因为滑动操作天然适合滚动模式。所以我把左右滑动留给分页模式上下滑动留给滚动模式交互语义互不干扰。4.3 字号、行距、主题的调节与夜间模式阅读设置里我最在意的是字号和背景。字号直接决定每页字数也就是重新分页的颗粒度背景色则影响长时间阅读的疲劳感。主题我内置了四套白底黑字日间、微黄暖纸护眼、深灰夜间、纯黑OLED。护眼模式并不是简单降低亮度而是调整背景色到接近纸张的米黄色同时把文字对比度略微降低避免纯黑背景上刺眼的白字。字号调节范围放在16px到24px之间步进2px。为什么下限设16px因为很多手机系统默认字号本来就15-16px再小看起来吃力上限24px是考虑到大屏手机的阅读边距更大的字号会显著减少单页信息量翻页频率会高到让人烦躁。设置项统一保存在localStorage的readerSettings对象里每次修改立即生效并重新渲染当前章。重渲染后定位回原章节原进度百分比这是体验上最容易翻车的一环我单独封装了一个restorePosition()函数处理。function restorePosition(percentage) { if (readingMode scroll) { const total contentBox.scrollHeight - clientHeight; contentBox.scrollTop total * percentage; } else { const currentPageIndex Math.floor(currentTotalPages * percentage); goToPage(currentPageIndex); } }4.4 阅读进度记录与跨设备同步进度记录我是按“书 章节索引 章节内百分比”三维度存的。每次翻页、滚动停止时都触发一次保存节流到每3秒写一次localStorage。这样即使App突然被系统杀掉恢复后也能回到最近一次保存的位置。跨设备同步这块如果不想引入繁琐的账号体系最简单的方案是把进度数据导出成JSON文件导入到另一台设备的前端页面里。我在设置面板里加了“导出阅读记录”和“导入阅读记录”两个按钮实际体验下来比登录同步更轻量。5. 书架与本地缓存断网也能继续追更5.1 SQLite表结构和关键字段设计前端书架页面需要展示书名、作者、最近阅读章节、最后阅读时间。这些信息如果全部存在localStorage里数据一多就难维护存在后端SQLite里则更清晰而且未来换客户端也能继续复用。CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT DEFAULT , book_url TEXT UNIQUE, cover_url TEXT, total_chapters INTEGER DEFAULT 0, current_chapter_index INTEGER DEFAULT 0, last_progress REAL DEFAULT 0.0, last_read_time TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, chapter_index INTEGER NOT NULL, title TEXT, url TEXT, content TEXT, fetched_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(book_id, chapter_index) );chapters表用UNIQUE(book_id, chapter_index)做约束保证同一本书的同一章节不会重复存储。添加书架时我先抓目录并写入books表的总章节数再把目录列表写进一个内存缓存等用户真正打开某章时才把正文写入chapters表。这样书架建立速度快数据库也不会因为预写全本内容而膨胀。5.2 章节预取与缓存命中策略缓存命中逻辑和预取逻辑其实是一体两面的。每次打开章节前先查chapters表有没有对应记录有就直接返回数据库内容没有就抓取并写入。预取队列只处理“后续章节且尚未缓存”的章节。我踩过的预取坑是预取请求发多了很容易被封IP。所以预取队列做了严格限速——每两个请求间隔至少1.2秒并且单本书的连续抓取上限是50章超过后暂停预取等阅读进度移动到预取范围之外再继续。这个限速策略让我在连续阅读10小时以上的测试里没有触发反爬稳得很。5.3 书架排序、更新提醒与本地裁剪书架页面我用最后阅读时间倒序排列并在每本书卡片右上角显示“未读章节数”。未读章节数通过total_chapters - current_chapter_index计算这个数字只有在刷新目录时才会更新。我加了一个“检测更新”按钮点击后重新抓取目录对比最新章节数如果有新增章节就提示用户同时更新总数。裁减本地缓存也是一开始容易忽略的点。阅读器跑久了chapters表会积累大量已经读过的旧章节白白占用空间。我在设置里加了“清理已读章节”功能执行一条SQL把chapter_index小于current_chapter_index - 20的记录删除保留最近20章方便回看避免缓存无限膨胀。6. 跑通之后踩过的坑编码、分页正文与移动端适配6.1 编码识别出错导致乱码和抓取失败最典型的坑是站点返回的Content-Type头只写了text/html没写charsetrequests默认猜到ISO-8859-1于是整页文本变成乱码选择器全部匹配失败。修复方法是先尝试用response.encoding response.apparent_encoding自动猜测还是乱码的话就看页面源码里的meta标签把编码显式写到书源规则里。规则里写了charset之后抓取稳定性明显提升。6.2 正文里夹带章节标题或评论区内容有些书源站会把“本章未完点击下一页”这类提示直接放在正文节点末尾还有一些会在每个章节后附加一堆“精彩评论”模块。这些内容如果被前端渲染出来读者会在正文结尾看到一堆不搭边的文字观感非常差。解决思路是在规则里增加一个content_stop_pattern字段内容是正文结束标记的正则抓取时一旦匹配到这个标记后面的内容直接丢弃。比如很多书源站在正文结尾固定有“完”或“本章未完”之类的字符串我就把这些标记配置进规则。这个字段比清洗固定广告串更可靠因为它是从结构上截断而不是试图删除所有噪声。6.3 手机端字号变化导致分页错乱分页模式下系统字体缩放会影响CSS的px像素渲染同一个字号在不同缩放级别下实际显示宽度不同导致同一章在不同设备上调出的分页数量不一致。这个问题在Android设备上尤其明显。我最开始的实现是每次打开章节时重新分页一次结果遇到系统字体较大的用户分页数比预期多出30%翻页频繁不说进度百分比的计算也更粗糙。后来我把阅读页根元素的font-size用px固定并给正文容器设置了text-size-adjust: 100%从根上禁止了系统字体缩放对阅读页的影响。这样分页结果是可预测的不同设备的一致性也有了保障。6.4 章节内容太短或太长时的异常处理有些公众章节只有几十字有些VIP章节长达两三万字。短章节在分页模式下可能只有半页长章节的滚动模式则会让进度百分比跳变非常大。针对短章节我做了“合并独立页”的处理当一页字数不足目标字数的一半时直接把整章作为一页并在页面上居中显示避免出现一个大白底只有几行字的尴尬。针对长章节分页模式正常分页即可但滚动模式要把进度记录的频率提高不然快速刷长章节时百分比跨度会大到无法精确定位。两章之间如果出现空白页也要检查是不是段落切分逻辑出了问题多半是清洗阶段把正文里的段落分隔符全部吞掉了。6.5 个人使用习惯带来的一些额外建议跑通整个阅读器之后我自己又加了不少小功能有些很基础但真提升体验比如书架页支持搜索框过滤书名、正文页支持长按复制选中的句子、导出阅读进度时按书分组并列成文本清单。最推荐的是“章节合并阅读”功能把当前章自动与前后几章连续加载滚动模式下一次能连读三五章翻页断感会大幅降低。回顾整个自建过程最有价值的部分其实不是“做出了一个阅读器”而是“通过阅读器理解了网页数据从抓取、清洗到排版展示的完整链路”。如果你照着这个思路自己动手写不要一上来就追求功能大而全先跑通“一本书的目录——正文——翻页——进度记忆”这条主链路再去加书架、缓存、主题、多书源反而更容易出成果。