Skill: wangminrui2022/link-resolver-engine (version 1.0.6)

--- name: link-resolver-engine description: | 当用户想要**下载抖音视频**、**下载B站视频**、**解析无水印直链**、**提取最高画质视频**、**把视频下下来**、**保存抖音/B站视频**时自动触发。 专门处理哔哩哔哩（Bilibili/B站）和抖音（Douyin）的任意视频链接（包括短链 b23.tv、douyin.com、bilibili.com 等）。 自动识别平台，优先使用 yt-dlp 获取最高画质，失败时自动切换备用方案，最后本地下载并用 ffmpeg 合并音视频，保存到 downloads 文件夹。 【重要约束】仅支持 B站 和 抖音 链接，其他任何平台的链接一律不处理。 常见触发口语（越多越好）： - “帮我下载这个抖音视频” - “帮我下载这个抖音视频：[链接]” - “解析这个B站链接” - “解析这个B站链接的无水印最高画质：[链接]” - “Bilibili视频无水印下载” - “抖音最高画质直链” - “把这个视频下下来” - “把这个视频下下来：[链接]” - “下载Douyin/B站视频” - “提取视频直链” - “视频下载器” - “帮我保存这个抖音/B站视频” - “给我这个视频的无水印版本” - “帮我处理这个视频链接” metadata: openclaw: requires: bins: - python user-invocable: true --- # link-resolver-engine **功能**：高性能、智能、双平台视频链接解析与下载引擎。专为 OpenClaw Skill 生态设计，支持 **B站（Bilibili）** 和 **抖音（Douyin）** 视频链接的自动识别、智能解析与无水印下载。 ### 触发时机（Triggers） - 用户直接发抖音或B站视频链接，并带有“下载”“解析”“直链”“无水印”“最高画质”“下下来”“保存”等关键词。 - 用户说“帮我处理这个视频链接”“给我这个视频的最高画质版本”等口语指令。 - 支持批量处理：一次发多个链接也行。 - 自动检测短链并跳转。 ### 核心优势 - 完全离线运行（仅在需要时使用网络下载视频）。 - 采用“主方案 + 备用方案”双保险机制，确保最高成功率。 - 自动检测平台（支持短链自动跳转）。 - 下载完成后按指定目录保存 `.mp4` 文件。 ## 参数提取指南 当决定调用此技能时，请从用户上下文中提取以下参数： 1. **`<视频链接>`** (必填): 用户提供的完整链接或短链（如 `b23.tv`、`v.douyin.com` 等）。 2. **`<文件名前缀>`** (选填): 用户如果明确指定了保存的文件名。 3. **`<下载目录>`** (选填): 用户如果明确指定了保存路径。若未指定，无需添加此参数（默认 `./downloads`）。 4. **`<格式代码>`** (选填): 用户如果明确提出了特定的画质或格式要求。 ### B站下载方案 1. **第一优先** → 使用 `yt-dlp`（最稳定、最快，支持最高画质） 2. **第二备用** → 自动展开 `b23.tv` 短链接 → 获取无水印直链 → `requests` + `ffmpeg` 合并 3. **第三兜底** → 两步都失败时给出清晰错误提示 ### 抖音下载方案 1. **第一优先** → 短链展开 → PC 端最高画质无水印直链下载 2. **第二备用** → 移动端轻量模式下载（兼容性极强） 3. **第三兜底** → 优先保证画质，失败后自动降级 ### 执行步骤 1. **解析链接**：识别用户的源链接（支持单个或批量 B站/抖音视频链接或短链，自动判断平台）。 2. **默认目标**：若未指定下载目录，默认保存在 `./downloads` 文件夹（支持 `--filename-prefix` 或自动生成带时间戳的文件名）。 3. **调用命令**：使用以下兼容性命令启动脚本（优先 `python3`，失败则 `python`）。脚本会自动创建虚拟环境、检查依赖并初始化。 ```bash (python3 scripts/video_snapper.py -u "<视频链接>" [-p "<文件名前缀>"] [-d "<下载目录>"] [-f "<格式代码>"]) || (python scripts/video_snapper.py -u "<视频链接>" [-p "<文件名前缀>"] [-d "<下载目录>"] [-f "<格式代码>"])

path: .gitignore
content: # Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Environments
.venv
venv/
ENV/
env/
.env

