Skill: sskun/news-hot-hub (version 1.0.2)

--- name: news-hot-hub description: 新闻热点数据聚合器——整合知乎、今日头条、AIBase三大平台热搜数据。支持单独获取任一平台热榜，也支持一次性获取所有平台数据并汇总输出。当用户提到"热搜聚合"、"全平台热点"、"各平台热门话题"、"热榜整合"、"热点数据采集"、"我要看全网热点"、"刷一下各平台热榜"、"一键获取热榜"、"知乎头条热榜"、"全网热点"、"多平台热搜"时使用此技能。也可以单独触发某个平台：提到"知乎热搜/知乎热门/知乎数据"触发知乎，提到"头条热榜/今日头条"触发头条，提到"AIBase/AI基地/AI新闻/AI日报"触发AIBase。 --- # news Hot Hub — 中文热点数据聚合器 ## 概述 整合知乎、今日头条、AIBase三大中文平台的热搜/热门数据。采用「调度器 + 独立脚本」插件式架构，每个平台一个独立 Python 脚本，由统一的 `hub.py` 调度。 ## 目录结构 ``` news-hot-hub/ ├── SKILL.md # 本文件 — AI Agent 技能指令 ├── requirements.txt # Python 依赖 ├── scripts/ # 可执行脚本 │ ├── hub.py # 统一调度入口 ★ │ ├── zhihu.py # 知乎（已实现） │ ├── toutiao.py # 今日头条（已实现） │ └── aibase.py # AIBase（已实现） └── references/ # 参考文档 ├── architecture.md # 架构设计 ├── data-schema.md # 数据格式规范 └── platform-guide.md # 平台接入指南 ``` ## 安装 ```bash pip install -r ${SKILL_DIR}/requirements.txt ``` 核心依赖：`requests`, `beautifulsoup4`, `lxml` ## CLI 使用 统一入口：`${SKILL_DIR}/scripts/hub.py` ```bash python ${SKILL_DIR}/scripts/hub.py <command> [options] ``` ### 平台标识 | 全称 | 缩写 | 中文 | |---|---|---| | `zhihu` | `zh` | 知乎 | | `toutiao` | `tt` | 今日头条 | | `aibase` | `ab` | AIBase | ### 命令一览 | 命令 | 说明 | 选项 | |---|---|---| | `fetch <platform>` | 获取指定平台热榜 | `--limit N` | | `all` | 并行获取全部平台 | `--limit N` | | `compare` | 跨平台热点词频对比 | `--limit N` | | `status` | 检查各平台脚本可用性 | — | ### 使用示例 ```bash # 单平台获取 python ${SKILL_DIR}/scripts/hub.py fetch zhihu python ${SKILL_DIR}/scripts/hub.py fetch toutiao --limit 20 python ${SKILL_DIR}/scripts/hub.py fetch aibase python ${SKILL_DIR}/scripts/hub.py fetch aibase daily # 多平台 python ${SKILL_DIR}/scripts/hub.py fetch zhihu,toutiao # 全部平台 python ${SKILL_DIR}/scripts/hub.py all # 跨平台对比 python ${SKILL_DIR}/scripts/hub.py compare # 检查可用性 python ${SKILL_DIR}/scripts/hub.py status ``` ## 触发逻辑 Agent 加载本 skill 后，根据用户意图判断调用方式： 1. **只提单一平台** → `fetch <platform>` 2. **提到多个平台** → `fetch <p1>,<p2>` 逗号分隔 3. **“全部”/“所有”/“一键”/“全平台”** → `all` 4. **“对比”/“比较”/“交叉分析”** → `compare` 5. **不明确** → 询问用户需要哪个/哪些平台 ### AIBase子命令映射 | 用户意图 | 子命令 | |---|---| | AI新闻 | `fetch aibase`（默认 hot-search → news）| | AI日报 | `fetch aibase daily` | | AI新闻+日报 | `fetch aibase all` | ## 输出格式 ### fetch / all JSON Lines（每行一个平台结果）： ```json {"platform": "知乎", "success": true, "data": {"type": "hot_search", "count": 25, "data": [...]}} ``` ### compare 跨平台词频分析 JSON，包含 `top_keywords` 和 `per_platform` 字段。 > 详细数据格式见 `references/data-schema.md` ## 环境变量 | 变量 | 说明 | |---|---| | `HOT_HUB_LIMIT` | 全局默认 limit，CLI `--limit` 可覆盖 | ## 参考文档 需要深入了解时，按需读取 `references/` 目录下的文档： - **架构设计** → `references/architecture.md`：系统架构、数据流、扩展机制 - **数据格式** → `references/data-schema.md`：各平台 JSON 输出字段详细定义 - **接入指南** → `references/platform-guide.md`：新平台接入步骤、脚本模板、认证说明 ## 快速参考 | 需求 | 命令 | |---|---| | 知乎热搜 | `fetch zhihu` | | 头条热榜 | `fetch toutiao` | | AIBase新闻 | `fetch aibase` | | AI日报 | `fetch aibase daily` | | 全部平台 | `all` | | 词频对比 | `compare` | | 可用性检查 | `status` |

path: requirements.txt
content: requests>=2.28.0
beautifulsoup4>=4.12.0
lxml>=4.9.0


path: scripts/__init__.py
content: # news Hot Hub Scripts


path: scripts/hub.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""news Hot Hub — 中文热点数据聚合调度器

统一入口，调度 scripts/ 目录下各平台脚本，支持单平台、多平台、全平台获取与对比。

Usage:
    python hub.py fetch <platform> [subcmd] [--limit N] [--query Q]
    python hub.py all [--limit N]
    python hub.py compare [--limit N]
    python hub.py status
