Skill: supermalo/social-hotsearch (version 1.1.0)

--- name: 社媒热搜助手 description: 微博/小红书/抖音/知乎/百度热搜与舆情分析助手。一句话查热搜榜、对话题做声量/情感/互动/平台分布分析、采样原帖样本。适配营销选题、品牌讨论分析、社媒舆情、PR 传播效果场景,覆盖头部消费品牌、热门事件、品类词。免费 200 次,无需注册。 version: 1.1.0 metadata: openclaw: requires: bins: - python3 --- # 社媒热搜助手 (social-hotsearch) 面向**社会化营销 / 内容运营 / 公关 PR / 媒介投放 / 行业研究**从业者的热搜情报与社媒舆情分析技能。 零配置,首次使用自动注册,免费 200 次调用额度。 ## 你能用它做什么(双能力) | 能力 | 工具 | 典型用途 | |---|---|---| | **A. 看热搜找选题** | `query_hot_list` | "今天有什么火?给我推选题" | | **B. 对已知话题做多维剖析** | `analyze_topic` + `sample_posts` | "我已有目标(品牌/事件/品类),想看它在社媒的全貌" | > 注:能力 B **不依赖热搜状态**,只要话题/品牌有足够的社媒讨论量(头部消费品牌、热门事件、品类词)即可分析。 ## 适合谁用 + 典型场景 ### 社会化营销 / 内容运营(追热点、找选题) - "看下小红书最近'夏日防晒'有哪些爆款话题,我要做种草选题" - "618 大促期间国货美妆品牌在抖音的热度排行" ### 公关 / 品牌(口碑监测、传播复盘) - "瑞幸最近一周在微博和小红书的口碑怎么样,正面率多少" - "某品牌发布会后两天网友都在说什么,对比微博/小红书/抖音的讨论" ### 媒介投放(评估热度与价值) - "某热播综艺这一周的全网讨论量级,值不值得投" - "新能源车这波话题在抖音的互动量级有多大" ### 行业研究 / 咨询(跨平台情报) - "AI 大模型最近一周在知乎 vs 微博的话题分布" - "宠物经济在小红书 vs 抖音的内容差异" ## 核心入口(三个 CLI 脚本) ### 1. 查询热搜榜单 → `scripts/query_hot_list.py` ```bash python3 ./scripts/query_hot_list.py --source 微博 --size 20 python3 ./scripts/query_hot_list.py --source 抖音 --keyword 国货 python3 ./scripts/query_hot_list.py --source 知乎 --date 2026-05-13 ``` **参数:** - `--source` (必填):平台,可选 `微博 / 抖音 / 知乎 / 百度`(**热搜榜不支持小红书**,要查小红书话题用下面的 `analyze_topic`) - `--size`:返回数量,默认 50,最多 100 - `--keyword`:可选,过滤包含特定关键词的话题 - `--date`:可选,日期 YYYY-MM-DD,默认今天 **消耗:** 1 次额度 ### 2. 单话题深度分析 → `scripts/analyze_topic.py` ```bash python3 ./scripts/analyze_topic.py --topic '瑞幸' python3 ./scripts/analyze_topic.py --topic '618' --days 7 python3 ./scripts/analyze_topic.py --topic '夏日防晒' --datasource 小红书 --search-fields title content screenshot python3 ./scripts/analyze_topic.py --topic '某综艺' --datasource 抖音 --search-fields title content video_asr video_ocr ``` **参数:** - `--topic` (必填):话题关键词 - `--days`:时间窗口天数,默认 3(**最少 1 个完整自然日**,即 `--days 1` 起步) - `--datasource`:平台列表,**默认 `微博 小红书 短视频`**(覆盖营销/PR 三大主力)。可选:`小红书 / 微博 / 短视频 / 视频 / 微信 / 电商 / 博客 / 问答 / 新闻 / 论坛` - `--search-fields`:搜索范围,默认 `title content`(标题+正文)。短视频场景可加 `video_asr video_ocr`,图文场景可加 `screenshot` - `--with-clusters`:同时返回相关话题聚类(**额外消耗 1 次额度**,用户没明确要求时不要加) **输出:** 声量、情感指数、互动数、用户数、平台分布、(可选)关联话题 **消耗:** 基础 1 次,加 `--with-clusters` 共 2 次 ### 3. 话题原帖采样 → `scripts/sample_posts.py` ```bash python3 ./scripts/sample_posts.py --topic '瑞幸' --size 20 python3 ./scripts/sample_posts.py --topic '夏日防晒' --datasource 小红书 --order 互动数 python3 ./scripts/sample_posts.py --topic '某新品' --search-fields title content video_asr video_ocr ``` **参数:** - `--topic` (必填):话题关键词 - `--days`:时间窗口,默认 3 - `--datasource`:平台列表,**默认 `微博 小红书 短视频`**(同上,可选项也同上) - `--search-fields`:搜索范围,同 `analyze_topic` - `--size`:采样数量,默认 20,最多 100 - `--order`:排序,可选 `综合/发布时间/互动数/阅读数/曝光量` **输出:** 帖子列表(标题/内容/作者/链接/互动数据;含视频帖子时会带 ASR/OCR 字段) **消耗:** 1 次额度 ### 4. 查询当前剩余额度 → `scripts/ensure_user.py` ```bash python3 ./scripts/ensure_user.py ``` 输出 user_id、已用、剩余次数。**首次运行会自动注册,不消耗额度。** ## 操作规则(给 AI 看的) ### 1. 首次使用 任何脚本第一次调用时会自动通过 `~/.config/social-hotsearch/user.json` 注册新用户,无需用户手动配置。 ### 2. 配额耗尽时 脚本退出码为 `3`,stderr 输出引导用户填写飞书表单的提示。**直接把表单 URL 转告用户,不要尝试绕过**。 ### 3. 平台名两套体系(关键) - **`query_hot_list`(热搜榜)** 用具体站点名:`微博 / 抖音 / 知乎 / 百度`,**不支持小红书**(用户问"小红书热搜",告知"热搜榜暂不支持小红书,要查小红书话题请改用 analyze_topic") - **`analyze_topic` / `sample_posts`(分析/采样)** 用 platform 大类:`小红书 / 微博 / 短视频 / 视频 / 微信 / 电商 / 博客 / 问答 / 新闻 / 论坛` - **脚本已内置归一化**:用户/AI 传 `抖音` / `快手` 会自动转为 `短视频`,`B站 / 哔哩哔哩` 转为 `视频`,`公众号 / 微信公众号` 转为 `微信`。所以可以放心传口语化平台名 ### 4. 数据少时的诊断流程(重要) 如果 `analyze_topic` 返回的声量/`sample_posts` 返回的 `count` 明显偏少(例如声量 < 100),按顺序尝试: 1. **检查关键词是否过长**:`瑞幸咖啡品牌` → 改成 `瑞幸`(关键词太具体会过滤掉大量相关讨论) 2. **加视频字段**:加 `--search-fields title content video_asr video_ocr screenshot` —— 短视频内容的核心信息常在语音/画面字幕里 3. **扩大 datasource**:从默认 3 平台扩到 `--datasource 微博 小红书 短视频 视频 微信`(覆盖更多内容生态) 4. **放宽时间窗**:`--days 7` 或 `--days 30`(注意:最大 365 天) ### 5. 配额节省 - 用户问"看看 XX 热搜"通常只需要 `query_hot_list`,不要叠加 `analyze_topic` + `sample_posts` 三连击 - 用户没明确要求关联话题时,**不要加 `--with-clusters`**(单独消耗 1 次额度) ### 6. 结果解读 返回的 JSON 较长时,用 `head` / `jq` 截取关键字段展示给用户;**不要把完整 JSON 灌给用户看**。 ## 典型工作流 **场景 A - 用户:"今天微博热搜前 10 是什么?"** ```bash python3 ./scripts/query_hot_list.py --source 微博 --size 10 ``` 解读 top 10,简单评论。**消耗 1 次** **场景 B - 用户:"分析一下瑞幸最近一周的口碑"** ```bash python3 ./scripts/analyze_topic.py --topic '瑞幸' --days 7 ``` 基于声量/情感/互动数据给出洞察(默认覆盖微博+小红书+短视频)。**消耗 1 次** **场景 C - 用户:"看看小红书上'夏日防晒'用户都在说什么"** ```bash python3 ./scripts/sample_posts.py --topic '夏日防晒' --datasource 小红书 --size 30 --order 互动数 --search-fields title content screenshot ``` 拉高互动样本,提炼用户态度。`screenshot` 字段帮你捞到图片 OCR 内容(小红书图文笔记的核心信息常在图里)。**消耗 1 次** **场景 D - 完整热搜情报(用户明确要深度分析)** ```bash # 1. 看榜 python3 ./scripts/query_hot_list.py --source 微博 --size 20 # 2. 用户挑一个话题 python3 ./scripts/analyze_topic.py --topic '挑出的话题' --with-clusters # 3. 取代表帖 python3 ./scripts/sample_posts.py --topic '挑出的话题' --size 30 --order 互动数 ``` **消耗 4 次**(榜单 1 + 分析含聚类 2 + 采样 1) ## 能力边界(诚实告知) ✅ **擅长**: - 已上热搜的话题(`query_hot_list` 直接拉) - **头部消费品牌**(瑞幸/喜茶/小米/泡泡玛特等)的多维度社媒分析 - **品类词**(国货美妆/夏日防晒/新能源车/宠物经济)的跨平台对比 - **热门事件**(618/双十一/某热播综艺/某品牌发布会)的传播分析 ❌ **不擅长**: - **长尾/小品牌/B2B 类目**:讨论量太低,情感占比、平台分布等指标没有统计意义 - **实时危机预警**:数据 T+1,做不了"刚发生 5 分钟"级别的监测 - **热搜榜上的小红书**:小红书没有热搜榜接入(用 `analyze_topic` 查任意话题在小红书的表现仍然完整可用) - **未公开内容**:私域/群聊/封闭社区数据不在范围 ## 异常处理 | 异常 | 含义 | 处理 | |---|---|---| | 退出码 3 + stderr 飞书 URL | 配额耗尽 | 转告用户,引导填表升级 | | 退出码 2 + JSON `error` | 网络/服务异常 | 提示稍后重试,不要循环重试 | | `count: 0` 或声量极低 | 该话题在该时间窗内数据稀疏 | 走"数据少时的诊断流程"(见上) | | 退出码 1 | 参数错误 | 检查 `--source` / `--topic` / `--datasource` 是否正确 | | `当前选择的时间范围不足一个自然日` | `--days` 太小或时间窗跨度 < 24h | 改成 `--days 1` 起步 | ## 数据时效 - 热搜榜单:实时(5 分钟级延迟) - 话题分析/原帖:T+1(前一天数据完整) ## 升级到无限额度 免费 200 次用完后,填写以下飞书表单联系商务: **https://tj4ovkghqw.feishu.cn/share/base/form/shrcnHBiiUFOUhRO7Y6H6eeEbQd**

