Skill: tujinsama/data-cleaning-claw (version 1.0.0)

--- name: data-cleaning-claw description: | 数据自动化清洗虾。处理脏数据、重复数据与广告噪音，输出高质量干净数据。 触发场景：用户提到"清洗数据"、"去重"、"数据清理"、"脏数据"、"数据标准化"、"格式统一"、"去噪"、"数据预处理"、"数据质量"、"缺失值处理"，或上传了 CSV/Excel/JSON 文件并要求清洗处理时。 支持：Excel/CSV/JSON 输入，去重、缺失值填充、格式标准化（日期/金额/电话）、HTML去噪、数据验证，输出清洗后文件 + 清洗报告。 --- # 数据自动化清洗虾 处理脏数据的专用 skill。核心脚本：`scripts/data_clean.py`。 ## 工作流程 ### 1. 接收数据 用户可通过以下方式提供数据： - 上传文件（CSV / Excel / JSON）→ 保存到 workspace，记录路径 - 直接粘贴数据 → 写入临时 CSV 文件 ### 2. 确认清洗需求 如果用户没有明确说明，询问以下信息（可一次性问完）： - 需要哪些清洗操作（去重/缺失值/格式标准化/HTML去噪/数据验证）？ - 去重时是否有关键字段（如手机号、邮箱）？ - 输出格式（CSV/Excel/JSON）？ 如果用户说"全部清洗"或"帮我清洗一下"，默认执行所有规则：`strip-html,deduplicate,fill-missing,standardize,validate`。 ### 3. 执行清洗 使用 `exec` 运行脚本： ```bash python3 ~/.openclaw/skills/data-cleaning-claw/scripts/data_clean.py \ --input "<输入文件路径>" \ --output "<输出文件路径>" \ --rules "strip-html,deduplicate,fill-missing,standardize,validate" \ --key-fields "<可选：去重关键字段，逗号分隔>" ``` 可用规则（`--rules` 参数，逗号分隔）： - `strip-html` — 去除 HTML 标签和广告噪音 - `deduplicate` — 去重（默认全字段；`--key-fields` 指定关键字段） - `fill-missing` — 缺失值填充（数值→中位数，文本→"未知"） - `standardize` — 格式标准化（自动识别日期/金额/电话列） - `validate` — 数据验证，异常行添加 `_数据质量标记` 列 可选字段强制指定参数： - `--date-fields` — 强制指定日期列（逗号分隔列名） - `--amount-fields` — 强制指定金额列 - `--phone-fields` — 强制指定电话列 ### 4. 输出结果 脚本自动生成两个文件： - `<output>` — 清洗后的数据文件 - `<output>.report.json` — 清洗报告（删除行数、各列处理情况） 向用户展示清洗报告摘要，并发送清洗后的文件。 ## 参考资料 需要了解具体规则时读取： - `references/cleaning-rules.md` — 去重、缺失值、格式标准化的详细规则 - `references/noise-patterns.md` — HTML噪音、广告文案、无效字符的识别模板 - `references/data-types.md` — 日期、金额、电话、邮箱的识别正则 ## 注意事项 - 清洗前提醒用户备份原始数据（如果是重要数据） - 默认策略是**标记**异常值（添加 `_数据质量标记` 列），不直接删除，保留人工复核 - 脚本依赖：`pandas numpy openpyxl beautifulsoup4`，如缺少依赖先运行 `pip install pandas numpy openpyxl beautifulsoup4`

path: references/cleaning-rules.md
content: # 数据清洗规则库

## 去重规则

### 完全去重
- 所有字段完全相同的行视为重复，保留第一条（或最新一条）

### 关键字段去重
- 指定关键字段（如手机号、邮箱、订单号），关键字段相同则视为重复
- 保留策略：优先保留字段最完整的记录；字段完整度相同时保留最新记录

### 模糊去重
- 文本字段去除空格/大小写后比较
- 姓名字段：全角/半角统一后比较

## 缺失值处理