"""

import argparse
import concurrent.futures
import json
import os
import re
import subprocess
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path

# ──────────────────────────── Platform Registry ────────────────────

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

PLATFORMS = {
    "zhihu": {
        "name": "知乎",
        "script": "zhihu.py",
        "default_cmd": "hot-search",
        "commands": ["hot-search"],
    },
    "toutiao": {
        "name": "今日头条",
        "script": "toutiao.py",
        "default_cmd": "hot-search",
        "commands": ["hot-search"],
    },
    "aibase": {
        "name": "AIBase",
        "script": "aibase.py",
        "default_cmd": "hot-search",
        "commands": ["hot-search", "news", "daily", "all"],
    },
}

ALIASES = {
    "zh": "zhihu", "知乎": "zhihu",
    "tt": "toutiao", "头条": "toutiao", "今日头条": "toutiao",
    "aibase": "aibase", "ai基地": "aibase", "ab": "aibase",
}

ALL_KEYS = list(PLATFORMS.keys())


# ──────────────────────────── Helpers ──────────────────────────────

def _now():
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


def _resolve(name: str) -> str:
    """Resolve platform alias to canonical key."""
    name = name.strip().lower()
    return ALIASES.get(name, name)


def _script_path(platform: str) -> Path:
    meta = PLATFORMS.get(platform)
    if not meta:
        return None
    return SCRIPTS_DIR / meta["script"]


def _run_platform(platform: str, subcmd: str = None, limit: int = 50, query: str = None) -> dict:
    """Execute a platform script and return parsed result."""
    meta = PLATFORMS.get(platform)
    if not meta:
        return {"platform": platform, "success": False, "error": f"未知平台: {platform}"}

    script = _script_path(platform)
    if not script or not script.exists():
        return {"platform": meta["name"], "success": False, "error": "脚本文件不存在"}

    subcmd = subcmd or meta["default_cmd"]
    cmd = [sys.executable, str(script), subcmd]

    # 知乎 topic 命令需要 query 参数
    if subcmd == "topic" and query:
        cmd.append(query)

    # 只有支持 --limit 的子命令才传
    no_limit_cmds = {"all"}
    # 知乎 hot-search 不接受 --limit 参数
    if platform == "zhihu":
        no_limit_cmds.add("hot-search")

    if subcmd not in no_limit_cmds:
        cmd.extend(["--limit", str(limit)])

    try:
        result = subprocess.run(
            cmd, capture_output=True, text=True, timeout=30)

        if result.returncode != 0:
            return {
                "platform": meta["name"],
                "success": False,
                "error": result.stderr.strip() or f"退出码 {result.returncode}",
            }

        output = result.stdout.strip()
        if not output:
            return {"platform": meta["name"], "success": False, "error": "输出为空"}

        data = json.loads(output)

        # 检测 not_implemented 脚手架响应
        if isinstance(data, dict) and data.get("error") == "not_implemented":
            return {
                "platform": meta["name"],
                "success": False,
                "error": data.get("message", "脚本尚未实现"),
            }

        return {"platform": meta["name"], "success": True, "data": data}

    except subprocess.TimeoutExpired:
        return {"platform": meta["name"], "success": False, "error": "请求超时 (30s)"}
    except json.JSONDecodeError as e:
        return {"platform": meta["name"], "success": False, "error": f"JSON 解析错误: {e}"}
    except Exception as e:
        return {"platform": meta["name"], "success": False, "error": str(e)}


def _extract_titles(data) -> list:
    """从平台数据中提取标题列表。"""
    titles = []
    if isinstance(data, dict):
        for key in ("data", "hot_search", "hot_question", "hot_video"):
            val = data.get(key)
            if isinstance(val, dict) and "data" in val:
                val = val["data"]
            if isinstance(val, list):
                for item in val:
                    if isinstance(item, dict):
                        t = item.get("title") or item.get("query", "")
                        if t:
                            titles.append(t)
                if titles:
                    break
    return titles


# ──────────────────────────── Commands ─────────────────────────────

def cmd_fetch(args):
    """获取指定平台热榜。"""
    limit = args.limit or int(os.environ.get("HOT_HUB_LIMIT", "50"))
    raw_platforms = args.platforms.split(",")
    results = []

    for p in raw_platforms:
        key = _resolve(p)
        if key not in PLATFORMS:
            print(json.dumps({"platform": p, "success": False,
                  "error": f"未知平台: {p}"}, ensure_ascii=False))
            continue
        res = _run_platform(key, subcmd=args.subcmd,
                            limit=limit, query=args.query)
        results.append(res)

    for res in results:
        print(json.dumps(res, ensure_ascii=False))


def cmd_all(args):
    """并行获取全部平台。"""
    limit = args.limit or int(os.environ.get("HOT_HUB_LIMIT", "50"))

    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
        futures = {pool.submit(_run_platform, p, limit=limit)
                               : p for p in ALL_KEYS}
        for future in concurrent.futures.as_completed(futures):
            res = future.result()
            print(json.dumps(res, ensure_ascii=False))


def cmd_compare(args):
    """跨平台热点词频对比。"""
    limit = args.limit or int(os.environ.get("HOT_HUB_LIMIT", "50"))

    results = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
        futures = {pool.submit(_run_platform, p, limit=limit)
                               : p for p in ALL_KEYS}
        for future in concurrent.futures.as_completed(futures):
            p = futures[future]
            results[p] = future.result()

    per_platform = {}
    keyword_counter = Counter()

    for p, res in results.items():
        name = PLATFORMS[p]["name"]
        if not res["success"]:
            per_platform[name] = {
                "status": "unavailable", "error": res["error"]}
            continue
        titles = _extract_titles(res["data"])
        per_platform[name] = {"status": "ok", "titles": titles}
        for title in titles:
            for seg in re.findall(r"[\u4e00-\u9fff]{2,}", title):
                keyword_counter[seg] += 1
            for word in re.findall(r"[a-zA-Z0-9]{2,}", title):
                keyword_counter[word.lower()] += 1

    # 跨平台关键词统计
    top_keywords = []
    for kw, count in keyword_counter.most_common(30):
        sources = set()
        for name, info in per_platform.items():
            if info["status"] != "ok":
                continue
            if any(kw in t for t in info["titles"]):
                sources.add(name)
        top_keywords.append({
            "keyword": kw,
            "platforms": len(sources),
            "total_mentions": count,
            "platform_names": sorted(sources),
        })

    top_keywords.sort(key=lambda x: (
        x["platforms"], x["total_mentions"]), reverse=True)

    output = {
        "summary": "跨平台热点词频分析",
        "timestamp": _now(),
        "platforms_total": len(ALL_KEYS),
        "platforms_success": sum(1 for v in results.values() if v["success"]),
        "top_keywords": top_keywords[:20],
        "per_platform": per_platform,
    }
    print(json.dumps(output, ensure_ascii=False, indent=2))


def cmd_status(args):
    """检查各平台脚本可用性。"""
    status = {"timestamp": _now(), "scripts_dir": str(
        SCRIPTS_DIR), "platforms": {}}

    for key, meta in PLATFORMS.items():
        script = _script_path(key)
        status["platforms"][meta["name"]] = {
            "key": key,
            "script": meta["script"],
            "available": script is not None and script.exists(),
            "commands": meta["commands"],
        }

    print(json.dumps(status, ensure_ascii=False, indent=2))


# ──────────────────────────── Main ─────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="news Hot Hub — 中文热点数据聚合器",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
示例:
  python hub.py fetch zhihu                     # 知乎热搜
  python hub.py fetch toutiao --limit 20        # 今日头条热榜
  python hub.py fetch aibase                    # AIBase AI新闻
  python hub.py fetch aibase daily              # AIBase AI日报
  python hub.py fetch zhihu,toutiao             # 多平台
  python hub.py all                             # 全部平台
  python hub.py compare                         # 跨平台词频对比
  python hub.py status                          # 脚本可用性

平台缩写: zh=知乎  tt=头条  ab=AIBase
""",
    )
    sub = parser.add_subparsers(dest="command")

    # fetch
    p_fetch = sub.add_parser("fetch", help="获取指定平台热榜")
    p_fetch.add_argument("platforms", help="平台名（逗号分隔多平台，如 zhihu,weibo）")
    p_fetch.add_argument("subcmd", nargs="?", help="子命令（默认 hot-search）")
    p_fetch.add_argument("--query", "-q", help="搜索关键词（知乎 topic 用）")
    p_fetch.add_argument("--limit", type=int, help="结果数量限制")

    # all
    p_all = sub.add_parser("all", help="并行获取全部平台（知乎、今日头条、AIBase）")
    p_all.add_argument("--limit", type=int, help="结果数量限制")

    # compare
    p_cmp = sub.add_parser("compare", help="跨平台热点词频对比分析")
    p_cmp.add_argument("--limit", type=int, help="结果数量限制")

    # status
    sub.add_parser("status", help="检查各平台脚本可用性")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 1

    handlers = {
        "fetch": cmd_fetch,
        "all": cmd_all,
        "compare": cmd_compare,
        "status": cmd_status,
    }

    try:
        return handlers[args.command](args)
    except KeyboardInterrupt:
        print("\n中断。", file=sys.stderr)
        return 130
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1


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