path: README.md
content: # 社媒热搜助手 (social-hotsearch)

> 微博/小红书/抖音/知乎/百度热搜与社媒舆情分析。一句话查热搜、做话题分析、采样原帖。免费 200 次,无需注册。

## 你会得到什么

- **跨 5 大平台的实时数据能力**:微博 / 小红书 / 抖音 / 知乎 / 百度(其中小红书走话题分析,不走热搜榜)
- **对单个话题的多维度分析**:声量、情感指数、互动数、平台分布、关联话题
- **真实原帖样本**:看到舆论现场的具体讨论,不只是数字
- **支持视频/图文深度搜索**:可选搜索视频 ASR(语音)/ OCR(画面)/ 图片 OCR(适合短视频和小红书图文笔记)
- **零配置开箱即用**:首次使用自动注册一个匿名 ID,无需手动管理 token

## 适合谁用

- **社会化营销 / 内容运营**:每天追热点、找选题(小红书种草、抖音爆款话题)
- **公关 / 品牌**:监测品牌相关话题、舆论复盘、传播效果评估
- **媒介投放**:评估热点话题/综艺/影视的投放价值
- **行业研究 / 咨询**:跨平台情报快速汇总,对比内容差异

## 快速体验

skill 装好后,直接对你的 AI 助手说自然语言即可:

> "今天微博热搜前 10 是什么?"
> "看下小红书最近'夏日防晒'有哪些爆款话题"
> "瑞幸最近一周在微博和小红书的口碑怎么样"
> "某热播综艺这一周的全网讨论量级,值不值得投"
> "AI 大模型最近在知乎 vs 微博的话题分布"

AI 会自动调用对应脚本并把结果整理给你看。

## 命令行直接体验

也可以从 skill 根目录直接跑脚本:

```bash
# 1. 查询微博热搜前 10
python3 ./scripts/query_hot_list.py --source 微博 --size 10

# 2. 分析话题(默认覆盖 微博+小红书+短视频)
python3 ./scripts/analyze_topic.py --topic '瑞幸' --days 7

# 3. 采样原帖,小红书图文笔记加图片 OCR
python3 ./scripts/sample_posts.py --topic '夏日防晒' --datasource 小红书 \
    --search-fields title content screenshot --size 20

# 4. 短视频场景加 ASR/OCR 多捞内容
python3 ./scripts/analyze_topic.py --topic '某综艺' --datasource 抖音 \
    --search-fields title content video_asr video_ocr

# 5. 查询当前剩余额度
python3 ./scripts/ensure_user.py
```

无需 `pip install`,仅需 Python 3.10+ 和能访问公网的环境。

