@deepwhale-cn/dsh-docx-batch 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +91 -2
- package/bin/cli.mjs +245 -0
- package/cordis.patch.yml +4 -0
- package/lib/docx_tool.py +651 -0
- package/lib/index.js +10 -0
- package/package.json +17 -4
- package/skill/docx-batch.md +59 -0
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,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @deepwhale-cn/dsh-docx-batch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](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
|
+
- ❌ 不是格式转换器:不导出 PDF、不做 OCR、不做批量重命名。
|
|
62
|
+
- ❌ 替换是**字面匹配**,大小写敏感,不支持正则;`--from` 是空字符串之类无意义输入不做特殊处理。
|
|
63
|
+
- ✅ 全部在本机由 python-docx 处理,不联网、不上传文档。
|
|
64
|
+
|
|
65
|
+
## 安装
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
dsh plugin --profile web add @deepwhale-cn/dsh-docx-batch
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
运行依赖本机的 **python3 + python-docx**(插件不会替你装)。缺依赖时 CLI 会直接打印安装命令:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m pip install python-docx
|
|
75
|
+
# macOS 没有 python3 时
|
|
76
|
+
brew install python3
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 用法
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
dsh-docx-batch --help
|
|
83
|
+
dsh-docx-batch --demo # 自造样例文件跑完整流程,打印产物路径
|
|
84
|
+
dsh-docx-batch <action> --input <文件或目录> [--out <目录>] [--json]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`--demo` 会在系统临时目录里造样例文档并依次跑 replace / extract / to-md / merge / headers,
|
|
88
|
+
不动你的任何文件,跑完直接看产物路径就知道它能干什么。
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT © 温州深鲸智能科技有限公司
|
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);
|
package/cordis.patch.yml
ADDED
package/lib/docx_tool.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
dsh-docx-batch 的真实处理逻辑(python-docx)
|
|
5
|
+
|
|
6
|
+
由 bin/cli.mjs 通过 spawnSync 调用:
|
|
7
|
+
python3 lib/docx_tool.py <action> '<json payload>'
|
|
8
|
+
约定:
|
|
9
|
+
- stdout 只输出一行 JSON(最后一行),人类可读文案由 Node 侧负责
|
|
10
|
+
- stderr 输出异常栈(调试用)
|
|
11
|
+
- 退出码:0 成功 / 1 运行失败 / 2 参数或输入问题 / 3 缺依赖
|
|
12
|
+
- 只读输入文件,所有产物写进 --out 目录(默认 <输入目录>/_输出)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import traceback
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from docx import Document
|
|
26
|
+
from docx.oxml.ns import qn
|
|
27
|
+
from docx.table import Table
|
|
28
|
+
from docx.text.paragraph import Paragraph
|
|
29
|
+
except ImportError as exc: # 缺依赖必须明确提示安装命令,不能静默
|
|
30
|
+
print(json.dumps({
|
|
31
|
+
"ok": False,
|
|
32
|
+
"code": 3,
|
|
33
|
+
"message": "缺少 Python 依赖:%s" % getattr(exc, "name", exc),
|
|
34
|
+
"hint": "请先安装:\n python3 -m pip install python-docx\n"
|
|
35
|
+
"如果 python3 也没有:\n macOS: brew install python3\n Ubuntu: sudo apt install python3",
|
|
36
|
+
}, ensure_ascii=False))
|
|
37
|
+
sys.exit(3)
|
|
38
|
+
|
|
39
|
+
DOCX_EXT = ".docx"
|
|
40
|
+
SKIP_DIRS = {"_输出", "node_modules", ".git", "__pycache__", ".venv", "venv"}
|
|
41
|
+
DEMO_HEADER = "机密 · 深鲸科技"
|
|
42
|
+
DEMO_FOOTER = "第 1 页"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── 错误 ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
class ToolError(Exception):
|
|
48
|
+
def __init__(self, message, hint="", code=2):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.message = message
|
|
51
|
+
self.hint = hint
|
|
52
|
+
self.code = code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit(obj):
|
|
56
|
+
print(json.dumps(obj, ensure_ascii=False))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── 路径与文件收集 ────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def natural_key(text):
|
|
62
|
+
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", text)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_out(payload, input_path):
|
|
66
|
+
"""输出目录:--out 优先,否则 <输入目录>/_输出。"""
|
|
67
|
+
out = payload.get("out")
|
|
68
|
+
if out:
|
|
69
|
+
return Path(out).expanduser().resolve()
|
|
70
|
+
base = Path(input_path).expanduser().resolve()
|
|
71
|
+
if not base.is_dir():
|
|
72
|
+
base = base.parent
|
|
73
|
+
return base / "_输出"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _out_skip_set(out_dir):
|
|
77
|
+
try:
|
|
78
|
+
return {str(out_dir.resolve())}
|
|
79
|
+
except OSError:
|
|
80
|
+
return set()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_docx(input_path, out_dir=None):
|
|
84
|
+
"""接受单个 .docx 或目录(递归),按文件名自然排序。"""
|
|
85
|
+
if not input_path:
|
|
86
|
+
raise ToolError("缺少 --input", "例:replace --input ./合同目录 --from 旧 --to 新")
|
|
87
|
+
root = Path(input_path).expanduser().resolve()
|
|
88
|
+
if not root.exists():
|
|
89
|
+
raise ToolError(
|
|
90
|
+
"输入不存在:%s" % input_path,
|
|
91
|
+
"请检查路径是否正确;传目录会递归处理其中的 .docx 文件。",
|
|
92
|
+
)
|
|
93
|
+
skip_out = _out_skip_set(out_dir) if out_dir else set()
|
|
94
|
+
if root.is_file():
|
|
95
|
+
if root.suffix.lower() != DOCX_EXT:
|
|
96
|
+
raise ToolError(
|
|
97
|
+
"输入不是 .docx 文件:%s" % root.name,
|
|
98
|
+
"本插件只处理 Word 2007+ 的 .docx;.doc 请先用 Word/WPS 另存为 .docx。",
|
|
99
|
+
)
|
|
100
|
+
return [root]
|
|
101
|
+
files = []
|
|
102
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
103
|
+
dirnames[:] = sorted(
|
|
104
|
+
[d for d in dirnames if d not in SKIP_DIRS and not d.startswith("~$")],
|
|
105
|
+
key=natural_key,
|
|
106
|
+
)
|
|
107
|
+
if str(Path(dirpath).resolve()) in skip_out:
|
|
108
|
+
dirnames[:] = []
|
|
109
|
+
continue
|
|
110
|
+
for name in filenames:
|
|
111
|
+
if name.startswith("~$"):
|
|
112
|
+
continue # Word 打开时的临时文件
|
|
113
|
+
p = Path(dirpath) / name
|
|
114
|
+
if p.suffix.lower() == DOCX_EXT:
|
|
115
|
+
files.append(p)
|
|
116
|
+
files.sort(key=lambda p: natural_key(str(p.relative_to(root))))
|
|
117
|
+
if not files:
|
|
118
|
+
raise ToolError(
|
|
119
|
+
"目录里没有 .docx 文件:%s" % root,
|
|
120
|
+
"确认目录选对了;本插件不处理 .doc(请先另存为 .docx)。",
|
|
121
|
+
)
|
|
122
|
+
return files
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unique_path(directory, filename):
|
|
126
|
+
"""同名文件不覆盖,追加 _1/_2 序号。"""
|
|
127
|
+
directory = Path(directory)
|
|
128
|
+
target = directory / filename
|
|
129
|
+
if not target.exists():
|
|
130
|
+
return target
|
|
131
|
+
stem, suffix = target.stem, target.suffix
|
|
132
|
+
n = 1
|
|
133
|
+
while (directory / ("%s_%d%s" % (stem, n, suffix))).exists():
|
|
134
|
+
n += 1
|
|
135
|
+
return directory / ("%s_%d%s" % (stem, n, suffix))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── docx 遍历 ─────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def iter_block_items(parent):
|
|
141
|
+
"""按文档顺序遍历段落和表格。"""
|
|
142
|
+
body = parent.element.body
|
|
143
|
+
for child in body.iterchildren():
|
|
144
|
+
if child.tag == qn("w:p"):
|
|
145
|
+
yield Paragraph(child, parent)
|
|
146
|
+
elif child.tag == qn("w:tbl"):
|
|
147
|
+
yield Table(child, parent)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def iter_cell_paragraphs(cell):
|
|
151
|
+
for p in cell.paragraphs:
|
|
152
|
+
yield p
|
|
153
|
+
for table in cell.tables:
|
|
154
|
+
for row in table.rows:
|
|
155
|
+
for c in row.cells:
|
|
156
|
+
yield from iter_cell_paragraphs(c)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_table_paragraphs(table):
|
|
160
|
+
for row in table.rows:
|
|
161
|
+
for cell in row.cells:
|
|
162
|
+
yield from iter_cell_paragraphs(cell)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def all_editable_paragraphs(doc):
|
|
166
|
+
"""正文 + 表格 + 页眉页脚,去重(链接到前一节的页眉是同一个 part)。"""
|
|
167
|
+
out, seen = [], set()
|
|
168
|
+
|
|
169
|
+
def add(p):
|
|
170
|
+
key = id(p._p)
|
|
171
|
+
if key not in seen:
|
|
172
|
+
seen.add(key)
|
|
173
|
+
out.append(p)
|
|
174
|
+
|
|
175
|
+
for p in doc.paragraphs:
|
|
176
|
+
add(p)
|
|
177
|
+
for t in doc.tables:
|
|
178
|
+
for p in iter_table_paragraphs(t):
|
|
179
|
+
add(p)
|
|
180
|
+
for section in doc.sections:
|
|
181
|
+
for part in (section.header, section.footer):
|
|
182
|
+
try:
|
|
183
|
+
for p in part.paragraphs:
|
|
184
|
+
add(p)
|
|
185
|
+
for t in part.tables:
|
|
186
|
+
for p in iter_table_paragraphs(t):
|
|
187
|
+
add(p)
|
|
188
|
+
except Exception:
|
|
189
|
+
# 极少数残缺文档取不到页眉/页脚定义:跳过,不影响正文
|
|
190
|
+
continue
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── 文本替换 ──────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def replace_in_paragraph(paragraph, pairs):
|
|
197
|
+
"""先做 run 内替换(保留格式),跨 run 的再做段落级重建。返回替换次数。"""
|
|
198
|
+
count = 0
|
|
199
|
+
if not paragraph.runs:
|
|
200
|
+
return 0
|
|
201
|
+
for old, new in pairs:
|
|
202
|
+
if not old:
|
|
203
|
+
continue
|
|
204
|
+
for run in paragraph.runs:
|
|
205
|
+
if old in run.text:
|
|
206
|
+
count += run.text.count(old)
|
|
207
|
+
run.text = run.text.replace(old, new)
|
|
208
|
+
if old in paragraph.text:
|
|
209
|
+
runs = paragraph.runs
|
|
210
|
+
full = "".join(r.text for r in runs)
|
|
211
|
+
if old in full:
|
|
212
|
+
count += full.count(old)
|
|
213
|
+
runs[0].text = full.replace(old, new)
|
|
214
|
+
for r in runs[1:]:
|
|
215
|
+
r.text = ""
|
|
216
|
+
return count
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── 各动作 ────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def action_replace(payload):
|
|
222
|
+
files = collect_docx(payload.get("input"))
|
|
223
|
+
out = resolve_out(payload, payload.get("input"))
|
|
224
|
+
olds = payload.get("from") or []
|
|
225
|
+
news = payload.get("to") or []
|
|
226
|
+
if not olds:
|
|
227
|
+
raise ToolError("缺少 --from(要替换的旧文本)",
|
|
228
|
+
"例:replace --input ./合同 --from 旧公司名 --to 新公司名")
|
|
229
|
+
if len(olds) != len(news):
|
|
230
|
+
raise ToolError(
|
|
231
|
+
"--from 与 --to 数量不一致(%d vs %d)" % (len(olds), len(news)),
|
|
232
|
+
"每一组 --from 都要配一个 --to,按顺序一一对应。",
|
|
233
|
+
)
|
|
234
|
+
pairs = list(zip(olds, news))
|
|
235
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
results, total = [], 0
|
|
237
|
+
for src in files:
|
|
238
|
+
doc = Document(str(src))
|
|
239
|
+
n = 0
|
|
240
|
+
for p in all_editable_paragraphs(doc):
|
|
241
|
+
n += replace_in_paragraph(p, pairs)
|
|
242
|
+
target = unique_path(out, src.name)
|
|
243
|
+
doc.save(str(target))
|
|
244
|
+
results.append({
|
|
245
|
+
"input": str(src),
|
|
246
|
+
"input_name": src.name,
|
|
247
|
+
"output": str(target),
|
|
248
|
+
"output_name": target.name,
|
|
249
|
+
"replacements": n,
|
|
250
|
+
})
|
|
251
|
+
total += n
|
|
252
|
+
return {
|
|
253
|
+
"action": "replace",
|
|
254
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
255
|
+
"out": str(out),
|
|
256
|
+
"files": results,
|
|
257
|
+
"replacements": total,
|
|
258
|
+
"summary": "处理 %d 个文件,共替换 %d 处" % (len(files), total),
|
|
259
|
+
"artifacts": [r["output"] for r in results],
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def doc_body_text(doc):
|
|
264
|
+
lines = []
|
|
265
|
+
for block in iter_block_items(doc):
|
|
266
|
+
if isinstance(block, Paragraph):
|
|
267
|
+
lines.append(block.text)
|
|
268
|
+
else:
|
|
269
|
+
lines.append("")
|
|
270
|
+
lines.append("[表格]")
|
|
271
|
+
for row in block.rows:
|
|
272
|
+
lines.append("\t".join(c.text.replace("\n", " ") for c in row.cells))
|
|
273
|
+
return "\n".join(lines).strip() + "\n"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def action_extract(payload):
|
|
277
|
+
files = collect_docx(payload.get("input"))
|
|
278
|
+
out = resolve_out(payload, payload.get("input"))
|
|
279
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
results = []
|
|
281
|
+
for src in files:
|
|
282
|
+
text = doc_body_text(Document(str(src)))
|
|
283
|
+
target = unique_path(out, src.stem + ".txt")
|
|
284
|
+
target.write_text(text, encoding="utf-8")
|
|
285
|
+
results.append({
|
|
286
|
+
"input": str(src),
|
|
287
|
+
"input_name": src.name,
|
|
288
|
+
"output": str(target),
|
|
289
|
+
"output_name": target.name,
|
|
290
|
+
"chars": len(text),
|
|
291
|
+
})
|
|
292
|
+
return {
|
|
293
|
+
"action": "extract",
|
|
294
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
295
|
+
"out": str(out),
|
|
296
|
+
"files": results,
|
|
297
|
+
"summary": "提取 %d 个文件的正文文本" % len(files),
|
|
298
|
+
"artifacts": [r["output"] for r in results],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def copy_run(dst_paragraph, src_run):
|
|
303
|
+
run = dst_paragraph.add_run(src_run.text)
|
|
304
|
+
try:
|
|
305
|
+
run.bold = src_run.bold
|
|
306
|
+
run.italic = src_run.italic
|
|
307
|
+
run.underline = src_run.underline
|
|
308
|
+
if src_run.font.size is not None:
|
|
309
|
+
run.font.size = src_run.font.size
|
|
310
|
+
if src_run.font.name:
|
|
311
|
+
run.font.name = src_run.font.name
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # 字体属性个别无法复制时不影响文本合并
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def copy_paragraph(dst_doc, src_paragraph):
|
|
317
|
+
p = dst_doc.add_paragraph()
|
|
318
|
+
try:
|
|
319
|
+
style_name = src_paragraph.style.name if src_paragraph.style else None
|
|
320
|
+
if style_name and style_name.lower() not in ("normal", "正文"):
|
|
321
|
+
p.style = style_name
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
if src_paragraph.alignment is not None:
|
|
326
|
+
p.alignment = src_paragraph.alignment
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
for run in src_paragraph.runs:
|
|
330
|
+
copy_run(p, run)
|
|
331
|
+
return p
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def copy_table(dst_doc, src_table):
|
|
335
|
+
rows = len(src_table.rows)
|
|
336
|
+
cols = max((len(r.cells) for r in src_table.rows), default=0)
|
|
337
|
+
if rows == 0 or cols == 0:
|
|
338
|
+
return None
|
|
339
|
+
table = dst_doc.add_table(rows=rows, cols=cols)
|
|
340
|
+
for i, row in enumerate(src_table.rows):
|
|
341
|
+
for j, cell in enumerate(row.cells):
|
|
342
|
+
if j < cols:
|
|
343
|
+
table.cell(i, j).text = cell.text
|
|
344
|
+
try:
|
|
345
|
+
table.style = src_table.style.name
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
return table
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def action_merge(payload):
|
|
352
|
+
files = collect_docx(payload.get("input"))
|
|
353
|
+
out = resolve_out(payload, payload.get("input"))
|
|
354
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
merged = Document()
|
|
356
|
+
results = []
|
|
357
|
+
for src in files:
|
|
358
|
+
doc = Document(str(src))
|
|
359
|
+
paragraphs_before = len(merged.paragraphs)
|
|
360
|
+
for block in iter_block_items(doc):
|
|
361
|
+
if isinstance(block, Paragraph):
|
|
362
|
+
copy_paragraph(merged, block)
|
|
363
|
+
else:
|
|
364
|
+
copy_table(merged, block)
|
|
365
|
+
results.append({
|
|
366
|
+
"input": str(src),
|
|
367
|
+
"input_name": src.name,
|
|
368
|
+
"paragraphs": len(merged.paragraphs) - paragraphs_before,
|
|
369
|
+
"source_paragraphs": len(doc.paragraphs),
|
|
370
|
+
})
|
|
371
|
+
target = unique_path(out, "merged.docx")
|
|
372
|
+
merged.save(str(target))
|
|
373
|
+
return {
|
|
374
|
+
"action": "merge",
|
|
375
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
376
|
+
"out": str(out),
|
|
377
|
+
"files": results,
|
|
378
|
+
"output": str(target),
|
|
379
|
+
"output_name": target.name,
|
|
380
|
+
"paragraphs": len(merged.paragraphs),
|
|
381
|
+
"summary": "按文件名顺序合并 %d 个文件,共 %d 段" % (len(files), len(merged.paragraphs)),
|
|
382
|
+
"artifacts": [str(target)],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def section_texts(doc):
|
|
387
|
+
sections = []
|
|
388
|
+
for idx, section in enumerate(doc.sections, 1):
|
|
389
|
+
item = {"section": idx, "header": [], "footer": []}
|
|
390
|
+
for label, part in (("header", section.header), ("footer", section.footer)):
|
|
391
|
+
try:
|
|
392
|
+
texts = [p.text.strip() for p in part.paragraphs if p.text.strip()]
|
|
393
|
+
except Exception:
|
|
394
|
+
texts = []
|
|
395
|
+
item[label] = texts
|
|
396
|
+
sections.append(item)
|
|
397
|
+
return sections
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def action_headers(payload):
|
|
401
|
+
files = collect_docx(payload.get("input"))
|
|
402
|
+
out = resolve_out(payload, payload.get("input"))
|
|
403
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
results, lines = [], []
|
|
405
|
+
for src in files:
|
|
406
|
+
sections = section_texts(Document(str(src)))
|
|
407
|
+
hit = sum(1 for s in sections if s["header"] or s["footer"])
|
|
408
|
+
results.append({
|
|
409
|
+
"input": str(src),
|
|
410
|
+
"input_name": src.name,
|
|
411
|
+
"sections": len(sections),
|
|
412
|
+
"sections_with_text": hit,
|
|
413
|
+
"detail": sections,
|
|
414
|
+
})
|
|
415
|
+
lines.append("文件:%s(%d 节)" % (src.name, len(sections)))
|
|
416
|
+
for s in sections:
|
|
417
|
+
h = " / ".join(s["header"]) if s["header"] else "(空)"
|
|
418
|
+
f = " / ".join(s["footer"]) if s["footer"] else "(空)"
|
|
419
|
+
lines.append(" 第 %d 节 页眉:%s" % (s["section"], h))
|
|
420
|
+
lines.append(" 页脚:%s" % f)
|
|
421
|
+
lines.append("")
|
|
422
|
+
target = unique_path(out, "headers.txt")
|
|
423
|
+
target.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
424
|
+
return {
|
|
425
|
+
"action": "headers",
|
|
426
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
427
|
+
"out": str(out),
|
|
428
|
+
"files": results,
|
|
429
|
+
"output": str(target),
|
|
430
|
+
"output_name": target.name,
|
|
431
|
+
"summary": "列出 %d 个文件的页眉页脚" % len(files),
|
|
432
|
+
"artifacts": [str(target)],
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def inline_runs(paragraph):
|
|
437
|
+
parts = []
|
|
438
|
+
for run in paragraph.runs:
|
|
439
|
+
text = run.text
|
|
440
|
+
if not text:
|
|
441
|
+
continue
|
|
442
|
+
if run.bold and run.italic:
|
|
443
|
+
text = "***%s***" % text
|
|
444
|
+
elif run.bold:
|
|
445
|
+
text = "**%s**" % text
|
|
446
|
+
elif run.italic:
|
|
447
|
+
text = "*%s*" % text
|
|
448
|
+
parts.append(text)
|
|
449
|
+
text = "".join(parts).strip()
|
|
450
|
+
return text or paragraph.text.strip()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def table_to_md(table):
|
|
454
|
+
rows = []
|
|
455
|
+
for row in table.rows:
|
|
456
|
+
cells = [" ".join(c.text.split()) for c in row.cells]
|
|
457
|
+
if any(cells):
|
|
458
|
+
rows.append(cells)
|
|
459
|
+
if not rows:
|
|
460
|
+
return ""
|
|
461
|
+
width = max(len(r) for r in rows)
|
|
462
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
463
|
+
|
|
464
|
+
def esc(s):
|
|
465
|
+
return s.replace("|", "\\|")
|
|
466
|
+
|
|
467
|
+
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
|
|
468
|
+
"| " + " | ".join(["---"] * width) + " |"]
|
|
469
|
+
for r in rows[1:]:
|
|
470
|
+
out.append("| " + " | ".join(esc(c) for c in r) + " |")
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def paragraph_to_md(paragraph, state):
|
|
475
|
+
style = (paragraph.style.name if paragraph.style else "") or ""
|
|
476
|
+
style_low = style.lower()
|
|
477
|
+
text = inline_runs(paragraph)
|
|
478
|
+
m = re.match(r"^(heading|标题)\s*([1-6])$", style, re.I)
|
|
479
|
+
if m:
|
|
480
|
+
return "#" * int(m.group(2)) + " " + text
|
|
481
|
+
if style_low.startswith("title"):
|
|
482
|
+
return "# " + text
|
|
483
|
+
if "list bullet" in style_low or ("列表" in style and "项目" in style):
|
|
484
|
+
return "- " + text
|
|
485
|
+
if "list number" in style_low:
|
|
486
|
+
state["number"] += 1
|
|
487
|
+
return "%d. %s" % (state["number"], text)
|
|
488
|
+
if not text:
|
|
489
|
+
return ""
|
|
490
|
+
return text
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def doc_to_md(doc):
|
|
494
|
+
blocks = []
|
|
495
|
+
state = {"number": 0}
|
|
496
|
+
for block in iter_block_items(doc):
|
|
497
|
+
if isinstance(block, Paragraph):
|
|
498
|
+
md = paragraph_to_md(block, state)
|
|
499
|
+
else:
|
|
500
|
+
md = table_to_md(block)
|
|
501
|
+
if md.strip():
|
|
502
|
+
blocks.append(md)
|
|
503
|
+
return "\n\n".join(blocks).strip() + "\n"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def action_to_md(payload):
|
|
507
|
+
files = collect_docx(payload.get("input"))
|
|
508
|
+
out = resolve_out(payload, payload.get("input"))
|
|
509
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
results = []
|
|
511
|
+
for src in files:
|
|
512
|
+
md = doc_to_md(Document(str(src)))
|
|
513
|
+
target = unique_path(out, src.stem + ".md")
|
|
514
|
+
target.write_text(md, encoding="utf-8")
|
|
515
|
+
results.append({
|
|
516
|
+
"input": str(src),
|
|
517
|
+
"input_name": src.name,
|
|
518
|
+
"output": str(target),
|
|
519
|
+
"output_name": target.name,
|
|
520
|
+
"chars": len(md),
|
|
521
|
+
})
|
|
522
|
+
return {
|
|
523
|
+
"action": "to-md",
|
|
524
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
525
|
+
"out": str(out),
|
|
526
|
+
"files": results,
|
|
527
|
+
"summary": "转换 %d 个文件为 Markdown" % len(files),
|
|
528
|
+
"artifacts": [r["output"] for r in results],
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ── demo ─────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
def _make_demo_docx(path, heading, lines, table_rows=None, header=True):
|
|
535
|
+
doc = Document()
|
|
536
|
+
doc.add_heading(heading, level=1)
|
|
537
|
+
for line in lines:
|
|
538
|
+
doc.add_paragraph(line)
|
|
539
|
+
if table_rows:
|
|
540
|
+
table = doc.add_table(rows=len(table_rows), cols=len(table_rows[0]))
|
|
541
|
+
for i, row in enumerate(table_rows):
|
|
542
|
+
for j, value in enumerate(row):
|
|
543
|
+
table.cell(i, j).text = value
|
|
544
|
+
if header:
|
|
545
|
+
doc.sections[0].header.paragraphs[0].text = DEMO_HEADER
|
|
546
|
+
doc.sections[0].footer.paragraphs[0].text = DEMO_FOOTER
|
|
547
|
+
doc.save(str(path))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def action_demo(payload):
|
|
551
|
+
root = Path(tempfile.mkdtemp(prefix="dsh-docx-batch-demo-"))
|
|
552
|
+
inputs = root / "样例"
|
|
553
|
+
inputs.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
_make_demo_docx(
|
|
555
|
+
inputs / "合同A.docx",
|
|
556
|
+
"第一章 总则",
|
|
557
|
+
["甲方:深鲸科技", "乙方:示例客户", "本合同由深鲸科技起草。"],
|
|
558
|
+
[["条款", "内容"], ["保密", "深鲸科技对数据保密"]],
|
|
559
|
+
)
|
|
560
|
+
_make_demo_docx(
|
|
561
|
+
inputs / "附件B.docx",
|
|
562
|
+
"附件 B",
|
|
563
|
+
["附件内容第一段", "附件内容第二段"],
|
|
564
|
+
)
|
|
565
|
+
out = root / "输出"
|
|
566
|
+
steps, artifacts = [], []
|
|
567
|
+
|
|
568
|
+
def run(name, fn, extra):
|
|
569
|
+
p = {"input": str(inputs), "out": str(out / name)}
|
|
570
|
+
p.update(extra)
|
|
571
|
+
result = fn(p)
|
|
572
|
+
steps.append({
|
|
573
|
+
"step": name,
|
|
574
|
+
"action": result.get("action"),
|
|
575
|
+
"summary": result.get("summary", ""),
|
|
576
|
+
"out": result.get("out"),
|
|
577
|
+
})
|
|
578
|
+
artifacts.extend(result.get("artifacts", []))
|
|
579
|
+
|
|
580
|
+
run("替换", action_replace, {"from": ["深鲸科技"], "to": ["深海鲸"]})
|
|
581
|
+
replaced_dir = out / "替换"
|
|
582
|
+
# 提取/转 Markdown 用替换后的产物,顺带演示链路
|
|
583
|
+
p_extract = {"input": str(replaced_dir), "out": str(out / "文本")}
|
|
584
|
+
result = action_extract(p_extract)
|
|
585
|
+
steps.append({"step": "文本", "action": "extract", "summary": result["summary"], "out": result["out"]})
|
|
586
|
+
artifacts.extend(result["artifacts"])
|
|
587
|
+
p_md = {"input": str(replaced_dir), "out": str(out / "markdown")}
|
|
588
|
+
result = action_to_md(p_md)
|
|
589
|
+
steps.append({"step": "markdown", "action": "to-md", "summary": result["summary"], "out": result["out"]})
|
|
590
|
+
artifacts.extend(result["artifacts"])
|
|
591
|
+
run("合并", action_merge, {})
|
|
592
|
+
run("页眉页脚", action_headers, {})
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
"action": "demo",
|
|
596
|
+
"demoRoot": str(root),
|
|
597
|
+
"steps": steps,
|
|
598
|
+
"summary": "演示完成:5 个动作全部跑通,产物 %d 个" % len(artifacts),
|
|
599
|
+
"artifacts": artifacts,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
ACTIONS = {
|
|
604
|
+
"replace": action_replace,
|
|
605
|
+
"extract": action_extract,
|
|
606
|
+
"merge": action_merge,
|
|
607
|
+
"headers": action_headers,
|
|
608
|
+
"to-md": action_to_md,
|
|
609
|
+
"demo": action_demo,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def main(argv):
|
|
614
|
+
if len(argv) < 3:
|
|
615
|
+
emit({"ok": False, "code": 2,
|
|
616
|
+
"message": "内部调用参数不足:需要 <action> 和 JSON payload",
|
|
617
|
+
"hint": "请通过 bin/cli.mjs 调用本脚本。"})
|
|
618
|
+
return 2
|
|
619
|
+
action, raw = argv[1], argv[2]
|
|
620
|
+
try:
|
|
621
|
+
payload = json.loads(raw) if raw else {}
|
|
622
|
+
except json.JSONDecodeError as exc:
|
|
623
|
+
emit({"ok": False, "code": 2, "message": "参数 JSON 解析失败:%s" % exc, "hint": ""})
|
|
624
|
+
return 2
|
|
625
|
+
fn = ACTIONS.get(action)
|
|
626
|
+
if not fn:
|
|
627
|
+
emit({"ok": False, "code": 2, "message": "未知动作:%s" % action,
|
|
628
|
+
"hint": "可用动作:%s" % " / ".join(k for k in ACTIONS if k != "demo")})
|
|
629
|
+
return 2
|
|
630
|
+
try:
|
|
631
|
+
result = fn(payload)
|
|
632
|
+
result.setdefault("ok", True)
|
|
633
|
+
emit(result)
|
|
634
|
+
return 0
|
|
635
|
+
except ToolError as exc:
|
|
636
|
+
emit({"ok": False, "code": exc.code, "message": exc.message, "hint": exc.hint})
|
|
637
|
+
return exc.code
|
|
638
|
+
except PermissionError as exc:
|
|
639
|
+
emit({"ok": False, "code": 1,
|
|
640
|
+
"message": "没有权限读写文件:%s" % exc,
|
|
641
|
+
"hint": "检查目标文件是否被 Word 占用、或输出目录是否有写权限。"})
|
|
642
|
+
return 1
|
|
643
|
+
except Exception as exc: # noqa: BLE001 —— 兜底,保证永远输出 JSON
|
|
644
|
+
traceback.print_exc(file=sys.stderr)
|
|
645
|
+
emit({"ok": False, "code": 1, "message": "处理失败:%s" % exc,
|
|
646
|
+
"hint": "文件可能已损坏或被加密;可先用 Word 打开另存一次再试。"})
|
|
647
|
+
return 1
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
if __name__ == "__main__":
|
|
651
|
+
sys.exit(main(sys.argv))
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createPlugin, pkgRootFrom } from '@deepwhale-cn/dsh-guard-core/plugin';
|
|
2
|
+
|
|
3
|
+
export const { name, inject, apply } = createPlugin({
|
|
4
|
+
name: 'docx-batch',
|
|
5
|
+
pkgRoot: pkgRootFrom(import.meta.url),
|
|
6
|
+
skillFile: 'skill/docx-batch.md',
|
|
7
|
+
binFile: 'bin/cli.mjs',
|
|
8
|
+
description: 'Word 批量替换 / 合并 / 提取文本 / 页眉页脚 / 转 Markdown,一次处理整目录',
|
|
9
|
+
whenToUse: '当用户要批量处理整目录的 Word(.docx)时使用:批量替换文本(合同改甲方、术语统一)、合并多份文档、提取正文到 txt、查看页眉页脚、转 Markdown。典型说法:「把这批 Word 里的旧公司名全换掉」「这些 docx 合并成一份」「Word 转 Markdown」。不用于 .doc 老格式、加密文档或需要保留复杂排版的场景。',
|
|
10
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepwhale-cn/dsh-docx-batch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Word 批量替换 / 合并 / 提取文本 / 页眉页脚 / 转 Markdown,一次处理整目录",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["dsh", "dsh-plugin", "deepseek-harness", "skill", "tool", "docx-batch"],
|
|
8
|
+
"files": ["lib", "bin", "skill", "cordis.patch.yml", "tests/fixtures", "README.md", "LICENSE"],
|
|
9
|
+
"main": "./lib/index.js",
|
|
10
|
+
"exports": { ".": "./lib/index.js", "./package.json": "./package.json" },
|
|
11
|
+
"bin": { "dsh-docx-batch": "./bin/cli.mjs" },
|
|
12
|
+
"engines": { "dsh": ">=0.1.0-rc.6 <0.3.0", "node": ">=18" },
|
|
13
|
+
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
|
|
14
|
+
"dependencies": { "@deepwhale-cn/dsh-guard-core": "^0.5.1" },
|
|
15
|
+
"peerDependencies": { "@deepseek-ai/dsh-skill": "*" },
|
|
16
|
+
"repository": { "type": "git", "url": "git+https://github.com/feely0208/dsh-docx-batch.git" },
|
|
17
|
+
"homepage": "https://www.deepwhale.org.cn/skills.html",
|
|
18
|
+
"bugs": { "url": "https://github.com/feely0208/dsh-docx-batch/issues" }
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Word 批量处理
|
|
2
|
+
|
|
3
|
+
**一句话**:整目录的 Word 一次处理完 —— 批量替换文本、提取正文、按文件名合并、列页眉页脚、转 Markdown。只读输入,产物单独输出,不覆盖原文件。
|
|
4
|
+
|
|
5
|
+
## 什么时候用
|
|
6
|
+
|
|
7
|
+
用户说这些话时用:
|
|
8
|
+
|
|
9
|
+
- 「把这批 Word 里的××全部改成××」「50 份合同批量替换甲方」
|
|
10
|
+
- 「把目录下这些 docx 合并成一份」「周报合成一个文件」
|
|
11
|
+
- 「把这些 Word 的正文提取成 txt」「把 Word 转成 Markdown」
|
|
12
|
+
- 「看看这些文档的页眉页脚都写了什么」
|
|
13
|
+
- 用户给了一个装了很多 `.docx` 的目录,要求批量处理其中文字
|
|
14
|
+
|
|
15
|
+
## 怎么调用
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
<cli 路径> --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 批量替换(--from/--to 可多组,按顺序配对;改正文、表格、页眉页脚)
|
|
23
|
+
<cli 路径> replace --input <文件或目录> [--out <输出目录>] \
|
|
24
|
+
--from 旧文本 --to 新文本 [--from 旧2 --to 新2] [--json]
|
|
25
|
+
|
|
26
|
+
# 提取正文到 .txt
|
|
27
|
+
<cli 路径> extract --input <文件或目录> [--out <输出目录>] [--json]
|
|
28
|
+
|
|
29
|
+
# 按文件名自然顺序合并
|
|
30
|
+
<cli 路径> merge --input <文件或目录> [--out <输出目录>] [--json]
|
|
31
|
+
|
|
32
|
+
# 列出页眉页脚
|
|
33
|
+
<cli 路径> headers --input <文件或目录> [--out <输出目录>] [--json]
|
|
34
|
+
|
|
35
|
+
# 转 Markdown
|
|
36
|
+
<cli 路径> to-md --input <文件或目录> [--out <输出目录>] [--json]
|
|
37
|
+
|
|
38
|
+
# 自造样例跑完整流程(不碰用户文件),推荐先跑这个确认环境正常
|
|
39
|
+
<cli 路径> --demo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 输出约定(要向用户说清)
|
|
43
|
+
|
|
44
|
+
- **不修改输入文件**;输出默认写到 `<输入目录>/_输出`,可用 `--out` 指定。
|
|
45
|
+
- 同名产物不覆盖:自动变成 `合同_1.docx`、`合同_2.docx`。
|
|
46
|
+
- 目录递归处理,跳过 `~$` 临时文件和 `_输出` 目录。
|
|
47
|
+
- 成功退出码 0;参数/输入错误退出码 2;缺依赖退出码 3;运行失败退出码 1。
|
|
48
|
+
- `--json` 时输出结构化结果(含每个文件的产物路径和替换次数 / 字符数等)。
|
|
49
|
+
|
|
50
|
+
## 边界(必须向用户说清)
|
|
51
|
+
|
|
52
|
+
- **替换可能影响局部格式**:跨多个 run 的匹配会合并段落内的 run,局部加粗/字号/颜色可能丢失。
|
|
53
|
+
重要文档先小批量跑一份对比,再整目录处理。
|
|
54
|
+
- 只支持 `.docx`;`.doc` 老格式、`.docm`、加密文档不支持,需先另存为 `.docx`。
|
|
55
|
+
- 只覆盖正文段落、表格、页眉页脚;**不处理**脚注、尾注、批注、文本框、SmartArt、图片里的文字。
|
|
56
|
+
- `merge` 只合并正文与表格,不合并页眉页脚和样式主题;复杂排版会退化为顺序文本。
|
|
57
|
+
- `headers` 只读不改;替换是字面匹配,大小写敏感,不支持正则。
|
|
58
|
+
- 缺 `python3` 或 `python-docx` 时会明确打印安装命令(`python3 -m pip install python-docx`),不会静默失败。
|
|
59
|
+
- 不联网、不上传文档,全部在本机处理。
|