path: scripts/aibase.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AIBase Data Fetcher — AIBase新闻和日报数据获取工具

Self-contained script for news Hot Hub dispatcher.

Usage:
    python aibase.py news [--limit N]
    python aibase.py daily [--limit N]
    python aibase.py all [--limit N]
    python aibase.py hot-search [--limit N]   # alias for 'news' (hub compatibility)
"""

import argparse
import contextlib
import json
import re
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# ──────────────────────────── Constants ────────────────────────────

NEWS_URL = "https://news.aibase.cn/news"
DAILY_URL = "https://news.aibase.cn/daily"

HEADERS = {
    "user-agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    ),
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "accept-language": "zh-CN,zh;q=0.9",
}

# Nuxt data key mapping for each data type
_NUXT_KEYS = {
    "news": "getAINewsList",
    "daily": "getDailyNews",
}

# ──────────────────────────── Helpers ────────────────────────────


def _now():
    """返回带时区的当前时间戳字符串。"""
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


def _output(data_type, data):
    """构建标准输出信封。"""
    result = {
        "type": data_type,
        "timestamp": _now(),
        "count": len(data),
        "data": data,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


def _error(msg):
    """输出错误并退出。"""
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(1)


# ──────────────────────────── HTTP Session ────────────────────────────


@contextlib.contextmanager
def _session():
    """创建带重试机制的HTTP会话（上下文管理器）。"""
    s = requests.Session()
    retry = Retry(total=3, backoff_factor=0.5)
    adapter = HTTPAdapter(max_retries=retry)
    s.mount("http://", adapter)
    s.mount("https://", adapter)
    try:
        yield s
    finally:
        s.close()


# ──────────────────────────── Data Parsing ────────────────────────────


def _parse_nuxt_item(arr: List, idx: int) -> Any:
    """解析单个Nuxt数据项。"""
    if not isinstance(idx, int) or idx >= len(arr):
        return idx
    item = arr[idx]
    if isinstance(item, list) and item:
        tag = item[0]
        if tag in ["ShallowReactive", "Reactive", "Ref"]:
            return _parse_nuxt_item(arr, item[1])
    return item


def _parse_nuxt_data(arr: List, idx: int) -> Any:
    """递归解析Nuxt数据结构。"""
    item = _parse_nuxt_item(arr, idx)
    if isinstance(item, dict):
        result = {}
        for k, v in item.items():
            result[k] = _parse_nuxt_data(arr, v) if isinstance(v, int) else v
        return result
    if isinstance(item, list):
        return [_parse_nuxt_data(arr, i) for i in item]
    return item


def _extract_nuxt_data(html: str) -> Optional[Dict]:
    """从HTML中提取__NUXT_DATA__。"""
    match = re.search(r'<script[^>]*id="__NUXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
    if not match:
        return None
    try:
        raw_data = json.loads(match.group(1))
        return _parse_nuxt_data(raw_data, 0)
    except Exception as e:
        print(f"解析Nuxt数据失败: {e}", file=sys.stderr)
        return None


def _fetch_article_content(session: requests.Session, url: str) -> str:
    """获取文章详细内容（HTML）。"""
    try:
        resp = session.get(url, headers=HEADERS, timeout=15)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")
        content_div = soup.find("div", class_="article-content") or soup.find("article")
        return content_div.decode_contents() if content_div else ""
    except Exception as e:
        print(f"获取文章内容失败 {url}: {e}", file=sys.stderr)
        return ""


# ──────────────────────────── Fetchers ────────────────────────────


def _extract_items(data: Dict, data_type: str) -> List:
    """从Nuxt数据中提取列表项。"""
    nuxt_key = _NUXT_KEYS.get(data_type)
    if not nuxt_key:
        return []
    return data.get("data", {}).get(nuxt_key, {}).get("data", {}).get("list", [])


def _build_item(item: Dict, data_type: str, content: str = "") -> Dict:
    """构建单条结果数据。"""
    article_id = item.get("oid", "")
    result = {
        "id": article_id,
        "title": item.get("title", ""),
        "subtitle": item.get("subtitle", ""),
        "url": f"https://news.aibase.cn/{data_type}/{article_id}",
        "thumb": item.get("thumb", ""),
        "source": item.get("sourceName", ""),
        "create_time": item.get("createTime", ""),
        "pv": item.get("pv", 0),
        "description": item.get("description", ""),
    }
    if content is not None:
        result["content"] = content
    return result


def fetch_data(
    session: requests.Session,
    url: str,
    data_type: str,
    limit: int,
    with_content: bool = True,
) -> List[Dict]:
    """获取列表数据，可选附带文章内容。

    Returns list of items. Raises on HTTP/parsing errors.
    """
    resp = session.get(url, headers=HEADERS, timeout=15)
    resp.raise_for_status()

    data = _extract_nuxt_data(resp.text)
    if not data:
        return []

    items = _extract_items(data, data_type)[:limit]
    results = []
    for item in items:
        content = ""
        if with_content:
            article_url = f"https://news.aibase.cn/{data_type}/{item.get('oid', '')}"
            content = _fetch_article_content(session, article_url)
        results.append(_build_item(item, data_type, content if with_content else None))

    return results


# ──────────────────────────── CLI ────────────────────────────


def main():
    parser = argparse.ArgumentParser(description="AIBase数据获取工具")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # news
    news_parser = subparsers.add_parser("news", help="获取AI新闻资讯")
    news_parser.add_argument("--limit", type=int, default=20, help="获取数量")

    # daily
    daily_parser = subparsers.add_parser("daily", help="获取AI日报")
    daily_parser.add_argument("--limit", type=int, default=20, help="获取数量")

    # all
    all_parser = subparsers.add_parser("all", help="获取所有数据")
    all_parser.add_argument("--limit", type=int, default=20, help="获取数量")

    # hot-search (hub compatibility alias → news)
    hs_parser = subparsers.add_parser("hot-search", help="获取AI新闻资讯（hub兼容）")
    hs_parser.add_argument("--limit", type=int, default=20, help="获取数量")

    args = parser.parse_args()

    try:
        with _session() as session:
            if args.command in ("news", "hot-search"):
                data = fetch_data(session, NEWS_URL, "news", args.limit)
                _output("news", data)

            elif args.command == "daily":
                data = fetch_data(session, DAILY_URL, "daily", args.limit)
                _output("daily", data)

            elif args.command == "all":
                news = fetch_data(session, NEWS_URL, "news", args.limit)
                daily = fetch_data(session, DAILY_URL, "daily", args.limit)
                result = {
                    "news": {"type": "news", "timestamp": _now(), "count": len(news), "data": news},
                    "daily": {"type": "daily", "timestamp": _now(), "count": len(daily), "data": daily},
                }
                print(json.dumps(result, ensure_ascii=False, indent=2))

    except requests.exceptions.RequestException as e:
        _error(f"网络请求失败: {e}")
    except Exception as e:
        _error(f"获取数据失败: {e}")


if __name__ == "__main__":
    main()


path: scripts/toutiao.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""今日头条热榜数据获取 — Toutiao Hot Board Fetcher

获取今日头条热榜数据，输出结构化 JSON。

Usage:
    python toutiao.py hot-search [--limit N]
"""

