account-video-downloader
多平台账号主页视频提取器 — 输入平台名称和账号id/链接,自动拉取抖音/快手/B站/YouTube 四大平台主页作品,解析下载链接,支持批量下载。适用场景:竞品账号内容拆解与逐帧学习、收藏喜欢的创作者全部作品避免被删、批量搬运素材二次剪辑创作、保存自己账号作品做本地备份防丢失、课程/教程类视频离线囤货反复看、达人合作前快速拉取对方作品做背调分析。触发词:主页视频下载、批量下载视频、账号视频提取、快手主页下载、B站视频提取、YouTube频道下载、视频保存、无水印下载、竞品视频下载、博主视频备份。
Install
npx skills add https://github.com/redfox-data/redfox-community/tree/main/skills/account-video-downloader
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install redfox-data-redfox-community@llmmart
git clone https://github.com/redfox-data/redfox-community.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole redfox-data/redfox-community collection as a plugin from our marketplace. Git is the plain clone.
README
多平台账号主页视频提取器 / Account Video Downloader
简介
输入账号标识,自动拉取主页作品并解析下载链接,支持一键批量下载到本地。覆盖抖音、快手、B站、YouTube 四大平台。抖音、快手请输入账号ID,B站、YouTube 请输入作者主页链接
核心价值
- 无水印直链:解析后的视频/图文链接已去除平台水印,可直接用于二次创作或备份。
- 批量高效:一次输入平台和账号,自动完成作品拉取、链接解析、文件下载全流程。
- 灵活筛选:支持按发布时间范围、分页浏览,精准定位目标作品。
- 多平台统一:一个工具覆盖五大平台,相同的使用体验。
适用对象
- 🎬 内容创作者 — 收藏学习竞品账号的优质内容,逐帧拆解拍摄与剪辑技巧。
- 📦 运营 / MCN — 批量备份达人合作作品,快速完成背调分析。
- 🎓 知识学习者 — 离线囤积教程类视频,随时反复观看。
功能特性
核心功能
- 作品拉取:通过账号标识获取主页近期作品,包含标题、互动数据和作品链接。
- 链接解析:逐条解析视频/图文的下载直链,支持视频、封面、音频多种资源类型。
- 批量下载:一键下载全部作品到本地,视频和图文均支持。
- 分页浏览:支持翻页查看更多作品,每页最多 50 条。
- 日期筛选:按作品发布时间范围精确筛选,支持自然语言识别(如「最近一周」「7月份的作品」)。
- 多平台覆盖:抖音 / 快手 / B站 / YouTube 四大平台统一入口。
支持平台
| --platform | 平台 | 账号标识 | 说明 |
|---|---|---|---|
douyin |
抖音 | 抖音号(uniqueName) | 如 Fish688688 |
kuaishou |
快手 | kwaiId | 快手展示 ID |
bilibili |
哔哩哔哩 | 主页链接(accountUrl) | 如 https://space.bilibili.com/123456 |
youtube |
YouTube | 频道 URL(channel) | 如 https://www.youtube.com/@channel |
密钥获取与安全说明
- 本技能需要使用环境变量:
REDFOX_API_KEY。 REDFOX_API_KEY由 红狐 hub (https://redfox.hk)提供。- 请前往 红狐 hub 注册账号,获取
REDFOX_API_KEY。 - 配置设备环境变量
REDFOX_API_KEY后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
- 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。
使用指南
直接用自然语言描述需求,无需记忆命令。
常用说法速查
| 意图 | 示例话术 | 效果 |
|---|---|---|
| 下载单个平台视频 | 「帮我下载这个B站账号的视频」 | 拉取作品列表并解析下载链接 |
| 批量下载 | 「把快手和B站的视频都下载下来」 | 多平台依次拉取并下载 |
| 按时间筛选 | 「下载这个账号 7 月份的作品」 | 自动识别日期范围,精准筛选 |
| 查看更多作品 | 「继续看下一页」 | 翻页获取更多作品 |
输出示例
解析完成后,你将看到类似以下格式的作品表格(示意):
| # | 发布时间 | 作品 | 赞 | 评论 | 收藏 | 分享 | 资源下载 |
|---|---|---|---|---|---|---|---|
| 1 | 07-28 | 作品标题 | 5.2k | 67 | 689 | 5.8k | 🎬视频 · 🖼封面 · 🎵音频 |
表格底部会显示可下载作品数和失败数,并询问您是否需要批量下载到本地。
使用场景
| 场景 | 角色 | 示例问法 | 收益 |
|---|---|---|---|
| 竞品内容拆解 | 创作者 / 编导 | 「帮我下载这个B站UP主的视频,逐帧学习」 | 离线反复观看,拆解拍摄手法 |
| 作品备份防丢 | 博主 / 运营 | 「把我账号的视频都备份一份到本地」 | 防止平台删除或账号异常丢失内容 |
| 达人背调分析 | MCN / 品牌 | 「把这个达人的作品全部拉下来看看」 | 合作前快速了解内容风格与数据表现 |
| 教程离线囤货 | 学习者 | 「把这个YouTube频道的视频下载下来慢慢学」 | 无需联网,随时反复观看学习 |
Skill manifest
多平台账号主页视频提取器
输入平台 + 账号 → 拉取作品列表 → 解析下载链接 → 一键下载到本地
简介
多平台账号主页视频批量下载工具。支持 抖音、快手、B站、YouTube 四大平台,只需提供平台名称和账号标识,自动拉取该账号近期作品列表,逐条解析视频/图文下载直链,支持一键批量下载到本地。
功能特性
| 功能 | 说明 |
|---|---|
| 📋 作品拉取 | 通过账号获取主页近期作品(标题、互动数据、作品链接) |
| 🔗 链接解析 | 逐条调用解析接口,获取视频/图文下载直链 |
| 📥 批量下载 | 一键下载全部作品(视频+图文)到本地 output/ 目录 |
| 📊 数据展示 | Markdown 表格 / JSON 双格式输出,含完整互动数据 |
| 📄 分页翻页 | 支持翻页查看更多作品 |
| 📅 日期筛选 | 可按作品发布时间挑选 |
| 🌐 多平台 | 一个工具覆盖抖音/快手/B站/YouTube 四大平台 |
支持平台
| --platform | 平台 | 账号标识 | 说明 |
|---|---|---|---|
douyin |
抖音 | 抖音号 | 如 Fish688688 |
kuaishou |
快手 | 快手号 | 如 Fish688688 |
bilibili |
哔哩哔哩 | 主页链接(accountUrl) | 如 https://space.bilibili.com/123456 |
youtube |
YouTube | 频道 URL(channel) | 如 https://www.youtube.com/@channel |
API 说明
本 Skill 调用 redfox.hk 接口,每个平台分两步:
| 步骤 | 接口路径 | 说明 |
|---|---|---|
| 1. 拉取作品 | POST /story/api/{platform}/... |
根据账号标识获取作品列表 |
| 2. 解析下载 | POST /story/api/parseWork/videoDownload/{platform} |
根据作品链接获取下载直链 |
认证
前往 红狐hub 获取 API Key,设为环境变量:
# macOS / Linux
export REDFOX_API_KEY=ak_你的密钥
# Windows PowerShell
$env:REDFOX_API_KEY="ak_你的密钥"
使用方式
CLI 命令行
# 抖音:基础用法
python3 "$SKILL_PATH/scripts/main.py" --platform douyin --account "Fish688688"
# 快手:基础用法
python3 "$SKILL_PATH/scripts/main.py" --platform kuaishou --account "kwaiId"
# B站:拉取作品并解析下载链接(不下载文件)
python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "https://space.bilibili.com/123456"
# YouTube:下载视频到本地
python3 "$SKILL_PATH/scripts/main.py" --platform youtube --account "https://www.youtube.com/@channel" --download
# YouTube:指定下载目录
python3 "$SKILL_PATH/scripts/main.py" --platform youtube --account "https://www.youtube.com/@channel" --download --output-dir ./my_videos
# 指定作品数量(默认10,最多50)
python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --count 20 --download
# 翻页查看更多作品
python3 "$SKILL_PATH/scripts/main.py" --platform kuaishou --account "用户ID" --page 2
# 按日期范围筛选作品
python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --date-start 2026-07-01 --date-end 2026-07-31
# 多账号(逗号分隔)
python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --accounts "MID1,MID2" --download
# 组合使用:第2页 + 日期过滤 + 下载
python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --page 2 --count 20 --date-start 2026-06-01 --download
参数说明
| 参数 | 说明 |
|---|---|
--platform |
目标平台(必填,见上表) |
--account |
单个账号标识 |
--accounts |
多个账号标识,逗号分隔 |
--count |
拉取作品数量(默认10,最多50) |
--page |
页码(默认1) |
--date-start |
起始日期 YYYY-MM-DD |
--date-end |
结束日期 YYYY-MM-DD |
--download |
下载视频文件到本地 |
--output-dir |
下载目录(默认 output/) |
--json |
JSON 格式输出 |
--rate-limit |
请求间隔秒数(默认 1.0) |
依赖
| 依赖 | 安装命令 |
|---|---|
requests |
pip3 install requests |
账号标识要求
不同平台对账号标识有不同要求,必须提供各平台的唯一标识:
| 平台 | 要求 | URL 支持 | 获取方式 |
|---|---|---|---|
| B站 | 主页链接(accountUrl) | ✅ 支持 | 直接复制个人空间页 URL |
| YouTube | 频道 URL(channel) | ✅ 支持 | 直接复制频道页 URL |
| 抖音 | 抖音号(uniqueName) | ❌ 不支持 | 抖音 APP → 目标主页 → 头像下方「抖音号:xxx」字段 |
| 快手 | 账号 ID(kwaiId) | ❌ 不支持 | 快手 APP → 目标主页 → 昵称下方显示的 ID |
⚠️ 抖音、快手不支持主页链接! 如果用户粘贴了 URL,必须提示用户提供上述唯一标识。
重要: 若用户只输入账号名称而未提供唯一标识,必须提示用户提供准确的唯一标识。
📸 抖音号获取方式: 打开抖音 APP → 进入目标用户主页 → 在头像下方、粉丝数据下方可看到「抖音号:xxx」字段。若用户不确定抖音号在哪,可展示示意截图(红框标注「抖音号」位置)帮助用户定位。
Agent 集成指南
触发词
- 主页视频下载 / 批量下载视频 / 账号视频提取
- 抖音下载 / 抖音视频提取 / 下载抖音作品
- B站视频提取 / B站视频下载 / 哔哩哔哩视频下载
- YouTube频道下载 / YouTube视频提取
- 帮我下载 xxx 的主页视频
Agent 执行流程
Step 1: 确认用户意图、平台与账号标识
- 从用户输入中识别目标平台(抖音/快手/B站/YouTube)
- 若用户未明确平台,询问:「你想下载哪个平台的视频?」
- 确认账号标识格式正确,若用户提供的是中文昵称,提示提供唯一 ID
Step 2: 识别用户输入的时间范围(如有)
- 若用户提到了作品时间范围,自动识别并转换为 --date-start / --date-end:
- "7.1~7.20" → --date-start 2026-07-01 --date-end 2026-07-20
- "7月1日到7月20日" → --date-start 2026-07-01 --date-end 2026-07-20
- "最近一周" → 当前日期往前推 7 天
- "上个月" → 上个月 1 日到上个月最后一天
- "7月份的作品" → --date-start 2026-07-01 --date-end 2026-07-31
- 日期格式固定为 YYYY-MM-DD,年份默认为当前年份
- 若用户要求"继续"/"下一页",添加 --page N
Step 3: 调用脚本拉取作品 + 解析下载链接
python3 "$SKILL_PATH/scripts/main.py" --platform <platform> --account "<id>"
Step 4: 展示结果 + 询问翻页
- 展示 Markdown 表格,含可点击的作品链接和下载链接
- 若有失败:提示「可能是用户已删除该视频,如需数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
- 若可翻页:告诉用户「还有更多作品,是否需要翻看下一页?」,输入下一页翻页
- 提示用户支持按时间范围提取作品
Step 5: 询问用户是否需要批量下载
- 使用 AskUserQuestion:「是否需要将能下载的视频批量下载到本地?」
- 选项:「下载到本地」/「只看链接即可」
Step 6: 用户确认后执行下载
python3 "$SKILL_PATH/scripts/main.py" --platform <platform> --account "<id>" --download
- 告知用户文件保存路径
Agent 输出规范
解析完成后按以下格式输出(自然语言风格,面向普通用户)。
⛔ 强制规则:禁止简化输出
Agent 必须原样展示脚本返回的完整表格,禁止以下行为:
- ❌ 禁止删除或合并任何列(发布时间、作品、赞、评论、收藏、分享、资源下载 缺一不可)
- ❌ 禁止省略翻页提示行
- ❌ 禁止省略时间范围筛选提示("💡 支持输入想提取的作品时间范围…")
- ❌ 禁止省略下载询问提示("💾 需要将这 X 条作品批量下载到本地吗?")
- ❌ 禁止用「...」或摘要代替完整表格内容
- ✅ 必须逐行展示所有作品的完整信息(含资源下载链接)
输出模板
## 📥 {平台}视频下载 — @账号名(粉丝: X)
当前是**第 1 页**,共 10 条作品 | 还有更多作品,输入 `--page 2` 翻看下一页
| # | 发布时间 | 作品 | 赞 | 评论 | 收藏 | 分享 | 资源下载 |
| --- | -------- | ----------------- | ---- | ---- | ---- | ---- | --------------------------------------------- |
| 1 | 07-28 | [作品标题](链接) | 5.2k | 67 | 689 | 5.8k | [🎬视频](...) · [🖼封面](...) · [🎵音频](...) |
| 2 | 07-24 | [作品标题2](链接) | 3.1k | 22 | 136 | 865 | [🖼封面](...) |
| 3 | … | … | … | … | … | … | … |
**合计:** 10 条作品,8 条可下载,2 条下载失败
> ⚠️ 下载失败的视频可能是用户已删除该视频,如需数据核查可联系工作人员邮箱 **redfoxdata@proton.me** 处理。
> 💡 需要提取特定时间范围的作品?直接告诉我时间范围即可,如「7.1~7.20」「最近一周」
> 💾 需要将这 X 条作品批量下载到本地吗?直接告诉我即可。
平台名称会根据实际平台自动替换(快手 / B站 / YouTube)。
常见问题
Q:如何获取 API Key? A:前往 redfox.hk 注册获取。
Q:下载的视频有水印吗? A:无水印。API 返回的视频/图文直链已去除平台水印。
Q:图文作品能下载吗? A:可以。图文作品(幻灯片、相册类)会自动下载首张图片,格式为 JPG/PNG/WebP。
Q:为什么提示「调用频率超限」?
A:API 返回 code=3108 时表示请求过快触发了限流。可增加 --rate-limit 2.0 延长间隔。
Q:支持哪些平台? A:抖音、快手、B站(哔哩哔哩)、YouTube 四大平台。
Q:各平台账号标识从哪里获取? A:抖音需要抖音号(如 JCLjiangchenglan),快手需要账号 ID / kwaiId(账号名称下方),B站需要主页链接,YouTube 需要频道 URL。
Files (redfox-community)
-
scripts
-
base.py 24.9 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 共享基类 — API Key 管理、作品缓存、文件下载、输出格式化 ======================================================================== 各平台子类继承 BaseDownloader,仅需实现 fetch_works() 和 get_download_info()。 """ import json import os import re import sys import time import warnings from abc import ABC, abstractmethod from datetime import datetime from pathlib import Path from typing import Optional from urllib.parse import unquote, urlparse import requests warnings.filterwarnings("ignore", category=Warning) warnings.filterwarnings("ignore", message=".*NotOpenSSLWarning.*") # ─── 配置 ───────────────────────────────────────────────────────────────────────── API_BASE = "https://redfox.hk" ENV_KEY = "REDFOX_API_KEY" DEFAULT_PAGE_SIZE = 10 MAX_PAGE_SIZE = 50 DEFAULT_RATE_LIMIT = 1.0 SUPPORT_EMAIL = "redfoxdata@proton.me" # ─── 作品列表缓存(全量拉取优化)───────────────────────────────────── CACHE_DIR = Path.home() / ".qoder" / "account_video_extractor_cache" CACHE_TTL_SECONDS = 1800 # 30 分钟 # ─── 终端颜色 ────────────────────────────────────────────────────────────────────── GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" CYAN = "\033[96m" BOLD = "\033[1m" RESET = "\033[0m" def info(msg: str): print(f"{GREEN}[OK]{RESET} {msg}") def warn(msg: str): print(f"{YELLOW}[!!]{RESET} {msg}") def error(msg: str): print(f"{RED}[XX]{RESET} {msg}") def step(msg: str): print(f"{CYAN}[>>]{RESET} {msg}") # ─── API Key 管理 ────────────────────────────────────────────────────────────────── def get_api_key() -> Optional[str]: """从环境变量 REDFOX_API_KEY 获取 API Key""" return os.environ.get(ENV_KEY) # ─── 数字格式化 ──────────────────────────────────────────────────────────────────── def format_number(n) -> str: """格式化数字: >=1亿->x.x亿, >=1万->x.xw, >=1千->x.xk, 其余逗号分隔""" if n is None: return "-" try: n = int(n) except (ValueError, TypeError): return str(n) if n >= 100000000: return f"{n / 100000000:.1f}亿" if n >= 10000: return f"{n / 10000:.1f}w" if n >= 1000: return f"{n / 1000:.1f}k" return f"{n:,}" # ─── 中文检测 ────────────────────────────────────────────────────────────────────── def _is_chinese(text: str) -> bool: """判断输入是否包含中文字符""" return bool(re.search(r'[\u4e00-\u9fff]', text)) # ─── 作品列表缓存(全量拉取优化)───────────────────────────────────── def _cache_key(platform: str, account_id: str) -> str: """生成缓存文件名(hash 防路径注入)""" import hashlib raw = f"{platform}:{account_id.strip().lower()}" return hashlib.sha256(raw.encode()).hexdigest()[:16] + ".json" def _load_works_cache(platform: str, account_id: str) -> Optional[dict]: """ 读取缓存。返回 (data_dict, is_fresh) 或 None。 data_dict 包含: account, works, timestamp """ if not CACHE_DIR.exists(): return None cache_file = CACHE_DIR / _cache_key(platform, account_id) if not cache_file.exists(): return None try: data = json.loads(cache_file.read_text(encoding="utf-8")) age = time.time() - data.get("timestamp", 0) if age > CACHE_TTL_SECONDS: cache_file.unlink(missing_ok=True) return None return data except (json.JSONDecodeError, OSError): cache_file.unlink(missing_ok=True) return None def _save_works_cache(platform: str, account_id: str, data: dict): """保存作品列表缓存""" CACHE_DIR.mkdir(parents=True, exist_ok=True) data["timestamp"] = time.time() cache_file = CACHE_DIR / _cache_key(platform, account_id) cache_file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") # ─── 文件名清理 ──────────────────────────────────────────────────────────────────── def safe_filename(text: str) -> str: """将文本转为安全的文件名(去除非法字符、限制长度)""" text = re.sub(r'[\\/:*?"<>|\n\r\t]', '_', text) text = text.strip().strip('.') if len(text) > 80: text = text[:80] return text or "video" # ─── 抽象基类 ────────────────────────────────────────────────────────────────────── class BaseDownloader(ABC): """多平台视频下载器基类""" def __init__(self, api_key: str, platform_key: str, platform_label: str): """ Args: api_key: API 密钥 platform_key: 平台标识(如 "kuaishou", "bilibili") platform_label: 平台中文名(如 "快手", "B站") """ self.api_key = api_key self.platform_key = platform_key self.platform_label = platform_label # ── 子类必须实现 ──────────────────────────────────────────────────────────── @abstractmethod def fetch_works(self, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "") -> dict: """ 拉取账号作品列表 Returns: dict: { "success": bool, "account": dict | None, "works": list[dict], "error": str | None, "rate_limited": bool } 其中每个 work dict 已通过 _normalize_work() 统一字段。 """ ... @abstractmethod def get_download_info(self, work_url: str) -> dict: """ 解析视频下载链接 Returns: dict: {"success": bool, "download_url": str|None, "title": str|None, "cover": str|None, "duration": int|None, "resources": list, "error": str|None} """ ... # ── 可选覆写 ──────────────────────────────────────────────────────────────── def validate_account_id(self, account_id: str) -> tuple: """ 验证账号标识格式。默认拒绝纯中文输入。 Returns: (is_valid: bool, message: str) """ if _is_chinese(account_id): return False, ( f"「{account_id}」看起来是账号昵称而非唯一标识。\n" "请提供平台账号的唯一 ID 以便精准查询。" ) if not account_id or len(account_id) < 2: return False, f"「{account_id}」不是有效的账号标识,请提供正确的 ID。" return True, "" def description_hint(self) -> str: """返回该平台账号标识的说明文案,用于提示用户输入正确格式""" return f"请提供 {self.platform_label} 的账号唯一标识(如用户 ID)。" # ── 共享实现 ──────────────────────────────────────────────────────────────── def download_video(self, download_url: str, output_path: str) -> bool: """ 下载视频文件到本地 Args: download_url: 视频下载直链 output_path: 保存路径 Returns: bool: 是否下载成功 """ try: resp = requests.get(download_url, timeout=120, stream=True) resp.raise_for_status() total = int(resp.headers.get("content-length", 0)) downloaded = 0 with open(output_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) downloaded += len(chunk) # 验证文件大小 if total > 0 and downloaded < total * 0.9: warn(f" 文件可能不完整: {downloaded}/{total} bytes") return False return True except requests.exceptions.RequestException as e: error(f" 下载失败: {e}") return False except OSError as e: error(f" 文件写入失败: {e}") return False # ── 字段归一化(子类可覆写) ──────────────────────────────────────────────── def _normalize_work(self, work: dict, account_id: str) -> dict: """ 将平台特有字段映射为通用 schema。子类可覆写以支持平台特定字段名。 通用 schema: { "title": str, "workUrl": str, "publishTime": str (YYYY-MM-DD), "likeCount": int, "commentCount": int, "collectCount": int, "shareCount": int, "authorName": str, "followerCount": int } """ return { "title": work.get("title") or work.get("content") or work.get("desc") or "", "workUrl": work.get("workUrl") or work.get("opusUrl") or work.get("url") or work.get("link") or "", "publishTime": work.get("publishTime") or work.get("createTime") or work.get("created_at") or work.get("pubdate") or "", "likeCount": work.get("likeCount") or work.get("diggCount") or work.get("like_count") or 0, "commentCount": work.get("commentCount") or work.get("comment_count") or 0, "collectCount": work.get("collectCount") or work.get("collect_count") or work.get("favoriteCount") or 0, "shareCount": work.get("shareCount") or work.get("share_count") or 0, "authorName": work.get("authorName") or work.get("nickname") or work.get("name") or account_id, "followerCount": work.get("authorFansCount") or work.get("authorFans") or work.get("followerCount") or work.get("follower_count") or 0, } # ─── 主处理流程 ──────────────────────────────────────────────────────────────────── def process_account( downloader: BaseDownloader, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "", rate_limit: float = DEFAULT_RATE_LIMIT, do_download: bool = False, output_dir: str = "", ) -> tuple: """ 处理单个账号:拉取作品 → 日期过滤 → 解析下载链接 → 下载视频 Args: downloader: 平台下载器实例 account_id: 账号标识 page_size: 每页数量 page_num: 页码 date_start: 起始日期 YYYY-MM-DD date_end: 结束日期 YYYY-MM-DD rate_limit: 请求间隔秒数 do_download: 是否下载文件 output_dir: 下载目录 Returns: (account_info: dict, results: list[dict]) """ # Step 1: 拉取作品 label = downloader.platform_label date_info = "" if date_start or date_end: date_info = f" (日期 {date_start}~{date_end})" step(f"拉取{label}账号作品: {account_id}{date_info}") works_result = downloader.fetch_works(account_id, page_size, page_num=page_num, date_start=date_start, date_end=date_end) if not works_result["success"]: error(f"拉取失败: {works_result['error']}") return works_result.get("account") or {"accountId": account_id}, [] account = works_result["account"] works = works_result["works"] has_more_api = works_result.get("has_more") # YouTube 翻页标志 if has_more_api is not None: account["_has_more"] = has_more_api info(f"拉取到 {len(works)} 条作品 — {account.get('accountName', account_id)}") if not works: warn("该账号暂无作品数据") return account, [] # Step 1.5: 客户端日期过滤 if date_start or date_end: original_count = len(works) filtered = [] for w in works: pt = w.get("publishTime", "") if not pt: filtered.append(w) continue if date_start and pt[:10] < date_start: continue if date_end and pt[:10] > date_end: continue filtered.append(w) works = filtered skipped = original_count - len(works) if skipped > 0: info(f"日期过滤:{original_count} → {len(works)} 条(跳过 {skipped} 条)") if not works: warn("日期范围内暂无作品") return account, [] # Step 1.6: 客户端分页截断(YouTube 等全量返回的平台) if page_size > 0 and len(works) > page_size: works = works[:page_size] # Step 2: 逐条解析下载链接 results = [] total = len(works) for i, work in enumerate(works, 1): work_url = work.get("workUrl") or "" title = work.get("title") or "无标题" print(f"\n [{i}/{total}] {CYAN}解析:{RESET} {title[:50]}{'...' if len(title) > 50 else ''}") if not work_url: warn(f" 无作品链接,跳过") results.append({ "title": title, "work_url": "", "likeCount": work.get("likeCount", 0), "shareCount": work.get("shareCount", 0), "commentCount": work.get("commentCount", 0), "collectCount": work.get("collectCount", 0), "publishTime": work.get("publishTime", ""), "download_success": False, "download_error": "无作品链接", "download_url": None, "cover": None, "duration": None, "resources": [], "local_path": None, }) continue # 优先使用作品列表自带的无水印直链(快手),省掉下载接口调用 direct_url = work.get("directVideoUrl") if direct_url: dl_result = { "success": True, "download_url": direct_url, "title": title, "cover": work.get("coverUrl") or "", "duration": None, "resources": [{"type": "video", "downloadUrl": direct_url}], "error": None, } info(f" ✓ 免解析(作品列表含无水印直链)") else: dl_result = downloader.get_download_info(work_url) result_entry = { "title": title, "work_url": work_url, "likeCount": work.get("likeCount", 0), "shareCount": work.get("shareCount", 0), "commentCount": work.get("commentCount", 0), "collectCount": work.get("collectCount", 0), "publishTime": work.get("publishTime", ""), "download_success": dl_result["success"], "download_error": dl_result.get("error"), "download_url": dl_result.get("download_url"), "cover": dl_result.get("cover"), "duration": dl_result.get("duration"), "resources": dl_result.get("resources", []), "local_path": None, } if dl_result["success"] and dl_result.get("download_url"): resources = dl_result.get("resources", []) is_image_post = ( not any(r.get("type") == "video" for r in resources if isinstance(r, dict)) and any(r.get("type") == "image" for r in resources if isinstance(r, dict)) ) media_label = "图片" if is_image_post else "视频" info(f" 解析成功({media_label})" + (f" | 时长: {dl_result['duration']}s" if dl_result.get("duration") else "")) # Step 3: 下载 if do_download: dl_url = dl_result["download_url"] pub_time = work.get("publishTime", "") time_part = pub_time[:10].replace("-", "") if pub_time else datetime.now().strftime("%Y%m%d") safe_title = safe_filename(title) VIDEO_EXTS = ("mp4", "mov", "webm", "avi") IMAGE_EXTS = ("jpg", "jpeg", "png", "webp", "gif", "bmp") ext = ".jpg" if is_image_post else ".mp4" parsed = urlparse(dl_url) path_part = unquote(parsed.path) if "." in path_part.rsplit("/", 1)[-1]: url_ext = path_part.rsplit(".", 1)[-1].split("?")[0].lower() if is_image_post and url_ext in IMAGE_EXTS: ext = f".{url_ext}" elif not is_image_post and url_ext in VIDEO_EXTS: ext = f".{url_ext}" filename = f"{time_part}_{safe_title}{ext}" out_path = os.path.join(output_dir, filename) print(f" {CYAN}下载中...{RESET}", end="", flush=True) if downloader.download_video(dl_url, out_path): file_size = os.path.getsize(out_path) size_mb = file_size / (1024 * 1024) info(f"\r 下载完成: {filename} ({size_mb:.1f} MB)") result_entry["local_path"] = out_path else: warn(f"\r 下载失败") result_entry["download_success"] = False result_entry["download_error"] = "文件下载失败" else: warn(f" 解析失败: {dl_result.get('error', '未知错误')}") results.append(result_entry) # 速率限制 if i < total: time.sleep(rate_limit) return account, results # ─── Markdown 输出 ──────────────────────────────────────────────────────────────── def print_markdown_table( downloader: BaseDownloader, account: dict, results: list, page_num: int = 1, page_size: int = DEFAULT_PAGE_SIZE, ): """ 输出 Markdown 格式的结果表格 ⛔ 强制规则:Agent 调用方必须原样展示本函数的完整输出,禁止以下行为: - 禁止删除或合并任何列 - 禁止省略翻页提示行 - 禁止省略时间范围筛选提示和下载询问提示 - 禁止用「...」或摘要代替完整表格内容 - 必须逐行展示所有作品信息(含完整资源下载链接) """ name = account.get("accountName", account.get("accountId", "未知")) fans = format_number(account.get("followerCount")) label = downloader.platform_label # 粉丝数:None 表示平台无此数据,不展示 fans_display = f"(粉丝: {fans})" if account.get("followerCount") is not None else "" print(f"\n## 📥 {label}视频下载 — @{name}{fans_display}") print() total = len(results) # 翻页提示:YouTube 用 continuation token;其他平台用 pageNum yt_has_more = account.get("_has_more") show_more = yt_has_more if yt_has_more is not None else (total >= page_size) page_info = f"当前是**第 {page_num} 页**,共 {total} 条作品" if show_more: next_page = page_num + 1 page_info += f" | 还有更多作品,输入 `--page {next_page}` 翻看下一页" print(page_info) print() print("| # | 发布时间 | 作品 | 赞 | 评论 | 收藏 | 分享 | 资源下载 |") print("|---|----------|------|-----|------|------|------|------|") success_count = 0 fail_count = 0 for i, r in enumerate(results, 1): title_raw = (r.get("title") or "无标题")[:25] title = title_raw.replace("|", "\\|").replace("\n", " ") work_url = r.get("work_url", "") if work_url: title_display = f"[{title}]({work_url})" else: title_display = title pub_time = r.get("publishTime", "")[:10] if r.get("publishTime") else "-" likes = format_number(r.get("likeCount")) comments = format_number(r.get("commentCount")) collects = format_number(r.get("collectCount")) shares = format_number(r.get("shareCount")) # 资源下载列 resources = r.get("resources", []) resource_parts = [] seen_types = set() has_video = False for res in resources: if not isinstance(res, dict): continue rtype = res.get("type", "") rurl = res.get("downloadUrl") or res.get("url") or "" if rtype and rurl and rtype not in seen_types: seen_types.add(rtype) if rtype == "video": has_video = True resource_parts.append(f"[视频]({rurl})") elif rtype == "image": resource_parts.append(f"[封面]({rurl})") elif rtype == "audio": resource_parts.append(f"[音频]({rurl})") else: resource_parts.append(f"[{rtype}]({rurl})") cover = r.get("cover") if cover and "image" not in seen_types: resource_parts.append(f"[封面]({cover})") # YouTube CDN 链接是 IP 锁定的签名 URL,浏览器无法直接访问 if downloader.platform_key == "youtube" and resource_parts: resource_parts = ["[▶ 播放]({}) · ⚠️ 下载需 `--download`".format(work_url)] if work_url else ["⚠️ 下载需 `--download`"] resource_display = "<br>".join(resource_parts) if resource_parts else "-" if r.get("download_success") and has_video: success_count += 1 elif r.get("download_success"): success_count += 1 else: fail_count += 1 print(f"| {i} | {pub_time} | {title_display} | {likes} | {comments} | {collects} | {shares} | {resource_display} |") print() parts = [f"{success_count} 条可下载"] if fail_count > 0: parts.append(f"{fail_count} 条下载失败") print(f"**合计:** {total} 条作品,{','.join(parts)}") if fail_count > 0: print(f"\n> ⚠️ 下载失败的视频可能是用户已删除该视频,如需数据核查可联系工作人员邮箱 **{SUPPORT_EMAIL}** 处理。") print(f"\n> 💡 支持输入想提取的作品时间范围,如 `--date-start 2026-07-01 --date-end 2026-07-20`") if success_count > 0: print(f"> 💾 需要将这 {success_count} 条作品批量下载到本地吗?直接告诉我即可。") # ─── JSON 输出 ──────────────────────────────────────────────────────────────────── def print_json_output(account: dict, results: list): """输出 JSON 格式结果""" output = { "account": account, "total": len(results), "success": sum(1 for r in results if r.get("download_success")), "failed": sum(1 for r in results if not r.get("download_success")), "results": results, "generated_at": datetime.now().isoformat(), } print(json.dumps(output, ensure_ascii=False, indent=2)) -
bilibili.py 9.4 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 哔哩哔哩平台视频下载器 """ import json import re import requests from .base import ( API_BASE, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, BaseDownloader, ) class BilibiliDownloader(BaseDownloader): """B站账号视频下载器""" WORKS_ENDPOINT = "/story/api/bili/data/accountWorkList" DOWNLOAD_ENDPOINT = "/story/api/parseWork/videoDownload/bilibili" def __init__(self, api_key: str): super().__init__(api_key, "bilibili", "B站") @staticmethod def _extract_mid(account_id: str) -> str: """从B站主页链接中提取 mid""" m = re.search(r'space\.bilibili\.com/(\d+)', account_id) return m.group(1) if m else "" def validate_account_id(self, account_id: str) -> tuple: """B站账号标识为账号主页链接(accountUrl)""" if not account_id or len(account_id) < 5: return False, f"「{account_id}」不是有效的B站主页链接,请提供完整的个人空间 URL。" if "bilibili.com" not in account_id and "b23.tv" not in account_id: return False, f"「{account_id}」不是B站域名链接,请提供如 https://space.bilibili.com/123456 格式的主页 URL。" return True, "" def description_hint(self) -> str: return "请提供B站账号的主页链接(如 https://space.bilibili.com/123456)。" def fetch_works(self, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "") -> dict: mid = self._extract_mid(account_id) payload = { "accountUrl": account_id, "page": page_num, "pageSize": min(page_size, MAX_PAGE_SIZE), "order": "time", "source": "多平台主页作品提取-GitHub", } if mid: payload["mid"] = mid url = f"{API_BASE}{self.WORKS_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) result = resp.json() except requests.exceptions.Timeout: return {"success": False, "account": None, "works": [], "error": "请求超时,请稍后重试"} except requests.exceptions.RequestException as e: return {"success": False, "account": None, "works": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "account": None, "works": [], "error": "API 返回无效数据"} code = result.get("code") msg = result.get("msg", "") if code in (200, 2000): data_raw = result.get("data", {}) if not data_raw: return {"success": False, "account": None, "works": [], "error": "未查询到该账号的作品数据,可能账号尚未收录"} # B站 API 返回 data.workList raw_works = data_raw.get("workList", []) if isinstance(data_raw, dict) else [] works = [self._normalize_work(w, account_id) for w in raw_works] # 提取账号信息:用第一条作品的 author 作为账号名 account_info = {"accountId": account_id} if works: account_info["accountName"] = works[0].get("authorName", account_id) else: account_info["accountName"] = account_id # B站 API 无粉丝数返回 account_info["followerCount"] = None # B站 API 返回 total(作品总数),用于精确判断 has_more total_count = data_raw.get("total", 0) has_more = (page_num * min(page_size, MAX_PAGE_SIZE)) < total_count return { "success": True, "account": account_info, "works": works, "error": None, "has_more": has_more, } if code == 3108: return {"success": False, "account": None, "works": [], "error": "调用频率超限,请稍后重试或增加 --rate-limit 参数"} if code in (3106, 3107): return {"success": False, "account": None, "works": [], "error": f"API Key 无效 (code {code}),请检查配置"} if code == 400: return {"success": False, "account": None, "works": [], "error": f"请求参数错误: {msg}"} return {"success": False, "account": None, "works": [], "error": f"API 错误 (code {code}): {msg}"} def _normalize_work(self, work: dict, account_id: str) -> dict: """B站视频字段归一化""" bv_id = work.get("bvId", "") return { "title": work.get("title") or "", "workUrl": f"https://www.bilibili.com/video/{bv_id}" if bv_id else (work.get("url") or ""), "publishTime": work.get("created") or work.get("publishTime") or "", "likeCount": work.get("likeCount", 0), "commentCount": work.get("commentCount", 0), "collectCount": work.get("favoriteCount", 0), "shareCount": work.get("shareCount", 0), "authorName": work.get("author") or work.get("authorName") or account_id, "followerCount": 0, } def get_download_info(self, work_url: str) -> dict: payload = {"url": work_url, "source": "多平台主页作品提取-GitHub"} url = f"{API_BASE}{self.DOWNLOAD_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) data = resp.json() except requests.exceptions.Timeout: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "解析超时"} except requests.exceptions.RequestException as e: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "API 返回无效数据"} code = data.get("code") msg = data.get("msg", "") if not str(code).startswith("2"): if code == 3108: err = "调用频率超限" elif code in (3106, 3107): err = "API Key 无效" elif code == 400: err = f"参数错误: {msg}" else: err = f"API 错误 (code {code}): {msg}" return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": err} return self._parse_download_data(data.get("data")) def _parse_download_data(self, payload_data) -> dict: """解析下载接口返回数据""" result = { "success": True, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": None, } if not payload_data: return {**result, "success": False, "error": "API 返回空数据"} if isinstance(payload_data, dict): result["title"] = payload_data.get("desc") or payload_data.get("title") result["cover"] = payload_data.get("cover") or payload_data.get("coverUrl") dur = payload_data.get("duration") or payload_data.get("durationSeconds") if isinstance(dur, (int, float)): result["duration"] = int(dur) resources = payload_data.get("resources", []) if isinstance(resources, list): result["resources"] = resources for res in resources: if isinstance(res, dict): rtype = res.get("type", "") dl = res.get("downloadUrl") or res.get("url") if dl and rtype == "video" and not result["download_url"]: result["download_url"] = dl if dl and rtype == "image": if not result["cover"]: result["cover"] = dl if not result["download_url"]: result["download_url"] = dl res_dur = res.get("durationSeconds") if isinstance(res_dur, (int, float)) and not result["duration"]: result["duration"] = int(res_dur) if not result["download_url"]: result["download_url"] = ( payload_data.get("videoUrl") or payload_data.get("video_url") or payload_data.get("downloadUrl") or payload_data.get("download_url") or payload_data.get("playUrl") ) return result -
douyin.py 9.9 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 抖音平台视频下载器 """ import json import re import requests from .base import ( API_BASE, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, BaseDownloader, ) class DouyinDownloader(BaseDownloader): """抖音账号视频下载器""" WORKS_ENDPOINT = "/story/api/dy/data/listWorkByAccount" DOWNLOAD_ENDPOINT = "/story/api/parseWork/videoDownload/douyin" def __init__(self, api_key: str): super().__init__(api_key, "douyin", "抖音") def validate_account_id(self, account_id: str) -> tuple: """抖音只接受抖音号(uniqueName),拒绝 URL / 昵称 / sec_uid""" if not account_id or len(account_id) < 2: return False, "请输入抖音号。" # 拒绝主页链接 if "douyin.com" in account_id: return False, ( "抖音不支持主页链接,请提供**抖音号**。\n" "抖音号获取方式:抖音 APP → 目标账号主页 → 头像下方「抖音号:xxx」字段(例如 JCLjiangchenglan)。" ) # 拒绝中文昵称 if re.search(r'[\u4e00-\u9fff]', account_id): return False, ( f"「{account_id}」是账号昵称而非抖音号。抖音昵称可能重名,请提供唯一的**抖音号**。\n" "获取方式:抖音 APP → 目标账号主页 → 头像下方「抖音号:xxx」字段。" ) # 拒绝 sec_uid(长哈希值,非抖音号) if len(account_id) > 20: return False, ( "这看起来是加密的用户 ID(sec_uid),不是抖音号。请提供**抖音号**。\n" "获取方式:抖音 APP → 目标账号主页 → 头像下方「抖音号:xxx」字段(例如 JCLjiangchenglan)。" ) return True, "" def description_hint(self) -> str: return "请提供抖音号(抖音 APP → 目标主页 → 头像下方「抖音号」字段,如 JCLjiangchenglan)。" def fetch_works(self, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "") -> dict: payload = { "uniqueName": account_id, "shortId": "", "pageNum": page_num, "pageSize": min(page_size, MAX_PAGE_SIZE), "source": "多平台主页作品提取-GitHub", } if date_start: payload["startDate"] = date_start if date_end: payload["endDate"] = date_end url = f"{API_BASE}{self.WORKS_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) result = resp.json() except requests.exceptions.Timeout: return {"success": False, "account": None, "works": [], "error": "请求超时,请稍后重试"} except requests.exceptions.RequestException as e: return {"success": False, "account": None, "works": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "account": None, "works": [], "error": "API 返回无效数据"} code = result.get("code") msg = result.get("msg", "") if code in (200, 2000): data_raw = result.get("data", {}) if not data_raw: return {"success": False, "account": None, "works": [], "error": "未查询到该账号的作品数据,可能账号尚未收录"} if isinstance(data_raw, list): raw_works = data_raw elif isinstance(data_raw, dict): raw_works = data_raw.get("list") or [] else: raw_works = [] works = [self._normalize_work(w, account_id) for w in raw_works] account_info = {"accountId": account_id} if works: account_info["accountName"] = works[0].get("authorName", account_id) account_info["followerCount"] = works[0].get("followerCount", 0) else: account_info["accountName"] = account_id return { "success": True, "account": account_info, "works": works, "error": None, } if code == 3108: return {"success": False, "account": None, "works": [], "error": "调用频率超限,请稍后重试或增加 --rate-limit 参数"} if code in (3106, 3107): return {"success": False, "account": None, "works": [], "error": f"API Key 无效 (code {code}),请检查配置"} if code == 400: return {"success": False, "account": None, "works": [], "error": f"请求参数错误: {msg}"} return {"success": False, "account": None, "works": [], "error": f"API 错误 (code {code}): {msg}"} def _normalize_work(self, work: dict, account_id: str) -> dict: """抖音视频字段归一化""" return { "title": work.get("content") or work.get("title") or "", "workUrl": work.get("opusUrl") or work.get("workUrl") or "", "publishTime": work.get("publishTime") or "", "likeCount": work.get("likeCount", 0), "commentCount": work.get("commentCount", 0), "collectCount": work.get("collectCount", 0), "shareCount": work.get("shareCount", 0), "authorName": work.get("authorName") or work.get("nickname") or account_id, "followerCount": work.get("authorFansCount") or work.get("followerCount") or 0, } def get_download_info(self, work_url: str) -> dict: payload = {"url": work_url, "source": "多平台主页作品提取-GitHub"} url = f"{API_BASE}{self.DOWNLOAD_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) data = resp.json() except requests.exceptions.Timeout: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "解析超时"} except requests.exceptions.RequestException as e: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "API 返回无效数据"} code = data.get("code") msg = data.get("msg", "") if not str(code).startswith("2"): if code == 3108: err = "调用频率超限" elif code in (3106, 3107): err = "API Key 无效" elif code == 400: err = f"参数错误: {msg}" else: err = f"API 错误 (code {code}): {msg}" return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": err} return self._parse_download_data(data.get("data")) def _parse_download_data(self, payload_data) -> dict: """解析下载接口返回数据""" result = { "success": True, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": None, } if not payload_data: return {**result, "success": False, "error": "API 返回空数据"} if isinstance(payload_data, dict): result["title"] = payload_data.get("desc") or payload_data.get("title") result["cover"] = payload_data.get("cover") or payload_data.get("coverUrl") dur = payload_data.get("duration") or payload_data.get("durationSeconds") if isinstance(dur, (int, float)): result["duration"] = int(dur) resources = payload_data.get("resources", []) if isinstance(resources, list): result["resources"] = resources for res in resources: if isinstance(res, dict): rtype = res.get("type", "") dl = res.get("downloadUrl") or res.get("url") if dl and rtype == "video" and not result["download_url"]: result["download_url"] = dl if dl and rtype == "image": if not result["cover"]: result["cover"] = dl if not result["download_url"]: result["download_url"] = dl res_dur = res.get("durationSeconds") if isinstance(res_dur, (int, float)) and not result["duration"]: result["duration"] = int(res_dur) if not result["download_url"]: result["download_url"] = ( payload_data.get("videoUrl") or payload_data.get("video_url") or payload_data.get("downloadUrl") or payload_data.get("download_url") or payload_data.get("playUrl") ) return result -
kuaishou.py 9.9 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 快手平台视频下载器 """ import json import re import sys import requests from .base import ( API_BASE, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, BaseDownloader, error, info, warn, ) class KuaishouDownloader(BaseDownloader): """快手账号视频下载器""" # 平台端点 WORKS_ENDPOINT = "/story/api/ksAllData/queryWorkList" DOWNLOAD_ENDPOINT = "/story/api/parseWork/videoDownload/kuaishou" def __init__(self, api_key: str): super().__init__(api_key, "kuaishou", "快手") def validate_account_id(self, account_id: str) -> tuple: """快手只接受 kwaiId,拒绝 URL / 昵称""" if not account_id or len(account_id) < 2: return False, "请输入快手账号 ID(kwaiId)。" # 拒绝主页链接 if "kuaishou.com" in account_id or "kuaishou.cn" in account_id: return False, ( "快手不支持主页链接,请提供**账号 ID(kwaiId)**。\n" "获取方式:快手 APP → 目标账号主页 → 昵称下方显示的 ID(例如 junningjunning666)。" ) # 拒绝中文昵称 if re.search(r'[\u4e00-\u9fff]', account_id): return False, ( f"「{account_id}」是账号昵称而非 ID。快手昵称可能重名,请提供唯一的**账号 ID(kwaiId)**。\n" "获取方式:快手 APP → 目标账号主页 → 昵称下方显示的 ID。" ) return True, "" def description_hint(self) -> str: return "请提供快手账号 ID / kwaiId(快手 APP → 目标主页 → 昵称下方显示,如 junningjunning666)。" def fetch_works(self, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "") -> dict: payload = { "kwaiId": account_id, "page": page_num, "size": min(page_size, MAX_PAGE_SIZE), "source": "多平台主页作品提取-GitHub", } url = f"{API_BASE}{self.WORKS_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) result = resp.json() except requests.exceptions.Timeout: return {"success": False, "account": None, "works": [], "error": "请求超时,请稍后重试"} except requests.exceptions.RequestException as e: return {"success": False, "account": None, "works": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "account": None, "works": [], "error": "API 返回无效数据"} code = result.get("code") msg = result.get("msg", "") if code in (200, 2000): data_raw = result.get("data", {}) if not data_raw: return {"success": False, "account": None, "works": [], "error": "未查询到该账号的作品数据,可能账号尚未收录"} if isinstance(data_raw, list): raw_works = data_raw elif isinstance(data_raw, dict): raw_works = data_raw.get("list") or [] else: raw_works = [] works = [self._normalize_work(w, account_id) for w in raw_works] # 提取账号信息:快手响应中每项有 nickname / authorFans account_info = {"accountId": account_id} if works: account_info["accountName"] = works[0].get("authorName", account_id) account_info["followerCount"] = works[0].get("followerCount", 0) else: account_info["accountName"] = account_id return { "success": True, "account": account_info, "works": works, "error": None, } if code == 3108: return {"success": False, "account": None, "works": [], "error": "调用频率超限,请稍后重试或增加 --rate-limit 参数"} if code in (3106, 3107): return {"success": False, "account": None, "works": [], "error": f"API Key 无效 (code {code}),请检查配置"} if code == 400: return {"success": False, "account": None, "works": [], "error": f"请求参数错误: {msg}"} return {"success": False, "account": None, "works": [], "error": f"API 错误 (code {code}): {msg}"} def _normalize_work(self, work: dict, account_id: str) -> dict: """快手视频字段归一化""" photo_id = work.get("photoId", "") return { "title": work.get("caption") or work.get("title") or "", "workUrl": f"https://www.kuaishou.com/short-video/{photo_id}" if photo_id else (work.get("workUrl") or ""), "publishTime": work.get("publishTime") or "", "likeCount": work.get("likeCount", 0), "commentCount": work.get("commentCount", 0), "collectCount": work.get("collectCount") or 0, "shareCount": work.get("shareCount", 0), "authorName": work.get("nickname") or work.get("authorName") or account_id, "followerCount": work.get("authorFans") or work.get("authorFansCount") or 0, # 作品列表接口直接返回无水印 mp4 地址,无需再调下载接口 "directVideoUrl": work.get("videoUrl") or "", "coverUrl": work.get("coverUrl") or "", } def get_download_info(self, work_url: str) -> dict: payload = {"url": work_url, "source": "多平台主页作品提取-GitHub"} url = f"{API_BASE}{self.DOWNLOAD_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) data = resp.json() except requests.exceptions.Timeout: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "解析超时"} except requests.exceptions.RequestException as e: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "API 返回无效数据"} code = data.get("code") msg = data.get("msg", "") if not str(code).startswith("2"): if code == 3108: err = "调用频率超限" elif code in (3106, 3107): err = "API Key 无效" elif code == 400: err = f"参数错误: {msg}" else: err = f"API 错误 (code {code}): {msg}" return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": err} payload_data = data.get("data") if not payload_data: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "API 返回空数据"} return self._parse_download_data(payload_data) def _parse_download_data(self, payload_data) -> dict: """解析下载接口返回数据,提取资源链接""" result = { "success": True, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": None, } if isinstance(payload_data, dict): result["title"] = payload_data.get("desc") or payload_data.get("title") result["cover"] = payload_data.get("cover") or payload_data.get("coverUrl") dur = payload_data.get("duration") or payload_data.get("durationSeconds") if isinstance(dur, (int, float)): result["duration"] = int(dur) resources = payload_data.get("resources", []) if isinstance(resources, list): result["resources"] = resources for res in resources: if isinstance(res, dict): rtype = res.get("type", "") dl = res.get("downloadUrl") or res.get("url") if dl and rtype == "video" and not result["download_url"]: result["download_url"] = dl if dl and rtype == "image": if not result["cover"]: result["cover"] = dl if not result["download_url"]: result["download_url"] = dl res_dur = res.get("durationSeconds") if isinstance(res_dur, (int, float)) and not result["duration"]: result["duration"] = int(res_dur) if not result["download_url"]: result["download_url"] = ( payload_data.get("videoUrl") or payload_data.get("video_url") or payload_data.get("downloadUrl") or payload_data.get("download_url") or payload_data.get("playUrl") ) return result -
main.py 10 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 多平台账号主页视频提取器 — 统一入口 ===================================== 支持平台:抖音、快手、哔哩哔哩、YouTube Usage: python3 main.py --platform douyin --account "抖音号" python3 main.py --platform kuaishou --account "kwaiId" python3 main.py --platform bilibili --account "主页URL" --download python3 main.py --platform youtube --account "频道URL" --download --output-dir ./videos """ import argparse import os import sys from datetime import datetime # Windows 终端 UTF-8 编码修复 if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass from .base import ( DEFAULT_PAGE_SIZE, DEFAULT_RATE_LIMIT, MAX_PAGE_SIZE, get_api_key, info, warn, error, BOLD, CYAN, RED, YELLOW, RESET, process_account, print_markdown_table, print_json_output, ) from .kuaishou import KuaishouDownloader from .douyin import DouyinDownloader from .bilibili import BilibiliDownloader from .youtube import YouTubeDownloader # ─── 平台注册表 ──────────────────────────────────────────────────────────────────── PLATFORM_REGISTRY = { "douyin": { "label": "抖音", "downloader_cls": DouyinDownloader, "help": "抖音账号视频下载(抖音号 uniqueName)", }, "kuaishou": { "label": "快手", "downloader_cls": KuaishouDownloader, "help": "快手账号视频下载(用户 ID)", }, "bilibili": { "label": "B站", "downloader_cls": BilibiliDownloader, "help": "B站账号视频下载(主页链接 accountUrl)", }, "youtube": { "label": "YouTube", "downloader_cls": YouTubeDownloader, "help": "YouTube 频道视频下载(频道 ID / @handle)", }, } def get_platform(name: str) -> dict: """根据平台名获取平台配置,支持模糊匹配""" name_lower = name.lower().strip() if name_lower in PLATFORM_REGISTRY: return PLATFORM_REGISTRY[name_lower] # 模糊匹配 fuzzy_map = { "dy": "douyin", "douyin": "douyin", "ks": "kuaishou", "kuaishou": "kuaishou", "bili": "bilibili", "b站": "bilibili", "yt": "youtube", "youtube": "youtube", } mapped = fuzzy_map.get(name_lower) if mapped: return PLATFORM_REGISTRY[mapped] return None def main(): platforms_help = "\n".join( f" {k:12s} — {v['label']}:{v['help']}" for k, v in PLATFORM_REGISTRY.items() ) parser = argparse.ArgumentParser( description="多平台账号主页视频提取器 — 支持抖音/快手/B站/YouTube", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" 支持的平台: {platforms_help} 示例: python3 main.py --platform kuaishou --account "kwaiId" python3 main.py --platform bilibili --account "主页URL" --download python3 main.py --platform youtube --account "频道URL" --download --output-dir ./videos """, ) parser.add_argument("--platform", "-p", required=True, help="目标平台:douyin / kuaishou / bilibili / youtube") parser.add_argument("--account", "-a", required=False, help="单个账号标识") parser.add_argument("--accounts", required=False, help="多个账号标识,逗号分隔") parser.add_argument("--count", "-c", type=int, default=DEFAULT_PAGE_SIZE, help=f"拉取作品数量(默认{DEFAULT_PAGE_SIZE},最多{MAX_PAGE_SIZE})") parser.add_argument("--page", type=int, default=1, help="页码(默认1)") parser.add_argument("--date-start", help="起始日期 YYYY-MM-DD") parser.add_argument("--date-end", help="结束日期 YYYY-MM-DD") parser.add_argument("--download", "-d", action="store_true", help="下载视频文件到本地") parser.add_argument("--output-dir", "-o", default="", help="下载目录(默认 output/)") parser.add_argument("--json", "-j", action="store_true", help="JSON 格式输出") parser.add_argument("--rate-limit", "-r", type=float, default=DEFAULT_RATE_LIMIT, help=f"请求间隔秒数(默认{DEFAULT_RATE_LIMIT})") args = parser.parse_args() # ── 平台识别 ── platform_cfg = get_platform(args.platform) if not platform_cfg: error(f"不支持的平台: {args.platform}") print(f" 支持: {', '.join(PLATFORM_REGISTRY.keys())}") sys.exit(1) platform_label = platform_cfg["label"] downloader_cls = platform_cfg["downloader_cls"] # ── 收集账号列表 ── account_ids = [] if args.account: account_ids.append(args.account.strip()) if args.accounts: account_ids.extend([a.strip() for a in args.accounts.split(",") if a.strip()]) if not account_ids: error("请提供至少一个账号标识(--account 或 --accounts)") sys.exit(1) # ── API Key ── api_key = get_api_key() if not api_key: error("未找到 API Key,请设置 REDFOX_API_KEY 环境变量") print(f" 获取 Key: https://redfox.hk/settings/api-keys?source=github") print(f" 设置方式: export REDFOX_API_KEY=ak_你的密钥") sys.exit(1) # ── 创建下载器 ── downloader = downloader_cls(api_key) # ── 账号标识验证 ── invalid_ids = [] for aid in account_ids: valid, msg = downloader.validate_account_id(aid) if not valid: invalid_ids.append((aid, msg)) if invalid_ids: print(f"\n{RED}{BOLD}⚠️ 以下输入不是有效的{platform_label}账号标识:{RESET}\n") for aid, msg in invalid_ids: print(f" {YELLOW}• {msg}{RESET}\n") print(f"{CYAN}💡 {downloader.description_hint()}{RESET}\n") sys.exit(2) # ── 下载目录 ── output_dir = args.output_dir or os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output") if args.download: os.makedirs(output_dir, exist_ok=True) # ── Banner ── print(f"""{CYAN}{BOLD} ╔══════════════════════════════════════════╗ ║ 多平台账号主页视频提取器 ║ ║ Multi-Platform Account Video ║ ║ Extractor ║ ╚══════════════════════════════════════════╝{RESET} """) info(f"平台: {platform_label} | API Key 已加载 | 账号数: {len(account_ids)} | 作品数/账号: {min(args.count, MAX_PAGE_SIZE)}") all_accounts = [] all_results = [] for idx, account_id in enumerate(account_ids): if idx > 0: print(f"\n{CYAN}{'─' * 50}{RESET}\n") account, results = process_account( downloader, account_id, page_size=args.count, page_num=args.page, date_start=args.date_start or "", date_end=args.date_end or "", rate_limit=args.rate_limit, do_download=args.download, output_dir=output_dir, ) all_accounts.append(account) all_results.append((account, results)) # ── 输出结果 ── print(f"\n{CYAN}{BOLD}{'=' * 50}{RESET}\n") if args.json: output = { "platform": platform_cfg["label"], "platform_key": args.platform, "accounts": [], "total_works": 0, "total_success": 0, "total_failed": 0, "generated_at": datetime.now().isoformat(), } for account, results in all_results: output["accounts"].append({ "account": account, "total": len(results), "success": sum(1 for r in results if r.get("download_success")), "failed": sum(1 for r in results if not r.get("download_success")), "results": results, }) output["total_works"] += len(results) output["total_success"] += sum(1 for r in results if r.get("download_success")) output["total_failed"] += sum(1 for r in results if not r.get("download_success")) print(json.dumps(output, ensure_ascii=False, indent=2)) else: for account, results in all_results: if results: print_markdown_table(downloader, account, results, page_num=args.page, page_size=args.count) else: name = account.get("accountName", account.get("accountId", "未知")) warn(f"@{name}:暂无作品或拉取失败") total_works = sum(len(r) for _, r in all_results) total_success = sum(sum(1 for x in r if x.get("download_success")) for _, r in all_results) total_failed = total_works - total_success print(f"\n{BOLD}总计:{RESET}{len(account_ids)} 个账号,{total_works} 条作品,成功 {total_success} 条,失败 {total_failed} 条") if args.download: info(f"视频文件保存至: {os.path.abspath(output_dir)}") total_success_final = sum(sum(1 for x in r if x.get("download_success")) for _, r in all_results) total_works_final = sum(len(r) for _, r in all_results) sys.exit(0 if total_success_final == total_works_final else 1) if __name__ == "__main__": main() -
youtube.py 10.7 KB
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ YouTube 平台视频下载器 """ import json import requests from .base import ( API_BASE, DEFAULT_PAGE_SIZE, BaseDownloader, ) class YouTubeDownloader(BaseDownloader): """YouTube 频道视频下载器 翻页机制:使用 YouTube 原生 continuation token,非传统 pageNum/pageSize。 每页约 100 条视频。 """ WORKS_ENDPOINT = "/story/api/youtube/channel/videos" DOWNLOAD_ENDPOINT = "/story/api/parseWork/videoDownload/youtube" def __init__(self, api_key: str): super().__init__(api_key, "youtube", "YouTube") self._continuation_token = None # 翻页 token self._has_more = False # 是否还有更多页 def validate_account_id(self, account_id: str) -> tuple: """YouTube 频道标识为频道 URL(channel)""" if not account_id or len(account_id) < 5: return False, f"「{account_id}」不是有效的 YouTube 频道标识,请提供频道 URL。" return True, "" def description_hint(self) -> str: return "请提供 YouTube 频道 URL(如 https://www.youtube.com/@channelname 或 https://www.youtube.com/channel/UC...)。" def fetch_works(self, account_id: str, page_size: int = DEFAULT_PAGE_SIZE, page_num: int = 1, date_start: str = "", date_end: str = "") -> dict: """ 拉取 YouTube 频道视频列表。 翻页机制:首页传 channel,后续页传 continuation token。 page_size 参数在此平台上不生效(每页固定约 100 条)。 """ # 翻页逻辑:page_num==1 首页传 channel;后续页传 continuation token if page_num <= 1: self._continuation_token = None payload = {"channel": account_id, "source": "多平台主页作品提取-GitHub"} elif self._continuation_token: payload = {"continuation": self._continuation_token, "source": "多平台主页作品提取-GitHub"} else: return {"success": False, "account": None, "works": [], "error": "无更多页面可翻(请从首页开始或等待 continuation token)"} url = f"{API_BASE}{self.WORKS_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) result = resp.json() except requests.exceptions.Timeout: return {"success": False, "account": None, "works": [], "error": "请求超时,请稍后重试"} except requests.exceptions.RequestException as e: return {"success": False, "account": None, "works": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "account": None, "works": [], "error": "API 返回无效数据"} # YouTube API 响应可能被 redfox 的 {code, data, msg} 包裹,也可能直接返回 code = result.get("code") msg = result.get("msg", "") if code is not None: # 标准 redfox 包裹格式 if code not in (200, 2000): if code == 3108: return {"success": False, "account": None, "works": [], "error": "调用频率超限,请稍后重试或增加 --rate-limit 参数"} if code in (3106, 3107): return {"success": False, "account": None, "works": [], "error": f"API Key 无效 (code {code}),请检查配置"} if code == 400: return {"success": False, "account": None, "works": [], "error": f"请求参数错误: {msg}"} return {"success": False, "account": None, "works": [], "error": f"API 错误 (code {code}): {msg}"} data_raw = result.get("data", result) else: # 直接返回数据(无 code 包裹) data_raw = result return self._parse_channel_response(data_raw, account_id) def get_download_info(self, work_url: str) -> dict: payload = {"url": work_url, "source": "多平台主页作品提取-GitHub"} url = f"{API_BASE}{self.DOWNLOAD_ENDPOINT}" headers = { "Content-Type": "application/json", "REDFOX_API_KEY": self.api_key, } try: resp = requests.post(url, json=payload, headers=headers, timeout=30) data = resp.json() except requests.exceptions.Timeout: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "解析超时"} except requests.exceptions.RequestException as e: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": f"网络请求失败: {e}"} except json.JSONDecodeError: return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": "API 返回无效数据"} code = data.get("code") msg = data.get("msg", "") if not str(code).startswith("2"): if code == 3108: err = "调用频率超限" elif code in (3106, 3107): err = "API Key 无效" elif code == 400: err = f"参数错误: {msg}" else: err = f"API 错误 (code {code}): {msg}" return {"success": False, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": err} return self._parse_download_data(data.get("data")) def _parse_channel_response(self, data_raw: dict, account_id: str) -> dict: """解析 YouTube 频道视频 API 响应 响应结构: { "results": [...], "playlist_info": {"title": "...", "numVideos": "5200", ...}, "continuation_token": "...", "has_more": true } """ if not data_raw or not isinstance(data_raw, dict): return {"success": False, "account": None, "works": [], "error": "未查询到该频道的作品数据,可能频道尚未收录"} # 提取视频列表 raw_works = data_raw.get("results", []) if not raw_works: return {"success": True, "account": {"accountId": account_id, "accountName": account_id}, "works": [], "error": None} works = [self._normalize_work(w, account_id) for w in raw_works] # 提取频道信息 playlist_info = data_raw.get("playlist_info", {}) or {} account_info = { "accountId": account_id, "accountName": playlist_info.get("title") or account_id, "followerCount": None, } # 存储翻页 token self._continuation_token = data_raw.get("continuation_token") self._has_more = data_raw.get("has_more", False) return { "success": True, "account": account_info, "works": works, "error": None, "has_more": self._has_more, "continuation_token": self._continuation_token, } def _normalize_work(self, work: dict, account_id: str) -> dict: """YouTube 视频字段归一化 API 返回字段:videoId, title, lengthText, viewCountText, thumbnails, channelHandle, channelId, channelTitle, index """ video_id = work.get("videoId", "") # 封面取最高分辨率缩略图 thumbnails = work.get("thumbnails", []) or [] cover = thumbnails[-1].get("url") if thumbnails else "" return { "title": work.get("title") or "", "workUrl": f"https://www.youtube.com/watch?v={video_id}" if video_id else "", "publishTime": "", "likeCount": 0, "commentCount": 0, "collectCount": 0, "shareCount": 0, "authorName": work.get("channelTitle") or account_id, "followerCount": None, "coverUrl": cover, } def _parse_download_data(self, payload_data) -> dict: """解析下载接口返回数据""" result = { "success": True, "download_url": None, "title": None, "cover": None, "duration": None, "resources": [], "error": None, } if not payload_data: return {**result, "success": False, "error": "API 返回空数据"} if isinstance(payload_data, dict): result["title"] = payload_data.get("desc") or payload_data.get("title") result["cover"] = payload_data.get("cover") or payload_data.get("coverUrl") or payload_data.get("thumbnail") dur = payload_data.get("duration") or payload_data.get("durationSeconds") if isinstance(dur, (int, float)): result["duration"] = int(dur) resources = payload_data.get("resources", []) if isinstance(resources, list): result["resources"] = resources for res in resources: if isinstance(res, dict): rtype = res.get("type", "") dl = res.get("downloadUrl") or res.get("url") if dl and rtype == "video" and not result["download_url"]: result["download_url"] = dl if dl and rtype == "image": if not result["cover"]: result["cover"] = dl if not result["download_url"]: result["download_url"] = dl res_dur = res.get("durationSeconds") if isinstance(res_dur, (int, float)) and not result["duration"]: result["duration"] = int(res_dur) if not result["download_url"]: result["download_url"] = ( payload_data.get("videoUrl") or payload_data.get("video_url") or payload_data.get("downloadUrl") or payload_data.get("download_url") or payload_data.get("playUrl") ) return result -
__init__.py 339 B
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ account-video-extractor — 多平台账号主页视频提取器 """ from .base import BaseDownloader, process_account, print_markdown_table, print_json_output __all__ = [ "BaseDownloader", "process_account", "print_markdown_table", "print_json_output", ]
-
-
README.en.md 2.9 KB
# Account Video Downloader — Multi-Platform Account Video Downloader --- ## Overview A multi-platform account video downloader supporting **Douyin**, **Kuaishou**, **Bilibili**, and **YouTube**. Provide the platform name and account identifier, and the tool automatically fetches the account's recent works, resolves download links, and supports batch downloading to your local machine. --- ## Supported Platforms | --platform | Platform | Account ID | Notes | |:---|:---|:---|:---| | `douyin` | 抖音 (Douyin) | uniqueName | e.g. `Fish688688` | | `kuaishou` | 快手 (Kuaishou) | kwaiId | Display ID | | `bilibili` | 哔哩哔哩 (Bilibili) | Homepage URL (accountUrl) | e.g. `https://space.bilibili.com/123456` | | `youtube` | YouTube | Channel URL (channel) | e.g. `https://www.youtube.com/@channel` | --- ## Features - **Work Fetching**: Retrieve account works with titles, engagement data, and links. - **Link Resolution**: Parse video/image download links (watermark-free). - **Batch Download**: Download all works to local `output/` directory. - **Pagination**: Browse more works across multiple pages. - **Date Filtering**: Filter works by publish date range. - **Multi-Platform**: Single tool for four major platforms. --- ## API Key Get your API Key at [redfox.hk](https://redfox.hk/settings/api-keys?source=github). ```bash export REDFOX_API_KEY=ak_your_key ``` --- ## CLI Usage ```bash # Kuaishou python3 main.py --platform kuaishou --account "kwaiId" # Bilibili python3 main.py --platform bilibili --account "https://space.bilibili.com/123456" --download # YouTube python3 main.py --platform youtube --account "https://www.youtube.com/@channel" --date-start 2026-07-01 ``` ### Parameters | Parameter | Description | |-----------|-------------| | `--platform` | Target platform (required) | | `--account` | Single account ID | | `--accounts` | Multiple account IDs, comma-separated | | `--count` | Number of works to fetch (default 10, max 50) | | `--page` | Page number (default 1) | | `--date-start` | Start date YYYY-MM-DD | | `--date-end` | End date YYYY-MM-DD | | `--download` | Download video files to local | | `--output-dir` | Download directory (default `output/`) | | `--json` | JSON format output | | `--rate-limit` | Request interval in seconds (default 1.0) | | `--api-key` | API Key | ### Dependencies ```bash pip3 install requests ``` --- ## FAQ **Q: Are downloaded videos watermarked?** A: No. The API returns watermark-free direct links. **Q: Can I download image posts?** A: Yes. Image/gallery posts will download the cover image (JPG/PNG/WebP). **Q: What does "rate limit exceeded" mean?** A: API returned code=3108. Increase `--rate-limit 2.0` to add delay between requests. **Q: How do I find the account ID for each platform?** A: Check the platform-specific profile page URL. Each platform section above shows what to look for. -
README.md 4.4 KB
# 多平台账号主页视频提取器 / Account Video Downloader --- ## 简介 输入账号标识,自动拉取主页作品并解析下载链接,支持一键批量下载到本地。覆盖抖音、快手、B站、YouTube 四大平台。抖音、快手请输入账号ID,B站、YouTube 请输入作者主页链接 **核心价值** - **无水印直链**:解析后的视频/图文链接已去除平台水印,可直接用于二次创作或备份。 - **批量高效**:一次输入平台和账号,自动完成作品拉取、链接解析、文件下载全流程。 - **灵活筛选**:支持按发布时间范围、分页浏览,精准定位目标作品。 - **多平台统一**:一个工具覆盖五大平台,相同的使用体验。 **适用对象** - 🎬 **内容创作者** — 收藏学习竞品账号的优质内容,逐帧拆解拍摄与剪辑技巧。 - 📦 **运营 / MCN** — 批量备份达人合作作品,快速完成背调分析。 - 🎓 **知识学习者** — 离线囤积教程类视频,随时反复观看。 --- ## 功能特性 ### 核心功能 - **作品拉取**:通过账号标识获取主页近期作品,包含标题、互动数据和作品链接。 - **链接解析**:逐条解析视频/图文的下载直链,支持视频、封面、音频多种资源类型。 - **批量下载**:一键下载全部作品到本地,视频和图文均支持。 - **分页浏览**:支持翻页查看更多作品,每页最多 50 条。 - **日期筛选**:按作品发布时间范围精确筛选,支持自然语言识别(如「最近一周」「7月份的作品」)。 - **多平台覆盖**:抖音 / 快手 / B站 / YouTube 四大平台统一入口。 --- ## 支持平台 | --platform | 平台 | 账号标识 | 说明 | |:---|:---|:---|:---| | `douyin` | 抖音 | 抖音号(uniqueName) | 如 `Fish688688` | | `kuaishou` | 快手 | kwaiId | 快手展示 ID | | `bilibili` | 哔哩哔哩 | 主页链接(accountUrl) | 如 `https://space.bilibili.com/123456` | | `youtube` | YouTube | 频道 URL(channel) | 如 `https://www.youtube.com/@channel` | --- ## 密钥获取与安全说明 - 本技能需要使用环境变量:`REDFOX_API_KEY`。 - `REDFOX_API_KEY` 由 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`)提供。 - 请前往 [红狐 hub](https://redfox.hk?source=github) 注册账号,获取 `REDFOX_API_KEY`。 - 配置设备环境变量 `REDFOX_API_KEY` 后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。 - 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。 --- ## 使用指南 直接用自然语言描述需求,无需记忆命令。 ### 常用说法速查 | 意图 | 示例话术 | 效果 | | ---- | -------- | ---- | | 下载单个平台视频 | 「帮我下载这个B站账号的视频」 | 拉取作品列表并解析下载链接 | | 批量下载 | 「把快手和B站的视频都下载下来」 | 多平台依次拉取并下载 | | 按时间筛选 | 「下载这个账号 7 月份的作品」 | 自动识别日期范围,精准筛选 | | 查看更多作品 | 「继续看下一页」 | 翻页获取更多作品 | ### 输出示例 解析完成后,你将看到类似以下格式的作品表格(示意): | # | 发布时间 | 作品 | 赞 | 评论 | 收藏 | 分享 | 资源下载 | |---|----------|------|-----|------|------|------|------| | 1 | 07-28 | [作品标题](链接) | 5.2k | 67 | 689 | 5.8k | 🎬视频 · 🖼封面 · 🎵音频 | 表格底部会显示可下载作品数和失败数,并询问您是否需要批量下载到本地。 --- ## 使用场景 | 场景 | 角色 | 示例问法 | 收益 | | ---- | ---- | -------- | ---- | | 竞品内容拆解 | 创作者 / 编导 | 「帮我下载这个B站UP主的视频,逐帧学习」 | 离线反复观看,拆解拍摄手法 | | 作品备份防丢 | 博主 / 运营 | 「把我账号的视频都备份一份到本地」 | 防止平台删除或账号异常丢失内容 | | 达人背调分析 | MCN / 品牌 | 「把这个达人的作品全部拉下来看看」 | 合作前快速了解内容风格与数据表现 | | 教程离线囤货 | 学习者 | 「把这个YouTube频道的视频下载下来慢慢学」 | 无需联网,随时反复观看学习 | -
SKILL.md 12.2 KB
--- name: account-video-downloader description: 多平台账号主页视频提取器 — 输入平台名称和账号id/链接,自动拉取抖音/快手/B站/YouTube 四大平台主页作品,解析下载链接,支持批量下载。适用场景:竞品账号内容拆解与逐帧学习、收藏喜欢的创作者全部作品避免被删、批量搬运素材二次剪辑创作、保存自己账号作品做本地备份防丢失、课程/教程类视频离线囤货反复看、达人合作前快速拉取对方作品做背调分析。触发词:主页视频下载、批量下载视频、账号视频提取、快手主页下载、B站视频提取、YouTube频道下载、视频保存、无水印下载、竞品视频下载、博主视频备份。 --- # 多平台账号主页视频提取器 > 输入平台 + 账号 → 拉取作品列表 → 解析下载链接 → 一键下载到本地 --- ## 简介 多平台账号主页视频批量下载工具。支持 **抖音、快手、B站、YouTube** 四大平台,只需提供平台名称和账号标识,自动拉取该账号近期作品列表,逐条解析视频/图文下载直链,支持一键批量下载到本地。 --- ## 功能特性 | 功能 | 说明 | | ----------- | ---------------------------------------------------- | | 📋 作品拉取 | 通过账号获取主页近期作品(标题、互动数据、作品链接) | | 🔗 链接解析 | 逐条调用解析接口,获取视频/图文下载直链 | | 📥 批量下载 | 一键下载全部作品(视频+图文)到本地 `output/` 目录 | | 📊 数据展示 | Markdown 表格 / JSON 双格式输出,含完整互动数据 | | 📄 分页翻页 | 支持翻页查看更多作品 | | 📅 日期筛选 | 可按作品发布时间挑选 | | 🌐 多平台 | 一个工具覆盖抖音/快手/B站/YouTube 四大平台 | --- ## 支持平台 | --platform | 平台 | 账号标识 | 说明 | | :--------- | :------- | :--------------------- | :------------------------------------- | | `douyin` | 抖音 | 抖音号 | 如 `Fish688688` | | `kuaishou` | 快手 | 快手号 | 如 `Fish688688` | | `bilibili` | 哔哩哔哩 | 主页链接(accountUrl) | 如 `https://space.bilibili.com/123456` | | `youtube` | YouTube | 频道 URL(channel) | 如 `https://www.youtube.com/@channel` | --- ## API 说明 本 Skill 调用 redfox.hk 接口,每个平台分两步: | 步骤 | 接口路径 | 说明 | | ----------- | ---------------------------------------------------- | ------------------------ | | 1. 拉取作品 | `POST /story/api/{platform}/...` | 根据账号标识获取作品列表 | | 2. 解析下载 | `POST /story/api/parseWork/videoDownload/{platform}` | 根据作品链接获取下载直链 | ### 认证 前往 [红狐hub](https://redfox.hk/settings/api-keys?source=github) 获取 API Key,设为环境变量: ```bash # macOS / Linux export REDFOX_API_KEY=ak_你的密钥 # Windows PowerShell $env:REDFOX_API_KEY="ak_你的密钥" ``` --- ## 使用方式 ### CLI 命令行 ```bash # 抖音:基础用法 python3 "$SKILL_PATH/scripts/main.py" --platform douyin --account "Fish688688" # 快手:基础用法 python3 "$SKILL_PATH/scripts/main.py" --platform kuaishou --account "kwaiId" # B站:拉取作品并解析下载链接(不下载文件) python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "https://space.bilibili.com/123456" # YouTube:下载视频到本地 python3 "$SKILL_PATH/scripts/main.py" --platform youtube --account "https://www.youtube.com/@channel" --download # YouTube:指定下载目录 python3 "$SKILL_PATH/scripts/main.py" --platform youtube --account "https://www.youtube.com/@channel" --download --output-dir ./my_videos # 指定作品数量(默认10,最多50) python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --count 20 --download # 翻页查看更多作品 python3 "$SKILL_PATH/scripts/main.py" --platform kuaishou --account "用户ID" --page 2 # 按日期范围筛选作品 python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --date-start 2026-07-01 --date-end 2026-07-31 # 多账号(逗号分隔) python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --accounts "MID1,MID2" --download # 组合使用:第2页 + 日期过滤 + 下载 python3 "$SKILL_PATH/scripts/main.py" --platform bilibili --account "MID" --page 2 --count 20 --date-start 2026-06-01 --download ``` ### 参数说明 | 参数 | 说明 | | -------------- | ------------------------------ | | `--platform` | 目标平台(必填,见上表) | | `--account` | 单个账号标识 | | `--accounts` | 多个账号标识,逗号分隔 | | `--count` | 拉取作品数量(默认10,最多50) | | `--page` | 页码(默认1) | | `--date-start` | 起始日期 YYYY-MM-DD | | `--date-end` | 结束日期 YYYY-MM-DD | | `--download` | 下载视频文件到本地 | | `--output-dir` | 下载目录(默认 `output/`) | | `--json` | JSON 格式输出 | | `--rate-limit` | 请求间隔秒数(默认 1.0) | ### 依赖 | 依赖 | 安装命令 | | ---------- | ----------------------- | | `requests` | `pip3 install requests` | --- ## 账号标识要求 不同平台对账号标识有不同要求,**必须提供各平台的唯一标识**: | 平台 | 要求 | URL 支持 | 获取方式 | | :------ | :--------------------- | :-------- | :------------------------------------------------ | | B站 | 主页链接(accountUrl) | ✅ 支持 | 直接复制个人空间页 URL | | YouTube | 频道 URL(channel) | ✅ 支持 | 直接复制频道页 URL | | 抖音 | 抖音号(uniqueName) | ❌ 不支持 | 抖音 APP → 目标主页 → 头像下方「抖音号:xxx」字段 | | 快手 | 账号 ID(kwaiId) | ❌ 不支持 | 快手 APP → 目标主页 → 昵称下方显示的 ID | > ⚠️ **抖音、快手不支持主页链接!** 如果用户粘贴了 URL,必须提示用户提供上述唯一标识。 **重要:** 若用户只输入账号名称而未提供唯一标识,必须提示用户提供准确的唯一标识。 > 📸 **抖音号获取方式:** 打开抖音 APP → 进入目标用户主页 → 在头像下方、粉丝数据下方可看到「抖音号:xxx」字段。若用户不确定抖音号在哪,可展示示意截图(红框标注「抖音号」位置)帮助用户定位。 --- ## Agent 集成指南 ### 触发词 - 主页视频下载 / 批量下载视频 / 账号视频提取 - 抖音下载 / 抖音视频提取 / 下载抖音作品 - B站视频提取 / B站视频下载 / 哔哩哔哩视频下载 - YouTube频道下载 / YouTube视频提取 - 帮我下载 xxx 的主页视频 ### Agent 执行流程 ``` Step 1: 确认用户意图、平台与账号标识 - 从用户输入中识别目标平台(抖音/快手/B站/YouTube) - 若用户未明确平台,询问:「你想下载哪个平台的视频?」 - 确认账号标识格式正确,若用户提供的是中文昵称,提示提供唯一 ID Step 2: 识别用户输入的时间范围(如有) - 若用户提到了作品时间范围,自动识别并转换为 --date-start / --date-end: - "7.1~7.20" → --date-start 2026-07-01 --date-end 2026-07-20 - "7月1日到7月20日" → --date-start 2026-07-01 --date-end 2026-07-20 - "最近一周" → 当前日期往前推 7 天 - "上个月" → 上个月 1 日到上个月最后一天 - "7月份的作品" → --date-start 2026-07-01 --date-end 2026-07-31 - 日期格式固定为 YYYY-MM-DD,年份默认为当前年份 - 若用户要求"继续"/"下一页",添加 --page N Step 3: 调用脚本拉取作品 + 解析下载链接 python3 "$SKILL_PATH/scripts/main.py" --platform <platform> --account "<id>" Step 4: 展示结果 + 询问翻页 - 展示 Markdown 表格,含可点击的作品链接和下载链接 - 若有失败:提示「可能是用户已删除该视频,如需数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」 - 若可翻页:告诉用户「还有更多作品,是否需要翻看下一页?」,输入下一页翻页 - 提示用户支持按时间范围提取作品 Step 5: 询问用户是否需要批量下载 - 使用 AskUserQuestion:「是否需要将能下载的视频批量下载到本地?」 - 选项:「下载到本地」/「只看链接即可」 Step 6: 用户确认后执行下载 python3 "$SKILL_PATH/scripts/main.py" --platform <platform> --account "<id>" --download - 告知用户文件保存路径 ``` ### Agent 输出规范 解析完成后按以下格式输出(自然语言风格,面向普通用户)。 #### ⛔ 强制规则:禁止简化输出 > **Agent 必须原样展示脚本返回的完整表格,禁止以下行为:** > > - ❌ 禁止删除或合并任何列(发布时间、作品、赞、评论、收藏、分享、资源下载 缺一不可) > - ❌ 禁止省略翻页提示行 > - ❌ 禁止省略时间范围筛选提示("💡 支持输入想提取的作品时间范围…") > - ❌ 禁止省略下载询问提示("💾 需要将这 X 条作品批量下载到本地吗?") > - ❌ 禁止用「...」或摘要代替完整表格内容 > - ✅ 必须逐行展示所有作品的完整信息(含资源下载链接) #### 输出模板 ```markdown ## 📥 {平台}视频下载 — @账号名(粉丝: X) 当前是**第 1 页**,共 10 条作品 | 还有更多作品,输入 `--page 2` 翻看下一页 | # | 发布时间 | 作品 | 赞 | 评论 | 收藏 | 分享 | 资源下载 | | --- | -------- | ----------------- | ---- | ---- | ---- | ---- | --------------------------------------------- | | 1 | 07-28 | [作品标题](链接) | 5.2k | 67 | 689 | 5.8k | [🎬视频](...) · [🖼封面](...) · [🎵音频](...) | | 2 | 07-24 | [作品标题2](链接) | 3.1k | 22 | 136 | 865 | [🖼封面](...) | | 3 | … | … | … | … | … | … | … | **合计:** 10 条作品,8 条可下载,2 条下载失败 > ⚠️ 下载失败的视频可能是用户已删除该视频,如需数据核查可联系工作人员邮箱 **redfoxdata@proton.me** 处理。 > 💡 需要提取特定时间范围的作品?直接告诉我时间范围即可,如「7.1~7.20」「最近一周」 > 💾 需要将这 X 条作品批量下载到本地吗?直接告诉我即可。 ``` 平台名称会根据实际平台自动替换(快手 / B站 / YouTube)。 --- ## 常见问题 **Q:如何获取 API Key?** A:前往 [redfox.hk](https://redfox.hk/settings/api-keys?source=github) 注册获取。 **Q:下载的视频有水印吗?** A:无水印。API 返回的视频/图文直链已去除平台水印。 **Q:图文作品能下载吗?** A:可以。图文作品(幻灯片、相册类)会自动下载首张图片,格式为 JPG/PNG/WebP。 **Q:为什么提示「调用频率超限」?** A:API 返回 code=3108 时表示请求过快触发了限流。可增加 `--rate-limit 2.0` 延长间隔。 **Q:支持哪些平台?** A:抖音、快手、B站(哔哩哔哩)、YouTube 四大平台。 **Q:各平台账号标识从哪里获取?** A:抖音需要抖音号(如 JCLjiangchenglan),快手需要账号 ID / kwaiId(账号名称下方),B站需要主页链接,YouTube 需要频道 URL。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.