Skill: if530770/gzh-prohibited-word (version 1.0.1)

--- name: gzh-prohibited-word description: 基于官方违禁词库实时同步，覆盖广告法、医疗美容、金融风险等10+类目，支持文本、文件、图片多形式检测，快速输出违禁词标记+替换建议+优化文案，帮你避开封号限流风险。 version: 1.0.0 tags: - wechat-official-account - compliance - content-moderation - python-script dependency: python: - python-docx==1.1.0 - beautifulsoup4==4.12.3 - playwright==1.58.0 system: - playwright install chromium --- # 公众号违禁词检测 > 推文发不出去、被删改、被封号？可能就是一两个词踩了线。 --- ## 你会得到什么 **把你的公众号文案扔进来，快速告诉你哪里踩线、怎么改才安全。** 🔍 **违禁词标记** —— 原文中命中词直接标红，一眼看到风险点，同时汇总类型与数量 💡 **替换建议表** —— 每个违禁词配一个结合上下文的替换词 + 更换理由，帮你"改通顺"而不是机械替换 ✏️ **优化文案** —— 直接给你一份替换后的可发布版本，替换词标蓝标记，复制粘贴就能用 📎 **优化文案文件** —— 自动生成纯文本文件，点击即可下载保存 📏 **长文案分批检测** —— 超过 3000 字自动提醒，支持分批检测后合并结果 ## 怎么用 四种输入方式，挑你最顺手的： **💬 直接贴文案** > 帮我看下这篇公众号草稿有没有违禁词：这款美白神器真的太有效了…… **📎 上传文件** > 支持 TXT、DOC、DOCX 文件，直接上传自动读取检测 **🖼️ 上传图片** > 上传海报截图/推文截图，自动提取图中文字进行检测（仅提取文字，不分析图片内容） **🌐 粘贴链接** > 帮我检测这个网页：https://example.com/article **注意**：平台固定为公众号，无需指定。PDF 不支持，需转为图片或文本文件后上传。 ## 谁适合用 - 📢 **公众号运营** —— 推文发布前自查，排除极限词、禁宣用语，避免被删改或封号。如：*"帮我检查这篇推文有没有违规词"* - ✍️ **新媒体编辑** —— 多篇短文或单篇长文分段合规预审。如：*"这几篇文章帮我批量过一遍"* - 🛒 **品牌/市场团队** —— 活动落地页、H5 页面文案抽查。如：*"检测这个网页内容有没有违规"* - 🎨 **设计师协作** —— 海报/推文图上的文字合规检查。如：*"这个海报上的文案帮我查一下"* ## 使用须知 - **覆盖类目广**：广告法极限词、医疗美容、金融风险、教育等 10+ 违禁词类目 - **内容长度**：建议单次 ≤ 3000 字，效果最佳；超过 3000 字会询问你是否分批；超过 10000 字暂不支持 - **支持格式**：纯文本、TXT、DOC、DOCX、图片（提取文字）、网页链接 - **暂不支持**：PDF（请转为图片或文本文件） - **数据隐私**：文案将通过加密连接发送至对接检测服务进行匹配，不在本地存储。如不希望外传，请提前告知 - **英文内容友好**：内置英文误匹配过滤，不会把正常英文单词中的片段误判为违禁词 - **网络容错**：内置自动重试机制，偶尔网络波动无需手动重试 --- > **完整执行规程**：调用脚本、格式化输出前，**必须先读取并严格遵循** [`references/core_workflow.md`](references/core_workflow.md) **全文**。 ### 触发条件 满足以下任一即激活本 Skill： - 用户提到「公众号违禁词」「公众号敏感词」「推文合规」「广告法违禁词」「封号风险」 - 用户要求检测公众号推文、草稿、活动页、海报文字等内容安全性 - 用户上传文件/图片/链接并提到公众号相关的检测需求 ### 执行铁律 1. **禁止编造结果** —— 仅以脚本返回 JSON 为准，不得臆造违禁词或替换建议 2. **禁止输出原始 JSON** —— 必须解析后填入输出模板 3. **禁止多余寒暄** —— 仅输出三个板块（🔍检测结果、💡修改建议、✏️优化文案），无开场白/结束语 4. **分批/字数超限必须暂停** —— 发出提示后立即停止，等待用户明确回复 1/2/3 后再继续 5. **图片仅限文字** —— 禁止获取图片的视觉风格、布局、色彩、人物等任何非文字信息 6. **必须交付文件** —— 三板块输出完成后，必须将纯文本优化文案写入 `./公众号_优化文案_{随机6位数字}.txt` 并以卡片形式发送给用户，禁止省略 ### 执行流程 完整步骤、话术模板与切割规则详见 [`references/core_workflow.md`](references/core_workflow.md)，速览如下： 1. **识别输入类型** → 文本/文件(TXT/DOC/DOCX)/网页/图片，分别处理 2. **字数判断** → ≤3000 直接检测；3001~10000 暂停询问（1=单次/2=分批/3=取消）；>10000 中断 3. **调用脚本** → `python {skill_dir}/scripts/check_sensitive_words.py --content="..."` 或 `--file` 或 `--url` 4. **模板输出** → 按核心工作流三板块模板排版，违禁词用 `<span style="color:red">` 标红，替换词用 `<span style="color:blue">` 标蓝 5. **交付文件** → 写入 `./公众号_优化文案_{随机6位数字}.txt` 并发送文件卡片 #### 命令速查 | 输入形态 | 助手动作 | |---|---| | 纯文本 | 计算字数 → 字数闸门 → `--content="..."` → 三板块输出 → 写文件 | | 上传 TXT/DOC/DOCX | `--file=path --extract-only` 查字数 → 按规则检测 → 三板块输出 → 写文件 | | 上传图片 | 仅提取图中文字 → 计算字数 → `--content="提取文字"` → 三板块输出 → 写文件 | | 粘贴 `https://...` | `--url=... --extract-only` 查字数 → 按规则检测 → 三板块输出 → 写文件 | | 超长文案 | 发出询问语，**收到 1/2/3 后再继续** | `--content`、`--file`、`--url` 三者互斥。平台已硬编码为公众号。 #### 异常处理 | 异常场景 | 用户话术 | |---|---| | 文件/网页提取失败 | 「内容提取失败，请检查文件格式或网址是否可访问」 | | PDF 文件 | 「不支持 PDF 文件，请转为图片或文本文件后重新上传」 | | API 超时或网络异常 | 「检测服务暂时不可用，已自动重试仍失败，请稍后重试」 | | API 返回业务错误（code≠2000） | 「检测服务返回异常，请稍后重试」 | | 未配置检测接口 | 「检测接口未配置，请设置 GZH_SENSITIVE_WORD_API_HOST 或参考 gzh_sensitive_word_api.example.json 配置」 | | 依赖缺失（python-docx/playwright 等） | 提示用户安装对应依赖后重试 |

