☰
Node.js鉴权分块下载器:EPUB/PDF离线备份与断点续传
2026/10/29 22:01:16 网站建设 项目流程

简介:vitalsource-dl 是一套面向电子书收藏者与 Node.js 开发者的 VitalSource EPUB 下载工具,主要解决在浏览器认证后按数字 ISBN 定位电子书并取回本地 EPUB 文件的需求,适合希望研究下载器实现、整理自有电子书或进行网络请求练手的用户。压缩包共 8 个文件、约 110KB,以 2 个 JavaScript 脚本承担主流程与限速逻辑,2 个 JSON 文件描述依赖及版本锁定,另配 README 说明、许可证、示例截图和忽略配置;结构轻量、依赖清晰,主流程与辅助模块分层放置,便于快速了解项目组织与扩展点。已有 1747 人学习下载,说明该工具在同类需求中具备一定参考价值。读者可从中获得一套可运行的 Node.js 下载脚本、基于 Cookie 与 ISBN 参数化的实现思路、speed-limiter 限速处理方式、依赖管理示例以及 README 中的配置说明,适合按需二次调整或作为文件下载类小项目研读。

1. 从断网高铁上翻教材说起

有人问过我一个很实在的问题:账号里有一堆教材的在线阅读权限,能不能提前拉到本地,出差路上没网也能翻。这个问题背后就是一类工具要解决的事。vitalsource-dl 是一个 Node.js 写的命令行下载器,给它书籍标识和一份有效的登录凭据,它把已经在你账号下的电子书资源拉成本地的 EPUB 或 PDF。适合两类人:已经购买或订阅了内容、只想做个人离线备份的读者;以及想研究「带鉴权的分块下载器」怎么写的 Node.js 开发者——里面的会话管理、并发控制、断点续传、容器重组,都是通用技能。先把边界说清楚:

提示:这类工具只处理你自己账号里已有访问权限的内容,用于个人离线阅读备份。能下什么取决于你账号本来有什么,不解决版权归属,也不做二次分发。

2. 拆开下载链路:从登录态到资源清单的四步模型

一个带鉴权的电子书下载器,本质上不是「爬」,而是「按你自己的权限把服务端已经愿意给你的东西完整取回来」。整条链路可以拆成四步:拿到可复用的会话凭据 → 用凭据换取书籍元数据 → 解析出这本书由哪些资源文件组成(章节 XHTML、样式、图片、字体,或者整本 PDF 的分块)→ 按清单逐个拉取并做本地重组。四步里最容易翻车的是第一步和第三步:凭据会过期,清单结构会随平台前端改版而变。所以写这类 Node.js 下载器,我一般先把「抓包确认请求长什么样」当成第一件正事,而不是先写代码。用浏览器开发者工具打开目标页面,切到 Network,勾选 Preserve log,翻几页书,你会看到三类请求:登录/续期接口、书籍信息接口、资源文件请求。把这三类请求的 URL 形态、必需的请求头、返回体结构抄下来,后续所有代码都是围绕它们做参数化。

2.1 Node.js 运行环境与目录约定

先定环境。Node 18 起步,因为要直接用内置的fetch和AbortController,省掉一堆 HTTP 库的兼容问题;Node 20 LTS 更省心,fetch的行为和流处理都成熟了。不要用很老的 Node 16 去跑,fetch还得靠 polyfill,出问题排查起来纯属给自己加戏。目录上我习惯把「凭据缓存」「临时分块」「成品」三处分开放,避免半截文件混进成品目录,后面做校验时你会感谢这个决定。

# 目录骨架:缓存、临时、成品三层分离 mkdir -p vitalsource-dl/{.cache,tmp,out} cd vitalsource-dl npm init -y # 只装真正需要的:epub 重组用 archiver,命令行解析用 commander npm i archiver commander

npm init -y生成默认package.json,记得把"type": "module"加进去,后面代码统一用 ESM 的import;不加的话import语句会直接报语法错,这是新手第一个卡点。.cache放登录态,tmp放下载中的.part文件,out只放校验通过的成品。这种三层分离的好处是:任务中断后你能明确知道哪些东西可以放心删、哪些需要续传、哪些已经是成品。参数上没什么可调的,唯一建议是给tmp和out留足磁盘空间——带图教材的 EPUB 单本几百兆很常见,PDF 扫描版上 G 也不稀奇。

