Skill: zways/automate-excel (version 0.1.3)

--- name: automate-excel description: Automates reading, writing, merging, transforming, and validating Excel (.xlsx/.xls) files. Use when the user works with spreadsheets, .xlsx files, Excel data, CSV-to-Excel conversion, batch Excel processing, or report generation from tables. --- # Excel 自动化处理 在用户需要处理 Excel 文件、表格数据、批量转换或报表生成时应用本 skill。 | 你想做的事 | 调用的脚本 | 典型参数 | |------------|------------|----------| | 多个 Excel 或多个 sheet 合成一张表 | **merge_sheets.py** | `--inputs 文件或目录 --output out.xlsx` | | 把某 sheet 导出成 CSV | **excel_to_csv.py** | `--input file.xlsx --output file.csv` | | 把 CSV 转成 Excel | **csv_to_excel.py** | `--input a.csv --output a.xlsx` 或 `--inputs a.csv b.csv` | | 按条件筛选行（等于/大于/包含） | **filter_excel.py** | `--where "列名=值"` 或 `"列名>100"`、`"列名~北京"` | | 按行数拆成多个文件，或按某列取值拆分 | **split_excel.py** | `--by-rows 5000` 或 `--by-column 地区` | | 按某列去重 | **deduplicate_excel.py** | `--keys 编号 --keep first/last` | | 按列分组并求和/计数/平均 | **aggregate_excel.py** | `--group-by 地区 --agg "销售额:sum"` | | 检查必须列、重复键、空行 | **validate_excel.py** | `--require-cols 列名 --key-cols 列名` | | 只保留/重命名部分列 | **select_columns.py** | `--columns 列1,列2 --rename "旧:新"` | | 两个表按一列对齐合并（VLOOKUP） | **merge_tables.py** | `--left a.xlsx --right b.xlsx --on 键列` | | 主表依次跟多个表做 VLOOKUP | **vlookup_multi.py** | `--main 主.xlsx --lookups "表1.xlsx:键列" "表2.xlsx:键列"` | | 行列转置 | **transpose_excel.py** | `--input in.xlsx --output out.xlsx` | | 用数据表按行填模板里的 {{列名}} | **template_fill.py** | `--template t.xlsx --data d.csv --output out.xlsx` | | 重命名工作表 | **rename_sheets.py** | `--rename "Sheet1:新名"` 或 `--prefix "2024_"` | | 条件格式（大于/小于/重复值/色阶） | **format_conditional.py** | `--column C --rule gt --value 100 --fill red` | | 某列当文本显示，避免科学计数法 | **format_columns_as_text.py** | `--columns 身份证号,订单号` | **本地直接运行**：进入本 skill 所在目录（或把 `scripts/` 加入路径），先 `pip install -r scripts/requirements.txt`，再执行 `python scripts/脚本名.py --help` 看参数，或按上表传参运行。 ## 技术栈 - **读写 .xlsx**：`openpyxl`（保留格式、公式、多工作表） - **数据分析/透视**：`pandas` + `openpyxl` 引擎 - **旧格式 .xls**：`xlrd`（只读） 优先用 `openpyxl` 做单元格级操作和格式保留；需要筛选、聚合、合并多表时用 `pandas`。 ## 依赖 ```bash pip install openpyxl pandas xlrd ``` 或使用 skill 自带：`pip install -r scripts/requirements.txt` ## 通用流程 1. **确认输入**：文件路径、工作表名或索引、是否有表头、编码（CSV 时）。 2. **读取**：用 openpyxl 或 pandas 按需读取整表/区域。 3. **处理**：按用户需求转换、过滤、合并、计算。 4. **写出**：指定输出路径与格式（.xlsx/.csv）；必要时保留原格式或新建工作簿。 5. **校验**：检查行数、关键列、重复值或业务规则。 ## 读取 Excel **整表为 list of dict（保留表头）：** ```python import openpyxl wb = openpyxl.load_workbook("input.xlsx", read_only=True, data_only=True) ws = wb.active # 或 wb["Sheet1"] rows = list(ws.iter_rows(min_row=1, values_only=True)) header = rows[0] data = [dict(zip(header, row)) for row in rows[1:]] wb.close() ``` **用 pandas（适合分析、过滤、合并）：** ```python import pandas as pd df = pd.read_excel("input.xlsx", sheet_name=0, engine="openpyxl") # sheet_name 可为 0、"Sheet1" 或 [0, 1] 多表 ``` **指定区域：** ```python # openpyxl for row in ws["A1:D10"]: ... # pandas df = pd.read_excel("input.xlsx", usecols="A:D", header=0, nrows=100) ``` ## 写入 Excel **新建并写入（openpyxl）：** ```python from openpyxl import Workbook from openpyxl.styles import Font, Alignment wb = Workbook() ws = wb.active ws.title = "结果" ws.append(["列A", "列B", "列C"]) for row in data_rows: ws.append(row) ws["A1"].font = Font(bold=True) wb.save("output.xlsx") ``` **用 pandas 写出多表：** ```python with pd.ExcelWriter("output.xlsx", engine="openpyxl") as writer: df1.to_excel(writer, sheet_name="汇总", index=False) df2.to_excel(writer, sheet_name="明细", index=False) ``` **追加到已有文件（先加载再写）：** ```python wb = openpyxl.load_workbook("existing.xlsx") ws = wb["Sheet1"] for row in new_rows: ws.append(row) wb.save("existing.xlsx") ``` ## 常见任务速查 | 任务 | 做法 | |----------------|------| | 多文件合并 | **merge_sheets.py** 或 pandas `read_excel` + `pd.concat` + `to_excel`。 | | CSV ↔ Excel | **excel_to_csv.py** / **csv_to_excel.py** 或 pandas 读写。 | | 按条件筛选 | **filter_excel.py** 或 `df[df["列"] == 值]` / `df.query()`。 | | 按行/按列拆分 | **split_excel.py**（按行数或按某列取值分文件）。 | | 去重 | **deduplicate_excel.py** 或 `df.drop_duplicates(subset=["列"], keep="first")`。 | | 按列聚合 | **aggregate_excel.py** 或 `df.groupby("列").agg({"数值列": "sum"})`。 | | 校验 | **validate_excel.py**。 | | 选择/重命名列 | **select_columns.py**。 | | 两表按键合并 | **merge_tables.py**（left/inner/outer）。 | | 行列转置 | **transpose_excel.py**。 | | 模板填充 | **template_fill.py**（{{列名}} 占位符）。 | | 重命名 sheet | **rename_sheets.py**。 | | 多表 VLOOKUP | **vlookup_multi.py**（主表 + 多个查找表依次左连接）。 | | 条件格式 | **format_conditional.py**（大于/小于/介于/重复值/色阶）。 | | 列格式为文本 | **format_columns_as_text.py**（避免长数字科学计数法）。 | | 保留原格式写数据 | 用 openpyxl 加载原文件，只改写目标单元格，再 save。 | ## 批量处理 当用户需要处理目录下多个 Excel 时： 1. 用 `pathlib.Path("目录").glob("*.xlsx")` 枚举文件。 2. 对每个文件读取 → 处理 → 可汇总到一个 DataFrame 或分别写出。 3. 输出可为一个合并文件，或 `原名_out.xlsx` 等约定；在 SKILL 中明确输出命名规则。 4. 出错时记录文件名和异常，继续处理其余文件，最后汇总报错列表。 ## 校验与错误处理 - 读取前用 `Path(file).exists()` 检查文件存在。 - 表为空或缺少预期列时给出明确提示（列名/行数）。 - 写入前若目标文件已存在，按用户要求覆盖或换名；大文件考虑 `write_only=True` 或分块。 - 捕获 `openpyxl.utils.exceptions.InvalidFileException`、`KeyError`（工作表名）等并返回可读错误信息。 ## 工具脚本 skill 目录下 `scripts/` 提供可执行脚本，优先在用户环境中运行脚本而非临时写长代码。 | 脚本 | 功能 | |------|------| | **merge_sheets.py** | 多 Excel 或同文件多 sheet 合并为一张表 | | **excel_to_csv.py** | 指定 sheet 导出为 CSV | | **csv_to_excel.py** | CSV 转 Excel（单/多 CSV → 多 sheet） | | **filter_excel.py** | 按列条件筛选（=、>、<、~ 包含）输出 Excel/CSV | | **split_excel.py** | 按行数分片或按某列取值拆成多个文件 | | **deduplicate_excel.py** | 按指定列去重，保留 first/last | | **aggregate_excel.py** | 按列分组聚合（sum/count/mean/min/max） | | **validate_excel.py** | 校验必须列、重复键、空行 | | **select_columns.py** | 选择/重命名/排序列 | | **merge_tables.py** | 两表按键列合并（VLOOKUP 式，left/inner/outer） | | **transpose_excel.py** | 行列转置 | | **template_fill.py** | 用数据表按行填充模板中的 {{列名}} 占位符 | | **rename_sheets.py** | 重命名工作表（原名:新名、索引:新名、前缀/后缀） | | **vlookup_multi.py** | 多表 VLOOKUP：主表依次与多个查找表左连接 | | **format_conditional.py** | 按条件格式化（gt/lt/介于/重复值/色阶） | | **format_columns_as_text.py** | 将指定列设为文本格式，避免纯数字显示为科学计数法 | 用法见各脚本 `--help` 或 [reference.md](reference.md)。 ## 更多说明 - 详细 API 与参数见 [reference.md](reference.md)。 - 更多场景示例见 [examples.md](examples.md)。

