Skill: yourlin/douyin-sensitive-check (version 1.0.2)

--- name: douyin-sensitive-check description: 抖音/短视频违禁词和敏感词检测（本地词库版，无需 API Key）。每天首次使用自动从 GitHub 开源词库更新本地缓存，离线检测文案合规性。支持多词库合并（广告极限词、平台限流词、暴恐、色情、涉枪涉爆等）。使用场景：(1) 生成短视频文案后自动检测违禁词，(2) 用户要求检查某段文字是否有问题，(3) 抖音/快手/B站内容合规审核，(4) 直播话术自查。触发词：违禁词、敏感词、检测、合规、抖音风控、限流词、能不能发。 metadata: openclaw: emoji: "🚨" requires: bins: ["python3"] platform: ["macos", "linux"] data: ["data/"] network: description: "每天首次使用时从 raw.githubusercontent.com 拉取开源词库更新（3 个公开仓库），无数据上传。网络失败时自动降级为本地缓存，不影响使用。" hosts: ["raw.githubusercontent.com"] trigger: "daily-first-use" optional: true --- # 抖音违禁词检测 Skill（开源词库版） 本地词库 + 每日自动更新，无需 API Key，离线可用。 ## 脚本路径 ``` scripts/ check.py # 主检测脚本（入口） update_words.py # 词库更新模块（每天首次自动触发） data/ # 运行时生成，词库缓存目录（.gitignore 排除） sensitive_words.txt .update_state.json ``` ## 常用命令 ```bash SKILL=~/.agents/skills/douyin-sensitive-check # 检测一段文案 python3 $SKILL/scripts/check.py "今天给大家推荐史上最好用的护肤品，加我微信领优惠券" # 检测文件 python3 $SKILL/scripts/check.py -f /path/to/script.txt # 管道 echo "文案内容" | python3 $SKILL/scripts/check.py # 强制更新词库 python3 $SKILL/scripts/check.py --update # 查看词库状态 python3 $SKILL/scripts/check.py --status ``` ## 工作流 1. **每天首次运行** → 自动调用 `update_words.py` 从 3 个 GitHub 开源词库拉取最新内容合并 2. 加载本地 `data/sensitive_words.txt`（去重合并，含数万词条） 3. 对输入文案做全文子串匹配（长词优先） 4. 输出：🔴 违禁词（必改）/ 🟡 广告极限词（建议改）+ 上下文标注 5. 根据结果帮用户改写文案，改完后再次检测直到通过 ## 词库来源 - `konsheng/Sensitive-lexicon`：广告、政治、暴恐、色情、涉枪涉爆、补充词库 - `bigdata-labs/sensitive-stop-words`：广告、政治、色情、涉枪涉爆 - `jkiss/sensitive-words`：广告、政治、色情 ## 更新机制 - `data/.update_state.json` 记录最后更新日期 - 每天第一次使用自动触发，当天内后续使用直接读缓存 - 网络失败时保留本地缓存，不影响使用 - 手动强制更新：`--update` ## 重要提示 - 开源词库以通用违禁词为主，抖音平台的部分特有限流词（如"私信"、"加微信"）已内置在 `check.py` 的 `CATEGORY_PATTERNS` 中补充 - 匹配策略是子串匹配，可能有误报；如需精确匹配可编辑 `data/sensitive_words.txt` 删除误报词 - 改写建议：被标注词优先用谐音、符号分割、同义替换等方式规避

path: README.md
content: # douyin-sensitive-check

> 抖音/短视频违禁词检测 OpenClaw Skill — 开源词库版，无需 API Key

## 功能

- 🔴 **违禁词检测**：涵盖政治、暴恐、色情、涉枪涉爆等（3,000+ 词）
- 🟠 **平台限流词**：抖音已知限流词，如"推广"、"加微信"、"优惠券"等
- 🟡 **广告极限词**：广告法违禁极限词，如"最好"、"第一"、"史上最"等
- 🟡 **医疗违禁词**：如"包治"、"根治"、"无副作用"等
- 📍 **上下文标注**：精确定位词在文案中的位置
- 🔄 **每日自动更新**：每天首次使用自动从 GitHub 拉取最新词库

