Skill: jacobzwj/bilibili-hot-monitor (version 1.0.21)

--- name: bilibili-monitor description: 生成B站热门视频日报并发送邮件。触发词：B站热门、bilibili日报、视频日报、热门视频 metadata: {"openclaw":{"emoji":"📺","requires":{"bins":["python3"]},"os":["darwin","linux","win32"]}} --- # B站热门视频日报 ## 🔒 安全说明 - 所有凭据仅存储在用户本地设备上，Skill 发布包中不包含任何凭据 - 配置文件 `bilibili-monitor.json` 已通过 `.gitignore` 排除，不会被意外上传或分享 - 网络传输使用 HTTPS 和 TLS/STARTTLS 加密 - 同时支持环境变量和命令行参数传递凭据，用户可自行选择 ## 执行流程（分步询问） ### 检查配置文件 首先检查是否存在配置文件： ```bash test -f {baseDir}/bilibili-monitor.json && echo "CONFIG_EXISTS" || echo "CONFIG_NOT_EXISTS" ``` - 如果输出 `CONFIG_EXISTS` → 跳到【直接执行】 - 如果输出 `CONFIG_NOT_EXISTS` → 进入【分步创建配置】 --- ### 分步创建配置（首次使用） **第1步：询问 B站 Cookies** ``` 请提供 B站 Cookies： （获取方法：登录B站首页 → F12 → Network选项卡 → 刷新页面 → 点击 www.bilibili.com 请求 → 找到 Request Headers 中的 Cookie 字段 → 复制整个值） ``` 等待用户回复，保存为变量 `COOKIES` **第2步：询问 AI 服务** ``` AI 功能说明： - 需要 OpenRouter API Key - 用于生成视频内容总结（基于字幕）和 AI 点评 是否启用 AI 功能？ 1 = 是（推荐，需要 OpenRouter API Key） 2 = 否（将无法生成视频总结和点评） 请回复数字： ``` 等待用户回复 **第3步：如果选了 1（启用 AI）** ``` 请选择模型： 1 = Gemini（推荐，便宜快速） 2 = Claude（高质量） 3 = GPT 4 = DeepSeek（性价比） ``` 等待用户回复，然后： ``` 请提供 OpenRouter API Key： 获取地址：https://openrouter.ai/keys ``` 保存为 `OPENROUTER_KEY` 和 `MODEL` **第4步：询问发件邮箱** ``` 请提供 Gmail 发件邮箱： ``` 等待用户回复，保存为 `SMTP_EMAIL` **第5步：询问应用密码** ``` 请提供 Gmail 应用密码（16位）： 获取地址：https://myaccount.google.com/apppasswords ``` 保存为 `SMTP_PASSWORD` **第6步：询问收件人** ``` 请提供收件人邮箱（多个用逗号分隔）： ``` 保存为 `RECIPIENTS` **第7步：生成配置文件** 根据收集的信息创建配置文件： ```bash cat > {baseDir}/bilibili-monitor.json << 'EOF' { "bilibili": { "cookies": "COOKIES值" }, "ai": { "openrouter_key": "OPENROUTER_KEY值或空", "model": "MODEL值" }, "email": { "smtp_email": "SMTP_EMAIL值", "smtp_password": "SMTP_PASSWORD值", "recipients": ["收件人1", "收件人2"] }, "report": {"num_videos": 10} } EOF ``` --- ### 确认并执行 向用户展示确认信息： ``` ✅ 配置已就绪 🚀 即将开始执行：获取热门视频 → 提取字幕 → AI生成总结和点评 → 发送邮件 ⏱️ 预计耗时：10-15 分钟，请耐心等待 是否开始执行？ ``` 等待用户确认后，执行以下命令。 ⚠️ **AI Agent 注意事项**： - 脚本执行需要 10-15 分钟，这是正常的，请设置超时 900 秒以上 - 脚本会在 25%、50%、75%、100% 时自动输出进度，**请只转发脚本实际输出** - **不要**在等待期间发送"等待中..."、"继续等待..."等自定义消息，会导致刷屏 - 执行完成后再向用户汇报结果即可 **生成报告：** ```bash python3 {baseDir}/generate_report.py --config {baseDir}/bilibili-monitor.json --output /tmp/bilibili_report.md ``` **发送邮件（邮件标题自动使用当前日期）：** ```bash python3 {baseDir}/send_email.py --config {baseDir}/bilibili-monitor.json --body-file /tmp/bilibili_report.md --html ``` --- ## OpenRouter 模型映射 | 用户选择 | model 值 | |---------|---------| | 1 / Gemini | google/gemini-3-flash-preview | | 2 / Claude | anthropic/claude-sonnet-4.5 | | 3 / GPT | openai/gpt-5.2-chat | | 4 / DeepSeek | deepseek/deepseek-chat-v3-0324 | ## 配置文件示例 见 `bilibili-monitor.example.json` ## ⏱️ 执行时间 | 阶段 | 预计时间 | |------|---------| | 获取视频列表 | 5-10 秒 | | 字幕提取+AI总结（20个视频） | 2-3 分钟 | | AI点评（20个视频） | 8-12 分钟 | | 生成报告+发送邮件 | 10-20 秒 | | **总计** | **10-15 分钟** | ⚠️ 完整执行需要 10-15 分钟，请确保命令超时设置足够长（建议 900 秒以上）。 ## ⚠️ 重要提示 **AI 视频总结说明：** - 视频总结基于字幕生成，需要视频有字幕（CC字幕或AI字幕） - 部分视频可能没有字幕，这些视频将无法生成总结 - 推荐启用 AI 功能以获得完整的视频分析体验 - 需要 OpenRouter API Key（支持 Gemini、Claude、GPT、DeepSeek 等模型）

path: .gitignore
content: # Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/

# 生成的报告文件（保留示例）
report*.md
!example_report.md

# 包含敏感信息的文件
.env
*.env
config.json
bilibili-monitor.json

# 测试文件
test_*.py

# Claude Code 缓存
.claude/

# IDE
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db


path: bilibili-monitor.example.json
content: {
  "ai": {
    "model": "google/gemini-3-flash-preview"
  },
  "email": {
    "recipients": ["[REDACTED_SECRET]", "[REDACTED_SECRET]"]
  },
  "report": {
    "num_videos": 10
  }
}


path: bilibili_api.py
content: #!/usr/bin/env python3
"""
B站 API 工具集

包含：
- WBI 签名生成
- 热门视频获取
- AI 视频总结获取
- 视频详情获取

参考文档：
- https://socialsisteryi.github.io/bilibili-API-collect/docs/misc/sign/wbi.html
- https://socialsisteryi.github.io/bilibili-API-collect/docs/video/summary.html
"""

import hashlib
import json
import time
import urllib.parse
from functools import reduce
from typing import Any

import requests


# WBI 签名用的混淆表
MIXIN_KEY_ENC_TAB = [
    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
    27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
    37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
    22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
]


class BilibiliAPI:
    """B站 API 客户端"""

    def __init__(self, sessdata: str = "", bili_jct: str = "", buvid3: str = "", dedeuserid: str = "", all_cookies: dict = None):
        """
        初始化 API 客户端

        Args:
            sessdata: SESSDATA cookie
            bili_jct: bili_jct cookie
            buvid3: buvid3 cookie
            dedeuserid: DedeUserID cookie
            all_cookies: 所有 cookies 字典（可选，直接设置全部 cookies）
        """
        self.session = requests.Session()
        self.session.headers.update({
            "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",
            "Referer": "https://www.bilibili.com",
        })

        # 如果提供了全部 cookies，直接设置
        if all_cookies:
            for key, value in all_cookies.items():
                self.session.cookies.set(key, value, domain=".bilibili.com")
        else:
            # 设置单独的 cookies
            if sessdata:
                self.session.cookies.set("SESSDATA", sessdata, domain=".bilibili.com")
            if bili_jct:
                self.session.cookies.set("bili_jct", bili_jct, domain=".bilibili.com")
            if buvid3:
                self.session.cookies.set("buvid3", buvid3, domain=".bilibili.com")
            if dedeuserid:
                self.session.cookies.set("DedeUserID", dedeuserid, domain=".bilibili.com")

        # WBI 密钥缓存
        self._img_key = ""
        self._sub_key = ""

    def _get_mixin_key(self, orig: str) -> str:
        """生成混淆后的密钥"""
        return reduce(lambda s, i: s + orig[i], MIXIN_KEY_ENC_TAB, '')[:32]

    def _get_wbi_keys(self) -> tuple[str, str]:
        """获取 WBI 签名所需的 img_key 和 sub_key"""
        if self._img_key and self._sub_key:
            return self._img_key, self._sub_key

        resp = self.session.get("https://api.bilibili.com/x/web-interface/nav")
        data = resp.json()

        if data["code"] != 0:
            raise Exception(f"获取 WBI 密钥失败: {data['message']}")

        img_url = data["data"]["wbi_img"]["img_url"]
        sub_url = data["data"]["wbi_img"]["sub_url"]

        # 从 URL 中提取密钥
        self._img_key = img_url.rsplit('/', 1)[1].split('.')[0]
        self._sub_key = sub_url.rsplit('/', 1)[1].split('.')[0]

        return self._img_key, self._sub_key

    def _sign_params(self, params: dict[str, Any]) -> dict[str, Any]:
        """对请求参数进行 WBI 签名"""
        img_key, sub_key = self._get_wbi_keys()
        mixin_key = self._get_mixin_key(img_key + sub_key)

        # 添加时间戳
        params["wts"] = int(time.time())

        # 按 key 排序
        params = dict(sorted(params.items()))

        # 过滤特殊字符并 URL 编码
        query = urllib.parse.urlencode(params)

        # 计算签名
        w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
        params["w_rid"] = w_rid

        return params

    def get_popular_videos(self, page: int = 1, page_size: int = 20) -> list[dict]:
        """
        获取热门视频列表

        Args:
            page: 页码
            page_size: 每页数量

        Returns:
            视频列表
        """
        resp = self.session.get(
            "https://api.bilibili.com/x/web-interface/popular",
            params={"pn": page, "ps": page_size}
        )
        data = resp.json()

        if data["code"] != 0:
            raise Exception(f"获取热门视频失败: {data['message']}")

        return data["data"]["list"]

    def get_video_info(self, bvid: str) -> dict:
        """
        获取视频详情

        Args:
            bvid: 视频 BV 号

        Returns:
            视频信息
        """
        resp = self.session.get(
            "https://api.bilibili.com/x/web-interface/view",
            params={"bvid": bvid}
        )
        data = resp.json()

        if data["code"] != 0:
            raise Exception(f"获取视频信息失败: {data['message']}")

        return data["data"]

    def get_ai_summary(self, bvid: str, cid: int, up_mid: int) -> dict | None:
        """
        获取视频 AI 总结

        Args:
            bvid: 视频 BV 号
            cid: 视频 cid
            up_mid: UP主 mid

        Returns:
            AI 总结信息，如果没有则返回 None
        """
        params = {
            "bvid": bvid,
            "cid": cid,
            "up_mid": up_mid,
            "web_location": "333.788",  # 网页位置标识
        }

        # WBI 签名
        signed_params = self._sign_params(params)

        # 增加额外的请求头以模拟浏览器
        headers = {
            "Origin": "https://www.bilibili.com",
            "Accept": "application/json, text/plain, */*",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        }

        resp = self.session.get(
            "https://api.bilibili.com/x/web-interface/view/conclusion/get",
            params=signed_params,
            headers=headers
        )
        data = resp.json()

        if data["code"] != 0:
            print(f"[WARNING] 获取 AI 总结失败 ({bvid}): {data['message']}")
            return None

        return data["data"]

    def get_video_subtitle(self, bvid: str, aid: int = None, cid: int = None) -> list[dict] | None:
        """
        获取视频字幕（使用 wbi/v2 API）

        Args:
            bvid: 视频 BV 号
            aid: 视频 aid（如果不提供，会自动获取）
            cid: 视频 cid（如果不提供，会自动获取）

        Returns:
            字幕列表，如果没有则返回 None
        """
        # 如果没有提供 aid 或 cid，先获取视频信息
        if aid is None or cid is None:
            try:
                video_info = self.get_video_info(bvid)
                aid = video_info.get("aid")
                cid = video_info.get("cid")
            except Exception:
                return None

        # 使用 wbi/v2 API（与 bilibili_subtitle.py 一致）
        resp = self.session.get(
            "https://api.bilibili.com/x/player/wbi/v2",
            params={"aid": aid, "cid": cid}
        )
        data = resp.json()

        if data["code"] != 0:
            return None

        subtitles = data.get("data", {}).get("subtitle", {}).get("subtitles", [])
        if not subtitles:
            return None

        return subtitles

    def download_subtitle(self, subtitle_url: str) -> list[dict] | None:
        """
        下载字幕内容

        Args:
            subtitle_url: 字幕文件 URL

        Returns:
            字幕内容列表，每项包含 from, to, content
        """
        # 确保 URL 以 https 开头
        if subtitle_url.startswith("//"):
            subtitle_url = "https:" + subtitle_url

        try:
            resp = self.session.get(subtitle_url)
            data = resp.json()
            return data.get("body", [])
        except Exception as e:
            print(f"  [WARNING] 下载字幕失败: {e}")
            return None

    def get_video_subtitle_text(self, bvid: str, aid: int = None, cid: int = None, prefer_lang: str = "zh") -> str | None:
        """
        获取视频字幕的纯文本内容

        Args:
            bvid: 视频 BV 号
            aid: 视频 aid（可选，不提供会自动获取）
            cid: 视频 cid（可选，不提供会自动获取）
            prefer_lang: 优先语言，默认 "zh"（中文）

        Returns:
            字幕纯文本，如果没有则返回 None
        """
        subtitles = self.get_video_subtitle(bvid, aid, cid)
        if not subtitles:
            return None

        # 优先选择中文字幕
        selected_subtitle = None
        for sub in subtitles:
            lan = sub.get("lan", "")
            if prefer_lang in lan or f"ai-{prefer_lang}" in lan:
                selected_subtitle = sub
                break

        if not selected_subtitle:
            selected_subtitle = subtitles[0]

        subtitle_url = selected_subtitle.get("subtitle_url", "")
        if not subtitle_url:
            return None

        subtitle_body = self.download_subtitle(subtitle_url)
        if not subtitle_body:
            return None

        # 转换为纯文本
        return "\n".join(item.get("content", "") for item in subtitle_body)