path: examples.md
content: # Excel 自动化 — 示例

## 示例 1：多文件合并为一个 Excel

需求：`data/` 下多个 .xlsx，表结构相同，合并为一个表并写到一个新 Excel。

```python
import pandas as pd
from pathlib import Path

files = list(Path("data").glob("*.xlsx"))
dfs = [pd.read_excel(f, engine="openpyxl") for f in files]
merged = pd.concat(dfs, ignore_index=True)
merged.to_excel("data/merged.xlsx", index=False)
```

## 示例 2：按条件筛选并导出 CSV

需求：从 `report.xlsx` 的「订单」表里筛选「状态」为「已完成」，导出为 CSV。

```python
import pandas as pd

df = pd.read_excel("report.xlsx", sheet_name="订单", engine="openpyxl")
done = df[df["状态"] == "已完成"]
done.to_csv("orders_done.csv", index=False, encoding="utf-8-sig")
```

## 示例 3：保留原表格式并写入新数据

需求：在已有模板 `template.xlsx` 的「汇总」表从第 2 行起写入新数据，不动表头和格式。

```python
import openpyxl

wb = openpyxl.load_workbook("template.xlsx")
ws = wb["汇总"]
for i, row in enumerate(new_data_rows, start=2):
    for j, val in enumerate(row, start=1):
        ws.cell(row=i, column=j, value=val)
wb.save("output.xlsx")
```

## 示例 4：使用脚本合并多表

```bash
python scripts/merge_sheets.py --inputs file1.xlsx file2.xlsx --output merged.xlsx
python scripts/merge_sheets.py --inputs ./data --output merged.xlsx
```

## 示例 5：校验并检查重复键

```bash
python scripts/validate_excel.py --input data.xlsx --key-cols 编号,日期 --require-cols 名称,金额
```

## 示例 6：CSV 转 Excel（多 CSV 多 sheet）

```bash
python scripts/csv_to_excel.py --inputs a.csv b.csv --output report.xlsx
python scripts/csv_to_excel.py --input data.csv --output data.xlsx --sheet-name 原始数据
```

## 示例 7：按条件筛选并输出

```bash
python scripts/filter_excel.py --input orders.xlsx --where "状态=已完成" --where "金额>100" --output done.xlsx
python scripts/filter_excel.py --input users.xlsx --where "城市~北京" --output beijing.csv
```

## 示例 8：按行数或按列拆分

```bash
python scripts/split_excel.py --input large.xlsx --by-rows 5000 --output-dir ./chunks
python scripts/split_excel.py --input sales.xlsx --by-column 地区 --output-dir ./by_region
```

## 示例 9：去重与聚合

```bash
python scripts/deduplicate_excel.py --input data.xlsx --keys 编号 --keep last --output unique.xlsx
python scripts/aggregate_excel.py --input sales.xlsx --group-by 地区 --agg "销售额:sum,数量:sum" --output summary.xlsx
python scripts/aggregate_excel.py --input orders.xlsx --group-by 部门,月份 --agg "金额:sum" "订单数:count"
```

## 示例 10：选择列与重命名

```bash
python scripts/select_columns.py --input data.xlsx --columns 姓名,部门,金额 --output out.xlsx
python scripts/select_columns.py --input data.xlsx --columns 姓名,部门,金额 --rename "部门:dept,金额:amount" --output out.csv
```

## 示例 11：两表按键合并（VLOOKUP）

```bash
python scripts/merge_tables.py --left 订单.xlsx --right 客户.xlsx --on 客户ID --output 订单带客户.xlsx
python scripts/merge_tables.py --left a.xlsx --right b.xlsx --on 编号 --how inner --output merged.xlsx
```

## 示例 12：行列转置

```bash
python scripts/transpose_excel.py --input data.xlsx --output transposed.xlsx
python scripts/transpose_excel.py --input data.xlsx --output transposed.xlsx --first-col-as-header
```

## 示例 13：模板填充

模板中某行单元格写 `{{姓名}}`、`{{金额}}` 等，数据表有对应列；每行数据生成一行填充结果。

```bash
python scripts/template_fill.py --template report_template.xlsx --data items.csv --output report.xlsx
python scripts/template_fill.py --template t.xlsx --data d.xlsx --pattern-row 2 --output out.xlsx
```

## 示例 14：重命名 sheet

```bash
python scripts/rename_sheets.py --input file.xlsx --rename "Sheet1:汇总" "Sheet2:明细" --output out.xlsx
python scripts/rename_sheets.py --input file.xlsx --rename "0:首页" "1:数据"
python scripts/rename_sheets.py --input file.xlsx --prefix "2024_" --output out.xlsx
```

## 示例 15：多表 VLOOKUP

主表依次与多个查找表按指定键列左连接，每次追加查找表中的列。

```bash
python scripts/vlookup_multi.py --main 订单.xlsx --lookups "客户.xlsx:客户ID" "产品.xlsx:产品ID" --output 订单完整.xlsx
python scripts/vlookup_multi.py --main main.xlsx --lookups "a.xlsx:编号" "b.xlsx:ID" --output merged.xlsx
```

## 示例 16：条件格式

对指定区域设置大于/小于/介于/重复值高亮或双色色阶。

```bash
python scripts/format_conditional.py --input data.xlsx --range "C2:C100" --rule gt --value 100 --fill red --output out.xlsx
python scripts/format_conditional.py --input data.xlsx --column D --rule duplicates --fill yellow
python scripts/format_conditional.py --input data.xlsx --range "E2:E50" --rule between --min 0 --max 1 --fill green
python scripts/format_conditional.py --input data.xlsx --range "F2:F100" --color-scale --output out.xlsx
```

## 示例 17：列设为文本格式（避免科学计数法）

长数字列（身份证号、订单号、条码等）在 Excel 中易被显示为科学计数法，将列设为文本格式并写为字符串即可完整显示。

```bash
python scripts/format_columns_as_text.py --input data.xlsx --columns 身份证号,订单号 --output out.xlsx
python scripts/format_columns_as_text.py --input data.xlsx --columns A,B,C --output out.xlsx
python scripts/format_columns_as_text.py --input data.xlsx --columns 编号 --sheet 明细 --output out.xlsx
```


path: reference.md
content: # Excel 自动化 — API 与参数参考

## openpyxl 常用

- `openpyxl.load_workbook(path, read_only=False, data_only=False, keep_vba=False)`
  - `read_only`：大文件时 True 节省内存，此时不可写。
  - `data_only=True`：读单元格时取计算后的值而非公式。
