
Universal Media Downloader
- 105 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Downloads media from video and podcast links using yt-dlp, covering Douyin, Bilibili, YouTube, and other sites, with cookies.txt retry for restricted content.
About
A media downloader that uses yt-dlp to fetch video and podcast files from links across many sites. Developers use it to save videos from Douyin, Bilibili, YouTube, and podcast platforms, retrying restricted content with cookies.txt.
- yt-dlp-based coverage of many video and podcast sites
- cookies.txt retry for 403/login/region-restricted media
Universal Media Downloader by the numbers
- 105 all-time installs (skills.sh)
- Ranked #779 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill universal-media-downloaderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 105 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Downloads media from video and podcast links using yt-dlp, covering Douyin, Bilibili, YouTube, and other sites, with cookies.txt retry for restricted content.
Files
Universal Media Downloader(通用视频/播客下载)
适用范围
优先覆盖(通常可直接用)
- 抖音视频:
douyin.com、v.douyin.com等视频链接 - 抖音图文笔记:暂不支持自动下载(反爬限制),需手动截图
- B站:
bilibili.com、b23.tv等 - YouTube:
youtube.com、youtu.be - 以及其它 yt-dlp 支持的网站(数量很多)
播客平台
- 小宇宙(单集/节目页):多数情况下可直接下载音频(yt-dlp 通常能用)
- 其它播客平台:如果页面可解析出音频直链,通常也能下载
合规提示:仅用于下载你有权保存的内容(例如你自己上传/拥有版权/获得授权/平台允许离线的内容)。遇到 DRM/加密或平台限制时,不要尝试绕过。
快速开始
1)下载视频(默认)
- 命令:
python scripts/download_media.py "<URL>"
- 默认保存目录:
- 技能目录下的
downloads/文件夹(自包含) - 可通过
--out-dir参数自定义输出路径
2)只下载音频(适合播客 / 只想要 MP3)
- 命令:
python scripts/download_media.py --audio-only --audio-format mp3 "<URL>"
3)遇到 403 / 需要登录 / 风控拦截:用 cookies 重试
- 让用户提供浏览器导出的 Netscape 格式
cookies.txt
- 然后重试:
python scripts/download_media.py --cookies "/path/to/cookies.txt" "<URL>"
4)需要代理(可选)
- 例如:
--proxy "socks5://127.0.0.1:7890"
5)指定下载路径
- 用户可通过自然语言指定保存位置,AI 应自动转换为
--out-dir参数 - 示例:
- 用户说"下载到桌面" →
--out-dir ~/Desktop - 用户说"保存到 Videos/bilibili" →
--out-dir "~/Videos/bilibili" - 用户说"下载到这个文件夹"(指定某路径)→ 使用用户指定的绝对路径
- 注意:确保目标目录存在,如不存在可自动创建
平台差异与限制(重要)
- YouTube/B站/抖音:
- 常见失败原因:年龄限制、地区限制、频繁请求触发风控、需要登录
- 处理方式:cookies、代理、或降低并发/等待后重试
- YouTube 额外提示:若出现 Signature solving failed / JS challenge 警告,可按 yt-dlp 的 EJS 指引启用挑战求解组件(例如加
--remote-components ejs:github),或让用户提供 cookies
- Spotify:
- Spotify 上的内容可能存在 DRM、账号权限/订阅限制,且“下载”可能违反平台条款。
- 本 skill 不保证 Spotify 链接一定可下载。
- 可行替代:
- 使用官方离线功能(若平台提供)
- 提供该播客的 RSS/音频直链(如果你拥有/可获得),再用本脚本下载
Bundled scripts
scripts/download_media.py- 基于
yt-dlp的通用下载器 - 输出:成功时最后一行
SAVED_FILEPATH=... - AI 使用指引:当用户指定保存路径时,自动使用
--out-dir参数 - 参数:
url(必填)--audio-only/--audio-format--subtitles(可选,自动下载字幕)--sub-lang(可选,字幕语言,默认 all)--cookies(可选)--proxy(可选)--out-dir(可选,自定义输出目录)
依赖
系统依赖
| 依赖 | 安装方式 |
|---|---|
yt-dlp | pip install yt-dlp |
ffmpeg(可选,用于字幕提取和音频转换) | macOS: brew install ffmpeg<br>Linux: sudo apt-get install ffmpeg |
Python 包
无需额外 Python 依赖,yt-dlp 已包含所需库。
变更日志
[0.2.0] - 2026-02-12
新增
- 新增字幕下载支持(
--subtitles参数) - 支持指定字幕语言(
--sub-lang,默认下载所有可用字幕) - 添加"依赖"章节到 SKILL.md
改进
- 修复 docstring 中的过时路径示例
[0.1.0] - 2026-02-12
新增
- 初始版本,基于 yt-dlp 实现通用视频/播客下载功能
- 支持抖音、B站、YouTube 等主流视频平台
- 支持小宇宙等播客平台音频下载
- 支持音频-only 模式(MP3 等格式)
- 支持 cookies.txt 用于绕过登录/403限制
- 支持代理设置
- 自包含 downloads/ 输出目录
技术优化
- 默认输出目录设置为技能目录下的
downloads/文件夹 - 使用
--print after_move:filepath获取最终文件路径 - 输出
SAVED_FILEPATH便于 AI 解析结果
待办事项
- 添加更多平台兼容性测试
- 添加批量下载支持(播放列表)
MIT License
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""抖音图文笔记下载器
下载抖音图文笔记中的所有图片。
使用方法:
python download_douyin_note.py "<url>" [--out-dir <directory>]
示例:
python download_douyin_note.py "https://v.douyin.com/xxx/"
python download_douyin_note.py "https://www.douyin.com/note/xxx/" --out-dir ~/Downloads/douyin
"""
import argparse
import os
import re
import json
import requests
from pathlib import Path
from urllib.parse import urlparse, unquote
SCRIPT_DIR = Path(__file__).parent.parent
DEFAULT_OUT_DIR = SCRIPT_DIR / "downloads" / "douyin_notes"
# 请求头
HEADERS = {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.38',
'Referer': 'https://www.douyin.com/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}
def expand_short_url(url: str) -> str:
"""展开短链接"""
try:
resp = requests.head(url, headers=HEADERS, allow_redirects=True, timeout=10)
return resp.url
except:
return url
def extract_note_id(url: str) -> str:
"""从 URL 中提取笔记 ID"""
# 展开短链接
full_url = expand_short_url(url)
# 匹配各种格式的笔记 ID
patterns = [
r'/note/(\d+)',
r'/share/note/(\d+)',
r'modal_id=(\d+)',
r'/video/(\d+)', # 有些图文笔记也可能是 video 格式
]
for pattern in patterns:
match = re.search(pattern, full_url)
if match:
return match.group(1)
return None
def get_note_data(note_id: str) -> dict:
"""获取笔记数据(通过模拟 API 请求)"""
# 尝试通过移动端 API 获取数据
api_url = f"https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids={note_id}"
try:
resp = requests.get(api_url, headers=HEADERS, timeout=10)
data = resp.json()
if data.get('status_code') == 0:
return data.get('item_list', [{}])[0]
except Exception as e:
print(f"[warning] API 请求失败: {e}")
return None
def extract_images_from_html(url: str) -> list:
"""从 HTML 页面提取图片 URL"""
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
html = resp.text
# 尝试从页面中提取图片数据
# 抖音页面中通常有 <script>__RENDER_DATA__=xxx</script>
match = re.search(r'__RENDER_DATA__\s*=\s*([^<]+)</script>', html)
if match:
try:
render_data = json.loads(unquote(match.group(1)))
# 解析 render_data 获取图片列表
# 这里需要根据实际数据结构调整
images = []
# 尝试不同的数据路径
paths = [
('app', 'video', 'images'),
('app', 'aweme', 'detail', 'aweme', 'images'),
('aweme_detail', 'images'),
]
for path in paths:
data = render_data
for key in path:
if isinstance(data, dict) and key in data:
data = data[key]
else:
data = None
break
if data and isinstance(data, list):
for img in data:
if isinstance(img, dict):
url_list = img.get('url_list', [])
if url_list:
images.append(url_list[0])
elif isinstance(img, str):
images.append(img)
if images:
return images
except json.JSONDecodeError:
pass
# 备用:直接匹配图片 URL
img_pattern = r'https?://[^"\'>\s]+\.(?:jpg|jpeg|png|webp|heic)'
images = list(set(re.findall(img_pattern, html)))
# 过滤掉头像等非内容图片
images = [img for img in images if 'p26-' in img or 'p3-' in img or 'p9-' in img]
return images[:20] # 最多返回 20 张
except Exception as e:
print(f"[error] 提取图片失败: {e}")
return []
def download_images(images: list, out_dir: Path, title: str = "douyin_note") -> list:
"""下载所有图片"""
downloaded = []
# 清理标题
safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
safe_title = re.sub(r'[-\s]+', '_', safe_title)
for i, img_url in enumerate(images, 1):
try:
# 确定扩展名
ext = '.jpg'
if '.png' in img_url:
ext = '.png'
elif '.webp' in img_url:
ext = '.webp'
elif '.heic' in img_url:
ext = '.heic'
# 下载图片
resp = requests.get(img_url, headers=HEADERS, timeout=30)
if resp.status_code == 200:
filename = f"{safe_title}_{i:02d}{ext}"
filepath = out_dir / filename
with open(filepath, 'wb') as f:
f.write(resp.content)
downloaded.append(str(filepath))
print(f"[downloaded] {filename}")
else:
print(f"[warning] 图片 {i} 下载失败: HTTP {resp.status_code}")
except Exception as e:
print(f"[warning] 图片 {i} 下载失败: {e}")
return downloaded
def main():
parser = argparse.ArgumentParser(description='抖音图文笔记下载器')
parser.add_argument('url', help='抖音图文笔记链接')
parser.add_argument('--out-dir', default=str(DEFAULT_OUT_DIR), help='输出目录')
parser.add_argument('--title', default='', help='自定义标题')
args = parser.parse_args()
# 创建输出目录
out_dir = Path(args.out_dir).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
url = args.url
print(f"[info] 处理链接: {url}")
# 展开短链接
full_url = expand_short_url(url)
print(f"[info] 完整链接: {full_url}")
# 检查是否是图文笔记
if '/note/' not in full_url and '/share/note/' not in full_url:
print("[warning] 这可能不是图文笔记链接,可能需要使用 universal-media-downloader 处理视频")
# 提取图片
print("[info] 正在提取图片...")
images = extract_images_from_html(full_url)
if not images:
print("[error] 未能提取到任何图片")
print("[提示] 可以尝试:")
print(" 1. 手动打开链接截图保存")
print(" 2. 使用浏览器开发者工具查看图片 URL")
return 1
print(f"[info] 找到 {len(images)} 张图片")
# 下载图片
print("[info] 开始下载...")
title = args.title or "douyin_note"
downloaded = download_images(images, out_dir, title)
print(f"\n[完成] 成功下载 {len(downloaded)} 张图片到: {out_dir}")
# 输出文件路径(供 AI 读取)
for path in downloaded:
print(f"SAVED_FILEPATH={path}")
return 0
if __name__ == '__main__':
exit(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Universal media downloader for video sites and podcasts.
This script is designed to be called by AnyGen as a deterministic helper.
It relies on yt-dlp, which supports many video sites (YouTube/Bilibili/Douyin, etc.)
AND can often download podcast audio when the page exposes a direct audio URL.
Examples:
# Download best video+audio (mp4)
python scripts/download_media.py "https://www.youtube.com/watch?v=xxxx"
# Download audio only (mp3)
python scripts/download_media.py --audio-only --audio-format mp3 "https://www.xiaoyuzhoufm.com/episode/xxxx"
# Download with subtitles (auto-detect all available)
python scripts/download_media.py --subtitles "https://www.youtube.com/watch?v=xxxx"
# Download with Chinese subtitles only
python scripts/download_media.py --subtitles --sub-lang "zh" "https://www.youtube.com/watch?v=xxxx"
# Use cookies when needed (e.g., Douyin/Bilibili/YouTube age-gated)
python scripts/download_media.py --cookies "/path/to/cookies.txt" "<URL>"
Output:
- Prints the final saved filepath (absolute) as the last line: SAVED_FILEPATH=<path>
"""
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
import sys
from pathlib import Path
# Default output directory: relative to skill root
SCRIPT_DIR = Path(__file__).parent.parent
DEFAULT_OUT_DIR = SCRIPT_DIR / "downloads"
def run(cmd: list[str]) -> tuple[int, str]:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return p.returncode, p.stdout
def main() -> int:
parser = argparse.ArgumentParser(description="Download video/audio by URL using yt-dlp")
parser.add_argument("url", help="Video or podcast episode URL")
parser.add_argument(
"--out-dir",
default=str(DEFAULT_OUT_DIR),
help=f"Output directory (default: {DEFAULT_OUT_DIR})",
)
parser.add_argument(
"--cookies",
default="",
help="Path to cookies.txt (Netscape format). Useful when 403/login required.",
)
parser.add_argument(
"--proxy",
default="",
help="Proxy URL, e.g. socks5://127.0.0.1:7890 or http://127.0.0.1:7890",
)
parser.add_argument(
"--audio-only",
action="store_true",
help="Extract audio only (no video). For podcasts, this is usually what you want.",
)
parser.add_argument(
"--audio-format",
default="mp3",
help="Audio format when --audio-only is set (default: mp3)",
)
parser.add_argument(
"--subtitles",
action="store_true",
help="Download subtitles if available.",
)
parser.add_argument(
"--sub-lang",
default="all",
help="Subtitle language(s) to download (default: all, e.g., 'zh,en')",
)
args = parser.parse_args()
out_dir = Path(args.out_dir).expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
# Output template: keep title short + id to avoid collisions
outtmpl = str(out_dir / "%(title).120B_%(id)s.%(ext)s")
cmd = [
"yt-dlp",
"--no-playlist",
"--js-runtimes",
"node",
"-o",
outtmpl,
"--print",
"after_move:filepath",
]
if args.cookies:
cookies_path = Path(args.cookies).expanduser().resolve()
if not cookies_path.exists():
raise FileNotFoundError(f"cookies file not found: {cookies_path}")
cmd.extend(["--cookies", str(cookies_path)])
if args.proxy:
cmd.extend(["--proxy", args.proxy])
if args.audio_only:
cmd.extend([
"--extract-audio",
"--audio-format",
args.audio_format,
"--audio-quality",
"0",
])
else:
# Prefer mp4 for convenience (may still be webm depending on source)
cmd.extend(["--merge-output-format", "mp4"])
if args.subtitles:
cmd.extend([
"--write-sub",
"--sub-langs",
args.sub_lang,
])
cmd.append(args.url)
rc, out = run(cmd)
# Echo full yt-dlp output for debugging.
sys.stdout.write(out)
if rc != 0:
sys.stderr.write("\n[download_media] yt-dlp failed. Command was:\n")
sys.stderr.write(" " + " ".join(shlex.quote(c) for c in cmd) + "\n")
return rc
# yt-dlp prints the moved file path via --print after_move:filepath.
# Usually the last non-empty line.
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
saved = ""
if lines:
cand = lines[-1]
if os.path.exists(cand):
saved = cand
if saved:
sys.stdout.write(f"\nSAVED_FILEPATH={saved}\n")
else:
# Fallback: pick newest file in out_dir
files = sorted([p for p in out_dir.glob('*') if p.is_file()], key=lambda p: p.stat().st_mtime, reverse=True)
if files:
saved = str(files[0])
sys.stdout.write(f"\nSAVED_FILEPATH={saved}\n")
else:
sys.stdout.write("\nSAVED_FILEPATH=\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())