# Logs
logs/*.log
logs/*.log.*

# OS
.DS_Store
Thumbs.db

# OpenClaw / temp
*.tmp
temp/

path: README.md
content: # 🚀 OpenClaw Skill: 抖音 & B站全能高清视频下载器 (Video Downloader)

这是一个为 [OpenClaw](https://github.com/openclaw/openclaw) 定制的专属综合下载 Skill。本插件旨在解析**抖音（Douyin）**与**哔哩哔哩（Bilibili）**的视频链接，精准提取最高画质的直链，并提供稳定可靠的本地下载与音视频合并功能。

针对不同平台的反爬策略，本 Skill 综合使用了 `Playwright` 动态拦截、`Requests` 原生请求、正则表达式提取以及 `yt-dlp` 封装库，极大提高了视频解析与下载的成功率。

## ✨ 核心特性

- **🔗 智能链接解析**：自动识别并展开 `v.douyin.com` 与 `b23.tv` 各种短链接。
- **🎬 B站全功能下载**：支持 `Requests` 纯请求抓取（自动调用 FFmpeg 合并音视频）以及 `yt-dlp` 双重下载引擎。
- **📱 抖音最高画质提取**：通过无头浏览器拦截动态请求获取最高比特率（Bit Rate）无水印直链，并配备移动端页面备用解析方案。
- **🚫 突破水印限制**：智能转换播放链接，获取纯净无水印视频。
- **🕵️‍♂️ 反爬虫绕过**：内置 `tf-playwright-stealth` 和伪造请求头，有效隐藏浏览器与爬虫特征。
- **📂 自动文件管理**：支持自定义下载目录，自动按平台、时间戳和视频 ID 生成规范的文件名。

## 🛠️ 环境依赖

本 Skill 运行时依赖以下 Python 库（代码内部已通过 `ensure_package` 模块自动检查并安装）：

- `requests`
- `playwright`
- `tf-playwright-stealth`
- `yt-dlp`

### ⚠️ 系统依赖 (重要)
由于 B站的高清视频通常是**视频轨与音频轨分离**的，本 Skill 在合并 B站音视频时依赖系统级别的 **FFmpeg**：
- 请确保你的操作系统已安装 [FFmpeg](https://ffmpeg.org/download.html)，并且将其添加到了系统的环境变量（PATH）中，否则 `download_bilibili_video_request` 方法将会失败并只保留无声视频。
- **浏览器内核**：首次运行代码时，系统会自动执行 `playwright install chromium` 以下载必要的浏览器内核。

## 💻 核心方法 (API)

本 Skill 提供了针对双平台的独立解析和下载函数：

### 🎵 抖音 (Douyin) 模块

- **`expand_douyin_url(short_url: str) -> tuple[bool, str]`**
  展开 `v.douyin.com` 短链。返回 `(是否成功, 完整 URL 或 错误信息)`。
- **`get_douyin_no_wm(raw_url: str) -> str | None`**
  通过 Playwright 拦截网页请求，获取最高画质的无水印直链。
- **`download_douyin_video(no_wm_url: str, filename: str = None, download_dir: str = None) -> bool`**
  带 Referer 请求头下载抖音无水印视频。
- **`download_douyin_video_mobile(short_url: str, ... ) -> tuple[bool, str]`**
  备选方案。模拟移动端访问，直接从 HTML/JSON 中正则提取视频流并下载。

### 📺 哔哩哔哩 (Bilibili) 模块

- **`expand_bilibili_url(short_url: str) -> tuple[bool, str]`**
  展开 `b23.tv` 格式的短链接。返回 `(是否成功, 完整 URL 或 错误信息)`。
- **`get_bilibili_no_wm(raw_url: str) -> dict | None`**
  纯 requests 提取器，避开 Playwright 解码问题。通过模拟 WBI 参数获取 B站视频流和音频流的直链，返回包含 `video_url` 和 `audio_url` 的字典。
- **`download_bilibili_video_request(play_info: dict, filename: str = None, download_dir: str = None) -> bool`**
  下载 B站视频流与音频流，并自动调用 FFmpeg 进行无损合并。
- **`download_bilibili_video_yt_dlp(url: str, ... ) -> tuple[bool, str]`**
  极其稳定的封装方案。直接调用 `yt-dlp` 进行 B站视频下载，自动选择最佳音视频轨并合并，支持指定下载目录。

## 📖 参数说明

| 参数 (短) | 参数 (长)           | 描述 |
|----------|--------------------|------|
| `-u`     | `--url`            | **(必需)** 视频链接，支持长链、短链、带参数链接 |
| `-p`     | `--filename-prefix`| *(可选)* 自定义文件名前缀，默认使用视频标题 |
| `-d`     | `--download-dir`   | *(可选)* 下载存放路径，默认当前目录下 `downloads` |
| `-f`     | `--format`         | *(可选)* B站专用，指定 `yt-dlp` 的下载格式字符串 |

### 💻 命令行直接调用 (Terminal Usage)

你可以直接通过命令行脚本 video_snapper.py 快速下载视频。以下是针对不同场景的使用示例：

# 1. 基础下载 (默认下载到 ./downloads 目录)
python scripts/video_snapper.py -u "https://b23.tv/ReXDyVO"

# 2. 指定下载目录
python scripts/video_snapper.py -u "https://b23.tv/ReXDyVO" -d "F:/命理学"

# 3. 指定文件名前缀 + 下载目录
python scripts/video_snapper.py -u "https://b23.tv/ReXDyVO" -p "测试" -d "F:/命理学"


# 1. 基础下载 (自动去水印并提取最高画质)
python scripts/video_snapper.py -u "https://v.douyin.com/ezOqxZPY3kc/"

# 2. 指定下载目录
python scripts/video_snapper.py -u "https://v.douyin.com/ezOqxZPY3kc/" -d "F:/命理学"

# 3. 指定文件名前缀 + 下载目录
python scripts/video_snapper.py -u "https://v.douyin.com/ezOqxZPY3kc/" -p "测试" -d "F:/命理学"

### **在 OpenClaw 聊天中**

你可以直接对你的 Agent 说：

下载这个视频 https://www.bilibili.com/video/BV1MU95BsEey/?spm_id_from=333.1007.tianma.1-2-2.click&vd_source=b845b6e7ad62d0465263434acf42945b

这个B站视频下下来 https://www.bilibili.com/video/BV1ExX6BGELJ/?spm_id_from=333.1007.tianma.3-1-7.click&vd_source=b845b6e7ad62d0465263434acf42945b

视频下载器，帮我处理这个：https://www.douyin.com/jingxuan?modal_id=7624114795335257353

这个视频保存到F盘命名张雪机车https://www.douyin.com/jingxuan?modal_id=7611124804929785115


path: scripts/bilibili_video.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Skill Name: link-resolver-engine
Author: 王岷瑞 / https://github.com/wangminrui2022
License: Apache License
Description: 这段代码是一个功能完备的 Bilibili（B站）视频下载工具集。
它提供了两种主要的解析和下载思路：一种是基于原生的 requests 手动解析与合并，另一种是调用成熟的 yt-dlp 库。
以下是代码的详细功能描述和逻辑拆解：
    1. 核心功能概述
        该代码库主要用于处理 B 站视频的链接转换、信息提取和媒体下载。它具备处理“短链接”、获取“无水印（DASH流）地址”以及“音视频自动合并”的能力。
    2. 主要函数解析
        A. 链接还原：expand_bilibili_url
            功能：将 B 站的短链接（如 b23.tv/xxxx）还原为完整的视频详情页 URL。
            逻辑：使用正则表达式验证短链接格式，通过 requests.head 请求获取重定向后的最终地址，避免了下载不必要的页面内容，提高效率。
    
        B. 视频信息解析：get_bilibili_no_wm
            功能：从视频链接中提取 BV 号，并获取视频和音频的分离下载地址（DASH 格式）。
            核心逻辑：正则匹配：提取 URL 中的 bvid。
            获取 CID：访问视频页源码，利用正则提取 cid（弹幕/播放标识符），若源码提取失败则调用 API 备用接口。
            调用 PlayURL 接口：访问 B 站官方接口获取播放地址。它优先尝试获取 DASH 格式（音视频分离，画质更高），如果没有则退而求其次选择 DURL（传统 MP4 格式）。
            最优选择：在返回的多个清晰度中，自动筛选带宽（bandwidth）最高的视频轨和音频轨。     
        
        C. 手动下载与合并：download_bilibili_video_request
            功能：使用 requests 流式下载音视频文件，并利用 ffmpeg 进行合并。
            关键步骤：
            流式下载：通过 stream=True 逐块写入文件，防止大视频占用过多内存。
            FFmpeg 合并：由于 DASH 格式音轨和视轨是分开的，代码调用系统命令 ffmpeg 将两者合成为一个 .mp4 文件，并设置 -movflags +faststart 以优化在线播放。
            容错处理：若 ffmpeg 合并失败，会保留原始视频文件作为备份。     

        D. 自动化工具封装：download_bilibili_video_yt_dlp
            功能：利用专业的开源工具 yt-dlp 进行下载。
            特点：这是一种更稳健的方案。它封装了 yt-dlp 的 API，支持自定义画质参数（format_spec），并能自动处理复杂的重试逻辑和格式转换。    
    3. 技术要点与亮点特性说明
        1、反爬避让模拟了真实的浏览器 User-Agent 和 Referer，并处理了 Accept-Encoding 以避免压缩格式导致的解码错误。
        2、目录管理自动创建 downloads 文件夹，并使用时间戳防止文件名冲突，同时对标题中的特殊非法字符（如 \/:*?）进行过滤。
        3、多方案互补既有轻量级的 requests 方案（无需重型依赖），也有强力的 yt-dlp 方案作为保障。
        4、日志追踪接入了 LoggerManager，对每一个关键步骤（获取 CID、下载、合并）都有详细的日志记录，方便排查问题。       
"""

from pathlib import Path
import subprocess
import sys
import re
import ensure_package
from datetime import datetime
from config import MODEL_DIR, SKILL_ROOT, VENV_DIR
import os  # 用于跨平台换行符）
from logger_manager import LoggerManager
ensure_package.pip("requests")  
ensure_package.pip("playwright")  
ensure_package.pip("tf-playwright-stealth")
ensure_package.pip("yt-dlp")
# 安装 chromium 浏览器
subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"])
import requests
import yt_dlp
from yt_dlp.utils import DownloadError  # 专门捕获 yt_dlp 下载错误

logger = LoggerManager.setup_logger(logger_name="link-resolver-engine")

def get_bilibili_no_wm(raw_url: str) -> dict | None:
    """
    B站视频下载地址提取器（纯 requests 版，避开 Playwright 解码问题）
    """
    match = re.search(r'(BV[0-9a-zA-Z]{10})', raw_url)
    if not match:
        logger.error("❌ 未找到 BV 号")
        return None

    bvid = match.group(1)
    logger.info(f"✅ 处理 B站视频: {bvid}")

    # 通用 Headers（模拟真实浏览器）
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
        "Referer": "https://www.bilibili.com/",
        "Accept": "application/json, text/plain, */*",
        "Accept-Encoding": "gzip, deflate",   # 避免 br 问题
        "Accept-Language": "zh-CN,zh;q=0.9",
    }

    session = requests.Session()
    session.headers.update(headers)

    try:
        # 第一步：访问页面获取必要的 cookie 和 aid（cid）
        page_url = f"https://www.bilibili.com/video/{bvid}"
        resp = session.get(page_url, timeout=15)
        resp.raise_for_status()

        # 简单提取 cid（从页面源码中找）
        cid_match = re.search(r'"cid":(\d+)', resp.text)
        if not cid_match:
            logger.info("⚠️ 无法提取 cid，尝试备用方式")
            # 备用：调用接口获取视频信息
            info_url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
            info_resp = session.get(info_url, timeout=10)
            data = info_resp.json().get("data", {})
            cid = data.get("cid") or (data.get("pages") or [{}])[0].get("cid")
        else:
            cid = cid_match.group(1)

        if not cid:
            logger.info("❌ 无法获取 cid")
            return None

        # 第二步：调用 playurl 接口（带简单 wbi 参数模拟）
        play_url = f"https://api.bilibili.com/x/player/wbi/playurl?bvid={bvid}&cid={cid}&qn=64&fnval=4048&fourk=1"
        
        play_resp = session.get(play_url, timeout=15)
        play_resp.raise_for_status()
        json_data = play_resp.json()

        if json_data.get("code") != 0:
            logger.info(f"❌ 接口返回错误: {json_data.get('message')}")
            return None

        data = json_data.get("data", {})
        dash = data.get("dash") or {}

        result = {
            "video_url": None,
            "audio_url": None,
            "title": data.get("title") or "bilibili_video",
            "quality": None
        }

        if dash and dash.get("video"):
            video_tracks = dash["video"]
            # 优先最高画质
            best_video = max(video_tracks, key=lambda x: x.get("bandwidth", 0))
            result["video_url"] = best_video.get("base_url") or best_video.get("baseUrl")
            result["quality"] = f"{best_video.get('height', 0)}p"

            audio_tracks = dash.get("audio") or []
            if audio_tracks:
                best_audio = max(audio_tracks, key=lambda x: x.get("bandwidth", 0))
                result["audio_url"] = best_audio.get("base_url") or best_audio.get("baseUrl")

        elif data.get("durl"):
            durls = data.get("durl") or []
            if durls:
                result["video_url"] = durls[0].get("url")
                result["quality"] = "720p"

        if result["video_url"]:
            logger.info(f"✅ 成功获取 B站地址！质量: {result['quality']}")
            return result
        else:
            logger.info("⚠️ 未找到有效播放地址")
            return None

    except Exception as e:
        logger.error(f"❌ 请求异常: {e}")
        return None

