Skill: q1lin570/media-cluster (version 1.0.1)

--- name: media-cluster description: Crawls social media by search keyword (MediaCrawler: 小红书/抖音/微博/B站等), summarizes content and outputs report plus short voice summary. User gives one sentence only (e.g. “爬一下小红书关键词比特币并总结”); agent runs full workflow automatically—env setup, crawl, report, TTS. Use when the user asks to search/crawl a topic on Chinese social platforms and summarize. --- # Media Cluster – 社交平台关键词爬取与总结 **路径约定**：文中 `{baseDir}` 表示本技能所在目录（media-cluster 技能根目录）。Agent 执行命令时需将 `{baseDir}` 替换为实际路径。 基于 [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler) 按「搜索关键词」爬取社交平台内容，汇总有哪些内容，并生成报告与简洁版语音总结。爬虫结果保存在 `{baseDir}/MediaCrawler/data/` 下。 ## 使用者一句话指令（仅文本输入） 使用者只需输入一句自然语言，例如： - 「帮我爬一下小红书关键词比特币，并总结」 - 「搜一下抖音上关于 AI 的内容并出报告」 - 「微博搜“编程副业”爬取并语音总结」 **Agent 收到此类请求后，应自动完成全流程**（无需用户再执行任何命令）： 1. **首次执行时下载 MediaCrawler**：若 `{baseDir}/MediaCrawler` 目录不存在，则先执行 `{baseDir}/scripts/ensure_mediacrawler.sh` 或手动 `git clone https://github.com/NanmiCoder/MediaCrawler.git {baseDir}/MediaCrawler`（将 `{baseDir}` 换为实际路径），然后再进行后续步骤。 2. **解析**：从用户文本中识别**平台**（小红书/xhs、抖音/dy、微博/wb、B站/bili、快手/ks、贴吧/tieba、知乎/zhihu）和**搜索关键词**；若未指明平台则默认小红书（xhs）。 3. **环境**：若尚未配置，先执行 `scripts/setup_env.sh`（或等价 conda create + pip install + playwright install）；否则跳过。 4. **爬取**：在 `MediaCrawler` 目录下执行 `python main.py --platform <平台> --lt qrcode --type search --keywords "<关键词>" --save_data_option jsonl`；提示用户首次需扫码登录。 5. **报告与语音**：爬取结束后执行 `scripts/summarize_and_voice.py`，带上对应的 `--platform`、`--keyword`，并加上 `--voice` 生成简洁版语音总结；将报告路径与语音稿内容回复给用户。 使用者只需一次文本输入，其余由 Agent 按上述步骤完成。 ## 前置条件 - **Conda**：用于创建并激活 `media-cluster` 环境。 - **Node.js**：>= 16（爬抖音、知乎时需要）。 - 工具已位于：`{baseDir}/MediaCrawler`。 - **语音总结（TTS）**：需在环境变量中配置 `SENSEAUDIO_API_KEY`（[接口密钥](https://senseaudio.cn/platform/api-key)）；可选 `SENSEAUDIO_VOICE_ID`。 ## 0. 首次执行：下载 MediaCrawler 若本地尚未有 MediaCrawler 项目，需先从 GitHub 拉取到技能目录下。执行时将 `{baseDir}` 替换为实际技能根路径： ```bash # macOS/Linux：使用脚本（推荐，会检测目录是否存在再克隆） bash {baseDir}/scripts/ensure_mediacrawler.sh # 所有平台（macOS/Windows/Linux）：手动克隆 git clone https://github.com/NanmiCoder/MediaCrawler.git {baseDir}/MediaCrawler ``` 完成后目录结构为：`{baseDir}/MediaCrawler/`（含 `main.py`、`config/`、`requirements.txt` 等）。之后再执行环境配置与爬取。 ## 1. 环境配置（首次或环境异常时执行） 以下命令适用于所有平台（macOS / Windows / Linux），在终端或命令提示符中执行： ```bash # 创建 conda 环境（Python 3.11） conda create -n media-cluster python=3.11 -y # 激活环境 conda activate media-cluster # 进入 MediaCrawler 目录 cd {baseDir}/MediaCrawler # 安装依赖 pip install -r requirements.txt # 安装 Playwright 浏览器驱动 playwright install ``` macOS/Linux 也可直接执行技能提供的脚本（将 `{baseDir}` 换为实际路径）： ```bash bash {baseDir}/scripts/setup_env.sh ``` ## 2. 爬取流程 ### 2.1 确定参数 - **平台** `--platform`：`xhs`（小红书）| `dy`（抖音）| `ks`（快手）| `bili`（B站）| `wb`（微博）| `tieba`（贴吧）| `zhihu`（知乎）。 - **搜索关键词** `--keywords`：一个或多个，英文逗号分隔，如 `比特币` 或 `比特币,以太坊`。 - **爬取类型**：本技能使用关键词搜索，即 `--type search`。 - **登录方式**：默认 `--lt qrcode`（扫码登录）；首次运行会弹出浏览器，用对应 APP 扫码。 ### 2.2 执行爬虫 先激活环境，再进入 `{baseDir}/MediaCrawler` 运行： ```bash conda activate media-cluster cd {baseDir}/MediaCrawler # 示例：小红书搜索「比特币」 python main.py --platform xhs --lt qrcode --type search --keywords "比特币" --save_data_option jsonl ``` 数据默认写入 MediaCrawler 下的 `data/`，即 `{baseDir}/MediaCrawler/data/<platform>/`。如需指定绝对路径，可加 `--save_data_path <绝对路径>`。 ### 2.3 可选参数（按需） - `--save_data_option jsonl`：推荐 jsonl，便于后续解析。 - `--get_comment yes/no`：是否爬评论，默认从 `config/base_config.py` 读取。 - `--save_data_path <path>`：自定义数据目录；不填则用默认 `data/`。 ## 3. 报告与语音总结 爬取完成后： 1. **读数据**：从 `{baseDir}/MediaCrawler/data/<platform>/` 下读取最新生成的 jsonl（或 json/csv，视 `--save_data_option`）。 2. **生成报告**： - 统计条数、关键词/平台、时间范围。 - 按主题/类型归纳「有哪些内容」（标题、摘要、作者、互动数等）。 - 小红书笔记示例字段：`note_id`, `type`, `title`, `desc`, `user_id`, `nickname`, `liked_count`, `collected_count`, `comment_count`, `share_count`, `ip_location`, `tag_list`, `note_url`, `source_keyword` 等。 3. **输出**： - 写一份 **Markdown 报告** 到技能目录或用户指定路径（如 `media_cluster_report_<关键词>_<日期>.md`）。 - 生成一份 **简洁版语音稿**（3–5 句话）：概括平台、关键词、条数、主要几类内容。 - **语音输出**：使用 [SenseAudio API](https://senseaudio.cn/docs/) 将语音稿合成并保存为 mp3（可选播放）。需在环境变量中配置 `SENSEAUDIO_API_KEY`（在 [接口密钥](https://senseaudio.cn/platform/api-key) 创建）；可选 `SENSEAUDIO_VOICE_ID`。 报告结构建议： ```markdown # 媒体爬取报告：<关键词> - **平台**：<platform> - **关键词**：<keywords> - **爬取条数**：<count> - **数据时间**：<时间范围> ## 内容概览 （按主题/类型归纳，列出代表性标题与摘要） ## 明细（可选） （表格或列表：标题、作者、互动、链接等） ``` ## 4. 小红书结果示例（JSON 单条） 爬虫得到的小红书笔记可能形如： ```json { "note_id": "68f0ae5b0000000003020914", "type": "normal", "title": "原以为比特币绝对安全呢", "desc": "比特币不是去中心化吗？美国司法部是怎么没收...", "video_url": "", "time": 1760603739000, "user_id": "60337046000000000101fd66", "nickname": "贝格尔号", "liked_count": "1308", "collected_count": "963", "comment_count": "664", "share_count": "573", "tag_list": "", "note_url": "https://www.xiaohongshu.com/explore/...", "source_keyword": "比特币" } ``` 总结时重点用：`title`, `desc`, `nickname`, `liked_count`, `comment_count`, `source_keyword`。 ## 5. 脚本说明 | 脚本 | 用途 | |------|------| | `scripts/ensure_mediacrawler.sh` | （macOS/Linux）首次执行时若不存在 `MediaCrawler` 目录，则从 GitHub 克隆到技能目录下；Windows 请直接 `git clone`。 | | `scripts/setup_env.sh` | （macOS/Linux）创建 conda 环境、安装依赖与 Playwright；Windows 请按「环境配置」小节手动执行 conda/pip 命令。 | | `scripts/summarize_and_voice.py` | 读取 `MediaCrawler/data/` 下数据，生成 Markdown 报告与简短语音稿，并用 SenseAudio API 合成语音（需环境变量 `SENSEAUDIO_API_KEY`）。 | 调用示例（将 `{baseDir}` 换为实际路径）： ```bash conda activate media-cluster python {baseDir}/scripts/summarize_and_voice.py \ --data-dir {baseDir}/MediaCrawler/data \ --platform xhs \ --keyword "比特币" \ --report report.md \ --voice # 可选：--voice-id <音色ID>、--voice-out <输出mp3路径>；API 文档 https://senseaudio.cn/docs/ ``` ## 6. 注意事项 - 仅用于学习与研究，遵守平台条款与法律法规。 - 首次运行需扫码登录；登录态可被缓存，后续可减少扫码。 - 若遇反爬或验证码，可尝试关闭无头模式（在 `config/base_config.py` 中设置 `HEADLESS = False`）。 - 抖音/知乎需本机已安装 Node.js >= 16。 - **语音总结**：TTS 使用 SenseAudio API，需在环境变量中配置 `SENSEAUDIO_API_KEY`（在 [senseaudio.cn 接口密钥](https://senseaudio.cn/platform/api-key) 创建）；可选 `SENSEAUDIO_VOICE_ID`。API 文档见 <https://senseaudio.cn/docs/>。 ## 7. 参考 - MediaCrawler 用法与配置：[MediaCrawler/README.md](MediaCrawler/README.md) - 数据存储与字段说明见项目 `docs/data_storage_guide.md`（若有）。 - 语音合成（TTS）：[SenseAudio API 文档](https://senseaudio.cn/docs/)、[语音合成 API](https://senseaudio.cn/docs/text_to_speech_api)、[接口密钥](https://senseaudio.cn/platform/api-key)。