| 字段类型 | 默认策略 | 备注 |
|---------|---------|------|
| 数值 | 填充均值或中位数 | 异常分布时用中位数 |
| 文本 | 填充"未知" | 关键字段建议删除该行 |
| 日期 | 标记为异常，不自动填充 | 避免引入错误时间 |
| 布尔值 | 填充 False/0 | 视业务语义决定 |

- 空字符串、"NULL"、"N/A"、"nan"、"-" 均视为缺失值

## 格式标准化

### 日期
- 目标格式：`YYYY-MM-DD` 或 `YYYY-MM-DD HH:MM:SS`
- 识别并转换：`MM/DD/YYYY`、`DD-MM-YYYY`、`YYYY年MM月DD日`、`2026/3/1` 等

### 金额
- 去除货币符号（¥ $ € £）
- 去除千分位逗号
- 统一为浮点数，保留 2 位小数
- 示例：`¥1,200.00` → `1200.00`

### 电话号码
- 手机号：统一为 11 位纯数字（去除 +86、空格、横线）
- 固话：统一为 `区号-号码` 格式
- 超出合法长度的号码标记为异常

### 文本
- 去除首尾空格（strip）
- 全角字符转半角（数字、字母、标点）
- 统一换行符为 `\n`

### 邮箱
- 转为小写
- 验证格式：`[REDACTED_SECRET]`

## 异常值处理

| 字段 | 异常判断 | 处理策略 |
|------|---------|---------|
| 年龄 | <0 或 >150 | 标记为可疑 |
| 金额 | <0（非退款场景） | 标记为可疑 |
| 手机号 | 非 11 位 | 标记为异常 |
| 日期 | 未来日期（业务不允许时） | 标记为可疑 |

- 默认策略：标记（添加 `_flag` 列），不直接删除，保留人工复核


path: references/data-types.md
content: # 数据类型识别库

## 日期识别

常见格式（按优先级排序）：

| 格式 | 示例 |
|------|------|
| `YYYY-MM-DD` | 2026-03-01 |
| `YYYY/MM/DD` | 2026/03/01 |
| `YYYY年MM月DD日` | 2026年3月1日 |
| `MM/DD/YYYY` | 03/01/2026 |
| `DD-MM-YYYY` | 01-03-2026 |
| `YYYYMMDD` | 20260301 |
| `YYYY-MM-DD HH:MM:SS` | 2026-03-01 14:30:00 |

- 使用 `pandas.to_datetime(errors='coerce')` 自动解析，失败则标记为异常
- 歧义格式（如 `01/02/03`）优先按 `YYYY/MM/DD` 解析，无法确定时标记为可疑

## 金额识别

正则：`[¥$€£]?\s*[\d,]+\.?\d*`

- 货币符号：¥ $ € £ ￥ USD CNY
- 千分位：`,`（如 `1,200,000`）
- 小数点：`.`
- 负数：`-` 前缀或 `()` 包裹（如 `(1200.00)` 表示负数）

## 联系方式识别

| 类型 | 正则 | 示例 |
|------|------|------|
| 手机号 | `1[3-9]\d{9}` | 13800138000 |
| 固话 | `\d{3,4}-\d{7,8}` | 010-12345678 |
| 邮箱 | `[\w.+-]+@[\w-]+\.[a-z]{2,}` | [REDACTED_SECRET] |
| 网址 | `https?://[^\s]+` | https://example.com |

## 身份证识别

- 格式：18 位，前 17 位为数字，最后一位为数字或 X
- 正则：`[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]`
- 校验：最后一位可通过加权求和验证（可选，性能敏感时跳过）

## 布尔值识别

以下值统一识别为布尔：
- True：`true` `True` `TRUE` `1` `yes` `Yes` `是` `Y`
- False：`false` `False` `FALSE` `0` `no` `No` `否` `N`


path: references/noise-patterns.md
content: # 噪音识别模板

## HTML 噪音

常见需要清除的 HTML 元素：
- 标签：`<div>` `<span>` `<p>` `<a>` `<img>` `<script>` `<style>` `<table>` `<td>` `<tr>` 等
- 属性：`class=` `id=` `style=` `href=` `src=`
- HTML 实体：`&nbsp;` `&amp;` `&lt;` `&gt;` `&quot;` → 转换为对应字符或空格
- 处理方式：使用 BeautifulSoup 或正则 `<[^>]+>` 提取纯文本

