@wecom/wecom-openclaw-plugin 2026.9.15 → 2026.10.19-beta.1

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.
Files changed (24) hide show
  1. package/package.json +2 -2
  2. package/skills/wecomcli-sheet/SKILL.md +64 -107
  3. package/skills/wecomcli-sheet/references/sheet-conditional-formatting.md +211 -0
  4. package/skills/wecomcli-sheet/references/sheet-filter.md +187 -0
  5. package/skills/wecomcli-sheet/references/sheet-float-image.md +248 -0
  6. package/skills/wecomcli-sheet/references/sheet-formulas.md +73 -0
  7. package/skills/wecomcli-sheet/references/sheet-history.md +125 -0
  8. package/skills/wecomcli-sheet/references/sheet-protection.md +180 -0
  9. package/skills/wecomcli-sheet/references/sheet-range-operations.md +323 -0
  10. package/skills/wecomcli-sheet/references/sheet-read-data.md +153 -0
  11. package/skills/wecomcli-sheet/references/sheet-search-replace.md +111 -0
  12. package/skills/wecomcli-sheet/references/sheet-structure-operations.md +416 -0
  13. package/skills/wecomcli-sheet/references/sheet-style.md +246 -0
  14. package/skills/wecomcli-sheet/references/sheet-validation.md +243 -0
  15. package/skills/wecomcli-sheet/references/sheet-workbook.md +271 -0
  16. package/skills/wecomcli-sheet/references/sheet-write-cells.md +332 -0
  17. package/skills/wecomcli-sheet/references/xlsx-process.md +328 -0
  18. package/skills/wecomcli-sheet/scripts/collect.py +868 -0
  19. package/skills/wecomcli-sheet/scripts/recalc.py +403 -0
  20. package/skills/wecomcli-sheet/references/sheet-contents-update.md +0 -49
  21. package/skills/wecomcli-sheet/references/sheet-ranges-get.md +0 -47
  22. package/skills/wecomcli-sheet/references/sheet-rows-append.md +0 -47
  23. package/skills/wecomcli-sheet/references/sheet-subsheets-add.md +0 -28
  24. package/skills/wecomcli-sheet/references/sheet-subsheets-delete.md +0 -22