- `wb.sheetnames`：工作表名列表。
- `wb.active`：当前活动表；`wb["Sheet1"]` 按名取表。
- `ws.iter_rows(min_row, max_row, min_col, max_col, values_only=True)`：按行迭代。
- `ws["A1"].value`、`ws.cell(row=1, column=1).value`：读单元格。
- `ws.append([a, b, c])`：在最后一行后追加一行。
- 写出：`wb.save(path)`；若为 read_only 打开的，需用另一 Workbook 写入。

## pandas read_excel

- `pd.read_excel(io, sheet_name=0, header=0, names=None, usecols=None, index_col=None, engine="openpyxl")`
  - `sheet_name`：0、"Sheet1"、或 [0, 1] 多表（返回 dict）。
  - `header`：表头行索引。
  - `usecols`：如 "A:D" 或 [0, 1, 2]。
  - `nrows`：只读前 n 行。
- 引擎：.xlsx 用 `openpyxl`，.xls 用 `xlrd`。

## pandas to_excel / ExcelWriter

- `df.to_excel(excel_writer, sheet_name="Sheet1", index=True, startrow=0, startcol=0)`
- 多表：`with pd.ExcelWriter(path, engine="openpyxl") as w: df1.to_excel(w, sheet_name="A"); df2.to_excel(w, sheet_name="B")`

## scripts 参数速查

**merge_sheets.py**

- `--inputs`：输入文件或目录（多个用空格）。
- `--output`：输出 .xlsx 路径。
- `--sheet`：若输入为单文件，可指定要合并的 sheet 名（默认全部）。
- `--header-row`：表头行索引，默认 0。

**excel_to_csv.py**

- `--input`：.xlsx 文件。
- `--output`：输出 .csv 路径。
- `--sheet`：工作表名或索引，默认第一个。
- `--encoding`：如 utf-8-sig。
- `--sep`：分隔符，默认 `,`。

**validate_excel.py**

- `--input`：.xlsx 文件。
- `--sheet`：工作表名或索引。
- `--key-cols`：用于检查重复的列名，逗号分隔。
- `--require-cols`：必须存在的列名，逗号分隔。
- 退出码：0 通过，非 0 校验失败。

**csv_to_excel.py**

- `--input`：单个 CSV（与 `--inputs` 二选一）。
- `--inputs`：多个 CSV，每个转为一个 sheet。
- `--output`：输出 .xlsx。
- `--encoding`、`--sep`：CSV 编码与分隔符。
- `--sheet-name`：单文件时的 sheet 名。

**filter_excel.py**

- `--input`、`--output`：输入 .xlsx，输出 .xlsx 或 .csv。
- `--sheet`：工作表名或索引。
- `--where`：条件，可多次（AND）。格式：`列名=值`、`列名>值`、`列名~子串`（包含）等。

**split_excel.py**

- `--input`、`--sheet`：输入 .xlsx 与工作表。
- `--by-rows`：每 N 行一个文件。
- `--by-column`：按该列取值拆分，每个取值一个文件。
- `--output-dir`、`--prefix`：输出目录与行分片时的文件名前缀。

**deduplicate_excel.py**

- `--input`、`--output`：输入/输出 .xlsx 或 .csv。
- `--sheet`：工作表名或索引。
- `--keys`：去重键列名，逗号分隔。
- `--keep`：first 或 last。

**aggregate_excel.py**

- `--input`、`--output`：输入 .xlsx，输出可选（默认输入名_agg.xlsx）。
- `--sheet`：工作表名或索引。
- `--group-by`：分组列，逗号分隔。
- `--agg`：聚合，如 `列名:sum`、`列名:count`，可多次或逗号分隔。支持 sum/count/mean/min/max。

**select_columns.py**

- `--input`、`--output`：输入 .xlsx，输出 .xlsx 或 .csv。
- `--columns`：保留的列名，按此顺序，逗号分隔。
- `--rename`：重命名 旧名:新名，逗号分隔。

**merge_tables.py**

- `--left`、`--right`：左表/右表 .xlsx。
- `--on`：键列名（可逗号分隔多键）。
- `--output`：输出 .xlsx 或 .csv。
- `--how`：left（默认）/inner/outer。
- `--sheet-left`、`--sheet-right`：左右表 sheet。
- `--suffixes`：重名列后缀，默认 _left,_right。

**transpose_excel.py**

- `--input`、`--output`：输入/输出 .xlsx 或 .csv。
- `--sheet`：工作表名或索引。
- `--first-col-as-header`：转置后用第一列作为列名。

**template_fill.py**

- `--template`、`--data`：模板 .xlsx、数据 .xlsx 或 .csv。
- `--output`：输出 .xlsx。
- `--pattern-row`：模板中占位符所在行号（1-based），默认 2。
- `--sheet-template`、`--sheet-data`：模板/数据 sheet。模板单元格内 {{列名}} 会被替换为数据行对应列的值。

**rename_sheets.py**

- `--input`、`--output`：输入 .xlsx，输出可选（默认覆盖）。
- `--rename`：原名:新名 或 索引:新名（索引从 0 起），可多次。
- `--prefix`、`--suffix`：为所有 sheet 名加前缀/后缀。

**vlookup_multi.py**

- `--main`：主表 .xlsx。
- `--lookups`：查找表 文件:键列名，如 "ref.xlsx:ID"，可多个，依次左连接。
- `--output`：输出 .xlsx 或 .csv。
- `--sheet-main`：主表 sheet。查找表默认每个文件的第一个 sheet。
- `--suffix`：与主表重名列在查找表中的后缀，默认 _lookup。

**format_conditional.py**

- `--input`、`--output`：输入 .xlsx，输出可选（默认覆盖）。
- `--sheet`：工作表名或索引。
- `--range` 或 `--column`：应用区域（如 A2:A100）或列字母（自动从第 2 行到最大行）。
- `--rule`：gt/gte/lt/lte/eq/between/duplicates。
- `--value`：gt/lt/eq 等的比较值。
- `--min`、`--max`：between 时的下限、上限。
- `--fill`：满足条件时的填充色（red、yellow 或六位十六进制如 FF6B6B）。
- `--color-scale`：使用双色色阶（红-绿），忽略 --rule/--value。

**format_columns_as_text.py**

- `--input`、`--output`：输入 .xlsx，输出可选（默认覆盖）。
- `--sheet`：工作表名或索引。
- `--columns`：要设为文本的列，逗号分隔；可为**列名**（与表头一致，如 身份证号,订单号）或**列字母**（如 A,B,C）。
- `--header-row`：表头行号（1-based），默认 1。脚本会将该列所有单元格设为数字格式 "@" 并将值转为字符串存储，避免长数字显示为科学计数法。


path: requirements.txt
content: openpyxl>=3.1.0
pandas>=2.0.0
xlrd>=2.0.0


path: scripts/_make_test_data.py
content: #!/usr/bin/env python3
"""生成测试用 Excel/CSV，供 run_all_tests 使用。"""
import os
from pathlib import Path

import pandas as pd

TEST_DIR = Path(__file__).resolve().parent / "_test_out"
TEST_DIR.mkdir(parents=True, exist_ok=True)
os.chdir(TEST_DIR)

# 主表：编号, 姓名, 地区, 金额, 销售额, 数量, 状态, 日期
df_main = pd.DataFrame({
    "编号": ["A001", "A002", "A003", "A004", "A005"],
    "姓名": ["张三", "李四", "王五", "赵六", "钱七"],
    "地区": ["北京", "上海", "北京", "广州", "上海"],
    "金额": [100, 200, 150, 300, 250],
    "销售额": [1000, 2000, 1500, 3000, 2500],
    "数量": [10, 20, 15, 30, 25],
    "状态": ["已完成", "进行中", "已完成", "已完成", "进行中"],
    "日期": ["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04", "2024-01-05"],
})
df_main.to_excel("sample.xlsx", index=False, sheet_name="Sheet1")