def download_bilibili_video_request(play_info: dict, filename: str = None, download_dir: str = None) -> bool:
    """
    下载 B站视频 + 自动合并音频
    支持自定义下载目录（默认 ./downloads）
    """

    if not play_info or not play_info.get("video_url"):
        logger.info("❌ 没有有效视频地址")
        return False

    # ==================== 目录处理 ====================
    if download_dir is None:
        download_dir = os.path.join(os.getcwd(), "downloads")

    os.makedirs(download_dir, exist_ok=True)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    # ==================== 文件名处理 ====================
    if filename is None:
        safe_title = re.sub(r'[\\/:*?"<>|]', '_', play_info.get('title', 'bilibili_video'))
        filename = f"{safe_title}_{timestamp}.mp4"

    if not filename.endswith(".mp4"):
        filename += ".mp4"

    final_file = os.path.join(download_dir, filename)

    # 临时文件也放目录里（避免污染当前目录）
    temp_video = os.path.join(download_dir, f"temp_video_{timestamp}.mp4")
    temp_audio = os.path.join(download_dir, f"temp_audio_{timestamp}.m4a") if play_info.get("audio_url") else None

    headers = {
        "Referer": "https://www.bilibili.com/",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; Win64; x64) AppleWebKit/537.36",
    }

    video_url = play_info["video_url"]
    audio_url = play_info.get("audio_url")

    logger.info(f"🚀 开始下载: {play_info.get('title', 'bilibili_video')}")
    logger.info(f"📁 保存目录: {download_dir}")

    try:
        # ==================== 下载视频 ====================
        logger.info("下载视频轨...")
        r = requests.get(video_url, headers=headers, stream=True, timeout=120)
        r.raise_for_status()
        with open(temp_video, "wb") as f:
            for chunk in r.iter_content(chunk_size=512 * 1024):
                f.write(chunk)

        # ==================== 下载音频 ====================
        if audio_url:
            logger.info("下载音频轨...")
            r = requests.get(audio_url, headers=headers, stream=True, timeout=120)
            r.raise_for_status()
            with open(temp_audio, "wb") as f:
                for chunk in r.iter_content(chunk_size=512 * 1024):
                    f.write(chunk)

            # ==================== 合并 ====================
            logger.info("正在合并音视频（ffmpeg）...")

            cmd = [
                "ffmpeg", "-y",
                "-i", temp_video,
                "-i", temp_audio,
                "-c:v", "copy",
                "-c:a", "copy",
                "-movflags", "+faststart",
                final_file
            ]

            result = subprocess.run(
                cmd,
                capture_output=False,
                text=False,
                timeout=180
            )

            if result.returncode == 0:
                logger.info(f"🎉 合并完成：{final_file}")
            else:
                logger.info(f"⚠️ ffmpeg 失败: {result.returncode}")
                if os.path.exists(temp_video):
                    fallback = final_file.replace(".mp4", "_video_only.mp4")
                    os.rename(temp_video, fallback)
                return False

            # 清理
            for temp in [temp_video, temp_audio]:
                if temp and os.path.exists(temp):
                    os.remove(temp)

        else:
            # 无音频
            os.rename(temp_video, final_file)
            logger.info(f"🎉 下载完成：{final_file}")

        return True

    except Exception as e:
        logger.info(f"❌ 异常: {e}")
        for temp in [temp_video, temp_audio]:
            if temp and os.path.exists(temp):
                try:
                    os.remove(temp)
                except:
                    pass
        return False
    

