书庐 · 从零开发一本本地图书管理与阅读软件
------写给 Python 小白的网页应用实战教学
本文档基于一个真实、可运行的项目「书庐」整理而成。它不依赖任何后端、不依赖网络、不依赖第三方打包工具,纯前端(HTML + CSS + 原生 JavaScript)实现。
你会学到:如何读取本地文件、如何用《中国图书馆分类法》自动归类、如何彻底干掉中文乱码、如何解析 EPUB / MOBI / TXT / MD / DOCX 等十多种格式、如何写一个"起点中文网式"的滚屏阅读器(到底自动加载下一章、左右翻页),以及如何用自动化测试守护质量。
受众画像:熟悉 Python 基础(变量、函数、列表、字典、
if/for、文件读写),但没写过网页的初学者。文中会用"Python 类比"帮你在已有知识上搭新桥。
第一章 项目全景:我们要造一个什么东西
1.1 痛点
你硬盘里堆了几百本电子书:EPUB、MOBI、AZW3、PDF、Word、TXT、Markdown......它们散落在各个文件夹,文件名五花八门,想找一本要看半天;某些书打开是乱码;想按"文学 / 历史 / 计算机"分类得手动整理;想接着上次读到的地方继续,得自己记页码。
书庐 就是来解决这些的:一个完全在你自己电脑上跑、不联网、不上传任何数据的图书管家 + 阅读器。
1.2 最终长什么样(功能清单)
| 能力 | 说明 |
|---|---|
| 导入 | 选一个文件夹或一个文件,批量读取 |
| 自动分类 | 按《中国图书馆分类法》自动判定大类与细分 |
| 元数据 | 书名、作者、年份、出版社、简介、封面 |
| 可读格式 | EPUB / MOBI / AZW3 / PDF / DOCX / ODT / FB2 / CBZ / TXT / MD / HTML / RTF |
| 阅读器 | 滚屏(到底自动加载下一章)+ 左右翻页两种模式 |
| 续读 | 记录进度,下次直接跳到上次那一章、那个位置 |
| 笔记 / 书签 / 高亮 | 划词即记,可显隐 |
| 乱码治理 | 自动识别编码;一键检查 + 修复;问题书籍筛选 |
| 书架 | 网格/列表视图、按格式筛选、搜索、收藏、排序 |
1.3 技术选型:为什么是"纯前端、零依赖"
- 零依赖 :不引入 React/Vue/打包器,所有库(ZIP 解析、DEFLATE、HUFF 解压)都自己用几百行 JS 实现。好处是整个项目就是一个文件夹,双击或者起个静态服务器就能跑,没有任何安装门槛。
- 纯本地 :数据存在浏览器的 IndexedDB (一种浏览器内置的本地数据库)。类比 Python 里的
sqlite3,只不过它住在浏览器里,不用你装数据库。 - 为什么不用 Python 写 GUI:Python 的桌面 GUI(tkinter/PyQt)分发麻烦、跨平台样式难看;而"网页"人人都有浏览器,渲染文字和排版是浏览器天生的强项------读书软件正需要好排版。
Python 类比 :把整个项目想象成一个 Python 包,里面有
store.py(数据库层)、classify.py(分类算法)、encoding.py(乱码克星)、reader.py(阅读器)。区别只是:这里没有import,而是用<script src=...>把文件按顺序加载进浏览器。
第二章 环境准备:只要一个浏览器
2.1 你需要什么
- 任意现代浏览器(推荐 Chrome / Edge)。
- 不需要 Node.js、不需要 Python 也能看效果;但为了方便"像网站一样"打开并让浏览器记住文件夹权限,建议起一个本地静态服务器。
2.2 起一个本地服务器(一行命令)
Python 自带一个静态服务器,正好用上你的 Python 基础:
bash
# 进入项目里的 library 文件夹
cd library
# 启动一个只监听本机的静态服务器,端口 8321
python -m http.server 8321 --bind 127.0.0.1
然后浏览器打开 http://127.0.0.1:8321/index.html 即可。
为什么不直接双击 index.html? 因为浏览器出于安全,对
file://协议下的"读文件夹""IndexedDB"限制很严。用http://127.0.0.1打开就是"本机网站",权限最全。这也是为什么我们前面强调"零依赖"------你只需要 Python 标准库里这一个命令。
2.3 项目文件结构(职责一览表)
library/
├─ index.html 页面骨架(所有按钮、抽屉、阅读器都在这里"占位")
├─ css/style.css 全部样式(书架、详情、阅读器、弹窗)
└─ js/
├─ inflate.js 原始 DEFLATE 解压(ZIP 内部压缩用)
├─ zip.js 纯 JS 的 ZIP 读取器(EPUB 本质是 ZIP)
├─ encoding.js ★ 编码识别引擎(乱码克星)
├─ huff.js MOBI/AZW3 的 HUFF/CDIC 解压
├─ classify.js 《中国图书馆分类法》自动归类
├─ mobi.js MOBI / AZW3 解析
├─ textdoc.js TXT / Markdown 解析与渲染
├─ formats.js 各格式统一入口(EPUB/HTML/DOCX/FB2/RTF/CBZ...)
├─ meta.js EPUB 元数据 + 目录(nav/ncx) 绑定到章节
├─ store.js IndexedDB 存储层
├─ reader.js ★ 阅读器内核(滚屏/翻页/续读)
└─ app.js 主程序(书架、导入、筛选、批量、检查可读性)
标 ★ 的是本文重点讲解的核心模块。
第三章 页面骨架:一切从 index.html 开始
网页的三驾马车:HTML(结构)+ CSS(外观)+ JS(行为)。HTML 就像 Python 里你先定义好一组"数据容器",JS 之后往里填内容、绑事件。
下面是 index.html 的骨架思路(已精简,保留关键节点):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"> <!-- 告诉浏览器本页面用 UTF-8 解读 -->
<title>书庐 · 本地图书管理与阅读</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app">
<aside class="sidebar"> <!-- 左侧栏:导入按钮 + 分类导航 -->
<button id="btnPickDir">选择图书文件夹</button>
<nav id="nav">
<div data-filter="all">全部图书</div>
<div data-filter="bad">问题书籍</div> <!-- 乱码/不可读筛选入口 -->
...
</nav>
</aside>
<main class="main">
<header class="topbar"> <!-- 顶部:搜索 + 格式筛选 + 排序 -->
<input id="searchInput" placeholder="搜索书名/作者...">
<button id="btnSelect">批量管理</button>
</header>
<div id="shelf" class="shelf grid"></div> <!-- 书架:JS 会把书卡塞进来 -->
<div id="bulkBar" class="bulk-bar hidden"> <!-- 批量操作条(默认隐藏) -->
<button id="btnBulkDel">删除选中</button>
</div>
</main>
</div>
<!-- 详情抽屉:点书卡弹出,展示元数据与编辑表单 -->
<div id="detail" class="drawer hidden"> ... </div>
<!-- 阅读器:默认隐藏,点"开始阅读"才显示 -->
<div id="reader" class="reader hidden"> ... </div>
<!-- 两个隐藏的文件选择器:一个选文件夹、一个选多个文件 -->
<input type="file" id="dirInput" webkitdirectory directory multiple class="hidden">
<input type="file" id="fileInput" multiple class="hidden">
<!-- 所有 JS 按依赖顺序加载(注意顺序!encoding 要在使用它的模块之前) -->
<script src="js/inflate.js"></script>
<script src="js/zip.js"></script>
<script src="js/encoding.js"></script>
...
<script src="js/app.js"></script>
</body>
</html>
小白要点:
id="xxx"是元素的"身份证"。JS 用document.getElementById('shelf')拿到它,就像 Python 里dict['shelf']取值。class="hidden"是 CSS 里.hidden{display:none}的意思------先藏起来,等用到再el.classList.remove('hidden')显示(Python 类比:一个visible=False的控件)。<script>顺序很重要:被依赖的放前面。app.js永远最后,因为它要用到前面所有模块。
第四章 数据落地:IndexedDB 存储层(store.js)
4.1 这个模块解决什么问题
读书软件要"记住"你的书库、阅读进度、笔记。这些数据不能只放在内存里(一刷新就没了),得存到本地。浏览器的本地数据库就是 IndexedDB。
Python 类比 :IndexedDB ≈ 你的
sqlite3数据库;"对象仓库 ObjectStore" ≈ 一张表;keyPath:'id'≈ 表的主键。
4.2 核心代码(精简自 store.js,带注释)
js
// store.js ------ 把"打开数据库 / 读写"封装成 Promise,方便上层 await 调用
(function (global) {
'use strict';
var DB_NAME = 'libread'; // 数据库名
var DB_VER = 2; // 版本号:改结构时 +1 触发升级
// 5 张"表"(对象仓库):书、进度、键值、目录句柄、笔记、书签
var S_BOOK='books', S_PROG='progress', S_KV='kv', S_DIR='dirs', S_NOTE='notes', S_BM='bookmarks';
// 打开数据库(带"升级"逻辑:首次或版本变化时建表、建索引)
function open() {
return new Promise(function (resolve, reject) {
var req = indexedDB.open(DB_NAME, DB_VER);
// onupgradeneeded:数据库结构变更时才会执行(比如第一次创建)
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(S_BOOK)) {
var s = db.createObjectStore(S_BOOK, { keyPath: 'id' }); // 主键 id
s.createIndex('category', 'category'); // 按分类建索引,便于按类查询
s.createIndex('addedAt', 'addedAt'); // 按加入时间索引
}
if (!db.objectStoreNames.contains(S_PROG)) db.createObjectStore(S_PROG, { keyPath: 'id' });
if (!db.objectStoreNames.contains(S_NOTE)) {
var n = db.createObjectStore(S_NOTE, { keyPath: 'id' });
n.createIndex('bookId', 'bookId'); // 按书查笔记
}
// ... 其余表类似
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
// 通用事务执行器:把"开始事务→操作→完成"包成 Promise
function run(store, mode, fn) {
return open().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(store, mode); // mode: 'readonly' | 'readwrite'
var os = tx.objectStore(store);
var out = fn(os); // 调用方在这个回调里做具体操作
tx.oncomplete = function () { resolve(out); }; // 事务提交成功
tx.onerror = function () { reject(tx.error); };
});
});
}
// 对外暴露的 API(小白只需看这一层)
var api = {
putBook: function (b) { return run(S_BOOK, 'readwrite', function (os){ os.put(b); }); },
getBook: function (id) { return run(S_BOOK, 'readonly', function (os){ return os.get(id); }); },
allBooks: function () { return run(S_BOOK, 'readonly', function (os){ return os.getAll(); }); },
delBook: function (id) { /* 删书同时删它的进度/笔记/书签 */ },
// ... 进度、笔记、书签、键值、目录句柄 同理
};
global.Store = api; // 挂到全局,供 app.js 使用
})(window);
4.3 代码逻辑逐行解读
- 立即执行函数
(function(global){ ... })(window):这是纯 JS 里"模块封装"的经典手法。它把内部变量(DB_NAME 等)关在自己作用域里,不污染全局;最后把api挂到window.Store,对外只暴露干净接口。类比 Python 的"只在模块内可见的变量 +return一个公开 API"。 - Promise :JS 里处理异步(数据库操作要时间)的标准方式。类比 Python 的
async/await------这里用.then()链式写法,本质一样:"先做 A,A 好了再做 B"。 - 事务
transaction:IndexedDB 规定所有读写必须在事务里。就像数据库要BEGIN; ... COMMIT;。我们把它包进run(),上层就只需要await Store.allBooks()这么简单。
4.4 小节
- 产出:一个
Store对象,提供putBook/getBook/allBooks/delBook/putProgress...等方法。 - 你可以动手试:在浏览器控制台输入
await Store.allBooks(),看看返回了什么(初始是空数组)。
第五章 自动分类:《中国图书馆分类法》(classify.js)
5.1 这个模块解决什么问题
中国图书馆分类法把图书分成 22 个大类(A 马克思主义→Z 综合性图书)。我们要根据书名/作者/简介,自动猜一本书属于哪一类。
5.2 核心思路
本质是一个关键词打分器:
- 给每个大类准备一批"特征词"(如 I 文学 含"小说/散文/诗歌/鲁迅/三体...")。
- 把书名、文件名、作者、简介逐一去匹配这些词,命中就加分(书名命中权重最高,简介最低)。
- 总分最高的类就是结果;再在它的"子类"里做同样匹配,得到细分。
- 全都没命中时,用"体裁后缀兜底"(如书名以"小说/散文/诗集"结尾 → 归 I 文学),绝不瞎猜。
5.3 核心代码(classify 函数)
js
// classify.js(精简,保留算法骨架)
(function (global) {
'use strict';
// 22 大类,每类含 kw 关键词 与 sub 细分
var CATEGORIES = [
{ code:'I', name:'文学', color:'#8c4a63',
kw:['小说','散文','诗歌','鲁迅','金庸','余华','三体','红楼梦', /* ...上千词 */],
sub:[ {code:'I2',name:'中国文学',kw:['中国文学','鲁迅','金庸']},
{code:'I247',name:'中国现代小说',kw:['小说','长篇','悬疑']} ] },
{ code:'T', name:'工业技术', color:'#5f5f5f',
kw:['计算机','编程','Python','算法','人工智能','前端','Web', /* ... */],
sub:[ {code:'TP',name:'自动化技术、计算机',kw:['计算机','编程','算法']},
{code:'TP312',name:'程序语言',kw:['Python','Java','JavaScript']} ] },
// ... 其余 20 类结构相同
];
// 主分类函数:输入元信息,输出 {code, name, sub, subName, score}
function classify(info) {
var title = (info.title || '').toLowerCase();
var author= (info.author|| '').toLowerCase();
var desc = (info.desc || '').toLowerCase();
var filename=(info.filename||'').toLowerCase();
var best = { code:'', name:'', sub:'', subName:'', score:0 };
CATEGORIES.forEach(function (cat) {
var score = 0, subHit = null, subScore = 0;
// ① 大类关键词打分
(cat.kw||[]).forEach(function (k) {
var kk = k.toLowerCase();
if (title.indexOf(kk) >= 0) score += 5; // 书名命中:权重最高
else if (filename.indexOf(kk)>=0) score += 3; // 文件名次之
else if (author.indexOf(kk)>=0) score += 2; // 作者再次
else if (desc.indexOf(kk) >=0) score += 1; // 简介最低
});
// ② 子类关键词打分(子类权重略高,更精确)
(cat.sub||[]).forEach(function (s) {
var sc = 0;
(s.kw||[]).forEach(function (k) {
var kk = k.toLowerCase();
if (title.indexOf(kk)>=0) sc += 6;
else if (filename.indexOf(kk)>=0)sc += 3;
else if (author.indexOf(kk)>=0) sc += 2;
else if (desc.indexOf(kk)>=0) sc += 1;
});
if (sc > subScore) { subScore = sc; subHit = s; }
});
// ③ 取总分最高者
var total = score + subScore;
if (total > best.score) {
best = { code:cat.code, name:cat.name,
sub: subHit&&subScore>0 ? subHit.code : '',
subName: subHit&&subScore>0 ? subHit.name : '',
score: total };
}
});
// ④ 全未命中:体裁后缀兜底,命中才认
if (!best.score) {
var g = guessByTitle(title, filename);
if (g) best = { code:g.code, name:'...', sub:g.sub, subName:'...', score:1, guess:true };
}
return best;
}
// 兜底:只认书名/文件名的体裁后缀,绝不硬猜
var TITLE_GENRE = [
{ code:'I', sub:'I2', re:/(小说|散文|诗歌|童话|剧本|自传|回忆录)$/ },
{ code:'K', sub:'K2', re:/(通史|断代史|史话|年谱|方志)$/ },
{ code:'T', sub:'TP', re:/(讲义|教程|教材|入门|实战|指南|编程)$/ },
];
function guessByTitle(title, filename) {
for (var i=0;i<TITLE_GENRE.length;i++){
var g = TITLE_GENRE[i];
if (g.re.test(title) || g.re.test(filename)) return g;
}
return null;
}
global.CLC = { categories:CATEGORIES, classify:classify, labelOf:function(c){/*...*/} };
})(window);
5.4 逻辑解读 & 坑
- 大小写归一 :中文虽无大小写,但作者名可能含英文(如"J.K.罗琳")。先
.toLowerCase()再比,避免"Python"和"python"判成两样。 - 为什么书名权重高:因为书名最能代表一本书的主题。
- 兜底不硬猜:最初版本对"完全无关键词"的书直接归到某个类,结果《雪山旧闻》被错分。改成"只认体裁后缀、否则标未分类",更诚实。
第六章 ★ 干掉乱码:编码识别引擎(encoding.js)
这是整个项目最难也最关键的一章。先讲概念,再给代码。
6.1 先搞清三个词(小白必看)
- 字节(byte) :文件在硬盘上的真实样子,是一串
0~255的数字。 - 字符(character):你眼睛看到的"中""A""★"。
- 编码(encoding) :把"字符"变成"字节"的规则;解码是它的逆操作。
同一个"中"字:
- 用 UTF-8 编码 → 3 个字节
E4 B8 AD - 用 GBK 编码 → 2 个字节
D6 D0
乱码的本质:文件是 GBK 字节,你却用 UTF-8 去解,于是每个"字节对"被错误拼成别的字,出来一堆"锘挎暀銆佹槸"------这就是乱码。
Python 类比 :
b'\\xd6\\xd0'.decode('gbk')得到'中';但b'\\xd6\\xd0'.decode('utf-8', errors='replace')就会出乱子。我们的encoding.js干的就是"自动猜该用哪个 decode"。
6.2 核心思路(打分法,不靠"碰运气")
- 先看 BOM :文件开头几个特殊字节(如
EF BB BF)明确声明"我是 UTF-8",直接采用。 - 试解 UTF-16:看字节里是否大量"每两个字节一个 0",判断是不是 UTF-16。
- 统一打分(关键修正) :过去很多实现是"只要字节流形式上能过 UTF-8 校验就当 UTF-8"------但约一半的 GBK 双字节对恰好能蒙混过关 ,于是产生乱码。我们改为:把 UTF-8 和一堆候选编码(gb18030 / big5 / cp1252...)都试一遍,各自解出来后用
score()打分,选"最像正常中文"的那个。 - 乱码检测
looksBad():即使解出来了,也要判断是否"解出来的是一堆生僻乱码字",用于"问题书籍"筛选。
6.3 核心代码(真实可运行片段)
js
// encoding.js(核心函数,略去超长词表)
(function (global) {
'use strict';
// Windows 代码页号 → 解码标签
var CP = { 936:'gbk', 950:'big5', 1252:'windows-1252', 65001:'utf-8', /* ... */ };
// 用"严格模式"试解:任何一个字节不对就返回 null(不兜底)
function tryDecode(u8, label) {
try { return new TextDecoder(label, { fatal: true }).decode(u8); }
catch (e) { return null; }
}
// 用"宽松模式"试解:遇到错字节用替换符代替,保证有结果
function loose(u8, label) {
try { return new TextDecoder(label, { fatal: false }).decode(u8); }
catch (e) { return null; }
}
// 逐字符"可信度"打分:正常中文得正分,乱码产物(生僻/扩展区字)得负分
function score(s) {
var sc = 0;
for (var i=0; i<s.length; ) {
var code = s.codePointAt(i);
if (code === 0xfffd) { sc -= 20; i++; continue; } // 替换符:解码失败,重罚
if (code < 0x20) { sc += (code===9||code===10||code===13)?1:-15; i++; continue; }
if (code === 0x7f || (code>=0x80&&code<=0x9f)) { sc -= 15; i++; continue; }
if (code < 0x80) { sc += 1; i++; continue; } // 英文/数字:微正分
if (COMMON_SET[ch]) { sc += 3; i++; continue; } // 常用汉字:强正信号
if (code>=0x4e00&&code<=0x9fff) { sc += 0.3; i++; continue; } // 普通汉字:弱正分
if (code>=0x3400&&code<=0x4dbf) { sc -= 2.5; i++; continue; } // 扩展A区:乱码高发,罚
if (code>=0x20000) { sc -= 4; i++; continue; } // 扩展B+区:罚更重
if (code>=0xf900&&code<=0xfaff){ sc -= 1.5; i++; continue; } // 兼容汉字区:罚
// ... 日文/韩文/西里尔等给正分(多语言文本也算正常)
sc -= 3; i++;
}
return sc;
}
// 主识别函数
function detect(u8, declared) {
if (!u8 || !u8.length) return { text:'', encoding:'utf-8' };
// ① BOM 优先
if (u8[0]===0xef && u8[1]===0xbb && u8[2]===0xbf)
return { text: loose(u8.subarray(3),'utf-8')||'', encoding:'utf-8' };
// ② UTF-16 BOM
if (u8[0]===0xff && u8[1]===0xfe) return { text:loose(u8.subarray(2),'utf-16le')||'', encoding:'utf-16le' };
// ③ 统一打分:UTF-8 与候选编码一起比,不再"校验过就采用"
var order = [];
if (declared) order.push(declared); // 文件自声明的编码优先试
order.push('utf-8'); // 无声明时 UTF-8 最常见
['gb18030','big5','windows-1252','shift_jis','euc-kr','iso-8859-1'].forEach(function(c){
if (order.indexOf(c)<0) order.push(c);
});
var PRIOR = { 'utf-8':4, 'windows-1252':2 }; // 先验偏好,仅用于打破平手
var best=null, bestScore=-Infinity;
order.forEach(function(label){
var t = tryDecode(u8, label);
if (t==null) return;
var sc = score(t) + (PRIOR[label]||0); // 解码 + 打分
if (sc > bestScore) { bestScore = sc; best = { text:t, encoding:label }; }
});
return best || { text: loose(u8,'utf-8')||'', encoding:'utf-8' };
}
// 判断一段文本是否"像乱码"(用于问题书籍筛选)
function looksBad(s) {
s = String(s||'');
if (s.indexOf('�') >= 0) return true; // 含替换符:解码失败
var cjk=0, susp=0;
for (var i=0;i<s.length;i++){
var c = s.charCodeAt(i);
if (c>=0x4e00 && c<=0x9fff) cjk++; // 正常汉字
else if ((c>=0x3400&&c<=0x4dbf)||(c>=0xf900&&c<=0xfaff)||(c>=0x2e80&&c<=0x2fdf)||c>=0x20000) susp++; // 生僻区
}
// 中文文本里"生僻区字"密集出现 → 极可能是乱码
if (cjk>=3 && susp>=2 && susp >= cjk*0.4) return true;
return false;
}
global.TextCodec = { detect:detect, decode:function(u,d){return detect(u,d).text;},
decodeAs:function(u,l){/*指定编码强制解*/}, looksBad:looksBad, score:score };
})(window);
6.4 逻辑解读 & 经验
score()是灵魂 :正常中文绝大多数是"常用字 + 普通汉字",会给正分;而 GBK 被误当 UTF-8 解出的一堆字,大量落在"扩展 A 区 / 兼容汉字区 / 部首区"------这些区正常中文几乎不会出现,所以给负分。谁的总分高,谁就是正确解码。COMMON_SET:一张"常用汉字查表"("的、一、是、不..."),命中 +3。它是looksBad之外、打分正信号的来源。looksBad()修正教训:最初版本只识别"拉丁误码"(À-ɿ),结果中文乱码(锘挎暀)根本检不出,导致"问题书籍筛选"是空的。改成"看生僻区字是否密集"才真正生效。- 验证方法 :构造一段中文,用 GBK 编码成字节,再喂给
detect------它会返回{encoding:'gb18030', text:'原文'},而不是乱码。
第七章 读懂各种电子书格式
不同格式内部结构天差地别,但我们对上层统一成同一个模型:{ title, author, year, publisher, desc, chapters:[{title,html}], toc:[{label,chapter}] }。各解析器就像"不同的翻译官",把各自格式翻成这份统一简历。
| 格式 | 容器内情 | 解析要点 |
|---|---|---|
| EPUB | 本质是一个 ZIP,里面是 HTML + OPF 清单 | 用 zip.js 解压 → 读 OPF 拿元数据与"脊(spine)"顺序 → 读 nav.xhtml/ncx 拿目录,并把目录项绑定到 spine 章节序号(这是修"点目录跳错"的关键) |
| MOBI / AZW3 | PDB 容器 + PalmDOC/HUFF 压缩 + EXTH 元数据 | 最复杂:先解 PDB 记录,再按"HUFF/CDIC"(KF8 新书)或 PalmDOC(老书)解压正文;EXTH 偏移读错曾导致元数据乱码;记录尾部还有"额外数据标志"要剥掉 |
| TXT / MD | 纯文本 | TXT 用正则 第X章/卷X/楔子/序... 切章节;MD 用自写渲染器转成 HTML(标题/列表/表格/代码块) |
| DOCX | ZIP 里的 word/document.xml |
遍历 <w:r> 文本片段,保留表格/图片引用;长文档只在标签边界切分,避免切断标签导致乱码 |
| 二进制 | 交给浏览器原生 PDF 预览(用 <iframe> 加载),不自己解析 |
|
| FB2 / RTF / HTML / CBZ / ODT | 各自 XML/标记/图片包 | 走通用解析 + 加固 |
这一章不逐格式铺代码(会非常长)。核心要记住一句话:所有格式的解析目标都是产出"统一书籍模型",阅读器只认这个模型,不关心来源。
第八章 ★ 阅读器内核(reader.js)
8.1 两种阅读模式的状态机
- 滚屏模式(scroll) :像起点中文网------把所有章节顺序排成长文,向下滚。滚到接近底部时,自动加载并续接下一章(起点式"下拉加载")。
- 翻页模式(page) :用 CSS 多栏(
column)把正文切成"虚拟页",通过translateX位移来"翻页",左右键/点两侧/滚轮都能翻。
阅读器维护几个关键状态:index(当前章)、loaded(已渲染进 DOM 的章区间)、mode、page(翻页模式下的当前页)。
8.2 自动加载下一章(滚屏的核心)
js
// reader.js 关键逻辑(精简)
Reader.prototype._maybeLoadMore = function () {
if (!this.doc) return;
if (this._loading) return; // 正在加载就跳过,防重复
var next = this.loaded[this.loaded.length-1] + 1; // 下一章序号
if (next >= this.total) return; // 没有下一章了
var nearEnd;
if (this.mode === 'page') {
nearEnd = this.page >= this.pageCount() - 1; // 翻页:已在最后一页
} else {
var c = this.dom.content;
// 滚屏:滚动位置 + 视口高度 接近 内容总高 → 到底了
nearEnd = c.scrollTop + c.clientHeight >= c.scrollHeight - 700;
}
// 兜底:布局量还没算出来(首帧/隐藏)也视为到底,先续上,避免"永远不加载"
if (!nearEnd && this.dom.flow.scrollHeight === 0) nearEnd = true;
if (!nearEnd) return;
this.loadMore(); // 真正去读并渲染下一章(SEGMENT=4 章一批)
};
Reader.prototype.loadMore = async function () {
if (this._loading) return;
this._loading = true;
try {
var start = this.loaded.length ? this.loaded[this.loaded.length-1]+1 : this.index;
for (var k=0; k<SEGMENT && start+k < this.total; k++) {
await this.appendChapter(start+k); // 把第 k 章塞进 DOM
}
if (this.mode === 'page') this.layoutPage(); // 翻页模式要重新算页数
this.updatePos(); // 更新底部进度
} finally { this._loading = false; }
};
逻辑解读:
_maybeLoadMore由"滚动事件"和"翻到末页"触发,是个守门员:只在"快到底"时才加载,避免一次性把整本书塞进内存。SEGMENT=4、MAX_LOADED=60:每次最多续 4 章、DOM 里最多留 60 章,超出把远处的章换成占位符(虚拟列表思路),保证几百章的书也不卡。finally { this._loading=false }:无论成功失败都解锁,防止"加载中卡死"。
8.3 左右翻页
js
Reader.prototype.pageNext = function () {
if (this.mode !== 'page') return;
var total = this.pageCount();
if (this.page < total - 1) this.showPage(this.page + 1); // 本页内翻下一页
else if (this.loaded[this.loaded.length-1] < this.total-1) {
// 已是本批最后一页,但书还没读完 → 先加载更多,再跳到新的最后一页
var self = this;
this.loadMore().then(function(){ self.layoutPage(); self.showPage(self.pageCount()-1); });
}
};
Reader.prototype.showPage = function (n, instant) {
var f = this.dom.flow;
this.page = n;
// 用位移把"第 n 页"挪到视口:translateX(列宽方向偏移)
f.style.transform = 'translateX(' + (this.pagePadding() - n * this.stride()) + 'px)';
this.updatePos();
};
小白理解 :把正文想象成一条很长的"报纸卷",
column让它自动折成多栏;translateX就像用手把报纸向左推,露出下一栏------这就是"翻页"。
8.4 目录与续读
- 目录不跳错 :每一条目录项在建的时候就算好它对应第几章(
chapter索引)。点目录 →gotoToc(i)→ 直接跳到那一章。这是修"点目录和实际内容不匹配"的根本。 - 续读 :
goto()结尾调用saveProgress(),把"第几章 + 滚动比例"存进 IndexedDB;下次打开这本书,loadProgress()还原,直接定位。
第九章 主程序:书架、筛选与批量(app.js 概览)
app.js 是把所有模块"串起来"的总指挥。它负责:
- 导入 :监听文件选择器 → 逐本
BookMeta.extract()解析 → 分类 →Store.putBook()入库 → 渲染书架。 - 书架渲染 :分批渲染(
PAGE_SIZE=60)+ 封面懒加载(IntersectionObserver)+ 搜索防抖(180ms),解决"浏览卡顿"。 - 筛选 / 排序 / 搜索 :
filtered()函数根据左侧导航的data-filter(all/reading/fav/bad/history/notes/分类)与关键词过滤。 - 问题书籍筛选(你最关心的功能):
js
// app.js 中"问题书籍"的判定------不再依赖"必须先点检查"
function isBadBook(b) {
if (b.health === 'unreadable') return true; // 检查过且判定不可读
if (b.health === 'mojibake') return true; // 检查过且判定乱码
// 没检查过时,用元数据直接判定(书名/作者/简介是否像乱码)
return TextCodec.looksBad(b.title) || TextCodec.looksBad(b.author) ||
TextCodec.looksBad(b.desc) || TextCodec.looksBad(b.publisher);
}
- 批量删除 :
btnSelect进入选择模式,书卡显示勾选框;底部bulkBar提供"全选当前 / 全不选 / 删除选中 / 退出"。 - 检查可读性
checkAllReadable():逐本重新extract;解析抛错的 → 标记unreadable并自动从书架移除 (只删书库记录,不动磁盘文件);像乱码的 → 标记mojibake不删。
一次重要教训 :最初判定"不可读"误用了"简介长度"当依据,导致 5 本正常书(HTML/DOCX/MOBI/FB2)被当成不可读自动删掉。修正为"不可读 = 解析抛错",其余一律可读。自动化测试当场抓出了这个 bug。
第十章 用自动化测试守护质量
网页项目最容易"改一处坏一片"。我们用 jsdom(模拟浏览器 DOM)+ fake-indexeddb(模拟数据库) 把整个 app 在 Node 里跑起来,像写 Python 单测一样验证功能。
js
// tools/test-app.js 骨架(示意)
const { JSDOM } = require('jsdom');
const fake = require('fake-indexeddb');
// 1) 用 JSDOM 加载真实的 index.html,并执行里面的 <script>
const dom = await JSDOM.fromFile('index.html', {
runScripts:'dangerously', resources:'usable',
beforeParse(w){ w.indexedDB = fake.indexedDB; /* 注入假数据库 */ }
});
const win = dom.window, doc = win.document;
// 2) 模拟"选了一堆文件"
const input = doc.getElementById('fileInput');
Object.defineProperty(input,'files',{ value: fileList });
input.dispatchEvent(new win.Event('change')); // 触发导入
// 3) 等导入完成,断言书架上有 N 张卡、分类正确、目录能跳转、滚屏能自动加载...
console.assert(doc.querySelectorAll('.card').length === 17, '应导入 17 本');
项目里维护了一组测试:test-zip.js(解压)、test-huff.js(MOBI 解压)、test-encoding.js(23 组编码无乱码)、test-meta.js(元数据+分类)、test-app.js(端到端)、test-health.js(乱码筛选)、test-unreadable.js(不可读自动移除)。每次改动后全跑一遍,全绿才放心------这正是专业开发的"回归测试"习惯。
Python 类比 :相当于你的
pytest用例。区别在于被测对象是"网页",所以用 jsdom 在内存里造了一个假浏览器。
第十一章 运行、验证与分享
- 本地运行 (见第二章):
python -m http.server 8321,浏览器开http://127.0.0.1:8321/index.html。 - 验证清单 :
- 导入示例图书 → 书架出现卡片、分类角标正确;
- 点书卡 → 详情页元数据无乱码;
- 点"开始阅读" → 滚屏到底自动续章;切"翻页"可左右翻;
- 点"检查可读性"→ 乱码书被打标;点左侧"问题书籍"能筛出;
- 进"批量管理"→ 全选 → 删除选中。
- 分享给朋友 :因为零依赖,把整个
library/文件夹发给对方,对方用同样的一条python -m http.server即可打开(或部署到任意静态托管)。
第十二章 学习路线与下一步
如果你是从零开始的小白,建议按这个顺序动手:
- 先跑起来 :按第二章起服务器,导入
示例图书/,感受成品。 - 改 CSS:调一调书架配色、卡片大小,建立"HTML+CSS"手感。
- 读 store.js / classify.js:这两个最独立、最好懂,建立"模块 + 全局对象"的概念。
- 啃 encoding.js:这是含金量最高的部分,弄懂"字节/编码/解码 + 打分法",你以后遇到任何乱码都能自己修。
- 读 reader.js:理解"状态 + 事件 + DOM 渲染"的协作。
- 跑测试 :
node tools/test-encoding.js,看断言怎么写。
可以加的功能(练手):云端同步、标签云、阅读统计图表、听书(TTS)、EPUB 3 有声书、深色模式自动跟随系统。
附录:关键文件速查
| 文件 | 一句话职责 | 难度 |
|---|---|---|
index.html |
所有界面占位 | ★ |
css/style.css |
全部外观 | ★★ |
js/store.js |
IndexedDB 本地数据库封装 | ★★ |
js/classify.js |
中图法自动分类 | ★★ |
js/encoding.js |
编码识别 + 乱码检测 | ★★★★ |
js/mobi.js / huff.js |
MOBI/AZW3 解析与解压 | ★★★★ |
js/textdoc.js |
TXT/MD 解析渲染 | ★★★ |
js/formats.js |
各格式统一入口 | ★★★ |
js/meta.js |
EPUB 元数据与目录绑定 | ★★★ |
js/reader.js |
阅读器内核(滚屏/翻页/续读) | ★★★★ |
js/app.js |
总指挥(导入/书架/筛选/批量/检查) | ★★★★ |
全文完。你已掌握:文件怎么读、分类怎么算、乱码怎么解、阅读器怎么翻页,以及如何用测试守护它们。下一步,就是把这份骨架 clone 下来,自己改出第一个属于你的功能。