path: scripts/check_sensitive_words.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import json
import os
import re
import socket
import ssl
import sys
import time
from pathlib import Path
from urllib.parse import urlencode, urlparse

try:
    from docx import Document
    from bs4 import BeautifulSoup
except ImportError as e:
    print(json.dumps({"status": "error", "error": f"缺少依赖库: {str(e)}"}, ensure_ascii=False))
    sys.exit(1)

# API单次查询最大字符数
MAX_CONTENT_LENGTH = 3000

# 内容总字数上限（超过此值直接提示用户手动分批，不执行检测）
MAX_TOTAL_LENGTH = 10000

# 违禁词检测 HTTPS API：不在仓库中内置第三方域名，由部署方通过环境变量或本地 JSON 配置（见 _resolve_sensitive_word_api_config）
DEFAULT_API_PATH = "/story/cozeSkill/sensitiveWordSearch"
DEFAULT_API_PORT = 443
API_CONFIG_FILENAME = "gzh_sensitive_word_api.json"
API_CONFIG_EXAMPLE_FILENAME = "gzh_sensitive_word_api.example.json"


def _load_api_config_dict(data):
    """从 dict 解析 API 配置；失败返回 (None, error_message)。"""
    if not isinstance(data, dict):
        return None, "API 配置必须是 JSON 对象"
    host = str(data.get("host", "")).strip()
    if not host:
        return None, '配置中缺少非空 "host" 字段'
    try:
        port = int(data.get("port", DEFAULT_API_PORT) or DEFAULT_API_PORT)
    except (TypeError, ValueError):
        return None, '"port" 必须是整数'
    path = str(data.get("path") or DEFAULT_API_PATH).strip() or DEFAULT_API_PATH
    host_header = str(data.get("host_header") or host).strip() or host
    return {"host": host, "port": port, "path": path, "host_header": host_header}, None


def _resolve_sensitive_word_api_config():
    """
    解析检测 API 接入点（Host / Path 等），优先级：
    1) 环境变量 GZH_SENSITIVE_WORD_API_HOST（及可选 PORT、PATH、HOST_HEADER）
    2) 环境变量 GZH_SENSITIVE_WORD_API_CONFIG 指向的 JSON 文件路径
    3) 与本脚本同目录下的 gzh_sensitive_word_api.json

    不在代码或仓库中写死第三方域名，便于 Skill 开源分发与合规审计。
    """
    env_host = os.environ.get("GZH_SENSITIVE_WORD_API_HOST", "").strip()
    if env_host:
        try:
            port = int(os.environ.get("GZH_SENSITIVE_WORD_API_PORT", str(DEFAULT_API_PORT)) or DEFAULT_API_PORT)
        except ValueError:
            return None, "环境变量 GZH_SENSITIVE_WORD_API_PORT 必须是整数"
        path = (os.environ.get("GZH_SENSITIVE_WORD_API_PATH") or DEFAULT_API_PATH).strip() or DEFAULT_API_PATH
        host_header = os.environ.get("GZH_SENSITIVE_WORD_API_HOST_HEADER", "").strip() or env_host
        return {"host": env_host, "port": port, "path": path, "host_header": host_header}, None

    script_dir = Path(__file__).resolve().parent
    config_path = None
    env_config = os.environ.get("GZH_SENSITIVE_WORD_API_CONFIG", "").strip()
    if env_config:
        config_path = Path(env_config).expanduser()
    else:
        config_path = script_dir / API_CONFIG_FILENAME

    if config_path.is_file():
        try:
            with open(config_path, encoding="utf-8") as f:
                data = json.load(f)
        except (OSError, json.JSONDecodeError) as e:
            return None, f"读取 API 配置文件失败 ({config_path}): {e}"
        cfg, err = _load_api_config_dict(data)
        if err:
            return None, f"{config_path.name}: {err}"
        return cfg, None

    example_name = API_CONFIG_EXAMPLE_FILENAME
    return None, (
        "未配置违禁词检测 API：本脚本不在仓库中内置第三方服务器域名。"
        "请设置环境变量 GZH_SENSITIVE_WORD_API_HOST，"
        f"或设置 GZH_SENSITIVE_WORD_API_CONFIG 指向 JSON 配置文件，"
        f"或将 {API_CONFIG_FILENAME} 放在脚本同目录（可参考 {example_name}）。"
    )