@@ -0,0 +1,868 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """collect.py — 表格事实采集器(只测量、不判断)。
4
+
5
+ 由 wecom_sheet_collector wheel(src/wecom_sheet_collector)还原而来:
6
+ __init__.py 的 collect() 与 _core.py 的全部采集逻辑内联为单文件脚本,
7
+ 恢复 CLI 入口(argparse),agent 直接以子进程方式调用:
8
+
9
+ python3 collect.py <file.xlsx> # 采集所有子表,紧凑 JSON 打 stdout
10
+ python3 collect.py <file.xlsx> --sheet 汇总 # 只采指定子表
11
+ python3 collect.py <file.xlsx> --range A1:D20 [--sheet 汇总] # 定向采区域
12
+ python3 collect.py <file.xlsx> --column B [--sheet 汇总] # 定向采整列
13
+
14
+ 设计原则(石蕊测试):
15
+ 允许报告:"B列第5行以下97%是数值"、"A4:A5是合并区"、"第15行含 =SUM(B7:B14)"
16
+ 禁止结论:"所以表头在第5行"、"第15行是小计"、"这是二维表"
17
+ —— 任何带"所以/因此/这是"的判断都留给模型,脚本不碰。
18
+
19
+ 支持 .xlsx/.xlsm(全事实);.csv/.tsv(无合并/样式,给类型剖面);.xls 提示先转换。
20
+ 出错打印 {"error": "..."} 到 stdout 并以退出码 1 结束(正常为 0)。
21
+ 依赖:openpyxl(沙箱默认预装)。
22
+ """
23
+
24
+ import argparse
25
+ import csv
26
+ import json
27
+ import os
28
+ import sys
29
+ from collections import Counter
30
+ from contextlib import contextmanager
31
+ from datetime import datetime, date, time
32
+ from pathlib import Path
33
+ from threading import RLock
34
+
35
+ MAX_COLS_DISPLAY = 60 # 列剖面/快照「数据行取值」最多展示列数(表头列名不受此限,始终给全)
36
+ CELL_VALUE_TRUNC = 60 # 单元格值展示截断
37
+ NUM_DECIMALS = 4 # 快照里浮点数展示的最大小数位(探查阶段不需要全精度,降噪省体积)
38
+ MAX_CSV_BYTES = 200 * 1024 * 1024 # CSV 全量读入内存上限(200 MiB),防超大文件 OOM 拖垮同机其他 skill
39
+ MERGE_DISPLAY_CAP = 50 # 合并区展示上限,超出只给前 N 个 + total;全量用 --range 定向采时带 merge 状态
40
+ IMAGE_DETAIL_CAP = 10 # 浮动图片详情展示上限,超出只给前 N 张 + total;编辑需 offset_emu 等细粒度字段时用 --range 定向采
41
+ ROW_RUNS_CAP = 15 # 行游程段数展示上限,超出只给前 N 段 + total_runs;后段用 --range 看实际值
42
+
43
+ # openpyxl 的 Fill.from_tree 是进程级类方法。当前脚本通常以独立子进程运行,
44
+ # 但仍用锁保护,避免未来被同一进程多线程调用时发生临时补丁交叉覆盖。
45
+ _OPENPYXL_LOAD_LOCK = RLock()
46
+
47
+
48
+ # --------------------------------------------------------------------------
49
+ # openpyxl 空 Fill 兼容加载(只影响解析过程,不修改原文件)
50
+ # --------------------------------------------------------------------------
51
+ def _is_empty_fill_error(exc):
52
+ """判断异常链是否为 openpyxl 解析空 <fill/> 时产生的已知错误。"""
53
+ messages = []
54
+ visited = set()
55
+ current = exc
56
+ while current is not None and id(current) not in visited:
57
+ visited.add(id(current))
58
+ messages.append(str(current))
59
+ current = current.__cause__ or current.__context__
60
+ combined = "\n".join(messages)
61
+ return ("openpyxl.styles.fills.Fill" in combined
62
+ and "Fill() takes no arguments" in combined)
63
+
64
+
65
+ @contextmanager
66
+ def _tolerate_empty_fills():
67
+ """临时将空 <fill/> 解释为无填充;退出时恢复 openpyxl 原始解析器。"""
68
+ from openpyxl.styles import PatternFill
69
+ from openpyxl.styles.fills import Fill
70
+
71
+ original_descriptor = Fill.__dict__["from_tree"]
72
+ state = {"fill_index": 0}
73
+ normalized_fill_ids = []
74
+
75
+ def tolerant_from_tree(cls, element):
76
+ fill_id = state["fill_index"]
77
+ state["fill_index"] += 1
78
+ if len(element) == 0:
79
+ normalized_fill_ids.append(fill_id)
80
+ return PatternFill(patternType="none")
81
+ return original_descriptor.__func__(cls, element)
82
+
83
+ Fill.from_tree = classmethod(tolerant_from_tree)
84
+ try:
85
+ yield normalized_fill_ids
86
+ finally:
87
+ Fill.from_tree = original_descriptor
88
+
89
+
90
+ def load_workbook_compat(path, **kwargs):
91
+ """优先标准加载;仅命中空 Fill 错误时临时容错并重试。
92
+
93
+ 返回 (workbook, warnings)。warnings 只在发生兼容处理时出现,便于模型知道
94
+ 源文件存在 OOXML 兼容问题,且本次只在内存解析阶段按无填充处理。
95
+ """
96
+ import openpyxl
97
+
98
+ with _OPENPYXL_LOAD_LOCK:
99
+ try:
100
+ return openpyxl.load_workbook(path, **kwargs), []
101
+ except Exception as exc:
102
+ if not _is_empty_fill_error(exc):
103
+ raise
104
+
105
+ with _tolerate_empty_fills() as fill_ids:
106
+ workbook = openpyxl.load_workbook(path, **kwargs)
107
+
108
+ warnings = [{
109
+ "code": "empty_fill_normalized",
110
+ "fill_ids": list(fill_ids),
111
+ "message": ("检测到 openpyxl 不兼容的空 fill,采集时已临时按无填充处理;"
112
+ "原始文件未修改"),
113
+ }]
114
+ return workbook, warnings
115
+
116
+
117
+ # 顶层 _fields:子表字段含义说明,只出现一次,不在每个 sheet 里重复
118
+ _FIELDS = {
119
+ "title": "当前子表名称,与sheets对象中的键相同",
120
+ "freeze_panes": "冻结窗格起点;例如A2表示第1行被冻结,可作为表头末行的辅助证据,不能单独据此判定表头",
121
+ "top_snapshot": "顶部样本{columns,rows,notes};columns是全部Excel列字母,rows是前N行的全宽值,notes记录前60列中的合并锚点/加粗/数字格式;用于识别标题、表头和首批数据",
122
+ "bottom_snapshot": "底部样本{rows,notes},与顶部样本去重;rows是末N行的全宽值,用于发现合计、备注、来源等尾部内容",
123
+ "column_profile": "各列剖面[{col,type_counts,number_formats}];统计前max_rows行、覆盖全部列,type_counts不计空单元格,用于判断列的数据类型和显示格式",
124
+ "row_runs": "连续相同行结构的压缩段[{start_row,end_row,count,nonempty_cols,dominant_type,has_formula,any_bold}];最多返回前15段,用于定位表头/数据区转换和多个数据块",
125
+ "merged": "合并区域坐标列表,如A1:B2;最多返回前50个,用于还原多级表头和区分合并覆盖格与普通空值",
126
+ "formulas": "公式位置与文本[{cell,formula}];扫描前max_rows行和前60列,最多返回200个,用于定位计算列、合计和小计",
127
+ "blanks": "扫描范围内的全空行列{rows,cols};扫描前max_rows行和前60列,用于发现同一子表内不同表格之间的分隔",
128
+ "image_count": "成功识别到的浮动图片数量;提示编辑时注意图片及其锚点可能受影响",
129
+ "charts": "图表列表[{type,type_name,title,anchor,series_count,series:[{title,val_ref,cat_ref}]}];val_ref/cat_ref是图表数值和分类标签的单元格引用,可据此定向追采",
130
+ "trunc": "完整性标记{rows,formulas,merged,row_runs,scan_cols};true表示对应扫描或输出未覆盖全部内容;scan_cols仅表示公式、空行空列和快照notes只检查前60列,顶部/底部值、列剖面和行游程仍覆盖全部列",
131
+ }
132
+
133
+
134
+ # --------------------------------------------------------------------------
135
+ # 沙箱路径闸门(软失败,与 wecomagent WECOMAGENT_*_DIRS 白名单口径对齐)
136
+ # --------------------------------------------------------------------------
137
+ def _parse_dirs_env(env_name):
138
+ """解析 WECOMAGENT_*_DIRS,支持 ["/abs", ...] 或 [{"path":"/abs"}, ...];
139
+ 缺失/非法一律返回空列表(-> 视为未配置,由调用方决定是否放行)。"""
140
+ raw = os.environ.get(env_name, "")
141
+ if not raw:
142
+ return []
143
+ try:
144
+ items = json.loads(raw)
145
+ except (json.JSONDecodeError, TypeError):
146
+ return []
147
+ if not isinstance(items, list):
148
+ return []
149
+ roots = []
150
+ for d in items:
151
+ p = None
152
+ if isinstance(d, dict) and isinstance(d.get("path"), str) and d["path"]:
153
+ p = d["path"]
154
+ elif isinstance(d, str) and d:
155
+ p = d
156
+ if p:
157
+ roots.append(os.path.realpath(p))
158
+ return roots
159
+
160
+
161
+ def ensure_readable(path_str):
162
+ """读路径闸门(软失败):字面校验(空字节/相对路径/".."段) + realpath 前缀归属。
163
+
164
+ 与 deep-research/utils.py 口径一致:仅当 WECOMAGENT_READABLE_DIRS /
165
+ WECOMAGENT_WRITABLE_DIRS 至少配置了一个时才启用白名单归属校验;两者都
166
+ 未配置时按"非沙箱/开发环境"放行,不误伤本地调用(纵深防御,不替代上层沙箱)。
167
+ 校验失败返回错误信息字符串;通过返回 None。"""
168
+ if not isinstance(path_str, str) or not path_str:
169
+ return "路径不能为空"
170
+ if "\x00" in path_str:
171
+ return "路径包含非法空字节"
172
+ # ".." 段必须在 realpath 折叠前检查(realpath 会把 /a/b/../c 静默折叠为 /a/c)
173
+ if any(seg == ".." for seg in path_str.replace("\\", "/").split("/")):
174
+ return "路径不能包含 '..' 段"
175
+
176
+ readable = _parse_dirs_env("WECOMAGENT_READABLE_DIRS")
177
+ writable = _parse_dirs_env("WECOMAGENT_WRITABLE_DIRS")
178
+ roots = readable + writable
179
+ if not roots:
180
+ return None # 未配置白名单 -> 放行(依赖上层沙箱兜底)
181
+
182
+ real = os.path.realpath(path_str)
183
+ for root in roots:
184
+ try:
185
+ if os.path.commonpath([real, root]) == root:
186
+ return None
187
+ except ValueError:
188
+ continue # 跨盘符/相对绝对混合
189
+ return f"路径不在可读白名单内(解析为 {real})"
190
+
191
+
192
+ # --------------------------------------------------------------------------
193
+ # 类型分类(纯测量)
194
+ # --------------------------------------------------------------------------
195
+ def classify(cell):
196
+ v = cell.value
197
+ if v is None or v == "":
198
+ return "empty"
199
+ dt = getattr(cell, "data_type", None)
200
+ if dt == "f" or (isinstance(v, str) and v.startswith("=")):
201
+ return "formula"
202
+ if dt == "e":
203
+ return "error"
204
+ if isinstance(v, bool): # 必须在 int 之前判断
205
+ return "bool"
206
+ if isinstance(v, (datetime, date, time)):
207
+ return "date"
208
+ if isinstance(v, (int, float)):
209
+ return "number"
210
+ if isinstance(v, str):
211
+ return "text"
212
+ return "other"
213
+
214
+
215
+ def cell_is_bold(cell):
216
+ try:
217
+ return bool(cell.font and cell.font.bold)
218
+ except Exception:
219
+ return False
220
+
221
+
222
+ def trunc(v):
223
+ s = str(v)
224
+ return s if len(s) <= CELL_VALUE_TRUNC else s[:CELL_VALUE_TRUNC] + "…"
225
+
226
+
227
+ def emit_value(v):
228
+ """值输出统一入口:截断时回带 truncated + full_len 信号,让消费方区分'天然以…结尾'与'被截断'。"""
229
+ s = "" if v is None else str(v)
230
+ if len(s) <= CELL_VALUE_TRUNC:
231
+ return {"value": s or None}
232
+ return {"value": s[:CELL_VALUE_TRUNC] + "…", "truncated": True, "full_len": len(s)}
233
+
234
+
235
+ def cell_display(v):
236
+ """head 形态里单个数据格的展示值(标量,非 dict):
237
+ - None/空 -> None
238
+ - float -> 降到 NUM_DECIMALS 位小数(去掉全精度长串,探查不需要),整数值不带小数
239
+ - 其它 -> 字符串并做长度截断(超长补 …)
240
+ """
241
+ if v is None or v == "":
242
+ return None
243
+ if isinstance(v, bool):
244
+ return v
245
+ if isinstance(v, float):
246
+ if v == int(v):
247
+ return int(v)
248
+ return round(v, NUM_DECIMALS)
249
+ if isinstance(v, int):
250
+ return v
251
+ s = str(v)
252
+ return s if len(s) <= CELL_VALUE_TRUNC else s[:CELL_VALUE_TRUNC] + "…"
253
+
254
+
255
+ def col_letter(idx):
256
+ from openpyxl.utils import get_column_letter
257
+ return get_column_letter(idx)
258
+
259
+
260
+ # --------------------------------------------------------------------------
261
+ # 合并区索引(事实)
262
+ # --------------------------------------------------------------------------
263
+ def build_merge_index(ws):
264
+ from openpyxl.utils import range_boundaries
265
+ anchors = {} # (r,c) -> {"span_rows":.., "span_cols":..}
266
+ covered = set() # 被合并覆盖的非锚点单元格
267
+ ranges = []
268
+ for rng in ws.merged_cells.ranges:
269
+ c1, r1, c2, r2 = range_boundaries(str(rng))
270
+ ranges.append(str(rng))
271
+ anchors[(r1, c1)] = {"span_rows": r2 - r1 + 1, "span_cols": c2 - c1 + 1}
272
+ for r in range(r1, r2 + 1):
273
+ for c in range(c1, c2 + 1):
274
+ if (r, c) != (r1, c1):
275
+ covered.add((r, c))
276
+ return ranges, anchors, covered
277
+
278
+
279
+ def merge_status(r, c, anchors, covered):
280
+ if (r, c) in anchors:
281
+ a = anchors[(r, c)]
282
+ return {"role": "anchor", "span_rows": a["span_rows"], "span_cols": a["span_cols"]}
283
+ if (r, c) in covered:
284
+ return {"role": "covered"}
285
+ return None
286
+
287
+
288
+ # --------------------------------------------------------------------------
289
+ # 图表元信息采集(事实)—— 不只判断有无,采出类型/标题/锚点/系列引用区域
290
+ # --------------------------------------------------------------------------
291
+ _CHART_TYPE_NAME = {
292
+ "BarChart": "柱形/条形图", "BarChart3D": "3D柱形/条形图",
293
+ "LineChart": "折线图", "LineChart3D": "3D折线图",
294
+ "PieChart": "饼图", "PieChart3D": "3D饼图",
295
+ "AreaChart": "面积图", "AreaChart3D": "3D面积图",
296
+ "ScatterChart": "散点图", "RadarChart": "雷达图",
297
+ "DoughnutChart": "圆环图", "BubbleChart": "气泡图",
298
+ "SurfaceChart": "曲面图", "SurfaceChart3D": "3D曲面图",
299
+ }
300
+
301
+
302
+ def _chart_title(ch):
303
+ """尽量稳健地取出图表标题文本(标题可能是 str / 富文本 / 引用)。"""
304
+ t = ch.title
305
+ if t is None:
306
+ return None
307
+ if isinstance(t, str):
308
+ return t
309
+ tx = getattr(t, "tx", None)
310
+ rich = getattr(tx, "rich", None) if tx else None
311
+ if not rich:
312
+ return None
313
+ parts = []
314
+ for p in (rich.p or []):
315
+ for r in (p.r or []):
316
+ if getattr(r, "t", None):
317
+ parts.append(r.t)
318
+ return "".join(parts) if parts else None
319
+
320
+
321
+ def _ref_of(ds):
322
+ """从 NumDataSource / AxDataSource 取引用区域 f,如 'Sheet1!$B$2:$B$10'。"""
323
+ if ds is None:
324
+ return None
325
+ for attr in ("numRef", "strRef"):
326
+ ref = getattr(ds, attr, None)
327
+ if ref is not None and getattr(ref, "f", None):
328
+ return ref.f
329
+ return None
330
+
331
+
332
+ def _series_info(s):
333
+ title = None
334
+ if s.title is not None:
335
+ sl = s.title
336
+ if getattr(sl, "strRef", None) and sl.strRef.f:
337
+ title = sl.strRef.f # 引用式标题
338
+ elif getattr(sl, "v", None):
339
+ title = sl.v # 字面量标题
340
+ return {
341
+ "title": title,
342
+ "val_ref": _ref_of(s.val), # 数值区域
343
+ "cat_ref": _ref_of(s.cat), # 分类标签区域
344
+ }
345
+
346
+
347
+ def collect_charts(ws):
348
+ out = []
349
+ for ch in getattr(ws, "_charts", []) or []:
350
+ cls = type(ch).__name__
351
+ anchor = None
352
+ a = getattr(ch, "anchor", None)
353
+ frm = getattr(a, "_from", None) if a else None
354
+ if frm is not None: # openpyxl anchor 的 col/row 是 0-based
355
+ anchor = f"{col_letter(frm.col + 1)}{frm.row + 1}"
356
+ out.append({
357
+ "type": cls,
358
+ "type_name": _CHART_TYPE_NAME.get(cls, cls),
359
+ "title": _chart_title(ch),
360
+ "anchor": anchor,
361
+ "series_count": len(ch.series) if ch.series else 0,
362
+ "series": [_series_info(s) for s in (ch.series or [])],
363
+ })
364
+ return out
365
+
366
+
367
+ # --------------------------------------------------------------------------
368
+ # 浮动图片事实采集(事实)—— 不只判断有无,采出数量/锚点/位置/尺寸/格式
369
+ # --------------------------------------------------------------------------
370
+ def collect_images(ws):
371
+ """浮动图片元数据事实(不碰二进制,只测定位信息)。
372
+
373
+ 图片不在单元格内(iter_rows/values_only 读不到),靠锚点定位,是编辑时
374
+ 最容易整体丢失的隐性数据。单张图解析失败只跳过并记 skipped,不拖垮采集。
375
+
376
+ 探查阶段只保留核心字段(cell/width_px/height_px/format),超出 IMAGE_DETAIL_CAP
377
+ 张时只给前 N 张详情 + total;编辑需 offset_emu 等细粒度字段时用 --range 定向采。
378
+ """
379
+ out = []
380
+ skipped = []
381
+ for idx, img in enumerate(getattr(ws, "_images", []) or []):
382
+ info = {"index": idx}
383
+ try:
384
+ a = img.anchor
385
+ frm = getattr(a, "_from", None)
386
+ if frm is None:
387
+ skipped.append({"index": idx, "reason": "无锚点(_from 缺失)"})
388
+ continue
389
+ kind = type(a).__name__ # OneCellAnchor / TwoCellAnchor
390
+ info["anchor_type"] = kind
391
+ info["cell"] = f"{col_letter(frm.col + 1)}{frm.row + 1}"
392
+ info["width_px"] = getattr(img, "width", None)
393
+ info["height_px"] = getattr(img, "height", None)
394
+ fmt = getattr(img, "format", None)
395
+ info["format"] = fmt if isinstance(fmt, str) else (
396
+ str(fmt) if fmt is not None else None)
397
+ # 探查阶段不输出 offset_emu / to_cell 等编辑定位字段;超出 cap 的图也不展开
398
+ out.append(info)
399
+ except Exception as e:
400
+ skipped.append({"index": idx, "reason": f"{type(e).__name__}: {e}"})
401
+ total = len(out)
402
+ truncated = total > IMAGE_DETAIL_CAP
403
+ shown = out[:IMAGE_DETAIL_CAP]
404
+ result = {"count": total, "images": shown}
405
+ if truncated:
406
+ result["images_truncated"] = True
407
+ result["images_shown"] = len(shown)
408
+ result["images_total"] = total
409
+ if skipped:
410
+ result["skipped"] = skipped
411
+ return result
412
+
413
+
414
+ # --------------------------------------------------------------------------
415
+ # 逐行精细快照(事实)—— top / bottom 共用
416
+ # 形态:类似 pandas df.head() 的二维表(columns + rows),而非逐格 {col,type,value}
417
+ # 对象。关键:columns 给「全部列名」始终保真、绝不因 MAX_COLS_DISPLAY 截断
418
+ # (年份/字段名是结构核心,这里截断会让模型看不到尾部列 -> 曾致范围误判)。
419
+ # 数据行的取值列数仍受 ncols 限,超出以 cols_truncated 标注。
420
+ # --------------------------------------------------------------------------
421
+ def _row_values(ws, r, ncols):
422
+ """取第 r 行前 ncols 列的展示值(标量数组,None 表示空格)。"""
423
+ return [cell_display(ws.cell(r, c).value) for c in range(1, ncols + 1)]
424
+
425
+
426
+ def _all_column_letters(ws):
427
+ """全部列字母(A..最后一列),不受 MAX_COLS_DISPLAY 限制 —— 表头保真的关键。"""
428
+ return [col_letter(c) for c in range(1, ws.max_column + 1)]
429
+
430
+
431
+ def _struct_notes(ws, r_start, r_end, anchors, covered, ncols):
432
+ """稀疏结构线索:合并锚点 / 加粗格 / 非常规数字格式,只在存在时逐条给,
433
+ 不再逐格铺满。格式如 {"cell":"A4","merge":{...}} / {"cell":"B5","bold":true}。"""
434
+ notes = []
435
+ for r in range(r_start, r_end + 1):
436
+ for c in range(1, ncols + 1):
437
+ cell = ws.cell(r, c)
438
+ ref = f"{col_letter(c)}{r}"
439
+ ms = merge_status(r, c, anchors, covered)
440
+ if ms and ms.get("role") == "anchor":
441
+ notes.append({"cell": ref, "merge": ms})
442
+ nf = getattr(cell, "number_format", None)
443
+ if nf and nf != "General" and classify(cell) != "empty":
444
+ notes.append({"cell": ref, "number_format": nf})
445
+ if cell_is_bold(cell) and classify(cell) != "empty":
446
+ notes.append({"cell": ref, "bold": True})
447
+ return notes
448
+
449
+
450
+ def top_snapshot(ws, anchors, covered, top_rows, ncols):
451
+ """顶部 head:columns 给全部列名(保真),rows 给前 top_rows 行的值。
452
+ 取值列用全宽(ws.max_column):前几行含表头文字,是结构核心,绝不截列
453
+ (只有 top_rows 行,全宽取值成本极小);notes 扫描列仍用 ncols 控成本。"""
454
+ snap_cols = ws.max_column
455
+ last = min(ws.max_row, top_rows)
456
+ rows = [{"row": r, "values": _row_values(ws, r, snap_cols)} for r in range(1, last + 1)]
457
+ return {
458
+ "columns": _all_column_letters(ws),
459
+ "rows": rows,
460
+ "notes": _struct_notes(ws, 1, last, anchors, covered, ncols),
461
+ }
462
+
463
+
464
+ def bottom_snapshot(ws, anchors, covered, top_rows, bottom_rows, ncols):
465
+ """底部 head:采最后 bottom_rows 行,与 top 去重(start=max(top_rows+1,max_row-bottom+1))。
466
+ 取值列同 top 用全宽;文件太短(已被 top 覆盖)时 rows 为空。columns 不重复给。"""
467
+ snap_cols = ws.max_column
468
+ max_row = ws.max_row
469
+ start = max(top_rows + 1, max_row - bottom_rows + 1)
470
+ if start > max_row:
471
+ return {"rows": [], "notes": []}
472
+ rows = [{"row": r, "values": _row_values(ws, r, snap_cols)} for r in range(start, max_row + 1)]
473
+ return {
474
+ "rows": rows,
475
+ "notes": _struct_notes(ws, start, max_row, anchors, covered, ncols),
476
+ }
477
+
478
+
479
+ # --------------------------------------------------------------------------
480
+ # 全列类型剖面 + 逐行签名(游程压缩)(事实)
481
+ # --------------------------------------------------------------------------
482
+ def scan_profile(ws, ncols, max_rows):
483
+ scan_to = min(ws.max_row, max_rows)
484
+ truncated = ws.max_row > scan_to
485
+
486
+ col_types = {c: Counter() for c in range(1, ncols + 1)}
487
+ col_formats = {c: set() for c in range(1, ncols + 1)}
488
+
489
+ row_runs = [] # 游程压缩后的行签名
490
+ prev_key = None
491
+ run = None
492
+
493
+ for r in range(1, scan_to + 1):
494
+ nonempty_cols = []
495
+ types = Counter()
496
+ has_formula = False
497
+ any_bold = False
498
+ for c in range(1, ncols + 1):
499
+ cell = ws.cell(r, c)
500
+ t = classify(cell)
501
+ if t != "empty":
502
+ nonempty_cols.append(c)
503
+ types[t] += 1
504
+ col_types[c][t] += 1
505
+ nf = getattr(cell, "number_format", None)
506
+ if nf and nf != "General":
507
+ col_formats[c].add(nf)
508
+ if t == "formula":
509
+ has_formula = True
510
+ if cell_is_bold(cell):
511
+ any_bold = True
512
+ dom = types.most_common(1)[0][0] if types else "empty"
513
+ key = (tuple(nonempty_cols), dom, has_formula, any_bold)
514
+ if key == prev_key:
515
+ run["end_row"] = r
516
+ run["count"] += 1
517
+ else:
518
+ if run:
519
+ row_runs.append(run)
520
+ run = {
521
+ "start_row": r, "end_row": r, "count": 1,
522
+ "nonempty_cols": [col_letter(c) for c in nonempty_cols],
523
+ "nonempty_count": len(nonempty_cols),
524
+ "dominant_type": dom,
525
+ "has_formula": has_formula,
526
+ "any_bold": any_bold,
527
+ }
528
+ prev_key = key
529
+ if run:
530
+ row_runs.append(run)
531
+
532
+ col_profile = []
533
+ for c in range(1, ncols + 1):
534
+ counts = dict(col_types[c])
535
+ col_profile.append({
536
+ "col": col_letter(c),
537
+ "type_counts": counts,
538
+ "number_formats": sorted(col_formats[c])[:5],
539
+ })
540
+ return col_profile, row_runs, scan_to, truncated
541
+
542
+
543
+ # --------------------------------------------------------------------------
544
+ # 公式清单(事实)
545
+ # --------------------------------------------------------------------------
546
+ def collect_formulas(ws, ncols, max_rows, cap=200):
547
+ out = []
548
+ for r in range(1, min(ws.max_row, max_rows) + 1):
549
+ for c in range(1, ncols + 1):
550
+ cell = ws.cell(r, c)
551
+ v = cell.value
552
+ if isinstance(v, str) and v.startswith("="):
553
+ entry = {"cell": f"{col_letter(c)}{r}"}
554
+ entry.update(emit_value(v))
555
+ # 公式文本若被截断,补一个 untruncated_formula 仅供定位(不带则不出现)
556
+ entry["formula"] = entry.pop("value")
557
+ out.append(entry)
558
+ if len(out) >= cap:
559
+ return out, True
560
+ return out, False
561
+
562
+
563
+ # --------------------------------------------------------------------------
564
+ # 空行/空列图(事实)—— 多表分隔线索
565
+ # --------------------------------------------------------------------------
566
+ def blank_map(ws, ncols, max_rows):
567
+ scan_to = min(ws.max_row, max_rows)
568
+ blank_rows = []
569
+ for r in range(1, scan_to + 1):
570
+ if all(ws.cell(r, c).value in (None, "") for c in range(1, ncols + 1)):
571
+ blank_rows.append(r)
572
+ blank_cols = []
573
+ for c in range(1, ncols + 1):
574
+ if all(ws.cell(r, c).value in (None, "") for r in range(1, scan_to + 1)):
575
+ blank_cols.append(col_letter(c))
576
+ return blank_rows, blank_cols
577
+
578
+
579
+ # --------------------------------------------------------------------------
580
+ # 单个 sheet 的完整事实
581
+ # --------------------------------------------------------------------------
582
+ def collect_sheet(ws, top_rows, max_rows, bottom_rows=5):
583
+ ncols = min(ws.max_column, MAX_COLS_DISPLAY) # 快照数据行/游程/公式的取值列上限
584
+ cols_truncated = ws.max_column > ncols
585
+ # 列类型剖面单独放开到全部列:每列只是一个小对象,成本可控,且能覆盖
586
+ # 尾部列(如靠后的年份列),避免模型漏采表尾字段。
587
+ prof_cols = ws.max_column
588
+ ranges, anchors, covered = build_merge_index(ws)
589
+ col_profile, row_runs, scanned, row_trunc = scan_profile(ws, prof_cols, max_rows)
590
+ formulas, fx_trunc = collect_formulas(ws, ncols, max_rows)
591
+ blank_rows, blank_cols = blank_map(ws, ncols, max_rows)
592
+
593
+ charts = collect_charts(ws)
594
+ images = collect_images(ws)
595
+
596
+ # 合并区:精简成 A1:B1 字符串列表,超 cap 截断
597
+ merge_truncated = len(ranges) > MERGE_DISPLAY_CAP
598
+ shown_merged = ranges[:MERGE_DISPLAY_CAP]
599
+
600
+ # 行游程:超 cap 只给前 N 段
601
+ runs_total = len(row_runs)
602
+ runs_truncated = runs_total > ROW_RUNS_CAP
603
+ shown_runs = row_runs[:ROW_RUNS_CAP]
604
+
605
+ return {
606
+ "title": ws.title,
607
+ "freeze_panes": ws.freeze_panes,
608
+ "top_snapshot": top_snapshot(ws, anchors, covered, top_rows, ncols),
609
+ "bottom_snapshot": bottom_snapshot(ws, anchors, covered, top_rows, bottom_rows, ncols),
610
+ "column_profile": col_profile,
611
+ "row_runs": shown_runs,
612
+ "merged": shown_merged,
613
+ "formulas": formulas,
614
+ "blanks": {"rows": blank_rows, "cols": blank_cols},
615
+ "image_count": images["count"],
616
+ "charts": charts,
617
+ "trunc": {
618
+ "rows": row_trunc,
619
+ "formulas": fx_trunc,
620
+ "merged": merge_truncated,
621
+ "row_runs": runs_truncated,
622
+ "scan_cols": cols_truncated,
623
+ },
624
+ }
625
+
626
+
627
+ # --------------------------------------------------------------------------
628
+ # 定向采集
629
+ # --------------------------------------------------------------------------
630
+ def collect_range(ws, rng_str):
631
+ from openpyxl.utils import range_boundaries
632
+ c1, r1, c2, r2 = range_boundaries(rng_str)
633
+ _, anchors, covered = build_merge_index(ws)
634
+ out = []
635
+ for r in range(r1, r2 + 1):
636
+ row = []
637
+ for c in range(c1, c2 + 1):
638
+ cell = ws.cell(r, c)
639
+ t = classify(cell)
640
+ e = {"ref": f"{col_letter(c)}{r}", "type": t}
641
+ if t != "empty":
642
+ e.update(emit_value(cell.value))
643
+ ms = merge_status(r, c, anchors, covered)
644
+ if ms:
645
+ e["merge"] = ms
646
+ nf = getattr(cell, "number_format", None)
647
+ if nf and nf != "General":
648
+ e["number_format"] = nf
649
+ row.append(e)
650
+ out.append(row)
651
+ return {"range": rng_str, "cells": out}
652
+
653
+
654
+ def collect_column(ws, letter, max_rows):
655
+ from openpyxl.utils import column_index_from_string
656
+ c = column_index_from_string(letter.upper())
657
+ scan_to = min(ws.max_row, max_rows)
658
+ vals = []
659
+ for r in range(1, scan_to + 1):
660
+ cell = ws.cell(r, c)
661
+ t = classify(cell)
662
+ entry = {"row": r, "type": t}
663
+ if t != "empty":
664
+ entry.update(emit_value(cell.value))
665
+ vals.append(entry)
666
+ return {"column": letter.upper(), "values": vals,
667
+ "scanned_rows": scan_to, "truncated": ws.max_row > scan_to}
668
+
669
+
670
+ # --------------------------------------------------------------------------
671
+ # CSV 轻量路径(无合并/样式)
672
+ # --------------------------------------------------------------------------
673
+ def collect_csv(path, sep, top_rows, max_rows, bottom_rows=5):
674
+ # 全量 list(csv.reader) 会把整文件读进内存,超大文件可能 OOM;读前先卡文件大小
675
+ try:
676
+ size = os.path.getsize(path)
677
+ except OSError as e:
678
+ return {"error": f"无法读取 CSV 文件属性: {type(e).__name__}"}
679
+ if size > MAX_CSV_BYTES:
680
+ return {"error": f"CSV 文件过大({size}字节 > 上限{MAX_CSV_BYTES}字节),"
681
+ f"请先拆分或用 --range/--column 定向采集"}
682
+
683
+ encs = ["utf-8-sig", "gbk", "gb18030", "utf-8", "latin-1"]
684
+ rows = None
685
+ used_enc = None
686
+ for enc in encs:
687
+ try:
688
+ with open(path, newline="", encoding=enc) as f:
689
+ rows = list(csv.reader(f, delimiter=sep))
690
+ used_enc = enc
691
+ break
692
+ except (UnicodeDecodeError, UnicodeError):
693
+ # 只吞编码类异常继续换下一种编码;PermissionError/OSError/MemoryError
694
+ # 等真实错误不吞,交由上层 except 报出准确类型,便于定位
695
+ continue
696
+ if rows is None:
697
+ return {"error": f"无法解码 CSV: {path}"}
698
+
699
+ def cls(s):
700
+ if s is None or s == "":
701
+ return "empty"
702
+ try:
703
+ float(s.replace(",", ""))
704
+ return "number"
705
+ except ValueError:
706
+ return "text"
707
+
708
+ def csv_cell(s):
709
+ """CSV 单格展示值:数值转 number 降精度,其余截断字符串,空 -> None。"""
710
+ if s is None or s == "":
711
+ return None
712
+ if cls(s) == "number":
713
+ try:
714
+ f = float(s.replace(",", ""))
715
+ return int(f) if f == int(f) else round(f, NUM_DECIMALS)
716
+ except ValueError:
717
+ pass
718
+ return s if len(s) <= CELL_VALUE_TRUNC else s[:CELL_VALUE_TRUNC] + "…"
719
+
720
+ def row_values(i):
721
+ return {"row": i + 1,
722
+ "values": [csv_cell(rows[i][j]) if j < len(rows[i]) else None
723
+ for j in range(full_width)]}
724
+
725
+ full_width = max((len(r) for r in rows[:max_rows]), default=0) # 真实全宽
726
+ ncols = min(full_width, MAX_COLS_DISPLAY) # 列类型剖面列上限
727
+ all_cols = [col_letter(j + 1) for j in range(full_width)] # 全部列字母,不截断
728
+ top = [row_values(i) for i in range(min(len(rows), top_rows))]
729
+ total = len(rows)
730
+ start_i = max(top_rows, total - bottom_rows) # 0-based, 已含 top 覆盖区间
731
+ bottom = [row_values(i) for i in range(start_i, total)] if start_i < total else []
732
+ col_types = [{"col": col_letter(j + 1),
733
+ "type_counts": dict(Counter(cls(r[j] if j < len(r) else "") for r in rows[:max_rows]))}
734
+ for j in range(ncols)]
735
+ return {
736
+ "note": "CSV/TSV 无合并单元格与样式信息,仅提供值类型剖面。",
737
+ "encoding": used_enc,
738
+ "dimensions": {"rows": len(rows), "cols": full_width},
739
+ "top_snapshot": {"columns": all_cols, "rows": top},
740
+ "bottom_snapshot": {"rows": bottom},
741
+ "column_profile": col_types,
742
+ "merged": [],
743
+ "trunc": {"column_profile_cols": full_width > ncols},
744
+ }
745
+
746
+
747
+ # --------------------------------------------------------------------------
748
+ # 主采集入口(对应原 wheel 的 collect())
749
+ # --------------------------------------------------------------------------
750
+ def collect(path, sheet=None, rng=None, column=None, top_rows=5, bottom_rows=5, max_rows=20000):
751
+ """采集表格事实(只测量、不判断),返回 dict。
752
+
753
+ 参数:
754
+ path (str): 表格文件路径。支持 .xlsx/.xlsm/.csv/.tsv。
755
+ sheet (str|None): 只采指定子表;None 采所有子表。
756
+ rng (str|None): 定向采区域,如 "A1:D20"(需 xlsx/xlsm,作用于 sheet 或首个子表)。
757
+ column (str|None): 定向采整列,如 "B"(同上)。
758
+ top_rows/bottom_rows/max_rows: 快照行数与扫描行上限。
759
+
760
+ 返回 dict;出错返回 {"error": "..."},不抛异常。
761
+ """
762
+ path_str = str(path)
763
+
764
+ # 沙箱路径闸门(软失败)
765
+ path_err = ensure_readable(path_str)
766
+ if path_err:
767
+ return {"error": path_err}
768
+
769
+ p = Path(path_str)
770
+ if not p.exists():
771
+ return {"error": f"文件不存在: {path_str}"}
772
+
773
+ suffix = p.suffix.lower()
774
+ try:
775
+ if suffix in (".csv", ".tsv"):
776
+ if rng or column or sheet:
777
+ return {"error": "CSV/TSV 不支持 --sheet/--range/--column 定向采集"}
778
+ sep = "\t" if suffix == ".tsv" else ","
779
+ csv_result = collect_csv(str(p), sep, top_rows, max_rows, bottom_rows)
780
+ return {"file": path_str, **csv_result}
781
+
782
+ if suffix == ".xls":
783
+ return {"error": ".xls 为老二进制格式,openpyxl 无法读取合并/样式。请先把 .xls 转成 .xlsx 再采集。"}
784
+
785
+ if suffix not in (".xlsx", ".xlsm"):
786
+ return {"error": f"不支持的格式: {suffix}"}
787
+
788
+ wb, load_warnings = load_workbook_compat(
789
+ str(p), data_only=False, keep_vba=(suffix == ".xlsm"))
790
+
791
+ # 定向采集:--range / --column(作用于 --sheet 指定子表,缺省首个子表)
792
+ if rng or column:
793
+ target_name = sheet or wb.sheetnames[0]
794
+ if target_name not in wb.sheetnames:
795
+ return {"error": f"子表不存在: {target_name}(可选: {wb.sheetnames})"}
796
+ ws = wb[target_name]
797
+ result = {"file": path_str, "sheet": target_name}
798
+ if load_warnings:
799
+ result["_warnings"] = load_warnings
800
+ if rng:
801
+ result.update(collect_range(ws, rng))
802
+ else:
803
+ result.update(collect_column(ws, column, max_rows))
804
+ return result
805
+
806
+ # 整体采集:--sheet 只采一个子表,缺省采所有子表
807
+ if sheet:
808
+ if sheet not in wb.sheetnames:
809
+ return {"error": f"子表不存在: {sheet}(可选: {wb.sheetnames})"}
810
+ names = [sheet]
811
+ else:
812
+ names = wb.sheetnames
813
+
814
+ sheets_meta = [{"name": n,
815
+ "max_row": wb[n].max_row, "max_col": wb[n].max_column}
816
+ for n in names]
817
+ detail = {n: collect_sheet(wb[n], top_rows, max_rows, bottom_rows) for n in names}
818
+
819
+ result = {
820
+ "_fields": _FIELDS,
821
+ "file": path_str,
822
+ "sheet_names": wb.sheetnames,
823
+ "sheets_overview": sheets_meta,
824
+ "sheets": detail,
825
+ "_note": "以上均为测量事实。表头位置/小计判定/表型推断请由模型完成;"
826
+ "图表的 series[].val_ref/cat_ref 是追采真实数值的入口;"
827
+ }
828
+ if load_warnings:
829
+ result["_warnings"] = load_warnings
830
+ return result
831
+
832
+ except Exception as e:
833
+ return {"error": f"{type(e).__name__}: {e}"}
834
+
835
+
836
+ # --------------------------------------------------------------------------
837
+ # CLI
838
+ # --------------------------------------------------------------------------
839
+ def main():
840
+ parser = argparse.ArgumentParser(
841
+ description="表格事实采集器(只测量、不判断),紧凑 JSON 输出到 stdout。")
842
+ parser.add_argument("file", help="表格文件路径(.xlsx/.xlsm/.csv/.tsv)")
843
+ parser.add_argument("--sheet", help="只采指定子表(缺省采所有子表)")
844
+ parser.add_argument("--range", dest="range",
845
+ help="定向采区域,如 A1:D20(配合 --sheet,缺省首个子表)")
846
+ parser.add_argument("--column", help="定向采整列,如 B(配合 --sheet,缺省首个子表)")
847
+ parser.add_argument("--top", type=int, default=5, help="顶部快照行数(默认 5)")
848
+ parser.add_argument("--bottom", type=int, default=5, help="底部快照行数(默认 5)")
849
+ parser.add_argument("--max-rows", type=int, default=20000,
850
+ help="行扫描上限(默认 20000)")
851
+ parser.add_argument("--pretty", action="store_true",
852
+ help="缩进美化输出(默认紧凑单行,省 token)")
853
+ args = parser.parse_args()
854
+
855
+ result = collect(args.file, sheet=args.sheet, rng=args.range,
856
+ column=args.column, top_rows=args.top,
857
+ bottom_rows=args.bottom, max_rows=args.max_rows)
858
+
859
+ if args.pretty:
860
+ print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
861
+ else:
862
+ print(json.dumps(result, ensure_ascii=False, separators=(",", ":"), default=str))
863
+
864
+ sys.exit(1 if "error" in result else 0)
865
+
866
+
867
+ if __name__ == "__main__":
868
+ main()