def format_duration(seconds: int) -> str:
    """格式化时长"""
    m, s = divmod(seconds, 60)
    h, m = divmod(m, 60)
    if h > 0:
        return f"{h}小时{m}分{s}秒"
    return f"{m}分{s}秒"


def format_number(num: int) -> str:
    """格式化数字（万）"""
    if num >= 10000:
        return f"{num / 10000:.1f}万"
    return str(num)


def format_timestamp(ts: int) -> str:
    """格式化时间戳"""
    import datetime
    dt = datetime.datetime.fromtimestamp(ts)
    return dt.strftime("%Y-%m-%d %H:%M")


def main():
    """测试函数"""
    import argparse

    parser = argparse.ArgumentParser(description="B站 API 工具")
    parser.add_argument("--sessdata", required=True, help="SESSDATA cookie")
    parser.add_argument("--bili-jct", default="", help="bili_jct cookie")
    parser.add_argument("--buvid3", default="", help="buvid3 cookie")
    parser.add_argument("--dedeuserid", default="", help="DedeUserID cookie")
    parser.add_argument("--action", choices=["popular", "summary", "info"], default="popular")
    parser.add_argument("--bvid", help="视频 BV 号（用于 summary 和 info）")
    parser.add_argument("--output", help="输出文件路径")

    args = parser.parse_args()

    api = BilibiliAPI(
        sessdata=args.sessdata,
        bili_jct=args.bili_jct,
        buvid3=args.buvid3,
        dedeuserid=args.dedeuserid,
    )

    if args.action == "popular":
        videos = api.get_popular_videos()
        print(f"获取到 {len(videos)} 个热门视频")

        for i, video in enumerate(videos[:5], 1):
            print(f"\n{i}. {video['title']}")
            print(f"   UP主: {video['owner']['name']}")
            print(f"   播放: {format_number(video['stat']['view'])}")

    elif args.action == "info":
        if not args.bvid:
            print("错误：需要提供 --bvid 参数")
            return

        info = api.get_video_info(args.bvid)
        print(f"标题: {info['title']}")
        print(f"UP主: {info['owner']['name']} (mid: {info['owner']['mid']})")
        print(f"cid: {info['cid']}")
        print(f"播放: {format_number(info['stat']['view'])}")

    elif args.action == "summary":
        if not args.bvid:
            print("错误：需要提供 --bvid 参数")
            return

        # 先获取视频信息
        info = api.get_video_info(args.bvid)
        print(f"视频: {info['title']}")

        # 获取 AI 总结
        summary = api.get_ai_summary(
            bvid=args.bvid,
            cid=info["cid"],
            up_mid=info["owner"]["mid"]
        )

        if summary:
            print(f"\nAI 总结:")
            if summary.get("model_result"):
                result = summary["model_result"]
                print(f"摘要: {result.get('summary', '无')}")
                if result.get("outline"):
                    print("\n大纲:")
                    for item in result["outline"]:
                        print(f"  - {item.get('title', '')}: {item.get('content', '')}")
        else:
            print("该视频没有 AI 总结")


if __name__ == "__main__":
    main()


path: bilibili_subtitle.py
content: """
B站视频字幕获取工具
获取指定B站视频的字幕并打印出来
"""

import re
import json
import sys
import io
import requests
from typing import Optional

# 修复Windows命令行编码问题
if sys.platform == "win32":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')


def bv_to_av(bv: str) -> int:
    """将BV号转换为AV号"""
    table = [REDACTED_SECRET]
    tr = {table[i]: i for i in range(58)}
    s = [11, 10, 3, 8, 4, 6]
    xor = 177451812
    add = 8728348608

    r = sum(tr[bv[s[i]]] * (58 ** i) for i in range(6))
    return (r - add) ^ xor


def extract_bvid(url: str) -> Optional[str]:
    """从URL中提取BV号"""
    # 匹配 BV 号的正则表达式
    pattern = r'BV[a-zA-Z0-9]+'
    match = re.search(pattern, url)
    if match:
        return match.group()
    return None


def get_video_info(bvid: str) -> dict:
    """获取视频信息，包括aid和cid"""
    url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
    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",
        "Referer": "https://www.bilibili.com"
    }

    response = requests.get(url, headers=headers)
    data = response.json()

    if data["code"] != 0:
        raise Exception(f"获取视频信息失败: {data.get('message', '未知错误')}")

    return data["data"]


def get_subtitle_info(aid: int, cid: int, sessdata: str = "") -> list:
    """获取字幕信息"""
    url = f"https://api.bilibili.com/x/player/wbi/v2?aid={aid}&cid={cid}"

    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",
        "Referer": "https://www.bilibili.com"
    }

    cookies = {}
    if sessdata:
        cookies["SESSDATA"] = sessdata

    response = requests.get(url, headers=headers, cookies=cookies)
    data = response.json()

    if data["code"] != 0:
        raise Exception(f"获取字幕信息失败: {data.get('message', '未知错误')}")

    subtitles = data.get("data", {}).get("subtitle", {}).get("subtitles", [])
    return subtitles


def download_subtitle(subtitle_url: str) -> list:
    """下载字幕内容"""
    # 确保URL以https开头
    if subtitle_url.startswith("//"):
        subtitle_url = "https:" + subtitle_url

    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",
        "Referer": "https://www.bilibili.com"
    }

    response = requests.get(subtitle_url, headers=headers)
    data = response.json()

    return data.get("body", [])


def format_time(seconds: float) -> str:
    """将秒数格式化为时间字符串"""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    secs = seconds % 60
    return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"


def print_subtitle(subtitle_body: list):
    """打印字幕内容"""
    print("\n" + "=" * 60)
    print("字幕内容")
    print("=" * 60 + "\n")

    for item in subtitle_body:
        from_time = format_time(item["from"])
        to_time = format_time(item["to"])
        content = item["content"]
        print(f"[{from_time} --> {to_time}]")
        print(f"  {content}\n")


def get_bilibili_subtitle(url: str, sessdata: str = ""):
    """
    主函数：获取B站视频字幕

    参数:
        url: B站视频URL
        sessdata: 可选，登录后的SESSDATA cookie，用于获取需要登录的字幕
    """
    print(f"正在处理视频: {url}")

    # 1. 提取BV号
    bvid = extract_bvid(url)
    if not bvid:
        raise Exception("无法从URL中提取BV号")
    print(f"BV号: {bvid}")

    # 2. 获取视频信息
    video_info = get_video_info(bvid)
    aid = video_info["aid"]
    cid = video_info["cid"]
    title = video_info["title"]

    print(f"视频标题: {title}")
    print(f"AID: {aid}, CID: {cid}")

    # 3. 获取字幕列表
    subtitles = get_subtitle_info(aid, cid, sessdata)

    if not subtitles:
        print("\n该视频没有可用的字幕")
        print("提示: 如果视频有CC字幕或AI字幕，可能需要登录才能获取")
        print("请提供SESSDATA cookie（从浏览器中获取）")
        return None

    print(f"\n找到 {len(subtitles)} 个字幕:")
    for i, sub in enumerate(subtitles):
        lang = sub.get("lan_doc", sub.get("lan", "未知"))
        print(f"  {i + 1}. {lang}")

    # 4. 下载并显示第一个字幕（通常是中文或AI生成的字幕）
    # 优先选择中文字幕
    selected_subtitle = None
    for sub in subtitles:
        lan = sub.get("lan", "")
        if "zh" in lan or "ai-zh" in lan:
            selected_subtitle = sub
            break

    if not selected_subtitle:
        selected_subtitle = subtitles[0]

    subtitle_url = selected_subtitle.get("subtitle_url", "")
    if not subtitle_url:
        raise Exception("字幕URL为空")

    print(f"\n正在下载字幕: {selected_subtitle.get('lan_doc', '未知语言')}")

    # 5. 下载并打印字幕
    subtitle_body = download_subtitle(subtitle_url)
    print_subtitle(subtitle_body)

    return subtitle_body


def subtitle_to_text(subtitle_body: list) -> str:
    """将字幕转换为纯文本"""
    return "\n".join(item["content"] for item in subtitle_body)