2.2 会话凭据怎么拿到、怎么存

登录态是整个下载器的命门。绝大多数人第一次写这类工具,都是把 cookie 硬编码在脚本里,跑两天发现 401,然后手动改一次,如此反复。血泪经验是:把凭据抽成独立模块,带过期判断,落盘时收紧文件权限。凭据本身从浏览器里拿最直接——登录后从开发者工具的 Application → Cookies 里复制,或者从某个需要鉴权的接口请求头里的Authorization复制。别把整段 cookie 打进日志,日志是会被分享出去的。

// session.js —— 登录态落盘,带过期判断,权限收紧到仅本人可读 import fs from 'node:fs/promises'; import path from 'node:path'; const SESSION_FILE = path.resolve('.cache/session.json'); export async function saveSession(payload) { await fs.mkdir(path.dirname(SESSION_FILE), { recursive: true }); // 只保留必要字段,避免把整包响应原样写盘 const slim = { cookie: payload.cookie, token: payload.token, expiresAt: payload.expiresAt, // 毫秒时间戳,从接口返回的过期时间换算 }; await fs.writeFile(SESSION_FILE, JSON.stringify(slim, null, 2), { mode: 0o600 }); } export async function loadSession() { try { const raw = await fs.readFile(SESSION_FILE, 'utf8'); const s = JSON.parse(raw); if (!s.expiresAt || Date.now() > s.expiresAt - 60_000) return null; // 留 60 秒余量 return s; } catch { return null; // 文件不存在或格式坏了,一律当成未登录 } }

这段代码的关键点有三个。mode: 0o600让文件只有当前用户可读写,避免多用户机器上凭据被顺走;expiresAt - 60_000留了一分钟余量,防止请求刚发出去凭据就过期,那种 401 最难解释;loadSession用 try/catch 兜底,任何解析异常都返回null,让上层逻辑走「重新登录」分支,而不是抛异常把整个任务炸掉。参数上唯一要改的是过期余量,如果你跑的是批量任务、单本耗时较长,把余量调到 5 分钟更保险。

2.3 书籍元数据与资源清单的解析

拿到凭据后先用它换书籍元数据,这一步的返回值里通常包含书名、作者、ISBN、格式(EPUB/PDF)以及一个资源入口。真正的资源清单一般藏在第二层:请求入口拿回一个类似 manifest 的 JSON 或 XML,里面有每个资源的路径、类型、大小。EPUB 的 manifest 结构和 EPUB 标准里的content.opf很像,PDF 则常常是一组分块 URL 加总页数。解析这层要做的事很简单:把清单归一化成同一个内部结构,后续下载器只认这个结构,平台改版时你只改解析函数。

// manifest.js —— 把不同平台的清单归一化成统一结构 // 统一结构:[{ path, type, size, url }] export function normalizeManifest(raw, baseUrl) { const items = raw.resources ?? raw.manifest ?? []; return items.map((it) => { // 有的平台给相对路径,有的直接给绝对 URL,统一转成绝对地址 const url = /^https?:\/\//.test(it.href) ? it.href : new URL(it.href, baseUrl).toString(); return { path: decodeURIComponent(it.href.split('?')[0]).replace(/^\/+/, ''), type: it.mediaType ?? it.type ?? guessType(it.href), size: Number(it.size ?? 0), url, }; }); } function guessType(href) { const ext = href.split('.').pop().toLowerCase(); return { xhtml: 'application/xhtml+xml', jpg: 'image/jpeg', png: 'image/png', css: 'text/css' }[ext] ?? 'application/octet-stream'; }

normalizeManifest做了两件容易漏的事:一是相对路径转绝对地址,平台给的清单里相对路径非常常见,不转的话后面fetch直接抛Failed to parse URL;二是decodeURIComponent,中文书名、带空格的文件名会被编码成%E4%B8%AD,不解码本地建目录时会得到一堆乱码路径。guessType只是兜底,清单里给了mediaType就优先用清单的。参数上要留意baseUrl的取值:一定要用清单所在 URL 的目录部分,而不是站点根,否则相对路径拼出来的地址会 404,这个坑我踩过不止一次。

3. 动手实现:分块下载、并发控制与断点续传

