@deepwhale-cn/dsh-doc-batch 0.0.0-stage → 0.1.3

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.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Excel 批量处理 CLI
4
+ *
5
+ * 形态:
6
+ * dsh-xlsx-batch --help (无参数也打印 help,退出 0)
7
+ * dsh-xlsx-batch --demo (自造样例跑完整流程,退出 0)
8
+ * dsh-xlsx-batch <action> --input <文件或目录> [--out <目录>] [--json]
9
+ *
10
+ * 真实逻辑全部委托 python3 + openpyxl(lib/xlsx_tool.py),
11
+ * Node 只负责参数校验、调用与人类可读输出。
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const PY_SCRIPT = join(PKG_ROOT, 'lib', 'xlsx_tool.py');
19
+ const TITLE = 'Excel 批量处理';
20
+ const CMD = 'dsh-xlsx-batch';
21
+ const ACTIONS = {
22
+ merge: '多文件/多 sheet 合并到一个工作簿,加来源列',
23
+ clean: '删空行、去重、trim 文本',
24
+ summary: '每列非空计数 / 数值列求和,导出报告',
25
+ csv: '每个 sheet 导出 CSV',
26
+ };
27
+ const PY_HINT = [
28
+ '需要 python3 + openpyxl。安装:',
29
+ ' python3 -m pip install openpyxl',
30
+ '没有 python3 时:',
31
+ ' macOS: brew install python3',
32
+ ' Ubuntu: sudo apt install python3',
33
+ ].join('\n');
34
+ const LINE = '─'.repeat(56);
35
+
36
+ function help() {
37
+ console.log(`${TITLE}
38
+
39
+ 用法:
40
+ ${CMD} <action> --input <文件或目录> [--out <目录>] [--json]
41
+ ${CMD} --demo
42
+ ${CMD} --help
43
+
44
+ 子命令:
45
+ ${Object.entries(ACTIONS).map(([k, v]) => ` ${k.padEnd(9)}${v}`).join('\n')}
46
+
47
+ 参数:
48
+ --input, -i .xlsx/.xlsm 文件或目录(目录递归处理)
49
+ --out, -o 输出目录;默认 <输入目录>/_输出;同名文件不覆盖,自动加序号
50
+ --json 输出 JSON(给脚本/CI 用)
51
+ --demo 自造样例文件,跑完整流程并打印产物路径
52
+
53
+ 示例:
54
+ ${CMD} merge --input ./周报 --out ./汇总
55
+ ${CMD} clean --input ./名单 --out ./清洗后
56
+ ${CMD} summary --input ./报表 --out ./统计
57
+ ${CMD} csv --input ./报表 --out ./csv
58
+
59
+ 依赖:python3 + openpyxl(缺失时会提示安装命令)`);
60
+ process.exit(0);
61
+ }
62
+
63
+ function fail(message, hint = '', code = 2, json = false) {
64
+ if (json) {
65
+ console.log(JSON.stringify({ ok: false, code, message, hint }, null, 2));
66
+ } else {
67
+ console.error(`❌ ${message}`);
68
+ if (hint) console.error(`\n${hint}`);
69
+ }
70
+ process.exit(code);
71
+ }
72
+
73
+ function findPython() {
74
+ const candidates = [
75
+ process.env.DSH_PYTHON,
76
+ process.env.XLSX_BATCH_PYTHON,
77
+ 'python3',
78
+ '/opt/homebrew/bin/python3',
79
+ '/usr/local/bin/python3',
80
+ '/usr/bin/python3',
81
+ ].filter(Boolean);
82
+ for (const cmd of candidates) {
83
+ const r = spawnSync(cmd, ['-c', 'import sys; print(sys.version_info[0])'], {
84
+ encoding: 'utf8',
85
+ timeout: 10000,
86
+ });
87
+ if (!r.error && r.status === 0) return cmd;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function parseArgs(argv) {
93
+ const opt = { action: null, input: null, out: null, json: false, demo: false };
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const raw = argv[i];
96
+ const eq = raw.indexOf('=');
97
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
98
+ const inline = eq > 0 ? raw.slice(eq + 1) : undefined;
99
+ if (flag === '--json') {
100
+ opt.json = true;
101
+ continue;
102
+ }
103
+ if (flag === '--demo') {
104
+ opt.demo = true;
105
+ continue;
106
+ }
107
+ if (flag === '--help' || flag === '-h') help();
108
+ if (flag === '--input' || flag === '-i' || flag === '--out' || flag === '-o') {
109
+ let value = inline;
110
+ if (value === undefined) {
111
+ value = argv[i + 1];
112
+ if (value === undefined || value.startsWith('--')) {
113
+ fail(`参数 ${flag} 缺少取值`, `例:${flag} <值>`);
114
+ }
115
+ i += 1;
116
+ }
117
+ if (flag === '--input' || flag === '-i') opt.input = value;
118
+ else opt.out = value;
119
+ continue;
120
+ }
121
+ if (flag.startsWith('-')) {
122
+ fail(`未知参数:${raw}`, `用 ${CMD} --help 查看支持的参数。`);
123
+ }
124
+ if (!opt.action) {
125
+ opt.action = raw;
126
+ continue;
127
+ }
128
+ fail(`多余的参数:${raw}`, '一次只能执行一个子命令。');
129
+ }
130
+ return opt;
131
+ }
132
+
133
+ function human(res) {
134
+ console.log('');
135
+ console.log(` ${TITLE} · ${res.action}`);
136
+ console.log(` ${LINE}`);
137
+ if (res.input) console.log(` 输入 ${res.input}`);
138
+ if (res.out) console.log(` 输出目录 ${res.out}`);
139
+ for (const f of res.files || []) {
140
+ let extra = '';
141
+ if (f.sheet !== undefined) extra += ` [${f.sheet}]`;
142
+ if (f.rows_in !== undefined) extra += ` · ${f.rows_in} 行 → ${f.rows_out} 行`;
143
+ else if (f.rows !== undefined) extra += ` · ${f.rows} 行`;
144
+ else if (f.sheets !== undefined) extra += ` · ${f.sheets} 个工作表`;
145
+ console.log(` ✓ ${f.input_name} → ${f.output_name}${extra}`);
146
+ }
147
+ if (res.summary) console.log(`\n ${res.summary}`);
148
+ if (res.artifacts && res.artifacts.length) {
149
+ console.log('\n 产物:');
150
+ for (const a of res.artifacts) console.log(` ${a}`);
151
+ }
152
+ console.log('');
153
+ }
154
+
155
+ function humanDemo(res) {
156
+ console.log('');
157
+ console.log(` ${TITLE} · 演示(--demo)`);
158
+ console.log(` ${LINE}`);
159
+ console.log(` 演示目录 ${res.demoRoot}`);
160
+ console.log('');
161
+ for (const s of res.steps || []) {
162
+ console.log(` ✓ ${String(s.action).padEnd(8)} ${s.summary}`);
163
+ }
164
+ console.log('\n 产物:');
165
+ for (const a of res.artifacts || []) console.log(` ${a}`);
166
+ console.log('');
167
+ }
168
+
169
+ function callPython(py, action, payload) {
170
+ const r = spawnSync(py, [PY_SCRIPT, action, JSON.stringify(payload)], {
171
+ encoding: 'utf8',
172
+ maxBuffer: 128 * 1024 * 1024,
173
+ });
174
+ if (r.error) {
175
+ fail(`调用 python 失败:${r.error.message}`, PY_HINT, 3);
176
+ }
177
+ const lines = (r.stdout || '').trim().split('\n').filter(Boolean);
178
+ let result = null;
179
+ try {
180
+ result = JSON.parse(lines[lines.length - 1] || '');
181
+ } catch {
182
+ /* 下面统一报错 */
183
+ }
184
+ if (!result) {
185
+ const detail = (r.stderr || '').trim().split('\n').slice(-5).join('\n');
186
+ fail('Python 处理失败(没有拿到有效结果)', detail || PY_HINT, r.status || 1);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ const argv = process.argv.slice(2);
192
+ if (argv.length === 0) help();
193
+ if (argv.includes('--help') || argv.includes('-h')) help();
194
+
195
+ const opt = parseArgs(argv);
196
+ const python = findPython();
197
+ if (!python) {
198
+ fail('未找到 python3,无法处理 Excel 文件', PY_HINT, 3, opt.json);
199
+ }
200
+
201
+ if (opt.demo) {
202
+ const res = callPython(python, 'demo', {});
203
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
204
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
205
+ else humanDemo(res);
206
+ process.exit(0);
207
+ }
208
+
209
+ if (!opt.action) {
210
+ fail('缺少子命令', `可用子命令:${Object.keys(ACTIONS).join(' / ')}\n用 ${CMD} --help 查看详情。`, 2, opt.json);
211
+ }
212
+ if (!Object.hasOwn(ACTIONS, opt.action)) {
213
+ fail(`未知子命令:${opt.action}`, `可用子命令:${Object.keys(ACTIONS).join(' / ')}`, 2, opt.json);
214
+ }
215
+ if (!opt.input) {
216
+ fail('缺少 --input(文件或目录)', `例:${CMD} ${opt.action} --input ./报表目录`, 2, opt.json);
217
+ }
218
+
219
+ const payload = { input: opt.input, out: opt.out };
220
+ const res = callPython(python, opt.action, payload);
221
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
222
+
223
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
224
+ else human(res);
225
+ process.exit(0);
package/bin/cli.mjs ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 深鲸文档批量处理 · 统一 CLI
4
+ *
5
+ * 用法:
6
+ * dsh-doc-batch docx <action> --input <路径> [--out <目录>] [--from 旧 --to 新]
7
+ * dsh-doc-batch xlsx <action> --input <路径> [--out <目录>]
8
+ * dsh-doc-batch pdf <action> --input <路径> [--out <目录>] [--text 水印] …
9
+ *
10
+ * 设计:三种格式的内核(Python)与参数解析各自独立(见 bin/cli-docx|xlsx|pdf.mjs),
11
+ * 这里只做**分发**,不复制业务逻辑 —— 保证原来单格式的行为一字不差。
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const BIN = dirname(fileURLToPath(import.meta.url));
18
+ const MAP = {
19
+ docx: 'cli-docx.mjs', word: 'cli-docx.mjs',
20
+ xlsx: 'cli-xlsx.mjs', excel: 'cli-xlsx.mjs',
21
+ pdf: 'cli-pdf.mjs',
22
+ };
23
+ const CMD = 'dsh-doc-batch';
24
+
25
+ function help(code = 0) {
26
+ console.log(`${CMD} —— 深鲸文档批量处理(Word / Excel / PDF)
27
+
28
+ 用法:
29
+ ${CMD} <格式> <操作> --input <文件或目录> [--out <目录>] [其它参数]
30
+ ${CMD} <格式> --help 查看该格式支持的操作与参数
31
+
32
+ 格式:docx(别名 word) / xlsx(别名 excel) / pdf
33
+
34
+ 示例:
35
+ ${CMD} docx replace --input ./合同 --from 深鲸科技 --to 深海鲸
36
+ ${CMD} docx extract --input ./合同 --out ./文本
37
+ ${CMD} xlsx merge --input ./报表 --out ./合并
38
+ ${CMD} xlsx csv --input ./报表
39
+ ${CMD} pdf merge --input ./扫描件 --out ./合并
40
+ ${CMD} pdf split --input a.pdf --out ./拆分 --action page
41
+ ${CMD} pdf watermark --input a.pdf --out ./水印 --text 机密
42
+
43
+ 说明:三种格式的处理能力与原来的单格式插件完全一致;所有处理都在本机完成。`);
44
+ process.exit(code);
45
+ }
46
+
47
+ const argv = process.argv.slice(2);
48
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') help(0);
49
+ if (argv[0] === '--version' || argv[0] === '-v') {
50
+ console.log('0.1.0');
51
+ process.exit(0);
52
+ }
53
+ const fmt = String(argv[0]).toLowerCase();
54
+ const target = MAP[fmt];
55
+ if (!target) {
56
+ console.error(`未知格式:${argv[0]}\n可用:docx / xlsx / pdf(别名 word / excel)`);
57
+ help(2);
58
+ }
59
+ const r = spawnSync(process.execPath, [join(BIN, target), ...argv.slice(1)], {
60
+ stdio: 'inherit',
61
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
62
+ });
63
+ process.exit(typeof r.status === 'number' ? r.status : 1);
@@ -0,0 +1,4 @@
1
+ # @deepwhale-cn/dsh-doc-batch 的组合层:装上即注册 doc-batch skill + 面板
2
+ - insert:
3
+ - id: doc-batch
4
+ name: '@deepwhale-cn/dsh-doc-batch'
package/lib/api.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 深鲸文档批量处理 —— 宿主半边 HTTP 出口
3
+ *
4
+ * 一个出口管三种格式:/dsh-doc-batch/api/{ops,run},请求里带 format。
5
+ * 真正干活的是随包的 bin/cli.mjs(内部再分发到 cli-docx|xlsx|pdf.mjs,Python 内核不变)。
6
+ *
7
+ * 安全:action 白名单、参数数组传参(不拼 shell)、10 分钟超时。
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+
14
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const CLI = join(PKG_ROOT, 'bin', 'cli.mjs');
16
+ const ROUTE = '/dsh-doc-batch';
17
+ /** 面板要显示版本号:直接从 package.json 读,避免客户端硬编码漂移 */
18
+ const VERSION = (() => {
19
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version || ''; }
20
+ catch { return ''; }
21
+ })();
22
+
23
+ const OPS = {
24
+ docx: [
25
+ { key: 'replace', label: '批量替换文本', needs: ['pairs'], hint: '每行一组「旧文本 → 新文本」,可多行' },
26
+ { key: 'extract', label: '提取正文到 txt' },
27
+ { key: 'merge', label: '合并成一个 docx' },
28
+ { key: 'headers', label: '列出页眉页脚' },
29
+ { key: 'to-md', label: '转 Markdown' },
30
+ ],
31
+ xlsx: [
32
+ { key: 'merge', label: '合并工作簿(加来源列)' },
33
+ { key: 'clean', label: '清洗:删空行 / 去重 / trim' },
34
+ { key: 'summary', label: '列汇总报告' },
35
+ { key: 'csv', label: '每个 sheet 导出 CSV' },
36
+ ],
37
+ pdf: [
38
+ { key: 'merge', label: '合并多个 PDF' },
39
+ { key: 'split', label: '拆分(按页 / 每 N 页)', needs: ['mode'] },
40
+ { key: 'compress', label: '压缩' },
41
+ { key: 'watermark', label: '加水印(文字)', needs: ['text'] },
42
+ { key: 'extract-text', label: '提取文本' },
43
+ { key: 'extract-images', label: '提取图片' },
44
+ ],
45
+ };
46
+
47
+ const 上限 = 10 * 60 * 1000;
48
+
49
+ function 回(res, code, obj) {
50
+ const body = JSON.stringify(obj);
51
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
52
+ res.end(body);
53
+ }
54
+
55
+ async function 读体(req) {
56
+ const chunks = [];
57
+ for await (const c of req) chunks.push(c);
58
+ const text = Buffer.concat(chunks).toString('utf8');
59
+ if (!text) return {};
60
+ try { return JSON.parse(text); } catch { return {}; }
61
+ }
62
+
63
+ function 跑CLI(args) {
64
+ return new Promise((resolve) => {
65
+ const child = spawn(process.execPath, [CLI, ...args], {
66
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
67
+ windowsHide: true,
68
+ });
69
+ let out = '';
70
+ const 收 = (d) => { out += String(d); if (out.length > 200_000) out = out.slice(-200_000); };
71
+ child.stdout?.on('data', 收);
72
+ child.stderr?.on('data', 收);
73
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} 收('\n[超时] 超过 10 分钟已终止。'); }, 上限);
74
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, code: -1, output: `启动失败:${e?.message || e}` }); });
75
+ child.on('close', (code) => { clearTimeout(timer); resolve({ ok: code === 0, code: code ?? -1, output: out.trim() }); });
76
+ });
77
+ }
78
+
79
+ /** 把面板参数翻译成 CLI 参数(按格式分别处理) */
80
+ function 组参数(b) {
81
+ const format = String(b.format || '').toLowerCase();
82
+ const list = OPS[format];
83
+ if (!list) return { error: `未知格式:${b.format}` };
84
+ const action = String(b.action || '');
85
+ if (!list.some((o) => o.key === action)) return { error: `未知操作:${action}` };
86
+ const input = String(b.input || '').trim();
87
+ if (!input) return { error: '请填写要处理的文件或目录路径' };
88
+ if (!existsSync(input)) return { error: `路径不存在:${input}` };
89
+ const args = [format, action, '--input', input];
90
+ const out = String(b.out || '').trim();
91
+ if (out) args.push('--out', out);
92
+
93
+ if (format === 'docx' && action === 'replace') {
94
+ const 行 = String(b.pairs || '').split('\n').map((l) => l.trim()).filter(Boolean);
95
+ if (!行.length) return { error: '批量替换至少要一组「旧 → 新」' };
96
+ const 旧 = [], 新 = [];
97
+ for (const l of 行) {
98
+ const i = l.indexOf('→') >= 0 ? l.indexOf('→') : l.indexOf('->');
99
+ if (i < 0) return { error: `这一行缺少箭头(用「旧 → 新」):${l}` };
100
+ const sep = l[i] === '→' ? 1 : 2;
101
+ 旧.push(l.slice(0, i).trim()); 新.push(l.slice(i + sep).trim());
102
+ }
103
+ for (const v of 旧) args.push('--from', v);
104
+ for (const v of 新) args.push('--to', v);
105
+ }
106
+ if (format === 'pdf') {
107
+ const n = String(b.n || '').trim();
108
+ const text = String(b.text || '').trim();
109
+ const opacity = String(b.opacity || '').trim();
110
+ if (action === 'split') {
111
+ args.push('--action', String(b.mode || 'page') === 'every' ? 'every' : 'page');
112
+ if (n) args.push('--n', n);
113
+ }
114
+ if (action === 'watermark') {
115
+ if (!text) return { error: '加水印需要填写水印文字' };
116
+ args.push('--text', text);
117
+ if (opacity) args.push('--opacity', opacity);
118
+ }
119
+ if (action === 'extract-images' && n) args.push('--dpi', n);
120
+ }
121
+ return { args };
122
+ }
123
+
124
+ /** 挂到宿主 webServer(ctx 需已注入 webServer) */
125
+ export function registerDocBatchApi(ctx) {
126
+ const handler = async (req, res) => {
127
+ try {
128
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
129
+ const p = u.pathname;
130
+ if (p === `${ROUTE}/api/ops` && req.method === 'GET') {
131
+ const f = String(u.searchParams.get('format') || 'docx').toLowerCase();
132
+ return 回(res, 200, { ok: true, format: f, version: VERSION, ops: OPS[f] || [] });
133
+ }
134
+ if (p === `${ROUTE}/api/run` && req.method === 'POST') {
135
+ const b = await 读体(req);
136
+ const { args, error } = 组参数(b);
137
+ if (error) return 回(res, 400, { ok: false, error });
138
+ return 回(res, 200, await 跑CLI(args));
139
+ }
140
+ return 回(res, 404, { ok: false, error: 'not found' });
141
+ } catch (e) {
142
+ return 回(res, 500, { ok: false, error: e?.message || String(e) });
143
+ }
144
+ };
145
+ ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler });
146
+ }