path: scripts/ensure_mediacrawler.sh
content: #!/usr/bin/env bash
# 首次执行时从 GitHub 克隆 MediaCrawler 到技能目录下
# 执行时将 {baseDir} 换为技能根目录：bash {baseDir}/scripts/ensure_mediacrawler.sh

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
MEDIACRAWLER_DIR="$SKILL_ROOT/MediaCrawler"
REPO_URL="https://github.com/NanmiCoder/MediaCrawler.git"

if [[ -d "$MEDIACRAWLER_DIR" ]]; then
  echo "MediaCrawler 已存在: $MEDIACRAWLER_DIR"
  exit 0
fi

echo "正在克隆 MediaCrawler 到 $MEDIACRAWLER_DIR ..."
git clone "$REPO_URL" "$MEDIACRAWLER_DIR"
echo "MediaCrawler 下载完成。"


path: scripts/setup_env.sh
content: #!/usr/bin/env bash
# MediaCrawler 环境配置：创建 conda 环境、安装依赖与 Playwright
# 首次执行时会自动克隆 MediaCrawler（若不存在）
# 执行时将 {baseDir} 换为技能根目录：bash {baseDir}/scripts/setup_env.sh

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MEDIACRAWLER_DIR="$(dirname "$SCRIPT_DIR")/MediaCrawler"