## 词库来源（开源）

- [konsheng/Sensitive-lexicon](https://github.com/konsheng/Sensitive-lexicon) — MIT License
- [bigdata-labs/sensitive-stop-words](https://github.com/bigdata-labs/sensitive-stop-words)
- [jkiss/sensitive-words](https://github.com/jkiss/sensitive-words)

## 安装

```bash
# 克隆到 OpenClaw skills 目录
git clone https://github.com/YOUR_USERNAME/douyin-sensitive-check ~/.agents/skills/douyin-sensitive-check
```

## 使用

安装后直接在 OpenClaw 对话中说：

> "帮我检测这段文案有没有违禁词：今天给大家推广一款产品..."

### 命令行直接使用

```bash
SKILL=~/.agents/skills/douyin-sensitive-check

# 检测文案
python3 $SKILL/scripts/check.py "你的文案内容"

# 检测文件
python3 $SKILL/scripts/check.py -f script.txt

# 强制更新词库
python3 $SKILL/scripts/check.py --update

# 查看词库状态
python3 $SKILL/scripts/check.py --status
```

## 示例输出

```
🚨 发现 3 个风险词，建议修改后再发布

🟠 平台限流词（建议替换，影响流量）:
   ▸ 推广
     上下文: 今天给大家【推广】一款产品…

🟡 广告极限词（广告法风险）:
   ▸ 史上最
     上下文: …【史上最】好用！

── 标注后文案 ──
今天给大家【推广】一款产品，【史上最】好用！

📊 检测字数: 20 字 | 风险词: 3 个
```

## 隐私与网络说明

本 skill 仅在以下情况发起网络请求：

- 每天**首次使用时**自动从 `raw.githubusercontent.com` 拉取词库更新（3 个公开仓库）
- 请求目标均为公开 GitHub 仓库的原始文本文件，**无任何数据上传**
- 网络失败时自动降级为本地缓存，不影响正常使用
- 如需**完全离线使用**，可手动维护 `data/sensitive_words.txt`，将 `data/.update_state.json` 中 `last_update` 设为未来日期即可禁用自动更新

```json
// data/.update_state.json — 设置此值可禁用自动更新
{ "last_update": "2099-12-31", "word_count": 12345 }
```

## License

MIT


path: references/word_sources.md
content: # 开源词库来源说明

## 词库 1: konsheng/Sensitive-lexicon

- GitHub: <https://github.com/konsheng/Sensitive-lexicon>
- License: MIT
- 特点：持续维护，分类细，含 COVID/GFW 补充词

使用的文件：
- `Vocabulary/广告类型.txt`
- `Vocabulary/政治类型.txt`
- `Vocabulary/暴恐词库.txt`
- `Vocabulary/色情词库.txt`
- `Vocabulary/涉枪涉爆.txt`
- `Vocabulary/其他词库.txt`
- `Vocabulary/补充词库.txt`

## 词库 2: bigdata-labs/sensitive-stop-words

- GitHub: <https://github.com/bigdata-labs/sensitive-stop-words>
- License: 未注明（开源公开项目）
- 特点：常用互联网敏感词，含停止词

使用的文件：
- `广告.txt`
- `政治类.txt`
- `色情类.txt`
- `涉枪涉爆违法信息关键词.txt`

## 词库 3: jkiss/sensitive-words

- GitHub: <https://github.com/jkiss/sensitive-words>
- License: 未注明（开源公开项目）
- 特点：互联网常用敏感词，含停止词

使用的文件：
- `广告.txt`
- `政治类.txt`
- `色情类.txt`

## 合并策略

三个词库去重合并为 `data/sensitive_words.txt`，排序存储。
更新时全量替换，保持与上游同步。

## 抖音平台特有限流词（内置补充）

以下词汇不在上述词库中，但抖音平台已知会限流，直接内置在 `check.py` 的 `CATEGORY_PATTERNS`:

**平台限流词**: 推广、广告、营销、引流、私信、加微信、扫码、点链接、下单、购买、优惠券、福利、秒杀、限时

**广告极限词**: 最好、第一、唯一、顶级、极致、无敌、史上最、全网最、最强、最优、最大、最低、最高、最便宜、专家级、国家级


path: scripts/check.py
content: #!/usr/bin/env python3
"""
抖音/短视频违禁词检测脚本（本地词库版）

用法:
  python3 check.py "你的文案内容"
  python3 check.py -f input.txt
  echo "文案" | python3 check.py
  python3 check.py --update          # 强制更新词库
  python3 check.py --status          # 查看词库状态

工作流:
  1. 每天首次运行自动更新词库（从多个 GitHub 开源词库拉取）
  2. 对输入文案做全词匹配（含子串匹配）
  3. 输出命中词及上下文位置，给出替换建议
"""

import sys
import re
from pathlib import Path

# 添加 scripts 目录到路径
sys.path.insert(0, str(Path(__file__).parent))
from update_words import update_words, load_words, status, needs_update


# 按类别匹配关键词（用于显示风险等级）
# 这些词不在通用违禁词库，但抖音/短视频平台已知会限流或违规
BUILTIN_RISK_WORDS = {
    "广告极限词（广告法）": [
        "史上最", "最好", "第一", "唯一", "顶级", "极致", "无敌", "全网最",
        "最强", "最优", "最大", "最低", "最高", "最便宜", "最实惠", "最划算",
        "专家级", "国家级", "行业第一", "销量第一", "NO.1", "no.1",
        "绝对", "100%", "永久", "终身", "彻底", "根治",
    ],
    "平台限流词（抖音）": [
        "推广", "广告", "营销", "引流", "涨粉", "买粉", "刷量",
        "私信", "加微信", "加我微信", "微信号", "扫码", "二维码",
        "点链接", "点击链接", "下单", "购买", "下载", "安装",
        "优惠券", "领券", "领红包", "福利", "免费领",
        "秒杀", "限时", "限量", "抢购", "团购",
        "代理", "招商", "加盟", "合作", "分销",
    ],
    "医疗健康违禁词": [
        "包治", "根治", "治愈", "特效", "祖传秘方", "偏方",
        "无副作用", "无任何副作用", "药到病除", "立竿见影",
    ],
}


def categorize_word(word: str) -> str:
    """对命中词做简单分类，没有精确分类就返回通用"""
    for cat, keywords in BUILTIN_RISK_WORDS.items():
        if word in keywords or any(k in word for k in keywords):
            return cat
    return "违禁/敏感词"

def find_hits(text: str, words: set) -> list[dict]:
    """
    在文案中查找所有命中的词，返回列表。
    同时检测词库词 + 内置平台限流词。
    每条包含: word, positions, category, source
    """
    # 合并词库词 + 内置词
    all_check: list[tuple[str, str]] = []  # (word, source)
    for word in words:
        all_check.append((word, "词库"))
    for cat, cat_words in BUILTIN_RISK_WORDS.items():
        for word in cat_words:
            all_check.append((word, cat))

    # 按词长从长到短排序，优先匹配长词
    all_check.sort(key=lambda x: len(x[0]), reverse=True)

    hits = []
    found_words = set()

    for word, source in all_check:
        if word in found_words:
            continue
        positions = [m.span() for m in re.finditer(re.escape(word), text, re.IGNORECASE)]
        if positions:
            cat = source if source != "词库" else categorize_word(word)
            hits.append({
                "word": word,
                "positions": positions,
                "category": cat,
                "source": source,
                "count": len(positions),
            })
            found_words.add(word)

    # 按出现位置排序
    hits.sort(key=lambda h: h["positions"][0][0])
    return hits

def highlight_text(text: str, hits: list[dict]) -> str:
    """在命中位置插入标记（用于终端显示）"""
    if not hits:
        return text
    # 收集所有命中区间
    spans = []
    for h in hits:
        for start, end in h["positions"]:
            spans.append((start, end))
    spans.sort()

    result = []
    prev = 0
    for start, end in spans:
        result.append(text[prev:start])
        result.append(f"【{text[start:end]}】")
        prev = end
    result.append(text[prev:])
    return "".join(result)

def get_context(text: str, start: int, end: int, window: int = 10) -> str:
    """截取命中词的上下文"""
    ctx_start = max(0, start - window)
    ctx_end = min(len(text), end + window)
    prefix = "…" if ctx_start > 0 else ""
    suffix = "…" if ctx_end < len(text) else ""
    return f"{prefix}{text[ctx_start:start]}【{text[start:end]}】{text[end:ctx_end]}{suffix}"

def format_result(text: str, hits: list[dict]) -> str:
    lines = []

    if not hits:
        lines.append("✅ 检测通过 — 未发现违禁词/敏感词")
        lines.append(f"📊 检测字数: {len(text)} 字")
        return "\n".join(lines)

    # 分类
    forbidden = [h for h in hits if h["source"] == "词库"]
    platform  = [h for h in hits if "平台限流词" in h["category"]]
    adwords   = [h for h in hits if "广告极限词" in h["category"]]
    medical   = [h for h in hits if "医疗" in h["category"]]

    lines.append(f"🚨 发现 {len(hits)} 个风险词，建议修改后再发布\n")

    if forbidden:
        lines.append("🔴 违禁词（高风险，必改）:")
        for h in forbidden:
            ctx = get_context(text, *h["positions"][0])
            times = f"（出现{h['count']}次）" if h["count"] > 1 else ""
            lines.append(f"   ▸ {h['word']}{times}  [{h['category']}]")
            lines.append(f"     上下文: {ctx}")

    if platform:
        lines.append("\n🟠 平台限流词（建议替换，影响流量）:")
        for h in platform:
            ctx = get_context(text, *h["positions"][0])
            times = f"（出现{h['count']}次）" if h["count"] > 1 else ""
            lines.append(f"   ▸ {h['word']}{times}")
            lines.append(f"     上下文: {ctx}")

    if adwords:
        lines.append("\n🟡 广告极限词（广告法风险）:")
        for h in adwords:
            ctx = get_context(text, *h["positions"][0])
            times = f"（出现{h['count']}次）" if h["count"] > 1 else ""
            lines.append(f"   ▸ {h['word']}{times}")
            lines.append(f"     上下文: {ctx}")

    if medical:
        lines.append("\n🟡 医疗违禁词（广告法）:")
        for h in medical:
            ctx = get_context(text, *h["positions"][0])
            lines.append(f"   ▸ {h['word']}  上下文: {ctx}")

    lines.append("\n── 标注后文案 ──")
    lines.append(highlight_text(text, hits))
    lines.append(f"\n📊 检测字数: {len(text)} 字 | 风险词: {len(hits)} 个")
    return "\n".join(lines)


def main():
    # 处理特殊参数
    if "--status" in sys.argv:
        s = status()
        print(f"词库状态:")
        print(f"  最后更新: {s['last_update']}")
        print(f"  词条数量: {s['word_count']:,}")
        print(f"  词库文件: {s['words_file']}")
        print(f"  今日需更新: {'是' if s['needs_update'] else '否'}")
        return

    if "--update" in sys.argv:
        from update_words import update_words as _update_words
        _update_words(force=True)
        return

    # 读取输入文案
    if len(sys.argv) >= 3 and sys.argv[1] == "-f":
        with open(sys.argv[2], "r", encoding="utf-8") as f:
            content = f.read()
    elif len(sys.argv) >= 2 and not sys.argv[1].startswith("--"):
        content = " ".join(sys.argv[1:])
    elif not sys.stdin.isatty():
        content = sys.stdin.read()
    else:
        print("用法: python3 check.py \"文案内容\"")
        print("      python3 check.py -f input.txt")
        print("      python3 check.py --update    # 强制更新词库")
        print("      python3 check.py --status    # 查看词库状态")
        sys.exit(1)

    content = content.strip()
    if not content:
        print("❌ 内容为空")
        sys.exit(1)

    # 每天第一次自动更新词库
    if needs_update():
        update_words()
        print()

    # 加载词库
    words = load_words()
    if not words:
        print("⚠️  词库为空，请先运行: python3 check.py --update")
        sys.exit(1)

    # 检测
    hits = find_hits(content, words)
    print(format_result(content, hits))


if __name__ == "__main__":
    main()


path: scripts/update_words.py
content: #!/usr/bin/env python3
"""
词库更新脚本 - 从多个 GitHub 开源词库拉取最新内容合并到本地
每天第一次使用时自动触发

词库来源:
  - konsheng/Sensitive-lexicon (广告、政治、暴恐、色情、涉枪涉爆等)
  - bigdata-labs/sensitive-stop-words (广告、政治、色情、涉枪涉爆)
  - jkiss/sensitive-words (广告、政治、色情)
"""

import os
import sys
import json
import urllib.request
import urllib.error
from datetime import date
from pathlib import Path

# 词库保存目录（Skill 所在目录的 data/ 子目录）
SKILL_DIR = Path(__file__).parent.parent
DATA_DIR = SKILL_DIR / "data"
STATE_FILE = DATA_DIR / ".update_state.json"
WORDS_FILE = DATA_DIR / "sensitive_words.txt"

# 上游 raw 文件地址
SOURCES = [
    # konsheng/Sensitive-lexicon
    ("konsheng/Sensitive-lexicon", "广告类型", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E5%B9%BF%E5%91%8A%E7%B1%BB%E5%9E%8B.txt"),
    ("konsheng/Sensitive-lexicon", "政治类型", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E6%94%BF%E6%B2%BB%E7%B1%BB%E5%9E%8B.txt"),
    ("konsheng/Sensitive-lexicon", "暴恐词库", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E6%9A%B4%E6%81%90%E8%AF%8D%E5%BA%93.txt"),
    ("konsheng/Sensitive-lexicon", "色情词库", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E8%89%B2%E6%83%85%E8%AF%8D%E5%BA%93.txt"),
    ("konsheng/Sensitive-lexicon", "涉枪涉爆", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E6%B6%89%E6%9E%AA%E6%B6%89%E7%88%86.txt"),
    ("konsheng/Sensitive-lexicon", "其他词库", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E5%85%B6%E4%BB%96%E8%AF%8D%E5%BA%93.txt"),
    ("konsheng/Sensitive-lexicon", "补充词库", "https://raw.githubusercontent.com/konsheng/Sensitive-lexicon/main/Vocabulary/%E8%A1%A5%E5%85%85%E8%AF%8D%E5%BA%93.txt"),
    # bigdata-labs/sensitive-stop-words
    ([REDACTED_SECRET], "广告", "https://raw.githubusercontent.com/bigdata-labs/sensitive-stop-words/master/%E5%B9%BF%E5%91%8A.txt"),
    ([REDACTED_SECRET], "政治类", "https://raw.githubusercontent.com/bigdata-labs/sensitive-stop-words/master/%E6%94%BF%E6%B2%BB%E7%B1%BB.txt"),
    ([REDACTED_SECRET], "色情类", "https://raw.githubusercontent.com/bigdata-labs/sensitive-stop-words/master/%E8%89%B2%E6%83%85%E7%B1%BB.txt"),
    ([REDACTED_SECRET], "涉枪涉爆违法信息关键词", "https://raw.githubusercontent.com/bigdata-labs/sensitive-stop-words/master/%E6%B6%89%E6%9E%AA%E6%B6%89%E7%88%86%E8%BF%9D%E6%B3%95%E4%BF%A1%E6%81%AF%E5%85%B3%E9%94%AE%E8%AF%8D.txt"),
    # jkiss/sensitive-words
    ("jkiss/sensitive-words", "广告", "https://raw.githubusercontent.com/jkiss/sensitive-words/master/%E5%B9%BF%E5%91%8A.txt"),
    ("jkiss/sensitive-words", "政治类", "https://raw.githubusercontent.com/jkiss/sensitive-words/master/%E6%94%BF%E6%B2%BB%E7%B1%BB.txt"),
    ("jkiss/sensitive-words", "色情类", "https://raw.githubusercontent.com/jkiss/sensitive-words/master/%E8%89%B2%E6%83%85%E7%B1%BB.txt"),
]

def load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text())
        except Exception:
            pass
    return {}

def save_state(state: dict):
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))

def needs_update() -> bool:
    """判断今天是否还没更新过"""
    state = load_state()
    return state.get("last_update") != str(date.today())

def fetch_words(url: str) -> set:
    """从 URL 拉取词库，自动检测编码，返回词条集合"""
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read()

        # 自动检测编码（支持 UTF-16 BOM / UTF-8 BOM / GBK）
        if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
            content = raw.decode("utf-16", errors="ignore")
        elif raw[:3] == b"\xef\xbb\xbf":
            content = raw.decode("utf-8-sig", errors="ignore")
        else:
            # 优先 utf-8，失败降级 gbk
            try:
                content = raw.decode("utf-8", errors="strict")
            except UnicodeDecodeError:
                content = raw.decode("gbk", errors="ignore")

        words = set()
        for line in content.splitlines():
            # 去掉注释、空行、逗号（bigdata-labs 词库有尾随逗号）
            word = line.strip().rstrip(",")
            if word and not word.startswith("#") and len(word) >= 2:
                # 过滤掉全 ASCII 乱码行（UTF-16 解码残留）
                if any("\u4e00" <= c <= "\u9fff" for c in word) or any(c.isalpha() for c in word):
                    words.add(word)
        return words
    except urllib.error.URLError as e:
        print(f"  ⚠️  拉取失败: {url} → {e}", file=sys.stderr)
        return set()

def update_words(force: bool = False) -> bool:
    """
    更新词库。返回 True 表示更新了，False 表示跳过（今天已更新）。
    force=True 强制更新。
    """
    if not force and not needs_update():
        return False

    DATA_DIR.mkdir(parents=True, exist_ok=True)
    print("🔄 正在更新违禁词库...")

    all_words: set = set()
    ok_count = 0

    for repo, name, url in SOURCES:
        print(f"  📥 {repo} / {name}...", end=" ", flush=True)
        words = fetch_words(url)
        if words:
            all_words |= words
            print(f"{len(words)} 词")
            ok_count += 1
        else:
            print("跳过")

    # 保留旧词库（网络失败时不清空）
    if ok_count == 0:
        print("⚠️  所有词库拉取失败，保留本地缓存", file=sys.stderr)
        save_state({"last_update": str(date.today()), "word_count": _count_local()})
        return False

    # 写入合并后的词库（排序去重）
    sorted_words = sorted(all_words)
    WORDS_FILE.write_text("\n".join(sorted_words) + "\n", encoding="utf-8")

    state = {"last_update": str(date.today()), "word_count": len(sorted_words)}
    save_state(state)
    print(f"✅ 词库更新完成：共 {len(sorted_words):,} 词（来自 {ok_count}/{len(SOURCES)} 个来源）")
    return True

def _count_local() -> int:
    if WORDS_FILE.exists():
        return sum(1 for line in WORDS_FILE.read_text().splitlines() if line.strip())
    return 0

def load_words() -> set:
    """加载本地词库，返回集合"""
    if not WORDS_FILE.exists():
        return set()
    return set(
        line.strip()
        for line in WORDS_FILE.read_text(encoding="utf-8").splitlines()
        if line.strip()
    )

def status() -> dict:
    state = load_state()
    return {
        "last_update": state.get("last_update", "从未更新"),
        "word_count": state.get("word_count", _count_local()),
        "words_file": str(WORDS_FILE),
        "needs_update": needs_update(),
    }

if __name__ == "__main__":
    force = "--force" in sys.argv
    updated = update_words(force=force)
    if not updated and not force:
        s = status()
        print(f"ℹ️  今日已更新（{s['last_update']}），词库共 {s['word_count']:,} 词。跳过更新。")
        print("  使用 --force 强制重新拉取")


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