## 广告文案特征

以下模式通常为广告噪音，可直接删除：
- "点击查看更多" / "查看详情" / "立即购买" / "限时优惠"
- "广告" / "推广" / "赞助"
- 含有 URL 的纯推广文本（非数据字段）
- "关注我们" / "扫码关注" / "微信公众号"

## 导航/页脚元素

网页抓取数据中常见的无效文本：
- 导航栏：首页、关于我们、联系我们、新闻中心、产品中心
- 页脚：版权所有、ICP备案、隐私政策、使用条款
- 面包屑：`首页 > 分类 > 详情`

## 无效字符

| 类型 | 示例 | 处理 |
|------|------|------|
| 表情符号 | 😀🎉❤️ | 删除或替换为空 |
| 零宽字符 | U+200B U+FEFF | 删除 |
| 控制字符 | \x00-\x1f（除 \n \t） | 删除 |
| 重复标点 | ！！！ …… | 压缩为单个 |
| 全角空格 | `　`（U+3000） | 替换为半角空格 |

## 敏感词/脱敏规则

- 手机号：保留前 3 位和后 4 位，中间替换为 `****`（如 `138****8000`）
- 身份证：保留前 6 位和后 4 位，中间替换为 `********`
- 银行卡：保留后 4 位，其余替换为 `****`
- 敏感词：替换为 `***`，不直接删除整行（避免数据丢失）


path: scripts/data_clean.py
content: #!/usr/bin/env python3
"""
data_clean.py - 数据自动化清洗脚本
依赖：pandas, numpy, openpyxl, beautifulsoup4
安装：pip install pandas numpy openpyxl beautifulsoup4
"""

import argparse
import json
import re
import sys
from pathlib import Path

import numpy as np
import pandas as pd


# ── 工具函数 ──────────────────────────────────────────────

def load_data(path: str) -> pd.DataFrame:
    p = Path(path)
    ext = p.suffix.lower()
    if ext in (".xlsx", ".xls"):
        return pd.read_excel(path, dtype=str)
    elif ext == ".csv":
        return pd.read_csv(path, dtype=str)
    elif ext == ".json":
        return pd.read_json(path, dtype=str)
    else:
        raise ValueError(f"不支持的文件格式：{ext}，请使用 CSV/Excel/JSON")


def save_data(df: pd.DataFrame, path: str):
    p = Path(path)
    ext = p.suffix.lower()
    if ext in (".xlsx", ".xls"):
        df.to_excel(path, index=False)
    elif ext == ".csv":
        df.to_csv(path, index=False, encoding="utf-8-sig")
    elif ext == ".json":
        df.to_json(path, orient="records", force_ascii=False, indent=2)
    else:
        df.to_csv(path, index=False, encoding="utf-8-sig")


def strip_html(text: str) -> str:
    """去除 HTML 标签，转换常见 HTML 实体"""
    try:
        from bs4 import BeautifulSoup
        return BeautifulSoup(text, "html.parser").get_text(separator=" ")
    except ImportError:
        text = re.sub(r"<[^>]+>", " ", text)
        entities = {"&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"'}
        for k, v in entities.items():
            text = text.replace(k, v)
        return text


def clean_text(text: str) -> str:
    """通用文本清洗：去首尾空格、控制字符、零宽字符、全角空格"""
    if not isinstance(text, str):
        return text
    # 零宽字符 & 控制字符（保留 \n \t）
    text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u200b\u200c\u200d\ufeff\u3000]", "", text)
    text = text.strip()
    return text


def normalize_date(val: str) -> str:
    """尝试将各种日期格式统一为 YYYY-MM-DD"""
    if not isinstance(val, str) or not val.strip():
        return val
    try:
        parsed = pd.to_datetime(val, dayfirst=False, errors="raise")
        return parsed.strftime("%Y-%m-%d")
    except Exception:
        return val  # 无法解析，原样返回（后续标记为异常）