def expand_bilibili_url(short_url):
    # 正则表达式验证是否为有效的 Bilibili 短链接（b23.tv）
    # 支持 http/https、可选 www. 前缀，路径为字母、数字、下划线、连字符组成（长度至少 4 位）
    # 示例：https://b23.tv/ReXDyVO、https://b23.tv/BV1xx...、https://b23.tv/av123...
    pattern = r'^https?://(?:www\.)?b23\.tv/[a-zA-Z0-9_-]{4,}(?:\?.*)?$'
    
    if not re.match(pattern, short_url.strip()):
        return False, "不是有效的Bilibili短链接 (必须是 b23.tv 格式)"
    
    try:
        # 使用 head 请求通常比 get 更快，因为不需要下载页面内容
        # allow_redirects=True 会自动追踪重定向
        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"
        }
        response = requests.head(short_url, headers=headers, allow_redirects=True, timeout=10)
        
        # 最终的 URL 存储在 response.url 中
        return True,response.url
    except Exception as e:
        return False, f"{str(e)}"
    
def download_bilibili_video_yt_dlp(
    url: str,
    filename_prefix: str = None,
    download_dir: str = "downloads",
    format_spec: str = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
) -> tuple[bool, str]:
    """
    下载 Bilibili 视频（已封装，失败时返回结果，支持指定下载目录）
    
    返回值:
        (success: bool, message: str)
        - 成功时: (True, "下载成功！文件已保存为: xxx.mp4")
        - 失败时: (False, "下载失败: 具体错误信息")
    
    参数:
        url (str): 视频链接（支持完整 URL 或 b23.tv 短链接）
        filename_prefix (str): 文件名前缀，默认 "bilibili_video"
        add_timestamp (bool): 是否在文件名中添加时间戳（默认 True）
        download_dir (str): 下载目录，默认 "downloads"（相对当前工作目录）
                         如果目录不存在会自动创建
        format_spec (str): 视频格式选择
    
    用法示例:
        # 默认用法（下载到 ./downloads/ 目录，文件名带时间戳）
        success, msg = download_bilibili_video_yt_dlp("https://www.bilibili.com/video/BV1GYXKBzEvM/")
        # 生成的文件示例: download/bilibili_video_20260331_200512.mp4
        # 自定义下载目录
        success, msg = download_bilibili_video_yt_dlp(url, download_dir="F:/my_videos")
        # 自定义前缀 + 不加时间戳 + 指定目录
        success, msg = download_bilibili_video_yt_dlp(url, filename_prefix="我的视频", add_timestamp=False, download_dir="/absolute/path/to/folder")
    """
    # 确保下载目录存在（不存在则自动创建）
    os.makedirs(download_dir, exist_ok=True)
    
    # 生成文件名部分
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    if filename_prefix is None:
        filename_part = f"bilibili_video_{timestamp}.mp4"
    else:
        filename_part = f"{filename_prefix}_{timestamp}.%(ext)s"
    # 完整输出模板（目录 + 文件名）
    output_template = os.path.join(download_dir, filename_part)
    
    ydl_opts = {
        'outtmpl': output_template,
        'format': format_spec,
    }
    
    try:
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            ydl.download([url])
        
        # 下载成功，返回完整文件路径（假设为 mp4）
        actual_filename = output_template.replace('.%(ext)s', '.mp4')
        logger.info(f"下载成功: {actual_filename}")
        return True, f"下载成功！文件已保存为: {actual_filename}"
    
    except DownloadError as e:
        logger.error(f"下载失败（yt_dlp 错误）: {str(e)}")
        return False, f"{str(e)}"
    except Exception as e:  # 捕获其他意外错误
        logger.error(f"下载失败（未知错误）: {str(e)}")
        return False, f"{str(e)}"
    