## 适用边界(请提前了解)

✅ **擅长**:头部消费品牌、品类词、热门事件、综艺/影视等**有充分社媒讨论**的话题

❌ **不擅长**:
- 长尾品牌 / B2B / 小众话题:讨论量太少,统计意义弱
- 实时危机预警:数据 T+1,不做秒级监测
- 小红书热搜榜:平台无此数据(但小红书话题分析完全可用)

## 免费额度

- **首次使用自动注册**,无需手动操作
- **200 次免费调用额度**(每个独立机器/用户独立计数)
- 用满后,在终端会看到一个**飞书表单 URL**,填写联系商务即可解锁更多用量

用户身份保存在本地 `~/.config/social-hotsearch/user.json`,删除该文件相当于重新注册(不推荐,免费额度会清零)。

## 数据来源

底层数据来自明略科技 social-base 平台,该平台覆盖国内主流社媒数据,T+1 数据完整,实时榜单 5 分钟级延迟。

## 隐私与安全

- 你只通过 HTTPS 调用我们的代理服务,**真实 API key 不在你本地**
- 我们仅记录:你的匿名 user_id、机器指纹哈希、调用次数、调用工具名(用于配额管理)
- **不记录:** 你的查询关键词、查询结果

## 反馈与商务合作

- 反馈/建议:填表 https://tj4ovkghqw.feishu.cn/share/base/form/shrcnHBiiUFOUhRO7Y6H6eeEbQd
- 商务合作/批量授权:同上表单


path: agents/openai.yaml
content: interface:
  display_name: "社媒热搜助手"
  short_description: "微博/小红书/抖音/知乎/百度热搜与舆情分析。看话题声量/情感/平台分布,选题、品牌讨论、传播复盘一站搞定。免费 200 次"
  brand_color: "#FF4D4F"
  default_prompt: "看下今天微博热搜前 10,挑一个消费品牌相关的话题,分析它最近一周在小红书和抖音的讨论量与情感倾向。"

dependencies:
  tools:
    - type: "mcp"
      value: "social-base-proxy"
      description: "Social Skill Proxy(明略 social-base 的限流代理,自动注册免费 200 次额度)"
      transport: "streamable_http"
      url: "https://47-103-200-210.nip.io/mcp"

policy:
  allow_implicit_invocation: true