def _https_get_no_sni(host, port, path, params_dict, host_header, timeout=30, max_retries=2):
    """
    使用原生 socket + ssl 手动发送 HTTPS GET 请求，不发送 SNI（wrap_socket 不传 server_hostname）。
    通过 Host 头指定目标站点，绕过 CDN/ICP 基于 SNI 的拦截。
    支持自动重试：5xx、超时、连接重置等场景自动重试。

    Args:
        host: 目标服务器IP或域名
        port: 端口号
        path: 请求路径
        params_dict: Query参数字典
        host_header: HTTP Host头的值
        timeout: 超时秒数
        max_retries: 最大重试次数（不含首次请求）

    Returns:
        dict: {"status_code": int, "body": str, "headers": dict}
    """
    last_error = None

    for attempt in range(max_retries + 1):
        try:
            result = _do_https_get(host, port, path, params_dict, host_header, timeout)

            # 5xx 自动重试
            if result["status_code"] >= 500 and attempt < max_retries:
                time.sleep(1 * (attempt + 1))
                continue

            return result

        except (socket.timeout, ConnectionResetError, ConnectionRefusedError, OSError) as e:
            last_error = e
            if attempt < max_retries:
                time.sleep(1 * (attempt + 1))
                continue
            break

    # 所有重试耗尽，抛出最后一次异常
    if last_error:
        if isinstance(last_error, socket.timeout):
            raise Exception(f"连接超时，已重试{max_retries}次仍失败")
        elif isinstance(last_error, ConnectionResetError):
            raise Exception(f"连接被重置，已重试{max_retries}次仍失败")
        elif isinstance(last_error, ConnectionRefusedError):
            raise Exception(f"连接被拒绝，已重试{max_retries}次仍失败")
        else:
            raise Exception(f"网络异常: {str(last_error)}，已重试{max_retries}次仍失败")


def _do_https_get(host, port, path, params_dict, host_header, timeout):
    """单次HTTPS GET请求执行"""
    query_string = urlencode(params_dict)
    full_path = f"{path}?{query_string}" if query_string else path

    # 构建原始HTTP请求
    raw_request = (
        f"GET {full_path} HTTP/1.1\r\n"
        f"Host: {host_header}\r\n"
        f"Connection: close\r\n"
        f"Accept: application/json, */*\r\n"
        f"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n"
        f"\r\n"
    ).encode("utf-8")

    # 创建socket连接
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)

    try:
        sock.connect((host, port))

        # wrap_socket 不传 server_hostname → 不发送 SNI
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        ssl_sock = context.wrap_socket(sock)  # 故意不传 server_hostname

        # 发送请求
        ssl_sock.sendall(raw_request)

        # 接收响应
        response_data = b""
        while True:
            try:
                chunk = ssl_sock.recv(4096)
                if not chunk:
                    break
                response_data += chunk
            except socket.timeout:
                break

        ssl_sock.close()

        # 解析响应
        response_text = response_data.decode("utf-8", errors="replace")

        # 分离头部和正文
        header_end = response_text.find("\r\n\r\n")
        if header_end == -1:
            return {"status_code": 0, "body": response_text, "headers": {}}

        header_section = response_text[:header_end]
        body = response_text[header_end + 4:]

        # 解析状态行
        status_line = header_section.split("\r\n")[0]
        status_code = int(status_line.split(" ")[1]) if len(status_line.split(" ")) >= 2 else 0

        # 解析响应头
        headers_dict = {}
        for line in header_section.split("\r\n")[1:]:
            if ":" in line:
                key, value = line.split(":", 1)
                headers_dict[key.strip().lower()] = value.strip()

        # 处理Transfer-Encoding: chunked
        if headers_dict.get("transfer-encoding", "").lower() == "chunked":
            body = _decode_chunked(body)

        # 处理Content-Encoding: gzip
        if headers_dict.get("content-encoding", "").lower() == "gzip":
            import gzip
            try:
                body = gzip.decompress(body.encode("latin-1")).decode("utf-8", errors="replace")
            except Exception:
                pass

        return {"status_code": status_code, "body": body, "headers": headers_dict}

    except socket.gaierror as e:
        raise Exception(f"DNS解析失败: {str(e)}")
    except socket.timeout as e:
        raise e
    except ConnectionResetError as e:
        raise e
    except ConnectionRefusedError as e:
        raise e
    except Exception as e:
        raise Exception(f"请求异常: {str(e)}")
    finally:
        sock.close()


def _decode_chunked(body):
    """解码chunked传输编码"""
    decoded = ""
    remaining = body
    while remaining:
        chunk_end = remaining.find("\r\n")
        if chunk_end == -1:
            break
        size_str = remaining[:chunk_end].strip()
        if not size_str:
            break
        try:
            chunk_size = int(size_str, 16)
        except ValueError:
            break
        if chunk_size == 0:
            break
        remaining = remaining[chunk_end + 2:]
        decoded += remaining[:chunk_size]
        remaining = remaining[chunk_size + 2:]
    return decoded