def print_cookie_help():
    """打印获取Cookie的帮助信息"""
    print("""
================================================================================
如何获取 SESSDATA Cookie:
================================================================================
1. 打开浏览器，访问 https://www.bilibili.com 并登录
2. 按 F12 打开开发者工具
3. 点击 "Application"（应用程序）标签页
4. 在左侧找到 "Cookies" -> "https://www.bilibili.com"
5. 在右侧列表中找到 "SESSDATA"
6. 复制它的值（Value列）
7. 将复制的值填入代码中的 sessdata 变量

注意：SESSDATA 是你的登录凭证，请勿泄露给他人！
================================================================================
""")


if __name__ == "__main__":
    import sys

    # 默认测试URL
    default_url = "https://www.bilibili.com/video/BV11d6rB4EnK/"

    # 支持命令行参数
    if len(sys.argv) > 1:
        video_url = sys.argv[1]
    else:
        video_url = default_url

    # 如果需要登录才能获取字幕，请填入你的SESSDATA
    # 获取方法见 print_cookie_help() 的输出
    sessdata = ""  # 填入你的SESSDATA

    # 如果命令行提供了第二个参数作为SESSDATA
    if len(sys.argv) > 2:
        sessdata = sys.argv[2]

    try:
        subtitle = get_bilibili_subtitle(video_url, sessdata)

        if subtitle:
            # 也可以导出为纯文本
            print("\n" + "=" * 60)
            print("纯文本版本")
            print("=" * 60)
            print(subtitle_to_text(subtitle))
        else:
            print_cookie_help()

    except Exception as e:
        print(f"错误: {e}")
        print_cookie_help()


path: example_report.md
content: # B站热门视频日报

**生成时间**：2026-01-31 12:37:07

---

## 📋 本期热门视频（2026-01-31）