path: scripts/analyze_topic.py
content: #!/usr/bin/env python3
"""Analyze a single hot topic across platforms.

Calls social-base MCP tool `social_statistic_overview` to get volume,
sentiment, platform distribution, and `social_statistic_hot_topic` to
cluster related sub-topics.

Usage:
    python3 analyze_topic.py --topic '瑞幸'
    python3 analyze_topic.py --topic '618' --days 7 --datasource 微博 抖音
    python3 analyze_topic.py --topic '某热播综艺' --search-fields title content video_asr video_ocr
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, time, timedelta
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from social_client import (
    QuotaExhausted,
    ProxyError,
    call_tool,
    extract_text_content,
    normalize_analysis_datasource,
    print_quota_help,
)


SEARCH_FIELD_CHOICES = ["title", "content", "video_asr", "video_ocr", "screenshot"]


def main() -> int:
    p = argparse.ArgumentParser(description="对单个话题做声量/情感/平台分布分析")
    p.add_argument("--topic", required=True, help="话题关键词")
    p.add_argument("--days", type=int, default=3,
                   help="时间窗口,天,默认 3")
    p.add_argument("--datasource", nargs="+",
                   default=["微博", "小红书", "短视频"],
                   help="数据源平台列表。可选: 小红书 / 微博 / 短视频 / 视频 / 微信 / 电商 / 博客 / 问答 / 新闻 / 论坛。"
                        "默认 微博+小红书+短视频(覆盖营销/PR 场景三大主力)。"
                        "注意: 用户口中的'抖音'/'快手'在此处归一化为'短视频','B站/哔哩哔哩'归一化为'视频','公众号'归一化为'微信'。")
    p.add_argument("--search-fields", nargs="+", default=["title", "content"],
                   choices=SEARCH_FIELD_CHOICES,
                   help="关键词搜索范围,默认 title+content。"
                        "短视频内容场景建议加 video_asr(语音)video_ocr(画面字幕);"
                        "图文内容场景可加 screenshot(图片 OCR)。")
    p.add_argument("--with-clusters", action="store_true",
                   help="同时返回相关热点话题聚类(额外消耗 1 次额度)")
    args = p.parse_args()

    end_date = datetime.now()
    start_date = end_date - timedelta(days=args.days)
    start = start_date.replace(hour=0, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
    end = end_date.strftime("%Y-%m-%d %H:%M:%S")

    datasource = normalize_analysis_datasource(args.datasource)

    overview_args = {
        "target_type": "keyword",
        "name": args.topic,
        "anys": [[args.topic]],
        "start_time": start,
        "end_time": end,
        "datasource": datasource,
        "search_fields": args.search_fields,
        "is_compare": False,
    }

    try:
        overview_res = call_tool("social_statistic_overview", overview_args)
        overview_text = extract_text_content(overview_res)
        try:
            overview = json.loads(overview_text)
        except json.JSONDecodeError:
            overview = {"raw": overview_text}
    except QuotaExhausted as e:
        print_quota_help(e.info)
        return 3
    except ProxyError as e:
        print(json.dumps({"error": str(e), "stage": "overview"}, ensure_ascii=False),
              file=sys.stderr)
        return 2

    output: dict = {
        "topic": args.topic,
        "window": {
            "start": start,
            "end": end,
            "datasource": datasource,
            "search_fields": args.search_fields,
        },
        "overview": overview,
    }

    if args.with_clusters:
        cluster_args = {
            "target_type": "keyword",
            "name": args.topic,
            "anys": [[args.topic]],
            "start_time": start,
            "end_time": end,
            "datasource": datasource,
            "search_fields": args.search_fields,
        }
        try:
            cluster_res = call_tool("social_statistic_hot_topic", cluster_args)
            cluster_text = extract_text_content(cluster_res)
            clusters: list = []
            for line in cluster_text.splitlines():
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except json.JSONDecodeError:
                    continue
                if isinstance(obj, list):
                    clusters.extend(obj)
                else:
                    clusters.append(obj)
            output["related_topics"] = clusters
        except QuotaExhausted as e:
            print_quota_help(e.info)
            output["related_topics_error"] = "quota exhausted between overview and clusters"
        except ProxyError as e:
            output["related_topics_error"] = str(e)

    print(json.dumps(output, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ensure_user.py
content: #!/usr/bin/env python3
"""Ensure the user is registered with the proxy. Print user_id and remaining quota.

Usage:
    python3 ensure_user.py
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from social_client import ensure_user, get_usage, ProxyError