def _do_https_post(host, port, path, body_data, content_type, host_header, timeout):
    """单次HTTPS POST请求执行"""
    body_bytes = body_data.encode("utf-8")

    # 构建原始HTTP请求
    raw_request = (
        f"POST {path} HTTP/1.1\r\n"
        f"Host: {host_header}\r\n"
        f"Connection: close\r\n"
        f"Accept: application/json, */*\r\n"
        f"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n"
        f"Content-Type: {content_type}\r\n"
        f"Content-Length: {len(body_bytes)}\r\n"
        f"\r\n"
    ).encode("utf-8") + body_bytes

    # 创建socket连接
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)

    try:
        sock.connect((host, port))

        # wrap_socket 不传 server_hostname → 不发送 SNI
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        ssl_sock = context.wrap_socket(sock)  # 故意不传 server_hostname

        # 发送请求
        ssl_sock.sendall(raw_request)

        # 接收响应
        response_data = b""
        while True:
            try:
                chunk = ssl_sock.recv(4096)
                if not chunk:
                    break
                response_data += chunk
            except socket.timeout:
                break

        ssl_sock.close()

        # 解析响应
        response_text = response_data.decode("utf-8", errors="replace")

        # 分离头部和正文
        header_end = response_text.find("\r\n\r\n")
        if header_end == -1:
            return {"status_code": 0, "body": response_text, "headers": {}}

        header_section = response_text[:header_end]
        body = response_text[header_end + 4:]

        # 解析状态行
        status_line = header_section.split("\r\n")[0]
        status_code = int(status_line.split(" ", 2)[1]) if len(status_line.split(" ", 2)) >= 2 else 0

        # 解析响应头
        headers_dict = {}
        for line in header_section.split("\r\n")[1:]:
            if ":" in line:
                key, value = line.split(":", 1)
                headers_dict[key.strip().lower()] = value.strip()

        # 处理分块传输
        if headers_dict.get("transfer-encoding", "").lower() == "chunked":
            body = _decode_chunked(body)

        # 处理Content-Encoding: gzip
        if headers_dict.get("content-encoding", "").lower() == "gzip":
            import gzip
            try:
                body = gzip.decompress(body.encode("latin-1")).decode("utf-8", errors="replace")
            except Exception:
                pass

        return {"status_code": status_code, "body": body, "headers": headers_dict}

    except socket.gaierror as e:
        raise Exception(f"DNS解析失败: {str(e)}")
    except socket.timeout as e:
        raise e
    except ConnectionResetError as e:
        raise e
    except ConnectionRefusedError as e:
        raise e
    except Exception as e:
        raise Exception(f"请求异常: {str(e)}")
    finally:
        sock.close()


def _https_post_no_sni(host, port, path, body_data, content_type, host_header, timeout=30, max_retries=2):
    """
    使用原生 socket + ssl 手动发送 HTTPS POST 请求，不发送 SNI（wrap_socket 不传 server_hostname）。
    通过 Host 头指定目标站点，绕过 CDN/ICP 基于 SNI 的拦截。
    自动重试：遇到5xx、超时、连接重置等场景最多重试max_retries次。
    """
    last_error = None

    for attempt in range(max_retries + 1):
        try:
            result = _do_https_post(host, port, path, body_data, content_type, host_header, timeout)

            # 5xx错误自动重试
            if result["status_code"] >= 500 and attempt < max_retries:
                time.sleep(1 * (attempt + 1))
                continue

            return result

        except (socket.timeout, ConnectionResetError, ConnectionRefusedError, OSError) as e:
            last_error = e
            if attempt < max_retries:
                time.sleep(1 * (attempt + 1))
                continue
            break

    # 所有重试耗尽，抛出最后一次异常
    if last_error:
        if isinstance(last_error, socket.timeout):
            raise Exception(f"连接超时，已重试{max_retries}次仍失败")
        elif isinstance(last_error, ConnectionResetError):
            raise Exception(f"连接被重置，已重试{max_retries}次仍失败")
        elif isinstance(last_error, ConnectionRefusedError):
            raise Exception(f"连接被拒绝，已重试{max_retries}次仍失败")
        else:
            raise Exception(f"网络异常: {str(last_error)}，已重试{max_retries}次仍失败")


def extract_from_file(file_path):
    """从文件中提取文本（支持DOC、DOCX、TXT等文本类型文件）"""
    if not os.path.exists(file_path):
        raise Exception(f"文件不存在: {file_path}")

    file_ext = os.path.splitext(file_path)[1].lower()

    if file_ext in ['.doc', '.docx']:
        doc = Document(file_path)
        text = ""
        for paragraph in doc.paragraphs:
            text += paragraph.text + "\n"
        return text.strip()
    elif file_ext == '.txt':
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            return f.read().strip()
    elif file_ext in ['.csv', '.md', '.log', '.json', '.xml', '.html', '.htm']:
        with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
            return f.read().strip()
    else:
        raise Exception(f"不支持的文件类型: {file_ext}，仅支持图片、TXT、DOC、DOCX等文本类型文件")


def extract_from_web(url):
    """
    从网页中提取文本。优先使用Playwright无头浏览器渲染JS后提取（支持SPA页面），
    若Playwright不可用则回退到socket+ssl方式。
    """
    if not url.startswith(('http://', 'https://')):
        url = 'https://' + url

    # 优先尝试Playwright（支持SPA/JS动态渲染页面）
    try:
        from playwright.sync_api import sync_playwright
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            page = browser.new_page()
            page.goto(url, timeout=30000)
            page.wait_for_timeout(3000)  # 等待JS渲染

            # 优先提取文章正文区域（常见选择器）
            article_selectors = [
                'article', '.article-content', '.article-body', '.article-detail',
                '.post-content', '.post-body', '.content-body', '.entry-content',
                '.rich_media_content', '#js_content', '.detail-content',
                '.article-content', '.news-content', '.text-content',
            ]
            text = None
            for selector in article_selectors:
                try:
                    el = page.query_selector(selector)
                    if el:
                        extracted = el.inner_text().strip()
                        if len(extracted) > 100:  # 正文区域通常较长
                            text = extracted
                            break
                except Exception:
                    continue

            # 无匹配正文区域时回退到body
            if not text:
                text = page.inner_text('body')

            browser.close()
            return text.strip()
    except Exception:
        pass  # Playwright不可用，回退到socket方式

    # 回退：socket+ssl无SNI方式（仅能提取静态HTML中的文本）
    parsed = urlparse(url)
    host = parsed.netloc
    port = 443 if parsed.scheme == 'https' else 80
    path = parsed.path or '/'
    if parsed.query:
        path += '?' + parsed.query

    response = _https_get_no_sni(
        host=host,
        port=port,
        path=path,
        params_dict={},
        host_header=host,
        timeout=30
    )

    if response["status_code"] >= 400:
        raise Exception(f"网页请求失败: HTTP {response['status_code']}")

    soup = BeautifulSoup(response["body"], 'html.parser')

    for tag in soup(["script", "style", "noscript"]):
        tag.decompose()

    text = soup.get_text(separator='\n', strip=True)
    return text.strip()