def normalize_amount(val: str) -> str:
    """去除货币符号、千分位，统一为数值字符串"""
    if not isinstance(val, str) or not val.strip():
        return val
    cleaned = re.sub(r"[¥$€£￥,\s]", "", val)
    # 处理括号负数 (1200) → -1200
    if cleaned.startswith("(") and cleaned.endswith(")"):
        cleaned = "-" + cleaned[1:-1]
    try:
        return str(float(cleaned))
    except ValueError:
        return val


def normalize_phone(val: str) -> str:
    """统一手机号为 11 位纯数字"""
    if not isinstance(val, str) or not val.strip():
        return val
    digits = re.sub(r"[\s\-\(\)\+]", "", val)
    if digits.startswith("86") and len(digits) == 13:
        digits = digits[2:]
    return digits


def is_null(val) -> bool:
    if val is None:
        return True
    if isinstance(val, float) and np.isnan(val):
        return True
    if isinstance(val, str) and val.strip().lower() in ("", "null", "n/a", "nan", "none", "-", "na"):
        return True
    return False


# ── 清洗步骤 ──────────────────────────────────────────────

def step_deduplicate(df: pd.DataFrame, key_fields=None) -> tuple[pd.DataFrame, dict]:
    before = len(df)
    if key_fields:
        fields = [f.strip() for f in key_fields.split(",") if f.strip() in df.columns]
        df = df.drop_duplicates(subset=fields, keep="first")
    else:
        df = df.drop_duplicates(keep="first")
    removed = before - len(df)
    return df.reset_index(drop=True), {"去重删除行数": removed}