def main() -> int:
    try:
        u = ensure_user()
    except ProxyError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
        return 2

    user_id = u["user_id"]
    try:
        usage = get_usage(user_id)
    except ProxyError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
        return 2

    print(json.dumps({
        "user_id": user_id,
        "is_new": u.get("is_new", False),
        "used": usage["used"],
        "remaining": usage["remaining"],
        "limit": usage["limit"],
        "should_bind_email": usage["should_bind_email"],
    }, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/query_hot_list.py
content: #!/usr/bin/env python3
"""Query a platform's hot-search list.

Calls social-base MCP tool `query_rank_list` through the proxy.

Usage:
    python3 query_hot_list.py --source 微博
    python3 query_hot_list.py --source 抖音 --keyword 国货 --size 20
    python3 query_hot_list.py --source 知乎 --date 2026-05-14
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from social_client import (
    QuotaExhausted,
    ProxyError,
    call_tool,
    extract_text_content,
    print_quota_help,
)


SUPPORTED_SOURCES = ["微博", "抖音", "知乎", "百度"]


def main() -> int:
    p = argparse.ArgumentParser(description="查询平台热搜榜")
    p.add_argument("--source", required=True, choices=SUPPORTED_SOURCES,
                   help="平台名称")
    p.add_argument("--keyword", default=None,
                   help="可选,过滤包含该关键词的话题")
    p.add_argument("--size", type=int, default=50,
                   help="返回数量,默认 50,最多 100")
    p.add_argument("--date", default=None,
                   help="日期,格式 YYYY-MM-DD,默认今天")
    args = p.parse_args()

    if args.date:
        d = datetime.strptime(args.date, "%Y-%m-%d").date()
    else:
        d = datetime.now().date()
    start = datetime.combine(d, time(0, 0, 0)).strftime("%Y-%m-%d %H:%M:%S")
    end = datetime.combine(d, time(23, 59, 59)).strftime("%Y-%m-%d %H:%M:%S")

    arguments = {
        "source": args.source,
        "start_time": start,
        "end_time": end,
        "size": min(max(args.size, 1), 100),
    }
    if args.keyword:
        arguments["keyword"] = args.keyword

    try:
        result = call_tool("query_rank_list", arguments)
    except QuotaExhausted as e:
        print_quota_help(e.info)
        return 3
    except ProxyError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
        return 2

    text = extract_text_content(result)
    items = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            items.append(json.loads(line))
        except json.JSONDecodeError:
            continue

    print(json.dumps({
        "source": args.source,
        "date": d.isoformat(),
        "count": len(items),
        "items": items,
    }, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/sample_posts.py
content: #!/usr/bin/env python3
"""Sample representative posts about a topic.

Calls social-base MCP tool `query_raw_posts` through the proxy.

Usage:
    python3 sample_posts.py --topic '瑞幸' --size 20
    python3 sample_posts.py --topic '夏日防晒' --datasource 小红书 --order 互动数 --size 30
    python3 sample_posts.py --topic '618' --search-fields title content video_asr video_ocr
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from social_client import (
    QuotaExhausted,
    ProxyError,
    call_tool,
    extract_text_content,
    normalize_analysis_datasource,
    print_quota_help,
)


SEARCH_FIELD_CHOICES = ["title", "content", "video_asr", "video_ocr", "screenshot"]


def main() -> int:
    p = argparse.ArgumentParser(description="按话题采样原帖")
    p.add_argument("--topic", required=True, help="话题关键词")
    p.add_argument("--days", type=int, default=3, help="时间窗口,天,默认 3")
    p.add_argument("--datasource", nargs="+",
                   default=["微博", "小红书", "短视频"],
                   help="数据源平台列表。可选: 小红书 / 微博 / 短视频 / 视频 / 微信 / 电商 / 博客 / 问答 / 新闻 / 论坛。"
                        "默认 微博+小红书+短视频(覆盖营销/PR 场景三大主力)。"
                        "注意: 用户口中的'抖音'/'快手'在此处归一化为'短视频','B站/哔哩哔哩'归一化为'视频','公众号'归一化为'微信'。")
    p.add_argument("--search-fields", nargs="+", default=["title", "content"],
                   choices=SEARCH_FIELD_CHOICES,
                   help="关键词搜索范围,默认 title+content。"
                        "短视频内容场景建议加 video_asr(语音)video_ocr(画面字幕);"
                        "图文内容场景可加 screenshot(图片 OCR)。")
    p.add_argument("--size", type=int, default=20,
                   help="采样数量,默认 20,最多 100")
    p.add_argument("--order", default="综合",
                   choices=["综合", "发布时间", "互动数", "阅读数", "曝光量"],
                   help="排序方式")
    args = p.parse_args()

    end_date = datetime.now()
    start_date = end_date - timedelta(days=args.days)
    start = start_date.replace(hour=0, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
    end = end_date.strftime("%Y-%m-%d %H:%M:%S")

    datasource = normalize_analysis_datasource(args.datasource)

    arguments = {
        "target_type": "keyword",
        "name": args.topic,
        "anys": [[args.topic]],
        "start_time": start,
        "end_time": end,
        "datasource": datasource,
        "search_fields": args.search_fields,
        "size": min(max(args.size, 1), 100),
        "order_by": args.order,
    }

    try:
        result = call_tool("query_raw_posts", arguments)
    except QuotaExhausted as e:
        print_quota_help(e.info)
        return 3
    except ProxyError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
        return 2

    text = extract_text_content(result)
    posts: list = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(obj, list):
            posts.extend(obj)
        else:
            posts.append(obj)

    if not posts:
        try:
            parsed = json.loads(text)
            if isinstance(parsed, list):
                posts = parsed
            elif isinstance(parsed, dict) and "data" in parsed:
                posts = parsed["data"]
        except json.JSONDecodeError:
            pass

    print(json.dumps({
        "topic": args.topic,
        "window": {
            "start": start,
            "end": end,
            "datasource": datasource,
            "search_fields": args.search_fields,
        },
        "count": len(posts),
        "posts": posts,
    }, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/social_client.py
content: """Social Hot Search proxy client.

Stdlib-only client for calling the Social Skill proxy.
Handles: user_id persistence, machine fingerprint, MCP tool calls,
quota-exhausted detection.
"""
from __future__ import annotations

import getpass
import hashlib
import json
import os
import socket
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any


PROXY_BASE = os.environ.get("SOCIAL_PROXY_BASE", "https://47-103-200-210.nip.io")
SKILL_ID = "social-hotsearch"
USER_FILE = Path(os.environ.get(
    "SOCIAL_USER_FILE",
    str(Path.home() / ".config" / "social-hotsearch" / "user.json"),
))


class QuotaExhausted(RuntimeError):
    def __init__(self, info: dict[str, Any]):
        self.info = info
        super().__init__(info.get("message") or "quota exhausted")


class ProxyError(RuntimeError):
    pass


def machine_fingerprint() -> str:
    raw = f"{getpass.getuser()}@{socket.gethostname()}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]


# 平台名归一化:analyze_topic / sample_posts 用的 datasource 名,与口语/热搜榜不一致
# 用户/AI 习惯说"抖音""快手"等具体站点名,这里映射到 social-base 接受的 platform 大类
ANALYSIS_DATASOURCE_ALIASES = {
    "抖音": "短视频",
    "快手": "短视频",
    "微信公众号": "微信",
    "公众号": "微信",
    "B站": "视频",
    "哔哩哔哩": "视频",
    "bilibili": "视频",
}

ANALYSIS_DATASOURCE_VALID = {
    "小红书", "微博", "短视频", "视频", "微信", "电商", "博客", "问答", "新闻", "论坛",
}


def normalize_analysis_datasource(items: list[str]) -> list[str]:
    """把口语/具体站点名归一化到 social_statistic_* / query_raw_posts 接受的 platform 名。

    - 已是合法 platform → 原样保留
    - 命中别名表 → 转换(如 抖音→短视频)
    - 既不合法也不在别名表 → 原样保留(让上游报错给用户更清晰的提示)
    """
    out: list[str] = []
    for it in items:
        # 支持"platform__sub1,sub2"形式,只对 platform 部分做映射
        if "__" in it:
            head, sep, tail = it.partition("__")
            head = ANALYSIS_DATASOURCE_ALIASES.get(head, head)
            out.append(f"{head}{sep}{tail}")
        else:
            out.append(ANALYSIS_DATASOURCE_ALIASES.get(it, it))
    return out


def _post_json(path: str, payload: dict[str, Any], headers: dict[str, str] | None = None,
               timeout: int = 30) -> dict[str, Any]:
    url = PROXY_BASE.rstrip("/") + path
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/json")
    if headers:
        for k, v in headers.items():
            req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace")
        try:
            data = json.loads(body_text)
        except Exception:
            data = {"raw": body_text}
        if e.code == 402:
            raise QuotaExhausted(data) from e
        raise ProxyError(f"HTTP {e.code}: {data}") from e
    except urllib.error.URLError as e:
        raise ProxyError(f"network error: {e.reason}") from e


def _get(path: str, headers: dict[str, str] | None = None, timeout: int = 30) -> dict[str, Any]:
    url = PROXY_BASE.rstrip("/") + path
    req = urllib.request.Request(url, method="GET")
    if headers:
        for k, v in headers.items():
            req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace")
        try:
            data = json.loads(body_text)
        except Exception:
            data = {"raw": body_text}
        raise ProxyError(f"HTTP {e.code}: {data}") from e
    except urllib.error.URLError as e:
        raise ProxyError(f"network error: {e.reason}") from e


def load_user() -> dict[str, Any] | None:
    if not USER_FILE.exists():
        return None
    try:
        return json.loads(USER_FILE.read_text(encoding="utf-8"))
    except Exception:
        return None


def save_user(data: dict[str, Any]) -> None:
    USER_FILE.parent.mkdir(parents=True, exist_ok=True)
    USER_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def ensure_user() -> dict[str, Any]:
    """Load or create user_id. Returns full user record from server."""
    cached = load_user()
    fp = machine_fingerprint()
    resp = _post_json("/v1/register", {"machine_fingerprint": fp})
    user_id = resp["user_id"]
    record = {
        "user_id": user_id,
        "machine_fingerprint": fp,
        "is_new": resp.get("is_new", False),
    }
    save_user(record)
    return {**record, **resp}


def get_usage(user_id: str) -> dict[str, Any]:
    return _get("/v1/usage", headers={"Authorization": f"Bearer {user_id}"})


def get_upgrade_info() -> dict[str, Any]:
    return _get("/v1/upgrade-info")


def call_tool(tool_name: str, arguments: dict[str, Any], user_id: str | None = None,
              timeout: int = 180) -> dict[str, Any]:
    """Call an MCP tool through the proxy. Returns the parsed JSON-RPC result.

    Raises QuotaExhausted on 402, ProxyError otherwise.
    """
    if user_id is None:
        u = ensure_user()
        user_id = u["user_id"]

    url = PROXY_BASE.rstrip("/") + "/mcp"
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {"name": tool_name, "arguments": arguments},
    }
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Accept", "application/json, text/event-stream")
    req.add_header("Authorization", f"Bearer {user_id}")
    req.add_header("X-Skill-Id", SKILL_ID)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace")
        try:
            data = json.loads(body_text)
        except Exception:
            data = {"raw": body_text}
        if e.code == 402:
            raise QuotaExhausted(data) from e
        raise ProxyError(f"HTTP {e.code}: {data}") from e
    except urllib.error.URLError as e:
        raise ProxyError(f"network error: {e.reason}") from e

    msg = _parse_mcp_response(raw)
    if "error" in msg:
        err = msg["error"] or {}
        raise ProxyError(f"MCP error {err.get('code')}: {err.get('message')}")
    result = msg.get("result", {})
    if result.get("isError"):
        raise ProxyError(f"MCP tool {tool_name} returned an error")
    return result


def extract_text_content(result: dict[str, Any]) -> str:
    """Pull the text content from an MCP tool result (commonly returned as
    JSON-Lines or plain text in result.content[0].text).
    """
    content = result.get("content", [])
    if not isinstance(content, list):
        return ""
    parts = []
    for item in content:
        if isinstance(item, dict) and item.get("type") == "text":
            parts.append(item.get("text", ""))
    return "\n".join(parts)


def _parse_mcp_response(body: str) -> dict[str, Any]:
    """Parse either a JSON body or an SSE event stream containing JSON-RPC."""
    body = body.strip()
    if not body:
        raise ProxyError("empty response from proxy")
    # Plain JSON
    if body.startswith("{") or body.startswith("["):
        try:
            return json.loads(body)
        except json.JSONDecodeError:
            pass
    # SSE: look for `data: {...}` lines
    for line in body.splitlines():
        if line.startswith("data: "):
            chunk = line[6:].strip()
            if not chunk:
                continue
            try:
                obj = json.loads(chunk)
                if isinstance(obj, dict):
                    return obj
            except json.JSONDecodeError:
                continue
    raise ProxyError(f"unable to parse MCP response: {body[:200]}")


def print_quota_help(info: dict[str, Any]) -> None:
    """Print user-friendly quota-exhausted message and upgrade form URL."""
    sys.stderr.write("\n" + "=" * 60 + "\n")
    sys.stderr.write("免费额度已用完\n")
    sys.stderr.write("=" * 60 + "\n")
    sys.stderr.write(f"已使用: {info.get('used')} / {info.get('limit')}\n")
    sys.stderr.write(f"\n{info.get('message') or '请联系商务获取更多用量'}\n")
    if info.get("form_url"):
        sys.stderr.write(f"\n填写此表单联系商务:\n{info['form_url']}\n")
    sys.stderr.write("=" * 60 + "\n\n")


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