def check_sensitive_words(content):
    """
    调用公众号违禁词检测API（POST方式，参数通过请求体传递）

    Args:
        content: 待检测的文案内容

    Returns:
        dict: 包含检测结果和格式化HTML的字典
    """
    api_cfg, api_err = _resolve_sensitive_word_api_config()
    if api_err:
        return {
            "status": "error",
            "platform": "公众号",
            "original_content": content,
            "error": api_err,
        }

    # 内容长度校验
    if len(content) > MAX_CONTENT_LENGTH:
        return {
            "status": "error",
            "platform": "公众号",
            "original_content": content[:200] + "...",
            "error": f"文案内容过长（{len(content)}字符），单次上限为{MAX_CONTENT_LENGTH}字符，请缩减后重试"
        }

    # POST请求参数（JSON格式）
    params = {
        "content": content,
        "platform": "公众号",
        "source": "公众号违禁词查询-ClawHub"
    }
    body_data = json.dumps(params, ensure_ascii=False)

    try:
        # 发起HTTPS POST请求（不发送SNI，参数通过请求体传递，自动重试）
        response = _https_post_no_sni(
            host=api_cfg["host"],
            port=api_cfg["port"],
            path=api_cfg["path"],
            body_data=body_data,
            content_type="application/json; charset=utf-8",
            host_header=api_cfg["host_header"],
            timeout=30,
            max_retries=2
        )

        status_code = response["status_code"]
        resp_body = response["body"]

        if status_code >= 400:
            raise Exception(f"HTTP请求失败: {status_code}, {resp_body[:500]}")

        # 解析响应
        resp = json.loads(resp_body)

        # API返回格式: {"code": 2000, "data": {...}, "msg": "成功"}
        api_code = resp.get("code", 0)
        if api_code != 2000:
            raise Exception(f"API业务错误: code={api_code}, msg={resp.get('msg', '未知')}")

        api_data = resp.get("data") or {}

        # 从API返回的content中提取违禁词列表
        # API格式: content中用<span class="banned-word">或<span class="sensitive-word">标记违禁词
        api_content = api_data.get("content") or ""
        original_content = api_data.get("originalContent") or content
        prohibited_words_type = api_data.get("prohibitedWordsType") or []

        # 提取违禁词文本（兼容banned-word/sensitive-word/industry-banned-word三种类名，去重）
        sensitive_words = list(dict.fromkeys(
            re.findall(r'<span class="(?:banned-word|sensitive-word|industry-banned-word)">(.*?)</span>', api_content)
        ))

        # 将所有违禁词标签样式统一替换为color:red样式
        html_content = re.sub(
            r'<span class="(?:banned-word|sensitive-word|industry-banned-word)">',
            '<span style="color:red">',
            api_content
        )

        # 过滤英文误匹配：API会将英文单词内部子串误标为违禁词（如"Glasswing"中的"ass"）
        # 从原文提取所有英文单词，若违禁词是某个英文单词的子串则视为误匹配
        english_words = re.findall(r'[A-Za-z]+', original_content)
        false_positive_words = set()
        for ew in english_words:
            for sw in sensitive_words:
                if sw.isascii() and sw.isalpha() and sw.lower() in ew.lower() and sw.lower() != ew.lower():
                    false_positive_words.add(sw)

        # 从sensitive_words中移除误匹配项
        sensitive_words = [w for w in sensitive_words if w not in false_positive_words]

        # 从html_content中移除误匹配的span标签，还原为纯文本
        for fpw in false_positive_words:
            escaped = re.escape(fpw)
            html_content = re.sub(
                rf'<span style="color:red">{escaped}</span>',
                fpw,
                html_content
            )

        result = {
            "status": "success",
            "platform": "公众号",
            "original_content": original_content,
            "sensitive_words": sensitive_words,
            "prohibited_words_type": prohibited_words_type,
            "word_count": len(sensitive_words),
            "html_content": html_content
        }

        return result

    except json.JSONDecodeError as e:
        return {
            "status": "error",
            "platform": "公众号",
            "original_content": content,
            "error": f"响应解析失败: {str(e)}"
        }
    except Exception as e:
        return {
            "status": "error",
            "platform": "公众号",
            "original_content": content,
            "error": f"处理失败: {str(e)}"
        }


def main():
    parser = argparse.ArgumentParser(description="公众号违禁词检测工具")

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--content", help="直接传入文案文本")
    group.add_argument("--file", help="文件路径（支持TXT、DOC、DOCX等文本类型文件）")
    group.add_argument("--url", help="网页地址")

    parser.add_argument("--extract-only", action="store_true", help="仅提取文本不检测，返回提取的文本内容和长度")

    args = parser.parse_args()

    # 获取文本内容
    try:
        if args.content:
            text = args.content
        elif args.file:
            text = extract_from_file(args.file)
        elif args.url:
            text = extract_from_web(args.url)
        else:
            print(json.dumps({"status": "error", "error": "请指定输入方式：--content、--file 或 --url"}, ensure_ascii=False))
            return
    except Exception as e:
        print(json.dumps({"status": "error", "error": f"文本提取失败: {str(e)}"}, ensure_ascii=False))
        return

    if not text:
        print(json.dumps({"status": "error", "error": "未提取到文本内容"}, ensure_ascii=False))
        return

    # 仅提取模式：返回文本内容和长度，不调用检测API
    if args.extract_only:
        print(json.dumps({"status": "extracted", "content": text, "length": len(text)}, ensure_ascii=False))
        return

    # 调用违禁词检测
    result = check_sensitive_words(text)
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/gzh_sensitive_word_api.example.json
content: host: your-api-host.example.com
port: 443
path: /story/cozeSkill/sensitiveWordSearch
host_header: your-api-host.example.com