if [[ ! -d "$MEDIACRAWLER_DIR" ]]; then
  echo "MediaCrawler 目录不存在，正在从 GitHub 克隆..."
  bash "$SCRIPT_DIR/ensure_mediacrawler.sh"
fi

if [[ ! -d "$MEDIACRAWLER_DIR" ]]; then
  echo "Error: MediaCrawler not found at $MEDIACRAWLER_DIR"
  exit 1
fi

echo "Creating conda env media-cluster (Python 3.11)..."
conda create -n media-cluster python=3.11 -y

echo "Activating media-cluster and installing dependencies..."
source "$(conda info --base)/etc/profile.d/conda.sh"
conda activate media-cluster

cd "$MEDIACRAWLER_DIR"
pip install -r requirements.txt
pip install requests

echo "Installing Playwright browsers..."
playwright install

echo "Done. Use: conda activate media-cluster, then cd to MediaCrawler and run main.py"


path: scripts/summarize_and_voice.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Read MediaCrawler JSONL output, generate a Markdown report and a short voice script.
TTS uses SenseAudio API (https://senseaudio.cn/docs/). Requires SENSEAUDIO_API_KEY env var.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path

_SKILL_ROOT = Path(__file__).resolve().parent.parent

try:
    import requests
except ImportError:
    requests = None

SENSEAUDIO_API_BASE = "https://api.senseaudio.cn"
SENSEAUDIO_TTS_URL = f"{SENSEAUDIO_API_BASE}/v1/t2a_v2"
DEFAULT_VOICE_ID = "male_0004_a"


def platform_label(platform: str) -> str:
    labels = {
        "xhs": "小红书",
        "dy": "抖音",
        "ks": "快手",
        "bili": "B站",
        "wb": "微博",
        "tieba": "贴吧",
        "zhihu": "知乎",
    }
    return labels.get(platform, platform)


def find_latest_jsonl(data_dir: Path, platform: str) -> Path | None:
    """Find latest search notes jsonl in data/<platform>/jsonl/."""
    base = data_dir / platform / "jsonl"
    if not base.exists():
        return None
    candidates = list(base.glob("search_notes_*.jsonl"))
    if not candidates:
        return None
    return max(candidates, key=lambda p: p.stat().st_mtime)


def load_notes(path: Path) -> list[dict]:
    items = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                items.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return items


def build_report(notes: list[dict], platform: str, keyword: str) -> str:
    plabel = platform_label(platform)
    lines = [
        f"# 媒体爬取报告：{keyword}",
        "",
        f"- **平台**：{plabel}",
        f"- **关键词**：{keyword}",
        f"- **爬取条数**：{len(notes)}",
        "",
        "## 内容概览",
        "",
    ]
    if not notes:
        lines.append("（无数据）")
        return "\n".join(lines)

    # Group by source_keyword if present
    by_kw: dict[str, list] = {}
    for n in notes:
        kw = n.get("source_keyword") or keyword
        by_kw.setdefault(kw, []).append(n)

    for kw, group in by_kw.items():
        lines.append(f"### 关键词「{kw}」共 {len(group)} 条")
        lines.append("")
        for i, n in enumerate(group[:20], 1):  # top 20
            title = n.get("title") or n.get("desc") or "(无标题)"
            desc = (n.get("desc") or "")[:120]
            if desc and (n.get("title") or "") != desc:
                title = f"{title}"
            nickname = n.get("nickname", "")
            liked = n.get("liked_count", "")
            comment = n.get("comment_count", "")
            lines.append(f"{i}. **{title}**")
            if desc:
                lines.append(f"   - 摘要：{desc}…")
            lines.append(f"   - 作者：{nickname} | 点赞：{liked} | 评论：{comment}")
        if len(group) > 20:
            lines.append(f"   - …其余 {len(group) - 20} 条略")
        lines.append("")

    lines.append("## 明细（前 50 条）")
    lines.append("")
    lines.append("| 标题 | 作者 | 点赞 | 评论 | 链接 |")
    lines.append("|------|------|------|------|------|")
    for n in notes[:50]:
        title = (n.get("title") or n.get("desc") or "-")[:40]
        author = (n.get("nickname") or "-")[:12]
        liked = n.get("liked_count", "-")
        comment = n.get("comment_count", "-")
        url = n.get("note_url") or n.get("video_url") or n.get("url") or "-"
        lines.append(f"| {title} | {author} | {liked} | {comment} | {url} |")

    return "\n".join(lines)


def build_voice_script(notes: list[dict], platform: str, keyword: str) -> str:
    plabel = platform_label(platform)
    n = len(notes)
    if n == 0:
        return f"在{plabel}上按关键词「{keyword}」未爬取到内容。"
    # 3–5 句简洁总结
    parts = [
        f"在{plabel}上按关键词「{keyword}」共爬取到 {n} 条内容。",
    ]
    if notes:
        titles = [n.get("title") or n.get("desc") or "" for n in notes[:5] if n.get("title") or n.get("desc")]
        if titles:
            sample = "；".join(t[:20] for t in titles if t)[:80]
            parts.append(f"例如：{sample}。")
    parts.append("报告已生成，可查看 Markdown 文件了解详情。")
    return "".join(parts)


def tts_senseaudio(
    text: str,
    api_key: str,
    voice_id: str = DEFAULT_VOICE_ID,
    output_path: str | Path | None = None,
    stream: bool = False,
) -> Path | None:
    """
    Call SenseAudio TTS API (https://senseaudio.cn/docs/).
    Returns path to saved audio file (mp3), or None on failure.
    """
    if not requests:
        print("(TTS 需要 requests: pip install requests)", file=sys.stderr)
        return None
    payload = {
        "model": "SenseAudio-TTS-1.0",
        "text": text,
        "stream": stream,
        "voice_setting": {"voice_id": voice_id},
        "audio_setting": {"format": "mp3", "sample_rate": 32000},
    }
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    try:
        r = requests.post(SENSEAUDIO_TTS_URL, json=payload, headers=headers, timeout=60)
        r.raise_for_status()
        out = r.json()
    except requests.RequestException as e:
        print(f"SenseAudio API 请求失败: {e}", file=sys.stderr)
        if hasattr(e, "response") and e.response is not None and e.response.text:
            print(e.response.text[:500], file=sys.stderr)
        return None
    except json.JSONDecodeError as e:
        print(f"SenseAudio 响应解析失败: {e}", file=sys.stderr)
        return None

    data = out.get("data")
    base_resp = out.get("base_resp", {})
    if base_resp.get("status_code") != 0:
        print(f"SenseAudio 错误: {base_resp.get('status_msg', out)}", file=sys.stderr)
        return None
    if not data or "audio" not in data:
        print("SenseAudio 响应中无音频数据", file=sys.stderr)
        return None

    raw_hex = data["audio"]
    try:
        audio_bytes = bytes.fromhex(raw_hex)
    except ValueError:
        print("SenseAudio 返回的 audio 非合法 hex", file=sys.stderr)
        return None

    if output_path is None:
        fd, output_path = tempfile.mkstemp(suffix=".mp3")
        os.close(fd)
        output_path = Path(output_path)
    else:
        output_path = Path(output_path)
    output_path.write_bytes(audio_bytes)
    return output_path


def play_audio(path: Path) -> bool:
    """Play audio file (macOS: afplay, Windows: default player, Linux: paplay/aplay/ffplay)."""
    p = str(path.resolve())
    if sys.platform == "darwin":
        try:
            subprocess.run(["afplay", p], check=True)
            return True
        except (subprocess.CalledProcessError, FileNotFoundError):
            pass
    elif sys.platform == "win32":
        try:
            os.startfile(p)
            return True
        except OSError:
            pass
    else:
        for cmd in (["paplay", p], ["aplay", p], ["ffplay", "-nodisp", "-autoexit", p]):
            try:
                subprocess.run(cmd, check=True, capture_output=True)
                return True
            except (FileNotFoundError, subprocess.CalledProcessError):
                continue
    return False


def main() -> None:
    parser = argparse.ArgumentParser(description="Summarize MediaCrawler data and optionally speak.")
    parser.add_argument("--data-dir", type=str, default=str(_SKILL_ROOT / "MediaCrawler" / "data"),
                        help="Path to MediaCrawler data directory (default: {baseDir}/MediaCrawler/data)")
    parser.add_argument("--platform", type=str, default="xhs",
                        help="Platform: xhs, dy, ks, bili, wb, tieba, zhihu")
    parser.add_argument("--keyword", type=str, default="",
                        help="Search keyword (for report title)")
    parser.add_argument("--report", type=str, default="",
                        help="Output report path (default: media_cluster_report_<keyword>_<date>.md)")
    parser.add_argument("--voice", action="store_true",
                        help="Generate TTS with SenseAudio API and save/play audio")
    parser.add_argument("--api-key", type=str, default=os.environ.get("SENSEAUDIO_API_KEY", ""),
                        help="SenseAudio API key (default: from SENSEAUDIO_API_KEY env var)")
    parser.add_argument("--voice-id", type=str, default=os.environ.get("SENSEAUDIO_VOICE_ID", DEFAULT_VOICE_ID),
                        help="SenseAudio voice_id (default: from SENSEAUDIO_VOICE_ID env var or male_0004_a)")
    parser.add_argument("--voice-out", type=str, default="",
                        help="Path to save TTS audio (default: next to report, media_cluster_voice_<keyword>.mp3)")
    parser.add_argument("--file", type=str, default="",
                        help="Direct path to jsonl file (overrides data-dir + platform)")
    args = parser.parse_args()

    if args.file:
        path = Path(args.file)
        if not path.exists():
            print(f"File not found: {path}", file=sys.stderr)
            sys.exit(1)
    else:
        data_dir = Path(args.data_dir)
        path = find_latest_jsonl(data_dir, args.platform)
        if not path:
            print(f"No search_notes_*.jsonl found under {data_dir / args.platform / 'jsonl'}", file=sys.stderr)
            sys.exit(1)

    notes = load_notes(path)
    keyword = args.keyword or notes[0].get("source_keyword", "搜索") if notes else "搜索"

    report_body = build_report(notes, args.platform, keyword)
    voice_script = build_voice_script(notes, args.platform, keyword)

    if args.report:
        report_path = Path(args.report)
    else:
        safe_kw = "".join(c if c.isalnum() or c in " _-" else "_" for c in keyword)[:30]
        report_path = Path(f"media_cluster_report_{safe_kw}_{datetime.now().strftime('%Y%m%d_%H%M')}.md")
    report_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.write_text(report_body, encoding="utf-8")
    print(f"Report written: {report_path}")

    print("\n--- 简洁版语音稿 ---")
    print(voice_script)
    print("---")

    if args.voice:
        [REDACTED_SECRET] or os.environ.get("SENSEAUDIO_API_KEY") or "").strip()
        if not [REDACTED_SECRET]"(TTS 未执行：请设置环境变量 SENSEAUDIO_API_KEY 或使用 --api-key，参见 https://senseaudio.cn/docs/)", file=sys.stderr)
        else:
            voice_out = args.voice_out
            if not voice_out:
                safe_kw = "".join(c if c.isalnum() or c in " _-" else "_" for c in keyword)[:30]
                voice_out = str(report_path.parent / f"media_cluster_voice_{safe_kw}.mp3")
            audio_path = tts_senseaudio(voice_script, api_key, voice_id=args.voice_id, output_path=voice_out)
            if audio_path:
                print(f"(TTS 已保存: {audio_path})")
                if play_audio(audio_path):
                    print("(已播放)")
            else:
                print("(TTS 生成失败，请查看上方错误信息)")


if __name__ == "__main__":
    main()


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