moyu-ghost 1.0.0
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/README.md +169 -0
- package/index.js +564 -0
- package/logger.js +184 -0
- package/package.json +31 -0
- package/parser.js +355 -0
- package/scripts/init.js +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# VS-Ghost 使用文档
|
|
2
|
+
|
|
3
|
+
`vs-ghost` 是一个运行在 VS Code 终端中的隐蔽阅读器。
|
|
4
|
+
它会把小说内容伪装成前端构建日志输出,适合在终端环境中阅读 `.txt` / `.epub` 书籍。
|
|
5
|
+
|
|
6
|
+
## 1. 功能概览
|
|
7
|
+
|
|
8
|
+
- 伪装输出:正文以构建日志形式输出,穿插噪声日志。
|
|
9
|
+
- 自动翻页为主:支持按阅读速度自动滚动。
|
|
10
|
+
- 手动调位:保留上下方向键快速调整当前位置。
|
|
11
|
+
- 目录解析与跳转:`.epub` 解析真实目录(nav/NCX),`.txt` 按章节标题规则生成伪目录。
|
|
12
|
+
- 老板键:`Ctrl + C` 触发伪构建崩溃并快速刷屏退出。
|
|
13
|
+
- 进度记忆:自动保存阅读位置与速度,下次继续。
|
|
14
|
+
|
|
15
|
+
## 2. 环境要求
|
|
16
|
+
|
|
17
|
+
- Node.js 18+(推荐 LTS,已在 Node 24 环境验证)
|
|
18
|
+
- Windows / macOS / Linux 终端均可(推荐 VS Code Integrated Terminal)
|
|
19
|
+
|
|
20
|
+
## 3. 安装与启动
|
|
21
|
+
|
|
22
|
+
1. 安装依赖:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. 初始化数据目录(建议先执行):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm run init
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. 放入小说文件到 `logs_data` 目录(支持 `.txt` / `.epub`)。
|
|
35
|
+
默认目录:
|
|
36
|
+
- Windows:`C:\Users\<用户名>\.vs-ghost\logs_data`
|
|
37
|
+
- macOS/Linux:`~/.vs-ghost/logs_data`
|
|
38
|
+
|
|
39
|
+
可选:你也可以设置环境变量 `VS_GHOST_HOME` 自定义根目录,程序会使用 `<VS_GHOST_HOME>/logs_data`。
|
|
40
|
+
|
|
41
|
+
4. 启动:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm start
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
5. 启动后输入书籍序号,回车确认。
|
|
48
|
+
|
|
49
|
+
## 4. 键位说明
|
|
50
|
+
|
|
51
|
+
| 按键 | 功能 | 说明 |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `a` | 自动翻页开关 | 主阅读模式,开/关自动滚动 |
|
|
54
|
+
| `Down Arrow` | 下一行 | 手动向前调位 |
|
|
55
|
+
| `Up Arrow` | 上一行 | 手动回退调位(会输出重处理警告) |
|
|
56
|
+
| `[` / `-` / `Left Arrow` | 降速 | 自动翻页速度降低 |
|
|
57
|
+
| `]` / `=` / `Right Arrow` | 加速 | 自动翻页速度提高 |
|
|
58
|
+
| `t` | 打开目录 | 打印目录并进入跳转输入模式 |
|
|
59
|
+
| `Ctrl + C` | 老板键 | 触发伪构建错误 + 50 行堆栈后退出 |
|
|
60
|
+
| `Esc` | 正常退出 | 直接退出程序(不触发伪崩溃) |
|
|
61
|
+
|
|
62
|
+
## 5. 自动翻页规则
|
|
63
|
+
|
|
64
|
+
- 速度单位:`han-chars/s`(每秒汉字数)。
|
|
65
|
+
- 默认速度:`10 han-chars/s`。
|
|
66
|
+
- 可调范围:`4 ~ 60 han-chars/s`。
|
|
67
|
+
- 计速只统计汉字,不统计空格、英文标点和符号。
|
|
68
|
+
- 最小翻页间隔:`500ms`(防止短句滚动过快)。
|
|
69
|
+
- 最大翻页间隔:`7000ms`(防止长句停留过久)。
|
|
70
|
+
|
|
71
|
+
## 6. 目录(TOC)跳转
|
|
72
|
+
|
|
73
|
+
按 `t` 后会打印目录,并提示输入跳转参数。
|
|
74
|
+
|
|
75
|
+
支持两种输入方式:
|
|
76
|
+
|
|
77
|
+
- 输入章节号(推荐):例如 `105`
|
|
78
|
+
- 输入目录序号:例如 `#105` 或 `i105`
|
|
79
|
+
|
|
80
|
+
优先级规则:
|
|
81
|
+
|
|
82
|
+
- 纯数字优先按“章节号”匹配。
|
|
83
|
+
- 若没有匹配章节号,再按“目录序号”匹配。
|
|
84
|
+
|
|
85
|
+
示例:
|
|
86
|
+
|
|
87
|
+
- 输入 `105` -> 优先跳转到 `第105章 ...`
|
|
88
|
+
- 输入 `#105` -> 强制跳到 TOC 第 105 项
|
|
89
|
+
|
|
90
|
+
## 7. 进度与配置保存
|
|
91
|
+
|
|
92
|
+
程序会在数据目录根路径写入 `.ghost-session.json`(默认在 `~/.vs-ghost/.ghost-session.json`),按书籍保存:
|
|
93
|
+
|
|
94
|
+
- `index`:当前阅读位置
|
|
95
|
+
- `autoCharsPerSecond`:自动翻页速度
|
|
96
|
+
- `updatedAt`:更新时间
|
|
97
|
+
|
|
98
|
+
重启后会自动续读。
|
|
99
|
+
如果你想恢复默认速度,可删除对应书籍会话项或删除整个 `.ghost-session.json`。
|
|
100
|
+
|
|
101
|
+
## 8. 目录结构
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
.
|
|
105
|
+
├─ index.js # 主程序入口(交互、状态、自动翻页、TOC 跳转)
|
|
106
|
+
├─ parser.js # 文本/EPUB 解析与 TOC 生成
|
|
107
|
+
├─ logger.js # 伪装日志输出
|
|
108
|
+
└─ 需求文档.md # 产品需求文档
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
运行时数据目录(默认):
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
~/.vs-ghost/
|
|
115
|
+
├─ logs_data/ # 书籍目录(.txt/.epub)
|
|
116
|
+
└─ .ghost-session.json # 进度与速度持久化
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 9. 常见问题
|
|
120
|
+
|
|
121
|
+
1. 为什么我改了默认速度,启动后还是旧速度?
|
|
122
|
+
- 因为会话文件保存了历史速度。删除 `~/.vs-ghost/.ghost-session.json`(或你自定义 `VS_GHOST_HOME` 下对应文件)即可。
|
|
123
|
+
|
|
124
|
+
2. 为什么 `105` 有时不是 TOC 第 105 项?
|
|
125
|
+
- 设计如此:纯数字优先当作“章节号”。要强制按 TOC 序号,请用 `#105`。
|
|
126
|
+
|
|
127
|
+
3. 为什么有些 TXT 没有目录?
|
|
128
|
+
- TXT 目录是规则识别(如 `第xx章`、`Chapter x`)。如果原文无明显标题,目录可能较少或为空。
|
|
129
|
+
|
|
130
|
+
## 10. 打包与跨电脑使用
|
|
131
|
+
|
|
132
|
+
### A. 本机打包
|
|
133
|
+
|
|
134
|
+
在项目根目录执行:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm pack
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
会生成一个压缩包,例如:`vs-ghost-1.0.0.tgz`。
|
|
141
|
+
|
|
142
|
+
### B. 在另一台电脑安装
|
|
143
|
+
|
|
144
|
+
把 `.tgz` 文件拷贝到目标电脑后执行:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm install -g ./vs-ghost-1.0.0.tgz
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
安装后可用命令:
|
|
151
|
+
|
|
152
|
+
- `vs-ghost-init`:创建数据目录并打印实际路径
|
|
153
|
+
- `vs-ghost`:启动阅读器
|
|
154
|
+
|
|
155
|
+
### C. 推荐使用流程(另一台电脑)
|
|
156
|
+
|
|
157
|
+
1. `vs-ghost-init`
|
|
158
|
+
2. 把小说放到命令输出的 `data path`
|
|
159
|
+
3. `vs-ghost`
|
|
160
|
+
|
|
161
|
+
### D. 可选:自定义数据目录
|
|
162
|
+
|
|
163
|
+
设置环境变量 `VS_GHOST_HOME` 后,`vs-ghost` 与 `vs-ghost-init` 都会使用该目录:
|
|
164
|
+
|
|
165
|
+
PowerShell:
|
|
166
|
+
|
|
167
|
+
```powershell
|
|
168
|
+
$env:VS_GHOST_HOME = "D:\\my-vs-ghost"
|
|
169
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import readline from 'node:readline/promises';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import keypress from 'keypress';
|
|
10
|
+
import ora from 'ora';
|
|
11
|
+
import { GhostLogger } from './logger.js';
|
|
12
|
+
import { parseArchive, scanArchives } from './parser.js';
|
|
13
|
+
|
|
14
|
+
const APP_HOME = resolveAppHome();
|
|
15
|
+
const LOGS_DIR = path.join(APP_HOME, 'logs_data');
|
|
16
|
+
const SESSION_FILE = path.join(APP_HOME, '.ghost-session.json');
|
|
17
|
+
|
|
18
|
+
const AUTO_MIN_CHARS_PER_SEC = 4;
|
|
19
|
+
const AUTO_MAX_CHARS_PER_SEC = 60;
|
|
20
|
+
const AUTO_STEP_CHARS_PER_SEC = 2;
|
|
21
|
+
const AUTO_DEFAULT_CHARS_PER_SEC = 10;
|
|
22
|
+
|
|
23
|
+
const AUTO_MIN_DELAY_MS = 500;
|
|
24
|
+
const AUTO_MAX_DELAY_MS = 7000;
|
|
25
|
+
const AUTO_END_PAUSE_MS = 220;
|
|
26
|
+
|
|
27
|
+
const LEGACY_BASE_LINE_CHARS = 14;
|
|
28
|
+
|
|
29
|
+
const appState = {
|
|
30
|
+
archive: null,
|
|
31
|
+
lines: [],
|
|
32
|
+
toc: [],
|
|
33
|
+
nextIndex: 0,
|
|
34
|
+
autoCharsPerSecond: AUTO_DEFAULT_CHARS_PER_SEC,
|
|
35
|
+
autoPagingEnabled: false,
|
|
36
|
+
autoTimer: null,
|
|
37
|
+
autoTickQueued: false,
|
|
38
|
+
logger: null,
|
|
39
|
+
crashing: false,
|
|
40
|
+
prompting: false,
|
|
41
|
+
actionChain: Promise.resolve()
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
process.on('SIGINT', () => {
|
|
45
|
+
void triggerBossKey();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
main().catch((error) => {
|
|
49
|
+
console.error(chalk.red(`[fatal] ${error.message}`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const archive = await selectArchive();
|
|
55
|
+
appState.archive = archive;
|
|
56
|
+
|
|
57
|
+
const loading = ora(`parsing ${archive.name} ...`).start();
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = await parseArchive(archive.filePath);
|
|
61
|
+
loading.succeed(`archive ready: ${archive.name}`);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
loading.fail(`failed to parse ${archive.name}`);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!parsed.lines.length) {
|
|
68
|
+
throw new Error('Archive has no readable lines.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
appState.lines = parsed.lines;
|
|
72
|
+
appState.toc = parsed.toc;
|
|
73
|
+
|
|
74
|
+
const resumeState = await loadResumeState(archive.filePath, parsed.lines.length);
|
|
75
|
+
appState.nextIndex = resumeState.index;
|
|
76
|
+
appState.autoCharsPerSecond = resumeState.autoCharsPerSecond;
|
|
77
|
+
|
|
78
|
+
appState.logger = new GhostLogger({
|
|
79
|
+
archiveName: archive.name,
|
|
80
|
+
totalLines: parsed.lines.length
|
|
81
|
+
});
|
|
82
|
+
appState.logger.printStartup(
|
|
83
|
+
appState.nextIndex,
|
|
84
|
+
appState.autoCharsPerSecond,
|
|
85
|
+
appState.toc.length
|
|
86
|
+
);
|
|
87
|
+
appState.logger.printNoise(2);
|
|
88
|
+
|
|
89
|
+
setupKeyboard();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function selectArchive() {
|
|
93
|
+
const spinner = ora(`scanning ${LOGS_DIR} ...`).start();
|
|
94
|
+
const archives = await scanArchives(LOGS_DIR);
|
|
95
|
+
spinner.stop();
|
|
96
|
+
|
|
97
|
+
if (!archives.length) {
|
|
98
|
+
console.log(chalk.yellow(`[ghost] no .txt/.epub files found in ${LOGS_DIR}`));
|
|
99
|
+
console.log(chalk.gray(`[ghost] put source files into ${LOGS_DIR} and rerun npm start`));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(chalk.blue('[webpack-cli] discovered log archives:'));
|
|
104
|
+
for (let i = 0; i < archives.length; i += 1) {
|
|
105
|
+
const archive = archives[i];
|
|
106
|
+
console.log(
|
|
107
|
+
chalk.gray(
|
|
108
|
+
` ${i + 1}. ${archive.name} (${archive.extension.slice(1).toUpperCase()}, ${humanSize(archive.size)})`
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rl = readline.createInterface({
|
|
114
|
+
input: process.stdin,
|
|
115
|
+
output: process.stdout
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
while (true) {
|
|
120
|
+
const answer = await rl.question('Select archive #: ');
|
|
121
|
+
const index = Number(answer.trim()) - 1;
|
|
122
|
+
if (Number.isInteger(index) && index >= 0 && index < archives.length) {
|
|
123
|
+
return archives[index];
|
|
124
|
+
}
|
|
125
|
+
console.log(chalk.yellow('[WARN] invalid selection, try again.'));
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
rl.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function setupKeyboard() {
|
|
133
|
+
keypress(process.stdin);
|
|
134
|
+
process.stdin.on('keypress', (ch, key) => {
|
|
135
|
+
if (key?.name === 'escape') {
|
|
136
|
+
void exitNormally();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key?.ctrl && key?.name === 'c') {
|
|
140
|
+
void triggerBossKey();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (appState.crashing || appState.prompting) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (key?.name === 'down') {
|
|
148
|
+
enqueueAction(stepNext);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (key?.name === 'up') {
|
|
152
|
+
enqueueAction(stepPrevious);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (ch === 'a') {
|
|
156
|
+
enqueueAction(toggleAutoPaging);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (isSlowerKey(ch, key)) {
|
|
160
|
+
enqueueAction(() => updateAutoSpeed(-AUTO_STEP_CHARS_PER_SEC));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (isFasterKey(ch, key)) {
|
|
164
|
+
enqueueAction(() => updateAutoSpeed(+AUTO_STEP_CHARS_PER_SEC));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (ch === 't') {
|
|
168
|
+
enqueueAction(openTocMenu);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (process.stdin.isTTY) {
|
|
173
|
+
process.stdin.setRawMode(true);
|
|
174
|
+
}
|
|
175
|
+
process.stdin.resume();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function enqueueAction(action) {
|
|
179
|
+
appState.actionChain = appState.actionChain
|
|
180
|
+
.then(() => action())
|
|
181
|
+
.catch((error) => {
|
|
182
|
+
console.error(chalk.red(`[runtime] ${error.message}`));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function stepNext() {
|
|
187
|
+
if (appState.nextIndex >= appState.lines.length) {
|
|
188
|
+
appState.logger.printEnd();
|
|
189
|
+
if (appState.autoPagingEnabled) {
|
|
190
|
+
stopAutoPaging('eof');
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const line = appState.lines[appState.nextIndex];
|
|
196
|
+
appState.logger.printFrame(line, appState.nextIndex + 1);
|
|
197
|
+
appState.nextIndex += 1;
|
|
198
|
+
await saveProgress();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function stepPrevious() {
|
|
203
|
+
appState.logger.printRetry();
|
|
204
|
+
if (appState.nextIndex === 0) {
|
|
205
|
+
appState.logger.printNoise(2);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const targetIndex = Math.max(0, appState.nextIndex - 2);
|
|
210
|
+
appState.nextIndex = targetIndex;
|
|
211
|
+
await stepNext();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function openTocMenu() {
|
|
215
|
+
if (!appState.toc.length) {
|
|
216
|
+
appState.logger.printTocEmpty();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
appState.logger.printTocHeader(appState.toc.length);
|
|
221
|
+
for (let i = 0; i < appState.toc.length; i += 1) {
|
|
222
|
+
const item = appState.toc[i];
|
|
223
|
+
appState.logger.printTocEntry(i + 1, item.title, item.lineIndex + 1);
|
|
224
|
+
}
|
|
225
|
+
appState.logger.printTocPrompt();
|
|
226
|
+
|
|
227
|
+
const answer = await promptLine('TOC jump (chapter or #index): ');
|
|
228
|
+
if (!answer) {
|
|
229
|
+
appState.logger.printTocCanceled();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const selection = resolveTocSelection(answer, appState.toc);
|
|
234
|
+
if (!selection) {
|
|
235
|
+
appState.logger.printTocInvalid(answer);
|
|
236
|
+
appState.logger.printNoise(2);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const selected = selection.item;
|
|
241
|
+
appState.nextIndex = clampNumber(selected.lineIndex, 0, appState.lines.length - 1);
|
|
242
|
+
await saveProgress();
|
|
243
|
+
appState.logger.printTocJump(
|
|
244
|
+
selected.title,
|
|
245
|
+
appState.nextIndex + 1,
|
|
246
|
+
selection.mode
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (appState.autoPagingEnabled && !appState.autoTickQueued) {
|
|
250
|
+
scheduleNextAutoTick(0);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveTocSelection(input, toc) {
|
|
255
|
+
const trimmed = String(input ?? '').trim();
|
|
256
|
+
const indexOnly = trimmed.startsWith('#')
|
|
257
|
+
? Number(trimmed.slice(1))
|
|
258
|
+
: /^i\d+$/i.test(trimmed)
|
|
259
|
+
? Number(trimmed.slice(1))
|
|
260
|
+
: null;
|
|
261
|
+
|
|
262
|
+
if (Number.isInteger(indexOnly) && indexOnly > 0) {
|
|
263
|
+
const byIndexOnly = indexOnly - 1;
|
|
264
|
+
if (byIndexOnly >= 0 && byIndexOnly < toc.length) {
|
|
265
|
+
return { item: toc[byIndexOnly], mode: 'index' };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const numeric = Number(trimmed);
|
|
271
|
+
if (!Number.isInteger(numeric) || numeric <= 0) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Priority: chapter number -> toc index.
|
|
276
|
+
const byChapter = toc.find((item) => extractChapterNumber(item.title) === numeric);
|
|
277
|
+
if (byChapter) {
|
|
278
|
+
return { item: byChapter, mode: 'chapter' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const index = numeric - 1;
|
|
282
|
+
if (index >= 0 && index < toc.length) {
|
|
283
|
+
return { item: toc[index], mode: 'index' };
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function promptLine(text) {
|
|
289
|
+
appState.prompting = true;
|
|
290
|
+
if (process.stdin.isTTY) {
|
|
291
|
+
process.stdin.setRawMode(false);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rl = readline.createInterface({
|
|
295
|
+
input: process.stdin,
|
|
296
|
+
output: process.stdout
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
return (await rl.question(chalk.cyan(text))).trim();
|
|
301
|
+
} finally {
|
|
302
|
+
rl.close();
|
|
303
|
+
appState.prompting = false;
|
|
304
|
+
if (process.stdin.isTTY) {
|
|
305
|
+
process.stdin.setRawMode(true);
|
|
306
|
+
}
|
|
307
|
+
process.stdin.resume();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function loadResumeState(filePath, totalLines) {
|
|
312
|
+
const session = await loadSession();
|
|
313
|
+
const key = archiveKey(filePath);
|
|
314
|
+
const savedIndex = Number(session?.[key]?.index);
|
|
315
|
+
const savedSpeed = Number(session?.[key]?.autoCharsPerSecond);
|
|
316
|
+
const legacyIntervalMs = Number(session?.[key]?.autoIntervalMs);
|
|
317
|
+
const resolvedSpeed = Number.isFinite(savedSpeed)
|
|
318
|
+
? savedSpeed
|
|
319
|
+
: legacyIntervalToCharsPerSecond(legacyIntervalMs);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
index: Number.isInteger(savedIndex) && savedIndex >= 0 && savedIndex < totalLines ? savedIndex : 0,
|
|
323
|
+
autoCharsPerSecond: clampNumber(
|
|
324
|
+
resolvedSpeed,
|
|
325
|
+
AUTO_MIN_CHARS_PER_SEC,
|
|
326
|
+
AUTO_MAX_CHARS_PER_SEC
|
|
327
|
+
)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function saveProgress() {
|
|
332
|
+
if (!appState.archive) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await fs.mkdir(APP_HOME, { recursive: true });
|
|
337
|
+
const session = await loadSession();
|
|
338
|
+
const key = archiveKey(appState.archive.filePath);
|
|
339
|
+
session[key] = {
|
|
340
|
+
index: appState.nextIndex,
|
|
341
|
+
autoCharsPerSecond: appState.autoCharsPerSecond,
|
|
342
|
+
updatedAt: new Date().toISOString()
|
|
343
|
+
};
|
|
344
|
+
await fs.writeFile(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`, 'utf8');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function loadSession() {
|
|
348
|
+
try {
|
|
349
|
+
const raw = await fs.readFile(SESSION_FILE, 'utf8');
|
|
350
|
+
return JSON.parse(raw);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error.code === 'ENOENT') {
|
|
353
|
+
return {};
|
|
354
|
+
}
|
|
355
|
+
return {};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function triggerBossKey() {
|
|
360
|
+
if (appState.crashing) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
appState.crashing = true;
|
|
364
|
+
stopAutoPaging('panic');
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
await saveProgress();
|
|
368
|
+
} catch {
|
|
369
|
+
// Ignore progress persistence errors in emergency exit.
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!appState.logger) {
|
|
373
|
+
appState.logger = new GhostLogger({
|
|
374
|
+
archiveName: 'bootstrap',
|
|
375
|
+
totalLines: 1
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
appState.logger.printBossCrash();
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function exitNormally() {
|
|
383
|
+
if (appState.crashing) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (appState.autoTimer) {
|
|
387
|
+
clearTimeout(appState.autoTimer);
|
|
388
|
+
appState.autoTimer = null;
|
|
389
|
+
}
|
|
390
|
+
appState.autoPagingEnabled = false;
|
|
391
|
+
appState.autoTickQueued = false;
|
|
392
|
+
try {
|
|
393
|
+
await saveProgress();
|
|
394
|
+
} catch {
|
|
395
|
+
// Ignore persistence errors on normal exit.
|
|
396
|
+
}
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function toggleAutoPaging() {
|
|
401
|
+
if (appState.autoPagingEnabled) {
|
|
402
|
+
stopAutoPaging('manual');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
appState.autoPagingEnabled = true;
|
|
406
|
+
appState.autoTickQueued = false;
|
|
407
|
+
scheduleNextAutoTick(0);
|
|
408
|
+
appState.logger.printAutoState(true, appState.autoCharsPerSecond);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function stopAutoPaging(reason = '') {
|
|
412
|
+
if (!appState.autoPagingEnabled && !appState.autoTimer) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
appState.autoPagingEnabled = false;
|
|
416
|
+
appState.autoTickQueued = false;
|
|
417
|
+
if (appState.autoTimer) {
|
|
418
|
+
clearTimeout(appState.autoTimer);
|
|
419
|
+
appState.autoTimer = null;
|
|
420
|
+
}
|
|
421
|
+
if (appState.logger) {
|
|
422
|
+
appState.logger.printAutoState(false, appState.autoCharsPerSecond, reason);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function updateAutoSpeed(deltaCharsPerSecond) {
|
|
427
|
+
const nextSpeed = clampNumber(
|
|
428
|
+
appState.autoCharsPerSecond + deltaCharsPerSecond,
|
|
429
|
+
AUTO_MIN_CHARS_PER_SEC,
|
|
430
|
+
AUTO_MAX_CHARS_PER_SEC
|
|
431
|
+
);
|
|
432
|
+
appState.autoCharsPerSecond = nextSpeed;
|
|
433
|
+
appState.logger.printAutoSpeed(nextSpeed);
|
|
434
|
+
await saveProgress();
|
|
435
|
+
|
|
436
|
+
if (appState.autoPagingEnabled && !appState.autoTickQueued) {
|
|
437
|
+
scheduleNextAutoTick(0);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function scheduleNextAutoTick(delayMs) {
|
|
442
|
+
if (!appState.autoPagingEnabled) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (appState.autoTimer) {
|
|
447
|
+
clearTimeout(appState.autoTimer);
|
|
448
|
+
}
|
|
449
|
+
appState.autoTimer = setTimeout(() => {
|
|
450
|
+
if (appState.crashing || !appState.autoPagingEnabled) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (appState.prompting || appState.autoTickQueued) {
|
|
454
|
+
scheduleNextAutoTick(AUTO_MIN_DELAY_MS);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const pendingLine = appState.lines[appState.nextIndex] ?? '';
|
|
459
|
+
const delayForNextLine = calculateAutoDelayMs(
|
|
460
|
+
pendingLine,
|
|
461
|
+
appState.autoCharsPerSecond
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
appState.autoTickQueued = true;
|
|
465
|
+
enqueueAction(async () => {
|
|
466
|
+
try {
|
|
467
|
+
const moved = await stepNext();
|
|
468
|
+
if (moved && appState.autoPagingEnabled) {
|
|
469
|
+
scheduleNextAutoTick(delayForNextLine);
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
appState.autoTickQueued = false;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}, Math.max(0, delayMs));
|
|
476
|
+
|
|
477
|
+
if (typeof appState.autoTimer.unref === 'function') {
|
|
478
|
+
appState.autoTimer.unref();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function calculateAutoDelayMs(line, charsPerSecond) {
|
|
483
|
+
const chars = countReadableChars(line);
|
|
484
|
+
const rawDelay = (chars / charsPerSecond) * 1000;
|
|
485
|
+
const punctuationPause = /[。!?.!?;;]$/.test(String(line).trim())
|
|
486
|
+
? AUTO_END_PAUSE_MS
|
|
487
|
+
: 0;
|
|
488
|
+
|
|
489
|
+
return clampNumber(
|
|
490
|
+
Math.round(rawDelay + punctuationPause),
|
|
491
|
+
AUTO_MIN_DELAY_MS,
|
|
492
|
+
AUTO_MAX_DELAY_MS
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function countReadableChars(line) {
|
|
497
|
+
const hanCount = Array.from(String(line ?? ''))
|
|
498
|
+
.filter((char) => isHanCharacter(char))
|
|
499
|
+
.length;
|
|
500
|
+
return Math.max(1, hanCount);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function legacyIntervalToCharsPerSecond(intervalMs) {
|
|
504
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
505
|
+
return AUTO_DEFAULT_CHARS_PER_SEC;
|
|
506
|
+
}
|
|
507
|
+
return clampNumber(
|
|
508
|
+
(LEGACY_BASE_LINE_CHARS * 1000) / intervalMs,
|
|
509
|
+
AUTO_MIN_CHARS_PER_SEC,
|
|
510
|
+
AUTO_MAX_CHARS_PER_SEC
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function archiveKey(filePath) {
|
|
515
|
+
return path.relative(LOGS_DIR, filePath).replace(/\\/g, '/');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function humanSize(size) {
|
|
519
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
520
|
+
let current = size;
|
|
521
|
+
let unit = 0;
|
|
522
|
+
while (current >= 1024 && unit < units.length - 1) {
|
|
523
|
+
current /= 1024;
|
|
524
|
+
unit += 1;
|
|
525
|
+
}
|
|
526
|
+
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function clampNumber(value, min, max) {
|
|
530
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function isSlowerKey(ch, key) {
|
|
534
|
+
return ch === '[' || ch === '-' || ch === '_' || key?.name === 'left';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isFasterKey(ch, key) {
|
|
538
|
+
return ch === ']' || ch === '=' || ch === '+' || key?.name === 'right';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function isHanCharacter(char) {
|
|
542
|
+
return /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(char);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractChapterNumber(title) {
|
|
546
|
+
const text = String(title ?? '').trim();
|
|
547
|
+
const zh = text.match(/^第\s*(\d+)\s*[章回节卷]/);
|
|
548
|
+
if (zh) {
|
|
549
|
+
return Number(zh[1]);
|
|
550
|
+
}
|
|
551
|
+
const en = text.match(/^(?:chapter|chap\.?)\s*(\d+)/i);
|
|
552
|
+
if (en) {
|
|
553
|
+
return Number(en[1]);
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function resolveAppHome() {
|
|
559
|
+
const fromEnv = process.env.VS_GHOST_HOME?.trim();
|
|
560
|
+
if (fromEnv) {
|
|
561
|
+
return path.resolve(fromEnv);
|
|
562
|
+
}
|
|
563
|
+
return path.join(os.homedir(), '.vs-ghost');
|
|
564
|
+
}
|
package/logger.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const NOISE_TEMPLATES = [
|
|
4
|
+
() => `[webpack-cli] cache invalidated for chunk-${randomInt(7, 42)} (${randomHash(6)})`,
|
|
5
|
+
() => `[HMR] heartbeat sync completed in ${randomInt(8, 44)}ms`,
|
|
6
|
+
() => `[vite] pre-bundling dependency graph node ${randomInt(110, 980)}`,
|
|
7
|
+
() => `[ESLint] checked ${randomInt(4, 25)} files, ${randomInt(0, 2)} warnings`,
|
|
8
|
+
() => `Asset optimization: dist/runtime-${randomHash(8)}.js (${randomInt(22, 96)}.1 kB)`,
|
|
9
|
+
() => `[Parser] scanned src/modules/memo-${randomHash(5)}.tsx`,
|
|
10
|
+
() => `[ts-loader] transpiled ${randomInt(2, 12)} modules with incremental cache`,
|
|
11
|
+
() => `[build] tree-shaking ${randomInt(2, 11)} dead exports from vendor chunk`,
|
|
12
|
+
() => `[rollup] emitted sourcemap for app-shell-${randomHash(4)}.css`
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const CONTENT_TEMPLATES = [
|
|
16
|
+
(line, index, total) =>
|
|
17
|
+
`[INFO] [Parser] Analyzed dependency #${index}/${total}: "${line}"`,
|
|
18
|
+
(line) =>
|
|
19
|
+
`[INFO] [AssetGraph] Parsed virtual module -> "${line}"`,
|
|
20
|
+
(line) =>
|
|
21
|
+
`[INFO] [Loader] Linked text payload from chunk: "${line}"`,
|
|
22
|
+
(line) =>
|
|
23
|
+
`[INFO] [Resolver] Confirmed import trace: "${line}"`
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const STACK_TEMPLATES = [
|
|
27
|
+
() =>
|
|
28
|
+
` at Object.compile (node_modules/webpack/lib/Compiler.js:${randomInt(80, 490)}:${randomInt(3, 98)})`,
|
|
29
|
+
() =>
|
|
30
|
+
` at runBuildPipeline (node_modules/vite/dist/node/chunks/dep-${randomHash(8)}.js:${randomInt(40, 910)}:${randomInt(3, 98)})`,
|
|
31
|
+
() =>
|
|
32
|
+
` at async transformWithEsbuild (node_modules/esbuild/lib/main.js:${randomInt(200, 1200)}:${randomInt(3, 98)})`,
|
|
33
|
+
() =>
|
|
34
|
+
` at processTicksAndRejections (node:internal/process/task_queues:${randomInt(40, 95)}:${randomInt(3, 20)})`,
|
|
35
|
+
() =>
|
|
36
|
+
` at ModuleLoader.resolve (node_modules/webpack/lib/NormalModule.js:${randomInt(50, 700)}:${randomInt(3, 98)})`
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export class GhostLogger {
|
|
40
|
+
constructor({ archiveName, totalLines }) {
|
|
41
|
+
this.archiveName = archiveName;
|
|
42
|
+
this.totalLines = totalLines;
|
|
43
|
+
this.buildToken = randomHash(6);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
printStartup(resumeIndex, charsPerSecond, tocCount) {
|
|
47
|
+
console.log(chalk.blue(`[webpack-cli] build mode=production token=${this.buildToken}`));
|
|
48
|
+
console.log(chalk.gray(`[ghost] attached archive: ${this.archiveName}`));
|
|
49
|
+
console.log(chalk.dim(`[ghost] source lines: ${this.totalLines}, resume at ${resumeIndex + 1}`));
|
|
50
|
+
console.log(chalk.dim(`[ghost] auto speed: ${charsPerSecond} han-chars/s, toc: ${tocCount} entries`));
|
|
51
|
+
console.log(
|
|
52
|
+
chalk.gray(
|
|
53
|
+
'[ghost] keys: down next | up prev | a auto | [/left/- slower | ]/right/= faster | t toc | ctrl+c boss | esc exit'
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
printFrame(line, index) {
|
|
59
|
+
const content = choose(CONTENT_TEMPLATES)(compactLine(line), index, this.totalLines);
|
|
60
|
+
console.log(chalk.green(content));
|
|
61
|
+
this.printNoise(randomInt(2, 3));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
printRetry() {
|
|
65
|
+
console.log(chalk.yellow('[WARN] Re-processing chunk...'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
printAutoState(enabled, charsPerSecond, reason = '') {
|
|
69
|
+
const suffix = reason ? ` (${reason})` : '';
|
|
70
|
+
if (enabled) {
|
|
71
|
+
console.log(
|
|
72
|
+
chalk.white(
|
|
73
|
+
`[INFO] [Scheduler] Auto-paging enabled: speed=${charsPerSecond} han-chars/s${suffix}`
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log(
|
|
79
|
+
chalk.yellow(
|
|
80
|
+
`[WARN] [Scheduler] Auto-paging disabled${suffix}`
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
printAutoSpeed(charsPerSecond) {
|
|
86
|
+
console.log(
|
|
87
|
+
chalk.white(`[INFO] [Scheduler] Auto-paging speed updated -> ${charsPerSecond} han-chars/s`)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
printTocHeader(total) {
|
|
92
|
+
console.log(chalk.blue(`[webpack-cli] parsed table of contents (${total} entries)`));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
printTocEntry(index, title, lineNumber) {
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.white(
|
|
98
|
+
`[INFO] [TOC] #${index} @line ${lineNumber}: ${compactLine(title)}`
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
printTocPrompt() {
|
|
104
|
+
console.log(chalk.blue('[webpack-cli] toc jump mode, input chapter number (or #toc-index) and press enter'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
printTocEmpty() {
|
|
108
|
+
console.log(chalk.yellow('[WARN] [TOC] no chapters detected in current archive'));
|
|
109
|
+
this.printNoise(2);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
printTocInvalid(input) {
|
|
113
|
+
console.log(chalk.yellow(`[WARN] [TOC] invalid index "${input}"`));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
printTocJump(title, lineNumber, mode = 'index') {
|
|
117
|
+
const label = mode === 'chapter' ? 'chapter' : 'index';
|
|
118
|
+
console.log(
|
|
119
|
+
chalk.white(`[INFO] [TOC] jumped by ${label} to line ${lineNumber}: ${compactLine(title)}`)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
printTocCanceled() {
|
|
124
|
+
console.log(chalk.gray('[webpack-cli] toc jump canceled'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
printEnd() {
|
|
128
|
+
console.log(chalk.blue('[webpack-cli] reached end of source map stream'));
|
|
129
|
+
this.printNoise(2);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
printBossCrash() {
|
|
133
|
+
console.log(
|
|
134
|
+
chalk.redBright(
|
|
135
|
+
'ERROR in ./src/main.ts Module build failed (from ./node_modules/ts-loader):'
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
for (let i = 0; i < 50; i += 1) {
|
|
139
|
+
console.log(chalk.red(choose(STACK_TEMPLATES)()));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
printNoise(count) {
|
|
144
|
+
for (let i = 0; i < count; i += 1) {
|
|
145
|
+
console.log(colorNoise(choose(NOISE_TEMPLATES)()));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function colorNoise(line) {
|
|
151
|
+
const pick = randomInt(0, 2);
|
|
152
|
+
if (pick === 0) {
|
|
153
|
+
return chalk.dim(line);
|
|
154
|
+
}
|
|
155
|
+
if (pick === 1) {
|
|
156
|
+
return chalk.gray(line);
|
|
157
|
+
}
|
|
158
|
+
return chalk.blue(line);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function compactLine(line) {
|
|
162
|
+
const trimmed = String(line).replace(/\s+/g, ' ').trim();
|
|
163
|
+
if (trimmed.length <= 110) {
|
|
164
|
+
return trimmed;
|
|
165
|
+
}
|
|
166
|
+
return `${trimmed.slice(0, 107)}...`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function randomInt(min, max) {
|
|
170
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function randomHash(length) {
|
|
174
|
+
const alphabet = 'abcdef0123456789';
|
|
175
|
+
let out = '';
|
|
176
|
+
for (let i = 0; i < length; i += 1) {
|
|
177
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function choose(array) {
|
|
183
|
+
return array[Math.floor(Math.random() * array.length)];
|
|
184
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moyu-ghost",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Stealth CLI reader disguised as frontend build logs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"moyu-ghost": "index.js",
|
|
9
|
+
"moyu-ghost-init": "scripts/init.js",
|
|
10
|
+
"vs-ghost": "index.js",
|
|
11
|
+
"vs-ghost-init": "scripts/init.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"logger.js",
|
|
16
|
+
"parser.js",
|
|
17
|
+
"scripts/init.js",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node index.js",
|
|
22
|
+
"init": "node scripts/init.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"chalk": "^5.3.0",
|
|
26
|
+
"epub-parser": "^0.2.5",
|
|
27
|
+
"keypress": "^0.2.1",
|
|
28
|
+
"node-html-parser": "^6.1.13",
|
|
29
|
+
"ora": "^8.1.1"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/parser.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { parse as parseHtml } from 'node-html-parser';
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const epubParser = require('epub-parser');
|
|
8
|
+
|
|
9
|
+
const SUPPORTED_EXTENSIONS = new Set(['.txt', '.epub']);
|
|
10
|
+
const MAX_TOC_ENTRIES = 5000;
|
|
11
|
+
|
|
12
|
+
export async function scanArchives(logsDir) {
|
|
13
|
+
await fs.mkdir(logsDir, { recursive: true });
|
|
14
|
+
const entries = await fs.readdir(logsDir, { withFileTypes: true });
|
|
15
|
+
|
|
16
|
+
const archives = [];
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (!entry.isFile()) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
22
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const filePath = path.join(logsDir, entry.name);
|
|
26
|
+
const stat = await fs.stat(filePath);
|
|
27
|
+
archives.push({
|
|
28
|
+
name: entry.name,
|
|
29
|
+
extension,
|
|
30
|
+
filePath,
|
|
31
|
+
size: stat.size
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
archives.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
|
36
|
+
return archives;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function parseArchive(filePath) {
|
|
40
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
41
|
+
if (extension === '.txt') {
|
|
42
|
+
return readTxt(filePath);
|
|
43
|
+
}
|
|
44
|
+
if (extension === '.epub') {
|
|
45
|
+
return readEpub(filePath);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Unsupported archive format: ${extension}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readTxt(filePath) {
|
|
51
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
52
|
+
const lines = splitLines(content);
|
|
53
|
+
return {
|
|
54
|
+
lines,
|
|
55
|
+
toc: buildTxtToc(lines)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readEpub(filePath) {
|
|
60
|
+
const epubData = await openEpub(filePath);
|
|
61
|
+
const zip = epubParser.getZip();
|
|
62
|
+
const context = {
|
|
63
|
+
itemHashByHref: epubData?.easy?.itemHashByHref ?? {},
|
|
64
|
+
opsRoot: epubData?.paths?.opsRoot ?? ''
|
|
65
|
+
};
|
|
66
|
+
const sections = extractSpineTargets(epubData, context);
|
|
67
|
+
|
|
68
|
+
const lines = [];
|
|
69
|
+
const sectionLineStartMap = new Map();
|
|
70
|
+
for (const itemPath of sections) {
|
|
71
|
+
if (!sectionLineStartMap.has(itemPath)) {
|
|
72
|
+
sectionLineStartMap.set(itemPath, lines.length);
|
|
73
|
+
}
|
|
74
|
+
const file = zip.file(itemPath);
|
|
75
|
+
if (!file) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const html = file.asText();
|
|
79
|
+
const text = stripHtml(html);
|
|
80
|
+
lines.push(...splitLines(text));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let outputLines = lines;
|
|
84
|
+
if (lines.length > 0) {
|
|
85
|
+
const toc = buildEpubToc(epubData, context, sectionLineStartMap, lines.length);
|
|
86
|
+
return {
|
|
87
|
+
lines,
|
|
88
|
+
toc: toc.length > 0 ? toc : buildTxtToc(lines)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fallbackStrings = [];
|
|
93
|
+
collectStrings(epubData, fallbackStrings);
|
|
94
|
+
outputLines = fallbackStrings
|
|
95
|
+
.map((value) => stripHtml(value))
|
|
96
|
+
.flatMap((value) => splitLines(value))
|
|
97
|
+
.filter((line) => line.length >= 2);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
lines: uniqueLines(outputLines),
|
|
101
|
+
toc: buildTxtToc(outputLines)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function openEpub(filePath) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
epubParser.open(filePath, (error, epubData) => {
|
|
108
|
+
if (error) {
|
|
109
|
+
reject(error);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
resolve(epubData);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractSpineTargets(epubData, context) {
|
|
118
|
+
const easy = epubData?.easy ?? {};
|
|
119
|
+
const linearSpine = easy.linearSpine ?? {};
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const targets = [];
|
|
122
|
+
for (const value of Object.values(linearSpine)) {
|
|
123
|
+
const href = value?.item?.$?.href;
|
|
124
|
+
if (!href) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const normalized = resolveSpinePath(context, href);
|
|
128
|
+
if (normalized && !seen.has(normalized)) {
|
|
129
|
+
seen.add(normalized);
|
|
130
|
+
targets.push(normalized);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return targets;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveSpinePath(context, href) {
|
|
137
|
+
const cleanedHref = normalizePathKey(href);
|
|
138
|
+
if (!cleanedHref) {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (context.itemHashByHref[cleanedHref]) {
|
|
143
|
+
return cleanedHref;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const candidate = normalizePathKey(`${context.opsRoot}${cleanedHref}`);
|
|
147
|
+
if (context.itemHashByHref[candidate]) {
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return candidate;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildEpubToc(epubData, context, sectionLineStartMap, totalLines) {
|
|
155
|
+
const entries = parseEpubNavEntries(epubData);
|
|
156
|
+
if (entries.length === 0) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const resolved = [];
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const lineIndex = resolveTocLineIndex(entry.href, context, sectionLineStartMap);
|
|
164
|
+
if (lineIndex < 0 || lineIndex >= totalLines) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const title = compactTitle(entry.title);
|
|
168
|
+
if (!title) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const signature = `${title}@@${lineIndex}`;
|
|
172
|
+
if (seen.has(signature)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
seen.add(signature);
|
|
176
|
+
resolved.push({
|
|
177
|
+
title,
|
|
178
|
+
lineIndex
|
|
179
|
+
});
|
|
180
|
+
if (resolved.length >= MAX_TOC_ENTRIES) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return resolved;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseEpubNavEntries(epubData) {
|
|
188
|
+
const rawHtmlCandidates = [
|
|
189
|
+
epubData?.easy?.navMapHTML,
|
|
190
|
+
epubData?.easy?.epub3NavHtml
|
|
191
|
+
].filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
192
|
+
|
|
193
|
+
for (const html of rawHtmlCandidates) {
|
|
194
|
+
const anchors = parseAnchorsFromHtml(html);
|
|
195
|
+
if (anchors.length > 0) {
|
|
196
|
+
return anchors;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseAnchorsFromHtml(html) {
|
|
203
|
+
try {
|
|
204
|
+
const root = parseHtml(html);
|
|
205
|
+
const nodes = root.querySelectorAll('a');
|
|
206
|
+
return nodes
|
|
207
|
+
.map((node) => ({
|
|
208
|
+
href: normalizePathKey(node.getAttribute('href') ?? ''),
|
|
209
|
+
title: compactTitle(node.text)
|
|
210
|
+
}))
|
|
211
|
+
.filter((entry) => entry.href.length > 0 && entry.title.length > 0);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolveTocLineIndex(href, context, sectionLineStartMap) {
|
|
218
|
+
const normalizedHref = normalizePathKey(href);
|
|
219
|
+
if (!normalizedHref) {
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const resolved = resolveSpinePath(context, normalizedHref);
|
|
224
|
+
if (sectionLineStartMap.has(resolved)) {
|
|
225
|
+
return sectionLineStartMap.get(resolved);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const targetBasename = path.posix.basename(resolved);
|
|
229
|
+
for (const [sectionPath, index] of sectionLineStartMap.entries()) {
|
|
230
|
+
if (
|
|
231
|
+
sectionPath === targetBasename ||
|
|
232
|
+
sectionPath.endsWith(`/${targetBasename}`)
|
|
233
|
+
) {
|
|
234
|
+
return index;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return -1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildTxtToc(lines) {
|
|
242
|
+
const toc = [];
|
|
243
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
244
|
+
const line = lines[i];
|
|
245
|
+
if (!isLikelyTxtHeading(line)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const title = compactTitle(line);
|
|
249
|
+
if (!title) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const previous = toc[toc.length - 1];
|
|
253
|
+
if (previous && previous.title === title) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
toc.push({
|
|
257
|
+
title,
|
|
258
|
+
lineIndex: i
|
|
259
|
+
});
|
|
260
|
+
if (toc.length >= MAX_TOC_ENTRIES) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return toc;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isLikelyTxtHeading(line) {
|
|
268
|
+
const text = compactTitle(line);
|
|
269
|
+
if (!text || text.length > 50) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const patterns = [
|
|
274
|
+
/^第[\d零一二三四五六七八九十百千万两〇壹贰叁肆伍陆柒捌玖拾佰仟]+[章回节卷部篇集]/,
|
|
275
|
+
/^卷[\d零一二三四五六七八九十百千万两〇壹贰叁肆伍陆柒捌玖拾佰仟]+/,
|
|
276
|
+
/^(序章|序|楔子|引子|前言|后记|尾声|终章|番外|附录)/,
|
|
277
|
+
/^(chapter|chap\.?)\s*\d+/i,
|
|
278
|
+
/^(prologue|epilogue)\b/i
|
|
279
|
+
];
|
|
280
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stripHtml(value) {
|
|
284
|
+
const source = String(value ?? '');
|
|
285
|
+
if (!source.includes('<')) {
|
|
286
|
+
return source.replace(/\uFEFF/g, '');
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
return parseHtml(source).text.replace(/\uFEFF/g, '');
|
|
290
|
+
} catch {
|
|
291
|
+
return source.replace(/<[^>]+>/g, ' ').replace(/\uFEFF/g, '');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function splitLines(content) {
|
|
296
|
+
return String(content)
|
|
297
|
+
.split(/\r?\n/)
|
|
298
|
+
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
299
|
+
.filter((line) => line.length > 0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function uniqueLines(lines) {
|
|
303
|
+
const output = [];
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
if (line.length < 2) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (seen.has(line)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
seen.add(line);
|
|
313
|
+
output.push(line);
|
|
314
|
+
}
|
|
315
|
+
return output;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function compactTitle(value) {
|
|
319
|
+
return String(value ?? '')
|
|
320
|
+
.replace(/\s+/g, ' ')
|
|
321
|
+
.trim();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function normalizePathKey(value) {
|
|
325
|
+
return String(value ?? '')
|
|
326
|
+
.split('#')[0]
|
|
327
|
+
.replace(/\\/g, '/')
|
|
328
|
+
.replace(/^\.\//, '')
|
|
329
|
+
.replace(/^\/+/, '')
|
|
330
|
+
.trim();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function collectStrings(node, out, depth = 0) {
|
|
334
|
+
if (depth > 20 || node === null || node === undefined) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (typeof node === 'string') {
|
|
339
|
+
out.push(node);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (Array.isArray(node)) {
|
|
344
|
+
for (const value of node) {
|
|
345
|
+
collectStrings(value, out, depth + 1);
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (typeof node === 'object') {
|
|
351
|
+
for (const value of Object.values(node)) {
|
|
352
|
+
collectStrings(value, out, depth + 1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
package/scripts/init.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
const appHome = resolveAppHome();
|
|
9
|
+
const logsDir = path.join(appHome, 'logs_data');
|
|
10
|
+
const sessionFile = path.join(appHome, '.ghost-session.json');
|
|
11
|
+
|
|
12
|
+
await fs.mkdir(logsDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
console.log('[vs-ghost] initialization complete');
|
|
15
|
+
console.log(`[vs-ghost] app home: ${appHome}`);
|
|
16
|
+
console.log(`[vs-ghost] data path: ${logsDir}`);
|
|
17
|
+
console.log(`[vs-ghost] session file: ${sessionFile}`);
|
|
18
|
+
console.log('[vs-ghost] put your .txt/.epub files into the data path above');
|
|
19
|
+
|
|
20
|
+
function resolveAppHome() {
|
|
21
|
+
const fromEnv = process.env.VS_GHOST_HOME?.trim();
|
|
22
|
+
if (fromEnv) {
|
|
23
|
+
return path.resolve(fromEnv);
|
|
24
|
+
}
|
|
25
|
+
return path.join(os.homedir(), '.vs-ghost');
|
|
26
|
+
}
|