path: references/core_workflow.md
content: # 公众号违禁词查询 — 核心流程与输出规范

> 本文档为 Skill 的功能核心说明；执行检测、分批询问、结果呈现与文件输出时均以本文为准。

## 数据源说明

本 Skill 的违禁词匹配基于**站长之家官方违禁词库**，通过独立后端 API 完成检测。执行脚本时，会将待检测内容通过 HTTPS POST 发往对接服务端（JSON），**不在本地计算词库**。

| 项目 | 说明 |
|---|---|
| **数据来源** | 站长之家官方违禁词库 |
| **API 配置方式** | 环境变量 `GZH_SENSITIVE_WORD_API_HOST`，或 `GZH_SENSITIVE_WORD_API_CONFIG`，或 `scripts/gzh_sensitive_word_api.json` |
| **请求体字段** | `content`（待检测文案）、`platform`（固定为"公众号"）、`source`（来源标识） |
| **配置示例** | 见 `scripts/gzh_sensitive_word_api.example.json` |

**对话指引**：首次为用户检测前，应用一两句话说明「文案将通过加密连接发送至检测服务」；若用户拒绝外传数据，则不应调用检测脚本。

## 任务目标
- 本 Skill 用于:检测用户输入的文案、上传的文件（图片/TXT/DOC/DOCX等文本类型文件）或网页地址中是否包含公众号违禁词，将违禁词标红显示，提供替换建议和仅替换违禁词后的文案
- 能力包含:支持文本、文件、网页三种输入方式的公众号违禁词查询，一站式检测无需多次调用脚本
- 触发条件:用户输入文案内容、上传文件、提供网页地址，需要检测公众号违禁词

## 前置准备
- 依赖说明:脚本依赖python-docx（Word文档提取）、beautifulsoup4（网页内容提取）、playwright（无头浏览器渲染JS动态网页，需执行`playwright install chromium`安装浏览器）
- API 接入说明（必选其一）:脚本**不在仓库中内置**第三方服务器域名，由部署方自行配置。① 设置环境变量 `GZH_SENSITIVE_WORD_API_HOST`（可选 `GZH_SENSITIVE_WORD_API_PORT`、`GZH_SENSITIVE_WORD_API_PATH`、`GZH_SENSITIVE_WORD_API_HOST_HEADER`）；或② 将 JSON 配置文件路径设为 `GZH_SENSITIVE_WORD_API_CONFIG`；或③ 在 `scripts/` 目录放置 `gzh_sensitive_word_api.json`（可参考同目录 `gzh_sensitive_word_api.example.json`）。未配置时脚本会返回明确错误提示，不会向未知地址发包。
- 非标准文件/文件夹准备:本地 API 配置文件（若采用 JSON 方式）见上；公开仓库默认已将 `scripts/gzh_sensitive_word_api.json` 列入 `.gitignore`

## 操作步骤
1. 智能体识别用户输入类型（文本/文件/网页），执行对应流程
   - **文本输入**：
     1. **先判断文案字数**：智能体必须先计算用户输入文案的字符数
     2. 若不超过3000字符，直接调用 `python scripts/check_sensitive_words.py --content="文案内容"` 检测
     3. 若超过3000字符且不超过10000字符，**必须暂停并询问用户**"⚠ 单次查询内容字数建议不超过1000字，现在已超过单次执行字数是否进行分批查询？\n回复1：执行单次查询（仅检测前3000字符）\n回复2：执行分批查询（检测全部内容）\n回复3：取消执行"**——必须等待用户回答后再继续，禁止未等用户回复就自行执行后续流程**——用户回复"1"则仅取前3000字符调用脚本检测，用户回复"2"则按3000字符分批调用 `python scripts/check_sensitive_words.py --content="文案内容"` 检测全部内容（分批时在自然断句处切割，避免截断句子），用户回复"3"则结束任务
     4. 若超过10000字符，**直接提示用户**"⚠ 当前内容字数超过10000字，查询将会消耗过多的时间和积分，强烈建议手动分批查询。"**并中断后续逻辑，不执行任何检测**
   - **文本类型文件（TXT/DOC/DOCX等）**：
     1. 先调用 `python scripts/check_sensitive_words.py --file=/path/to/file.txt --extract-only` 提取全部文字内容
     2. 若返回的 length 超过10000，**直接提示用户**"⚠ 当前内容字数超过10000字，查询将会消耗过多的时间和积分，强烈建议手动分批查询。"**并中断后续逻辑，不执行任何检测**；若超过3000且不超过10000，**必须暂停并询问用户**"⚠ 单次查询内容字数建议不超过1000字，现在已超过单次执行字数是否进行分批查询？\n回复1：执行单次查询（仅检测前3000字符）\n回复2：执行分批查询（检测全部内容）\n回复3：取消执行"**——必须等待用户回答后再继续，禁止未等用户回复就自行执行后续流程**——用户回复"1"则仅取前3000字符调用脚本检测，用户回复"2"则按3000字符分批调用 `python scripts/check_sensitive_words.py --content="..."` 检测全部内容，用户回复"3"则结束任务；若未超过3000字符，直接调用 `python scripts/check_sensitive_words.py --content="提取的内容"` 检测
   - **网页地址**：
     1. 先调用 `python scripts/check_sensitive_words.py --url=https://example.com --extract-only` 提取全部文字内容
     2. 若返回的 length 超过10000，**直接提示用户**"⚠ 当前内容字数超过10000字，查询将会消耗过多的时间和积分，强烈建议手动分批查询。"**并中断后续逻辑，不执行任何检测**；若超过3000且不超过10000，**必须暂停并询问用户**"⚠ 单次查询内容字数建议不超过1000字，现在已超过单次执行字数是否进行分批查询？\n回复1：执行单次查询（仅检测前3000字符）\n回复2：执行分批查询（检测全部内容）\n回复3：取消执行"**——必须等待用户回答后再继续，禁止未等用户回复就自行执行后续流程**——用户回复"1"则仅取前3000字符调用脚本检测，用户回复"2"则按3000字符分批调用 `python scripts/check_sensitive_words.py --content="..."` 检测全部内容，用户回复"3"则结束任务；若未超过3000字符，直接调用 `python scripts/check_sensitive_words.py --content="提取的内容"` 检测
   - **图片文件**：智能体先用read_image提取文字（仅提取图片中的文字内容，禁止识别图片的视觉风格描述），再用 --content 传入；若提取文字超过10000字符，**直接提示用户**"⚠ 当前内容字数超过10000字，查询将会消耗过多的时间和积分，强烈建议手动分批查询。"**并中断后续逻辑，不执行任何检测**；若超过3000且不超过10000字符，**必须暂停并询问用户**"⚠ 单次查询内容字数建议不超过1000字，现在已超过单次执行字数是否进行分批查询？\n回复1：执行单次查询（仅检测前3000字符）\n回复2：执行分批查询（检测全部内容）\n回复3：取消执行"**——必须等待用户回答后再继续，禁止未等用户回复就自行执行后续流程**——用户回复"1"则仅检测前3000字符，用户回复"2"则按3000字符分批检测全部内容，用户回复"3"则结束任务
   - --content、--file、--url 三者互斥，每次调用仅传一个
   - 平台已硬编码为公众号，无需也无法指定其他平台
