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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,26 @@
1
+ import { createPlugin, pkgRootFrom } from '@deepwhale-cn/dsh-guard-core/plugin';
2
+ import { registerDocBatchApi } from './api.js';
3
+
4
+ const base = createPlugin({
5
+ name: 'doc-batch',
6
+ pkgRoot: pkgRootFrom(import.meta.url),
7
+ skillFile: 'skill/doc-batch.md',
8
+ binFile: 'bin/cli.mjs',
9
+ description: '深鲸文档批量处理:Word / Excel / PDF 一次处理整目录(替换、提取、合并、拆分、压缩、水印、导出)',
10
+ whenToUse: '当用户要批量处理整目录的文档时使用:Word(替换/提取/合并/页眉页脚/转 Markdown)、Excel(合并/清洗/汇总/导出 CSV)、PDF(合并/拆分/压缩/水印/提取文本或图片)。典型说法:「把这批合同里的旧公司名全换掉」「这些表合并成一张」「把这些 PDF 按页拆开」。',
11
+ });
12
+
13
+ export const name = base.name;
14
+ export const inject = [...base.inject, 'webServer'];
15
+
16
+ export function apply(ctx) {
17
+ base.apply(ctx);
18
+ try {
19
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
20
+ registerDocBatchApi(ctx);
21
+ ctx.logger?.info?.('[doc-batch] 已挂面板出口 /dsh-doc-batch/api');
22
+ }
23
+ } catch (e) {
24
+ ctx.logger?.warn?.('[doc-batch] 面板出口注册失败(CLI/skill 不受影响): ' + (e?.message || e));
25
+ }
26
+ }