清单到手之后就是体力活:按清单把每个资源取回来。这部分看着简单,实际是翻车最集中的地方——并发开太大被限流、网络抖动留下半截文件、任务中断后从头再来。写下载器我一般分三层:最底层一个单文件下载函数,负责重试和原子写入;中间一层并发池,控制同时跑几个;最上面一层调度,按清单把任务铺进去并汇总结果。三层拆开的好处是,限流了只改并发参数,重试策略变了只改底层,互不影响。

3.1 单文件下载的骨架

底层函数要解决三件事:超时中断、可重试错误的判定、原子写入。超时用AbortController加setTimeout,fetch本身没有默认超时,请求卡住会把并发槽位一直占着;可重试错误要跟致命错误分开,429 和 5xx 值得重试,401 和 404 重试多少次都没用;原子写入是先写.part再rename,rename在同一文件系统内是原子操作,能保证成品目录里永远不会出现半截文件。

// download.js —— 超时中断 + 分类重试 + 原子落盘 import fsp from 'node:fs/promises'; import path from 'node:path'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function downloadFile(url, dest, { headers = {}, retries = 3, timeout = 30_000 } = {}) { await fsp.mkdir(path.dirname(dest), { recursive: true }); for (let attempt = 0; attempt <= retries; attempt++) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), timeout); try { const res = await fetch(url, { headers, signal: ac.signal }); if (res.status === 429 || res.status >= 500) throw new Error(`retryable:${res.status}`); if (!res.ok) throw new Error(`fatal:${res.status}`); const tmp = `${dest}.part`; await fsp.writeFile(tmp, Buffer.from(await res.arrayBuffer())); await fsp.rename(tmp, dest); // 原子替换,成品目录里不会出现半截文件 return { ok: true, bytes: (await fsp.stat(dest)).size }; } catch (err) { if (String(err.message).startsWith('fatal:')) throw err; if (attempt === retries) throw err; // 指数退避 + 抖动,避免多个任务同一时刻一起重试 await sleep(500 * 2 ** attempt + Math.random() * 300); } finally { clearTimeout(timer); } } } export { sleep };

timeout默认 30 秒,小文件够用;如果清单里单个资源超过 10 MB,把它调到 120 秒,否则大图还没传完就被 abort,日志里全是AbortError。retries = 3配合指数退避,实际等待是 0.5s、1s、2s 左右,加上最多 300ms 抖动,能有效避开「所有任务同一秒集中重试」带来的二次限流。Math.random() * 300这个抖动看着不起眼,但它是批量任务里少见的、改一行就立竿见影的细节。Buffer.from(await res.arrayBuffer())会把整个文件读进内存,单文件几十兆没问题,真遇到上百兆的资源就改流式Readable.fromWeb(res.body)加pipeline,别硬扛。

3.2 并发池与限速:别把对方服务器当压测对象

并发是这类工具最容易被误用的参数。有人一上来开 20 并发,前几百个请求飞快,然后整批任务开始 429,接下来所有请求都失败。我的默认值是 4,跑批量任务时甚至降到 2。原因很朴素:对方的限流策略通常按短时间窗口内的请求数算,你并发开大只是把窗口填满得更快。并发池的实现不需要库,固定数量的 worker 从游标里取任务就够了。

// pool.js —— 固定并发 worker,游标自增分配任务 export async function runPool(tasks, limit = 4) { const results = new Array(tasks.length); let cursor = 0; const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => { while (true) { const i = cursor++; if (i >= tasks.length) return; // 任务抢完,worker 退出 try { results[i] = await tasks[i](); } catch (err) { results[i] = { ok: false, error: String(err.message) }; } } }); await Promise.all(workers); return results; }

runPool里cursor++是同步的,JavaScript 单线程保证它不会出现两个 worker 抢到同一个下标,比加锁简单得多。limit传 4 是通用值;如果你的清单里资源数量很少(比如整本 PDF 只切成 8 块),把limit提到 6 到 8 也没问题,因为总请求数本来就不多。真正需要压并发的是「几百个章节文件」的场景,那种情况下 2 到 4 是最优区间。单靠并发控制还不够,如果你发现限流依旧,就在 worker 里加一次固定间隔的sleep(200),代价是慢一点,换来的是整批跑完不用重来。

3.3 断点续传与重试策略

断点续传依赖 HTTP 的Range头,但不是所有资源服务器都支持。我的处理方式是「先试探再决定」:本地已有.part文件时,先带Range: bytes=N-发一次,返回 206 就续写,返回 200 说明服务端忽略了 Range,只能从头下。这个判断必须写进代码,不然会出现「续传后文件大小翻倍」这种诡异结果,EPUB 打不开时你还得回头查半天。