2. 解析脚本返回的JSON，**严格按下方输出模板呈现结果，禁止偏离**；分批检测时每批均输出三板块结果
3. **【必须执行，不可跳过】** 将纯文本优化文案（移除所有HTML标记）写入文件 `./公众号_优化文案_{随机6位数字}.txt`，写入后**必须将文件以卡片形式发送给用户**，让用户可以直接点击下载，禁止仅告知文件路径而不发送文件卡片。**该文件必须实际写入磁盘，禁止省略不输出、禁止仅提示文件名而不写入内容、禁止不发送文件卡片**。**分批检测时，所有批次检测完成后，必须将全部批次的替换结果汇总为一份完整的优化文案写入文件并发送文件卡片，不可遗漏任何批次的内容**

---

## 输出模板（必须严格照此输出，禁止增减任何板块）

以下三个板块为完整输出，必须同时出现、顺序固定、标题加粗。占位符用【】标注，替换为实际值。

🔍 **违禁词检测结果**
- ☁️**检测平台**：公众号
- **违禁词数量**：【word_count】个
- **违禁词类型**：【prohibited_words_type】

📚 **违禁文案：**
【html_content，原文中违禁词用<span style="color:red">标红，单独成段展示确保渲染生效】

> ⚠️ 数据说明：以上查询结果仅供参考，请您根据企业经营范围和产品实际效果或功效，自行核对。

💡 **修改建议**

针对每个违禁词，提供安全的替换表达。

| 违禁词 | 替换词 | 更换理由 |
|--------|--------|----------|
| 【违禁词1】 | 【替换词1】 | 【更换理由1】 |
| 【违禁词2】 | 【替换词2】 | 【更换理由2】 |

✏️ **建议优化文案**

> 💡 <span style="color:blue">蓝色文案</span>表示已替换的文案

【仅将违禁词替换为对应的安全替换词，替换时必须确保替换词与前后文语境通顺、语法正确、语义自然，如需微调前后助词/连接词以保证通顺则一并调整；所有替换词及微调处用\<span style="color:blue"\>标蓝；不改变原文语气、排版、用词风格，不添加emoji，不重写文案；该板块不使用代码块包裹，直接输出富文本】

---

## 输出格式铁律

1. **检测结果输出时仅输出上述三个板块**，禁止在该结果中添加开场白、结束语、补充说明、额外标题等任何其他内容；但检测完成并输出结果后，智能体可正常回复用户的后续提问
2. 三个板块标题必须加粗，emoji在加粗标记外：🔍 **违禁词检测结果**、💡 **修改建议**、✏️ **建议优化文案**
3. **✏️建议优化文案**必须确保替换词与前后文语境通顺，整句话语法正确、语义自然，禁止机械替换单词导致语病（如"回神秘公寓"→"回神秘居所"而非"回神秘公寓"）；如需微调前后助词/连接词以保证通顺则一并调整并用蓝色标记；不改变原文语气/排版/用词风格，禁止添加emoji，禁止重写文案；所有替换词及微调处必须用\<span style="color:blue"\>标蓝显示；该板块不使用代码块包裹，直接输出富文本
4. **【不可跳过】三板块输出完成后，智能体必须将纯文本优化文案写入文件** `./公众号_优化文案_{随机6位数字}.txt`，文件内容与"✏️建议优化文案"完全一致但移除所有HTML标记，仅保留纯文本。写入后**必须将文件以卡片形式发送给用户**，让用户可以直接点击下载，禁止仅告知文件路径而不发送文件卡片。**该文件必须实际写入磁盘，禁止省略不输出、禁止仅提示文件名而不写入内容、禁止不发送文件卡片**。**分批检测时，所有批次检测完成后，必须将全部批次的替换结果按原文顺序汇总为一份完整的优化文案写入文件并发送文件卡片，不可遗漏任何批次的内容**
5. 脚本返回JSON后，智能体必须解析并填入模板，禁止直接输出原始JSON
6. 未检测到违禁词（word_count=0）时：仅输出"**🔍 违禁词检测结果**"板块，内容为"未检测到违禁词，文案内容合规✅"，不输出后两个板块，不输出文件