# 第二个 Excel（多 sheet 合并用）
df_extra = pd.DataFrame({
    "编号": ["B001", "B002"],
    "姓名": ["孙八", "周九"],
    "地区": ["深圳", "杭州"],
    "金额": [180, 220],
    "销售额": [1800, 2200],
    "数量": [18, 22],
    "状态": ["已完成", "进行中"],
    "日期": ["2024-01-06", "2024-01-07"],
})
df_extra.to_excel("sample2.xlsx", index=False, sheet_name="Sheet1")

# 右表（merge_tables / vlookup）：编号 + 补充列
df_lookup = pd.DataFrame({
    "编号": ["A001", "A002", "A003", "A004", "A005"],
    "部门": ["销售", "技术", "销售", "市场", "技术"],
    "备注": ["备注1", "备注2", "备注3", "备注4", "备注5"],
})
df_lookup.to_excel("lookup.xlsx", index=False, sheet_name="Sheet1")

# CSV
df_main.to_csv("sample.csv", index=False, encoding="utf-8-sig")

# 模板：第一行表头，第二行占位符 {{姓名}} {{金额}}
wb_tpl = __import__("openpyxl").Workbook()
ws = wb_tpl.active
ws["A1"], ws["B1"], ws["C1"] = "姓名", "金额", "地区"
ws["A2"], ws["B2"], ws["C2"] = "{{姓名}}", "{{金额}}", "{{地区}}"
wb_tpl.save("template.xlsx")

print("Test data written to", TEST_DIR)


path: scripts/aggregate_excel.py
content: #!/usr/bin/env python3
"""
按列分组并聚合（sum/count/mean/min/max），输出到新 Excel。
用法:
  python aggregate_excel.py --input data.xlsx --group-by 地区 --agg "销售额:sum,数量:sum"
  python aggregate_excel.py --input data.xlsx --group-by 部门,月份 --agg "金额:sum" "人数:count" --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd

AGG_FUNCS = {"sum": "sum", "count": "count", "mean": "mean", "min": "min", "max": "max"}


def main():
    parser = argparse.ArgumentParser(description="按列分组聚合")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", help="输出 .xlsx，默认覆盖输入并加 _agg 后缀")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--group-by", "-g", required=True, help="分组列，逗号分隔")
    parser.add_argument("--agg", "-a", action="append", required=True, help="聚合：列名:sum 或 列名:count 等，可多次或逗号分隔")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    group_cols = [c.strip() for c in args.group_by.split(",") if c.strip()]
    missing = [c for c in group_cols if c not in df.columns]
    if missing:
        print(f"分组列不存在: {missing}", file=sys.stderr)
        sys.exit(1)

    agg_spec = {}
    for item in args.agg:
        for part in item.split(","):
            part = part.strip()
            if ":" not in part:
                continue
            col, func = part.split(":", 1)
            col, func = col.strip(), func.strip().lower()
            if col not in df.columns:
                print(f"聚合列不存在: {col}", file=sys.stderr)
                sys.exit(1)
            if func not in AGG_FUNCS:
                print(f"不支持的聚合: {func}，可选 sum/count/mean/min/max", file=sys.stderr)
                sys.exit(1)
            agg_spec[col] = AGG_FUNCS[func]

    if not agg_spec:
        print("请至少指定一个 --agg 列:函数", file=sys.stderr)
        sys.exit(1)

    result = df.groupby(group_cols, dropna=False).agg(agg_spec).reset_index()

    if args.output:
        out = Path(args.output)
    else:
        out = path.parent / f"{path.stem}_agg.xlsx"
    out.parent.mkdir(parents=True, exist_ok=True)
    result.to_excel(out, index=False, engine="openpyxl")
    print(f"聚合完成，{len(result)} 行 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/csv_to_excel.py
content: #!/usr/bin/env python3
"""
将 CSV 文件转为 Excel。支持单文件单 sheet，或多 CSV 合并为一个 Excel 多 sheet。
用法:
  python csv_to_excel.py --input data.csv --output data.xlsx
  python csv_to_excel.py --inputs a.csv b.csv --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="CSV 转 Excel")
    parser.add_argument("--input", "-i", help="单个输入 CSV（与 --inputs 二选一）")
    parser.add_argument("--inputs", nargs="+", help="多个 CSV，每个转为一个 sheet")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 路径")
    parser.add_argument("--encoding", default="utf-8-sig", help="CSV 编码")
    parser.add_argument("--sep", default=",", help="CSV 分隔符")
    parser.add_argument("--sheet-name", help="单文件时的 sheet 名，默认用文件名")
    args = parser.parse_args()

    if args.input and args.inputs:
        print("请只使用 --input 或 --inputs 之一", file=sys.stderr)
        sys.exit(1)
    if not args.input and not args.inputs:
        print("请指定 --input 或 --inputs", file=sys.stderr)
        sys.exit(1)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)

    if args.input:
        path = Path(args.input)
        if not path.exists():
            print(f"文件不存在: {path}", file=sys.stderr)
            sys.exit(1)
        df = pd.read_csv(path, encoding=args.encoding, sep=args.sep)
        sheet_name = args.sheet_name or path.stem
        if len(sheet_name) > 31:
            sheet_name = sheet_name[:31]
        df.to_excel(out, sheet_name=sheet_name, index=False, engine="openpyxl")
        print(f"已转换 {len(df)} 行 -> {out} (sheet: {sheet_name})")
        return

    paths = [Path(p) for p in args.inputs]
    for p in paths:
        if not p.exists():
            print(f"文件不存在: {p}", file=sys.stderr)
            sys.exit(1)

    with pd.ExcelWriter(out, engine="openpyxl") as writer:
        for path in paths:
            df = pd.read_csv(path, encoding=args.encoding, sep=args.sep)
            name = path.stem
            if len(name) > 31:
                name = name[:31]
            df.to_excel(writer, sheet_name=name, index=False)
    print(f"已转换 {len(paths)} 个 CSV -> {out}")


if __name__ == "__main__":
    main()


path: scripts/deduplicate_excel.py
content: #!/usr/bin/env python3
"""
按指定列去重，保留第一条或最后一条，输出到新文件。
用法:
  python deduplicate_excel.py --input data.xlsx --keys 编号 --output out.xlsx
  python deduplicate_excel.py --input data.xlsx --keys 姓名,日期 --keep last --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="按列去重")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--keys", "-k", required=True, help="作为去重键的列名，逗号分隔")
    parser.add_argument("--keep", choices=("first", "last"), default="first", help="保留第一条或最后一条")
    parser.add_argument("--encoding", default="utf-8-sig", help="输出为 CSV 时的编码")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    keys = [c.strip() for c in args.keys.split(",") if c.strip()]
    missing = [c for c in keys if c not in df.columns]
    if missing:
        print(f"列不存在: {missing}", file=sys.stderr)
        sys.exit(1)

    before = len(df)
    out_df = df.drop_duplicates(subset=keys, keep=args.keep)
    after = len(out_df)
    removed = before - after

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        out_df.to_csv(out, index=False, encoding=args.encoding)
    else:
        out_df.to_excel(out, index=False, engine="openpyxl")
    print(f"去重: {before} -> {after} 行，移除 {removed} 条 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/excel_to_csv.py
content: #!/usr/bin/env python3
"""
将指定 Excel 工作表导出为 CSV。
用法:
  python excel_to_csv.py --input file.xlsx --output file.csv
  python excel_to_csv.py --input file.xlsx --sheet 明细 --encoding utf-8-sig --output out.csv
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="将 Excel 指定 sheet 导出为 CSV")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", required=True, help="输出 .csv 路径")
    parser.add_argument("--sheet", default=0, help="工作表名或索引，默认第一个")
    parser.add_argument("--encoding", default="utf-8-sig", help="CSV 编码，默认 utf-8-sig")
    parser.add_argument("--sep", default=",", help="分隔符，默认逗号")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    df.to_csv(out, index=False, encoding=args.encoding, sep=args.sep)
    print(f"已导出 {len(df)} 行到 {out}")