def step_fill_missing(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
    log = {}
    for col in df.columns:
        null_mask = df[col].apply(is_null)
        count = null_mask.sum()
        if count == 0:
            continue
        # 尝试判断列类型
        non_null = df.loc[~null_mask, col]
        try:
            numeric = pd.to_numeric(non_null, errors="raise")
            fill_val = str(round(numeric.median(), 4))
            df.loc[null_mask, col] = fill_val
            log[col] = f"填充中位数 {fill_val}（{count} 处）"
        except Exception:
            df.loc[null_mask, col] = "未知"
            log[col] = f"填充'未知'（{count} 处）"
    return df, {"缺失值处理": log}


def step_standardize(df: pd.DataFrame, date_fields=None, amount_fields=None, phone_fields=None) -> tuple[pd.DataFrame, dict]:
    log = {}

    def auto_detect(col_name: str, sample: pd.Series) -> str:
        name = col_name.lower()
        if any(k in name for k in ("日期", "date", "time", "时间")):
            return "date"
        if any(k in name for k in ("金额", "amount", "price", "费用", "价格", "收入", "支出")):
            return "amount"
        if any(k in name for k in ("手机", "电话", "phone", "mobile", "tel")):
            return "phone"
        return "text"

    for col in df.columns:
        dtype = auto_detect(col, df[col])
        if date_fields and col in date_fields.split(","):
            dtype = "date"
        if amount_fields and col in amount_fields.split(","):
            dtype = "amount"
        if phone_fields and col in phone_fields.split(","):
            dtype = "phone"

        if dtype == "date":
            df[col] = df[col].apply(normalize_date)
            log[col] = "日期格式标准化"
        elif dtype == "amount":
            df[col] = df[col].apply(normalize_amount)
            log[col] = "金额格式标准化"
        elif dtype == "phone":
            df[col] = df[col].apply(normalize_phone)
            log[col] = "电话号码标准化"
        else:
            df[col] = df[col].apply(clean_text)

    return df, {"格式标准化": log}


def step_strip_html(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
    count = 0
    for col in df.columns:
        mask = df[col].apply(lambda v: isinstance(v, str) and bool(re.search(r"<[a-zA-Z/]", v)))
        if mask.any():
            df.loc[mask, col] = df.loc[mask, col].apply(strip_html)
            count += mask.sum()
    return df, {"HTML去噪处理列数": int(count)}


def step_validate(df: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
    flags = pd.Series([""] * len(df), index=df.index)
    issues = {}

    for col in df.columns:
        name = col.lower()
        if any(k in name for k in ("手机", "phone", "mobile")):
            bad = df[col].apply(lambda v: isinstance(v, str) and v.strip() and not re.fullmatch(r"1[3-9]\d{9}", v.strip()))
            if bad.any():
                flags[bad] += f"[{col}格式异常]"
                issues[col] = f"{bad.sum()} 条手机号格式异常"
        if any(k in name for k in ("邮箱", "email", "mail")):
            bad = df[col].apply(lambda v: isinstance(v, str) and v.strip() and not re.fullmatch(r"[\w.+-]+@[\w-]+\.[a-z]{2,}", v.strip(), re.I))
            if bad.any():
                flags[bad] += f"[{col}格式异常]"
                issues[col] = f"{bad.sum()} 条邮箱格式异常"
        if any(k in name for k in ("年龄", "age")):
            try:
                nums = pd.to_numeric(df[col], errors="coerce")
                bad = (nums < 0) | (nums > 150)
                if bad.any():
                    flags[bad] += f"[{col}异常值]"
                    issues[col] = f"{bad.sum()} 条年龄异常"
            except Exception:
                pass

    if flags.str.strip().any():
        df["_数据质量标记"] = flags
    return df, {"数据验证": issues}


# ── 主流程 ────────────────────────────────────────────────

def run_clean(args):
    print(f"📂 读取数据：{args.input}")
    df = load_data(args.input)
    total_rows = len(df)
    print(f"   共 {total_rows} 行，{len(df.columns)} 列")

    report = {"原始行数": total_rows, "原始列数": len(df.columns), "操作日志": {}}

    rules = [r.strip() for r in args.rules.split(",")]

    if "strip-html" in rules:
        df, log = step_strip_html(df)
        report["操作日志"].update(log)
        print(f"✅ HTML去噪完成")

    if "standardize" in rules:
        df, log = step_standardize(df, args.date_fields, args.amount_fields, args.phone_fields)
        report["操作日志"].update(log)
        print(f"✅ 格式标准化完成")

    if "deduplicate" in rules:
        df, log = step_deduplicate(df, args.key_fields)
        report["操作日志"].update(log)
        print(f"✅ 去重完成，删除 {log['去重删除行数']} 行")

    if "fill-missing" in rules:
        df, log = step_fill_missing(df)
        report["操作日志"].update(log)
        print(f"✅ 缺失值处理完成")

    if "validate" in rules:
        df, log = step_validate(df)
        report["操作日志"].update(log)
        print(f"✅ 数据验证完成")

    report["清洗后行数"] = len(df)
    report["清洗后列数"] = len(df.columns)
    report["数据质量提升"] = f"删除 {total_rows - len(df)} 行脏数据"

    save_data(df, args.output)
    print(f"\n💾 已保存清洗结果：{args.output}")

    # 输出报告
    report_path = Path(args.output).with_suffix(".report.json")
    with open(report_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"📊 清洗报告：{report_path}")
    print(json.dumps(report, ensure_ascii=False, indent=2))


def main():
    parser = argparse.ArgumentParser(description="数据自动化清洗脚本")
    parser.add_argument("--input", required=True, help="输入文件路径（CSV/Excel/JSON）")
    parser.add_argument("--output", required=True, help="输出文件路径")
    parser.add_argument(
        "--rules",
        default="strip-html,deduplicate,fill-missing,standardize,validate",
        help="清洗规则，逗号分隔：strip-html,deduplicate,fill-missing,standardize,validate"
    )
    parser.add_argument("--key-fields", default=None, help="去重关键字段，逗号分隔（不填则全字段去重）")
    parser.add_argument("--date-fields", default=None, help="强制指定为日期字段，逗号分隔")
    parser.add_argument("--amount-fields", default=None, help="强制指定为金额字段，逗号分隔")
    parser.add_argument("--phone-fields", default=None, help="强制指定为电话字段，逗号分隔")
    args = parser.parse_args()
    run_clean(args)


if __name__ == "__main__":
    main()


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