// resume.js —— 服务端支持 Range 就续传,不支持则回退整包 import fsp from 'node:fs/promises'; import { downloadFile } from './download.js'; export async function fetchWithRange(url, dest, headers = {}) { let start = 0; try { start = (await fsp.stat(dest)).size; } catch { /* 文件不存在,从头来 */ } if (start === 0) return downloadFile(url, dest, { headers }); const res = await fetch(url, { headers: { ...headers, Range: `bytes=${start}-` } }); if (res.status !== 206) { // 服务端不认 Range,删掉半截文件重下,避免新旧数据拼接 await fsp.rm(dest, { force: true }); return downloadFile(url, dest, { headers }); } await fsp.appendFile(dest, Buffer.from(await res.arrayBuffer())); return { resumedFrom: start }; }

res.status !== 206这个分支是整段代码的重点。很多 CDN 对 Range 请求的响应是同为 200 的完整内容,如果你直接appendFile,文件长度会变成 2 倍,后续校验必然失败,而报错信息只会告诉你「zip 损坏」,根本指不到这里。fsp.rm(dest, { force: true })里的force保证文件不存在时不抛异常。参数上建议给续传也加超时,fetch同样没有默认超时,卡住的请求会一直挂着。重试策略上我分两类:网络层错误(ECONNRESET、AbortError、429、5xx)重试 3 次,业务层错误(401、403、404)直接失败并上报,因为重试解决不了权限问题,只会浪费时间还刷高请求数。

4. EPUB 与 PDF 的落地处理:合并、命名与校验

资源拉回来只是原料,能不能正常打开取决于重组这一步。EPUB 本质是个 zip 容器,但它对内部结构有硬性要求,顺序错了阅读器直接报「文件损坏」;PDF 分块合并看着简单,页序错一位就是整本书内容错位。这一章的两个格式我都按「先验证再交付」的思路来写,每步都有可执行的检查命令。

4.1 EPUB 本质是个 zip:mimetype 必须第一个

EPUB 的规范要求 zip 包里的第一个条目必须是mimetype,内容固定为application/epub+zip,而且必须不压缩存储。很多手工组包的脚本用默认压缩参数打包,结果 Calibre 能开、某些阅读器打不开,排查半天最后发现是这一条。用archiver的话,store: true对应不压缩,条目顺序就是你append的顺序。

// epub-pack.js —— 手工组 EPUB:mimetype 第一且不压缩 import archiver from 'archiver'; import fs from 'node:fs'; export function packEpub(outPath, files) { const out = fs.createWriteStream(outPath); const zip = archiver('zip', { zlib: { level: 9 } }); zip.pipe(out); zip.append('application/epub+zip', { name: 'mimetype', store: true }); // 必须第一条、不压缩 zip.append(files.container, { name: 'META-INF/container.xml' }); for (const item of files.resources) { zip.append(item.content, { name: item.path }); } zip.finalize(); return new Promise((res, rej) => { out.on('close', res); out.on('error', rej); }); }

store: true是 mimetype 条目的关键,缺了它文件能打开但部分阅读器会判定不合规。zlib: { level: 9 }对 XHTML 和 CSS 压缩收益明显,图片本来就是压缩格式,压不压差别不大。files.container是META-INF/container.xml的内容,一般原样从服务端拿回来,改它没有意义。打包完成后必须验证,unzip -l看条目顺序,unzip -p看 mimetype 内容:

unzip -l out/book.epub | head -5 # 第一行应该是 mimetype unzip -p out/book.epub mimetype # 应输出 application/epub+zip,且不带换行

第二条命令的输出如果有换行符,说明你 append 的字符串尾部多了\n,某些严格的阅读器会因此拒绝加载。这个细节极其隐蔽,因为unzip -p的输出在终端里换行不换行看起来差不多,用| xxd | head -2看一眼十六进制最保险。

4.2 PDF 分块合并与页序校验

PDF 分块常见的切法是按页区间切,清单里会给出每块的起止页。合并时最怕两件事:页序错、缺块。页序错的典型表现是「读到中间突然跳回前面」,而缺块会让总页数对不上。我的做法是合并前先按起止页排序,合并后再用页数做一次断言,别指望肉眼翻。