if __name__ == "__main__":
    main()


path: scripts/filter_excel.py
content: #!/usr/bin/env python3
"""
按列条件筛选 Excel 行，输出到新 Excel 或 CSV。
条件格式：列名=值（等于）、列名>值、列名>=、<、<=、!=、列名~子串（包含）。
用法:
  python filter_excel.py --input data.xlsx --where "状态=已完成" --output out.xlsx
  python filter_excel.py --input data.xlsx --where "金额>1000" "地区~北京" --output out.csv
"""
import argparse
import re
import sys
from pathlib import Path

import pandas as pd


def _parse_where(expr):
    # 列名 运算符 值，值可带引号
    m = re.match(r"^(\w+)\s*(=|!=|>=|<=|>|<|~)\s*(.*)$", expr.strip())
    if not m:
        return None
    col, op, val = m.group(1), m.group(2), m.group(3).strip()
    if val.startswith('"') and val.endswith('"'):
        val = val[1:-1]
    elif val.startswith("'") and val.endswith("'"):
        val = val[1:-1]
    return col, op, val


def _apply_filter(df, col, op, val):
    if col not in df.columns:
        raise ValueError(f"列不存在: {col}")
    s = df[col].astype(str)
    if op == "=":
        return df[df[col].astype(str) == str(val)]
    if op == "!=":
        return df[df[col].astype(str) != str(val)]
    if op == "~":
        return df[s.str.contains(str(val), na=False, regex=False)]
    try:
        num = pd.to_numeric(df[col], errors="coerce")
        v = pd.to_numeric(val, errors="coerce")
    except Exception:
        v = val
        num = df[col]
    if op == ">":
        return df[num > v]
    if op == ">=":
        return df[num >= v]
    if op == "<":
        return df[num < v]
    if op == "<=":
        return df[num <= v]
    return df


