书庐开发实战教学

书庐 · 从零开发一本本地图书管理与阅读软件

------写给 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 核心思路

本质是一个关键词打分器:

  1. 给每个大类准备一批"特征词"(如 I 文学 含"小说/散文/诗歌/鲁迅/三体...")。
  2. 把书名、文件名、作者、简介逐一去匹配这些词,命中就加分(书名命中权重最高,简介最低)。
  3. 总分最高的类就是结果;再在它的"子类"里做同样匹配,得到细分。
  4. 全都没命中时,用"体裁后缀兜底"(如书名以"小说/散文/诗集"结尾 → 归 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 核心思路(打分法,不靠"碰运气")

  1. 先看 BOM :文件开头几个特殊字节(如 EF BB BF)明确声明"我是 UTF-8",直接采用。
  2. 试解 UTF-16:看字节里是否大量"每两个字节一个 0",判断是不是 UTF-16。
  3. 统一打分(关键修正) :过去很多实现是"只要字节流形式上能过 UTF-8 校验就当 UTF-8"------但约一半的 GBK 双字节对恰好能蒙混过关 ,于是产生乱码。我们改为:把 UTF-8 和一堆候选编码(gb18030 / big5 / cp1252...)都试一遍,各自解出来后用 score() 打分,选"最像正常中文"的那个。
  4. 乱码检测 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 二进制 交给浏览器原生 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 是把所有模块"串起来"的总指挥。它负责:

  1. 导入 :监听文件选择器 → 逐本 BookMeta.extract() 解析 → 分类 → Store.putBook() 入库 → 渲染书架。
  2. 书架渲染 :分批渲染(PAGE_SIZE=60)+ 封面懒加载(IntersectionObserver)+ 搜索防抖(180ms),解决"浏览卡顿"。
  3. 筛选 / 排序 / 搜索 :filtered() 函数根据左侧导航的 data-filter(all/reading/fav/bad/history/notes/分类)与关键词过滤。
  4. 问题书籍筛选(你最关心的功能):
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);
}
  1. 批量删除 :btnSelect 进入选择模式,书卡显示勾选框;底部 bulkBar 提供"全选当前 / 全不选 / 删除选中 / 退出"。
  2. 检查可读性 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 在内存里造了一个假浏览器。


第十一章 运行、验证与分享

  1. 本地运行 (见第二章):python -m http.server 8321,浏览器开 http://127.0.0.1:8321/index.html。
  2. 验证清单 :
    • 导入示例图书 → 书架出现卡片、分类角标正确;
    • 点书卡 → 详情页元数据无乱码;
    • 点"开始阅读" → 滚屏到底自动续章;切"翻页"可左右翻;
    • 点"检查可读性"→ 乱码书被打标;点左侧"问题书籍"能筛出;
    • 进"批量管理"→ 全选 → 删除选中。
  3. 分享给朋友 :因为零依赖,把整个 library/ 文件夹发给对方,对方用同样的一条 python -m http.server 即可打开(或部署到任意静态托管)。

第十二章 学习路线与下一步

如果你是从零开始的小白,建议按这个顺序动手:

  1. 先跑起来 :按第二章起服务器,导入 示例图书/,感受成品。
  2. 改 CSS:调一调书架配色、卡片大小,建立"HTML+CSS"手感。
  3. 读 store.js / classify.js:这两个最独立、最好懂,建立"模块 + 全局对象"的概念。
  4. 啃 encoding.js:这是含金量最高的部分,弄懂"字节/编码/解码 + 打分法",你以后遇到任何乱码都能自己修。
  5. 读 reader.js:理解"状态 + 事件 + DOM 渲染"的协作。
  6. 跑测试 :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 下来,自己改出第一个属于你的功能。

相关推荐
默_笙1 小时前
🛴 从散件到整机:DeepAgents 与 Agent 身上预留的那些"插槽"(前置介绍)
前端·javascript
一木 之林1 小时前
DeepSeek Agent 开发(一)
开发语言·前端·javascript
樱花落木兰3 小时前
分布式登录实战:Session 会话共享改造,Redis 存储用户登录状态
java·javascript·数据库·redis·分布式·缓存
liangshanbo12154 小时前
面试题:线上 JavaScript 报错如何快速定位到源码?
开发语言·javascript·ecmascript
专业程序开发源6 小时前
django新闻推荐系统70655-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·django·课程设计
acd120096 小时前
前端随笔:数据明明变了,Vue 页面就是不更新
前端·javascript·vue.js
传人7 小时前
悬停旋转放大和位移效果如何写
前端·javascript·css
羲云8 小时前
给 Vue 页面加个 Markdown 编辑器:ME.js 的接入、图片粘贴与音视频
javascript·vue.js
Highcharts.js8 小时前
可视化商用图表库对比,如何选择?
前端·javascript·学习·信息可视化·highcharts·前端可视化