// pdf-merge.js —— 按起始页排序后顺序拼接,并断言总页数 import fsp from 'node:fs/promises'; export async function mergePdfChunks(chunks, outPath, expectedPages) { // chunks: [{ startPage, endPage, file }] const sorted = [...chunks].sort((a, b) => a.startPage - b.startPage); // 连续性检查:后一块起始页应等于前一块结束页 + 1 for (let i = 1; i < sorted.length; i++) { if (sorted[i].startPage !== sorted[i - 1].endPage + 1) { throw new Error(`分块不连续:${sorted[i - 1].endPage} -> ${sorted[i].startPage}`); } } const buffers = []; for (const c of sorted) buffers.push(await fsp.readFile(c.file)); await fsp.writeFile(outPath, Buffer.concat(buffers)); const pages = sorted.at(-1).endPage - sorted[0].startPage + 1; if (expectedPages && pages !== expectedPages) { throw new Error(`页数不符:期望 ${expectedPages},实得 ${pages}`); } return { pages }; }

连续性检查这段比合并本身更有价值。sorted[i].startPage !== sorted[i-1].endPage + 1一旦触发,说明清单本身有问题或者某块下载失败被跳过了,这时候停下来报错,比生成一个「能打开但内容错位」的 PDF 强得多。expectedPages从元数据里取,取不到就传null跳过断言。参数上要注意:有些平台的页区间是闭区间(含两端),有些是半开区间,用之前一定拿一本已知页数的书对一遍,差一页的错误能让整个校验逻辑失效。

4.3 文件名与目录结构规范

成品文件名看着是小事,实际影响后面所有批处理。书名里带/、:、?、*这些字符,在 Linux 上建目录会直接被当成路径分隔符,在 Windows 上直接报非法字符。统一做一次清洗,长度截到 80 字符以内,避免某些文件系统 255 字节的限制被中文书名顶爆。

// name.js —— 清洗文件名,保留可读性 export function safeName(input) { return String(input) .replace(/[\\/:*?"<>|]/g, '_') // 各平台非法字符统一替换 .replace(/\s+/g, ' ') // 连续空白压成一个 .trim() .slice(0, 80); // 中文按字符截断,留足余量 }

目录结构我习惯按out/<作者>/<书名>.<ext>排,批量任务跑完一眼能看出缺了谁。slice(0, 80)是按 UTF-16 码元算的,中文一个字算一个,80 个中文字符对应的字节数在 240 左右,没超 255 的文件名限制。如果你的书名含 emoji 或者生僻字,实际字节数会更多,把上限压到 60 更保险。

5. 避坑与排查:三类翻车现场

下面这些是我在跑批量任务时真遇到过并被卡住过的问题,按「现象 → 原因 → 解决」记下来。共同点是:报错信息都不会直接指向真实原因,你得知道往哪儿看。

5.1 登录态与限流类

现象:任务跑到一半开始连续 401,重启脚本重新贴 cookie 后又好了,第二天同样的时间又挂。原因:凭据有固定有效期,批量任务耗时超过了剩余有效期,而脚本没有在中途刷新或重新读取的逻辑。解决:在loadSession里加过期判断(2.2 那段的expiresAt - 60_000就是干这个的),每次取凭据前重新读一遍文件而不是启动时读一次缓存在内存里;批量任务开始前打印一次剩余有效期,心里有数。

现象:前几十个资源秒下,之后全部 429,等几分钟重跑还是 429。原因:并发开太大把限流窗口打满,而且退避策略里没有抖动,所有 worker 在同一毫秒一起重试,等于把窗口又打满一次。解决:limit从 4 降到 2,退避里加Math.random() * 300的抖动;如果还是被限,在 worker 循环里加固定sleep(200)。

现象:登录接口返回 200,但后续资源请求全是 403。原因:资源请求需要的请求头不止 cookie,常见的是Referer和某个自定义头(比如X-Requested-With),只带 cookie 会被判定为非法来源。解决:从浏览器里把资源请求的完整请求头复制下来,逐个删掉试探,找出必需的那几个,写进headers参数。

5.2 文件完整性类

现象:EPUB 在 Calibre 里能打开,在手机上打不开,提示文件损坏。原因:mimetype不是 zip 的第一个条目,或者被压缩了,或者尾部多了换行符。解决:用unzip -l看条目顺序,用unzip -p book.epub mimetype | xxd | head -2看内容是否恰好是application/epub+zip无换行;打包代码里确保store: true且是第一句append。