def main():
    parser = argparse.ArgumentParser(description="按条件筛选 Excel 行")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--where", "-w", action="append", required=True, help="条件，可多次指定（AND）")
    parser.add_argument("--encoding", default="utf-8-sig", help="输出为 CSV 时的编码")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    for expr in args.where:
        parsed = _parse_where(expr)
        if not parsed:
            print(f"无效条件: {expr}", file=sys.stderr)
            sys.exit(1)
        col, op, val = parsed
        try:
            df = _apply_filter(df, col, op, val)
        except ValueError as e:
            print(e, file=sys.stderr)
            sys.exit(1)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        df.to_csv(out, index=False, encoding=args.encoding)
    else:
        df.to_excel(out, index=False, engine="openpyxl")
    print(f"筛选后 {len(df)} 行 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/format_columns_as_text.py
content: #!/usr/bin/env python3
"""
将指定列设为「文本」格式，避免纯数字（如长 ID、证件号）被显示为科学计数法。
用法:
  python format_columns_as_text.py --input data.xlsx --columns 身份证号,订单号 --output out.xlsx
  python format_columns_as_text.py --input data.xlsx --columns A,B,C --output out.xlsx
  python format_columns_as_text.py --input data.xlsx --columns 编号 --sheet 0 --output out.xlsx
"""
import argparse
import re
import sys
from pathlib import Path

import openpyxl


def _col_name_to_index(name):
    """列名（A, B, ..., Z, AA, ...）转 1-based 列索引。"""
    name = name.upper().strip()
    if not re.match(r"^[A-Z]+$", name):
        return None
    n = 0
    for c in name:
        n = n * 26 + (ord(c) - ord("A") + 1)
    return n


def main():
    parser = argparse.ArgumentParser(description="将指定列设为文本格式，避免科学计数法")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", help="输出 .xlsx，默认覆盖原文件")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--columns", "-c", required=True, help="要设为文本的列，逗号分隔：列名（如 身份证号）或列字母（如 A,B）")
    parser.add_argument("--header-row", type=int, default=1, help="表头行号（1-based），该行也一并设为文本格式")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    wb = openpyxl.load_workbook(path)
    if isinstance(args.sheet, int):
        ws = wb.worksheets[args.sheet]
    else:
        ws = wb[args.sheet]

    max_row = ws.max_row or 1
    max_col = ws.max_column or 1
    header_row = args.header_row
    header = [ws.cell(row=header_row, column=j).value for j in range(1, max_col + 1)]

    col_specs = [s.strip() for s in args.columns.split(",") if s.strip()]
    col_indices = []
    for spec in col_specs:
        idx = _col_name_to_index(spec)
        if idx is not None:
            if 1 <= idx <= max_col:
                col_indices.append(idx)
            else:
                print(f"列字母越界: {spec}", file=sys.stderr)
                sys.exit(1)
        else:
            try:
                i = header.index(spec) + 1
                col_indices.append(i)
            except ValueError:
                print(f"表头中未找到列名: {spec}", file=sys.stderr)
                sys.exit(1)

    for col_idx in col_indices:
        for row in range(1, max_row + 1):
            cell = ws.cell(row=row, column=col_idx)
            cell.number_format = "@"
            val = cell.value
            if val is not None and not isinstance(val, str):
                cell.value = str(val)

    out = Path(args.output) if args.output else path
    out.parent.mkdir(parents=True, exist_ok=True)
    wb.save(out)
    print(f"已将 {len(col_indices)} 列设为文本格式 -> {out}", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/format_conditional.py
content: #!/usr/bin/env python3
"""
为 Excel 指定区域添加条件格式：大于/小于/等于/介于/重复值，或双色/三色色阶。
用法:
  python format_conditional.py --input data.xlsx --range "C2:C100" --rule gt --value 100 --fill "FF6B6B"
  python format_conditional.py --input data.xlsx --column D --rule duplicates --fill yellow
  python format_conditional.py --input data.xlsx --range "E2:E50" --rule between --min 0 --max 1 --fill "90EE90"
  python format_conditional.py --input data.xlsx --range "F2:F100" --color-scale
"""
import argparse
import re
import sys
from pathlib import Path

import openpyxl
from openpyxl.formatting.rule import CellIsRule, Rule
from openpyxl.styles import PatternFill
from openpyxl.styles.differential import DifferentialStyle


# 常用颜色名 -> 六位十六进制（无前缀）
COLORS = {
    "red": "FF6B6B",
    "green": "90EE90",
    "yellow": "FFFF00",
    "orange": "FFA500",
    "blue": "87CEEB",
    "gray": "D3D3D3",
}


def _parse_fill(fill_arg):
    s = str(fill_arg).strip()
    if s.startswith("#"):
        s = s[1:]
    if len(s) == 6 and re.match(r"[0-9A-Fa-f]{6}", s):
        return s
    return COLORS.get(s.lower(), "FF6B6B")


def _resolve_range(ws, column_arg, range_arg):
    if range_arg:
        return range_arg
    if not column_arg:
        return None
    col = column_arg.upper()
    if len(col) == 1 and col.isalpha():
        max_row = ws.max_row or 1
        return f"{col}2:{col}{max(2, max_row)}"
    return None


def main():
    parser = argparse.ArgumentParser(description="为 Excel 区域添加条件格式")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", help="输出 .xlsx，默认覆盖原文件")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--range", "-r", help="区域，如 A2:A100（与 --column 二选一）")
    parser.add_argument("--column", "-c", help="列字母，如 A；将对该列从第 2 行到最大行应用格式")
    parser.add_argument("--rule", choices=("gt", "gte", "lt", "lte", "eq", "between", "duplicates"), help="条件：大于/大于等于/小于/小于等于/等于/介于/重复值")
    parser.add_argument("--value", help="--rule gt/lt/eq 等时的比较值")
    parser.add_argument("--min", help="--rule between 时的下限")
    parser.add_argument("--max", help="--rule between 时的上限")
    parser.add_argument("--fill", default="FF6B6B", help="满足条件时的填充色，如 red 或 FF6B6B")
    parser.add_argument("--color-scale", action="store_true", help="使用双色色阶（红-绿），忽略 --rule/--value")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    wb = openpyxl.load_workbook(path)
    if isinstance(args.sheet, int):
        ws = wb.worksheets[args.sheet]
    else:
        ws = wb[args.sheet]

    cell_range = _resolve_range(ws, args.column, args.range)
    if not cell_range:
        print("请指定 --range 或 --column", file=sys.stderr)
        sys.exit(1)

    fill_hex = _parse_fill(args.fill)
    fill = PatternFill(start_color=fill_hex, end_color=fill_hex, fill_type="solid")
    dxf = DifferentialStyle(fill=fill)

    if args.color_scale:
        from openpyxl.formatting.rule import ColorScaleRule
        rule = ColorScaleRule(
            start_type="min", start_value=0, start_color="F8696B",
            end_type="max", end_value=100, end_color="63BE7B",
        )
        ws.conditional_formatting.add(cell_range, rule)
        print(f"已添加色阶条件格式: {cell_range}", file=sys.stderr)
    elif args.rule == "duplicates":
        rule = Rule(type="duplicateValues", dxf=dxf)
        ws.conditional_formatting.add(cell_range, rule)
        print(f"已添加重复值条件格式: {cell_range}", file=sys.stderr)
    elif args.rule == "between":
        if args.min is None or args.max is None:
            print("--rule between 需同时指定 --min 和 --max", file=sys.stderr)
            sys.exit(1)
        rule = CellIsRule(operator="between", formula=[str(args.min), str(args.max)], fill=fill)
        ws.conditional_formatting.add(cell_range, rule)
        print(f"已添加介于 {args.min}-{args.max} 条件格式: {cell_range}", file=sys.stderr)
    else:
        op_map = {"gt": "greaterThan", "gte": "greaterThanOrEqual", "lt": "lessThan", "lte": "lessThanOrEqual", "eq": "equal"}
        if args.rule not in op_map or not args.value:
            print("--rule gt/lt/eq 等需指定 --value", file=sys.stderr)
            sys.exit(1)
        rule = CellIsRule(operator=op_map[args.rule], formula=[str(args.value)], fill=fill)
        ws.conditional_formatting.add(cell_range, rule)
        print(f"已添加 {args.rule} {args.value} 条件格式: {cell_range}", file=sys.stderr)

    out = Path(args.output) if args.output else path
    out.parent.mkdir(parents=True, exist_ok=True)
    wb.save(out)
    print(f"已保存 -> {out}", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/merge_sheets.py
content: #!/usr/bin/env python3
"""
合并多个 Excel 文件或同一文件中的多个 sheet 为单个表，输出一个 .xlsx。
用法:
  python merge_sheets.py --inputs a.xlsx b.xlsx --output out.xlsx
  python merge_sheets.py --inputs ./data --output out.xlsx
  python merge_sheets.py --inputs file.xlsx --sheet Sheet1 --sheet Sheet2 --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def _collect_paths(inputs):
    paths = []
    for p in inputs:
        path = Path(p).resolve()
        if not path.exists():
            print(f"跳过不存在的路径: {path}", file=sys.stderr)
            continue
        if path.is_file():
            if path.suffix.lower() in (".xlsx", ".xls"):
                paths.append(path)
            else:
                print(f"跳过非 Excel 文件: {path}", file=sys.stderr)
        else:
            for f in path.glob("*.xlsx"):
                paths.append(f)
            for f in path.glob("*.xls"):
                paths.append(f)
    return paths


def main():
    parser = argparse.ArgumentParser(description="合并多个 Excel 文件或 sheet 为单个表")
    parser.add_argument("--inputs", nargs="+", required=True, help="输入文件或目录")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 路径")
    parser.add_argument("--sheet", action="append", default=None, help="仅当输入为单文件时生效，可多次指定要合并的 sheet 名")
    parser.add_argument("--header-row", type=int, default=0, help="表头行索引，默认 0")
    args = parser.parse_args()

    paths = _collect_paths(args.inputs)
    if not paths:
        print("没有找到任何 Excel 文件", file=sys.stderr)
        sys.exit(1)

    dfs = []
    for path in paths:
        try:
            if len(paths) == 1 and args.sheet:
                for sh in args.sheet:
                    df = pd.read_excel(path, sheet_name=sh, header=args.header_row, engine="openpyxl")
                    dfs.append(df)
            else:
                # 单文件无 --sheet：读所有 sheet
                xl = pd.ExcelFile(path, engine="openpyxl")
                for name in xl.sheet_names:
                    df = pd.read_excel(xl, sheet_name=name, header=args.header_row)
                    dfs.append(df)
        except Exception as e:
            print(f"读取失败 {path}: {e}", file=sys.stderr)
            continue

    if not dfs:
        print("没有成功读取任何表", file=sys.stderr)
        sys.exit(1)

    merged = pd.concat(dfs, ignore_index=True)
    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    merged.to_excel(out, index=False, engine="openpyxl")
    print(f"已合并 {len(dfs)} 个表，共 {len(merged)} 行，输出: {out}")


if __name__ == "__main__":
    main()


path: scripts/merge_tables.py
content: #!/usr/bin/env python3
"""
两表按键列合并（类似 VLOOKUP）：左表为主，右表按 key 列匹配并追加列。
用法:
  python merge_tables.py --left left.xlsx --right right.xlsx --on 编号 --output out.xlsx
  python merge_tables.py --left left.xlsx --right right.xlsx --on 编号 --how inner --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="两表按键列合并（VLOOKUP 式）")
    parser.add_argument("--left", "-l", required=True, help="左表 .xlsx（主表）")
    parser.add_argument("--right", "-r", required=True, help="右表 .xlsx（被查表）")
    parser.add_argument("--on", required=True, help="键列名（两表共有），逗号分隔为多键")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet-left", default=0, help="左表 sheet 名或索引")
    parser.add_argument("--sheet-right", default=0, help="右表 sheet 名或索引")
    parser.add_argument("--how", choices=("left", "inner", "outer"), default="left", help="合并方式：left 保留左表全部，inner 交集，outer 并集")
    parser.add_argument("--suffixes", default="_left,_right", help="重名列后缀，逗号分隔")
    args = parser.parse_args()

    lpath = Path(args.left)
    rpath = Path(args.right)
    if not lpath.exists():
        print(f"左表不存在: {lpath}", file=sys.stderr)
        sys.exit(1)
    if not rpath.exists():
        print(f"右表不存在: {rpath}", file=sys.stderr)
        sys.exit(1)

    try:
        left = pd.read_excel(lpath, sheet_name=args.sheet_left, engine="openpyxl")
        right = pd.read_excel(rpath, sheet_name=args.sheet_right, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    keys = [k.strip() for k in args.on.split(",") if k.strip()]
    for k in keys:
        if k not in left.columns:
            print(f"左表缺少键列: {k}", file=sys.stderr)
            sys.exit(1)
        if k not in right.columns:
            print(f"右表缺少键列: {k}", file=sys.stderr)
            sys.exit(1)

    suffixes = [s.strip() for s in args.suffixes.split(",")]
    if len(suffixes) != 2:
        suffixes = ["_left", "_right"]
    merged = pd.merge(left, right, on=keys, how=args.how, suffixes=suffixes)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        merged.to_csv(out, index=False, encoding="utf-8-sig")
    else:
        merged.to_excel(out, index=False, engine="openpyxl")
    print(f"合并完成: 左 {len(left)} 行 + 右 {len(right)} 行 -> {len(merged)} 行 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/rename_sheets.py
content: #!/usr/bin/env python3
"""
重命名 Excel 中的工作表。按原名或索引指定要重命名的 sheet。
用法:
  python rename_sheets.py --input file.xlsx --rename "Sheet1:汇总" "Sheet2:明细"
  python rename_sheets.py --input file.xlsx --rename "0:首页" "1:数据"
  python rename_sheets.py --input file.xlsx --prefix "2024_" --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import openpyxl


def main():
    parser = argparse.ArgumentParser(description="重命名 Excel 工作表")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", help="输出 .xlsx，默认覆盖原文件")
    parser.add_argument("--rename", "-r", action="append", help="原名:新名 或 索引:新名（索引从 0 起），可多次")
    parser.add_argument("--prefix", help="为所有 sheet 名前加前缀")
    parser.add_argument("--suffix", help="为所有 sheet 名后加后缀")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    if not args.rename and not args.prefix and not args.suffix:
        print("请指定 --rename、--prefix 或 --suffix 之一", file=sys.stderr)
        sys.exit(1)

    wb = openpyxl.load_workbook(path)
    names = wb.sheetnames

    if args.rename:
        for item in args.rename:
            if ":" not in item:
                print(f"无效项（需 原名:新名 或 索引:新名）: {item}", file=sys.stderr)
                sys.exit(1)
            left, new_name = item.split(":", 1)
            left, new_name = left.strip(), new_name.strip()
            if not new_name:
                continue
            if left.isdigit():
                idx = int(left)
                if idx < 0 or idx >= len(names):
                    print(f"索引越界: {idx}", file=sys.stderr)
                    sys.exit(1)
                old_name = names[idx]
            else:
                old_name = left
                if old_name not in names:
                    print(f"工作表不存在: {old_name}", file=sys.stderr)
                    sys.exit(1)
            if len(new_name) > 31:
                new_name = new_name[:31]
            if new_name != old_name and new_name in names:
                print(f"新名已存在: {new_name}", file=sys.stderr)
                sys.exit(1)
            wb[old_name].title = new_name
            names = wb.sheetnames

    if args.prefix or args.suffix:
        for i, old in enumerate(wb.sheetnames):
            new = (args.prefix or "") + old + (args.suffix or "")
            if len(new) > 31:
                new = new[:31]
            if new != old:
                wb[old].title = new

    out = Path(args.output) if args.output else path
    out.parent.mkdir(parents=True, exist_ok=True)
    wb.save(out)
    print(f"已保存 -> {out}", file=sys.stderr)
    print(" ".join(wb.sheetnames))


if __name__ == "__main__":
    main()


path: scripts/requirements.txt
content: openpyxl>=3.1.0
pandas>=2.0.0
xlrd>=2.0.0


path: scripts/select_columns.py
content: #!/usr/bin/env python3
"""
选择列、重命名列、按指定顺序输出。不指定的列会被丢弃。
用法:
  python select_columns.py --input data.xlsx --columns 姓名,部门,金额 --output out.xlsx
  python select_columns.py --input data.xlsx --columns 姓名,部门,金额 --rename "部门:dept,金额:amount" --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="选择/重命名/排序列")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--columns", "-c", required=True, help="保留的列名，按此顺序输出，逗号分隔")
    parser.add_argument("--rename", "-r", help="重命名 旧名:新名，多个用逗号分隔，如 部门:dept,金额:amount")
    parser.add_argument("--encoding", default="utf-8-sig", help="输出为 CSV 时的编码")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    cols = [c.strip() for c in args.columns.split(",") if c.strip()]
    missing = [c for c in cols if c not in df.columns]
    if missing:
        print(f"列不存在: {missing}", file=sys.stderr)
        sys.exit(1)

    out_df = df[cols].copy()

    if args.rename:
        renames = {}
        for part in args.rename.split(","):
            part = part.strip()
            if ":" not in part:
                continue
            old_name, new_name = part.split(":", 1)
            old_name, new_name = old_name.strip(), new_name.strip()
            if old_name in out_df.columns:
                renames[old_name] = new_name
        out_df = out_df.rename(columns=renames)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        out_df.to_csv(out, index=False, encoding=args.encoding)
    else:
        out_df.to_excel(out, index=False, engine="openpyxl")
    print(f"已输出 {len(out_df)} 行 x {len(out_df.columns)} 列 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/split_excel.py
content: #!/usr/bin/env python3
"""
将一个大 Excel 按行数分片，或按某列取值拆成多个文件（每个取值一个文件）。
用法:
  python split_excel.py --input data.xlsx --by-rows 5000 --output-dir ./chunks
  python split_excel.py --input data.xlsx --by-column 地区 --output-dir ./by_region
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def _sanitize_filename(s):
    s = str(s).strip()
    for c in '\\/:*?"<>|':
        s = s.replace(c, "_")
    return s[:50] or "unnamed"


def main():
    parser = argparse.ArgumentParser(description="按行数或按列取值拆分 Excel")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--by-rows", type=int, help="每 N 行一个文件")
    parser.add_argument("--by-column", help="按该列取值拆分，每个取值一个文件")
    parser.add_argument("--output-dir", "-o", default=".", help="输出目录")
    parser.add_argument("--prefix", default="part", help="按行分片时的文件名前缀")
    args = parser.parse_args()

    if not args.by_rows and not args.by_column:
        print("请指定 --by-rows 或 --by-column", file=sys.stderr)
        sys.exit(1)
    if args.by_rows and args.by_column:
        print("请只指定 --by-rows 或 --by-column 之一", file=sys.stderr)
        sys.exit(1)

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    base = path.stem

    if args.by_rows:
        n = args.by_rows
        for i in range(0, len(df), n):
            chunk = df.iloc[i : i + n]
            out_path = out_dir / f"{args.prefix}_{i // n + 1:04d}.xlsx"
            chunk.to_excel(out_path, index=False, engine="openpyxl")
        count = (len(df) + n - 1) // n
        print(f"已按每 {n} 行拆成 {count} 个文件 -> {out_dir}")
        return

    col = args.by_column
    if col not in df.columns:
        print(f"列不存在: {col}", file=sys.stderr)
        sys.exit(1)
    for val, sub in df.groupby(col, dropna=False):
        name = _sanitize_filename(val) if pd.notna(val) else "null"
        out_path = out_dir / f"{base}_{name}.xlsx"
        sub.to_excel(out_path, index=False, engine="openpyxl")
    print(f"已按列 '{col}' 拆成 {df[col].nunique()} 个文件 -> {out_dir}")


if __name__ == "__main__":
    main()


path: scripts/template_fill.py
content: #!/usr/bin/env python3
"""
用数据表按行填充模板：模板中 {{列名}} 占位符被替换为数据行对应列的值，每行数据生成一行，输出到同一表。
用法:
  python template_fill.py --template template.xlsx --data data.xlsx --output out.xlsx
  python template_fill.py --template template.xlsx --data data.csv --pattern-row 2 --output out.xlsx
"""
import argparse
import re
import sys
from pathlib import Path

import pandas as pd
import openpyxl


def _replace_placeholders(text, row_dict):
    if text is None or not isinstance(text, str):
        return text
    result = text
    for col, val in row_dict.items():
        placeholder = "{{" + str(col) + "}}"
        if placeholder in result:
            result = result.replace(placeholder, str(val) if pd.notna(val) else "")
    # 未匹配的 {{xxx}} 保留或清空
    result = re.sub(r"\{\{[^}]+\}\}", "", result)
    return result


def main():
    parser = argparse.ArgumentParser(description="用数据表按行填充模板中的 {{列名}} 占位符")
    parser.add_argument("--template", "-t", required=True, help="模板 .xlsx，含占位符的一行作为样式来源")
    parser.add_argument("--data", "-d", required=True, help="数据 .xlsx 或 .csv")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx")
    parser.add_argument("--sheet-template", default=0, help="模板 sheet 名或索引")
    parser.add_argument("--sheet-data", default=0, help="数据 sheet 名或索引（仅 .xlsx）")
    parser.add_argument("--pattern-row", type=int, default=2, help="模板中占位符所在行号（1-based），默认第 2 行")
    parser.add_argument("--header-row", type=int, default=1, help="模板表头行号（1-based），默认第 1 行")
    args = parser.parse_args()

    tpath = Path(args.template)
    dpath = Path(args.data)
    if not tpath.exists():
        print(f"模板不存在: {tpath}", file=sys.stderr)
        sys.exit(1)
    if not dpath.exists():
        print(f"数据文件不存在: {dpath}", file=sys.stderr)
        sys.exit(1)

    if dpath.suffix.lower() == ".csv":
        data_df = pd.read_csv(dpath, encoding="utf-8-sig")
    else:
        data_df = pd.read_excel(dpath, sheet_name=args.sheet_data, engine="openpyxl")

    wb = openpyxl.load_workbook(tpath)
    if isinstance(args.sheet_template, int):
        ws = wb.worksheets[args.sheet_template]
    else:
        ws = wb[args.sheet_template]

    pattern_row_idx = args.pattern_row
    max_col = ws.max_column

    for i, row in enumerate(data_df.itertuples(index=False)):
        row_dict = dict(zip(data_df.columns, row))
        target_row = pattern_row_idx + i
        for col_idx in range(1, max_col + 1):
            src = ws.cell(row=pattern_row_idx, column=col_idx)
            cell = ws.cell(row=target_row, column=col_idx)
            cell.value = _replace_placeholders(src.value, row_dict)
            try:
                if src.has_style and src.font:
                    cell.font = src.font.copy()
                if src.has_style and src.border:
                    cell.border = src.border.copy()
                if src.has_style and src.fill:
                    cell.fill = src.fill.copy()
                if src.number_format:
                    cell.number_format = src.number_format
                if src.has_style and src.alignment:
                    cell.alignment = src.alignment.copy()
            except Exception:
                pass

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    wb.save(out)
    print(f"已用 {len(data_df)} 行数据填充模板 -> {out}")


if __name__ == "__main__":
    main()


path: scripts/transpose_excel.py
content: #!/usr/bin/env python3
"""
行列转置：首行变首列，首列变首行。可选保留原首行作为列名。
用法:
  python transpose_excel.py --input data.xlsx --output out.xlsx
  python transpose_excel.py --input data.xlsx --output out.xlsx --first-col-as-header
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="行列转置")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--first-col-as-header", action="store_true", help="转置后用第一列作为列名（否则用 0,1,2...）")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl", header=None)
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    out_df = df.T
    if args.first_col_as_header:
        out_df.columns = out_df.iloc[0]
        out_df = out_df.iloc[1:].reset_index(drop=True)

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        out_df.to_csv(out, index=False, encoding="utf-8-sig")
    else:
        out_df.to_excel(out, index=False, engine="openpyxl")
    print(f"已转置 {df.shape[0]}x{df.shape[1]} -> {out_df.shape[0]}x{out_df.shape[1]} -> {out}")