path: scripts/config.py
content: #!/usr/bin/env python3
# -*- coding: utf-8, -*-

"""
Skill Name: link-resolver-engine
Author: 王岷瑞/https://github.com/wangminrui2022
License: Apache License
Description: 这段代码是一个典型的 Python 项目路径管理模块。
它利用 pathlib 库实现了一套自动化的路径解析方案，旨在解决硬编码（Hardcoding）路径带来的跨平台兼容性差和维护困难等问题。
这段代码的核心逻辑是**“以当前文件为基准，向上溯源寻找根目录”**。
这种方式能确保无论项目被部署在哪个文件夹下，只要目录结构保持不变，代码都能准确找到对应的资源。
"""

from pathlib import Path

_BASE_PATH = Path(__file__).resolve()

class ProjectPaths:
    """
    统一路径管理
    ProjectPaths 类将项目中常用的核心目录进行了静态封装：
    | 路径变量 | 指向位置 | 用途 |
    | :--- | :--- | :--- |
    | VENV_DIR | 根目录/venv | 虚拟环境目录 |
    | DATA_DIR | 根目录/data | 数据文件存放处 |
    | LOG_DIR | 根目录/logs | 日志记录输出目录 |
    | MODEL_DIR | 根目录/models | AI模型权重等资源目录 |    
    """
    SCRIPT_PATH = _BASE_PATH
    SKILL_ROOT = _BASE_PATH.parent.parent
    VENV_DIR = _BASE_PATH.parent.parent.parent / "venv" #venv虚拟环境目录在skills/venv通用目录
    DATA_DIR = SKILL_ROOT / "data"
    LOG_DIR = SKILL_ROOT / "logs"
    MODEL_DIR = SKILL_ROOT / "models"      # 模型下载目录

    @classmethod
    def get_subpath(cls, *parts):
        """
        动态获取基于根目录的子路径
        用法: ProjectPaths.get_subpath("data", "raw", "test.txt")
        """
        path = cls.SKILL_ROOT.joinpath(*parts)
        # 自动创建不存在的目录（可选）
        # path.parent.mkdir(parents=True, exist_ok=True)
        return path

