@deepwhale-cn/dsh-docx-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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 深鲸 DeepWhale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
1
+ # @deepwhale-cn/dsh-docx-batch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/feely0208/dsh-docx-batch)
4
+
5
+ **整目录的 Word 一次处理完**:批量替换文本、提取正文、按文件名合并、列页眉页脚、转 Markdown —— 不打开 Word,不动原文件。
6
+
7
+ **关键词**:Word 批量替换 · docx 批量处理 · 批量合并 Word · 提取 Word 文本 · Word 转 Markdown · 页眉页脚提取 · 合同批量改甲方 · 命令行处理 Word
8
+
9
+ ## 不装会怎样
10
+
11
+ 50 份合同要统一把甲方「深鲸科技」改成「深海鲸」:不装就得逐份打开 Word、Ctrl+H、保存、关掉,
12
+ 半小时起步,改到第 37 份时手一滑漏一份也没人发现。
13
+
14
+ 几十份周报要合成一份、把 Word 文档塞进知识库前要先转成 Markdown、需要核对每份文档的页眉页脚
15
+ 写的是什么 —— 这些都属于「机械但量大」的活,人工做既慢又容易漏。
16
+
17
+ 更麻烦的是:直接让 AI 改原文件,一旦改坏没有退路。这个插件只读输入,所有产物写到单独的输出目录,
18
+ 同名文件自动加序号、不覆盖任何东西。
19
+
20
+ ## 能做什么
21
+
22
+ | 子命令 | 作用 | 产物(可核对) |
23
+ |---|---|---|
24
+ | `replace` | 整目录批量替换文本,`--from`/`--to` 可多组 | 替换后的 `.docx` 副本;JSON 里给出每个文件替换了几处 |
25
+ | `extract` | 提取正文文本(含表格) | 每个文档一个 `.txt` |
26
+ | `merge` | 按文件名自然顺序合并成一个 docx(正文 + 表格) | `merged.docx`,段落数只增不减 |
27
+ | `headers` | 列出每份文档每一节的页眉、页脚文本 | `headers.txt` |
28
+ | `to-md` | 按「标题样式 → `#`、表格 → Markdown 表格」转换 | 每个文档一个 `.md` |
29
+
30
+ 共同约定:
31
+
32
+ - **不修改输入文件**,默认输出到 `<输入目录>/_输出`,可用 `--out` 指定;
33
+ - **同名文件不覆盖**:已存在时自动变成 `合同_1.docx`、`合同_2.docx`;
34
+ - 目录递归处理,自动跳过 Word 临时文件(`~$` 开头)和 `_输出` 目录;
35
+ - `--json` 输出结构化结果,方便脚本 / CI 断言;出错时退出码非 0 并说明缺什么。
36
+
37
+ ```bash
38
+ # 整目录替换,多组同时替换
39
+ dsh-docx-batch replace --input ./合同 --out ./合同_新 \
40
+ --from 深鲸科技 --to 深海鲸 --from 2024 --to 2026
41
+
42
+ # 提取正文
43
+ dsh-docx-batch extract --input ./合同 --out ./文本
44
+
45
+ # 按文件名顺序合并
46
+ dsh-docx-batch merge --input ./周报 --out ./合并
47
+
48
+ # 页眉页脚清单 / 转 Markdown
49
+ dsh-docx-batch headers --input ./合同
50
+ dsh-docx-batch to-md --input ./合同 --out ./md
51
+ ```
52
+
53
+ ## 不能做什么
54
+
55
+ - ❌ **不保证排版 100% 不变**:替换跨多个 run(同一段里被拆开的文字块)时会合并该段落的 run,
56
+ 段落内的局部加粗、字号、颜色可能丢失。重要文档请先跑一份对比再批量用。
57
+ - ❌ 不处理 `.doc`(老格式)、`.docm`、加密/需要密码打开的文档;`.doc` 请先另存为 `.docx`。
58
+ - ❌ 不处理脚注、尾注、批注、文本框、SmartArt、图片里的文字 —— 只覆盖正文段落、表格、页眉页脚。
59
+ - ❌ `merge` 只合并正文和表格;页眉页脚、样式主题不合并,复杂排版(分栏、自动编号域)会退化为纯文本顺序。
60
+ - ❌ `headers` 只**读**页眉页脚,不改;`replace` 能改页眉页脚里的文字。
61
+ - ❌ **转 Markdown 会丢东西**:批注 / 修订痕迹 / 复杂表格样式(合并单元格降级为普通表格)/ 图片位置与浮动排版 / 脚注尾注 —— 只保留标题、段落、列表与基础表格文字,适合进知识库,不适合当排版成品。
62
+ - ❌ 不是格式转换器:不导出 PDF、不做 OCR、不做批量重命名。
63
+ - ❌ 替换是**字面匹配**,大小写敏感,不支持正则;`--from` 是空字符串之类无意义输入不做特殊处理。
64
+ - ✅ 全部在本机由 python-docx 处理,不联网、不上传文档。
65
+
66
+ ## 安装
67
+
68
+ ```bash
69
+ dsh plugin --profile web add @deepwhale-cn/dsh-docx-batch
70
+ ```
71
+
72
+ 运行依赖本机的 **python3 + python-docx**(插件不会替你装)。缺依赖时 CLI 会直接打印安装命令:
73
+
74
+ ```bash
75
+ python3 -m pip install python-docx
76
+ # macOS 没有 python3 时
77
+ brew install python3
78
+ ```
79
+
80
+ ## 用法
81
+
82
+ ```bash
83
+ dsh-docx-batch --help
84
+ dsh-docx-batch --demo # 自造样例文件跑完整流程,打印产物路径
85
+ dsh-docx-batch <action> --input <文件或目录> [--out <目录>] [--json]
86
+ ```
87
+
88
+ `--demo` 会在系统临时目录里造样例文档并依次跑 replace / extract / to-md / merge / headers,
89
+ 不动你的任何文件,跑完直接看产物路径就知道它能干什么。
90
+
91
+ ## License
92
+
93
+ MIT © 温州深鲸智能科技有限公司
94
+
95
+ ## 面板(0.1.1 起)
96
+
97
+ 装上后侧栏会多一个「Word 批量」图标,点开即可:
98
+
99
+ - 选操作:批量替换 / 提取正文 / 合并 / 页眉页脚 / 转 Markdown
100
+ - 填文件或目录、输出目录(留空 = `<输入目录>/_输出`)
101
+ - 批量替换时每行写一组「旧 → 新」
102
+ - 点「开始处理」,结果直接显示在面板里
103
+
104
+ 面板与 CLI 是同一套内核(`lib/docx_tool.py`),能力完全一致;所有处理都在本机完成,不上传文件。
105
+ 需要 `python3` + `python-docx`(面板里会给出提示)。
package/bin/cli.mjs ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Word 批量处理 CLI
4
+ *
5
+ * 形态:
6
+ * dsh-docx-batch --help (无参数也打印 help,退出 0)
7
+ * dsh-docx-batch --demo (自造样例跑完整流程,退出 0)
8
+ * dsh-docx-batch <action> --input <文件或目录> [--out <目录>] [--json]
9
+ *
10
+ * 真实逻辑全部委托 python3 + python-docx(lib/docx_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', 'docx_tool.py');
19
+ const TITLE = 'Word 批量处理';
20
+ const CMD = 'dsh-docx-batch';
21
+ const ACTIONS = {
22
+ replace: '批量替换文本(--from 旧 --to 新,可多组)',
23
+ extract: '提取正文文本到 .txt',
24
+ merge: '按文件名顺序合并成一个 docx',
25
+ headers: '列出页眉页脚文本',
26
+ 'to-md': '段落/表格转 Markdown',
27
+ };
28
+ const PY_HINT = [
29
+ '需要 python3 + python-docx。安装:',
30
+ ' python3 -m pip install python-docx',
31
+ '没有 python3 时:',
32
+ ' macOS: brew install python3',
33
+ ' Ubuntu: sudo apt install python3',
34
+ ].join('\n');
35
+ const LINE = '─'.repeat(56);
36
+
37
+ function help() {
38
+ console.log(`${TITLE}
39
+
40
+ 用法:
41
+ ${CMD} <action> --input <文件或目录> [--out <目录>] [--json]
42
+ ${CMD} --demo
43
+ ${CMD} --help
44
+
45
+ 子命令:
46
+ ${Object.entries(ACTIONS).map(([k, v]) => ` ${k.padEnd(9)}${v}`).join('\n')}
47
+
48
+ 参数:
49
+ --input, -i .docx 文件或目录(目录递归处理)
50
+ --out, -o 输出目录;默认 <输入目录>/_输出;同名文件不覆盖,自动加序号
51
+ --from 要替换的旧文本(可重复,与 --to 按顺序配对)
52
+ --to 替换后的新文本(可重复)
53
+ --json 输出 JSON(给脚本/CI 用)
54
+ --demo 自造样例文件,跑完整流程并打印产物路径
55
+
56
+ 示例:
57
+ ${CMD} replace --input ./合同 --from 深鲸科技 --to 深海鲸
58
+ ${CMD} extract --input ./合同 --out ./文本
59
+ ${CMD} merge --input ./周报 --out ./合并
60
+ ${CMD} headers --input ./合同
61
+ ${CMD} to-md --input ./合同 --out ./md
62
+
63
+ 依赖:python3 + python-docx(缺失时会提示安装命令)`);
64
+ process.exit(0);
65
+ }
66
+
67
+ function fail(message, hint = '', code = 2, json = false) {
68
+ if (json) {
69
+ console.log(JSON.stringify({ ok: false, code, message, hint }, null, 2));
70
+ } else {
71
+ console.error(`❌ ${message}`);
72
+ if (hint) console.error(`\n${hint}`);
73
+ }
74
+ process.exit(code);
75
+ }
76
+
77
+ function findPython() {
78
+ const candidates = [
79
+ process.env.DSH_PYTHON,
80
+ process.env.DOCX_BATCH_PYTHON,
81
+ 'python3',
82
+ '/opt/homebrew/bin/python3',
83
+ '/usr/local/bin/python3',
84
+ '/usr/bin/python3',
85
+ ].filter(Boolean);
86
+ for (const cmd of candidates) {
87
+ const r = spawnSync(cmd, ['-c', 'import sys; print(sys.version_info[0])'], {
88
+ encoding: 'utf8',
89
+ timeout: 10000,
90
+ });
91
+ if (!r.error && r.status === 0) return cmd;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function parseArgs(argv) {
97
+ const opt = { action: null, input: null, out: null, from: [], to: [], json: false, demo: false };
98
+ for (let i = 0; i < argv.length; i++) {
99
+ const raw = argv[i];
100
+ const eq = raw.indexOf('=');
101
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
102
+ const inline = eq > 0 ? raw.slice(eq + 1) : undefined;
103
+ if (flag === '--json') {
104
+ opt.json = true;
105
+ continue;
106
+ }
107
+ if (flag === '--demo') {
108
+ opt.demo = true;
109
+ continue;
110
+ }
111
+ if (flag === '--help' || flag === '-h') help();
112
+ if (flag === '--input' || flag === '-i' || flag === '--out' || flag === '-o' ||
113
+ flag === '--from' || flag === '--to') {
114
+ let value = inline;
115
+ if (value === undefined) {
116
+ value = argv[i + 1];
117
+ if (value === undefined || value.startsWith('--')) {
118
+ fail(`参数 ${flag} 缺少取值`, `例:${flag} <值>`);
119
+ }
120
+ i += 1;
121
+ }
122
+ if (flag === '--input' || flag === '-i') opt.input = value;
123
+ else if (flag === '--out' || flag === '-o') opt.out = value;
124
+ else if (flag === '--from') opt.from.push(value);
125
+ else opt.to.push(value);
126
+ continue;
127
+ }
128
+ if (flag.startsWith('-')) {
129
+ fail(`未知参数:${raw}`, `用 ${CMD} --help 查看支持的参数。`);
130
+ }
131
+ if (!opt.action) {
132
+ opt.action = raw;
133
+ continue;
134
+ }
135
+ fail(`多余的参数:${raw}`, '一次只能执行一个子命令。');
136
+ }
137
+ return opt;
138
+ }
139
+
140
+ function human(res) {
141
+ console.log('');
142
+ console.log(` ${TITLE} · ${res.action}`);
143
+ console.log(` ${LINE}`);
144
+ if (res.input) console.log(` 输入 ${res.input}`);
145
+ if (res.out) console.log(` 输出目录 ${res.out}`);
146
+ for (const f of res.files || []) {
147
+ let extra = '';
148
+ if (f.replacements !== undefined) extra = ` · 替换 ${f.replacements} 处`;
149
+ else if (f.chars !== undefined) extra = ` · ${f.chars} 字符`;
150
+ else if (f.sections !== undefined) extra = ` · ${f.sections} 节`;
151
+ else if (f.paragraphs !== undefined) extra = ` · ${f.paragraphs} 段`;
152
+ console.log(` ✓ ${f.input_name} → ${f.output_name}${extra}`);
153
+ }
154
+ if (res.summary) console.log(`\n ${res.summary}`);
155
+ if (res.artifacts && res.artifacts.length) {
156
+ console.log('\n 产物:');
157
+ for (const a of res.artifacts) console.log(` ${a}`);
158
+ }
159
+ console.log('');
160
+ }
161
+
162
+ function humanDemo(res) {
163
+ console.log('');
164
+ console.log(` ${TITLE} · 演示(--demo)`);
165
+ console.log(` ${LINE}`);
166
+ console.log(` 演示目录 ${res.demoRoot}`);
167
+ console.log('');
168
+ for (const s of res.steps || []) {
169
+ console.log(` ✓ ${String(s.action).padEnd(8)} ${s.summary}`);
170
+ }
171
+ console.log('\n 产物:');
172
+ for (const a of res.artifacts || []) console.log(` ${a}`);
173
+ console.log('');
174
+ }
175
+
176
+ function callPython(py, action, payload) {
177
+ const r = spawnSync(py, [PY_SCRIPT, action, JSON.stringify(payload)], {
178
+ encoding: 'utf8',
179
+ maxBuffer: 128 * 1024 * 1024,
180
+ });
181
+ if (r.error) {
182
+ fail(`调用 python 失败:${r.error.message}`, PY_HINT, 3);
183
+ }
184
+ const lines = (r.stdout || '').trim().split('\n').filter(Boolean);
185
+ let result = null;
186
+ try {
187
+ result = JSON.parse(lines[lines.length - 1] || '');
188
+ } catch {
189
+ /* 下面统一报错 */
190
+ }
191
+ if (!result) {
192
+ const detail = (r.stderr || '').trim().split('\n').slice(-5).join('\n');
193
+ fail('Python 处理失败(没有拿到有效结果)', detail || PY_HINT, r.status || 1);
194
+ }
195
+ return result;
196
+ }
197
+
198
+ const argv = process.argv.slice(2);
199
+ if (argv.length === 0) help();
200
+ if (argv.includes('--help') || argv.includes('-h')) help();
201
+
202
+ const opt = parseArgs(argv);
203
+ const python = findPython();
204
+ if (!python) {
205
+ fail('未找到 python3,无法处理 Word 文件', PY_HINT, 3, opt.json);
206
+ }
207
+
208
+ if (opt.demo) {
209
+ const res = callPython(python, 'demo', {});
210
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
211
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
212
+ else humanDemo(res);
213
+ process.exit(0);
214
+ }
215
+
216
+ if (!opt.action) {
217
+ fail('缺少子命令', `可用子命令:${Object.keys(ACTIONS).join(' / ')}\n用 ${CMD} --help 查看详情。`, 2, opt.json);
218
+ }
219
+ if (!Object.hasOwn(ACTIONS, opt.action)) {
220
+ fail(`未知子命令:${opt.action}`, `可用子命令:${Object.keys(ACTIONS).join(' / ')}`, 2, opt.json);
221
+ }
222
+ if (!opt.input) {
223
+ fail('缺少 --input(文件或目录)', `例:${CMD} ${opt.action} --input ./文档目录`, 2, opt.json);
224
+ }
225
+ if (opt.action === 'replace') {
226
+ if (opt.from.length === 0) {
227
+ fail('缺少 --from(要替换的旧文本)', `例:${CMD} replace --input ./合同 --from 旧文本 --to 新文本`, 2, opt.json);
228
+ }
229
+ if (opt.from.length !== opt.to.length) {
230
+ fail(
231
+ `--from 与 --to 数量不一致(${opt.from.length} vs ${opt.to.length})`,
232
+ '每一组 --from 都要配一个 --to,按顺序一一对应。',
233
+ 2,
234
+ opt.json,
235
+ );
236
+ }
237
+ }
238
+
239
+ const payload = { input: opt.input, out: opt.out, from: opt.from, to: opt.to };
240
+ const res = callPython(python, opt.action, payload);
241
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
242
+
243
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
244
+ else human(res);
245
+ process.exit(0);
@@ -0,0 +1,4 @@
1
+ # @deepwhale-cn/dsh-docx-batch 的组合层:装上即注册 docx-batch skill
2
+ - insert:
3
+ - id: docx-batch
4
+ name: '@deepwhale-cn/dsh-docx-batch'
package/lib/api.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Word 批量处理 —— 宿主半边 HTTP 出口(面板用)
3
+ *
4
+ * 面板(lib/client.js)跟这里是同源的:浏览器 fetch `/dsh-docx-batch/api/*`,
5
+ * 由 DSH 宿主把请求交给这个 handler,真正干活的是随包的 bin/cli.mjs(Python 内核不变)。
6
+ *
7
+ * 安全约束:
8
+ * · 参数一律走数组传给子进程,不拼 shell 字符串;
9
+ * · 只允许白名单里的 action;
10
+ * · 超时 10 分钟,超时杀掉整棵子进程树。
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { existsSync } from 'node:fs';
16
+
17
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const CLI = join(PKG_ROOT, 'bin', 'cli.mjs');
19
+ const ROUTE = '/dsh-docx-batch';
20
+
21
+ /** 面板可做的操作(与 CLI 的 action 一一对应) */
22
+ const OPS = [
23
+ { key: 'replace', label: '批量替换文本', needs: ['pairs'], hint: '每行一组「旧文本 → 新文本」,可多行' },
24
+ { key: 'extract', label: '提取正文到 txt' },
25
+ { key: 'merge', label: '合并成一个 docx' },
26
+ { key: 'headers', label: '列出页眉页脚' },
27
+ { key: 'to-md', label: '转 Markdown' },
28
+ ];
29
+
30
+ const 上限 = 10 * 60 * 1000;
31
+
32
+ function 回(res, code, obj) {
33
+ const body = JSON.stringify(obj);
34
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
35
+ res.end(body);
36
+ }
37
+
38
+ async function 读体(req) {
39
+ const chunks = [];
40
+ for await (const c of req) chunks.push(c);
41
+ const text = Buffer.concat(chunks).toString('utf8');
42
+ if (!text) return {};
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /** 跑 CLI:返回 { ok, code, output } */
51
+ function 跑CLI(args) {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(process.execPath, [CLI, ...args], {
54
+ // 与壳里其它子进程一致:用 Electron 当 Node(纯 node 环境下这个变量无副作用)
55
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
56
+ windowsHide: true,
57
+ });
58
+ let out = '';
59
+ const 收集 = (d) => {
60
+ out += String(d);
61
+ if (out.length > 200_000) out = out.slice(-200_000); // 防止刷爆内存
62
+ };
63
+ child.stdout?.on('data', 收集);
64
+ child.stderr?.on('data', 收集);
65
+ const timer = setTimeout(() => {
66
+ try {
67
+ child.kill('SIGKILL');
68
+ } catch {
69
+ /* 已退出 */
70
+ }
71
+ 收集('\n[超时] 超过 10 分钟仍未完成,已终止。');
72
+ }, 上限);
73
+ child.on('error', (e) => {
74
+ clearTimeout(timer);
75
+ resolve({ ok: false, code: -1, output: `启动失败:${e instanceof Error ? e.message : String(e)}` });
76
+ });
77
+ child.on('close', (code) => {
78
+ clearTimeout(timer);
79
+ resolve({ ok: code === 0, code: code ?? -1, output: out.trim() });
80
+ });
81
+ });
82
+ }
83
+
84
+ /** 把面板参数翻译成 CLI 参数 */
85
+ function 组参数(b) {
86
+ const action = String(b.action || '');
87
+ if (!OPS.some((o) => o.key === action)) return { error: `未知操作:${action}` };
88
+ const input = String(b.input || '').trim();
89
+ if (!input) return { error: '请填写要处理的文件或目录路径' };
90
+ if (!existsSync(input)) return { error: `路径不存在:${input}` };
91
+ const args = [action, '--input', input];
92
+ const out = String(b.out || '').trim();
93
+ if (out) args.push('--out', out);
94
+ if (action === 'replace') {
95
+ const 行 = String(b.pairs || '')
96
+ .split('\n')
97
+ .map((l) => l.trim())
98
+ .filter(Boolean);
99
+ if (!行.length) return { error: '批量替换至少要一组「旧 → 新」' };
100
+ const 旧 = [];
101
+ const 新 = [];
102
+ for (const l of 行) {
103
+ const i = l.indexOf('→') >= 0 ? l.indexOf('→') : l.indexOf('->');
104
+ if (i < 0) return { error: `这一行缺少箭头(用「旧 → 新」):${l}` };
105
+ const sep = l[i] === '→' ? 1 : 2;
106
+ 旧.push(l.slice(0, i).trim());
107
+ 新.push(l.slice(i + sep).trim());
108
+ }
109
+ for (const v of 旧) args.push('--from', v);
110
+ for (const v of 新) args.push('--to', v);
111
+ }
112
+ return { args };
113
+ }
114
+
115
+ /**
116
+ * 挂到宿主 webServer 上。
117
+ * @param {any} ctx 插件上下文(需已注入 webServer)
118
+ */
119
+ export function registerDocxBatchApi(ctx) {
120
+ const handler = async (req, res) => {
121
+ try {
122
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
123
+ const p = u.pathname;
124
+ if (p === `${ROUTE}/api/ops` && req.method === 'GET') {
125
+ return 回(res, 200, { ok: true, ops: OPS });
126
+ }
127
+ if (p === `${ROUTE}/api/run` && req.method === 'POST') {
128
+ const b = await 读体(req);
129
+ const { args, error } = 组参数(b);
130
+ if (error) return 回(res, 400, { ok: false, error });
131
+ const r = await 跑CLI(args);
132
+ return 回(res, 200, r);
133
+ }
134
+ return 回(res, 404, { ok: false, error: 'not found' });
135
+ } catch (e) {
136
+ return 回(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
137
+ }
138
+ };
139
+ ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler });
140
+ }