if __name__ == "__main__":
    main()


path: scripts/validate_excel.py
content: #!/usr/bin/env python3
"""
校验 Excel 表：检查必须列、空行、重复键等。
用法:
  python validate_excel.py --input data.xlsx
  python validate_excel.py --input data.xlsx --require-cols 名称,金额 --key-cols 编号
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="校验 Excel 表结构与数据")
    parser.add_argument("--input", "-i", required=True, help="输入 .xlsx 文件")
    parser.add_argument("--sheet", default=0, help="工作表名或索引")
    parser.add_argument("--key-cols", default="", help="用于检查重复的列名，逗号分隔")
    parser.add_argument("--require-cols", default="", help="必须存在的列名，逗号分隔")
    args = parser.parse_args()

    path = Path(args.input)
    if not path.exists():
        print(f"文件不存在: {path}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(path, sheet_name=args.sheet, engine="openpyxl")
    except Exception as e:
        print(f"读取失败: {e}", file=sys.stderr)
        sys.exit(1)

    errors = []

    if args.require_cols:
        required = [c.strip() for c in args.require_cols.split(",") if c.strip()]
        missing = [c for c in required if c not in df.columns]
        if missing:
            errors.append(f"缺少必须列: {missing}")

    if args.key_cols:
        keys = [c.strip() for c in args.key_cols.split(",") if c.strip()]
        key_missing = [c for c in keys if c not in df.columns]
        if key_missing:
            errors.append(f"键列不存在: {key_missing}")
        else:
            dup = df[df.duplicated(subset=keys, keep=False)]
            if not dup.empty:
                errors.append(f"存在重复键，共 {len(dup)} 行")

    # 全空行
    empty = df.dropna(how="all")
    if len(empty) < len(df):
        errors.append(f"存在 {len(df) - len(empty)} 行全空行")

    if errors:
        for e in errors:
            print(e, file=sys.stderr)
        sys.exit(1)
    print("校验通过")
    sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/vlookup_multi.py
content: #!/usr/bin/env python3
"""
多表 VLOOKUP：主表依次与多个查找表按键列左连接，每次合并追加新列。
用法:
  python vlookup_multi.py --main 订单.xlsx --lookups "客户.xlsx:客户ID" "产品.xlsx:产品ID" --output out.xlsx
  python vlookup_multi.py --main main.xlsx --lookups "a.xlsx:编号" "b.xlsx:ID" --sheet-main 0 --output out.xlsx