---

## 完整输出示例

用户输入："这款美白神器真的太有效了，用了三天就白了一个度"

🔍 **违禁词检测结果**
- ☁️**检测平台**：公众号
- **违禁词数量**：2个
- **违禁词类型**：禁用词

📚 **违禁文案：**
这款美白神器真的太<span style="color:red">有效</span>了，用了三天就<span style="color:red">白了一个度</span>

> ⚠️ 数据说明：以上查询结果仅供参考，请您根据企业经营范围和产品实际效果或功效，自行核对。

💡 **修改建议**

针对每个违禁词，提供安全的替换表达。

| 违禁词 | 替换词 | 更换理由 |
|--------|--------|----------|
| 有效 | 用着感觉不错 | "有效"属禁用词，改为描述使用感受的口语化表达 |
| 白了一个度 | 肤色提亮了不少 | "白了一个度"暗示美白功效，改为描述视觉感受的间接表达 |

✏️ **建议优化文案**

> 💡 <span style="color:blue">蓝色文案</span>表示已替换的文案

这款美白神器真的太<span style="color:blue">用着感觉不错</span>了，用了三天就<span style="color:blue">肤色提亮了不少</span>

（三板块输出完成后，智能体必须将纯文本优化文案写入 `./公众号_优化文案_{随机6位数字}.txt`，内容与上方一致但无HTML标记。文件必须实际写入磁盘，写入后**必须将文件以卡片形式发送给用户**，禁止仅告知路径而不发送文件卡片）

---

## 资源索引
- 脚本:见 [scripts/check_sensitive_words.py](../scripts/check_sensitive_words.py)(用途与参数:一站式公众号违禁词检测，支持三种输入方式——--content直接传入文案文本，--file传入TXT/DOC/DOCX等文本类型文件路径自动提取，--url传入网页地址自动提取（使用Playwright无头浏览器渲染JS动态页面，回退到socket+ssl静态提取）；--extract-only仅提取文本不检测，返回content和length；检测请求使用POST+JSON，**API Host/Path 由环境变量或** `gzh_sensitive_word_api.json` **配置**，平台字段仍硬编码为公众号)
- API 配置示例: [scripts/gzh_sensitive_word_api.example.json](../scripts/gzh_sensitive_word_api.example.json)

## 注意事项
- API单次查询支持3000字符；内容总字数上限10000字符。**所有输入类型（文本/文件/网页/图片）超过3000字符且不超过10000字符时均必须暂停并询问用户**"⚠ 单次查询内容字数建议不超过1000字，现在已超过单次执行字数是否进行分批查询？\n回复1：执行单次查询（仅检测前3000字符）\n回复2：执行分批查询（检测全部内容）\n回复3：取消执行"**——必须等待用户回答后再继续，禁止未等用户回复就自行执行后续流程**——用户回复"1"则仅检测前3000字符，用户回复"2"则按自然断句处切割分批检测（每批不超过3000字符），分批检测完成后必须汇总所有批次的替换结果输出一份完整的优化文案文件不可遗漏，用户回复"3"则结束任务；**超过10000字符时直接提示用户**"⚠ 当前内容字数超过10000字，查询将会消耗过多的时间和积分，强烈建议手动分批查询。"**并中断后续逻辑，不执行任何检测**
- 网络请求自动重试：遇到5xx、超时、连接重置等场景最多重试2次，智能体无需手动重试
- 脚本已内置英文误匹配过滤：API会将英文单词内部子串误标为违禁词（如"Glasswing"中的"ass"），脚本自动识别并过滤此类误匹配，无需智能体额外处理
- 违禁词用HTML <span style="color:red"> 标记（渲染红色）；优化文案中替换词用 <span style="color:blue"> 标记（渲染蓝色）
- "✏️建议优化文案"仅替换违禁词，替换时必须确保与前后文语境通顺、语法正确，如需微调前后助词/连接词以保证通顺则一并调整并用蓝色标记；禁止改变语气/排版/添加emoji/重写文案
- **【不可跳过】**三板块输出完成后必须将纯文本优化文案写入 `./公众号_优化文案_{随机6位数字}.txt`，其中随机数由智能体每次生成（如用当前时间戳后6位或随机6位数字），确保连续对话中不同次检测的文件名唯一、内容不被覆盖。文件内容与"✏️建议优化文案"一致但移除所有HTML标记。写入后**必须将文件以卡片形式发送给用户**，让用户可以直接点击下载，禁止仅告知文件路径而不发送文件卡片。**该文件必须实际写入磁盘，禁止省略、禁止仅提示文件名而不写入内容、禁止不发送文件卡片**。**分批检测时，所有批次完成后必须将全部替换结果按原文顺序汇总写入一份完整文件并发送文件卡片，不可遗漏任何批次**
- 图片文件由智能体先用read_image提取文字（仅提取图片中的文字内容，禁止识别图片的视觉风格描述），再以 --content 传入脚本检测
- 文件上传仅支持图片和文本类文件（TXT、CSV、MD、LOG、JSON、XML、HTML、DOC、DOCX），不支持PDF
- 检测完成并输出结果、写入文件后，智能体可正常回复用户的后续提问（如调整替换词、重新检测、解释违禁规则等），不应因铁律约束而拒绝后续对话


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