| 排名 | 视频标题 | 播放量 | 亮点 | 链接 |
|:---:|---------|:------:|------|:----:|
| 1 | 和今汐小卡一起在桂林画中游 【2026鸣潮新春会】 | 26.7万 | 游戏相关 | [打开视频](https://www.bilibili.com/video/BV1Ua6cBDEaF) |
| 2 | 「万灵和鸣」山海幻想群像手书【2026鸣潮新春会】 | 25.4万 | 🔥硬币最高 | [打开视频](https://www.bilibili.com/video/BV1Cy6cBkEoS) |
| 3 | Buzzy - 鲶鱼Mixtape「Official Audio」 | 10.3万 | 长视频深度内容 | [打开视频](https://www.bilibili.com/video/BV1RP6iBoEXq) |
| 4 | 新三国，但是一口气唱完所有逆天台词 | 56.2万 | 🔥播放量最高 | [打开视频](https://www.bilibili.com/video/BV1sp6wB2EEc) |
| 5 | 我是全网最丑免费加速器，很抱歉以这样的方式认识大家 | 17.1万 | 热门内容 | [打开视频](https://www.bilibili.com/video/BV1tG6FB1EUe) |
| 6 | 30岁的千万负翁，躺在上海出租屋的地板上思考人生。【UP主年终总结】 | 27.5万 | 长视频深度内容 | [打开视频](https://www.bilibili.com/video/BV1AG6qBGEsA) |
| 7 | 40年前的隐藏细节！30000字极致拉片《闪灵》视听解析，一口气看过瘾！ | 7.4万 | 长视频深度内容 | [打开视频](https://www.bilibili.com/video/BV1Qs6YB1E4t) |
| 8 | 老外摄影师拍中国乡村，这不是你想象中的扶贫故事 | 11.0万 | 热门内容 | [打开视频](https://www.bilibili.com/video/BV1KP6qB2Evz) |
| 9 | “后羿为什么不射工作日” | 36.4万 | 🔥点赞最高 | [打开视频](https://www.bilibili.com/video/BV1pczfBNEZb) |
| 10 | 腾讯为什么一直不愿意放弃元梦之星？ | 36.6万 | 热门内容 | [打开视频](https://www.bilibili.com/video/BV1Xp6cBoE2d) |

---

## 🌟 本期亮点

1. **播放量冠军**：《新三国，但是一口气唱完所有逆天台词》56.2万
2. **点赞数冠军**：《“后羿为什么不射工作日”》3.3万
3. **硬币数冠军**：《「万灵和鸣」山海幻想群像手书【2026鸣潮新春会】》1.6万
4. **分享数冠军**：《新三国，但是一口气唱完所有逆天台词》4,736

---

## 1. 和今汐小卡一起在桂林画中游 【2026鸣潮新春会】

- **UP主**：姜生花_
- **时长**：3分20秒 | **发布时间**：2026-01-30 19:50

**📊 数据统计**：
> 播放 26.7万 | 点赞 2.4万 | 收藏 7803 | 硬币 9427 | 弹幕 747 | 评论 627 | 分享 1,651

**📝 视频简介**：
> 终于跳了！特别喜欢！桂林的山水太美啦～ 喜欢这期视频请投给我一个免费的硬币吧！ 节目名：画中游 卡提希娅：姜生花 今汐：dudu冯玉憬 今汐舞者：龙傲娇 摄像：黑椒 统筹：血盟文化 妆造：叶咩 后勤：执着 舞美参考：刘宇 原创编舞：夫仔 BGM：《画中游》 王秋实

**🤖 B站官方AI总结**：
> 今汐小卡在桂林如诗如画的春景中，乘小舟轻摇，穿越烟雨朦胧、柳影飘香的街巷，感受夕阳微凉与万物生机，以清丽嗓音吟唱春意，将满目春色融入诗行，展现烟雨中邂逅的海棠之美与不负人间的悠然心境，意境唯美流转。
>
> **内容大纲**：
> • **桂林烟雨春色悠游画卷**
>   - 描绘桂林烟雨朦胧春景与婉转动听的旋律
>   - 以春色漫巷展现街巷风拂柳香的诗意旅程
> • **桂林烟雨春光摇曳画卷**
>   - 开篇以吟唱描绘桂林烟雨春景与灵动画面
>   - 反复咏叹红妆美景与漫步西墙的诗意意境

**💡 Claude点评**：
> 鸣潮新春会高质量舞蹈视频，桂林山水实景拍摄。9427硬币说明观众高度认可，游戏+国风+实景结合非常吸引眼球。

**📈 运营爆款分析**：
> **爆款因素**：蹭官方活动热度+桂林实景+游戏IP。**数据亮点**：硬币接近点赞40%。**成功关键**：专业团队+官方背书。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1Ua6cBDEaF)

---

## 2. 「万灵和鸣」山海幻想群像手书【2026鸣潮新春会】

- **UP主**：可靠de伏特加
- **时长**：2分12秒 | **发布时间**：2026-01-30 20:30

**📊 数据统计**：
> 播放 25.4万 | 点赞 2.5万 | 收藏 9621 | 硬币 1.6万 | 弹幕 1,208 | 评论 1,051 | 分享 2,444

**📝 视频简介**：
> 又是一种全新的频率，索拉里斯还有这样的可能性？ BGM：《浮光》—周深 企划：可靠de伏特加 执行监督：小发 统筹/分镜：观晴 监督：观晴、雨晴、了川 原画：雨晴、了川、侑荼、和香澄、八川、伊芙、柳川秋、青衫、橡皮、小修、阿欲、言衍、lemon、柠檬、喵白、阿步、池池、淡无、清风、茶茶 视频监督：乐宸 后期：小马、梨子、风心、Akki、强仔、YN、四一、昇 L2D/SPINE：叶随、huangEL、小维 封面：小马

**🤖 B站官方AI总结**：
> 2026鸣潮新春会《万灵和鸣》手书，通过诗意歌词描绘短暂相遇与深长思念，以“指尖拨像蝴蝶”“眼眸装满世界”等意象，交织离别、誓言与时光流转，传递沧海桑田中的永恒陪伴，强调在有限人生中与所爱共度流年的动人情感。
>
> **内容大纲**：
> • **沧海桑田共度流年如梦**
>   - 指尖拨动蝴蝶意象，演绎短暂相遇与无声岁月之梦
>   - 眼眸映照世界，承诺细数流年与沧海桑田共度
> • **万灵和鸣新春庆典启幕**
>   - 视频以「万灵和鸣」主题呈现山海幻想群像故事

**💡 Claude点评**：
> 本期硬币冠军（1.6万）！20位原画师群像手书，周深《浮光》作BGM。分享数2444说明粉丝愿意主动传播。

**📈 运营爆款分析**：
> **爆款因素**：官方活动+20人团队+周深BGM三重加持。**数据亮点**：硬币点赞比64%。**成功关键**：群体创作+优质音乐。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1Cy6cBkEoS)

---

## 3. Buzzy - 鲶鱼Mixtape「Official Audio」

- **UP主**：8uzzy5un
- **时长**：51分4秒 | **发布时间**：2026-01-30 15:29

**📊 数据统计**：
> 播放 10.3万 | 点赞 5682 | 收藏 2520 | 硬币 5145 | 弹幕 969 | 评论 3,349 | 分享 1,468

**🤖 B站官方AI总结**：
> Buzzy通过《鲶鱼Mixtape》展现自我成长、内心挣扎与独立精神，融合幽默自嘲、社会批判及街头文化，反思压力与选择，强调坚持音乐纯粹性、突破行业束缚，传递逆境中坚守初心、追求自由与艺术表达的励志态度，彰显山东rapper的自信与独特魅力。
>
> **内容大纲**：
> • **Buzzy自述说唱成长与坚持**
>   - 自我调侃不完美却坚持真实自我
>   - 反映前20年人生曲折与追求内心舒坦
> • **坚韧追梦的说唱人生旅程**
>   - 青春迷茫与自我救赎的挣扎对抗命运
>   - 跨地域奋斗历程与坚韧说唱信念坚守
> • **Buzzy 鲶鱼Mixtape狂野说唱**
>   - 展现自信奢华生活场景与酒局社交互动
>   - 强调财富积累与低调不露痕迹的格局

**💡 Claude点评**：
> 51分钟说唱Mixtape能登热门非常难得！"鲶鱼"概念有记忆点（搅动行业、激发活力），展现说唱圈的活力。

**📈 运营爆款分析**：
> **爆款因素**：超长内容展现诚意+"鲶鱼"独特概念定位。**数据亮点**：评论数高，讨论热情。**成功关键**：真诚表达+差异化定位。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1RP6iBoEXq)

---

## 4. 新三国，但是一口气唱完所有逆天台词

- **UP主**：赵导Zshiwu
- **时长**：3分15秒 | **发布时间**：2026-01-29 21:24

**📊 数据统计**：
> 播放 56.2万 | 点赞 1.9万 | 收藏 7149 | 硬币 7714 | 弹幕 646 | 评论 1,075 | 分享 4,736

**📝 视频简介**：
> 看完折棒爷的吐槽，星夜赶出来的视频

**🤖 B站官方AI总结**：
> 通过一口气唱诵新三国剧中夸张、戏谑的逆天台词，融合历史角色与滑稽桥段，展现刘备、曹操等人物的经典语录与荒诞情境，字幕内容错漏混杂，凸显幽默风格，串联起乱世纷争、权谋反转与角色个性，形成独特的解构式三国演绎。
>
> **内容大纲**：
> • **新三国乱世豪情逆天台词**
>   - 唱段融合三国角色经典台词展现逆天情节
>   - 对比人物命运冲突凸显忠义与权谋矛盾
> • **新三国角色狂拽台词混搭**
>   - 合唱三国角色经典逆天台词展现戏剧张力
>   - 以夸张歌词调侃历史人物与武功套路反差

**💡 Claude点评**：
> 本期播放量冠军（56.2万）+分享数冠军（4736）！新三国"逆天台词"用唱的方式演绎，是典型B站鬼畜二创。

**📈 运营爆款分析**：
> **爆款因素**：蹭热点速度快+经典鬼畜二创+怀旧IP。**数据亮点**：分享播放比0.84%，病毒传播效果极佳。**成功关键**：搞笑内容天然传播性。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1sp6wB2EEc)

---

## 5. 我是全网最丑免费加速器，很抱歉以这样的方式认识大家

- **UP主**：给梨加速器
- **时长**：4分10秒 | **发布时间**：2026-01-29 17:00

**📊 数据统计**：
> 播放 17.1万 | 点赞 8844 | 收藏 8682 | 硬币 8206 | 弹幕 164 | 评论 1,705 | 分享 1,114

**📝 视频简介**：
> geilijiasu.com，暗号「GLFREE」 ，终身VIP直接送，给梨致力于成为义父们最好用的加速器而奋斗！

**🤖 B站官方AI总结**：
> “夜骑环皮软件”团队以极简UI和免费模式打造加速器，虽被调侃“全网最丑”，但专注技术优化，投入300万专线资源、12次线路升级，覆盖30余款热门游戏，坚持不盈利、零工资运营，目标实现30万用户规模后改版UI，强调免费商业模式的体面与用户至上的服务理念。
>
> **内容大纲**：
> • **丑陋免费加速器坚持质量与初心**
>   - 团队专注加速质量投入资源无营销注重实用体验
>   - 源自游戏需求创业打造永久免费加速器解决用户痛点
> • **免费加速器极致优化与坚守**
>   - 采用高价优质线路确保上车点至游戏服务器最短路径
>   - 持续优化12次线路升级与3万IP采购保障低延迟

**💡 Claude点评**：
> 绝佳"自嘲式营销"案例！"全网最丑"主动暴露短板反而建立信任。收藏数8600几乎等于点赞数，说明用户真想尝试。

**📈 运营爆款分析**：
> **爆款因素**：反向营销引发好奇+"免费"直击痛点+技术投入展示专业。**数据亮点**：收藏率5%。**成功关键**：痛点营销+自嘲人设。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1tG6FB1EUe)

---

## 6. 30岁的千万负翁，躺在上海出租屋的地板上思考人生。【UP主年终总结】

- **UP主**：智能路障
- **时长**：57分34秒 | **发布时间**：2026-01-30 20:21

**📊 数据统计**：
> 播放 27.5万 | 点赞 1.6万 | 收藏 7855 | 硬币 1.3万 | 弹幕 2,469 | 评论 2,526 | 分享 1,206

**🤖 B站官方AI总结**：
> UP主30岁千万负翁经历，从18岁家庭变故、游戏代刷起步，历经打工、投机致富与负债，反思原生家庭影响、价值异化与心理挣扎，最终通过知识创作、自我觉醒摆脱生存困境，实现从工具性生存到内在价值认同的蜕变与成长。
>
> **内容大纲**：
> • **三十岁负翁回顾贫富人生路**
>   -  30岁反思时光流逝，18岁到30年间的起伏与未变的不安底色
>   -  12年从迷茫少年到行业顶峰，却仍困于生存逻辑与生活困境
> • **反思傲慢与原生家庭影响**
>   - 深层傲慢源于原生家庭价值缺失与自我价值绑定
>   - 白干工作折射出自我贬低与对剥削者无原则妥协
> • **底层挣扎与知识救赎之路**
>   -  极端压力引发植物神经紊乱与持续焦虑症，濒死感迫使就医
>   -  底层经验与海量阅读融合，构建系统性内容创作能力与知识输出

**💡 Claude点评**：
> 57分钟深度年终总结，从18岁讲到30岁千万负债，内容极其真实深刻。弹幕2500+说明观众全程高度投入。

**📈 运营爆款分析**：
> **爆款因素**：标题"千万负翁"极具冲击力+年终时间节点。**数据亮点**：弹幕密度高，全程参与。**成功关键**：真实经历+深度反思。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1AG6qBGEsA)

---

## 7. 40年前的隐藏细节！30000字极致拉片《闪灵》视听解析，一口气看过瘾！

- **UP主**：不善言辞的秃秃
- **时长**：1小时52分58秒 | **发布时间**：2026-01-30 17:30

**📊 数据统计**：
> 播放 7.4万 | 点赞 4213 | 收藏 2028 | 硬币 2002 | 弹幕 430 | 评论 242 | 分享 241

**📝 视频简介**：
> 文案：秃秃 文案协助：﻿@菲林的解码日记 ﻿ 文案解读彩蛋：﻿@录音系何某人_Grace ﻿ ﻿@小鱼陪拉片 ﻿ ﻿@辣椒梦话 ﻿ 参考资料来源： （传媒论坛 《闪灵》音效于人物塑造和受众效果的影响 李敬平） （文献 汤舜杰.斯坦利·库布里克电影的空间叙事研究 南京艺术学） （文献 孙潘,周荣.女性主义地理学视角下《闪灵》中的女性空间） （钱江晚报） 闪灵30号首次在内地上映 这是我们作为影视UP狂喜的一件事 之前秃秃还转发朋友菲林的闪灵拉片 结果非常可惜 菲林闪灵第一期内容没几天就被下

**🤖 B站官方AI总结**：
> （该视频暂无官方AI总结）

**💡 Claude点评**：
> 30000字极致拉片解析《闪灵》，42分钟深度内容。这类"考古+深度解读"的影视分析在B站有忠实受众。

**📈 运营爆款分析**：
> **爆款因素**：经典电影《闪灵》40周年+"30000字"展示工作量。**数据亮点**：硬币点赞比高。**成功关键**：经典IP+深度解读。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1Qs6YB1E4t)

---

## 8. 老外摄影师拍中国乡村，这不是你想象中的扶贫故事

- **UP主**：Craig也叫快克
- **时长**：17分39秒 | **发布时间**：2026-01-30 20:36

**📊 数据统计**：
> 播放 11.0万 | 点赞 8667 | 收藏 1710 | 硬币 5522 | 弹幕 603 | 评论 314 | 分享 167

**📝 视频简介**：
> 一路颠簸了好几个小时，我终于抵达了贵州深山里的台里村。 这里是苗族聚居的山村，下车的那一刻，芦笙的声音在山谷里回荡，是这个地方最熟悉的背景音。 还没来得及反应，我就被一群好奇的孩子团团围住——他们显然没见过外国人，更没见过扛着相机出现在小学里的那种。 我来这儿的原因其实很简单。一位朋友，一名长期做足球教育的教练，带着来自各地的教练们来到台里，为这里的孩子们做一次足球教学。而我，只是想用镜头把这段经历记录下来，顺便看看，真实的台里到底是什么样子。 这些年，我听过太多关于中国乡村“被改变”“被带出来”的故

**🤖 B站官方AI总结**：
> 摄影师探访贵州泰伊村，记录当地苗族学生通过足球诊所接受专业训练，展现足球如何成为改变乡村教育与个人成长的纽带。孩子们在艰苦环境中专注训练，教练以坚韧、团结等价值观引导，村民热情好客，社区紧密互助，揭示足球为偏远地区青少年打开未来可能性，传递教育扶贫与文化传承的温暖故事。
>
> **内容大纲**：
> • **外摄影师记录贵州乡村足球助力计划**
>   - 泰伊村保留苗族独特木构建筑与传统文化
>   - 足球教育助力山村青年发展未来机会
> • **乡村足球助力成长与社区温情**
>   - 当地特色涅奥菜虽难吃但体现文化独特性
>   - 足球训练营培养坚韧、勇敢与团队精神

**💡 Claude点评**：
> 老外摄影师Craig深入贵州苗族山村记录足球教育项目，17分钟纪录片质量很高。"外国人看中国"视角有天然吸引力。

**📈 运营爆款分析**：
> **爆款因素**：标题"这不是你想象中的扶贫故事"制造认知反差+外国人视角。**数据亮点**：硬币点赞比65%。**成功关键**：跨文化视角+真实记录。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1KP6qB2Evz)

---

## 9. “后羿为什么不射工作日”

- **UP主**：深渊薯片
- **时长**：2分20秒 | **发布时间**：2026-01-29 11:27

**📊 数据统计**：
> 播放 36.4万 | 点赞 3.3万 | 收藏 2.5万 | 硬币 6575 | 弹幕 154 | 评论 291 | 分享 1,431

**📝 视频简介**：
> 番剧：超时空辉夜姬 Bgm：上班族狂想曲—媛小小 求三连啊！！！！！！！！！！

**🤖 B站官方AI总结**：
> （该视频暂无官方AI总结）

**💡 Claude点评**：
> 本期点赞冠军（3.3万）！"后羿为什么不射工作日"精准戳中打工人痛点。收藏数2.5万（收藏播放比7%）。

**📈 运营爆款分析**：
> **爆款因素**：神话+打工人痛点创意结合+节后返工时间节点完美。**数据亮点**：收藏播放比7%极高。**成功关键**：创意标题+共鸣+短视频。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1pczfBNEZb)

---

## 10. 腾讯为什么一直不愿意放弃元梦之星？

- **UP主**：清酒好可怜
- **时长**：7分11秒 | **发布时间**：2026-01-30 12:00

**📊 数据统计**：
> 播放 36.6万 | 点赞 1.7万 | 收藏 3672 | 硬币 3942 | 弹幕 695 | 评论 822 | 分享 795

**🤖 B站官方AI总结**：
> 腾讯持续投入《元梦之星》的原因，通过玩法创新（如农场、竞技、山海寻灵等）和跨界联动（卡哇伊、喜羊羊等IP），将其转型为长线运营的平台型游戏，强调覆盖多代用户并构建虚拟社区，山海寻灵模式作为重磅更新展现高自由度与丰富内容，彰显腾讯对其长期战略价值的坚定支持。
>
> **内容大纲**：
> • **腾讯持续投入圆梦之星创新玩法**
>   - 持续创新玩法与联动IP拓展用户群体
>   - 从派对游戏转型为长线社区型平台游戏
> • **山海寻灵整合多玩法提升游戏体验**
>   - 集成探索、捕捉、建造、社交多元玩法满足多元玩家
>   - 丰富开服福利与联动资源降低玩家初期门槛

**💡 Claude点评**：
> 商业分析向视频，探讨腾讯为何坚持做《元梦之星》。这类"商业逻辑+游戏产业"分析内容在B站有稳定受众。

**📈 运营爆款分析**：
> **爆款因素**：腾讯+元梦之星两大热点话题+商业分析视角。**数据亮点**：播放量36万，话题热度高。**成功关键**：热点话题+专业分析。

🔗 [点击观看视频](https://www.bilibili.com/video/BV1Xp6cBoE2d)

---

## 📊 本期数据概览

| 指标 | 数值 |
|------|------|
| 视频总数 | 10 |
| 总播放量 | 254.5万 |
| 最高播放 | 《新三国，但是一口气唱完所有逆天台词...》56.2万 |
| 最高点赞 | 《“后羿为什么不射工作日”...》3.3万 |

---

*报告由 Claude + B站 API 自动生成*

path: generate_report.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站热门视频日报生成器

获取热门视频列表，通过字幕提取+LLM生成视频总结和点评。

使用方法：
    python generate_report.py --config bilibili-monitor.json --output report.md
"""

import argparse
import datetime
import io
import json
import os
import sys
import time
from pathlib import Path

import requests

# 修复 Windows 控制台编码问题
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

# 导入本地模块
from bilibili_api import BilibiliAPI, format_duration, format_number, format_timestamp


def call_openrouter(api_key: str, model: str, prompt: str, max_tokens: int = 500, max_retries: int = 3) -> str:
    """
    调用 OpenRouter API 生成内容（带重试机制）

    Args:
        [REDACTED_SECRET] API Key
        model: 模型名称，如 "anthropic/claude-sonnet-4.5"
        prompt: 提示词
        max_tokens: 最大生成 token 数
        max_retries: 最大重试次数

    Returns:
        生成的文本
    """
    for attempt in range(max_retries):
        try:
            response = requests.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": model,
                    "messages": [{"role": "user", "content": prompt}],
                    "max_tokens": max_tokens,
                    # 禁用所有模型的 extended thinking/reasoning 模式
                    # effort: "none" 完全禁用推理模式，避免 JSON 输出被截断或格式错乱
                    # 适用于 Claude、Gemini、DeepSeek 等支持 reasoning 的模型
                    "reasoning": {
                        "effort": "none"
                    },
                },
                timeout=60,
            )

            if response.status_code != 200:
                print(f"  [WARNING] OpenRouter API 错误: {response.status_code}")
                if attempt < max_retries - 1:
                    time.sleep(2)  # 等待2秒后重试
                    continue
                return ""

            data = response.json()
            return data["choices"][0]["message"]["content"].strip()

        except (requests.exceptions.SSLError, requests.exceptions.ConnectionError) as e:
            # SSL 或连接错误，等待后重试
            if attempt < max_retries - 1:
                print(f"  [RETRY] 网络错误，{2*(attempt+1)}秒后重试 ({attempt+1}/{max_retries})...")
                time.sleep(2 * (attempt + 1))  # 递增等待时间
                continue
            print(f"  [WARNING] OpenRouter 调用失败（已重试{max_retries}次）: {e}")
            return ""

        except Exception as e:
            print(f"  [WARNING] OpenRouter 调用失败: {e}")
            return ""

    return ""


def _extract_summary_from_incomplete_json(text: str) -> str:
    """
    从不完整的 JSON 中提取 summary 字段

    当 LLM 返回的 JSON 被截断时，尝试提取已有的 summary 内容
    """
    import re

    # 尝试匹配 "summary": "..." 模式
    # 支持多种引号格式
    patterns = [
        r'"summary"\s*:\s*"([^"]+)"',  # 标准双引号
        r'"summary"\s*:\s*"([^"]*)',    # 可能被截断的双引号
    ]

    for pattern in patterns:
        match = re.search(pattern, text, re.DOTALL)
        if match:
            summary = match.group(1).strip()
            # 清理可能的转义字符和截断内容
            summary = summary.replace('\\n', ' ').replace('\\r', ' ')
            summary = re.sub(r'\s+', ' ', summary)  # 合并多余空格
            if len(summary) > 20:  # 确保提取到有意义的内容
                return summary

    return ""


def generate_video_summary_from_subtitle(api_key: str, model: str, title: str, subtitle_text: str) -> dict:
    """
    使用 OpenRouter LLM 根据字幕生成视频总结（模拟B站AI总结格式）

    Args:
        [REDACTED_SECRET] API Key
        model: 模型名称
        title: 视频标题
        subtitle_text: 字幕纯文本

    Returns:
        包含 summary 和 outline 的字典
    """
    if not subtitle_text or not [REDACTED_SECRET] {"summary": "", "outline": []}

    # 限制字幕长度，避免超出 token 限制
    max_chars = 8000
    if len(subtitle_text) > max_chars:
        subtitle_text = subtitle_text[:max_chars] + "..."

    prompt = f"""你是一位专业的视频内容分析师。请根据以下视频字幕，生成一份结构化的视频总结。

视频标题：{title}

视频字幕：
{subtitle_text}

请按照以下JSON格式输出（直接输出JSON，不要加任何其他内容）：
{{
    "summary": "一段100字以内的视频内容概述，概括视频的主要内容和核心观点",
    "outline": [
        {{
            "title": "第一部分标题",
            "part_outline": [
                {{"content": "要点1"}},
                {{"content": "要点2"}}
            ]
        }},
        {{
            "title": "第二部分标题",
            "part_outline": [
                {{"content": "要点1"}},
                {{"content": "要点2"}}
            ]
        }}
    ]
}}

要求：
1. summary 要简洁精炼，抓住视频核心内容
2. outline 按照视频内容的逻辑结构分为2-4个部分
3. 每个部分包含2-3个关键要点
4. 语言要简洁明了，直接陈述内容
5. 只输出JSON，不要有任何其他文字"""

    result = call_openrouter(api_key, model, prompt, max_tokens=1000)

    if not result:
        return {"summary": "", "outline": []}

    # 解析 JSON 结果
    try:
        # 清理 markdown 代码块包裹
        clean_result = result.strip()
        if clean_result.startswith("```"):
            # 移除 ```json 或 ``` 开头
            lines = clean_result.split('\n')
            if lines[0].startswith("```"):
                lines = lines[1:]  # 移除第一行
            if lines and lines[-1].strip() == "```":
                lines = lines[:-1]  # 移除最后一行
            clean_result = '\n'.join(lines)

        # 尝试提取 JSON 部分（处理可能的额外文本）
        json_start = clean_result.find('{')
        json_end = clean_result.rfind('}') + 1
        if json_start >= 0 and json_end > json_start:
            json_str = clean_result[json_start:json_end]
            data = json.loads(json_str)
            return {
                "summary": data.get("summary", ""),
                "outline": data.get("outline", [])
            }
    except json.JSONDecodeError:
        # JSON 解析失败，尝试从不完整的 JSON 中提取 summary（静默处理）
        summary = _extract_summary_from_incomplete_json(result)
        if summary:
            return {"summary": summary, "outline": []}

    # 如果 JSON 解析失败，尝试从文本中提取有用信息
    summary = _extract_summary_from_incomplete_json(result)
    if summary:
        return {"summary": summary, "outline": []}

    # 如果是纯文本（不是 JSON 格式），可以作为摘要使用
    if not result.strip().startswith('{') and not result.strip().startswith('```'):
        return {"summary": result[:300].strip() if result else "", "outline": []}

    return {"summary": "", "outline": []}


def generate_ai_comment(api_key: str, model: str, video_info: dict) -> tuple[str, str]:
    """
    使用 OpenRouter LLM 生成 AI 点评和运营爆款分析
    
    Args:
        [REDACTED_SECRET] API Key
        model: 模型名称
        video_info: 视频信息字典
    
    Returns:
        (ai_comment, viral_analysis) 元组
    """
    title = video_info["title"]
    stat = video_info["stat"]
    ai_summary = video_info.get("ai_summary", "")
    like_rate = video_info.get("like_rate", 0)
    
    prompt = f"""你是一位B站内容分析专家。请根据以下视频信息，生成两段简短的分析：

视频标题：{title}
播放量：{format_number(stat['view'])}
点赞数：{format_number(stat['like'])}
收藏数：{format_number(stat['favorite'])}
硬币数：{format_number(stat['coin'])}
弹幕数：{stat['danmaku']:,}
评论数：{stat['reply']:,}
分享数：{stat['share']:,}
点赞率：{like_rate:.1f}%
B站AI总结：{ai_summary if ai_summary else '暂无'}

请生成：
1. **AI点评**（1-2句话，分析视频为何受欢迎，内容特点，或值得关注的地方）
2. **运营爆款分析**（使用固定格式：**爆款因素**：xxx **数据亮点**：xxx **成功关键**：xxx）

要求：
- 语言简洁有力，像专业的内容运营分析
- 每个部分不超过50字
- 直接输出内容，不要加额外的标题或序号

输出格式（严格遵守）：
AI点评：[你的点评内容]
运营分析：**爆款因素**：[内容] **数据亮点**：[内容] **成功关键**：[内容]"""

    # 调用 OpenRouter API
    result = call_openrouter(api_key, model, prompt)
    
    if not result:
        return "", ""
    
    # 解析结果
    ai_comment = ""
    viral_analysis = ""
    
    lines = result.split("\n")
    for line in lines:
        line = line.strip()
        if not line:
            continue
        # 匹配 AI点评
        if "AI点评" in line:
            parts = line.split("：", 1) if "：" in line else line.split(":", 1)
            if len(parts) > 1:
                ai_comment = parts[1].strip()
        # 匹配运营分析
        elif "运营分析" in line or "**爆款因素**" in line:
            if "：" in line:
                parts = line.split("：", 1)
                viral_analysis = parts[1].strip() if len(parts) > 1 else line
            elif ":" in line:
                parts = line.split(":", 1)
                viral_analysis = parts[1].strip() if len(parts) > 1 else line
            else:
                viral_analysis = line.strip()
    
    # 如果解析失败，尝试直接使用整个返回作为点评
    if not ai_comment and not viral_analysis and result:
        ai_comment = result.split("\n")[0][:100] if result else ""
    
    return ai_comment, viral_analysis


def _generate_data_analysis(vd):
    """根据视频数据自动生成数据分析"""
    stat = vd["stat"]
    like_rate = vd.get("like_rate", 0)
    
    analysis = []
    
    # 播放量分析
    views = stat["view"]
    if views >= 1000000:
        analysis.append(f"播放量{views // 10000}万，现象级热度")
    elif views >= 500000:
        analysis.append(f"播放量{views // 10000}万+，热度极高")
    elif views >= 100000:
        analysis.append(f"播放量{views // 10000}万+")
    else:
        analysis.append(f"播放量{views:,}")
    
    # 点赞率分析
    if like_rate >= 20:
        analysis.append(f"点赞率{like_rate:.0f}%（极高）")
    elif like_rate >= 10:
        analysis.append(f"点赞率{like_rate:.0f}%（优秀）")
    elif like_rate >= 5:
        analysis.append(f"点赞率{like_rate:.0f}%")
    
    # 硬币点赞比
    coin_like_ratio = stat["coin"] / stat["like"] * 100 if stat["like"] > 0 else 0
    if coin_like_ratio >= 50:
        analysis.append(f"硬币点赞比{coin_like_ratio:.0f}%（高投币意愿）")
    
    # 互动数据
    if stat["reply"] >= 1000:
        analysis.append(f"评论{stat['reply']}条（热议）")
    if stat["danmaku"] >= 1000:
        analysis.append(f"弹幕{stat['danmaku']:,}条")
    
    return "；".join(analysis[:4])


def _get_video_tag(vd):
    """根据视频内容生成标签"""
    title = vd["title"].lower()
    duration = vd["duration"]
    
    if duration < 60:
        return "超短视频"
    elif duration > 30 * 60:
        return "长视频深度内容"
    elif "说唱" in title or "rap" in title:
        return "说唱音乐"
    elif "游戏" in title or "原神" in title or "鸣潮" in title:
        return "游戏相关"
    elif "舞" in title or "跳" in title:
        return "舞蹈"
    elif "吃" in title or "美食" in title:
        return "美食"
    elif "vlog" in title or "日常" in title:
        return "生活记录"
    else:
        return "热门内容"


def generate_report(
    api: BilibiliAPI,
    num_videos: int = 10,
    delay: float = 1.0,
    openrouter_key: str = "",
    model: str = "google/gemini-3-flash-preview",
) -> str:
    """
    生成热门视频报告
    """
    import sys

    def print_progress(phase: str, current: int, total: int, detail: str = ""):
        """打印进度条（只在关键节点输出，避免刷屏）"""
        percentage = int((current / total) * 100) if total > 0 else 0
        # 只在 25%、50%、75%、100% 时输出，减少消息量
        if percentage in [25, 50, 75] or current == total:
            bar_len = 20
            filled = int(bar_len * current / total) if total > 0 else 0
            bar = "█" * filled + "░" * (bar_len - filled)
            if current == total:
                print(f"{phase} [{bar}] {current}/{total} (100%) ✓ 完成")
            else:
                detail_str = f" - {detail}" if detail else ""
                print(f"{phase} [{bar}] {current}/{total} ({percentage}%){detail_str}")

    print(f"\n{'='*60}")
    print(f"📊 B站热门视频日报生成器")
    print(f"{'='*60}")
    print(f"\n⏳ 预计耗时：{num_videos * 3}~{num_videos * 5} 秒（取决于网络）\n")

    print(f"📡 正在获取热门视频列表...")
    videos = api.get_popular_videos(page_size=num_videos)
    print(f"✅ 获取到 {len(videos)} 个热门视频\n")

    now = datetime.datetime.now()
    today_str = now.strftime('%Y-%m-%d')
    
    # 第一遍：收集所有数据并计算统计
    video_data_list = []
    total_views = 0
    max_views_idx, max_views = 0, 0
    max_likes_idx, max_likes = 0, 0
    max_coins_idx, max_coins = 0, 0
    max_shares_idx, max_shares = 0, 0

    print(f"📝 阶段1/2：获取字幕 & 生成视频总结")
    print(f"-" * 40)

    for i, video in enumerate(videos, 1):
        bvid = video["bvid"]
        title = video["title"]
        owner = video["owner"]
        stat = video["stat"]
        desc = video.get("desc", "")
        duration = video.get("duration", 0)
        pubdate = video.get("pubdate", 0)

        short_title = title[:18] + "..." if len(title) > 18 else title
        print_progress("字幕&总结", i, len(videos), short_title)

        # 统计
        total_views += stat["view"]
        if stat["view"] > max_views:
            max_views = stat["view"]
            max_views_idx = i
        if stat["like"] > max_likes:
            max_likes = stat["like"]
            max_likes_idx = i
        if stat["coin"] > max_coins:
            max_coins = stat["coin"]
            max_coins_idx = i
        if stat["share"] > max_shares:
            max_shares = stat["share"]
            max_shares_idx = i

        # 获取视频字幕并用 LLM 生成 AI 总结
        ai_summary = None
        ai_outline = []
        subtitle_text = None

        # 尝试获取字幕（使用 aid 和 cid，确保准确性）
        try:
            aid = video.get("aid", 0)
            cid = video.get("cid", 0)
            # 传入 aid 和 cid，如果缺失会自动通过 bvid 获取
            subtitle_text = api.get_video_subtitle_text(bvid, aid=aid if aid else None, cid=cid if cid else None)
        except Exception as e:
            pass  # 静默处理字幕获取失败

        # 如果有字幕且有 OpenRouter Key，用 LLM 生成总结
        if subtitle_text and openrouter_key:
            try:
                summary_result = generate_video_summary_from_subtitle(
                    [REDACTED_SECRET],
                    model=model,
                    title=title,
                    subtitle_text=subtitle_text
                )
                ai_summary = summary_result.get("summary", "")
                ai_outline = summary_result.get("outline", [])
                time.sleep(0.5)  # 避免 API 限流
            except Exception as e:
                pass  # 静默处理

        like_rate = stat["like"] / stat["view"] * 100 if stat["view"] > 0 else 0
        
        video_data_list.append({
            "idx": i,
            "bvid": bvid,
            "title": title,
            "owner": owner,
            "stat": stat,
            "desc": desc,
            "duration": duration,
            "pubdate": pubdate,
            "ai_summary": ai_summary,
            "ai_outline": ai_outline,
            "like_rate": like_rate,
        })

        if i < len(videos):
            time.sleep(delay)

    # 为最高数据添加亮点标签
    for vd in video_data_list:
        highlights = []
        if vd["idx"] == max_views_idx:
            highlights.append("🔥播放量最高")
        if vd["idx"] == max_likes_idx:
            highlights.append("🔥点赞最高")
        if vd["idx"] == max_coins_idx and max_coins_idx not in [max_views_idx, max_likes_idx]:
            highlights.append("🔥硬币最高")
        if vd["idx"] == max_shares_idx and max_shares_idx not in [max_views_idx, max_likes_idx]:
            highlights.append("🔥分享最高")
        if vd["like_rate"] > 15:
            highlights.append(f"点赞率{vd['like_rate']:.0f}%")
        vd["highlight"] = " ".join(highlights) if highlights else _get_video_tag(vd)

    # 生成 AI 点评（使用 OpenRouter）
    if openrouter_key:
        print(f"\n📝 阶段2/2：生成 AI 点评 & 爆款分析")
        print(f"-" * 40)
        for idx, vd in enumerate(video_data_list, 1):
            short_title = vd['title'][:18] + "..." if len(vd['title']) > 18 else vd['title']
            print_progress("AI点评", idx, len(video_data_list), short_title)
            ai_comment, viral_analysis = generate_ai_comment(openrouter_key, model, vd)
            vd["ai_comment"] = ai_comment
            vd["viral_analysis"] = viral_analysis
            time.sleep(0.5)  # 避免 API 限流

    # 生成报告
    report_lines = [
        "# B站热门视频日报",
        "",
        f"**生成时间**：{now.strftime('%Y-%m-%d %H:%M:%S')}",
        "",
        "---",
        "",
        f"## 📋 本期热门视频（{today_str}）",
        "",
        "| 排名 | 视频标题 | 播放量 | 亮点 | 链接 |",
        "|------|----------|--------|------|------|",
    ]

    # 摘要表格（标题中的 | 需要转义，避免破坏表格结构）
    for vd in video_data_list:
        link = f"https://www.bilibili.com/video/{vd['bvid']}"
        safe_title = vd['title'].replace('|', '｜')  # 替换为全角竖线
        safe_highlight = vd['highlight'].replace('|', '｜')
        report_lines.append(f"| {vd['idx']} | {safe_title} | {format_number(vd['stat']['view'])} | {safe_highlight} | [打开视频]({link}) |")

    report_lines.append("")
    report_lines.append("---")
    report_lines.append("")

    # 本期亮点
    report_lines.append("## 🌟 本期亮点")
    report_lines.append("")
    max_views_video = video_data_list[max_views_idx - 1]
    max_likes_video = video_data_list[max_likes_idx - 1]
    max_coins_video = video_data_list[max_coins_idx - 1]
    max_shares_video = video_data_list[max_shares_idx - 1]
    
    report_lines.append(f"1. **播放量冠军**：《{max_views_video['title']}》{format_number(max_views_video['stat']['view'])}")
    report_lines.append(f"2. **点赞数冠军**：《{max_likes_video['title']}》{format_number(max_likes_video['stat']['like'])}")
    report_lines.append(f"3. **硬币数冠军**：《{max_coins_video['title']}》{format_number(max_coins_video['stat']['coin'])}")
    report_lines.append(f"4. **分享数冠军**：《{max_shares_video['title']}》{max_shares_video['stat']['share']:,}")
    report_lines.append("")
    report_lines.append("---")
    report_lines.append("")

    # 详细报告
    for vd in video_data_list:
        report_lines.append(f"## {vd['idx']}. {vd['title']}")
        report_lines.append("")
        
        # 基本信息
        report_lines.append(f"- **UP主**：{vd['owner']['name']}")
        report_lines.append(f"- **时长**：{format_duration(vd['duration'])} | **发布时间**：{format_timestamp(vd['pubdate'])}")
        report_lines.append("")
        
        # 数据统计
        report_lines.append("**📊 数据统计**：")
        report_lines.append(f"> 播放 {format_number(vd['stat']['view'])} | 点赞 {format_number(vd['stat']['like'])} | 收藏 {format_number(vd['stat']['favorite'])} | 硬币 {format_number(vd['stat']['coin'])} | 弹幕 {vd['stat']['danmaku']:,} | 评论 {vd['stat']['reply']:,} | 分享 {vd['stat']['share']:,}")
        report_lines.append("")
        
        # 数据分析（自动生成）
        data_analysis = _generate_data_analysis(vd)
        report_lines.append("**📈 数据分析**：")
        report_lines.append(f"> {data_analysis}")
        report_lines.append("")

        # 视频简介
        if vd['desc'] and vd['desc'].strip() and vd['desc'].strip() != "-":
            desc_clean = vd['desc'][:500].replace('\n', ' ').replace('\r', ' ')
            desc_clean = ' '.join(desc_clean.split())
            report_lines.append("**📝 视频简介**：")
            report_lines.append(f"> {desc_clean}{'...' if len(vd['desc']) > 500 else ''}")
            report_lines.append("")

        # AI 视频总结（基于字幕生成）
        if vd['ai_summary']:
            report_lines.append("**🤖 AI视频总结**：")
            report_lines.append(f"> {vd['ai_summary']}")

            if vd['ai_outline']:
                report_lines.append(">")
                report_lines.append("> **内容大纲**：")
                for item in vd['ai_outline'][:4]:
                    outline_title = item.get("title", "")
                    outline_content = item.get("part_outline", [])
                    if outline_title:
                        report_lines.append(f"> • **{outline_title}**")
                        for part in outline_content[:3]:
                            content = part.get("content", "")
                            if content:
                                report_lines.append(f">   - {content}")
            report_lines.append("")
        else:
            report_lines.append("**🤖 AI视频总结**：")
            report_lines.append("> （该视频无字幕，无法生成总结）")
            report_lines.append("")

        # AI 点评
        ai_comment = vd.get("ai_comment", "")
        report_lines.append("**💡 AI点评**：")
        if ai_comment:
            report_lines.append(f"> {ai_comment}")
        else:
            report_lines.append("> （需要提供 API Key 生成）")
        report_lines.append("")

        # 运营爆款分析
        viral_analysis = vd.get("viral_analysis", "")
        report_lines.append("**🚀 运营爆款分析**：")
        if viral_analysis:
            report_lines.append(f"> {viral_analysis}")
        else:
            report_lines.append("> （需要提供 API Key 生成）")
        report_lines.append("")

        # 视频链接
        report_lines.append(f"🔗 [点击观看视频](https://www.bilibili.com/video/{vd['bvid']})")
        report_lines.append("")
        report_lines.append("---")
        report_lines.append("")

    # 底部统计
    report_lines.append("## 📊 本期数据概览")
    report_lines.append("")
    report_lines.append("| 指标 | 数值 |")
    report_lines.append("|------|------|")
    report_lines.append(f"| 视频总数 | {len(videos)} |")
    report_lines.append(f"| 总播放量 | {format_number(total_views)} |")
    report_lines.append(f"| 最高播放 | 《{max_views_video['title'][:20].replace('|', '｜')}...》{format_number(max_views_video['stat']['view'])} |")
    report_lines.append(f"| 最高点赞 | 《{max_likes_video['title'][:20].replace('|', '｜')}...》{format_number(max_likes_video['stat']['like'])} |")
    report_lines.append("")
    report_lines.append("---")
    report_lines.append("")
    report_lines.append("*报告由 B站 API + OpenRouter AI 自动生成*")

    return "\n".join(report_lines)


def parse_cookies(cookies_str: str) -> dict:
    """
    解析完整的 cookies 字符串
    格式：key1=value1; key2=value2; ...
    """
    result = {}
    for item in cookies_str.split(';'):
        item = item.strip()
        if '=' in item:
            key, value = item.split('=', 1)
            result[key.strip()] = value.strip()
    return result


def load_config(config_path: str) -> dict:
    """加载 JSON 配置文件"""
    with open(config_path, 'r', encoding='utf-8') as f:
        return json.load(f)


def main():
    parser = argparse.ArgumentParser(description="B站热门视频日报生成器")
    
    # 配置文件（推荐）
    parser.add_argument("--config", "-c", help="配置文件路径（推荐，配置一次永久使用）")
    
    # 或者单独指定参数
    parser.add_argument("--cookies", help="完整的B站cookies字符串")
    parser.add_argument("--sessdata", default="", help="SESSDATA cookie（如果不用--cookies）")
    parser.add_argument("--bili-jct", default="", help="bili_jct cookie")
    parser.add_argument("--buvid3", default="", help="buvid3 cookie")
    parser.add_argument("--dedeuserid", default="", help="DedeUserID cookie")
    parser.add_argument("--num-videos", type=int, default=20, help="获取视频数量")
    parser.add_argument("--delay", type=float, default=1.0, help="请求间隔（秒），建议 >= 1.0 避免触发B站速率限制")
    parser.add_argument("--output", "-o", help="输出文件路径")
    
    # AI 点评相关参数
    parser.add_argument("--openrouter-key", default="", help="OpenRouter API Key（用于生成 AI 点评）")
    parser.add_argument("--model", default="", 
                        help="OpenRouter 模型名称（默认从配置文件读取，或 google/gemini-3-flash-preview）")

    args = parser.parse_args()

    # 从配置文件或命令行参数获取配置
    config = {}
    if args.config:
        print(f"正在读取配置文件: {args.config}")
        config = load_config(args.config)
    
    # 解析 cookies（优先级：命令行参数 > 环境变量 > 配置文件）
    cookies_str = args.cookies or os.environ.get('BILIBILI_COOKIES', '') or config.get('bilibili', {}).get('cookies', '')
    all_cookies = None
    if cookies_str:
        all_cookies = parse_cookies(cookies_str)
        sessdata = all_cookies.get('SESSDATA', '')
        bili_jct = all_cookies.get('bili_jct', '')
        buvid3 = all_cookies.get('buvid3', '')
        dedeuserid = all_cookies.get('DedeUserID', '')
    else:
        sessdata = args.sessdata
        bili_jct = args.bili_jct
        buvid3 = args.buvid3
        dedeuserid = args.dedeuserid
    
    if not sessdata:
        print("错误：必须提供 --config、--cookies 或 --sessdata 参数")
        sys.exit(1)
    
    # AI 配置（优先级：命令行参数 > 环境变量 > 配置文件）
    ai_config = config.get('ai', {})
    openrouter_key = args.openrouter_key or os.environ.get('OPENROUTER_API_KEY', '') or ai_config.get('openrouter_key', '')
    model = args.model or ai_config.get('model', 'google/gemini-3-flash-preview')

    # 创建 API 客户端（传入所有 cookies 以确保完整性）
    api = BilibiliAPI(
        sessdata=sessdata,
        bili_jct=bili_jct,
        buvid3=buvid3,
        dedeuserid=dedeuserid,
        all_cookies=all_cookies,
    )

    # 报告配置
    report_config = config.get('report', {})
    num_videos = args.num_videos or report_config.get('num_videos', 10)
    
    # 生成报告
    try:
        report = generate_report(
            api=api,
            num_videos=num_videos,
            delay=args.delay,
            openrouter_key=openrouter_key,
            model=model,
        )
    except Exception as e:
        print(f"[ERROR] 生成报告失败: {e}")
        sys.exit(1)

    # 输出
    if args.output:
        output_path = Path(args.output)
        output_path.write_text(report, encoding="utf-8")
        print(f"\n{'='*60}")
        print(f"✅ 报告生成完成！")
        print(f"{'='*60}")
        print(f"📄 报告保存到: {output_path}")
        print(f"📊 共处理 {num_videos} 个视频")
        print(f"🤖 AI 模型: {model}")
    else:
        print("\n" + "=" * 50)
        print(report)


if __name__ == "__main__":
    main()


path: README.md
content: # B站热门视频监控 Skill

自动获取B站热门视频，使用字幕+LLM生成AI总结，生成包含数据分析的日报，支持邮件发送。

## 📦 版本历史

### v1.0.11 (2025-02-05)

**重大更新：改用字幕+LLM方案替代B站官方AI总结API**

- ✨ **新功能**
  - 使用视频字幕 + OpenRouter LLM 生成 AI 视频总结
  - 支持多种 LLM 模型：Claude、Gemini、GPT、DeepSeek
  - 实时进度条显示，用户可看到处理进度
  - 预估耗时提示

- 🔧 **优化**
  - 禁用所有模型的 thinking/reasoning 模式，避免输出被截断
  - 添加网络重试机制（最多3次），提高稳定性
  - 优化 JSON 解析，增加 fallback 提取逻辑
  - 改进 SKILL.md 中的 Cookie 获取说明（使用 Network 选项卡方法）

- 🐛 **修复**
  - 修复字幕获取兼容性问题
  - 修复 URL 链接被括号破坏的问题

### v1.0.10

- 初始版本，使用 B站官方 AI 总结 API

## ✨ 功能特点

- 📊 获取B站热门视频Top 10/20/30
- 🤖 字幕提取 + LLM 生成 AI 视频总结
- 📝 自动生成结构化Markdown报告
- 💡 支持 OpenRouter AI 智能点评（Claude/Gemini/GPT/DeepSeek）
- 📧 HTML邮件发送（支持多收件人）
- 🎨 精美的邮件排版（蓝色主题）
- 📈 关键节点进度显示（每阶段仅在 25%/50%/75%/100% 输出，避免刷屏）

## 🚀 快速开始

### 1. 安装依赖

```bash
pip install -r requirements.txt
```

### 2. 创建配置文件

复制示例配置：

```bash
cp bilibili-monitor.example.json bilibili-monitor.json
```

配置文件仅包含非敏感设置：

```json
{
  "ai": {
    "model": "google/gemini-3-flash-preview"
  },
  "email": {
    "recipients": ["[REDACTED_SECRET]"]
  },
  "report": {
    "num_videos": 10
  }
}
```

### 3. 设置环境变量

敏感凭据通过环境变量传递，不写入配置文件：

| 环境变量 | 说明 | 是否必须 |
|---------|------|---------|
| `BILIBILI_COOKIES` | 完整的B站 Cookie 字符串 | 是 |
| `OPENROUTER_API_KEY` | OpenRouter API Key（用于 AI 功能） | 可选 |
| `SMTP_EMAIL` | Gmail 发件邮箱 | 是（发邮件时） |
| `SMTP_PASSWORD` | Gmail 应用密码（16位） | 是（发邮件时） |

### 4. 获取B站Cookies

1. 登录 [bilibili.com](https://www.bilibili.com)
2. 按 `F12` → `Network` 选项卡 → 刷新页面
3. 点击 `www.bilibili.com` 请求 → Request Headers → 复制 Cookie 字段

### 5. 生成报告并发送邮件

```bash
# 设置环境变量
export BILIBILI_COOKIES="你的B站cookies"
export OPENROUTER_API_KEY="你的OpenRouter Key"
export SMTP_EMAIL="[REDACTED_SECRET]"
export SMTP_PASSWORD="xxxx xxxx xxxx xxxx"

# 生成报告
python generate_report.py --config bilibili-monitor.json --output report.md

# 发送邮件
python send_email.py --config bilibili-monitor.json --body-file report.md --html
```

也支持通过命令行参数传递凭据：

```bash
python generate_report.py --cookies "你的cookies" --openrouter-key "你的key" --config bilibili-monitor.json --output report.md
```

## 📋 报告内容

生成的报告包含：

```
📋 本期热门视频（摘要表格）
├── 排名、标题、播放量、亮点、链接

🌟 本期亮点
├── 播放量冠军
├── 点赞数冠军
├── 硬币数冠军
└── 分享数冠军

📹 详细报告（每个视频）
├── 基本信息（UP主、时长、发布时间）
├── 📊 数据统计
├── 📝 视频简介
├── 🤖 AI视频总结 + 内容大纲（LLM生成）
├── 💡 AI点评
├── 📈 运营爆款分析
└── 🔗 视频链接
```

## 🤖 作为AI Skill使用

本项目可作为 OpenClaw 等 AI Agent 的 Skill 使用。

触发词：
- "B站热门"
- "bilibili日报"
- "视频日报"
- "热门视频"

## 📁 文件结构

```
bilibili-monitor/
├── SKILL.md                    # AI Skill 说明文件
├── README.md                   # 本文件
├── requirements.txt            # Python 依赖
├── generate_report.py          # 报告生成脚本
├── send_email.py               # 邮件发送脚本
├── bilibili_api.py             # B站 API 工具集
├── bilibili_subtitle.py        # 字幕提取模块
├── bilibili-monitor.example.json  # 配置文件示例（无敏感信息）
└── example_report.md           # 报告示例
```

## 🔒 安全说明

- 所有凭据仅存储在用户本地设备上，Skill 发布包中不包含任何凭据
- 配置文件 `bilibili-monitor.json` 已通过 `.gitignore` 排除，不会被意外上传或分享
- 网络传输使用 HTTPS 和 TLS/STARTTLS 加密
- 同时支持环境变量和命令行参数传递凭据，用户可自行选择

## ⚙️ 配置说明

### OpenRouter 模型选择

| 模型 | model 值 | 特点 |
|------|---------|------|
| Gemini | google/gemini-3-flash-preview | 便宜快速，推荐 |
| Claude | anthropic/claude-sonnet-4.5 | 高质量 |
| GPT | openai/gpt-5.2-chat | OpenAI |
| DeepSeek | deepseek/deepseek-chat-v3-0324 | 性价比 |

### Gmail配置

需要使用应用专用密码（非登录密码）：
1. 访问 https://myaccount.google.com/apppasswords
2. 生成16位应用密码

## ⚠️ 注意事项

1. **Cookie有效期**：SESSDATA 约 1-3 个月，过期需重新获取
2. **API频率限制**：请求间隔建议 >= 1 秒
3. **字幕可用性**：部分视频可能无字幕，会跳过 AI 总结生成

## 📄 License

MIT License


path: requirements.txt
content: requests>=2.28.0


path: send_email.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B站热门视频日报邮件发送脚本 - 支持多收件人"""

import argparse
import os
import re
import smtplib
import sys
from email.message import EmailMessage
from email.headerregistry import Address
from pathlib import Path


def send_email(
    to_emails: list[str],
    subject: str,
    body: str,
    smtp_email: str | None = None,
    smtp_password: str | None = None,
    smtp_host: str | None = None,
    smtp_port: int | None = None,
    html: bool = False,
) -> bool:
    """发送邮件 - 支持多个收件人"""
    smtp_email = smtp_email or os.environ.get("SMTP_EMAIL")
    smtp_password = smtp_password or os.environ.get("SMTP_PASSWORD")
    smtp_host = smtp_host or os.environ.get("SMTP_HOST", "smtp.gmail.com")
    smtp_port = smtp_port or int(os.environ.get("SMTP_PORT", "587"))

    if not smtp_email or not smtp_password:
        print("Error: SMTP credentials not set.")
        return False

    try:
        msg = EmailMessage()
        msg["Subject"] = subject
        msg["From"] = smtp_email
        msg["To"] = ", ".join(to_emails)

        if html:
            msg.set_content(body, subtype="html", charset="utf-8")
        else:
            msg.set_content(body, charset="utf-8")

        print(f"Connecting to {smtp_host}:{smtp_port}...")
        with smtplib.SMTP(smtp_host, smtp_port) as server:
            server.starttls()
            print("Logging in...")
            server.login(smtp_email, smtp_password)
            for email in to_emails:
                print(f"Sending to {email}...")
            server.send_message(msg)

        print(f"[SUCCESS] Email sent to {len(to_emails)} recipient(s)")
        return True

    except Exception as e:
        print(f"[ERROR] Send failed: {e}")
        return False


def markdown_to_html(markdown_text: str) -> str:
    """将 Markdown 转换为精美的 HTML"""
    
    lines = markdown_text.split('\n')
    html_parts = []
    in_video_block = False
    
    for line in lines:
        stripped = line.strip()
        
        if not stripped:
            html_parts.append('')
            continue
        
        # 一级标题
        if stripped.startswith('# ') and not stripped.startswith('## '):
            title = stripped[2:]
            html_parts.append(f'<h1>{title}</h1>')
            continue
        
        # 二级标题 ## 1. 视频标题
        match = re.match(r'^## (\d+)\. (.+)$', stripped)
        if match:
            if in_video_block:
                html_parts.append('</div>')
            num, title = match.groups()
            html_parts.append(f'<div class="video-card">')
            html_parts.append(f'<div class="video-title"><span class="num">{num}</span>{title}</div>')
            in_video_block = True
            continue
        
        # 二级标题 ## 📊 统计
        if stripped.startswith('## '):
            if in_video_block:
                html_parts.append('</div>')
                in_video_block = False
            title = stripped[3:]
            html_parts.append(f'<h2>{title}</h2>')
            continue
        
        # 分隔线
        if stripped == '---':
            if in_video_block:
                html_parts.append('</div>')
                in_video_block = False
            html_parts.append('<hr>')
            continue
        
        # 列表项 - **标签**：内容
        match = re.match(r'^- \*\*(.+?)\*\*：(.+)$', stripped)
        if match:
            label, content = match.groups()
            # 处理 content 中的粗体和链接
            content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', content)
            content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', content)
            html_parts.append(f'<div class="info-row"><span class="label">{label}：</span><span class="value">{content}</span></div>')
            continue
        
        # 内容大纲列表项
        match = re.match(r'^- \*\*(.+?)\*\*$', stripped)
        if match:
            title = match.group(1)
            html_parts.append(f'<div class="outline-title">{title}</div>')
            continue
        
        # 子列表项
        match = re.match(r'^  - (.+)$', stripped)
        if match:
            content = match.group(1)
            html_parts.append(f'<div class="outline-item">• {content}</div>')
            continue
        
        # 引用块（支持多行，处理内部格式）
        if stripped.startswith('>'):
            content = stripped[1:].strip() if len(stripped) > 1 else ''
            # 处理引用内的粗体和链接
            content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', content)
            content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', content)
            # 检查是否是连续引用块
            if html_parts and html_parts[-1].startswith('<div class="quote">') and not html_parts[-1].endswith('</div>'):
                html_parts[-1] += f'<br>{content}'
            elif html_parts and html_parts[-1].endswith('</div>') and '<div class="quote">' in html_parts[-1]:
                # 上一行也是引用，合并
                html_parts[-1] = html_parts[-1][:-6] + f'<br>{content}</div>'
            else:
                html_parts.append(f'<div class="quote">{content}</div>')
            continue
        
        # 粗体标签行
        match = re.match(r'^\*\*(.+?)\*\*：$', stripped)
        if match:
            label = match.group(1)
            html_parts.append(f'<div class="section-label">{label}：</div>')
            continue
        
        # 粗体标签带内容
        match = re.match(r'^\*\*(.+?)\*\*：(.+)$', stripped)
        if match:
            label, content = match.groups()
            html_parts.append(f'<div class="section-label">{label}：</div>')
            html_parts.append(f'<div class="section-content">{content}</div>')
            continue
        
        # 视频链接（支持两种格式）
        if stripped.startswith('🔗'):
            # 格式1: 🔗 [点击观看视频](url)
            match = re.search(r'\[([^\]]+)\]\((https://www\.bilibili\.com/video/\S+)\)', stripped)
            if match:
                text, url = match.groups()
                html_parts.append(f'<div class="video-link">🔗 <a href="{url}">{text}</a></div>')
                continue
            # 格式2: 🔗 **视频链接**：url
            match = re.search(r'https://www\.bilibili\.com/video/\S+', stripped)
            if match:
                url = match.group()
                html_parts.append(f'<div class="video-link">🔗 <a href="{url}">点击观看视频</a></div>')
            continue
        
        # 表格行
        if stripped.startswith('|') and stripped.endswith('|'):
            cells = [c.strip() for c in stripped[1:-1].split('|')]
            # 跳过表格分隔行（只包含 - : 空格的行）
            if all(c.replace('-', '').replace(':', '').strip() == '' for c in cells):
                continue
            # 处理单元格中的链接和粗体
            processed_cells = []
            for c in cells:
                c = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', c)
                c = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', c)
                processed_cells.append(c)
            # 检查是否是表头行（前一行不是表格行，或者这是第一个表格行）
            is_header = len(html_parts) == 0 or not html_parts[-1].startswith('<tr>')
            if is_header:
                row_html = ''.join(f'<th>{c}</th>' for c in processed_cells)
            else:
                row_html = ''.join(f'<td>{c}</td>' for c in processed_cells)
            html_parts.append(f'<tr>{row_html}</tr>')
            continue
        
        # 普通文本
        text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
        html_parts.append(f'<p>{text}</p>')
    
    if in_video_block:
        html_parts.append('</div>')
    
    content = '\n'.join(html_parts)
    content = re.sub(r'(<tr>.*?</tr>(?:\s*<tr>.*?</tr>)*)', r'<table>\1</table>', content, flags=re.DOTALL)
    
    html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; margin: 0; padding: 20px; }}