"""
import argparse
import sys
from pathlib import Path

import pandas as pd


def main():
    parser = argparse.ArgumentParser(description="多表按键依次左连接（VLOOKUP 链）")
    parser.add_argument("--main", "-m", required=True, help="主表 .xlsx")
    parser.add_argument("--lookups", "-l", required=True, nargs="+", help="查找表 文件:键列名，如 ref.xlsx:ID，可多个")
    parser.add_argument("--output", "-o", required=True, help="输出 .xlsx 或 .csv")
    parser.add_argument("--sheet-main", default=0, help="主表 sheet 名或索引")
    parser.add_argument("--suffix", default="_lookup", help="追加列与主表重名时的后缀")
    args = parser.parse_args()

    mpath = Path(args.main)
    if not mpath.exists():
        print(f"主表不存在: {mpath}", file=sys.stderr)
        sys.exit(1)

    try:
        df = pd.read_excel(mpath, sheet_name=args.sheet_main, engine="openpyxl")
    except Exception as e:
        print(f"读取主表失败: {e}", file=sys.stderr)
        sys.exit(1)

    for spec in args.lookups:
        if ":" not in spec:
            print(f"无效 lookups 项（需 文件:键列）: {spec}", file=sys.stderr)
            sys.exit(1)
        fpath, on_col = spec.split(":", 1)
        fpath, on_col = fpath.strip(), on_col.strip()
        lpath = Path(fpath)
        if not lpath.exists():
            lpath = (mpath.parent / fpath).resolve()
        if not lpath.exists():
            print(f"查找表不存在: {fpath}", file=sys.stderr)
            sys.exit(1)
        try:
            right = pd.read_excel(lpath, sheet_name=0, engine="openpyxl")
        except Exception as e:
            print(f"读取查找表失败 {fpath}: {e}", file=sys.stderr)
            sys.exit(1)
        if on_col not in df.columns:
            print(f"主表缺少键列: {on_col}", file=sys.stderr)
            sys.exit(1)
        if on_col not in right.columns:
            print(f"查找表 {fpath} 缺少键列: {on_col}", file=sys.stderr)
            sys.exit(1)
        right_cols = [c for c in right.columns if c != on_col]
        if not right_cols:
            continue
        dup = [c for c in right_cols if c in df.columns]
        if dup:
            right = right.rename(columns={c: c + args.suffix for c in dup})
            right_cols = [c + args.suffix if c in dup else c for c in right_cols]
        df = pd.merge(df, right, on=on_col, how="left", suffixes=("", "_drop"))
        drop_cols = [c for c in df.columns if c.endswith("_drop")]
        df = df.drop(columns=drop_cols, errors="ignore")

    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.suffix.lower() == ".csv":
        df.to_csv(out, index=False, encoding="utf-8-sig")
    else:
        df.to_excel(out, index=False, engine="openpyxl")
    print(f"已合并 {len(args.lookups)} 个查找表，共 {len(df)} 行 x {len(df.columns)} 列 -> {out}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)