# 也可以直接导出变量，方便外部 import
SCRIPT_PATH = ProjectPaths.SCRIPT_PATH
SKILL_ROOT = ProjectPaths.SKILL_ROOT
VENV_DIR = ProjectPaths.VENV_DIR
DATA_DIR = ProjectPaths.DATA_DIR
LOG_DIR = ProjectPaths.LOG_DIR
MODEL_DIR = ProjectPaths.MODEL_DIR

path: scripts/douyin_video.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Skill Name: link-resolver-engine
Author: 王岷瑞 / https://github.com/wangminrui2022
License: Apache License
Description: 这段代码是一个功能完备的 抖音（Douyin）无水印视频解析与下载工具。
它结合了自动化浏览器（Playwright）和轻量级请求（Requests）两种策略，旨在从抖音链接中提取最高画质的无水印视频地址并保存到本地。
以下是该代码的核心功能和技术亮点描述：

1. 核心功能
    短链接解析：能够将抖音分享的短链接（v.douyin.com）还原为原始的长 URL。
    无水印提取：通过解析视频接口数据，获取不带水印的视频直链。
    高质量选择：代码会遍历视频比特率（Bitrate），优先选择画质最高的资源。
    自动化下载：支持带 Referer 防盗链校验的流式下载，确保视频能成功写入本地。
    环境自适应：内置了自动安装依赖（pip）和浏览器内核（chromium）的逻辑。

2. 技术方案
    代码采用了 “双轨制” 解析方案，提高了解析的成功率：
        方案 A：桌面端自动化解析 (get_douyin_no_wm)
        技术栈：Playwright + playwright-stealth。
        原理：启动一个无头浏览器模拟真实用户访问。使用 Stealth 插件 绕过抖音的反爬虫检测。
    核心逻辑：
        监听网络响应（page.on("response")），捕获包含视频详情的 JSON 接口（如 aweme/detail）。
        通过模拟鼠标滚轮滑动触发页面加载。
        从响应数据中提取 url_list，并将 playwm（带水印）替换为 play（无水印）。
    方案 B：移动端轻量级解析 (download_douyin_video_mobile)
    技术栈：Requests + Regex (正则表达式)。
    原理：模拟 iPhone 用户代理（User-Agent）访问移动版分享页面。
    核心逻辑：
        直接请求页面并解析 HTML 源码。
        利用正则从 window._ROUTER_DATA 或 HTML 标签中提取视频地址。
        这种方法不需要启动浏览器，速度更快，适合简单场景。

3. 代码结构分解
    依赖管理：使用 ensure_package 在运行时动态安装 playwright、requests 等库，减少了手动配置环境的麻烦。
    日志系统：集成 LoggerManager，全程记录解析进度、错误信息和下载状态。
    健壮性设计：
    异常处理：在浏览器操作和文件下载处使用了 try-except。
    超时控制：设置了合理的 timeout，防止程序因网络问题永久卡死。
    文件管理：自动创建 downloads 目录，并以时间戳或视频 ID 命名文件防止冲突。    