.container {{ max-width: 700px; margin: 0 auto; background: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1 {{ color: #00a1d6; font-size: 24px; border-bottom: 2px solid #00a1d6; padding-bottom: 10px; margin-bottom: 20px; }}
h2 {{ color: #333; font-size: 18px; margin: 25px 0 15px 0; }}
.video-card {{ background: #fafafa; border-radius: 8px; padding: 15px 20px; margin: 15px 0; border-left: 4px solid #00a1d6; }}
.video-title {{ font-size: 16px; font-weight: bold; color: #222; margin-bottom: 12px; }}
.num {{ background: #00a1d6; color: #fff; padding: 2px 8px; border-radius: 4px; margin-right: 10px; font-size: 14px; }}
.info-row {{ font-size: 14px; color: #555; margin: 6px 0; }}
.label {{ color: #00a1d6; font-weight: 500; }}
.value {{ color: #333; }}
.section-label {{ font-size: 14px; font-weight: bold; color: #333; margin: 12px 0 6px 0; }}
.section-content {{ font-size: 14px; color: #555; margin-bottom: 10px; }}
.quote {{ background: #e8f4fd; border-left: 3px solid #00a1d6; padding: 10px 15px; margin: 10px 0; font-size: 14px; color: #444; border-radius: 0 6px 6px 0; }}
.outline-title {{ font-size: 14px; font-weight: bold; color: #333; margin: 8px 0 4px 0; }}
.outline-item {{ font-size: 13px; color: #666; margin: 3px 0 3px 15px; }}
.video-link {{ margin-top: 12px; font-size: 14px; }}
.video-link a {{ color: #00a1d6; word-break: break-all; }}
hr {{ border: none; border-top: 1px solid #e0e0e0; margin: 20px 0; }}
table {{ width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px; }}
th {{ padding: 10px 12px; border: 1px solid #ddd; background: #00a1d6; color: #fff; font-weight: bold; text-align: left; }}
td {{ padding: 8px 12px; border: 1px solid #ddd; }}
p {{ margin: 8px 0; font-size: 14px; }}
strong {{ color: #333; }}
</style>
</head>
<body>
<div class="container">
{content}
</div>
</body>
</html>'''
    
    return html


def load_config(config_path: str) -> dict:
    """加载 JSON 配置文件"""
    import json
    with open(config_path, 'r', encoding='utf-8') as f:
        return json.load(f)


def main():
    parser = argparse.ArgumentParser(description="B站热门视频日报邮件发送")
    parser.add_argument("--config", "-c", help="配置文件路径（推荐）")
    parser.add_argument("--to", nargs="+", help="收件人邮箱（支持多个）")
    parser.add_argument("--subject", help="邮件主题")
    parser.add_argument("--subject-file", help="从文件读取邮件主题（支持中文）")
    parser.add_argument("--body", help="邮件内容")
    parser.add_argument("--body-file", help="从文件读取邮件内容")
    parser.add_argument("--html", action="store_true", help="转换为 HTML 格式")
    parser.add_argument("--smtp-email", help="发件人邮箱")
    parser.add_argument("--smtp-password", help="邮箱密码")
    parser.add_argument("--smtp-host", default="smtp.gmail.com", help="SMTP 服务器")
    parser.add_argument("--smtp-port", type=int, default=587, help="SMTP 端口")

    args = parser.parse_args()

    # 从配置文件读取
    config = {}
    if args.config:
        config = load_config(args.config)
    
    email_config = config.get('email', {})
    
    # 处理收件人列表（优先命令行参数）
    to_emails = []
    recipients = args.to or email_config.get('recipients', [])
    if isinstance(recipients, str):
        recipients = [recipients]
    for email in recipients:
        to_emails.extend([e.strip() for e in email.split(",") if e.strip()])
    
    print(f"Recipients: {', '.join(to_emails)}")

    # 获取邮件主题
    if args.subject_file:
        subject = Path(args.subject_file).read_text(encoding="utf-8").strip()
    elif args.subject:
        subject = args.subject
    else:
        from datetime import datetime
        subject = f"B站热门视频日报 - {datetime.now().strftime('%Y-%m-%d')}"

    # 获取邮件内容
    if args.body_file:
        body = Path(args.body_file).read_text(encoding="utf-8")
    elif args.body:
        body = args.body
    else:
        print("Error: Please provide --body or --body-file")
        sys.exit(1)

    if args.html:
        body = markdown_to_html(body)

    # SMTP 配置（优先级：命令行参数 > 环境变量 > 配置文件）
    smtp_email = args.smtp_email or os.environ.get('SMTP_EMAIL') or email_config.get('smtp_email')
    smtp_password = args.smtp_password or os.environ.get('SMTP_PASSWORD') or email_config.get('smtp_password')
    
    success = send_email(
        to_emails=to_emails,
        subject=subject,
        body=body,
        smtp_email=smtp_email,
        smtp_password=smtp_password,
        smtp_host=args.smtp_host,
        smtp_port=args.smtp_port,
        html=args.html,
    )

    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


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