Skip to content

Commit 5f812e0

Browse files
committedApr 10, 2024
新增将多个HTML文件转换成Epub电子书的功能
1 parent 38e1015 commit 5f812e0

17 files changed

+365
-12
lines changed
 

‎README.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Electron + Typescript + VUE3
2626

2727
### 使用
2828

29-
![image-20230112181356841](doc/imgages/main.png)
29+
![image-20230112181356841](doc/imgages/main.jpg)
3030

3131
![image-20230821104149231](doc/imgages/setting.jpg)
3232

@@ -112,7 +112,16 @@ Electron + Typescript + VUE3
112112
}
113113
```
114114

115-
115+
- 生成Epub
116+
117+
支持通过 HTML 文件生成 Epub 电子书,所以使用需要先使用**批量下载**将公众号文章保存到本地,再生成 Epub
118+
119+
使用参数如下
120+
121+
- 文件名:必要参数。例如填写 **test**,最后就会生成 **test.epub** 文件
122+
123+
- 文件夹:必要参数。保存了 HTML 文件的文件夹,也就是 Epub 的数据来源
124+
- 封面图片:Epub 文件的封面图片,支持 jpg、png 格式
116125

117126
### 功能
118127

‎doc/imgages/batch.gif

695 KB
Loading

‎doc/imgages/ca.png

301 KB
Loading

‎doc/imgages/main.jpg

93.2 KB
Loading

‎doc/imgages/monitoring.gif

1.43 MB
Loading

‎doc/imgages/setting.jpg

105 KB
Loading

‎doc/mysql.sql

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
DROP TABLE IF EXISTS wx_article;
2+
CREATE TABLE wx_article (
3+
id INT ( 11 ) NOT NULL AUTO_INCREMENT,
4+
title VARCHAR ( 255 ) NULL DEFAULT NULL COMMENT '标题',
5+
content LONGTEXT NULL COMMENT '内容',
6+
author VARCHAR ( 255 ) NULL DEFAULT NULL COMMENT '作者',
7+
content_url VARCHAR ( 1023 ) NULL DEFAULT NULL COMMENT '详情链接',
8+
create_time datetime ( 0 ) NULL DEFAULT NULL,
9+
copyright_stat INT ( 11 ) NULL DEFAULT NULL,
10+
PRIMARY KEY ( id ) USING BTREE,
11+
UNIQUE INDEX uni_title ( title, create_time ) USING BTREE
12+
) ;
13+
14+
-- 2023-4-1 添加评论字段
15+
ALTER TABLE wx_article ADD COLUMN comm LONGTEXT NULL COMMENT '精选评论',
16+
ADD COLUMN comm_reply LONGTEXT NULL COMMENT '评论回复';
17+
18+
-- 2024-3-19
19+
ALTER TABLE wx_article ADD COLUMN digest VARCHAR(1023) NULL COMMENT '摘要',
20+
ADD COLUMN cover VARCHAR(511) NULL COMMENT '封面',
21+
ADD COLUMN js_name VARCHAR ( 255 ) NULL COMMENT '公众号',
22+
ADD COLUMN md_content LONGTEXT NULL COMMENT 'markdown内容';

‎electron-builder.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@ publish:
4848
repo: wechatDownload
4949
releaseInfo:
5050
releaseNotes: |
51-
没有新增功能,只是修改了页面样式,更不更新都可以。
51+
新增将多个HTML文件转换成Epub电子书的功能

‎package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"name": "wechatDownload",
3-
"version": "1.6.0",
3+
"version": "1.7.0",
44
"description": "An Electron application with Vue and TypeScript",
55
"main": "./out/main/index.mjs",
6-
"author": "example.com",
6+
"author": "javaedit.com",
77
"homepage": "https://electron-vite.org",
88
"scripts": {
99
"format": "prettier --write .",

‎src/main/epubWorker.ts

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { parentPort, workerData } from 'worker_threads';
2+
import logger from './logger';
3+
import * as fs from 'fs';
4+
import { EPub, EpubOptions, EpubContentOptions } from '@lesjoursfr/html-to-epub';
5+
import * as cheerio from 'cheerio';
6+
import { JSDOM } from 'jsdom';
7+
import * as path from 'path';
8+
9+
import { NodeWorkerResponse, NwrEnum } from './service';
10+
11+
const port = parentPort;
12+
if (!port) throw new Error('IllegalState');
13+
14+
// epub文件名
15+
const title: string = workerData.title;
16+
// 数据来源文件夹
17+
const epubDataPath: string = workerData.epubDataPath;
18+
// 封面图片
19+
const epubCover: string = workerData.epubCover && workerData.epubCover.length > 0 ? workerData.epubCover : undefined;
20+
// 缓存路径
21+
const tmpPath: string = workerData.tmpPath;
22+
23+
// 接收消息,执行任务
24+
port.on('message', async (message: NodeWorkerResponse) => {
25+
if (message.code == NwrEnum.START) {
26+
resp(NwrEnum.SUCCESS, '正在生成Epub,开始获取文章...');
27+
28+
const limitCount = 666;
29+
// 遍历文件夹,获取文章
30+
const htmlArr: string[] = [];
31+
listHtmlFile(epubDataPath, htmlArr, 0);
32+
resp(NwrEnum.SUCCESS, `已获取到${htmlArr.length}篇文章,开始转换...`);
33+
34+
// 生成Epub
35+
if (htmlArr.length > 0) {
36+
if (htmlArr.length > limitCount) {
37+
resp(NwrEnum.SUCCESS, `文章数量超出限制,只转换${limitCount}篇文章`);
38+
htmlArr.length = limitCount;
39+
}
40+
41+
const epubItemArr: EpubContentOptions[] = [];
42+
for (const htmlPath of htmlArr) {
43+
const itemData = await getEpubItemData(htmlPath);
44+
epubItemArr.push(itemData);
45+
resp(NwrEnum.SUCCESS, `【${itemData.title}】转换完成`);
46+
}
47+
48+
resp(NwrEnum.SUCCESS, '开始创建Epub');
49+
50+
const option: EpubOptions = {
51+
title: title,
52+
description: 'created by wechatDownload',
53+
tocTitle: '目录',
54+
author: 'Nobody',
55+
tempDir: tmpPath,
56+
cover: epubCover,
57+
content: epubItemArr
58+
};
59+
60+
const savePath = path.join(epubDataPath, title + '.epub');
61+
const epub = new EPub(option, savePath);
62+
63+
epub
64+
.render()
65+
.then(() => {
66+
resp(NwrEnum.SUCCESS, `Epub创建成功,存放位置:${savePath}`);
67+
resp(NwrEnum.CLOSE, '');
68+
})
69+
.catch((err) => {
70+
console.error('Epub创建失败', err);
71+
resp(NwrEnum.SUCCESS, 'Epub创建失败');
72+
resp(NwrEnum.CLOSE, '');
73+
});
74+
}
75+
}
76+
});
77+
78+
port.on('close', () => {
79+
logger.info('on 线程关闭');
80+
});
81+
82+
port.addListener('close', () => {
83+
logger.info('addListener 线程关闭');
84+
});
85+
86+
/**
87+
* 递归文件夹获取html文件
88+
* @param dirPath 文件夹路径
89+
* @param htmlArr 存放html文件的数组
90+
* @param inLevel 递归层级
91+
*/
92+
function listHtmlFile(dirPath: string, htmlArr: string[], inLevel: number) {
93+
const files = fs.readdirSync(dirPath);
94+
files.forEach((file) => {
95+
const filePath = path.join(dirPath, file);
96+
const stats = fs.statSync(filePath);
97+
98+
if (stats.isDirectory()) {
99+
if (inLevel < 3) {
100+
listHtmlFile(filePath, htmlArr, inLevel + 1);
101+
}
102+
} else {
103+
if (filePath.endsWith('.html')) {
104+
htmlArr.push(filePath);
105+
}
106+
}
107+
});
108+
}
109+
110+
/**
111+
* 将html文件转成epub数据
112+
* @param htmlPath html文件路径
113+
*/
114+
async function getEpubItemData(htmlPath: string): Promise<EpubContentOptions> {
115+
const htmlStr = fs.readFileSync(htmlPath, 'utf8');
116+
const dom = new JSDOM(htmlStr, { runScripts: 'dangerously' });
117+
// 等待页面渲染完成
118+
await new Promise((resolve, reject) => {
119+
dom.window.addEventListener('load', () => {
120+
resolve('ok');
121+
});
122+
123+
setTimeout(() => {
124+
reject();
125+
}, 2000);
126+
});
127+
128+
const folderPath = path.dirname(htmlPath);
129+
// 处理html内容,删除标题和js,将相对路径图片转成绝对路径
130+
const $ = cheerio.load(dom.serialize());
131+
$('script').remove();
132+
$('h1').remove();
133+
const srcArr = $('[src]');
134+
for (let i = 0; i < srcArr.length; i++) {
135+
const $ele = $(srcArr[i]);
136+
const src = $ele.attr('src');
137+
if (src && src.length > 0) {
138+
if (!src.startsWith('http')) {
139+
// 获取相对路径
140+
$ele.attr('src', 'file://' + path.resolve(folderPath, src));
141+
}
142+
}
143+
}
144+
145+
const title = path.basename(folderPath);
146+
147+
return {
148+
title: title,
149+
data: $.xml()
150+
};
151+
}
152+
153+
function resp(code: NwrEnum, message: string, data?) {
154+
logger.info('resp', code, message, data);
155+
port!.postMessage(new NodeWorkerResponse(code, message, data));
156+
}

‎src/main/index.ts

+34-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { HttpUtil } from './utils';
99
import logger from './logger';
1010
import { GzhInfo, ArticleInfo, PdfInfo, NodeWorkerResponse, NwrEnum, DlEventEnum, DownloadOption } from './service';
1111
import creatWorker from './worker?nodeWorker';
12+
import createEpubWorker from './epubWorker?nodeWorker';
1213
import * as fs from 'fs';
1314
import icon from '../../resources/icon.png?asset';
1415
import * as child_process from 'child_process';
@@ -149,6 +150,30 @@ app.whenReady().then(() => {
149150
// 暂时只需要版本号
150151
event.returnValue = app.getVersion();
151152
});
153+
// 生成epub
154+
ipcMain.on('create-epub', (_event, options: any) => {
155+
options.tmpPath = store.get('tmpPath');
156+
const epubWorker = createEpubWorker({
157+
workerData: options
158+
});
159+
160+
epubWorker.on('message', (message) => {
161+
const nwResp: NodeWorkerResponse = message;
162+
switch (nwResp.code) {
163+
case NwrEnum.SUCCESS:
164+
case NwrEnum.FAIL:
165+
outputLog(nwResp.message, true);
166+
break;
167+
case NwrEnum.CLOSE:
168+
outputEpubLog('<hr />', true, true);
169+
// 关闭线程
170+
epubWorker.terminate();
171+
}
172+
});
173+
174+
outputLog('生成Epub线程启动中');
175+
epubWorker.postMessage(new NodeWorkerResponse(NwrEnum.START, ''));
176+
});
152177

153178
createWindow();
154179

@@ -509,7 +534,15 @@ async function testMysqlConnection() {
509534
async function outputLog(msg: string, append = false, flgHtml = false) {
510535
MAIN_WINDOW.webContents.send('output-log', msg, append, flgHtml);
511536
}
512-
537+
/*
538+
* 输出日志到生成Epub页面
539+
* msg:输出的消息
540+
* append:是否追加
541+
* flgHtml:消息是否是html
542+
*/
543+
async function outputEpubLog(msg: string, append = false, flgHtml = false) {
544+
MAIN_WINDOW.webContents.send('output-epub-log', msg, append, flgHtml);
545+
}
513546
/*
514547
* 第一次运行,默认设置
515548
*/

‎src/main/worker.ts

-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ if (!port) throw new Error('IllegalState');
7171

7272
// 接收消息,执行任务
7373
port.on('message', async (message: NodeWorkerResponse) => {
74-
console.log(777);
7574
if (message.code == NwrEnum.START) {
7675
// 初始化数据库连接
7776
await createMysqlConnection();

‎src/preload/index.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ declare global {
3434
loadInitInfo: () => string;
3535
// 检查更新
3636
checkForUpdate();
37+
// 生成epub
38+
createEpub(options: any);
3739

3840
/*** main->render ***/
3941
// 用于打开文件夹之后接收打开的路径
@@ -45,6 +47,8 @@ declare global {
4547
* flgHtml:消息是否是html
4648
*/
4749
outputLog(callback: (event: IpcRendererEvent, msg: string, flgAppend = false, flgHtml = false) => void);
50+
// 输出日志到生成Epub页面
51+
outputEpubLog(callback: (event: IpcRendererEvent, msg: string, flgAppend = false, flgHtml = false) => void);
4852
// 下载完成后做的处理
4953
downloadFnish(callback: (event: IpcRendererEvent) => void);
5054
/*

‎src/preload/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,15 @@ const api = {
3939
},
4040
// 检查更新
4141
checkForUpdate: () => ipcRenderer.send('check-for-update'),
42+
// 生成epub
43+
createEpub: (options: any) => ipcRenderer.send('create-epub', options),
4244
/*** main->render ***/
4345
// 用于打开文件夹之后接收打开的路径
4446
openDialogCallback: (callback) => ipcRenderer.on('open-dialog-callback', callback),
4547
// 输出日志
4648
outputLog: (callback) => ipcRenderer.on('output-log', callback),
49+
// 输出Epub日志
50+
outputEpubLog: (callback) => ipcRenderer.on('output-log', callback),
4751
// 下载完成
4852
downloadFnish: (callback) => ipcRenderer.on('download-fnish', callback),
4953
// 发送更新信息

‎src/renderer/src/App.vue

+7-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ref } from 'vue';
33
import Home from './views/Home.vue';
44
import Setting from './views/Setting.vue';
5+
import EpubCreator from './views/EpubCreator.vue';
56
67
const menuIdx = ref('1');
78
function changeMenuIdx(index) {
@@ -13,13 +14,17 @@ function changeMenuIdx(index) {
1314
<el-menu mode="horizontal" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b" class="my-menu" :default-active="menuIdx" @select="changeMenuIdx">
1415
<el-menu-item index="1">主页面</el-menu-item>
1516
<el-menu-item index="2">设置中心</el-menu-item>
17+
<el-menu-item index="3">生成Epub</el-menu-item>
1618
</el-menu>
1719
<div class="home-div">
1820
<keep-alive>
19-
<Home v-if="menuIdx === '1'" :menu-idx="menuIdx" />
21+
<Home v-if="menuIdx === '1'" />
2022
</keep-alive>
2123
<keep-alive>
22-
<Setting v-if="menuIdx === '2'" :menu-idx="menuIdx" />
24+
<Setting v-if="menuIdx === '2'" />
25+
</keep-alive>
26+
<keep-alive>
27+
<EpubCreator v-if="menuIdx === '3'" />
2328
</keep-alive>
2429
</div>
2530
</template>
@@ -35,8 +40,6 @@ function changeMenuIdx(index) {
3540
}
3641
3742
.home-div {
38-
/* margin-top: 40px; */
3943
height: 100%;
40-
/* background-color: red; */
4144
}
4245
</style>
+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<template>
2+
<el-container style="height: 100%">
3+
<el-header height="225px">
4+
<el-form :model="formData" label-width="auto">
5+
<el-form-item label="文件名">
6+
<el-input v-model="formData.title" placeholder="请填写Epub文件名" />
7+
</el-form-item>
8+
<el-form-item label="文件夹">
9+
<el-input v-model="formData.epubDataPath" placeholder="请选择数据来源文件夹" readonly>
10+
<template #append>
11+
<el-button @click="choseEpubDataPath">选择</el-button>
12+
</template>
13+
</el-input>
14+
</el-form-item>
15+
<el-form-item label="封面图片">
16+
<el-input v-model="formData.epubCover" placeholder="请选择Epub封面图片,非必填" readonly>
17+
<template #append>
18+
<el-button @click="choseCover">选择</el-button>
19+
</template>
20+
</el-input>
21+
</el-form-item>
22+
<el-form-item class="form-btn">
23+
<el-button type="primary" size="large" @click="onSubmit">生成</el-button>
24+
</el-form-item>
25+
</el-form>
26+
</el-header>
27+
<el-main>
28+
<el-scrollbar v-if="epubLogArr.length > 0" style="word-wrap: break-word">
29+
<div>
30+
<div v-for="(logItem, i) in epubLogArr" :key="i">
31+
<p v-if="logItem.flgHtml" v-html="logItem.msg"></p>
32+
<p v-else>{{ logItem.msg }}</p>
33+
</div>
34+
</div>
35+
</el-scrollbar>
36+
</el-main>
37+
</el-container>
38+
</template>
39+
40+
<script setup lang="ts">
41+
import { OpenDialogOptions } from 'electron';
42+
import { ElScrollbar } from 'element-plus';
43+
import { reactive } from 'vue';
44+
45+
class LogInfo {
46+
msg: string;
47+
flgHtml: boolean;
48+
49+
constructor(msg: string, flgHtml: boolean) {
50+
this.msg = msg;
51+
this.flgHtml = flgHtml;
52+
}
53+
}
54+
const epubLogArr = reactive([] as LogInfo[]);
55+
56+
const formData = reactive({
57+
title: '',
58+
epubDataPath: '',
59+
epubCover: ''
60+
});
61+
62+
const onSubmit = () => {
63+
if (!formData.title || formData.title.length <= 0) {
64+
ElMessage.error('请填写文件名');
65+
return;
66+
}
67+
if (!formData.epubDataPath || formData.epubDataPath.length <= 0) {
68+
ElMessage.error('请选择文件夹');
69+
return;
70+
}
71+
window.api.createEpub(Object.assign({}, formData));
72+
};
73+
/**
74+
* 选择文件夹
75+
*/
76+
function choseEpubDataPath() {
77+
const options: OpenDialogOptions = {
78+
title: '请选择文件夹',
79+
properties: ['openDirectory']
80+
};
81+
window.api.showOpenDialog(options, 'epubDataPath');
82+
}
83+
/**
84+
* 选择封面图片
85+
*/
86+
function choseCover() {
87+
const options: OpenDialogOptions = {
88+
title: '请选择图片',
89+
properties: ['openFile'],
90+
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png'] }]
91+
};
92+
window.api.showOpenDialog(options, 'epubCover');
93+
}
94+
/**
95+
* 选择文件夹、图片的回调
96+
*/
97+
window.api.openDialogCallback(async (_event, callbackMsg: string, pathStr: string) => {
98+
formData[callbackMsg] = pathStr;
99+
});
100+
/**
101+
* 输出日志
102+
*/
103+
window.api.outputEpubLog(async (_event, msg: string, flgAppend = false, flgHtml = false) => {
104+
outputLog(msg, flgAppend, flgHtml);
105+
});
106+
async function outputLog(msg: string, flgAppend = false, flgHtml = false) {
107+
if (flgAppend) {
108+
epubLogArr.push({ msg, flgHtml });
109+
} else {
110+
epubLogArr.length = 0;
111+
epubLogArr.push({ msg, flgHtml });
112+
}
113+
}
114+
</script>
115+
116+
<style scoped>
117+
.el-main {
118+
--el-main-padding: 5px 20px 20px 20px;
119+
}
120+
121+
:deep(.form-btn .el-form-item__content) {
122+
justify-content: center;
123+
}
124+
</style>

‎src/renderer/src/views/Setting.vue

-1
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ watch(settingInfo, async (_oldInfo, newInfo) => {
221221
for (const settingKey in newInfo) {
222222
const settingItem = newInfo[settingKey];
223223
if (settingItem != settingInfoOrigin[settingKey]) {
224-
console.log(settingKey, settingItem);
225224
storeSet(settingKey, settingItem);
226225
}
227226
}

0 commit comments

Comments
 (0)
Please sign in to comment.