现象:PDF 能打开,但总页数比预期多一倍,翻到中间内容重复。原因:断点续传时服务端忽略了Range返回 200,脚本直接把完整内容 append 到已有.part后面。解决:续传前判断状态码,非 206 就删文件重下(3.3 那段代码的核心);或者干脆在续传前用HEAD请求探一次Accept-Ranges。

现象:本地文件大小和清单里的size对不上,差几百字节。原因:清单里的size是压缩后大小而下载的是解压后内容,或者服务端做了 gzip 传输而清单没算这层。解决:别拿size做硬断言,改成「下载完成后再校验一次能否被对应解析器打开」,EPUB 用unzip -t,PDF 看文件头是否是%PDF-。

5.3 运行环境与依赖类

现象:fetch is not defined。原因:Node 版本低于 18,内置fetch不存在。解决:node -v确认版本,升到 20 LTS;不想升就用undici的fetch显式引入。

现象:SyntaxError: Cannot use import statement outside a module。原因:package.json里没加"type": "module",ESM 语法被当成 CommonJS 解析。解决:加上这个字段,或者把文件名改成.mjs。

现象:中文书名建出来的目录是一串%E4%B8%AD。原因:清单里的路径是 URL 编码的,没做decodeURIComponent就直接当文件路径用。解决:在normalizeManifest里统一解码,解完再过一遍safeName。

6. 收尾:一套五分钟能跑完的验证清单

工具跑通不等于结果可信。我现在每批任务结束都会走一遍固定检查,五分钟内能定位九成问题。第一步看数量:清单里的资源条数和out目录下的文件数(EPUB 是单个文件,PDF 是单个文件,这里比的是中间产物条数)必须一致,不一致就在日志里搜ok: false,那几条就是失败项,单独重跑即可,别整批重来。第二步看结构:EPUB 用unzip -t做完整性测试,这个命令会逐条目校验 CRC,比打开看一眼靠谱得多;PDF 用head -c 5 book.pdf确认文件头是%PDF-,再看一眼总页数。第三步抽样打开:随机挑三本,翻到中间和末尾各看一页,图片和公式是最容易出问题的地方,文字层正常但图片 404 的情况我遇到过不止一次。

进阶一点的用法是把这套检查做成脚本,跟着下载任务一起跑:

#!/usr/bin/env bash # verify.sh —— 批量校验成品,失败的列出来单独重跑 set -u fail=0 for f in out/*/*.epub; do [ -e "$f" ] || continue if ! unzip -t "$f" >/dev/null 2>&1; then echo "EPUB 校验失败: $f"; fail=1 fi # 额外确认 mimetype 在第一条 first=$(unzip -l "$f" | sed -n '4p' | awk '{print $4}') [ "$first" = "mimetype" ] || echo "mimetype 不在首位: $f" done for f in out/*/*.pdf; do [ -e "$f" ] || continue [ "$(head -c 5 "$f")" = "%PDF-" ] || { echo "PDF 头异常: $f"; fail=1; } done exit $fail

set -u让未定义变量直接报错,避免out/*/*.epub匹配不到时把空字符串传进循环。sed -n '4p'这个行号依赖unzip -l的输出格式,不同发行版可能差一行,跑之前先用单本文件确认一下——这类「看起来能用实际很脆」的解析,我一般会在脚本里加个echo把实际取值打出来,确认对了再删。脚本返回非零就说明有文件需要重跑,配合前面下载器的断点续传,重跑只会补缺失的那几个文件,很快。

还有一个技巧是给任务加一份「已完成后清单」,每成功一个就往里追加一行,重跑时先读这份清单跳过已完成项。文件名、大小、校验结果一起记,出问题时能直接定位是「没下」还是「下坏了」。这份清单比日志好用的地方在于它是累积的,跨多次运行都有效。

说实话,我最早写这类下载器的时候,图省事把凭据硬编码在脚本里,也不做原子写入,结果一次网络抖动留下一个半截 EPUB,我拿着它排查了两个小时,最后发现是文件本身只有 60% 的内容。从那以后我每次跑批量任务,都强制走一遍verify.sh加抽样打开,哪怕只下三本书也不跳。这套习惯比任何优化都省时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询