"""


import json
from pathlib import Path
import subprocess
import sys
import re
import time
import ensure_package
from datetime import datetime
from config import MODEL_DIR, SKILL_ROOT, VENV_DIR
import os  # 用于跨平台换行符）
from logger_manager import LoggerManager
ensure_package.pip("requests")  
ensure_package.pip("playwright")  
ensure_package.pip("tf-playwright-stealth")
ensure_package.pip("yt-dlp")
# 安装 chromium 浏览器
subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"])
import requests
from playwright.sync_api import sync_playwright
from playwright_stealth import stealth_sync  # tf-playwright-stealth 版本

logger = LoggerManager.setup_logger(logger_name="link-resolver-engine")

def get_douyin_no_wm(raw_url: str) -> str | None:
    """返回抖音最高画质无水印直链（稳定版）"""
    match = re.search(r'(?:modal_id=|/video/|douyin\.com/)(?P<id>\d{19})', raw_url)
    if not match:
        logger.error("❌ 未找到视频 ID")
        return None

    video_id = match.group("id")
    clean_url = f"https://www.douyin.com/video/{video_id}"
    logger.info(f"✅ 正在解析: {clean_url}")

    result = {"url": None}

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
        context = browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
            viewport={"width": 1920, "height": 1080},
            locale="zh-CN",
        )
        page = context.new_page()
        stealth_sync(page)

        def handle_response(response):
            if any(k in response.url for k in ["aweme/detail", "item/detail"]):
                try:
                    data = response.json()
                    aweme = (
                        data.get("aweme_detail")
                        or (data.get("item_list") or [{}])[0]
                        or data.get("aweme")
                    )
                    if not aweme:
                        return

                    video = aweme.get("video") or {}
                    bit_rates = video.get("bit_rate") or []

                    if bit_rates:
                        # 优先最高比特率（这就是 SnapAny 风格的高质量来源）
                        best = max(bit_rates, key=lambda x: x.get("bit_rate", 0))
                        url_list = best.get("play_addr", {}).get("url_list") or []
                    else:
                        url_list = (video.get("play_addr") or {}).get("url_list") or []

                    if url_list:
                        no_wm_url = url_list[0].replace("playwm", "play")
                        result["url"] = no_wm_url
                except:
                    pass

        page.on("response", handle_response)

        try:
            page.goto(clean_url, wait_until="domcontentloaded", timeout=45000)
            time.sleep(2.5)
            for _ in range(6):
                page.mouse.wheel(0, 600)
                time.sleep(1.2)
            time.sleep(3)
        except Exception as e:
            logger.error(f"加载异常: {e}")
        finally:
            browser.close()

    if result["url"]:
        logger.info("\n🎉 最高画质无水印直链（直接复制）：")
        logger.info(result["url"])
        logger.info("\n下载命令（推荐在 CMD/PowerShell 中运行）：")
        logger.info(f'curl -H "Referer: https://www.douyin.com/" -H "User-Agent: Mozilla/5.0" -o "抖音_{video_id}.mp4" "{result["url"]}"')
        return result["url"]
    else:
        return None

def download_douyin_video(no_wm_url: str, filename: str = None, download_dir: str = None) -> bool:
    """带 Referer 下载（支持自定义下载目录）"""
    
    if not no_wm_url:
        logger.info("❌ 没有有效链接")
        return False

    # ==================== 处理下载目录 ====================
    if download_dir is None:
        download_dir = os.path.join(os.getcwd(), "downloads")

    os.makedirs(download_dir, exist_ok=True)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    # ==================== 处理文件名 ====================
    if filename is None:
        filename = f"douyin_video_{timestamp}.mp4"

    if not filename.endswith(".mp4"):
        filename += ".mp4"

    file_path = os.path.join(download_dir, filename)

    headers = {
        "Referer": "https://www.douyin.com/",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    }

    logger.info(f"🚀 开始下载 → {file_path}")

    try:
        r = requests.get(no_wm_url, headers=headers, stream=True, timeout=60)
        r.raise_for_status()

        with open(file_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1024 * 512):
                if chunk:
                    f.write(chunk)

        logger.info(f"🎉 下载完成！文件：{file_path}")
        return True

    except Exception as e:
        logger.error(f"❌ 下载失败: {e}")
        return False

def expand_douyin_url(short_url: str):
    """
    展开抖音短链接 (v.douyin.com)
    返回: (bool, url或错误信息)
    """

    # ✅ 匹配抖音短链
    pattern = r'^https?://v\.douyin\.com/[a-zA-Z0-9]+/?(?:\?.*)?$'

    if not re.match(pattern, short_url.strip()):
        return False, "不是有效的抖音短链接 (必须是 v.douyin.com 格式)"

    try:
        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"
        }

        # ⚠️ 抖音有时候 HEAD 不稳定，建议 fallback 到 GET
        try:
            response = requests.head(short_url, headers=headers, allow_redirects=True, timeout=10)
            final_url = response.url
        except:
            response = requests.get(short_url, headers=headers, allow_redirects=True, timeout=10)
            final_url = response.url

        return True, final_url

    except Exception as e:
        return False, str(e)
 
def download_douyin_video_mobile(short_url: str, filename: str = None,download_dir: str = "downloads"):
    """
    下载抖音视频
    short_url: 抖音短链接，如 https://v.douyin.com/ezOqxZPY3kc/
    """
    headers = {
        "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "zh-CN,zh;q=0.9",
    }

    # ---------- 第1步：跟踪短链接，获取完整URL和视频ID ----------
    session = requests.Session()
    response = session.get(short_url, headers=headers, allow_redirects=True, timeout=15)
    full_url = response.url
    logger.info(f"重定向后URL: {full_url}")

    # 从URL中提取视频ID
    video_id_match = re.search(r'/video/(\d+)', full_url)
    if not video_id_match:
        return False, f"无法从URL中提取视频ID: {full_url}"
    video_id = video_id_match.group(1)
    logger.info(f"视频ID: {video_id}")

    # ---------- 第2步：请求移动版页面，获取视频信息 ----------
    mobile_url = f"https://m.douyin.com/share/video/{video_id}/"
    mobile_resp = session.get(mobile_url, headers=headers, timeout=15)
    html = mobile_resp.text

    # ---------- 第3步：从 HTML 中提取视频直链 ----------
    # 方法1：直接在HTML里搜索 playwm URL
    playwm_match = re.search(r'https://aweme\.snssdk\.com/aweme/v1/playwm/\?[^"]+', html)
    # 方法2：从 window._ROUTER_DATA 中提取（更可靠）
    router_match = re.search(r'window\._ROUTER_DATA\s*=\s*({.+?})\s*</script>', html, re.DOTALL)
    video_url = None

    if playwm_match:
        video_url = playwm_match.group(0)
        logger.info(f"方法1找到视频链接: {video_url}")
    elif router_match:
        try:
            router_data = json.loads(router_match.group(1))
            # 沿着嵌套结构找视频URL
            item_list = router_data.get("loaderData", {}).get("video_(id)/page", {}).get("videoInfoRes", {}).get("item_list", [])
            if item_list:
                play_addr = item_list[0].get("video", {}).get("play_addr", {})
                url_list = play_addr.get("url_list", [])
                if url_list:
                    video_url = url_list[0]
                    logger.info(f"方法2找到视频链接: {video_url}")
        except (json.JSONDecodeError, KeyError) as e:
            logger.error(f"JSON解析失败: {e}")

    if not video_url:
        # 备用：直接从HTML所有https://aweme.snssdk.com链接中找
        all_matches = re.findall(r'https://aweme\.snssdk\.com[^\s"\\]+', html)
        for m in all_matches:
            if "playwm" in m or "play" in m:
                video_url = m
                logger.info(f"备用方法找到: {video_url}")
                break

    if not video_url:
        return False, "无法找到视频下载链接，可能需要登录cookies"

    # ---------- 第4步：清理并补全参数，下载视频 ----------
    # 有些链接可能缺少必要参数，补全一下
    if "line=0" not in video_url:
        video_url = video_url + ("&" if "?" in video_url else "?") + "line=0"

    logger.info(f"最终下载链接: {video_url}")

    # 下载
    video_resp = session.get(video_url, headers=headers, timeout=60, stream=True)
    video_resp.raise_for_status()

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    if filename is None:
        filename = f"{video_id}_{timestamp}.mp4"

    if not filename.endswith(".mp4"):
        filename += ".mp4"

    # 确保下载目录存在（不存在则自动创建）
    os.makedirs(download_dir, exist_ok=True)
    filepath = download_dir.rstrip("/") + "/" + filename
    with open(filepath, "wb") as f:
        for chunk in video_resp.iter_content(chunk_size=8192):
            f.write(chunk)

    logger.info(f"下载完成: {filepath} ({len(open(filepath,'rb').read())} bytes)")
    return True, filepath

path: scripts/logger_manager.py
content: #!/usr/bin/env python3
# -*- coding: utf-8, -*-

"""
Skill Name: link-resolver-engine
Author: 王岷瑞/https://github.com/wangminrui2022
License: Apache License
Description: 这段代码定义了一个名为 LoggerManager 的类，专门用于自动化配置和管理日志系统。
它通过封装 Python 的 logging 模块，为项目提供了一个既能输出到控制台，又能自动按天滚动保存的日志工具。
智能目录管理：利用 pathlib 自动检查并创建日志目录（LOG_DIR），防止因文件夹不存在而报错。
1、双通道输出：
    1、控制台 (Stdout)：方便开发者在运行程序时实时查看状态。
    2、文件 (File)：持久化记录运行日志，便于后续排查问题。
