1. 项目背景与需求分析
作为一名长期混迹技术社区的老码农,我最近在折腾一个挺有意思的Electron项目——开发一个集搜书和下载功能于一体的桌面应用。这个想法的诞生源于我自己的痛点:每次想找本电子书都得在各种网站间反复横跳,还要忍受满屏的广告和复杂的下载流程。
市面上虽然有不少类似so-novel、51mazi这样的在线书库,但它们要么界面杂乱,要么需要注册登录,更别提那些隐藏极深的下载按钮了。于是我就琢磨着,能不能用Electron把这些资源整合起来,做个干净清爽的本地应用?
这个项目的核心目标很明确:
- 实现多书源统一搜索(至少包含so-novel和51mazi两个主流书库)
- 自动化下载流程,避免手动点击广告和验证码
- 提供简洁的本地书架管理功能
2. 技术选型与架构设计
2.1 为什么选择Electron?
用Electron来做这个项目有几个明显优势:
- 跨平台:一套代码可以打包成Windows、macOS和Linux版本
- 前端技术栈:可以直接用熟悉的HTML/CSS/JS开发界面
- Node.js集成:方便实现文件操作、网络请求等后端功能
不过Electron也有它的缺点,比如打包体积大、内存占用高。但对于这个工具类应用来说,这些缺点在可接受范围内。
2.2 整体架构设计
整个应用采用经典的三层架构:
code复制┌───────────────────────┐
│ Renderer │ <- 前端界面(Vue/React)
├───────────────────────┤
│ Main │ <- 主进程(窗口管理/菜单)
├───────────────────────┤
│ Service │ <- 爬虫服务/文件管理
└───────────────────────┘
特别说明一下Service层的设计:
- 爬虫服务:负责与各个书站API交互
- 缓存管理:本地存储搜索结果和书籍元数据
- 下载队列:管理并发下载任务
3. 爬虫实现细节
3.1 书源分析
我们先来看看两个目标书站的结构差异:
| 特征 | so-novel | 51mazi |
|---|---|---|
| 搜索接口 | GET /search?keyword=xxx | POST /api/search |
| 返回格式 | HTML | JSON |
| 反爬机制 | 频率限制 | 验证码+请求签名 |
| 下载链接 | 直接文件链接 | 中转页面+动态生成链接 |
3.2 通用爬虫封装
为了避免为每个书站写重复代码,我设计了一个基础爬虫类:
javascript复制class BaseCrawler {
constructor(options) {
this.siteName = options.siteName
this.searchUrl = options.searchUrl
this.headers = options.headers || {}
}
async search(keyword) {
// 统一处理请求和错误
try {
const raw = await this._fetchSearch(keyword)
return this._parseSearch(raw)
} catch (e) {
console.error(`[${this.siteName}]搜索失败:`, e)
return []
}
}
// 需要子类实现的方法
async _fetchSearch(keyword) {
throw new Error('必须实现_fetchSearch方法')
}
_parseSearch(raw) {
throw new Error('必须实现_parseSearch方法')
}
}
3.3 so-novel爬虫实现
针对so-novel的具体实现:
javascript复制class SoNovelCrawler extends BaseCrawler {
constructor() {
super({
siteName: 'so-novel',
searchUrl: 'https://www.so-novel.com/search'
})
}
async _fetchSearch(keyword) {
const params = new URLSearchParams()
params.append('q', keyword)
const response = await fetch(`${this.searchUrl}?${params}`, {
headers: {
...this.headers,
'User-Agent': 'Mozilla/5.0'
}
})
return await response.text()
}
_parseSearch(html) {
const $ = cheerio.load(html)
return $('.book-item').map((i, el) => {
const $el = $(el)
return {
title: $el.find('.title').text(),
author: $el.find('.author').text(),
url: $el.find('a').attr('href'),
source: this.siteName
}
}).get()
}
}
3.4 51mazi爬虫实现
51mazi的反爬措施更复杂,需要处理请求签名:
javascript复制class Mazi51Crawler extends BaseCrawler {
constructor() {
super({
siteName: '51mazi',
searchUrl: 'https://api.51mazi.com/v1/search'
})
this.secretKey = '...' // 通过逆向分析得到的密钥
}
async _fetchSearch(keyword) {
const timestamp = Date.now()
const sign = crypto
.createHash('md5')
.update(`${keyword}${timestamp}${this.secretKey}`)
.digest('hex')
const response = await fetch(this.searchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sign': sign,
'X-Timestamp': timestamp
},
body: JSON.stringify({ keyword })
})
return await response.json()
}
_parseSearch(json) {
return json.data.map(item => ({
title: item.name,
author: item.author,
url: item.detail_url,
downloadUrl: item.download_url,
source: this.siteName
}))
}
}
4. 下载功能实现
4.1 下载管理器设计
下载功能需要考虑的几个关键点:
- 支持断点续传
- 并发控制(避免被封IP)
- 进度反馈
javascript复制class DownloadManager {
constructor(maxConcurrent = 3) {
this.queue = []
this.activeCount = 0
this.maxConcurrent = maxConcurrent
}
addTask(url, savePath) {
return new Promise((resolve, reject) => {
this.queue.push({ url, savePath, resolve, reject })
this._next()
})
}
_next() {
while (this.activeCount < this.maxConcurrent && this.queue.length) {
const task = this.queue.shift()
this.activeCount++
this._download(task.url, task.savePath)
.then(task.resolve)
.catch(task.reject)
.finally(() => {
this.activeCount--
this._next()
})
}
}
async _download(url, savePath) {
const response = await fetch(url)
const fileStream = fs.createWriteStream(savePath)
return new Promise((resolve, reject) => {
response.body.pipe(fileStream)
response.body.on('error', reject)
fileStream.on('finish', resolve)
})
}
}
4.2 文件类型处理
不同书站提供的文件格式可能不同,我们需要统一处理:
javascript复制function getFileType(url) {
const ext = url.split('.').pop().toLowerCase()
const typeMap = {
epub: 'EPUB',
mobi: 'MOBI',
pdf: 'PDF',
txt: 'TXT',
azw: 'Kindle'
}
return typeMap[ext] || '未知格式'
}
5. Electron集成与优化
5.1 主进程配置
为了避免Electron常见的安全警告,需要做好基础配置:
javascript复制// main.js
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, 'preload.js')
}
})
// 加载Vue应用
win.loadFile('dist/index.html')
})
5.2 进程间通信
使用预加载脚本安全地暴露API:
javascript复制// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
searchBooks: (keyword) => ipcRenderer.invoke('search-books', keyword),
downloadBook: (url) => ipcRenderer.invoke('download-book', url),
onDownloadProgress: (callback) =>
ipcRenderer.on('download-progress', callback)
})
5.3 性能优化技巧
- 懒加载书源:不是所有书站都需要同时启用
- 本地缓存:使用lowdb实现简单的搜索结果缓存
- 请求合并:短时间内重复搜索直接返回缓存
javascript复制// 使用lowdb实现缓存
const db = new Low(new JSONFile('cache.json'))
async function searchWithCache(keyword) {
await db.read()
db.data ||= { searches: {} }
// 检查缓存
const cacheKey = `${keyword}:${Date.now() / 3600000 | 0}` // 每小时缓存
if (db.data.searches[cacheKey]) {
return db.data.searches[cacheKey]
}
// 实际搜索
const results = await actualSearch(keyword)
db.data.searches[cacheKey] = results
await db.write()
return results
}
6. 常见问题与解决方案
6.1 反爬虫应对策略
| 问题类型 | 解决方案 |
|---|---|
| 频率限制 | 1. 限制请求速率(2-3次/秒) 2. 使用代理IP轮换 |
| 验证码 | 1. 使用第三方打码服务 2. 提示用户手动输入(降级方案) |
| 请求签名 | 1. 逆向分析JS代码 2. 使用Puppeteer模拟浏览器环境获取动态参数 |
| 内容混淆 | 1. 分析字体映射关系 2. 使用OCR识别关键信息 |
6.2 Electron特有坑点
-
路径问题:
javascript复制// 错误写法 fs.readFile('./data.json') // 正确写法 const path = require('path') fs.readFile(path.join(__dirname, 'data.json')) -
白屏问题:
- 确保加载的页面路径正确
- 检查开发者工具中的报错
- 可能是Node集成与前端框架冲突
-
打包体积过大:
- 使用electron-builder的asar打包
- 排除不必要的依赖
- 考虑使用webpack优化
7. 进阶优化方向
7.1 书源热更新
将书源配置放在远程服务器,可以动态更新:
javascript复制async function updateSources() {
const resp = await fetch('https://your-server.com/sources.json')
const sources = await resp.json()
// 验证并更新本地书源
if (validateSources(sources)) {
fs.writeFileSync(sourcesPath, JSON.stringify(sources))
}
}
7.2 用户系统集成
虽然是个本地应用,但加入简单的用户系统可以实现:
- 收藏夹同步
- 阅读进度记录
- 多设备同步
javascript复制// 使用JWT实现简单认证
function login(username, password) {
// 验证凭证...
const token = jwt.sign({ userId: 123 }, secret, { expiresIn: '7d' })
localStorage.setItem('token', token)
}
7.3 阅读器功能扩展
集成基础EPUB阅读器提升用户体验:
javascript复制// 使用epub.js实现阅读器
const book = ePub("path/to/book.epub")
const rendition = book.renderTo("viewer", {
width: "100%",
height: "600px"
})
rendition.display()
8. 项目总结与反思
这个项目从技术角度来说不算复杂,但涉及的知识面很广,包括:
- Electron主进程与渲染进程的协作
- 不同书站的爬虫策略
- 下载管理与文件操作
- 性能优化与错误处理
几个关键收获:
- 错误处理要全面:网络请求、文件IO、用户输入都要考虑异常情况
- 进度反馈很重要:特别是下载大文件时,要给用户明确的进度提示
- 代码要可扩展:书站接口经常变动,爬虫代码要容易修改
如果重做这个项目,我会:
- 更早引入类型系统(TypeScript)
- 设计更好的插件系统来支持新书源
- 增加更多自动化测试
这个工具目前已经成了我的日常必备,每天节省了不少找书时间。如果你也想开发类似工具,建议先从一两个书源开始,逐步扩展功能。