@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,554 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ dsh-xlsx-batch 的真实处理逻辑(openpyxl)
5
+
6
+ 由 bin/cli.mjs 通过 spawnSync 调用:
7
+ python3 lib/xlsx_tool.py <action> '<json payload>'
8
+ 约定:
9
+ - stdout 只输出一行 JSON(最后一行),人类可读文案由 Node 侧负责
10
+ - stderr 输出异常栈(调试用)
11
+ - 退出码:0 成功 / 1 运行失败 / 2 参数或输入问题 / 3 缺依赖
12
+ - 只读输入文件,所有产物写进 --out 目录(默认 <输入目录>/_输出)
13
+ - 读取用 data_only=True:公式取 Excel 缓存的计算结果;输出为值(不是公式)
14
+ """
15
+
16
+ import csv
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ import tempfile
22
+ import traceback
23
+ from pathlib import Path
24
+
25
+ try:
26
+ from openpyxl import Workbook, load_workbook
27
+ from openpyxl.styles import Font
28
+ except ImportError as exc: # 缺依赖必须明确提示安装命令,不能静默
29
+ print(json.dumps({
30
+ "ok": False,
31
+ "code": 3,
32
+ "message": "缺少 Python 依赖:%s" % getattr(exc, "name", exc),
33
+ "hint": "请先安装:\n python3 -m pip install openpyxl\n"
34
+ "如果 python3 也没有:\n macOS: brew install python3\n Ubuntu: sudo apt install python3",
35
+ }, ensure_ascii=False))
36
+ sys.exit(3)
37
+
38
+ XLSX_EXTS = {".xlsx", ".xlsm"}
39
+ SKIP_DIRS = {"_输出", "node_modules", ".git", "__pycache__", ".venv", "venv"}
40
+ SOURCE_FILE = "来源文件"
41
+ SOURCE_SHEET = "来源工作表"
42
+ NUM_RE = re.compile(r"^-?\d{1,3}(?:,\d{3})*(?:\.\d+)?$|^-?\d+(?:\.\d+)?$")
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 = payload.get("out")
67
+ if out:
68
+ return Path(out).expanduser().resolve()
69
+ base = Path(input_path).expanduser().resolve()
70
+ if not base.is_dir():
71
+ base = base.parent
72
+ return base / "_输出"
73
+
74
+
75
+ def collect_xlsx(input_path, out_dir=None):
76
+ if not input_path:
77
+ raise ToolError("缺少 --input", "例:merge --input ./报表目录 --out ./汇总")
78
+ root = Path(input_path).expanduser().resolve()
79
+ if not root.exists():
80
+ raise ToolError(
81
+ "输入不存在:%s" % input_path,
82
+ "请检查路径是否正确;传目录会递归处理其中的 .xlsx 文件。",
83
+ )
84
+ skip_out = set()
85
+ if out_dir:
86
+ try:
87
+ skip_out = {str(out_dir.resolve())}
88
+ except OSError:
89
+ skip_out = set()
90
+ if root.is_file():
91
+ if root.suffix.lower() not in XLSX_EXTS:
92
+ raise ToolError(
93
+ "输入不是 Excel 文件:%s" % root.name,
94
+ "本插件只处理 .xlsx / .xlsm;老格式 .xls 请先另存为 .xlsx。",
95
+ )
96
+ return [root]
97
+ files = []
98
+ for dirpath, dirnames, filenames in os.walk(root):
99
+ dirnames[:] = sorted(
100
+ [d for d in dirnames if d not in SKIP_DIRS and not d.startswith("~$")],
101
+ key=natural_key,
102
+ )
103
+ if str(Path(dirpath).resolve()) in skip_out:
104
+ dirnames[:] = []
105
+ continue
106
+ for name in filenames:
107
+ if name.startswith("~$"):
108
+ continue # Excel 打开时的临时文件
109
+ p = Path(dirpath) / name
110
+ if p.suffix.lower() in XLSX_EXTS:
111
+ files.append(p)
112
+ files.sort(key=lambda p: natural_key(str(p.relative_to(root))))
113
+ if not files:
114
+ raise ToolError(
115
+ "目录里没有 .xlsx / .xlsm 文件:%s" % root,
116
+ "确认目录选对了;.xls 老格式请先另存为 .xlsx。",
117
+ )
118
+ return files
119
+
120
+
121
+ def unique_path(directory, filename):
122
+ directory = Path(directory)
123
+ target = directory / filename
124
+ if not target.exists():
125
+ return target
126
+ stem, suffix = target.stem, target.suffix
127
+ n = 1
128
+ while (directory / ("%s_%d%s" % (stem, n, suffix))).exists():
129
+ n += 1
130
+ return directory / ("%s_%d%s" % (stem, n, suffix))
131
+
132
+
133
+ def safe_name(text, default="sheet"):
134
+ name = re.sub(r'[\\/:*?"<>|\[\]]', "_", str(text)).strip()
135
+ return name or default
136
+
137
+
138
+ # ── 单元格判断与表格读取 ──────────────────────────────────────────
139
+
140
+ def is_empty(value):
141
+ return value is None or (isinstance(value, str) and value.strip() == "")
142
+
143
+
144
+ def trim_text(value):
145
+ if isinstance(value, str):
146
+ value = value.strip()
147
+ return value if value else None
148
+ return value
149
+
150
+
151
+ def to_number(value):
152
+ """数值列判定:数字原样;纯数字字符串(可带千分位)转数字;其余不算。"""
153
+ if isinstance(value, bool):
154
+ return None
155
+ if isinstance(value, (int, float)):
156
+ return value
157
+ if isinstance(value, str):
158
+ text = value.strip()
159
+ if NUM_RE.fullmatch(text):
160
+ try:
161
+ return float(text.replace(",", ""))
162
+ except ValueError:
163
+ return None
164
+ return None
165
+
166
+
167
+ def trim_trailing(rows):
168
+ while rows and all(is_empty(v) for v in rows[-1]):
169
+ rows.pop()
170
+ width = 0
171
+ for row in rows:
172
+ for i, value in enumerate(row):
173
+ if not is_empty(value):
174
+ width = max(width, i + 1)
175
+ return [(row + [None] * width)[:width] for row in rows]
176
+
177
+
178
+ def read_sheet_rows(ws):
179
+ rows = [list(row) for row in ws.iter_rows(values_only=True)]
180
+ return trim_trailing(rows)
181
+
182
+
183
+ def split_header(rows):
184
+ """第一条非空行当表头,其余当数据。"""
185
+ for idx, row in enumerate(rows):
186
+ if any(not is_empty(v) for v in row):
187
+ return row, rows[idx + 1:]
188
+ return [], []
189
+
190
+
191
+ def header_names(header):
192
+ names, used = [], {}
193
+ for i, value in enumerate(header):
194
+ base = str(value).strip() if not is_empty(value) else "列%d" % (i + 1)
195
+ count = used.get(base, 0) + 1
196
+ used[base] = count
197
+ names.append(base if count == 1 else "%s#%d" % (base, count))
198
+ return names
199
+
200
+
201
+ # ── 各动作 ────────────────────────────────────────────────────────
202
+
203
+ def action_merge(payload):
204
+ files = collect_xlsx(payload.get("input"))
205
+ out = resolve_out(payload, payload.get("input"))
206
+ out.mkdir(parents=True, exist_ok=True)
207
+
208
+ sheets = [] # (file, sheet_name, names, data_rows)
209
+ union, seen = [], set()
210
+ for src in files:
211
+ wb = load_workbook(str(src), data_only=True)
212
+ for ws in wb.worksheets:
213
+ rows = read_sheet_rows(ws)
214
+ header, data = split_header(rows)
215
+ if not header:
216
+ continue
217
+ names = header_names(header)
218
+ data = [r for r in data if any(not is_empty(v) for v in r)]
219
+ sheets.append((src, ws.title, names, data))
220
+ for name in names:
221
+ if name not in seen:
222
+ seen.add(name)
223
+ union.append(name)
224
+
225
+ header_row = [SOURCE_FILE, SOURCE_SHEET] + union
226
+ wb_out = Workbook()
227
+ ws_out = wb_out.active
228
+ ws_out.title = "合并"
229
+ ws_out.append(header_row)
230
+ for cell in ws_out[1]:
231
+ cell.font = Font(bold=True)
232
+
233
+ total_rows = 0
234
+ file_results = []
235
+ for src, sheet_name, names, data in sheets:
236
+ for row in data:
237
+ mapping = dict(zip(names, row))
238
+ ws_out.append([src.name, sheet_name] + [mapping.get(name) for name in union])
239
+ total_rows += 1
240
+ file_results.append({
241
+ "input": str(src),
242
+ "input_name": src.name,
243
+ "sheet": sheet_name,
244
+ "rows": len(data),
245
+ })
246
+
247
+ ws_out.freeze_panes = "A2"
248
+ if ws_out.max_row >= 2:
249
+ ws_out.auto_filter.ref = ws_out.dimensions
250
+ target = unique_path(out, "merged.xlsx")
251
+ wb_out.save(str(target))
252
+
253
+ return {
254
+ "action": "merge",
255
+ "input": str(Path(payload["input"]).expanduser().resolve()),
256
+ "out": str(out),
257
+ "files": file_results,
258
+ "output": str(target),
259
+ "output_name": target.name,
260
+ "rows": total_rows,
261
+ "sheets": len(sheets),
262
+ "summary": "合并 %d 个文件 / %d 个工作表,写入 %d 行数据" % (len(files), len(sheets), total_rows),
263
+ "artifacts": [str(target)],
264
+ }
265
+
266
+
267
+ def normalize_rows(rows):
268
+ """trim 文本 + 统计被 trim 的单元格数。"""
269
+ trimmed, out = 0, []
270
+ for row in rows:
271
+ new_row = []
272
+ for value in row:
273
+ if isinstance(value, str):
274
+ stripped = value.strip()
275
+ if stripped != value:
276
+ trimmed += 1
277
+ new_row.append(stripped if stripped else None)
278
+ else:
279
+ new_row.append(value)
280
+ out.append(new_row)
281
+ return out, trimmed
282
+
283
+
284
+ def action_clean(payload):
285
+ files = collect_xlsx(payload.get("input"))
286
+ out = resolve_out(payload, payload.get("input"))
287
+ out.mkdir(parents=True, exist_ok=True)
288
+ results = []
289
+ for src in files:
290
+ wb = load_workbook(str(src), data_only=True)
291
+ wb_out = Workbook()
292
+ wb_out.remove(wb_out.active)
293
+ total_in = total_empty = total_dup = total_trim = total_out = 0
294
+ for ws in wb.worksheets:
295
+ rows = read_sheet_rows(ws)
296
+ header, data = split_header(rows)
297
+ header, trim_h = normalize_rows([header] if header else [])
298
+ header = header[0] if header else []
299
+ data, trim_d = normalize_rows(data)
300
+ total_trim += trim_h + trim_d
301
+
302
+ seen, kept = set(), []
303
+ empty = dup = 0
304
+ for row in data:
305
+ if all(is_empty(v) for v in row):
306
+ empty += 1
307
+ continue
308
+ key = tuple(row)
309
+ if key in seen:
310
+ dup += 1
311
+ continue
312
+ seen.add(key)
313
+ kept.append(row)
314
+
315
+ total_in += len(data)
316
+ total_empty += empty
317
+ total_dup += dup
318
+ total_out += len(kept)
319
+
320
+ new_ws = wb_out.create_sheet(title=safe_name(ws.title)[:31])
321
+ if header:
322
+ new_ws.append(header)
323
+ for cell in new_ws[1]:
324
+ cell.font = Font(bold=True)
325
+ for row in kept:
326
+ new_ws.append(row)
327
+ new_ws.freeze_panes = "A2"
328
+
329
+ target = unique_path(out, src.stem + ".xlsx")
330
+ wb_out.save(str(target))
331
+ results.append({
332
+ "input": str(src),
333
+ "input_name": src.name,
334
+ "output": str(target),
335
+ "output_name": target.name,
336
+ "rows_in": total_in,
337
+ "rows_out": total_out,
338
+ "empty_removed": total_empty,
339
+ "duplicates_removed": total_dup,
340
+ "cells_trimmed": total_trim,
341
+ })
342
+ return {
343
+ "action": "clean",
344
+ "input": str(Path(payload["input"]).expanduser().resolve()),
345
+ "out": str(out),
346
+ "files": results,
347
+ "summary": "清洗 %d 个文件:删空行 %d、去重 %d、trim %d 个单元格" % (
348
+ len(files),
349
+ sum(r["empty_removed"] for r in results),
350
+ sum(r["duplicates_removed"] for r in results),
351
+ sum(r["cells_trimmed"] for r in results),
352
+ ),
353
+ "artifacts": [r["output"] for r in results],
354
+ }
355
+
356
+
357
+ def action_summary(payload):
358
+ files = collect_xlsx(payload.get("input"))
359
+ out = resolve_out(payload, payload.get("input"))
360
+ out.mkdir(parents=True, exist_ok=True)
361
+ wb_out = Workbook()
362
+ ws_out = wb_out.active
363
+ ws_out.title = "汇总"
364
+ ws_out.append(["文件", "工作表", "列名", "非空计数", "数值个数", "求和"])
365
+ for cell in ws_out[1]:
366
+ cell.font = Font(bold=True)
367
+ report_rows = 0
368
+ file_results = []
369
+
370
+ for src in files:
371
+ wb = load_workbook(str(src), data_only=True)
372
+ sheet_count = 0
373
+ for ws in wb.worksheets:
374
+ rows = read_sheet_rows(ws)
375
+ header, data = split_header(rows)
376
+ if not header:
377
+ continue
378
+ names = header_names(header)
379
+ sheet_count += 1
380
+ width = len(header)
381
+ for col in range(width):
382
+ values = [row[col] for row in data if col < len(row)]
383
+ values = [v for v in values if not is_empty(v)]
384
+ numbers = [n for n in (to_number(v) for v in values) if n is not None]
385
+ ws_out.append([
386
+ src.name,
387
+ ws.title,
388
+ names[col],
389
+ len(values),
390
+ len(numbers),
391
+ sum(numbers) if numbers else None,
392
+ ])
393
+ report_rows += 1
394
+ file_results.append({
395
+ "input": str(src),
396
+ "input_name": src.name,
397
+ "sheets": sheet_count,
398
+ })
399
+
400
+ ws_out.freeze_panes = "A2"
401
+ if ws_out.max_row >= 2:
402
+ ws_out.auto_filter.ref = ws_out.dimensions
403
+ for letter, width in (("A", 26), ("B", 22), ("C", 24), ("D", 12), ("E", 12), ("F", 14)):
404
+ ws_out.column_dimensions[letter].width = width
405
+ target = unique_path(out, "summary.xlsx")
406
+ wb_out.save(str(target))
407
+
408
+ return {
409
+ "action": "summary",
410
+ "input": str(Path(payload["input"]).expanduser().resolve()),
411
+ "out": str(out),
412
+ "files": file_results,
413
+ "output": str(target),
414
+ "output_name": target.name,
415
+ "rows": report_rows,
416
+ "summary": "汇总 %d 个文件,输出 %d 行列级统计" % (len(files), report_rows),
417
+ "artifacts": [str(target)],
418
+ }
419
+
420
+
421
+ def action_csv(payload):
422
+ files = collect_xlsx(payload.get("input"))
423
+ out = resolve_out(payload, payload.get("input"))
424
+ out.mkdir(parents=True, exist_ok=True)
425
+ results = []
426
+ for src in files:
427
+ wb = load_workbook(str(src), data_only=True)
428
+ for ws in wb.worksheets:
429
+ rows = read_sheet_rows(ws)
430
+ filename = "%s__%s.csv" % (safe_name(src.stem), safe_name(ws.title))
431
+ target = unique_path(out, filename)
432
+ with target.open("w", encoding="utf-8-sig", newline="") as fh:
433
+ writer = csv.writer(fh)
434
+ for row in rows:
435
+ writer.writerow(["" if v is None else v for v in row])
436
+ results.append({
437
+ "input": str(src),
438
+ "input_name": src.name,
439
+ "sheet": ws.title,
440
+ "output": str(target),
441
+ "output_name": target.name,
442
+ "rows": len(rows),
443
+ })
444
+ return {
445
+ "action": "csv",
446
+ "input": str(Path(payload["input"]).expanduser().resolve()),
447
+ "out": str(out),
448
+ "files": results,
449
+ "summary": "导出 %d 个 CSV(UTF-8 带 BOM,Excel 可直接打开)" % len(results),
450
+ "artifacts": [r["output"] for r in results],
451
+ }
452
+
453
+
454
+ # ── demo ─────────────────────────────────────────────────────────
455
+
456
+ def _demo_book(path, sheets):
457
+ wb = Workbook()
458
+ wb.remove(wb.active)
459
+ for name, rows in sheets:
460
+ ws = wb.create_sheet(title=name)
461
+ for row in rows:
462
+ ws.append(row)
463
+ wb.save(str(path))
464
+
465
+
466
+ def action_demo(payload):
467
+ root = Path(tempfile.mkdtemp(prefix="dsh-xlsx-batch-demo-"))
468
+ inputs = root / "样例"
469
+ inputs.mkdir(parents=True, exist_ok=True)
470
+ _demo_book(inputs / "销售A.xlsx", [
471
+ ("销售", [
472
+ ["姓名", "部门", "金额"],
473
+ ["张三", " 销售部 ", "100"],
474
+ ["李四", "销售部", "200"],
475
+ [None, None, None],
476
+ ["张三", " 销售部 ", "100"],
477
+ ]),
478
+ ("库存", [["品名", "数量"], ["A", 5], ["B", 7]]),
479
+ ])
480
+ _demo_book(inputs / "销售B.xlsx", [
481
+ ("销售", [["部门", "姓名", "金额"], ["市场部", "王五", "300"]]),
482
+ ("备注", [["说明"], ["样例数据"]]),
483
+ ])
484
+ out = root / "输出"
485
+ steps, artifacts = [], []
486
+
487
+ def run(name, fn):
488
+ result = fn({"input": str(inputs), "out": str(out / name)})
489
+ steps.append({"step": name, "action": result.get("action"),
490
+ "summary": result.get("summary", ""), "out": result.get("out")})
491
+ artifacts.extend(result.get("artifacts", []))
492
+
493
+ run("合并", action_merge)
494
+ run("清洗", action_clean)
495
+ run("汇总", action_summary)
496
+ run("CSV", action_csv)
497
+
498
+ return {
499
+ "action": "demo",
500
+ "demoRoot": str(root),
501
+ "steps": steps,
502
+ "summary": "演示完成:4 个动作全部跑通,产物 %d 个" % len(artifacts),
503
+ "artifacts": artifacts,
504
+ }
505
+
506
+
507
+ ACTIONS = {
508
+ "merge": action_merge,
509
+ "clean": action_clean,
510
+ "summary": action_summary,
511
+ "csv": action_csv,
512
+ "demo": action_demo,
513
+ }
514
+
515
+
516
+ def main(argv):
517
+ if len(argv) < 3:
518
+ emit({"ok": False, "code": 2,
519
+ "message": "内部调用参数不足:需要 <action> 和 JSON payload",
520
+ "hint": "请通过 bin/cli.mjs 调用本脚本。"})
521
+ return 2
522
+ action, raw = argv[1], argv[2]
523
+ try:
524
+ payload = json.loads(raw) if raw else {}
525
+ except json.JSONDecodeError as exc:
526
+ emit({"ok": False, "code": 2, "message": "参数 JSON 解析失败:%s" % exc, "hint": ""})
527
+ return 2
528
+ fn = ACTIONS.get(action)
529
+ if not fn:
530
+ emit({"ok": False, "code": 2, "message": "未知动作:%s" % action,
531
+ "hint": "可用动作:%s" % " / ".join(k for k in ACTIONS if k != "demo")})
532
+ return 2
533
+ try:
534
+ result = fn(payload)
535
+ result.setdefault("ok", True)
536
+ emit(result)
537
+ return 0
538
+ except ToolError as exc:
539
+ emit({"ok": False, "code": exc.code, "message": exc.message, "hint": exc.hint})
540
+ return exc.code
541
+ except PermissionError as exc:
542
+ emit({"ok": False, "code": 1,
543
+ "message": "没有权限读写文件:%s" % exc,
544
+ "hint": "检查目标文件是否被 Excel 占用、或输出目录是否有写权限。"})
545
+ return 1
546
+ except Exception as exc: # noqa: BLE001 —— 兜底,保证永远输出 JSON
547
+ traceback.print_exc(file=sys.stderr)
548
+ emit({"ok": False, "code": 1, "message": "处理失败:%s" % exc,
549
+ "hint": "文件可能已损坏、被加密或格式不是标准 xlsx;可先用 Excel 另存一次再试。"})
550
+ return 1
551
+
552
+
553
+ if __name__ == "__main__":
554
+ sys.exit(main(sys.argv))
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@deepwhale-cn/dsh-doc-batch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "description": "深鲸文档批量处理:Word / Excel / PDF 一次处理整目录(替换、提取、合并、拆分、压缩、水印、导出)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "deepseek-harness",
11
+ "skill",
12
+ "tool",
13
+ "doc-batch",
14
+ "word",
15
+ "excel",
16
+ "pdf"
17
+ ],
18
+ "files": [
19
+ "lib",
20
+ "bin",
21
+ "skill",
22
+ "cordis.patch.yml",
23
+ "README.md",
24
+ "LICENSE",
25
+ "assets"
26
+ ],
27
+ "main": "./lib/index.js",
28
+ "exports": {
29
+ ".": "./lib/index.js",
30
+ "./client": "./lib/client.js",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "dsh-doc-batch": "./bin/cli.mjs"
35
+ },
36
+ "engines": {
37
+ "dsh": ">=0.1.0-rc.6 <0.3.0",
38
+ "node": ">=18"
39
+ },
40
+ "dsh": {
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-runtime"
47
+ ],
48
+ "platform": "web",
49
+ "immediately": true
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "@deepwhale-cn/dsh-guard-core": "^0.5.1"
54
+ },
55
+ "peerDependencies": {
56
+ "@deepseek-ai/dsh-skill": "*"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/feely0208/dsh-doc-batch.git"
61
+ },
62
+ "homepage": "https://www.deepwhale.org.cn/skills.html",
63
+ "bugs": {
64
+ "url": "https://github.com/feely0208/dsh-doc-batch/issues"
65
+ },
66
+ "author": "温州深鲸智能科技有限公司 <contact@deepwhale.org.cn>"
67
+ }