import argparse
import contextlib
import json
import sys
from datetime import datetime

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


# ──────────────────────────── Constants ────────────────────────────

API_URL = "https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc"

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    ),
    "Accept": "application/json, text/plain, */*",
    "Referer": "https://www.toutiao.com/",
}

RETRIES = Retry(
    total=3,
    backoff_factor=1,
    status_forcelist=[500, 502, 503, 504],
)


# ──────────────────────────── Helpers ────────────────────────────────

def _now():
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


def _output(data_type, data):
    result = {
        "type": data_type,
        "timestamp": _now(),
        "count": len(data),
        "data": data,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


@contextlib.contextmanager
def _session():
    s = requests.Session()
    try:
        s.headers.update(HEADERS)
        s.mount("http://", HTTPAdapter(max_retries=RETRIES))
        s.mount("https://", HTTPAdapter(max_retries=RETRIES))
        yield s
    finally:
        s.close()


# ──────────────────────────── Fetcher ────────────────────────────────

def fetch_hot_search(limit=50):
    """获取今日头条热榜"""
    items = []
    with _session() as s:
        resp = s.get(API_URL, timeout=15)
        resp.raise_for_status()
        raw = resp.json()
        for entry in raw.get("data", [])[:limit]:
            title = entry.get("Title") or entry.get("QueryWord", "")
            if not title:
                continue
            items.append({
                "title": title,
                "url": entry.get("Url", ""),
                "hot": entry.get("HotValue", ""),
                "label": entry.get("Label", ""),
            })
    return items


# ──────────────────────────── CLI ───────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="今日头条热榜数据获取")
    sub = parser.add_subparsers(dest="command")

    p_hs = sub.add_parser("hot-search", help="获取今日头条热榜")
    p_hs.add_argument("--limit", type=int, default=50, help="结果数量 (默认 50)")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    if args.command == "hot-search":
        try:
            data = fetch_hot_search(limit=args.limit)
            _output("hot_search", data)
        except requests.exceptions.HTTPError as e:
            print(f"Error: HTTP error: {e}", file=sys.stderr)
            sys.exit(1)
        except requests.exceptions.ConnectionError:
            print("Error: Network connection failed", file=sys.stderr)
            sys.exit(1)
        except json.JSONDecodeError:
            print("Error: Failed to parse API response", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"Error: {e}", file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/zhihu.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Zhihu Hot Search Fetcher — 知乎热搜榜数据获取工具

获取知乎热搜榜数据，输出结构化 JSON。

Usage:
    python zhihu.py hot-search
"""

import argparse
import contextlib
import json
import sys
import urllib.parse
from datetime import datetime

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# ──────────────────────────── Constants ────────────────────────────

HOT_SEARCH_URL = "https://www.zhihu.com/topsearch"

HEADERS = {
    "x-api-version": "3.0.76",
    "user-agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    ),
    "accept": "application/json, text/plain, */*",
    "accept-language": "zh-CN,zh;q=0.9",
    "referer": "https://www.zhihu.com/",
    "origin": "https://www.zhihu.com",
}

RETRIES = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])


# ──────────────────────────── Helpers ──────────────────────────────

def _now():
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


@contextlib.contextmanager
def _session():
    s = requests.Session()
    try:
        s.headers.update(HEADERS)
        s.mount("http://", HTTPAdapter(max_retries=RETRIES))
        s.mount("https://", HTTPAdapter(max_retries=RETRIES))
        yield s
    finally:
        s.close()


def _output(data_type, data):
    result = {
        "type": data_type,
        "timestamp": _now(),
        "count": len(data),
        "data": data,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


def _error(msg):
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(1)


# ──────────────────────────── Fetcher ──────────────────────────────

def fetch_hot_search():
    """获取知乎热搜榜（公开接口，无需登录）"""
    items = []
    with _session() as s:
        resp = s.get(HOT_SEARCH_URL, timeout=15)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, features="html.parser")
        script = soup.find("script", type="text/json", id="js-initialData")
        if script:
            obj = json.loads(script.string)
            raw_items = obj.get("initialState", {}).get("topsearch", {}).get("data", [])
            for item in raw_items:
                q = item.get("realQuery", "")
                items.append({
                    "title": item.get("queryDisplay", ""),
                    "url": f"https://www.zhihu.com/search?q={urllib.parse.quote(q)}",
                    "query": q,
                })
    return items


# ──────────────────────────── CLI ──────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="知乎热搜榜数据获取工具")
    sub = parser.add_subparsers(dest="command", help="Available commands")
    sub.add_parser("hot-search", help="获取知乎热搜榜")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    try:
        if args.command == "hot-search":
            data = fetch_hot_search()
            _output("hot_search", data)

    except requests.exceptions.HTTPError as e:
        _error(f"HTTP error: {e}")
    except requests.exceptions.ConnectionError:
        _error("Network connection failed")
    except json.JSONDecodeError:
        _error("Failed to parse API response")
    except Exception as e:
        _error(f"Unexpected error: {e}")


if __name__ == "__main__":
    main()


path: references/architecture.md
content: # news Hot Hub 架构文档

## 目录

1. [系统概述](#系统概述)
2. [目录结构](#目录结构)
3. [架构设计](#架构设计)
4. [数据流](#数据流)
5. [平台注册表](#平台注册表)
6. [扩展新平台](#扩展新平台)
7. [版本规划](#版本规划)

---

## 系统概述

news Hot Hub 是一个中文热点数据聚合技能，整合知乎、微博、今日头条、V2EX、36氪、AIBase 六大平台的热搜/热门数据。采用「调度器 + 独立脚本」的插件式架构，每个平台是一个独立的 Python 脚本，由统一的 `hub.py` 调度。

### 设计原则

- **单一职责**：每个平台脚本只负责一个平台的数据获取
- **统一接口**：所有脚本遵循相同的 CLI 参数规范和 JSON 输出格式
- **渐进实现**：V1 先实现知乎，其他平台预留脚手架，V2 逐步补全
- **无状态**：脚本不持久化数据，每次调用独立获取

## 目录结构

```
news-hot-hub/
├── SKILL.md                 # AI Agent 技能描述（触发规则 + 使用指南）
├── requirements.txt         # Python 依赖
├── scripts/                 # 可执行脚本
│   ├── __init__.py
│   ├── hub.py               # 统一调度入口 ★
│   ├── zhihu.py             # 知乎数据获取（V1 已实现）
│   ├── aibase.py            # AIBase 数据获取（V1 已实现）
│   ├── weibo.py             # 微博数据获取（V2 待实现）
│   ├── toutiao.py           # 今日头条数据获取（V2 待实现）
│   ├── v2ex.py              # V2EX 数据获取（V2 待实现）
│   └── kr36.py              # 36氪数据获取（V2 待实现）
└── references/              # 参考文档
    ├── architecture.md      # 本文件 — 架构设计
    ├── data-schema.md       # 数据格式规范
    └── platform-guide.md    # 平台接入指南
```

## 架构设计

```
┌─────────────────────────────────────────────────┐
│                   AI Agent                       │
│  (根据用户意图选择 fetch/all/compare/status)       │
└──────────────────────┬──────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────┐
│               hub.py  (调度器)                    │
│                                                   │
│  ┌─────────┐  ┌─────────┐  ┌──────────────────┐ │
│  │ fetch   │  │  all    │  │    compare       │ │
│  │ (单/多) │  │ (并行)  │  │  (词频分析)      │ │
│  └────┬────┘  └────┬────┘  └────────┬─────────┘ │
│       │            │                │            │
│       ▼            ▼                ▼            │
│  ┌──────────────────────────────────────────┐   │
│  │        Platform Registry (注册表)         │   │
│  │  zhihu | weibo | toutiao | v2ex | 36kr | aibase   │   │
│  └──────────────────┬───────────────────────┘   │
└─────────────────────┼───────────────────────────┘
                      │  subprocess.run()
          ┌───────────┼───────────────────┐
          ▼           ▼                   ▼
    ┌──────────┐ ┌──────────┐      ┌──────────┐
    │ zhihu.py │ │ weibo.py │ ...  │ aibase.py │
    └──────────┘ └──────────┘      └──────────┘
          │           │                   │
          ▼           ▼                   ▼
    ┌──────────┐ ┌──────────┐      ┌──────────┐
    │ 知乎 API │ │ 微博 API │      │ AIBase公开│
    └──────────┘ └──────────┘      └──────────┘
```

### 调度器 (hub.py)

- 解析 CLI 参数，路由到对应命令处理函数
- 通过 `subprocess.run()` 调用各平台脚本，实现进程隔离
- `all` 和 `compare` 命令使用 `ThreadPoolExecutor` 并行调度
- 统一处理超时、错误、JSON 解析

### 平台脚本 (zhihu.py, weibo.py, ...)

- 每个脚本是独立可执行的 CLI 工具
- 输入：CLI 参数（command, --limit, --query 等）
- 输出：JSON 到 stdout，错误信息到 stderr
- 退出码：0 成功，1 失败

## 数据流

```
用户输入 → Agent 解析意图 → hub.py 调度
  → 平台脚本获取数据 → JSON stdout
  → hub.py 收集结果 → 输出给 Agent → 展示给用户
```

### fetch 流程

1. 解析平台名（支持别名和逗号分隔）
2. 顺序调用各平台脚本
3. 逐行输出 JSON Lines

### all 流程

1. 并行启动 6 个平台脚本（ThreadPool, max_workers=5）
2. 逐个收集完成结果
3. 逐行输出 JSON Lines

### compare 流程

1. 并行获取全部平台数据
2. 提取各平台标题列表
3. 分词统计关键词词频（中文 ≥2 字 / 英文 ≥2 字符）
4. 计算跨平台出现次数
5. 输出排序后的词频分析 JSON

## 平台注册表

hub.py 中的 `PLATFORMS` 字典定义了所有已注册平台：

| 字段          | 说明                        |
| ------------- | --------------------------- |
| `name`        | 平台中文名（用于输出展示）  |
| `script`      | scripts/ 目录下的脚本文件名 |
| `default_cmd` | 默认子命令                  |
| `commands`    | 支持的子命令列表            |

别名映射在 `ALIASES` 字典中，支持中文名和英文缩写。

## 扩展新平台

添加新平台只需 3 步：

1. 在 `scripts/` 下新建 `<platform>.py`，遵循标准 CLI 接口（见 `platform-guide.md`）
2. 在 `hub.py` 的 `PLATFORMS` 字典中注册
3. 在 `ALIASES` 中添加别名

无需修改调度逻辑，hub.py 会自动发现和调用。

## 版本规划

| 版本 | 范围                                          | 状态     |
| ---- | --------------------------------------------- | -------- |
| V1   | 知乎完整实现 + 整体架构 + skill 规范          | ✅ 已完成 |
| V1.1 | AIBase 完整实现（AI 新闻 + 日报，附正文内容） | ✅ 已完成 |
| V2   | 微博、头条、V2EX、36氪真实抓取实现            | 待开发   |
| V3   | 定时采集、数据持久化、趋势分析                | 规划中   |


path: references/platform-guide.md
content: # 平台接入指南

## 目录

1. [概述](#概述)
2. [脚本规范](#脚本规范)
3. [接入步骤](#接入步骤)
4. [平台现状](#平台现状)
5. [认证说明](#认证说明)
6. [常见问题](#常见问题)

---

## 概述

本文档指导如何为 news Hot Hub 接入新平台或实现已有平台的真实抓取逻辑。每个平台是一个独立的 Python CLI 脚本，放在 `scripts/` 目录下。

## 脚本规范

### 文件命名

- 使用平台英文名小写，如 `zhihu.py`, `weibo.py`
- 数字开头的平台名加前缀，如 36氪 → `kr36.py`

### CLI 接口约定

每个脚本必须支持以下基本结构：

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""<平台名>数据获取 — <Platform> Fetcher"""

import argparse
import json
import sys
from datetime import datetime


def _now():
    return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")


def _output(data_type, data):
    result = {
        "type": data_type,
        "timestamp": _now(),
        "count": len(data),
        "data": data,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


def fetch_hot_search(limit=50):
    """获取热搜/热榜数据"""
    items = []
    # ... 实际抓取逻辑 ...
    return items


def main():
    parser = argparse.ArgumentParser(description="<平台名>数据获取")
    sub = parser.add_subparsers(dest="command")

    p_hs = sub.add_parser("hot-search", help="获取热搜榜")
    p_hs.add_argument("--limit", type=int, default=50, help="结果数量")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    if args.command == "hot-search":
        data = fetch_hot_search(limit=args.limit)
        _output("hot_search", data)


if __name__ == "__main__":
    main()
```

### 输出规范

- **stdout**：仅输出 JSON 数据
- **stderr**：错误信息和警告
- **退出码**：0 = 成功，1 = 失败
- **编码**：UTF-8，`ensure_ascii=False`

### 每条数据必须包含的字段

| 字段    | 类型   | 说明                         |
| ------- | ------ | ---------------------------- |
| `title` | string | 标题（用于跨平台对比和展示） |
| `url`   | string | 原始链接                     |

其他字段按平台特性自由添加（如 `heat`, `author`, `rank` 等）。

---

## 接入步骤

### 1. 创建脚本

在 `scripts/` 下新建 `<platform>.py`，按上述模板实现。

### 2. 注册平台

编辑 `scripts/hub.py`，在 `PLATFORMS` 字典中添加：

```python
"newplatform": {
    "name": "新平台",
    "script": "newplatform.py",
    "default_cmd": "hot-search",
    "commands": ["hot-search"],
},
```

### 3. 添加别名

在 `ALIASES` 中添加缩写：

```python
"np": "newplatform", "新平台": "newplatform",
```

### 4. 更新文档

- `references/data-schema.md`：添加新平台的数据格式
- `references/architecture.md`：更新平台注册表说明
- `SKILL.md`：在触发词中添加新平台关键词

### 5. 测试

```bash
# 单独测试脚本
python scripts/newplatform.py hot-search --limit 5

# 通过 hub 调度测试
python scripts/hub.py fetch newplatform --limit 5

# 检查状态
python scripts/hub.py status
```

---

## 平台现状

| 平台     | 脚本         | 状态     | 认证               | 说明                                             |
| -------- | ------------ | -------- | ------------------ | ------------------------------------------------ |
| 知乎     | `zhihu.py`   | ✅ 已实现 | Cookie（部分接口） | 支持 hot-search/hot-question/hot-video/topic/all |
| AIBase   | `aibase.py`  | ✅ 已实现 | 无需认证           | 支持 hot-search/news/daily/all，附带正文内容     |
| 微博     | `weibo.py`   | ⏳ 脚手架 | —                  | V2 待实现                                        |
| 今日头条 | `toutiao.py` | ⏳ 脚手架 | —                  | V2 待实现                                        |
| V2EX     | `v2ex.py`    | ⏳ 脚手架 | —                  | V2 待实现                                        |
| 36氪     | `kr36.py`    | ⏳ 脚手架 | —                  | V2 待实现                                        |

---

## 认证说明

### AIBase

AIBase 所有命令均无需认证，直接访问公开页面即可。

### 知乎

知乎的 `hot-search` 命令无需认证即可使用。以下命令需要设置 `ZHIHU_COOKIES` 环境变量：

- `hot-question`（热门话题）
- `hot-video`（热门视频）
- `topic`（关键词搜索）

获取 Cookie 方法：

1. 浏览器登录知乎
2. 打开 DevTools → Network → 任意 zhihu.com 请求
3. 复制 Request Headers 中的 `Cookie` 值
4. 设置环境变量：`export ZHIHU_COOKIES="key1=val1; key2=val2"`

### 其他平台

V2 实现时根据各平台 API 特性决定认证方式。优先使用无认证的公开接口。

---

## 常见问题

### Q: 脚手架脚本返回 not_implemented 怎么办？

这是预期行为。V1 版本只有知乎实现了真实抓取，其他平台是占位脚手架。`hub.py` 会正确识别并在结果中标注失败原因。

### Q: 如何判断平台脚本是否可用？

```bash
python scripts/hub.py status
```

### Q: 新增平台需要修改 hub.py 的调度逻辑吗？

不需要。只需在 `PLATFORMS` 注册表中添加条目，调度逻辑自动适配。

### Q: 脚本超时怎么办？

hub.py 默认 30 秒超时。如果平台 API 响应慢，可以在平台脚本内部实现重试逻辑（参考 zhihu.py 的 `Retry` 配置）。


path: references/data-schema.md
content: # 数据格式规范

## 目录

1. [通用输出格式](#通用输出格式)
2. [知乎数据格式](#知乎数据格式)
3. [微博数据格式](#微博数据格式)
4. [今日头条数据格式](#今日头条数据格式)
5. [V2EX 数据格式](#v2ex-数据格式)
6. [36氪数据格式](#36氪数据格式)
7. [AIBase 数据格式](#aibase-数据格式)
8. [聚合输出格式](#聚合输出格式)
9. [错误格式](#错误格式)

---

## 通用输出格式

所有平台脚本的标准输出遵循统一的 JSON 结构：

```json
{
  "type": "<data_type>",
  "timestamp": "YYYY-MM-DD HH:MM:SS ±HHMM",
  "count": <int>,
  "data": [...]
}
```

| 字段        | 类型   | 说明                                            |
| ----------- | ------ | ----------------------------------------------- |
| `type`      | string | 数据类型标识（如 `hot_search`, `hot_question`） |
| `timestamp` | string | 获取时间，带时区                                |
| `count`     | int    | data 数组元素数量                               |
| `data`      | array  | 数据条目数组                                    |

### 输出约定

- JSON 输出到 **stdout**
- 错误信息输出到 **stderr**
- 退出码：`0` 成功，`1` 失败
- 编码：UTF-8，`ensure_ascii=False`

---

## 知乎数据格式

### hot_search（热搜榜）

```json
{
  "title": "话题标题",
  "url": "https://www.zhihu.com/search?q=...",
  "query": "原始搜索词"
}
```

### hot_question（热门话题）

```json
{
  "title": "问题标题",
  "url": "https://www.zhihu.com/question/...",
  "heat": "1234 万热度",
  "excerpt": "问题描述摘要"
}
```

### hot_video（热门视频）

```json
{
  "title": "视频标题",
  "url": "https://www.zhihu.com/zvideo/...",
  "heat": "567 万热度",
  "author": "作者名"
}
```

### topic（关键词搜索）

```json
{
  "title": "问题标题",
  "url": "https://www.zhihu.com/question/...",
  "answer_count": 150,
  "follower_count": 2300
}
```

### all（全部热榜）

```json
{
  "type": "all",
  "timestamp": "...",
  "hot_search": { "count": 25, "data": [...] },
  "hot_question": { "count": 50, "data": [...] },
  "hot_video": { "count": 50, "data": [...] }
}
```

---

## 微博数据格式

> V2 待实现，以下为规划格式

### hot_search（热搜榜）

```json
{
  "title": "热搜话题",
  "url": "https://s.weibo.com/weibo?q=...",
  "rank": 1,
  "heat": "5678901",
  "category": "热|新|沸"
}
```

---

## 今日头条数据格式

> V2 待实现，以下为规划格式

### hot_search（热榜）

```json
{
  "title": "新闻标题",
  "url": "https://www.toutiao.com/trending/...",
  "heat": "999999",
  "source": "来源媒体"
}
```

---

## V2EX 数据格式

> V2 待实现，以下为规划格式

### hot_search（热门主题）

```json
{
  "title": "帖子标题",
  "url": "https://www.v2ex.com/t/...",
  "node": "程序员",
  "replies": 42,
  "author": "用户名"
}
```

---

## 36氪数据格式

> V2 待实现，以下为规划格式

### hot_search（热门新闻）

```json
{
  "title": "文章标题",
  "url": "https://36kr.com/p/...",
  "summary": "文章摘要",
  "publish_time": "2026-03-24 10:00:00",
  "author": "作者/机构"
}
```

---

## AIBase 数据格式

### news / hot-search（AI 新闻资讯）

```json
{
  "id": 26543,
  "title": "文章标题",
  "subtitle": "文章副标题",
  "url": "https://news.aibase.cn/news/26543",
  "thumb": "https://pic.newsz.com/.../thumb.jpg",
  "source": "AIbase基地",
  "create_time": "2026-03-25 14:11:57",
  "pv": 7359,
  "description": "文章摘要描述",
  "content": "<div>...</div>"
}
```

### daily（AI 日报）

```json
{
  "id": 1234,
  "title": "日报标题",
  "subtitle": "日报副标题",
  "url": "https://news.aibase.cn/daily/1234",
  "thumb": "https://pic.newsz.com/.../thumb.jpg",
  "source": "AIbase基地",
  "create_time": "2026-03-25 08:00:00",
  "pv": 12345,
  "description": "日报摘要",
  "content": "<div>...</div>"
}
```

| 字段          | 类型   | 说明                    |
| ------------- | ------ | ----------------------- |
| `id`          | int    | 文章唯一 ID             |
| `title`       | string | 文章标题                |
| `subtitle`    | string | 副标题                  |
| `url`         | string | 文章链接                |
| `thumb`       | string | 封面图 URL              |
| `source`      | string | 来源名称                |
| `create_time` | string | 发布时间                |
| `pv`          | int    | 浏览量                  |
| `description` | string | 摘要                    |
| `content`     | string | 正文 HTML（需自行解析） |

### all（新闻 + 日报合并）

```json
{
  "news": {
    "type": "news",
    "timestamp": "...",
    "count": 20,
    "data": [...]
  },
  "daily": {
    "type": "daily",
    "timestamp": "...",
    "count": 20,
    "data": [...]
  }
}
```

> `hot-search` 是 `news` 的别名，用于兼容 hub.py 的统一调度接口。

---

## 聚合输出格式

### hub.py fetch / all 输出

JSON Lines 格式（每行一个平台结果）：

```jsonl
{"platform": "知乎", "success": true, "data": {...}}
{"platform": "微博", "success": false, "error": "脚本尚未实现"}
```

| 字段       | 类型   | 说明                               |
| ---------- | ------ | ---------------------------------- |
| `platform` | string | 平台中文名                         |
| `success`  | bool   | 是否成功获取                       |
| `data`     | object | 成功时返回的数据（同平台脚本输出） |
| `error`    | string | 失败时的错误信息                   |

### hub.py compare 输出

```json
{
  "summary": "跨平台热点词频分析",
  "timestamp": "...",
  "platforms_total": 5,
  "platforms_success": 3,
  "top_keywords": [
    {
      "keyword": "AI",
      "platforms": 3,
      "total_mentions": 8,
      "platform_names": ["知乎", "微博", "36氪"]
    }
  ],
  "per_platform": {
    "知乎": { "status": "ok", "titles": ["...", "..."] },
    "微博": { "status": "unavailable", "error": "..." }
  }
}
```

---

## 错误格式

### 脚手架（未实现）响应

```json
{
  "error": "not_implemented",
  "message": "XXX 抓取待 V2 实现"
}
```

### 运行时错误

```json
{
  "platform": "知乎",
  "success": false,
  "error": "HTTP error: 401 Unauthorized"
}
```


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