reader-shell 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +14 -0
- package/README.md +4 -2
- package/cli.js +4 -4
- package/package.json +3 -2
- package/src/epubReader.js +1 -1
- package/src/txtReader.js +164 -0
- package/src/ui.js +36 -16
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# 终端电子书阅读器
|
|
2
2
|
|
|
3
|
-
一个基于 Node.js 的终端电子书阅读器,支持导入 epub
|
|
3
|
+
一个基于 Node.js 的终端电子书阅读器,支持导入 epub 文件、记住阅读进度、快捷键翻页。
|
|
4
|
+
|
|
5
|
+

|
|
4
6
|
|
|
5
7
|
## 功能特性
|
|
6
8
|
- 支持导入 epub 电子书
|
|
@@ -48,4 +50,4 @@ node src/ui.js ./books/三体.epub
|
|
|
48
50
|
- [chalk](https://www.npmjs.com/package/chalk) 终端字体颜色
|
|
49
51
|
|
|
50
52
|
## 许可协议
|
|
51
|
-
MIT
|
|
53
|
+
MIT
|
package/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startReader } from './src/ui.js';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
const
|
|
5
|
-
if (!
|
|
6
|
-
console.error('用法: reader-shell
|
|
4
|
+
const filePath = process.argv[2];
|
|
5
|
+
if (!filePath) {
|
|
6
|
+
console.error('用法: reader-shell <文件路径>');
|
|
7
7
|
process.exit(1);
|
|
8
8
|
}
|
|
9
|
-
startReader(path.resolve(
|
|
9
|
+
startReader(path.resolve(filePath));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reader-shell",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"main": "cli.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"blessed": "^0.1.81",
|
|
15
15
|
"chalk": "^5.4.1",
|
|
16
|
-
"epub": "^1.3.0"
|
|
16
|
+
"epub": "^1.3.0",
|
|
17
|
+
"iconv-lite": "^0.7.0"
|
|
17
18
|
},
|
|
18
19
|
"bin": {
|
|
19
20
|
"reader-shell": "./cli.js"
|
package/src/epubReader.js
CHANGED
|
@@ -19,7 +19,7 @@ function parseEpub(epubPath) {
|
|
|
19
19
|
chapters[idx] = '';
|
|
20
20
|
} else {
|
|
21
21
|
let text = data.toString('utf-8');
|
|
22
|
-
chapters[idx] = text.replace(/<[^>]+>/g, '').replace(/\r\n/g, '')
|
|
22
|
+
chapters[idx] = text.replace(/<[^>]+>/g, '').replace(/\r\n/g, '');
|
|
23
23
|
}
|
|
24
24
|
loaded++;
|
|
25
25
|
if (loaded === chapterIds.length) {
|
package/src/txtReader.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import iconv from 'iconv-lite';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 检测文件编码
|
|
7
|
+
* @param {Buffer} buffer 文件内容
|
|
8
|
+
* @returns {string} 编码名称
|
|
9
|
+
*/
|
|
10
|
+
function detectEncoding(buffer) {
|
|
11
|
+
// 检查BOM标记
|
|
12
|
+
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
|
|
13
|
+
return 'utf8';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
|
|
17
|
+
return 'utf16le';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
|
21
|
+
return 'utf16be';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 简单的编码检测:尝试UTF-8解码
|
|
25
|
+
try {
|
|
26
|
+
const utf8Content = buffer.toString('utf8');
|
|
27
|
+
// 检查是否包含UTF-8无效字符
|
|
28
|
+
if (!utf8Content.includes('�')) {
|
|
29
|
+
return 'utf8';
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// UTF-8解码失败,尝试其他编码
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 尝试GBK/GB2312编码(中文Windows常用编码)
|
|
36
|
+
try {
|
|
37
|
+
const gbkContent = iconv.decode(buffer, 'gbk');
|
|
38
|
+
// 检查解码是否成功(没有太多乱码字符)
|
|
39
|
+
if (!gbkContent.includes('�') || gbkContent.indexOf('�') / gbkContent.length < 0.1) {
|
|
40
|
+
return 'gbk';
|
|
41
|
+
}
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// GBK解码失败
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 默认使用UTF-8
|
|
47
|
+
return 'utf8';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 以正确的编码读取文件
|
|
52
|
+
* @param {string} filePath 文件路径
|
|
53
|
+
* @returns {Promise<string>} 文件内容
|
|
54
|
+
*/
|
|
55
|
+
function readFileWithCorrectEncoding(filePath) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
fs.readFile(filePath, (err, buffer) => {
|
|
58
|
+
if (err) {
|
|
59
|
+
reject(err);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const encoding = detectEncoding(buffer);
|
|
64
|
+
let content;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (encoding === 'utf8') {
|
|
68
|
+
content = buffer.toString('utf8');
|
|
69
|
+
} else {
|
|
70
|
+
content = iconv.decode(buffer, encoding);
|
|
71
|
+
}
|
|
72
|
+
resolve(content);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// 如果指定编码解码失败,尝试UTF-8
|
|
75
|
+
try {
|
|
76
|
+
content = buffer.toString('utf8');
|
|
77
|
+
resolve(content);
|
|
78
|
+
} catch (utf8Err) {
|
|
79
|
+
reject(new Error(`无法解码文件,尝试的编码: ${encoding}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析txt文件,返回章节文本数组
|
|
88
|
+
* @param {string} txtPath txt文件路径
|
|
89
|
+
* @returns {Promise<string[]>} 章节文本数组
|
|
90
|
+
*/
|
|
91
|
+
function parseTxt(txtPath) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
readFileWithCorrectEncoding(txtPath).then(data => {
|
|
94
|
+
let content = data;
|
|
95
|
+
let chapters = [];
|
|
96
|
+
|
|
97
|
+
// 先尝试按明确的章节标题分割
|
|
98
|
+
const lines = content.split('\n');
|
|
99
|
+
let currentChapter = '';
|
|
100
|
+
let chapterHeaders = [];
|
|
101
|
+
|
|
102
|
+
// 寻找所有章节标题的行号
|
|
103
|
+
lines.forEach((line, index) => {
|
|
104
|
+
if (/^第[一二三四五六七八九十百千万0-9]+[章节]/.test(line.trim())) {
|
|
105
|
+
chapterHeaders.push(index);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (chapterHeaders.length > 1) {
|
|
110
|
+
// 按找到的章节标题分割
|
|
111
|
+
for (let i = 0; i < chapterHeaders.length; i++) {
|
|
112
|
+
const startLine = chapterHeaders[i];
|
|
113
|
+
const endLine = i < chapterHeaders.length - 1 ? chapterHeaders[i + 1] : lines.length;
|
|
114
|
+
|
|
115
|
+
const chapterLines = lines.slice(startLine, endLine);
|
|
116
|
+
const chapterContent = chapterLines.join('\n').trim();
|
|
117
|
+
|
|
118
|
+
if (chapterContent.length > 0) {
|
|
119
|
+
chapters.push(chapterContent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 如果没有找到章节分割,按段落分割
|
|
125
|
+
if (chapters.length === 0) {
|
|
126
|
+
// 按多个空行分割
|
|
127
|
+
const sections = content.split(/\n\s*\n\s*\n/);
|
|
128
|
+
chapters = sections.filter(section => section.trim().length > 0);
|
|
129
|
+
|
|
130
|
+
// 如果分割后章节太少,按单个空行分割,然后每10个段落合并
|
|
131
|
+
if (chapters.length <= 1) {
|
|
132
|
+
const paragraphs = content.split(/\n\s*\n/);
|
|
133
|
+
chapters = [];
|
|
134
|
+
const chapterSize = 10;
|
|
135
|
+
for (let i = 0; i < paragraphs.length; i += chapterSize) {
|
|
136
|
+
const chapter = paragraphs.slice(i, i + chapterSize).join('\n\n');
|
|
137
|
+
if (chapter.trim().length > 0) {
|
|
138
|
+
chapters.push(chapter);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 如果还是没有合适的章节,整个文件作为一章
|
|
145
|
+
if (chapters.length === 0) {
|
|
146
|
+
chapters = [content];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 清理每章内容
|
|
150
|
+
chapters = chapters.map(chapter => {
|
|
151
|
+
return chapter
|
|
152
|
+
.replace(/\r\n/g, '\n') // 统一换行符
|
|
153
|
+
.replace(/\n{3,}/g, '\n\n') // 去除多余的空行
|
|
154
|
+
.trim();
|
|
155
|
+
}).filter(chapter => chapter.length > 0);
|
|
156
|
+
|
|
157
|
+
resolve(chapters);
|
|
158
|
+
}).catch(err => {
|
|
159
|
+
reject(err);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { parseTxt, detectEncoding, readFileWithCorrectEncoding };
|
package/src/ui.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import blessed from "blessed";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { parseEpub } from "./epubReader.js";
|
|
4
|
+
import { parseTxt } from "./txtReader.js";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import { getBookProgress, setBookProgress } from "./progressStore.js";
|
|
6
7
|
import { config } from "./config.js";
|
|
@@ -16,9 +17,19 @@ const colorFns = {
|
|
|
16
17
|
yellow: chalk.yellow,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
async function startReader(
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
async function startReader(filePath) {
|
|
21
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
+
let chapters;
|
|
23
|
+
|
|
24
|
+
if (ext === '.epub') {
|
|
25
|
+
chapters = await parseEpub(filePath);
|
|
26
|
+
} else if (ext === '.txt') {
|
|
27
|
+
chapters = await parseTxt(filePath);
|
|
28
|
+
} else {
|
|
29
|
+
throw new Error(`不支持的文件格式: ${ext}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const progress = getBookProgress(filePath);
|
|
22
33
|
let currentChapter = (progress && progress.chapter) || 0;
|
|
23
34
|
let currentScroll = (progress && progress.scroll) || 0;
|
|
24
35
|
|
|
@@ -34,7 +45,7 @@ async function startReader(epubPath) {
|
|
|
34
45
|
top: 0,
|
|
35
46
|
left: 0,
|
|
36
47
|
width: "100%",
|
|
37
|
-
height: "
|
|
48
|
+
height: "70%",
|
|
38
49
|
border: "none",
|
|
39
50
|
content: config.bossContent
|
|
40
51
|
});
|
|
@@ -72,9 +83,18 @@ async function startReader(epubPath) {
|
|
|
72
83
|
screen.append(box);
|
|
73
84
|
screen.append(status);
|
|
74
85
|
|
|
75
|
-
|
|
86
|
+
// 用于跟踪当前显示的章节,避免重复设置相同内容
|
|
87
|
+
let lastDisplayedChapter = -1;
|
|
88
|
+
|
|
89
|
+
function render(forceContentRefresh = false) {
|
|
76
90
|
const colorFn = colorFns[fontColor] || chalk.white;
|
|
77
|
-
|
|
91
|
+
|
|
92
|
+
// 只有当章节改变或强制刷新时才重新设置内容
|
|
93
|
+
if (forceContentRefresh || lastDisplayedChapter !== currentChapter) {
|
|
94
|
+
box.setContent(colorFn(chapters[currentChapter] || ""));
|
|
95
|
+
lastDisplayedChapter = currentChapter;
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
status.setContent(
|
|
79
99
|
`progress: ${currentScroll}/${box.getScrollHeight()} | chapter: ${currentChapter + 1}/${
|
|
80
100
|
chapters.length
|
|
@@ -86,14 +106,14 @@ async function startReader(epubPath) {
|
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
function saveProgress() {
|
|
89
|
-
setBookProgress(
|
|
109
|
+
setBookProgress(filePath, { chapter: currentChapter, scroll: currentScroll });
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
function changeChapter(chapter) {
|
|
93
113
|
currentChapter += chapter;
|
|
94
114
|
currentScroll = 0;
|
|
95
115
|
saveProgress();
|
|
96
|
-
render();
|
|
116
|
+
render(true); // 切换章节时强制刷新内容
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
// 快捷键
|
|
@@ -113,7 +133,7 @@ async function startReader(epubPath) {
|
|
|
113
133
|
changeChapter(-1)
|
|
114
134
|
return
|
|
115
135
|
}
|
|
116
|
-
render();
|
|
136
|
+
render(true);
|
|
117
137
|
});
|
|
118
138
|
screen.key(["down", "d"], () => {
|
|
119
139
|
currentScroll++;
|
|
@@ -121,7 +141,7 @@ async function startReader(epubPath) {
|
|
|
121
141
|
changeChapter(1)
|
|
122
142
|
return
|
|
123
143
|
}
|
|
124
|
-
render();
|
|
144
|
+
render(true);
|
|
125
145
|
});
|
|
126
146
|
screen.key(["a"], () => {
|
|
127
147
|
// 老板键
|
|
@@ -134,7 +154,7 @@ async function startReader(epubPath) {
|
|
|
134
154
|
box.height = 0
|
|
135
155
|
status.height = 0
|
|
136
156
|
}
|
|
137
|
-
render()
|
|
157
|
+
render(false) // 老板键切换时不强制刷新内容
|
|
138
158
|
});
|
|
139
159
|
screen.key(["q", "C-c"], () => {
|
|
140
160
|
saveProgress();
|
|
@@ -145,15 +165,15 @@ async function startReader(epubPath) {
|
|
|
145
165
|
saveProgress();
|
|
146
166
|
});
|
|
147
167
|
|
|
148
|
-
render();
|
|
168
|
+
render(true); // 初始渲染时强制设置内容
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
// 允许命令行直接运行
|
|
152
|
-
const
|
|
153
|
-
if (!
|
|
154
|
-
console.error("用法: node src/ui.js
|
|
172
|
+
const filePath = process.argv[2];
|
|
173
|
+
if (!filePath) {
|
|
174
|
+
console.error("用法: node src/ui.js <文件路径>");
|
|
155
175
|
process.exit(1);
|
|
156
176
|
}
|
|
157
|
-
startReader(path.resolve(
|
|
177
|
+
startReader(path.resolve(filePath));
|
|
158
178
|
|
|
159
179
|
export { startReader };
|