2、自动滚动备份：使用了 TimedRotatingFileHandler，设置每 1天 滚动一次日志文件，并保留最近 3天 的记录，有效防止日志文件无限增大占用硬盘空间。
3、防止日志重复：通过 if not logger.handlers 判断，确保在多次调用初始化方法时，不会重复绑定处理器（Handler），避免出现一行日志被打印多次的情况。
"""

import logging
import sys
from pathlib import Path
from logging.handlers import TimedRotatingFileHandler
from config import LOG_DIR

class LoggerManager:
    @staticmethod
    def setup_logger(logger_name: str = "log", log_filename: str = "skill_execution.log"):
        """
        初始化并配置日志记录器
        :param log_dir: 日志保存目录 (Path 对象)
        :param logger_name: logger 的名称
        :param log_filename: 日志文件的名称
        """
        # 确保日志目录存在 (parents=True 确保父级目录也会被创建)
        LOG_DIR.mkdir(parents=True, exist_ok=True)
        log_file = LOG_DIR / log_filename
        
        logger = logging.getLogger(logger_name)
        logger.setLevel(logging.INFO)
        
        # 避免在多次调用时重复添加 handler 导致日志重复输出
        if not logger.handlers:
            formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
            
            # 文件输出 Handler
            file_handler = TimedRotatingFileHandler(
                log_file, when="D", interval=1, backupCount=3, encoding="utf-8"
            )
            file_handler.setFormatter(formatter)
            logger.addHandler(file_handler)
            
            # 控制台输出 Handler
            console_handler = logging.StreamHandler(sys.stdout)
            console_handler.setFormatter(formatter)
            logger.addHandler(console_handler)
        
        return logger

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