@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.
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/assets/deepwhale-badge.png +0 -0
- package/bin/cli-docx.mjs +245 -0
- package/bin/cli-pdf.mjs +581 -0
- package/bin/cli-xlsx.mjs +225 -0
- package/bin/cli.mjs +63 -0
- package/cordis.patch.yml +4 -0
- package/lib/api.js +162 -0
- package/lib/client.js +235 -0
- package/lib/docx_tool.py +651 -0
- package/lib/index.js +26 -0
- package/lib/pdf_ops.py +431 -0
- package/lib/xlsx_tool.py +554 -0
- package/package.json +65 -4
- package/skill/doc-batch.md +202 -0
package/lib/xlsx_tool.py
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|