
Douyin Batch Download
- 572 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
douyin-batch-download is a Claude Code skill at version 1.8.0 that batch downloads Douyin videos via the F2 framework with incremental updates, Cookie management, and ffmpeg compression.
About
douyin-batch-download is a cat-xierluo/legal-skills MIT-licensed tool at version 1.8.0 built on the F2 framework for efficient Douyin video batch downloads. It supports single-creator and multi-creator runs, skips already downloaded aweme_id entries, reads Cookies from the browser with manual fallback, maintains following.json, names folders by creator nickname, and optionally compresses videos with ffmpeg while saving engagement metadata. Developers reach for douyin-batch-download when automating periodic creator archive updates on a server or local Downloads directory rather than manual one-off saves.
- Single-creator and multi-creator batch downloading from Douyin
- Incremental download engine that skips already-downloaded videos using aweme_id
- Automatic browser Cookie extraction with manual fallback configuration
- Stores videos in creator-nickname folders with metadata (likes, comments, shares)
- Built-in video compression via ffmpeg and web dashboard for statistics
Douyin Batch Download by the numbers
- 572 all-time installs (skills.sh)
- Ranked #391 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 douyin-batch-downloadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 572 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
How do you batch download Douyin creator videos incrementally?
Efficiently batch download Douyin videos from multiple creators with automatic incremental updates and metadata capture.
Who is it for?
Developers automating recurring Douyin creator video archival with incremental downloads, Cookie handling, and ffmpeg storage optimization.
Skip if: Developers who lack rights to download target content or need generic video tools unrelated to Douyin/F2 automation.
When should I use this skill?
The user needs batch or scheduled Douyin creator downloads with incremental updates, Cookie setup, or ffmpeg compression.
What you get
Downloaded video files per creator folder, following.json state, aweme_id dedupe logs, and saved engagement metadata with optional ffmpeg-compressed outputs.
- Creator video folders
- following.json state file
- Video metadata records
By the numbers
- Skill version 1.8.0
- Maintains following.json for processed creators
Files
抖音视频批量下载
本技能基于 F2 框架实现抖音视频批量下载,提供高效、稳定的批量下载能力。
功能概述
- 单个博主下载 - 输入主页链接或 ID,下载全部或指定数量
- 批量下载 - 一次指定多个博主,批量处理
- 增量下载 - 自动跳过已下载的视频(按 aweme_id 判断)
- Cookie 管理 - 优先从浏览器自动读取,失败则提示手动配置
- 关注列表管理 - 维护 following.json 记录已处理的博主
- 差量更新 - 支持只下载主页有但本地没有的视频
- 博主昵称文件夹 - 使用博主昵称作为文件夹名,更易识别
- 自定义下载路径 - 支持自定义下载目录,默认使用系统 Downloads 目录
- 视频压缩 - 使用 ffmpeg 压缩视频,节省存储空间
- 视频元数据 - 抓取并保存视频统计数据(点赞、评论、收藏、分享数)
- 数据可视化 - Web 界面展示博主和视频的统计信息,支持排序和筛选
使用场景
- 服务器批量下载:部署在专用服务器上,定时批量抓取特定博主视频
- 定期更新视频库:自动检测新视频,只下载缺失部分
- 备份与迁移:视频文件分类存储,便于备份和后续处理
- 内容分析:基于视频统计数据(点赞、评论、收藏)进行博主内容分析
配置说明
下载路径配置
在 config/config.yaml 中配置下载路径:
# 下载路径配置
# 留空则使用系统 Downloads 目录下的 "抖音视频下载" 子目录
# macOS: ~/Downloads/抖音视频下载
# Windows: C:\Users\<用户名>\Downloads\抖音视频下载
download_path: ""
# 自定义路径示例:
# download_path: "/Users/maoking/Movies/抖音"
# download_path: "D:\\Videos\\抖音"文件夹命名
视频文件按博主昵称分类存储,例如:
~/Downloads/抖音视频下载/
├── 博主A/
│ ├── 2024-01-01_视频标题_xxx.mp4
│ └── ...
├── 博主B/
│ └── ...
└── data.js # Web 界面数据视频元数据
下载视频时,系统会自动提取并保存以下数据:
| 字段 | 说明 |
|---|---|
aweme_id | 视频唯一 ID |
uid | 作者 UID |
nickname | 博主昵称 |
desc | 视频描述/文案 |
create_time | 发布时间 |
duration | 视频时长 |
digg_count | 点赞数 |
comment_count | 评论数 |
collect_count | 收藏数 |
share_count | 分享数 |
数据存储在 douyin_users.db 的 video_metadata 表中。
手动提取/更新元数据
# 扫描本地视频并提取元数据(基本信息)
python scripts/extract-metadata.py
# 查看统计摘要
python scripts/extract-metadata.py --stats⚠️ 注意:--fetch选项已废弃。推荐使用download-v2.py重新下载视频,会自动保存统计数据。
快速开始
# 创建配置
mkdir -p config
cp config/config.yaml.example config/config.yaml
# 编辑配置(填写 Cookie)
${EDITOR:-nano} config/config.yaml
# 单个下载(推荐)
python scripts/download-v2.py "https://www.douyin.com/user/MS4wLjABAAAA..."
# 批量下载
python scripts/batch-download.py --all
# 交互式选择博主下载
python scripts/batch-download.py
# 采样下载(每个博主1个视频,快速更新数据)
python scripts/batch-download.py --sample
# 生成 Web 界面数据
python scripts/generate-data.py
# 查看 Web 界面
open ~/Downloads/抖音视频下载/index.html推荐工作流
1. 添加博主 → python scripts/manage-following.py --batch
2. 批量下载 → python scripts/batch-download.py --all
3. 查看数据 → open ~/Downloads/抖音视频下载/index.html下载时自动保存:
- ✅ 视频文件
- ✅ 点赞、评论、收藏、分享数
- ✅ 视频描述、发布时间、时长
目录结构
skills/douyin-batch-download/
├── SKILL.md # 本文件
├── references/
│ ├── INSTALLATION.md # 详细安装依赖说明
│ └── USAGE.md # 详细使用说明
├── scripts/
│ ├── utils/ # 工具模块
│ │ └── config.py # 统一配置加载
│ ├── download-v2.py # ✅ 推荐下载脚本(自动保存统计数据)
│ ├── batch-download.py # 批量下载入口
│ ├── download.py # ⚠️ 旧版下载脚本(已废弃)
│ ├── manage-following.py # 关注列表管理(添加/删除/搜索)
│ ├── sync-following.py # 从 F2 数据库同步 following.json
│ ├── compress.py # 视频压缩脚本
│ ├── extract-metadata.py # 视频元数据提取
│ ├── generate-data.py # 生成 Web 界面数据文件
│ ├── following.py # following.json 操作库
│ └── login.py # 扫码登录脚本
├── config/
│ ├── config.yaml.example # 配置模板
│ └── following.json # 关注列表(已下载的博主)
└── douyin_users.db # SQLite 数据库(用户信息 + 视频元数据)依赖
系统依赖
| 依赖 | 安装方式 |
|---|---|
| Chrome/Chromium | 下载地址 |
| ffmpeg | macOS: brew install ffmpeg / Ubuntu: sudo apt install ffmpeg |
ffmpeg 用于视频压缩功能,如仅需下载功能可不安装。
Python 包
| 包名 | 用途 |
|---|---|
f2 | 抖音视频下载框架 |
playwright | 浏览器自动化(扫码登录) |
pyyaml | YAML 配置文件解析 |
httpx | 异步 HTTP 客户端 |
aiofiles | 异步文件操作 |
详细安装说明:见 references/INSTALLATION.md
详细使用说明:见 references/USAGE.md
参考资源
- F2 官方文档:https://f2.wiki
- F2 GitHub:https://github.com/Johnserf-Seed/f2
与其他技能配合
FunASR 语音转文字
下载的视频可以使用 funasr-transcribe 技能将视频转录为带时间戳的 Markdown 文件。
配合方式:先使用抖音下载技能获取视频,再使用 FunASR 技能进行转录。两个技能独立运行,可根据需要灵活组合使用。
变更日志
本技能的所有变更记录,按时间倒序排列。
---
[1.8.0] - 2026-02-14
脚本架构优化与废弃清理
类型:🔧 重构 描述:统一数据获取方式为 F2 API,废弃浏览器方式抓取,修复 aweme_id 提取逻辑
变更文件:
scripts/batch-download.py- 改用 download-v2.pyscripts/download.py- 标记为废弃scripts/extract-metadata.py- 标记 --fetch 为废弃,修复 aweme_id 提取scripts/generate-data.py- 修复 aweme_id 提取逻辑SKILL.md- 更新文档
核心变更:
1. 数据获取方式统一
- 所有数据抓取统一使用 F2 API
- 浏览器仅用于 Cookie 管理(登录/更新)
- 废弃
extract-metadata.py --fetch选项
2. 脚本废弃标记
download.py→ 推荐使用download-v2.py--fetch选项 → 推荐重新下载视频(自动保存统计数据)
3. aweme_id 提取修复
- 修复文件名中包含下划线时 aweme_id 提取错误的问题
- 新逻辑:找出所有 15 位以上纯数字段,返回最长的那个
推荐工作流:
# 单个博主下载(推荐)
python scripts/download-v2.py "https://www.douyin.com/user/MS4wLjABAAAA..."
# 批量下载
python scripts/batch-download.py --all
# 采样下载(快速更新数据)
python scripts/batch-download.py --sample---
[1.7.0] - 2026-02-14
Web 界面交互重构
类型:🎯 交互重构 描述:重构 Web 界面交互设计,采用二级页面架构,优化视频浏览体验
变更文件:
downloads/index.html- 完全重构为二级页面架构
新交互设计:
┌─ 主页面 ─────────────────────────────────────┐
│ 抖音视频库 │
│ 20 博主 95 视频 共 4.0GB │
├────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 👤 头像 │ │ 👤 头像 │ │
│ │ 博主A │ │ 博主B │ │
│ │ 10视频 50MB │ │ 25视频 200MB │ │
│ │ ❤️ 1.2万 │ │ ❤️ 5000 │ │
│ └──────────────┘ └──────────────┘ │
│ 点击进入详情页 │
└────────────────────────────────────────────────┘
┌─ 详情页(二级页面)────────────────────────────┐
│ ← 返回 👤 博主A 访问主页 → │
│ 10 视频 50MB ❤️ 1.2万 │
├────────────────────────────────────────────────┤
│ 共 10 个视频 [点赞][时间][大小][评论][收藏] │
├────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 视频1 │ │ 视频2 │ │ 视频3 │ │
│ │ ❤️ 5000 │ │ ❤️ 3000 │ │ ❤️ 2000 │ │
│ │ 5MB │ │ 8MB │ │ 6MB │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└────────────────────────────────────────────────┘核心改进:
1. 二级页面架构
- 主页面:两列展示博主卡片,简洁清爽
- 详情页:全屏展示博主的所有视频
- 页面切换:滑入动画,ESC 键返回
2. 博主卡片
- 显示头像(点击跳转抖音主页)
- 显示视频数、占用空间、互动数据
- 两列网格布局
3. 视频排序
- 支持按点赞、时间、大小、评论、收藏排序
- 排序状态持久化
4. 视频播放
- 点击视频卡片打开播放模态框
- 显示视频标题和统计数据
- ESC 键或点击背景关闭
交互流程:
1. 主页面浏览博主列表
2. 点击博主卡片 → 滑入详情页
3. 详情页查看所有视频(可排序)
4. 点击视频 → 打开播放模态框
5. 点击返回按钮或 ESC → 返回主页面视觉优化:
- 简约风格:浅色背景 + 白色卡片 + 蓝色强调色
- 微妙动画:页面滑入、悬停效果
- 清晰层次:信息分组明确
---
[1.6.0] - 2026-02-13
采样下载 + 目录结构优化
类型:🔧 功能增强 描述:新增采样下载模式,优化脚本目录结构
新增功能:
- 新增
--sample参数:每个博主只下载 1 个视频,用于快速更新数据而不占用大量空间 - 新增
--yes参数:跳过确认步骤,适合自动化脚本 download.py支持--max-counts=N参数控制下载数量
目录优化:
- 将
scripts/helpers/following.py移至scripts/following.py(消除二级目录) - 移除不再需要的
sys.path配置 - 更新所有导入语句
使用示例:
# 采样下载(每个博主1个视频)
python scripts/batch-download.py --sample --yes
# 全量下载
python scripts/batch-download.py --all --yes
# 交互选择
python scripts/batch-download.py
# 指定博主下载(最多3个视频)
python scripts/download.py "https://www.douyin.com/user/xxx" --max-counts=3变更文件:
scripts/batch-download.py- 新增 --sample 和 --yes 参数scripts/download.py- 支持 --max-counts 参数scripts/following.py- 从 helpers 目录移至 scripts 目录scripts/manage-following.py- 更新导入语句scripts/sync-following.py- 更新导入语句
---
[1.5.0] - 2026-02-12
Accordion 交互重构
类型:🎯 交互重构 描述:移除独立标签页,改为点击博主卡片展开/折叠视频列表的 Accordion 模式
变更文件:
downloads/index.html- 完全重构为 Accordion 交互设计
新交互设计:
┌────────────────────────────────────┐
│ 抖音视频库 [搜索框] │
├────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 博主A │ │ 博主B │ │
│ │ 10视频 │ │ 25视频 │ │
│ └────────┘ └────────┘ │
│ ▼ ▼ │
│ ┌──────────────────┐ │
│ │ 视频1.mp4 │ │
│ │ 视频2.mp4 │ │
│ │ ... │ │
│ └──────────────────┘ │
│ │
└────────────────────────────────────┘交互逻辑:
1. 点击博主卡片 → 在下方展开显示该博主的视频列表 2. 再次点击 → 折叠视频列表 3. 可同时展开多个博主(方便对比不同博主的内容) 4. 搜索支持过滤博主或视频
视觉升级:
- 标题字号增大:2rem(原1.25rem)
- 展开/折叠动画:平滑过渡效果
- 展开指示器:箭头图标旋转 180°
- 字体更换:Noto Sans SC(更优雅的中文显示)
- 配色优化:电光蓝 (#5b7fff) 强调色
---
[1.4.4] - 2026-02-12
Glassmorphism UI 设计
类型:✨ 视觉升级 描述:采用 Glassmorphism(毛玻璃)设计语言,打造独特的视觉体验
变更文件:
downloads/index.html- 完全重构 CSS 和布局
设计特点:
- 动态背景:深蓝灰渐变 + 漂浮的光晕效果
- 毛玻璃卡片:半透明背景 + 背景模糊
- 优雅动效:入场动画、悬停效果、光晕发光
- 现代字体:Inter 字体 + JetBrains Mono 等宽字体
- 精致细节:噪点纹理、渐边框、圆角设计
配色方案:
背景: #0d0d12 → #1a1a2e → #0f0f1a (深蓝灰渐变)
卡片: rgba(255, 255, 255, 0.05) → 0.08 (悬停)
强调色: #4a90e2 (电光蓝)
光晕: rgba(74, 144, 226, 0.3)视觉升级:
- 标题采用渐变文字效果
- 统计信息使用胶囊样式 + 等宽字体
- 标签页悬停显示毛玻璃效果
- 用户卡片增大头像尺寸 (44px) + 渐边框
- 视频图标采用渐变背景 + 投影效果
- 所有空状态添加图标提示
---
[1.4.3] - 2026-02-12
Web 界面简化
类型:🔧 重构 描述:简化 Web 界面,采用纯静态数据加载方式(参考本地库.html)
变更文件:
downloads/index.html- 完全重写,去掉 File System Access APIscripts/generate-data.py- 修改输出格式为 .js 文件scripts/download.py- 下载完成后自动生成数据文件
新方案:
采用纯静态 JavaScript 数据文件架构:
用户操作流程:
1. 运行 python scripts/download.py <URL> → 下载完成后自动生成 data.js
2. 双击 index.html → 直接加载使用核心优势:
- 双击即可打开,无需任何服务器
- 无需手动选择文件/目录
- 下载完成后自动更新数据
- 压缩后手动运行
generate-data.py即可 - 浏览器兼容性最佳(所有现代浏览器)
使用方法:
# 下载(完成后自动生成数据)
python scripts/download.py "https://www.douyin.com/user/xxx"
# 直接用浏览器打开 index.html(或双击文件)
open /Users/maoking/Library/Application\\ Support/maoscripts/skills/legal-skills/test/douyin-batch-download/downloads/index.html---
[1.4.2] - 2026-02-12
压缩功能优化
类型:🔧 重构 描述:简化 Web 界面,采用纯静态数据加载方式(参考本地库.html)
变更文件:
downloads/index.html- 完全重写,去掉 File System Access APIscripts/generate-data.py- 修改输出格式为 .js 文件
新方案:
采用纯静态 JavaScript 数据文件架构:
用户操作流程:
1. 运行 python scripts/generate-data.py → 生成 data.js
2. 双击 index.html → 直接加载使用核心优势:
- 双击即可打开,无需任何服务器
- 无需手动选择文件/目录
- 压缩后重新生成 data.js 即可
- 浏览器兼容性最佳(所有现代浏览器)
使用方法:
# 1. 生成数据文件
python scripts/generate-data.py
# 2. 直接用浏览器打开 index.html(或双击文件)
open /Users/maoking/Library/Application\\ Support/maoscripts/skills/legal-skills/test/douyin-batch-download/downloads/index.html---
[1.4.2] - 2026-02-12
Web 界面修复
类型:🐛 Bug 修复 描述:修复 index.html 无法动态更新的问题,采用纯前端 File System Access API 方案
变更文件:
downloads/index.html- 重写为纯前端实现
问题分析:
downloads/index.html- 重写为纯前端实现
问题分析:
原方案存在以下问题: 1. 硬编码的文件路径无法通过 fetch() 访问(浏览器安全限制) 2. 静态数据无法反映压缩后的文件变化
新方案:
采用纯前端 File System Access API架构:
用户操作流程:
1. 点击 "选择 following.json" → 选择配置文件
2. 点击 "选择 downloads 目录" → 选择视频目录
3. 点击 "刷新数据" → 重新扫描文件系统使用方法:
# 直接用浏览器打开 index.html(无需服务器)
open /Users/maoking/Library/Application\ Support/maoscripts/skills/legal-skills/test/douyin-batch-download/downloads/index.html
# 或者双击文件打开功能:
- 📁 选择 following.json - 读取博主配置
- 📁 选择 downloads 目录 - 扫描视频文件
- 🔄 刷新数据 - 重新加载(压缩后点击)
- 博主列表视图 - 展示所有已下载的博主
- 视频网格视图 - 展示所有下载的视频
- 点击博主卡片可查看该用户的视频
- 搜索过滤 - 实时搜索博主或视频
- 统计信息 - 博主数、视频数、占用空间
浏览器兼容性:
- Chrome/Edge 86+:完整支持
- Firefox 103+:支持
- Safari:部分支持(需要用户测试)
使用方法:
# 启动本地 Web 服务器
cd /Users/maoking/Library/Application\ Support/maoscripts/skills/legal-skills/test/douyin-batch-download
python downloads/server.py
# 然后在浏览器打开
open http://localhost:8000功能:
- 博主列表视图 - 展示所有已下载的博主
- 视频网格视图 - 展示所有下载的视频
- 点击博主卡片可查看该用户的视频
- 搜索过滤 - 实时搜索博主或视频
- 统计信息 - 博主数、视频数、占用空间
- 刷新按钮 - 重新加载数据(压缩后点击刷新即可看到更新)
---
[1.4.1] - 2026-02-12
压缩功能优化
---
[1.4.0] - 2026-02-12
新增视频压缩功能
类型:✨ 功能增强 描述:新增视频压缩脚本,使用 ffmpeg 对下载的视频进行压缩,节省存储空间
变更文件:
scripts/compress.py- 新增视频压缩脚本config/config.yaml.example- 新增压缩配置选项SKILL.md- 更新文档,添加压缩功能说明
功能:
scripts/compress.py- 视频压缩脚本- 支持压缩全部视频或指定用户视频
- 支持压缩单个视频文件
- 默认直接替换原文件(节省空间)
- 可选保留原文件(使用
--keep) - 可配置压缩质量 (CRF) 和速度预设
- 智能跳过小文件(<5MB,避免压缩后变大)
- 显示压缩率和文件大小变化
- 自动跳过已压缩的视频
配置选项:
compression:
auto_compress: false # 是否在下载后自动压缩
crf: 32 # 压缩质量 (0-51, 默认32, 推荐28-38)
preset: "fast" # 压缩速度预设
replace_original: true # 压缩后是否替换原文件 (默认true)
skip_small_threshold: 5242880 # 小文件阈值 (5MB)使用示例:
# 压缩全部视频(默认直接替换原文件)
python scripts/compress.py
# 压缩指定用户视频
python scripts/compress.py --user 123456789
# 压缩单个视频文件
python scripts/compress.py --file video.mp4
# 保留原文件(生成 xxx_compressed.mp4)
python scripts/compress.py --keep
# 设置更高的压缩率
python scripts/compress.py --crf 38
# 不跳过小文件
python scripts/compress.py --no-skip-small依赖:
- 需要 ffmpeg 系统,压缩功能可选
---
[1.3.2] - 2026-02-12
数据源重构 + Web 管理界面 + 链接解析
类型:🔧 重构 + ✨ 功能增强 描述:重构数据源,以 F2 缓存为主,新增 Web 管理界面、链接解析、last_fetch_time 追踪
变更文件:
scripts/sync-following.py- 重构为从 douyin_users.db 同步downloads/index.html- 新增 Web 管理界面scripts/reorganize.py- 新增文件整理脚本scripts/parse-link.py- 新增链接解析脚本
数据源设计:
douyin_users.db (F2 缓存) → following.json (用户关注列表)
↓
index.html (Web 管理)| 文件 | 定位 | 用途 |
|---|---|---|
douyin_users.db | F2 内部缓存 | 技术数据源,用户信息 |
following.json | 用户关注列表 | 业务数据源,可手动编辑 |
downloads/ | 视频文件 | 本地存储 |
功能:
sync-following.py- 从 F2 数据库同步用户信息到 following.json
- 保留 last_fetch_time 等自定义字段
- 更新 following.json 的 last_fetch_time
downloads/index.html- Web 管理界面- 博主列表视图 - 展示已下载的所有博主
- 视频网格视图 - 展示所有下载的视频
- 搜索过滤 - 按名称搜索博主或视频
scripts/parse-link.py- 链接解析脚本- 从任意文本中提取抖音链接
- 支持
https://v.douyin.com/xxx短链接
使用示例:
# 下载并更新 last_fetch_time
python scripts/download.py "https://v.douyin.com/xxx"
# 从包含链接的文本中提取并下载
python scripts/parse-link.py "8- 长按复制此条消息... https://v.douyin.com/NX0YA7r0NXg/"following.json 结构:
{
"2722012335188296": {
"uid": "2722012335188296",
"sec_user_id": "...",
"name": "张总聊信任",
"last_fetch_time": "2026-02-12T...", // 自动更新
"video_count": 20
}
}---
[1.3.1] - 2026-02-12
following.json 同步功能完善
类型:✨ 功能增强 描述:新增统一下载脚本,自动整理文件结构,统一使用 uid 作为标识
变更文件:
scripts/download.py- 新增统一下载脚本scripts/sync-following.py- 新增关注列表同步脚本
功能:
scripts/download.py- 一键下载脚本- 使用 F2 CLI 下载视频
- 自动整理文件到
downloads/{uid}/ - 自动同步 following.json
scripts/sync-following.py- 同步关注列表- 从
downloads目录扫描已下载的博主 - 自动从 F2 数据库获取用户详细信息
- 同步更新
config/following.json
目录结构:
downloads/
└── {uid}/ # 使用纯数字 uid 作为文件夹名
└── *.mp4---
[1.3.0] - 2026-02-12
文档重构与依赖说明完善
类型:📝 文档重构 描述:重构文档结构,将详细依赖和使用说明移到 references 目录,简化 SKILL.md
变更文件:
- 新增
references/INSTALLATION.md- 详细依赖文档 - 新增
references/USAGE.md- 详细使用说明 SKILL.md- 添加依赖章节,简化内容README.md- 已删除(内容迁移到 references)
重构内容:
- 遵循 Progressive Disclosure 设计原则
- SKILL.md 只保留核心信息和快速开始
- 详细依赖说明移至 references/INSTALLATION.md
- 详细使用说明移至 references/USAGE.md
---
[1.2.5] - 2026-02-12
页面导航与持久化功能修复
类型:🐛 Bug 修复 描述:修复登录脚本无法自动导航到抖音网站的问题
变更文件:
scripts/login.py- 添加页面对象获取和网站导航逻辑
修复内容:
- 修复缺失的页面对象获取(持久化模式使用
context.pages[0],普通模式创建新页面) - 添加
await page.goto()导航到抖音首页 - 验证持久化功能:首次扫码,后续自动使用已保存登录状态
---
[1.2.4] - 2026-02-12
登录检测逻辑修复
类型:🐛 Bug 修复 描述:修复登录脚本误判问题,改用 cookie 检测代替 URL 检测
变更文件:
scripts/login.py- 修复登录检测逻辑和配置文件路径
修复内容:
- 修复配置文件路径解析(
parent.parent回到根目录) - 改用登录特征 cookie 检测(
sessionid、passport_csrf_token等) - 添加进度提示,每 5 秒显示等待状态
- 修复 URL 误判导致无法扫码的问题
---
[1.2.3] - 2026-02-12
登录脚本整理与 Bug 修复
类型:🔧 重构 + 🐛 Bug 修复 描述:合并重复的登录脚本,修复代码结构问题
变更文件:
scripts/login.py- 保留精简版,删除冗余版本scripts/login-simple.py- 已删除(合并到 login.py)README.md- 更新命令引用
修复内容:
- 删除重复的登录脚本,只保留一个维护入口
- 修复
async with块缩进错误(cookies 获取位置错误) - 移除未使用的
playwright_instance变量 - 浏览器关闭逻辑优化
---
[1.2.2] - 2026-02-12
扫码登录脚本重构
类型:🔧 重构 描述:将登录脚本重构为精简版本,只负责打开浏览器和获取 cookies
变更文件:
scripts/login-simple.py- 精简版扫码登录脚本README.md- 更新命令引用
重构内容:
- 移除登录按钮点击逻辑(应在下载脚本处理)
- 移除头像检测逻辑(应在下载脚本处理)
- 移除不必要的等待和调试代码
- 只保留核心功能:打开浏览器 + 等待登录 + 获取 cookies
- 简化异常处理流程
---
[1.2.1] - 2026-02-12
添加扫码登录功能
[1.2.1] - 2026-02-12
扫码登录调试优化
类型:🐛 调试优化 描述:修复 Playwright API 参数问题,添加详细调试日志,提升脚本稳定性
变更文件:
scripts/login.py- 扫码登录脚本
修复:
- 修复
query_selector()timeout 参数错误 - 延长页面加载超时时到 60 秒
- 添加
wait_until="domcontentloaded"等待 DOM 完全加载 - 添加页面标题获取用于调试
- 添加详细状态日志输出
- 添加完整异常堆栈跟踪
---
[1.2.0] - 2026-02-12
添加扫码登录功能
类型:✨ 功能增强 描述:添加扫码登录工具,自动获取抖音登录态 cookies,避免手动复制
变更文件:
scripts/login.py- 扫码登录脚本
功能:
- 打开浏览器显示抖音登录二维码
- 用户扫码后自动获取 cookies
- 自动保存到配置文件
- 输出 cookies 字符串供复制使用
---
[1.1.0] - 2026-02-12
添加 Web 管理界面
类型:✨ 功能增强 描述:添加轻量级 HTML 界面,支持浏览和搜索已下载的视频
变更文件:
index.html- Web 管理界面(纯静态,无需后端)config/following.json.example- 关注列表模板README.md- 添加 Web 界面使用说明
功能:
- 博主列表视图 - 展示已下载的所有博主
- 视频网格视图 - 展示所有下载的视频
- 搜索过滤 - 按名称搜索博主或视频
- 统计信息 - 显示博主数量、视频总数、占用空间
---
[1.0.0] - 2026-02-11
技能创建
类型:📦 新技能 描述:创建抖音视频批量下载 skill,包含完整的目录结构、配置管理、F2 框架集成
变更文件:
SKILL.md- 技能定义文档TASKS.md- 任务清单DECISIONS.md- 决策记录README.md- 使用说明LICENSE.txt- MIT 许可证scripts/download.py- 基于 F2 的下载脚本config/config.yaml.example- 配置模板
---
# Cookie 配置
cookie:
# 优先从浏览器自动读取
auto_extract: true
# 失败时手动填写
manual: ""
# 下载配置
# download_path: 留空则使用系统 Downloads 目录
# macOS: ~/Downloads/抖音视频下载
# Windows: C:\Users\<用户名>\Downloads\抖音视频下载
download_path: ""
naming_template: "{create}_{desc}_{aweme_id}"
# 增量模式
incremental:
enabled: true
# diff: 只下载主页有本地没有的
# sync: 同步主页(本地删的会重新下)
# Cookie 有效期提醒
cookie_expiry_days: 14
# 视频压缩配置
compression:
# 是否在下载后自动压缩
auto_compress: false
# 压缩质量 (0-51, 越小质量越好, 推荐28-38, 默认32)
# 数值越大压缩率越高,但质量会下降
crf: 32
# 压缩速度预设 (ultrafast~veryslow, 速度越慢压缩率越高)
preset: "fast"
# 压缩后是否替换原文件 (默认true,节省空间)
# 设置为 false 则保留原文件,生成 xxx_compressed.mp4
replace_original: true
# 小文件阈值 (字节),小于此值不压缩 (默认5MB)
# 原因:小视频压缩后可能变大(编码开销 > 压缩收益)
skip_small_threshold: 5242880
{
"说明": "关注列表模板 - 首次运行后会自动更新此文件",
"users": [
{
"uid": "博主ID",
"sec_user_id": "安全用户ID",
"name": "博主名称",
"nickname": "博主昵称",
"avatar_url": "头像URL",
"signature": "个人简介",
"follower_count": 粉丝数,
"following_count": 关注数,
"video_count": 作品数,
"last_updated": "最后更新时间",
"last_fetch_time": null
}
]
}
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
=======================================================================
This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- NonCommercial — You may not use the material for commercial purposes.
- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license.
No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
To view a copy of this license, visit:
http://creativecommons.org/licenses/by-nc-sa/4.0/
=======================================================================
Commercial License
For commercial use licenses, please contact:
Email: secretxierluo@gmail.com
WeChat: ywxlaw (微信)
=======================================================================安装指南
本技能依赖以下环境和软件包。
系统依赖
| 依赖 | 安装方式 |
|---|---|
| Chrome/Chromium | 下载地址 |
| ffmpeg | macOS: brew install ffmpeg / Ubuntu: sudo apt install ffmpeg |
ffmpeg 用于视频压缩功能,如仅需下载功能可不安装。
Python 包
| 包名 | 用途 | 安装命令 |
|---|---|---|
f2 | 抖音视频下载框架 | pip install f2 |
playwright | 浏览器自动化(扫码登录) | pip install playwright |
pyyaml | YAML 配置文件解析 | pip install pyyaml |
httpx | 异步 HTTP 客户端 | pip install httpx |
aiofiles | 异步文件操作 | pip install aiofiles |
快速安装
# 1. 安装 Python 依赖
pip install f2 playwright pyyaml httpx aiofiles
# 2. 安装 Playwright 浏览器
playwright install chromiumPlaywright 浏览器要求
扫码登录功能需要 Chromium 浏览器:
# 查看已安装的浏览器
playwright --version
# 安装/重新安装 Chromium
playwright install chromium使用说明
配置步骤
1. 复制配置文件
cd test/douyin-batch-download
cp config/config.yaml.example config/config.yaml2. 配置 Cookie
方式一:扫码登录(推荐)
python scripts/login.py脚本会自动打开浏览器显示二维码,扫码登录后自动保存 Cookie。
方式二:手动配置
编辑 config/config.yaml,填写 cookie 字段:
cookie:
auto_extract: true # 优先从浏览器自动读取
manual: "ttwid=xxx; sessionid=yyy; ..."3. Cookie 获取方式
- 扫码登录:运行
python scripts/login.py - 手动获取:从浏览器开发者工具复制
快速开始
# 单个博主下载
/douyin-batch-download "https://www.douyin.com/user/MS4wLjABAAAA..."
# 批量下载多个博主
/douyin-batch-download \
"博主A" "https://www.douyin.com/user/MS4wLjABBBB..." \
"博主B" "https://www.douyin.com/user/MS4wLjABCCC..."
# 增量更新(只下载新视频)
/douyin-batch-download --update关注列表管理
使用 manage-following.py 脚本动态管理关注列表(不影响已下载的视频文件):
# 查看关注列表
python scripts/manage-following.py --list
# 通过主页链接添加用户(推荐方式)
python scripts/manage-following.py --add "https://www.douyin.com/user/MS4wLjABAAAA..."
# 删除关注(保留视频文件)
python scripts/manage-following.py --remove 2722012335188296
# 搜索用户(按昵称/简介/UID)
python scripts/manage-following.py --search "张总"注意:
>
- 请通过主页链接方式添加用户(--add),不要使用add-user-by-uid.py
- 直接 UID 方式获取用户信息不稳定,可能会失败
- 直接 UID 方式获取用户信息不稳定,可能会失败
管理命令说明
| 命令 | 说明 |
|---|---|
--list | 显示所有关注用户及统计信息 |
--add <url> | 通过主页链接添加用户到关注列表 |
--remove <uid> | 删除关注(视频文件保留) |
--search <关键词> | 按昵称/简介/UID搜索用户 |
命令参数
下载参数
| 参数 | 说明 |
|---|---|
<url> | 抖音主页链接或博主 ID |
--limit <n> | 限制下载数量 |
--update | 启用差量更新模式 |
--list | 显示已下载博主列表 |
--cookie | 手动指定 Cookie 字符串 |
--help | 显示帮助信息 |
压缩参数
使用 ffmpeg 压缩视频以节省存储空间:
| 参数 | 说明 |
|---|---|
--compress | 压缩全部视频 |
--compress --user <uid> | 压缩指定用户视频 |
--compress --file <video.mp4> | 压缩单个视频文件 |
--compress --replace | 压缩后替换原文件(默认保留) |
--compress --crf <n> | 设置压缩质量 (0-51, 默认28) |
--compress --preset <level> | 压缩速度预设 (fast/medium/slow等) |
--compress --no-skip-small | 不跳过小文件(默认跳过<5MB的文件) |
示例:
# 压缩全部视频
python scripts/compress.py
# 压缩指定用户
python scripts/compress.py --user 123456789
# 压缩单个文件
python scripts/compress.py --file video.mp4
# 压缩后替换原文件
python scripts/compress.py --replaceWeb 管理界面
简洁方案(推荐):双击直接打开,无需服务器
# 1. 生成数据文件
python scripts/generate-data.py
# 2. 直接用浏览器打开 index.html(或双击文件)
open /Users/maoking/Library/Application\\ Support/maoscripts/skills/legal-skills/test/douyin-batch-download/downloads/index.html功能:
- 博主列表视图 - 展示所有已下载的博主
- 视频网格视图 - 展示所有下载的视频
- 点击博主卡片可查看该用户的视频
- 搜索过滤 - 实时搜索博主或视频
- 统计信息 - 博主数、视频数、占用空间
更新数据: 下载视频后会自动生成 data.js,无需手动操作。 压缩视频后,运行 python scripts/generate-data.py 即可更新数据。
输出目录结构
{download_path}/{数字ID}/
├── 视频/ # {aweme_id}.mp4
├── 封面/ # {aweme_id}.webp(可选)
└── 转录文字/ # {aweme_id}.md(预留)配置文件
config/config.yaml:
# Cookie 配置
cookie:
auto_extract: true # 优先从浏览器自动读取
manual: "" # 失败时手动填写
# 下载配置
download_path: "/path/to/downloads"
naming_template: "{create}_{desc}_{aweme_id}"
# 增量模式
incremental:
enabled: true
mode: "diff" # diff: 只下载新视频
# Cookie 有效期提醒
cookie_expiry_days: 14差量更新逻辑
使用 --update 参数时: 1. 获取主页所有视频 ID 2. 对比本地已下载列表 3. 只下载主页中有但本地没有的视频
注意事项
- Cookie 有效期:建议 7-14 天更新一次
- 请求频率:避免过快请求,防止被风控
- 法律合规:仅供个人学习研究使用
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量下载脚本 - 使用 F2 Python API 下载视频并自动保存统计数据
用法:
# 交互式选择博主下载
python scripts/batch-download.py
# 一键全量下载
python scripts/batch-download.py --all
# 下载指定博主
python scripts/batch-download.py --uid 7483912725043774523
# 采样下载(每个博主1个视频,用于快速更新统计数据)
python scripts/batch-download.py --sample
特性:
- 自动保存视频统计数据(点赞、评论、收藏、分享)
- 零额外 API 请求(数据在下载时获取)
- 使用博主昵称作为文件夹名
"""
import subprocess
import sys
import uuid
import time
from pathlib import Path
import os
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
# 切换到脚本目录(确保相对路径正确)
os.chdir(SKILL_DIR)
# 导入统一配置模块
from utils.config import (
get_download_path,
get_user_folder_name,
)
from following import (
list_users,
get_user,
update_fetch_time,
)
DOWNLOAD_SCRIPT = SKILL_DIR / "scripts" / "download-v2.py"
DOWNLOADS_PATH = get_download_path()
def get_local_video_count(folder: str) -> int:
"""获取本地视频数量"""
user_dir = DOWNLOADS_PATH / folder
if user_dir.exists():
return len(list(user_dir.glob("*.mp4")))
return 0
def download_user(uid: str, sec_user_id: str = None, nickname: str = "", max_counts: int = None, daemon: bool = False):
"""下载单个用户的视频
Args:
uid: 用户 ID
sec_user_id: 用户 sec_user_id
nickname: 用户昵称(用于文件夹命名)
max_counts: 最大下载数量,None 表示不限制
daemon: 是否后台运行
"""
# 构建用户主页 URL
if sec_user_id and sec_user_id.startswith("MS4w"):
url = f"https://www.douyin.com/user/{sec_user_id}"
else:
url = f"https://www.douyin.com/user/{uid}"
# 生成任务 ID
task_id = f"douyin-{uid}-{int(time.time())}"
if daemon:
# 后台运行模式
log_dir = DOWNLOADS_PATH / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"{task_id}.log"
cmd = [sys.executable, str(DOWNLOAD_SCRIPT), url, "--daemon", f"--task-id={task_id}"]
if max_counts is not None:
cmd.append(f"--max-counts={max_counts}")
# 使用 nohup 后台运行
with open(log_file, "w", encoding="utf-8") as f:
f.write(f"[任务创建] {task_id}\n")
f.write(f"[UID] {uid}\n")
f.write(f"[昵称] {nickname}\n")
f.write(f"[URL] {url}\n")
f.write(f"[时间] {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write("=" * 60 + "\n")
# 后台启动进程
subprocess.Popen(
cmd,
cwd=str(SKILL_DIR),
stdout=open(log_file, "a", encoding="utf-8"),
stderr=subprocess.STDOUT,
start_new_session=True, # 脱离父进程
)
print(f"✅ 已启动后台任务: {task_id}")
print(f" 📋 任务ID: {task_id}")
print(f" 📁 日志: {log_file}")
print(f" 🔍 查看进度: tail -f {log_file}")
return task_id
else:
# 同步运行模式
print(f"\n{'='*60}")
print(f"📥 开始下载: {nickname or uid}" + (f" (最多 {max_counts} 个)" if max_counts else ""))
print(f"{'='*60}")
cmd = [sys.executable, str(DOWNLOAD_SCRIPT), url]
if max_counts is not None:
cmd.append(f"--max-counts={max_counts}")
result = subprocess.run(cmd, cwd=str(SKILL_DIR))
if result.returncode == 0:
# 更新 last_fetch_time
update_fetch_time(uid, nickname)
print(f"✅ 下载完成: {nickname or uid}")
return True
else:
print(f"❌ 下载失败: {nickname or uid}")
return False
def interactive_select():
"""交互式选择博主下载"""
users = list_users()
if not users:
print("📋 关注列表为空,请先添加用户")
print(" 用法: python scripts/manage-following.py --batch")
return
print("\n📋 选择要下载的博主")
print("=" * 60)
for i, user in enumerate(users, 1):
uid = user.get("uid", "未知")
name = user.get("nickname", user.get("name", "未知"))
folder = user.get("folder", name or uid)
local_count = get_local_video_count(folder)
last_fetch = user.get("last_fetch_time", "未获取")
# 显示状态标记
if local_count > 0:
status = f"📦 已下载 {local_count} 个"
else:
status = "🆕 未下载"
print(f" {i:2}. {name}")
print(f" UID: {uid}")
print(f" 文件夹: {folder}")
print(f" 状态: {status} | 最后获取: {last_fetch or '未获取'}")
print()
print("=" * 60)
print("输入数字选择博主(支持多选,用逗号分隔)")
print("输入 'all' 下载全部,'q' 退出")
print("-" * 60)
choice = input("请选择: ").strip().lower()
if choice == "q" or choice == "":
print("❌ 已取消")
return
if choice == "all":
download_all_users(users)
return
# 解析选择的数字
try:
indices = [int(x.strip()) for x in choice.split(",")]
selected = []
for idx in indices:
if 1 <= idx <= len(users):
selected.append(users[idx - 1])
else:
print(f"⚠️ 无效的序号: {idx}")
if not selected:
print("❌ 没有有效的选择")
return
print(f"\n📝 已选择 {len(selected)} 个博主")
download_selected_users(selected)
except ValueError:
print("❌ 无效的输入,请输入数字")
def download_selected_users(users: list):
"""下载选定的用户"""
total = len(users)
success = 0
failed = 0
for i, user in enumerate(users, 1):
uid = user.get("uid")
sec_user_id = user.get("sec_user_id", "")
name = user.get("nickname", user.get("name", "未知"))
print(f"\n[{i}/{total}] 处理: {name}")
if download_user(uid, sec_user_id, name):
success += 1
else:
failed += 1
print("\n" + "=" * 60)
print(f"✨ 批量下载完成: 成功 {success},失败 {failed}")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
print("=" * 60)
def download_all_users(users: list = None, auto_confirm: bool = False, daemon: bool = False):
"""下载全部用户
Args:
users: 用户列表,None 表示从 following.json 加载
auto_confirm: 是否跳过确认
daemon: 是否后台运行
"""
if users is None:
users = list_users()
if not users:
print("📋 关注列表为空,请先添加用户")
return
total = len(users)
if daemon:
# 后台模式:直接启动所有任务
print(f"\n🚀 后台模式:准备启动全部 {total} 个下载任务")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
print("-" * 60)
task_ids = []
for user in users:
uid = user.get("uid")
sec_user_id = user.get("sec_user_id", "")
name = user.get("nickname", user.get("name", "未知"))
task_id = download_user(uid, sec_user_id, name, daemon=True)
if task_id:
task_ids.append((name, task_id))
print("\n" + "=" * 60)
print(f"✅ 已启动 {len(task_ids)} 个后台任务")
print("-" * 60)
for name, task_id in task_ids:
print(f" 📺 {name}: {task_id}")
print("-" * 60)
print("🔍 查看所有日志: ls {}/logs/")
print("=" * 60)
else:
# 同步模式
print(f"\n📥 准备下载全部 {total} 个博主")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
print("-" * 60)
if not auto_confirm:
confirm = input("确认开始?(y/N): ").strip().lower()
if confirm != "y":
print("❌ 已取消")
return
download_selected_users(users)
def download_by_uid(uid: str, max_counts: int = None, daemon: bool = False):
"""下载指定 UID 的用户
Args:
uid: 用户 ID
max_counts: 最大下载数量
daemon: 是否后台运行
"""
user = get_user(uid)
if not user:
print(f"❌ 用户 {uid} 不在关注列表中")
print(" 请先添加: python scripts/manage-following.py --add <URL>")
return
name = user.get("nickname", user.get("name", "未知"))
sec_user_id = user.get("sec_user_id", "")
if daemon:
print(f"\n🚀 后台模式:准备启动下载任务")
print(f" 📺 博主: {name} (UID: {uid})")
print(f" 📁 下载目录: {DOWNLOADS_PATH}")
print("-" * 60)
task_id = download_user(uid, sec_user_id, name, max_counts, daemon=True)
print("\n" + "=" * 60)
if task_id:
print(f"✅ 后台任务已启动")
print(f" 📋 任务ID: {task_id}")
print("=" * 60)
else:
print(f"\n📥 下载博主: {name} (UID: {uid})")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
download_user(uid, sec_user_id, name, max_counts)
def download_sample(auto_confirm: bool = False, daemon: bool = False):
"""每个用户只下载1个视频,用于快速更新数据
Args:
auto_confirm: 是否跳过确认
daemon: 是否后台运行
"""
users = list_users()
if not users:
print("📋 关注列表为空,请先添加用户")
return
total = len(users)
if daemon:
# 后台模式:直接启动所有任务
print(f"\n🚀 后台模式:准备启动 {total} 个采样下载任务")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
print("-" * 60)
task_ids = []
for user in users:
uid = user.get("uid")
sec_user_id = user.get("sec_user_id", "")
name = user.get("nickname", user.get("name", "未知"))
task_id = download_user(uid, sec_user_id, name, max_counts=1, daemon=True)
if task_id:
task_ids.append((name, task_id))
print("\n" + "=" * 60)
print(f"✅ 已启动 {len(task_ids)} 个后台任务")
print("-" * 60)
for name, task_id in task_ids:
print(f" 📺 {name}: {task_id}")
print("-" * 60)
print("🔍 查看所有日志: ls {}/logs/")
print("=" * 60)
else:
# 同步模式
print(f"\n📥 采样下载:每个博主只下载 1 个视频")
print(f" 共 {total} 个博主")
print(f"📁 下载目录: {DOWNLOADS_PATH}")
print("-" * 60)
if not auto_confirm:
confirm = input("确认开始?(y/N): ").strip().lower()
if confirm != "y":
print("❌ 已取消")
return
success = 0
failed = 0
for i, user in enumerate(users, 1):
uid = user.get("uid")
sec_user_id = user.get("sec_user_id", "")
name = user.get("nickname", user.get("name", "未知"))
print(f"\n[{i}/{total}] 采样下载: {name}")
if download_user(uid, sec_user_id, name, max_counts=1):
success += 1
else:
failed += 1
print("\n" + "=" * 60)
print(f"✨ 采样下载完成: 成功 {success},失败 {failed}")
print("=" * 60)
def main():
# 检查是否有 --yes 参数(跳过确认)
auto_confirm = "--yes" in sys.argv
if auto_confirm:
sys.argv.remove("--yes")
# 检查是否有 --daemon 参数(后台运行)
daemon_mode = "--daemon" in sys.argv
if daemon_mode:
sys.argv.remove("--daemon")
if len(sys.argv) < 2:
interactive_select()
return
action = sys.argv[1]
if action == "--all":
download_all_users(auto_confirm=auto_confirm, daemon=daemon_mode)
elif action == "--sample":
# 每个用户只下载1个视频,用于更新数据
download_sample(auto_confirm=auto_confirm, daemon=daemon_mode)
elif action == "--uid":
if len(sys.argv) < 3:
print("用法: python scripts/batch-download.py --uid <UID>")
return
download_by_uid(sys.argv[2], daemon=daemon_mode)
else:
print(f"❌ 未知参数: {action}")
print("用法:")
print(" python scripts/batch-download.py # 交互选择")
print(" python scripts/batch-download.py --all # 全量下载")
print(" python scripts/batch-download.py --sample # 采样下载(每个1个视频)")
print(" python scripts/batch-download.py --uid <UID> # 指定博主")
print(" --daemon # 后台运行模式")
print(" --yes # 跳过确认直接执行")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
环境检测脚本 - 检查 Python 版本和依赖
"""
import sys
import subprocess
import os
def check_python_version():
"""检查 Python 版本"""
version = sys.version_info
print(f"当前 Python 版本: {version.major}.{version.minor}.{version.micro}")
# f2 支持 Python 3.9 - 3.13
if version.major == 3 and version.minor <= 13:
print("✓ Python 版本兼容 (3.9-3.13)")
return True
else:
print(f"✗ Python 版本不兼容: f2 需要 Python 3.9-3.13,当前是 {version.major}.{version.minor}")
print(" 解决方案: 使用 pyenv 或 conda 安装 Python 3.11")
return False
def check_f2():
"""检查 f2 是否安装"""
try:
result = subprocess.run(['pip', 'show', 'f2'], capture_output=True, text=True)
if result.returncode == 0:
for line in result.stdout.split('\n'):
if line.startswith('Version:'):
print(f"✓ f2 已安装: {line.split(':')[1].strip()}")
return True
print("✗ f2 未安装")
return False
except Exception as e:
print(f"✗ 检查 f2 失败: {e}")
return False
def check_playwright():
"""检查 playwright 是否安装"""
try:
result = subprocess.run(['pip', 'show', 'playwright'], capture_output=True, text=True)
if result.returncode == 0:
for line in result.stdout.split('\n'):
if line.startswith('Version:'):
print(f"✓ playwright 已安装: {line.split(':')[1].strip()}")
return True
print("✗ playwright 未安装")
return False
except Exception as e:
print(f"✗ 检查 playwright 失败: {e}")
return False
def main():
print("=" * 50)
print("抖音批量下载技能 - 环境检测")
print("=" * 50)
checks = [
("Python 版本", check_python_version()),
("f2", check_f2()),
("playwright", check_playwright()),
]
print("=" * 50)
print("检测结果:")
for name, result in checks:
status = "✓" if result else "✗"
print(f" {status} {name}")
if all(r for _, r in checks):
print("\n✓ 环境检测通过,可以正常使用!")
return 0
else:
print("\n✗ 环境检测未通过,请先配置环境")
return 1
if __name__ == '__main__':
sys.exit(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音视频压缩脚本
使用 ffmpeg 对下载的视频进行压缩,支持:
- 单个视频压缩
- 指定用户目录压缩
- 全部下载目录压缩
- 默认直接替换原文件(节省空间)
- 可选保留原文件(使用 --keep)
- 智能跳过小文件(避免压缩后变大)
用法:
python scripts/compress.py # 压缩全部视频
python scripts/compress.py --user <folder> # 压缩指定用户视频
python scripts/compress.py --file <video.mp4> # 压缩单个文件
python scripts/compress.py --keep # 保留原文件
"""
import subprocess
import sys
import os
from pathlib import Path
import argparse
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
os.chdir(SKILL_DIR)
# 导入统一配置模块
from utils.config import get_download_path
DOWNLOADS_PATH = get_download_path()
# 小文件阈值(字节),小于此值的视频不压缩
# 原因:小视频压缩后可能变大(编码开销 > 压缩收益)
SMALL_FILE_THRESHOLD = 5 * 1024 * 1024 # 5MB
# 低分辨率阈值(高度),低于此值的视频跳过压缩
# 原因:激进模式会降低分辨率,对已低分辨率视频无意义
LOW_RESOLUTION_THRESHOLD = 720 # 720p 高度
def check_ffmpeg():
"""检查 ffmpeg 是否安装"""
try:
result = subprocess.run(
["ffmpeg", "-version"],
capture_output=True,
text=True
)
return result.returncode == 0
except FileNotFoundError:
return False
def get_video_info(video_path):
"""获取视频信息"""
try:
result = subprocess.run([
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
str(video_path)
], capture_output=True, text=True)
if result.returncode == 0:
import json
info = json.loads(result.stdout)
# 获取文件大小(字节)
size = int(info["format"]["size"])
# 获取时长(秒)
duration = float(info["format"]["duration"])
# 获取视频分辨率(高度)
video_stream = next((s for s in info.get("streams", []) if s.get("codec_type") == "video"), None)
height = int(video_stream.get("height", 0)) if video_stream else 0
return {"size": size, "duration": duration, "height": height}
except Exception:
pass
return None
def format_size(bytes_size):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if bytes_size < 1024.0:
return f"{bytes_size:.2f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.2f} TB"
def compress_video(input_path, output_path, replace=True, crf=32, preset="fast", skip_small=True, aggressive=True):
"""
压缩视频
参数:
input_path: 输入视频路径
output_path: 输出视频路径
replace: 是否替换原文件 (默认True,节省空间)
crf: 压缩质量 (0-51, 越小质量越好, 默认32, 推荐28-38)
preset: 压缩速度预设 (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow)
skip_small: 是否跳过小文件 (默认True)
aggressive: 激进压缩模式 (默认False), 牺牲质量获得更高压缩率
"""
info = get_video_info(input_path)
original_size = info["size"] if info else 0
height = info.get("height", 0) if info else 0
print(f" 压缩: {input_path.name}")
print(f" 原始大小: {format_size(original_size)}, 分辨率: {height}p")
# 跳过小文件(避免压缩后反而变大)
if skip_small and original_size < SMALL_FILE_THRESHOLD:
print(f" 跳过 (文件小于 {format_size(SMALL_FILE_THRESHOLD)})")
return None # 返回 None 表示跳过
# 跳过低分辨率视频(避免对已低分辨率视频进行压缩)
if aggressive and height > 0 and height < LOW_RESOLUTION_THRESHOLD:
print(f" 跳过 (分辨率 {height}p 已低于阈值 {LOW_RESOLUTION_THRESHOLD}p)")
return None # 返回 None 表示跳过
# ffmpeg 命令
if aggressive:
# 激进模式:降低分辨率 + 降低音频码率
cmd = [
"ffmpeg", "-i", str(input_path),
"-c:v", "libx264",
"-crf", str(crf),
"-preset", preset,
"-vf", "scale=iw/2:ih/2", # 降低分辨率到一半
"-c:a", "aac",
"-b:a", "64k", # 降低音频码率
"-movflags", "+faststart",
"-y",
str(output_path)
]
else:
# 标准模式
cmd = [
"ffmpeg", "-i", str(input_path),
"-c:v", "libx264",
"-crf", str(crf),
"-preset", preset,
"-c:a", "aac",
"-b:a", "128k",
"-movflags", "+faststart",
"-y",
str(output_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ✗ 压缩失败: {result.stderr}")
return False
# 获取压缩后大小
compressed_info = get_video_info(output_path)
compressed_size = compressed_info["size"] if compressed_info else 0
# 计算压缩率
if original_size > 0 and compressed_size > 0:
ratio = (1 - compressed_size / original_size) * 100
print(f" 压缩后: {format_size(compressed_size)} (压缩率: {ratio:.1f}%)")
# 如果需要替换原文件
if replace:
input_path.unlink()
output_path.rename(input_path)
print(f" 已替换原文件")
else:
print(f" 输出: {output_path}")
return True
def is_already_compressed(video_path):
"""检查视频是否已经是压缩版"""
# 简单判断:文件名包含 compressed 或 compressed_ 前缀
return "compressed" in video_path.stem.lower()
def compress_user_dir(user_dir, replace=True, skip_small=True, **kwargs):
"""压缩指定用户目录下的所有视频"""
if not user_dir.exists():
print(f"目录不存在: {user_dir}")
return
mp4_files = list(user_dir.glob("*.mp4"))
if not mp4_files:
print(f"没有找到视频文件: {user_dir}")
return
print(f"\n处理用户目录: {user_dir.name}")
print(f"找到 {len(mp4_files)} 个视频文件\n")
success_count = 0
skipped_count = 0
failed_count = 0
for video in mp4_files:
# 跳过已经是压缩版的文件
if is_already_compressed(video):
print(f" 跳过 (已压缩): {video.name}")
skipped_count += 1
print()
continue
if replace:
output = video.parent / f"{video.stem}.tmp.mp4"
else:
output = video.parent / f"{video.stem}_compressed.mp4"
result = compress_video(video, output, replace, skip_small=skip_small, **kwargs)
if result is True:
success_count += 1
elif result is False:
failed_count += 1
else: # None - 跳过
skipped_count += 1
print()
print(f"完成: {success_count} 成功, {skipped_count} 跳过, {failed_count} 失败")
def compress_all(replace=True, skip_small=True, **kwargs):
"""压缩下载目录下所有用户的视频"""
if not DOWNLOADS_PATH.exists():
print(f"下载目录不存在: {DOWNLOADS_PATH}")
return
user_dirs = [d for d in DOWNLOADS_PATH.iterdir() if d.is_dir()]
if not user_dirs:
print("没有找到用户目录")
return
print(f"下载目录: {DOWNLOADS_PATH}")
print(f"找到 {len(user_dirs)} 个用户目录\n")
for user_dir in sorted(user_dirs):
compress_user_dir(user_dir, replace, skip_small, **kwargs)
def main():
parser = argparse.ArgumentParser(
description="抖音视频压缩脚本",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s # 压缩全部视频(默认直接替换原文件)
%(prog)s --user 博主昵称 # 压缩指定用户视频
%(prog)s --file video.mp4 # 压缩单个文件
%(prog)s --keep # 保留原文件(生成 xxx_compressed.mp4)
%(prog)s --aggressive # 激进压缩模式 (牺牲质量,压缩率70-80%%)
%(prog)s --crf 38 --preset medium # 指定压缩质量和速度
%(prog)s --no-skip-small # 不跳过小文件
"""
)
parser.add_argument(
"--user", "-u",
help="指定用户文件夹名称(博主昵称),只压缩该用户的视频"
)
parser.add_argument(
"--file", "-f",
help="压缩单个视频文件"
)
parser.add_argument(
"--keep", "-k",
action="store_true",
help="保留原文件(默认压缩后直接替换,节省空间)"
)
parser.add_argument(
"--crf",
type=int,
default=32,
help="视频压缩质量 (0-51, 默认32). 数值越小质量越好,文件越大. 推荐28-38"
)
parser.add_argument(
"--no-skip-small",
action="store_true",
help="不跳过小文件 (默认跳过小于5MB的文件)"
)
parser.add_argument(
"--preset",
default="fast",
choices=["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"],
help="压缩速度预设 (默认: fast). 速度越慢压缩率越高"
)
parser.add_argument(
"--aggressive", "-a",
action="store_true",
help="激进压缩模式 (牺牲质量获得更高压缩率,适合视频仅作留存用途)"
)
args = parser.parse_args()
# 是否跳过小文件
skip_small = not args.no_skip_small
# 是否保留原文件(默认替换)
replace = not args.keep
# 检查 ffmpeg
if not check_ffmpeg():
print("错误: 未找到 ffmpeg")
print("请先安装 ffmpeg:")
print(" macOS: brew install ffmpeg")
print(" Ubuntu: sudo apt install ffmpeg")
print(" Windows: choco install ffmpeg")
sys.exit(1)
print(f"下载目录: {DOWNLOADS_PATH}")
# 执行压缩
if args.file:
# 压缩单个文件
file_path = Path(args.file)
if not file_path.is_absolute():
file_path = DOWNLOADS_PATH / file_path
if not file_path.exists():
print(f"文件不存在: {file_path}")
sys.exit(1)
if replace:
output = file_path.parent / f"{file_path.stem}.tmp.mp4"
else:
output = file_path.parent / f"{file_path.stem}_compressed.mp4"
compress_video(file_path, output, replace, args.crf, args.preset, skip_small, args.aggressive)
elif args.user:
# 压缩指定用户目录
user_dir = DOWNLOADS_PATH / args.user
compress_user_dir(user_dir, replace, skip_small, crf=args.crf, preset=args.preset, aggressive=args.aggressive)
else:
# 压缩全部
compress_all(replace, skip_small, crf=args.crf, preset=args.preset, aggressive=args.aggressive)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音视频下载脚本 v2 - 使用 F2 Python API,同时保存视频统计数据
工作流程:
1. 使用 F2 Python API 下载视频(自动跳过已存在文件)
2. 在下载过程中保存视频统计数据(点赞、评论、收藏等)到数据库
3. 自动整理文件到下载目录/{博主昵称}/
4. 同步 following.json
用法:
python scripts/download-v2.py <主页URL>
python scripts/download-v2.py <主页URL> --max-counts=10
优势:
- 不增加额外 API 请求(数据在下载时已获取)
- 自动保存视频统计数据到数据库
- 使用博主昵称作为文件夹名(更易识别)
"""
import shutil
import sqlite3
import asyncio
import sys
import yaml
import os
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
os.chdir(SKILL_DIR)
# 导入统一配置模块
from utils.config import (
get_download_path,
get_db_path,
get_user_folder_name,
sanitize_folder_name,
load_config,
)
# 导入 F2 模块
from f2.apps.douyin.handler import DouyinHandler
from f2.apps.douyin.db import AsyncUserDB, AsyncVideoDB
from f2.utils.conf_manager import ConfigManager
import f2
def merge_config(main_conf: dict, custom_conf: dict) -> dict:
"""合并配置"""
result = (main_conf or {}).copy()
for key, value in (custom_conf or {}).items():
if isinstance(value, dict) and key in result and isinstance(result[key], dict):
result[key].update(value)
else:
result[key] = value
return result
def get_f2_kwargs() -> dict:
"""获取 F2 所需的配置参数"""
# 加载 F2 默认配置
try:
main_conf_manager = ConfigManager(f2.F2_CONFIG_FILE_PATH)
all_conf = main_conf_manager.config # 获取完整配置
main_conf = all_conf.get("douyin", {}) if all_conf else {}
except Exception:
main_conf = {}
# 加载自定义配置
custom_conf = load_config()
douyin_custom = custom_conf.get("douyin", custom_conf) # 兼容两种格式
# 合并配置
kwargs = merge_config(main_conf, douyin_custom)
# 添加必要参数
kwargs["app_name"] = "douyin"
kwargs["mode"] = "post"
# 设置路径 - 使用统一配置模块
kwargs["path"] = str(get_download_path())
# 确保 cookie 存在
if not kwargs.get("cookie"):
raise ValueError("未配置 cookie,请在 config/config.yaml 中设置")
# 确保 headers 存在(F2 需要这个)
if not kwargs.get("headers"):
kwargs["headers"] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.douyin.com/",
}
return kwargs
def create_video_metadata_table():
"""确保视频元数据表存在"""
db_path = get_db_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS video_metadata (
aweme_id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
nickname TEXT,
desc TEXT,
create_time INTEGER,
duration INTEGER,
digg_count INTEGER DEFAULT 0,
comment_count INTEGER DEFAULT 0,
collect_count INTEGER DEFAULT 0,
share_count INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
local_filename TEXT,
file_size INTEGER,
fetch_time INTEGER
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_video_uid ON video_metadata(uid)
""")
# 添加 nickname 列(如果不存在)
try:
cursor.execute("ALTER TABLE video_metadata ADD COLUMN nickname TEXT")
except sqlite3.OperationalError:
pass # 列已存在
conn.commit()
conn.close()
def save_video_metadata_from_raw(raw_data: dict, nickname: str = ""):
"""从原始 API 响应中提取并保存视频统计数据"""
aweme_list = raw_data.get("aweme_list", [])
if not aweme_list:
return 0
db_path = get_db_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
fetch_time = int(datetime.now().timestamp())
saved_count = 0
for video in aweme_list:
aweme_id = video.get("aweme_id", "")
if not aweme_id:
continue
# 从原始数据中获取统计信息
stats = video.get("statistics", {}) or {}
author = video.get("author", {}) or {}
video_nickname = author.get("nickname", nickname)
cursor.execute("""
INSERT OR REPLACE INTO video_metadata
(aweme_id, uid, nickname, desc, create_time, duration,
digg_count, comment_count, collect_count, share_count, play_count,
fetch_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
aweme_id,
author.get("uid", ""),
video_nickname,
video.get("desc", ""),
video.get("create_time", 0),
video.get("video", {}).get("duration", 0) if video.get("video") else 0,
stats.get("digg_count", 0),
stats.get("comment_count", 0),
stats.get("collect_count", 0),
stats.get("share_count", 0),
stats.get("play_count", 0),
fetch_time
))
saved_count += 1
conn.commit()
conn.close()
return saved_count
def reorganize_files(nickname: str, uid: str) -> str:
"""整理文件到下载目录/{博主昵称}/"""
downloads_path = get_download_path()
old_path = downloads_path / "douyin" / "post" / nickname
if not old_path.exists():
return None
# 使用博主昵称作为文件夹名
folder_name = get_user_folder_name(nickname, uid)
new_path = downloads_path / folder_name
new_path.mkdir(parents=True, exist_ok=True)
# 移动文件
moved_count = 0
for pattern in ["*.mp4", "*.jpg", "*.webp"]:
for f in old_path.glob(pattern):
dest = new_path / f.name
if not dest.exists():
shutil.move(str(f), str(dest))
moved_count += 1
# 清理旧文件夹
if old_path.exists():
try:
shutil.rmtree(old_path)
except:
pass
if moved_count > 0:
print(f" [移动] {nickname} -> {folder_name} ({moved_count} 文件)")
return folder_name
def update_last_fetch_time(uid: str, nickname: str = ""):
"""更新 following.json 中的 last_fetch_time"""
try:
from following import update_fetch_time
update_fetch_time(uid, nickname)
print(f" [更新] last_fetch_time for {nickname or uid}")
except ImportError:
pass
def run_sync():
"""运行 sync-following.py"""
import subprocess
subprocess.run([sys.executable, str(SKILL_DIR / "scripts" / "sync-following.py")])
async def download_with_stats(url: str, max_counts: int = None):
"""
使用 F2 API 下载视频并保存统计数据
Args:
url: 用户主页 URL
max_counts: 最大下载数量
"""
# 获取配置
kwargs = get_f2_kwargs()
kwargs["url"] = url
if max_counts:
kwargs["max_counts"] = max_counts
downloads_path = get_download_path()
# 清理临时目录
f2_temp_path = downloads_path / "douyin"
if f2_temp_path.exists():
shutil.rmtree(f2_temp_path)
print("[清理] F2 临时目录")
print(f"[下载] 开始下载...")
print(f"[路径] {downloads_path}")
# 创建元数据表
create_video_metadata_table()
# 初始化 Handler
handler = DouyinHandler(kwargs)
# 解析 sec_user_id
from f2.apps.douyin.utils import SecUserIdFetcher
sec_user_id = await SecUserIdFetcher.get_sec_user_id(url)
if not sec_user_id:
print("[错误] 无法解析用户 ID")
return
print(f"[信息] sec_user_id: {sec_user_id[:30]}...")
# 获取用户信息并保存
async with AsyncUserDB(str(get_db_path())) as db:
user_path = await handler.get_or_add_user_data(kwargs, sec_user_id, db)
# 从数据库获取用户信息(昵称)
conn = sqlite3.connect(str(get_db_path()))
cursor = conn.cursor()
cursor.execute("SELECT uid, nickname FROM user_info_web ORDER BY ROWID DESC LIMIT 1")
user_info = cursor.fetchone()
conn.close()
uid = user_info[0] if user_info else ""
nickname = user_info[1] if user_info else ""
if nickname:
print(f"[博主] {nickname} (UID: {uid})")
# 收集所有视频数据
all_videos = []
total_downloaded = 0
total_stats_saved = 0
print("[下载] 正在获取视频列表...")
async for aweme_data_list in handler.fetch_user_post_videos(
sec_user_id,
max_counts=max_counts or float("inf")
):
# 获取视频数据列表(用于下载)
video_list = aweme_data_list._to_list()
if video_list:
all_videos.extend(video_list)
# 从原始数据中保存统计数据(不增加额外请求)
raw_data = aweme_data_list._to_raw()
stats_saved = save_video_metadata_from_raw(raw_data, nickname)
total_stats_saved += stats_saved
# 创建下载任务
await handler.downloader.create_download_tasks(
kwargs, video_list, user_path
)
total_downloaded += len(video_list)
print(f"[下载] 已处理 {total_downloaded} 个视频...")
# 显示统计结果
print(f"[统计] 保存了 {total_stats_saved} 条视频元数据(含点赞/评论等数据)")
# 整理文件
print("[整理] 重新组织文件...")
post_path = downloads_path / "douyin" / "post"
folder_name = None
if post_path.exists():
for folder in post_path.iterdir():
if folder.is_dir():
folder_name = reorganize_files(folder.name, uid)
# 更新 last_fetch_time
if folder_name:
update_last_fetch_time(uid, nickname or folder_name)
# 同步 following.json
print("[同步] 更新 following.json...")
run_sync()
print(f"\n[完成] 共下载 {total_downloaded} 个视频")
if folder_name:
print(f"[位置] {downloads_path / folder_name}")
async def main():
# 检查是否需要作为守护进程运行(由 batch-download.py 调用)
daemon_mode = "--daemon" in sys.argv
if daemon_mode:
sys.argv.remove("--daemon")
if len(sys.argv) < 2:
print("用法: python scripts/download-v2.py <主页URL>")
print(" 示例: python scripts/download-v2.py https://www.douyin.com/user/xxx")
print(" 限制数量: python scripts/download-v2.py <URL> --max-counts=10")
print(" 后台运行: python scripts/download-v2.py <URL> --daemon")
return
url = sys.argv[1]
# 解析参数
max_counts = None
task_id = None # 任务 ID(守护模式使用)
for arg in sys.argv[2:]:
if arg.startswith("--max-counts="):
max_counts = int(arg.split("=")[1])
elif arg.startswith("--task-id="):
task_id = arg.split("=")[1]
# 守护进程模式:立即输出进度信息后开始下载
if daemon_mode and task_id:
downloads_path = get_download_path()
log_file = downloads_path / "logs" / f"{task_id}.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
# 打开日志文件并保持打开状态
log_handle = open(log_file, "w", encoding="utf-8")
log_handle.write(f"[任务启动] {task_id}\n")
log_handle.write(f"[URL] {url}\n")
log_handle.write(f"[时间] {datetime.now().isoformat()}\n")
log_handle.write("=" * 60 + "\n")
log_handle.flush()
# 重定向 stdout/stderr 到日志文件
sys.stdout = log_handle
sys.stderr = log_handle
print(f"[守护模式] 任务 {task_id} 已启动")
print(f"[日志] {log_file}")
await download_with_stats(url, max_counts)
# 守护进程模式:关闭日志文件
if daemon_mode and task_id:
log_handle.close()
if __name__ == "__main__":
asyncio.run(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音视频下载脚本(已废弃)
⚠️ DEPRECATED: 此脚本已被 download-v2.py 取代
推荐使用: python scripts/download-v2.py <主页URL>
download-v2.py 优势:
- 使用 F2 Python API,更稳定
- 自动保存视频统计数据(点赞、评论、收藏、分享)
- 零额外 API 请求
---
工作流程:
1. 使用 F2 CLI 下载视频(自动跳过已存在文件)
2. 自动整理文件到 downloads/{uid}/
3. 运行 sync-following.py 更新 following.json(含 last_fetch_time)
用法:
python scripts/download.py <主页URL> # 首次下载(全量)
python scripts/download.py <主页URL> --incr # 增量下载(只下新视频)
增量抓取说明:
- following.json 记录每个用户的 last_fetch_time
- 下载时通过文件存在性检测自动跳过已有视频
- 下载完成后更新 last_fetch_time
"""
import subprocess
import shutil
import sqlite3
import asyncio
import sys
from pathlib import Path
import os
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
# 切换到脚本目录(确保相对路径正确)
os.chdir(SKILL_DIR)
CONFIG_PATH = SKILL_DIR / "config" / "config.yaml"
DB_PATH = SKILL_DIR / "douyin_users.db"
DOWNLOADS_PATH = SKILL_DIR / "downloads"
def get_uid_from_db():
"""从 F2 数据库获取最新的 uid"""
try:
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("SELECT uid, nickname FROM user_info_web ORDER BY ROWID DESC LIMIT 1")
result = cursor.fetchone()
conn.close()
return result
except Exception:
return None
def reorganize_files(nickname):
"""整理文件到 downloads/{uid}/"""
old_path = DOWNLOADS_PATH / "douyin" / "post" / nickname
if not old_path.exists():
return None
# 获取 uid
uid_info = get_uid_from_db()
if not uid_info:
return None
uid, _ = uid_info
new_path = DOWNLOADS_PATH / str(uid)
# 确保目标文件夹存在
new_path.mkdir(parents=True, exist_ok=True)
# 移动所有 mp4 文件(即使已存在也检查是否需要补充)
moved_count = 0
for f in old_path.glob("*.mp4"):
dest = new_path / f.name
if not dest.exists():
shutil.move(str(f), str(dest))
moved_count += 1
# 如果旧文件夹还有图片也一起移动
for f in old_path.glob("*.jpg"):
dest = new_path / f.name
if not dest.exists():
shutil.move(str(f), str(dest))
moved_count += 1
# 删除旧文件夹(如果为空或不再需要)
if old_path.exists():
try:
shutil.rmtree(old_path)
print(f" [清理] {nickname} -> 已删除旧文件夹")
except:
pass
if moved_count > 0:
print(f" [移动] {nickname} -> {uid} ({moved_count} 文件)")
return uid
def run_sync():
"""运行 sync-following.py"""
subprocess.run([sys.executable, str(SKILL_DIR / "scripts" / "sync-following.py")])
def update_last_fetch_time(uid):
"""更新 following.json 中的 last_fetch_time"""
from following import update_fetch_time
update_fetch_time(uid)
print(f" [更新] last_fetch_time for {uid}")
async def main():
# 弃用警告
print("=" * 60)
print("⚠️ 警告: 此脚本已被废弃,推荐使用 download-v2.py")
print(" 新版优势: 自动保存视频统计数据(点赞、评论等)")
print(" 用法: python scripts/download-v2.py <主页URL>")
print("=" * 60)
print()
if len(sys.argv) < 2:
print("用法: python scripts/download.py <主页URL>")
print(" 示例: python scripts/download.py https://www.douyin.com/user/xxx")
return
url = sys.argv[1]
# 解析可选参数
max_counts = None
extra_args = []
for arg in sys.argv[2:]:
if arg.startswith("--max-counts="):
max_counts = int(arg.split("=")[1])
else:
extra_args.append(arg)
print("开始下载...")
# 0. 清理 F2 临时结构
f2_temp_path = DOWNLOADS_PATH / "douyin"
if f2_temp_path.exists():
shutil.rmtree(f2_temp_path)
print("\n0. 清理 F2 临时目录...")
# 1. 运行 F2 CLI(设置工作目录环境变量,确保日志路径正确)
print("\n1. 下载中...")
# 设置 F2 工作目录为技能目录
f2_env = os.environ.copy()
f2_env["PWD"] = str(SKILL_DIR)
# 构建 F2 命令
f2_cmd = [
"f2", "dy",
"-c", str(CONFIG_PATH),
"-u", url,
"-M", "post"
]
if max_counts is not None:
f2_cmd.extend(["--max-counts", str(max_counts)])
result = subprocess.run(f2_cmd, env=f2_env, capture_output=True, text=True)
if result.returncode != 0:
print(f"F2 下载失败: {result.stderr}")
return
# 2. 整理文件
print("\n2. 整理文件...")
# 查找刚下载的昵称文件夹
post_path = DOWNLOADS_PATH / "douyin" / "post"
uid = None
if post_path.exists():
for folder in post_path.iterdir():
if folder.is_dir():
uid = reorganize_files(folder.name)
# 3. 更新 last_fetch_time
if uid:
update_last_fetch_time(uid)
# 4. 同步 following.json
print("\n3. 同步 following.json...")
run_sync()
# 5. 提取视频元数据(点赞、评论、收藏等统计信息)
print("\n4. 提取视频元数据...")
subprocess.run([sys.executable, str(SKILL_DIR / "scripts" / "extract-metadata.py")])
# 6. 生成 Web 数据文件
print("\n5. 生成数据文件...")
subprocess.run([sys.executable, str(SKILL_DIR / "scripts" / "generate-data.py")])
print("\n完成!")
if __name__ == "__main__":
asyncio.run(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视频元数据提取脚本
从本地视频文件和 F2 数据库提取视频元数据并保存到 SQLite 数据库。
用法:
python scripts/extract-metadata.py # 扫描本地视频并提取元数据
python scripts/extract-metadata.py --stats # 显示统计摘要
⚠️ DEPRECATED: --fetch 选项已废弃
推荐使用 download-v2.py 重新下载视频,会自动保存统计数据:
python scripts/download-v2.py <主页URL>
原因:
- --fetch 使用浏览器方式获取数据,可能触发反爬
- download-v2.py 在下载时自动保存统计数据,零额外请求
"""
import argparse
import asyncio
import os
import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
os.chdir(SKILL_DIR)
DB_PATH = SKILL_DIR / "douyin_users.db"
F2_VIDEO_DB_PATH = SKILL_DIR / "douyin_videos.db"
CONFIG_PATH = SKILL_DIR / "config" / "config.yaml"
def create_metadata_table():
"""创建视频元数据表"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS video_metadata (
aweme_id TEXT PRIMARY KEY,
uid TEXT NOT NULL,
desc TEXT,
create_time INTEGER,
duration INTEGER,
digg_count INTEGER DEFAULT 0,
comment_count INTEGER DEFAULT 0,
collect_count INTEGER DEFAULT 0,
share_count INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
local_filename TEXT,
file_size INTEGER,
fetch_time INTEGER
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_video_uid ON video_metadata(uid)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_video_digg ON video_metadata(digg_count DESC)
""")
conn.commit()
conn.close()
print("✅ 元数据表已创建/验证")
def get_video_stats_from_f2_db() -> List[Dict]:
"""从 F2 的视频数据库获取统计数据(如果存在)"""
if not F2_VIDEO_DB_PATH.exists():
return []
conn = sqlite3.connect(str(F2_VIDEO_DB_PATH))
cursor = conn.cursor()
try:
cursor.execute("""
SELECT
aweme_id, uid, desc, create_time, duration,
digg_count, comment_count, collect_count, share_count
FROM video_info
""")
rows = cursor.fetchall()
conn.close()
videos = []
for row in rows:
videos.append({
"aweme_id": row[0],
"uid": row[1] or "",
"desc": row[2] or "",
"create_time": int(row[3]) if row[3] else 0,
"duration": int(row[4]) if row[4] else 0,
"digg_count": int(row[5]) if row[5] else 0,
"comment_count": int(row[6]) if row[6] else 0,
"collect_count": int(row[7]) if row[7] else 0,
"share_count": int(row[8]) if row[8] else 0,
})
return videos
except sqlite3.OperationalError:
conn.close()
return []
def scan_local_videos() -> Dict[str, Dict]:
"""扫描本地视频文件,返回 aweme_id -> 文件信息的映射"""
downloads_path = SKILL_DIR / "downloads"
if not downloads_path.exists():
return {}
videos = {}
for video_file in downloads_path.rglob("*.mp4"):
# 文件名格式: {时间戳}_{描述}_{aweme_id}_video.mp4
# 注意:描述中可能包含下划线
stem = video_file.stem
# 移除末尾的 _video
stem = re.sub(r'_video$', '', stem)
# 找所有纯数字段
parts = stem.split("_")
numeric_parts = []
for part in parts:
part = part.strip()
if part.isdigit() and len(part) >= 15:
numeric_parts.append(part)
# 如果找到纯数字段,返回最长的那个
if numeric_parts:
aweme_id = max(numeric_parts, key=len)
else:
# 回退方案:使用正则表达式找最长的数字串
matches = re.findall(r'\d{15,}', stem)
if matches:
aweme_id = max(matches, key=len)
else:
continue
stat = video_file.stat()
parent_dir = video_file.parent.name # uid
videos[aweme_id] = {
"aweme_id": aweme_id,
"uid": parent_dir,
"local_filename": video_file.name,
"file_size": stat.st_size,
}
return videos
def save_metadata(videos: List[Dict]):
"""保存元数据到数据库"""
if not videos:
print("⚠️ 没有视频元数据需要保存")
return
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
fetch_time = int(datetime.now().timestamp())
for video in videos:
cursor.execute("""
INSERT OR REPLACE INTO video_metadata
(aweme_id, uid, desc, create_time, duration,
digg_count, comment_count, collect_count, share_count, play_count,
local_filename, file_size, fetch_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
video.get("aweme_id"),
video.get("uid", ""),
video.get("desc", ""),
video.get("create_time", 0),
video.get("duration", 0),
video.get("digg_count", 0),
video.get("comment_count", 0),
video.get("collect_count", 0),
video.get("share_count", 0),
video.get("play_count", 0),
video.get("local_filename", ""),
video.get("file_size", 0),
fetch_time
))
conn.commit()
conn.close()
print(f"✅ 已保存 {len(videos)} 条视频元数据")
def get_cookie_from_config() -> str:
"""从配置文件读取 Cookie"""
import yaml
if CONFIG_PATH.exists():
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
return config.get("douyin", {}).get("cookie", "") or config.get("cookie", "")
return ""
def get_sec_user_id_from_db(uid: str) -> str:
"""从数据库获取 sec_user_id"""
try:
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
cursor.execute("SELECT sec_user_id FROM user_info_web WHERE uid = ?", (uid,))
result = cursor.fetchone()
conn.close()
return result[0] if result else ""
except Exception:
return ""
async def fetch_video_stats_from_user_page(sec_user_id: str, delay: float = 2.0) -> Dict[str, Dict]:
"""
通过访问用户主页获取视频统计数据
从 API 响应中捕获 aweme_list 数据
Args:
sec_user_id: 用户的 sec_user_id
delay: 请求延迟
Returns:
aweme_id -> 统计数据的字典
"""
try:
from playwright.async_api import async_playwright
except ImportError:
print("❌ 需要安装 playwright: pip install playwright && playwright install chromium")
return {}
stats_data = {}
all_videos = []
print(" 启动浏览器...")
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
)
# 加载 Cookie
cookie_str = get_cookie_from_config()
if cookie_str:
cookies = []
for item in cookie_str.split(';'):
item = item.strip()
if '=' in item:
name, value = item.split('=', 1)
cookies.append({
"name": name.strip(),
"value": value.strip(),
"domain": ".douyin.com",
"path": "/"
})
if cookies:
await context.add_cookies(cookies)
print(f" 已加载 {len(cookies)} 个 Cookie")
# 监听 API 响应
async def handle_response(response):
if 'aweme/post' in response.url:
try:
data = await response.json()
aweme_list = data.get('aweme_list', [])
for v in aweme_list:
aweme_id = v.get('aweme_id', '')
stats = v.get('statistics', {})
if aweme_id:
all_videos.append({
"aweme_id": aweme_id,
"uid": v.get('author', {}).get('uid', ''),
"desc": v.get('desc', ''),
"create_time": v.get('create_time', 0),
"duration": v.get('duration', 0),
"digg_count": stats.get('digg_count', 0),
"comment_count": stats.get('comment_count', 0),
"collect_count": stats.get('collect_count', 0),
"share_count": stats.get('share_count', 0),
"play_count": stats.get('play_count', 0),
})
except Exception:
pass
page = await context.new_page()
page.on('response', handle_response)
url = f"https://www.douyin.com/user/{sec_user_id}"
print(f" 访问用户主页...")
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
print(" 页面加载完成,等待 API 响应...")
# 滚动页面以加载更多视频
for i in range(5):
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(2000)
print(f" 滚动加载中... ({i+1}/5)")
# 再等待一下确保所有请求完成
await page.wait_for_timeout(3000)
except Exception as e:
print(f" 访问页面时出错: {e}")
await browser.close()
# 整理数据
for v in all_videos:
stats_data[v['aweme_id']] = v
print(f" ✅ 获取到 {len(stats_data)} 个视频的统计数据")
return stats_data
def get_stats_summary() -> Dict:
"""获取统计摘要"""
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
# 视频总数
cursor.execute("SELECT COUNT(*) FROM video_metadata")
total_videos = cursor.fetchone()[0]
# 有统计数据的视频数
cursor.execute("SELECT COUNT(*) FROM video_metadata WHERE digg_count > 0")
videos_with_stats = cursor.fetchone()[0]
# 总点赞数
cursor.execute("SELECT SUM(digg_count) FROM video_metadata")
total_diggs = cursor.fetchone()[0] or 0
# 用户统计
cursor.execute("""
SELECT uid, COUNT(*) as count, SUM(digg_count) as total_diggs
FROM video_metadata
GROUP BY uid
ORDER BY total_diggs DESC
""")
user_stats = cursor.fetchall()
conn.close()
return {
"total_videos": total_videos,
"videos_with_stats": videos_with_stats,
"total_diggs": total_diggs,
"user_stats": user_stats
}
def main():
parser = argparse.ArgumentParser(description="视频元数据提取工具")
parser.add_argument("--fetch", action="store_true", help="从网页获取统计数据(需要 sec_user_id)")
parser.add_argument("--stats", action="store_true", help="显示统计摘要")
parser.add_argument("--uid", type=str, help="指定用户 UID")
args = parser.parse_args()
print("=" * 50)
print("视频元数据提取工具")
print("=" * 50)
# 创建表
create_metadata_table()
if args.stats:
# 显示统计
stats = get_stats_summary()
print(f"\n📊 统计摘要:")
print(f" 总视频数: {stats['total_videos']}")
print(f" 有统计数据的视频: {stats['videos_with_stats']}")
print(f" 总点赞数: {stats['total_diggs']:,}")
print(f"\n 用户统计:")
for uid, count, diggs in stats['user_stats'][:10]:
print(f" {uid}: {count} 视频, {diggs:,} 点赞")
return
# 扫描本地视频
print("\n📁 扫描本地视频文件...")
local_videos = scan_local_videos()
print(f" 找到 {len(local_videos)} 个本地视频")
if args.fetch:
# 弃用警告
print("\n" + "=" * 60)
print("⚠️ 警告: --fetch 选项已废弃")
print(" 推荐使用 download-v2.py 重新下载视频,会自动保存统计数据")
print(" 用法: python scripts/download-v2.py <主页URL>")
print("=" * 60)
# 使用 Playwright 从用户主页获取统计数据
print("\n🌐 从用户主页获取统计数据...")
# 获取用户的 sec_user_id
if args.uid:
uid = args.uid
else:
# 从本地视频推断 uid
uids = set(v['uid'] for v in local_videos.values())
if len(uids) == 1:
uid = list(uids)[0]
else:
print(f"❌ 发现多个用户: {uids},请使用 --uid 指定")
return
sec_user_id = get_sec_user_id_from_db(uid)
if not sec_user_id:
print(f"❌ 未找到用户 {uid} 的 sec_user_id")
return
print(f" 用户 UID: {uid}")
print(f" sec_user_id: {sec_user_id[:20]}...\n")
stats_data = asyncio.run(fetch_video_stats_from_user_page(sec_user_id))
if stats_data:
# 合并本地信息和 API 数据
merged = []
for aweme_id, local_info in local_videos.items():
video = local_info.copy()
if aweme_id in stats_data:
video.update(stats_data[aweme_id])
merged.append(video)
save_metadata(merged)
else:
# 检查 F2 数据库
print("\n📊 检查 F2 视频数据库...")
f2_videos = get_video_stats_from_f2_db()
print(f" 找到 {len(f2_videos)} 条 F2 数据库记录")
# 创建 F2 数据的索引
f2_index = {v["aweme_id"]: v for v in f2_videos}
# 合并数据
merged = []
for aweme_id, local_info in local_videos.items():
video = local_info.copy()
if aweme_id in f2_index:
f2_data = f2_index[aweme_id]
video.update({
"desc": f2_data.get("desc", ""),
"create_time": f2_data.get("create_time", 0),
"duration": f2_data.get("duration", 0),
"digg_count": f2_data.get("digg_count", 0),
"comment_count": f2_data.get("comment_count", 0),
"collect_count": f2_data.get("collect_count", 0),
"share_count": f2_data.get("share_count", 0),
})
merged.append(video)
save_metadata(merged)
# 显示结果
stats = get_stats_summary()
print(f"\n📊 当前状态:")
print(f" 总视频数: {stats['total_videos']}")
print(f" 有统计数据的视频: {stats['videos_with_stats']}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
following.json 统一读写模块
数据格式: {users: [{uid, nickname, folder, ...}, ...]}
"""
import json
import sqlite3
from pathlib import Path
from datetime import datetime
from typing import Optional
# 获取 skill 根目录(scripts/ 的上一级)
SKILL_DIR = Path(__file__).parent.parent.resolve()
FOLLOWING_PATH = SKILL_DIR / "config" / "following.json"
DB_PATH = SKILL_DIR / "douyin_users.db"
# 导入配置工具
try:
from utils.config import sanitize_folder_name
except ImportError:
def sanitize_folder_name(name: str) -> str:
"""简单的文件夹名称清理"""
import re
if not name:
return "unknown"
name = name.strip()
name = re.sub(r'[<>:"/\\|?*]', '_', name)
name = re.sub(r'[\s_]+', '_', name)
name = name.strip('_')
return name[:100] if name else "unknown"
def load_following() -> dict:
"""加载 following.json,返回 {users: [...]} 格式"""
if FOLLOWING_PATH.exists():
try:
with open(FOLLOWING_PATH, encoding="utf-8") as f:
data = json.load(f)
# 确保格式正确
if "users" not in data:
data = {"users": []}
return data
except Exception:
return {"users": []}
return {"users": []}
def save_following(data: dict):
"""保存 following.json"""
FOLLOWING_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(FOLLOWING_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _find_user_index(data: dict, uid: str) -> int:
"""查找用户在列表中的索引,找不到返回 -1"""
for i, user in enumerate(data.get("users", [])):
if user.get("uid") == uid:
return i
return -1
def get_user(uid: str) -> Optional[dict]:
"""获取单个用户信息,不存在返回 None"""
data = load_following()
for user in data.get("users", []):
if user.get("uid") == uid:
return user
return None
def list_users() -> list:
"""获取用户列表"""
data = load_following()
return data.get("users", [])
def add_user(uid: str, info: dict, merge: bool = True) -> bool:
"""
添加或更新用户
Args:
uid: 用户 ID
info: 用户信息字典
merge: 是否合并已有信息(True=保留未提供的字段)
Returns:
True=新增, False=更新已有用户
"""
data = load_following()
index = _find_user_index(data, uid)
# 确保 info 包含 uid
info["uid"] = uid
# 生成 folder 字段(如果提供了 nickname)
nickname = info.get("nickname") or info.get("name", "")
if nickname and "folder" not in info:
info["folder"] = sanitize_folder_name(nickname)
if index >= 0:
# 更新已有用户
if merge:
# 合并:保留已有值,更新新提供的值
existing = data["users"][index]
for key, value in info.items():
if value is not None and value != "":
existing[key] = value
# 确保 folder 字段存在
if "folder" not in existing:
existing["folder"] = sanitize_folder_name(existing.get("nickname") or existing.get("name", "") or uid)
info = existing
data["users"][index] = info
save_following(data)
return False
else:
# 新增用户
if "last_updated" not in info:
info["last_updated"] = datetime.now().isoformat()
if "last_fetch_time" not in info:
info["last_fetch_time"] = None
# 确保 folder 字段存在
if "folder" not in info:
info["folder"] = sanitize_folder_name(nickname) if nickname else str(uid)
data["users"].append(info)
save_following(data)
return True
def remove_user(uid: str) -> bool:
"""删除用户,返回是否成功"""
data = load_following()
index = _find_user_index(data, uid)
if index >= 0:
del data["users"][index]
save_following(data)
return True
return False
def update_fetch_time(uid: str, nickname: str = ""):
"""更新用户的 last_fetch_time
Args:
uid: 用户 ID
nickname: 用户昵称(可选,用于更新 folder 字段)
"""
data = load_following()
index = _find_user_index(data, uid)
if index >= 0:
data["users"][index]["last_fetch_time"] = datetime.now().isoformat()
# 如果提供了 nickname,也更新 folder
if nickname:
data["users"][index]["folder"] = sanitize_folder_name(nickname)
# 如果 nickname 字段为空,也更新它
if not data["users"][index].get("nickname"):
data["users"][index]["nickname"] = nickname
save_following(data)
def create_empty_user(uid: str, sec_user_id: str = "") -> dict:
"""创建空用户模板"""
return {
"uid": uid,
"sec_user_id": sec_user_id,
"name": "",
"nickname": "",
"folder": str(uid), # 默认使用 UID 作为 folder
"avatar_url": "",
"signature": "",
"follower_count": 0,
"following_count": 0,
"video_count": 0,
"last_updated": datetime.now().isoformat(),
"last_fetch_time": None,
}
def update_user_info_from_db(uid: str, last_fetch_time: str = None) -> bool:
"""
从 F2 数据库读取用户信息并更新到 following.json
Args:
uid: 用户 ID(可以是数字 UID 或 sec_user_id)
last_fetch_time: 保留的 last_fetch_time
Returns:
是否成功更新
"""
try:
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
# 先尝试用 uid 查找
cursor.execute("""
SELECT uid, sec_user_id, nickname, avatar_url, signature,
follower_count, following_count, aweme_count
FROM user_info_web WHERE uid = ?
""", (uid,))
result = cursor.fetchone()
# 如果没找到,尝试用 sec_user_id 查找
if not result:
cursor.execute("""
SELECT uid, sec_user_id, nickname, avatar_url, signature,
follower_count, following_count, aweme_count
FROM user_info_web WHERE sec_user_id = ?
""", (uid,))
result = cursor.fetchone()
conn.close()
if not result:
return False
nickname = result[2] or ""
user_info = {
"uid": result[0],
"sec_user_id": result[1] or "",
"name": nickname,
"nickname": nickname,
"folder": sanitize_folder_name(nickname) if nickname else str(result[0]),
"avatar_url": result[3] or "",
"signature": result[4] or "",
"follower_count": result[5] or 0,
"following_count": result[6] or 0,
"video_count": result[7] or 0,
"last_updated": datetime.now().isoformat(),
"last_fetch_time": last_fetch_time,
}
# 更新到 following.json
add_user(result[0], user_info, merge=False)
return True
except Exception:
return False
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据生成脚本 - 扫描下载目录和 following.json,生成前端可用数据
包含视频元数据(点赞、评论、收藏、分享数)
"""
import json
import re
import sqlite3
import sys
import os
from pathlib import Path
from datetime import datetime
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
os.chdir(SKILL_DIR)
# 导入统一配置模块
from utils.config import (
get_download_path,
get_db_path,
get_following_path,
)
# 技能目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
DOWNLOADS_PATH = get_download_path()
FOLLOWING_PATH = get_following_path()
DB_PATH = get_db_path()
OUTPUT_PATH = DOWNLOADS_PATH / "data.js"
# index.html 模板位置
INDEX_TEMPLATE = SKILL_DIR / "downloads" / "index.html"
def copy_index_template():
"""复制 index.html 模板到下载目录"""
if INDEX_TEMPLATE.exists():
dest = DOWNLOADS_PATH / "index.html"
# 只有当模板更新了才复制
if not dest.exists() or INDEX_TEMPLATE.stat().st_mtime > dest.stat().st_mtime:
import shutil
shutil.copy2(INDEX_TEMPLATE, dest)
return True
return False
def extract_aweme_id(filename: str) -> str:
"""从文件名提取 aweme_id
文件名格式: {时间戳}_{描述}_{aweme_id}_video.mp4
例如: 2023-09-11 20-55-58_描述_7277551294787620150_video.mp4
注意:描述中可能包含下划线,所以需要找所有纯数字段,然后选择合适的
aweme_id 通常是 18-19 位数字,以 7 开头
"""
stem = Path(filename).stem
# 移除末尾的 _video
stem = re.sub(r'_video$', '', stem)
# 找所有纯数字段
parts = stem.split("_")
numeric_parts = []
for part in parts:
part = part.strip()
# 检查是否是纯数字且长度 >= 15 (aweme_id 通常是 18-19 位)
if part.isdigit() and len(part) >= 15:
numeric_parts.append(part)
# 如果找到纯数字段,返回最长的那个(应该是 aweme_id)
if numeric_parts:
return max(numeric_parts, key=len)
# 回退方案:使用正则表达式找最长的数字串(15位以上)
matches = re.findall(r'\d{15,}', stem)
if matches:
return max(matches, key=len)
return stem # 最后返回文件名
def format_size(bytes_size):
"""格式化文件大小"""
if bytes_size < 1024:
return f"{bytes_size} B"
if bytes_size < 1024 * 1024:
return f"{bytes_size / 1024:.1f} KB"
if bytes_size < 1024 * 1024 * 1024:
return f"{bytes_size / 1024 / 1024:.1f} MB"
return f"{bytes_size / 1024 / 1024 / 1024:.2f} GB"
def format_number(num):
"""格式化数字(大数用万/亿表示)"""
if num >= 100000000: # 1亿
return f"{num / 100000000:.1f}亿"
if num >= 10000: # 1万
return f"{num / 10000:.1f}万"
return str(num)
def get_video_metadata():
"""从数据库获取视频元数据"""
if not DB_PATH.exists():
return {}
conn = sqlite3.connect(str(DB_PATH))
cursor = conn.cursor()
try:
# 检查 video_metadata 表是否存在
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='video_metadata'
""")
if not cursor.fetchone():
conn.close()
return {}
cursor.execute("""
SELECT
aweme_id, uid, nickname, desc, create_time, duration,
digg_count, comment_count, collect_count, share_count, play_count,
local_filename, file_size, fetch_time
FROM video_metadata
""")
rows = cursor.fetchall()
conn.close()
# 以 aweme_id 为键建立索引
metadata = {}
for row in rows:
aweme_id = row[0]
metadata[aweme_id] = {
"uid": row[1] or "",
"nickname": row[2] or "",
"desc": row[3] or "",
"create_time": row[4] or 0,
"duration": row[5] or 0,
"digg_count": row[6] or 0,
"comment_count": row[7] or 0,
"collect_count": row[8] or 0,
"share_count": row[9] or 0,
"play_count": row[10] or 0,
"local_filename": row[11] or "",
"file_size": row[12] or 0,
"fetch_time": row[13] or 0,
}
return metadata
except sqlite3.OperationalError:
conn.close()
return {}
def scan_videos_from_root(metadata: dict):
"""扫描下载目录下所有子目录中的视频文件"""
videos = []
# 扫描所有子目录中的 mp4 文件
for video_file in sorted(DOWNLOADS_PATH.rglob("*.mp4")):
stat = video_file.stat()
# 获取视频文件的直接父目录名作为 folder (即博主昵称)
parent_dir = video_file.parent.name
# 提取 aweme_id
aweme_id = extract_aweme_id(video_file.name)
video_data = {
"name": video_file.stem,
"aweme_id": aweme_id,
"size": stat.st_size,
"folder": parent_dir,
}
# 合并元数据
if aweme_id in metadata:
meta = metadata[aweme_id]
video_data["stats"] = {
"digg_count": meta["digg_count"],
"comment_count": meta["comment_count"],
"collect_count": meta["collect_count"],
"share_count": meta["share_count"],
"play_count": meta["play_count"],
}
video_data["desc"] = meta["desc"]
video_data["create_time"] = meta["create_time"]
video_data["duration"] = meta["duration"]
if meta["nickname"]:
video_data["nickname"] = meta["nickname"]
videos.append(video_data)
return videos
def scan_user_videos(user_folder: str, metadata: dict):
"""扫描指定用户目录的视频文件"""
user_dir = DOWNLOADS_PATH / user_folder
if not user_dir.exists():
return []
videos = []
for video_file in sorted(user_dir.glob("*.mp4")):
stat = video_file.stat()
aweme_id = video_file.stem.split("_")[0]
video_data = {
"name": video_file.stem,
"aweme_id": aweme_id,
"size": stat.st_size,
"folder": user_folder,
}
if aweme_id in metadata:
meta = metadata[aweme_id]
video_data["stats"] = {
"digg_count": meta["digg_count"],
"comment_count": meta["comment_count"],
"collect_count": meta["collect_count"],
"share_count": meta["share_count"],
"play_count": meta["play_count"],
}
video_data["desc"] = meta["desc"]
video_data["create_time"] = meta["create_time"]
video_data["duration"] = meta["duration"]
if meta["nickname"]:
video_data["nickname"] = meta["nickname"]
videos.append(video_data)
return videos
def calculate_user_stats(videos: list) -> dict:
"""计算用户统计信息"""
total_diggs = sum(v.get("stats", {}).get("digg_count", 0) for v in videos)
total_comments = sum(v.get("stats", {}).get("comment_count", 0) for v in videos)
total_collects = sum(v.get("stats", {}).get("collect_count", 0) for v in videos)
total_shares = sum(v.get("stats", {}).get("share_count", 0) for v in videos)
# 找出热门视频(点赞最多的前3个)
sorted_videos = sorted(
videos,
key=lambda x: x.get("stats", {}).get("digg_count", 0),
reverse=True
)
top_videos = sorted_videos[:3]
return {
"total_diggs": total_diggs,
"total_comments": total_comments,
"total_collects": total_collects,
"total_shares": total_shares,
"top_videos": [
{
"name": v["name"],
"digg_count": v.get("stats", {}).get("digg_count", 0)
} for v in top_videos
]
}
def main():
print("开始生成数据文件...")
# 1. 读取 following.json
if not FOLLOWING_PATH.exists():
print("错误: following.json 不存在")
return
with open(FOLLOWING_PATH, "r", encoding="utf-8") as f:
following = json.load(f)
# 2. 获取视频元数据
metadata = get_video_metadata()
print(f"从数据库读取 {len(metadata)} 条视频元数据")
# 3. 初始化数据结构
data = {
"generated_at": datetime.now().isoformat(),
"download_path": str(DOWNLOADS_PATH),
"users": [],
"videos": []
}
# 4. 先扫描根目录下的所有视频
all_videos = scan_videos_from_root(metadata)
# 5. 构建用户数据
# 支持两种 following.json 格式:
# - 旧格式:单个用户对象,uid 作为键
# - 新格式:users 数组
if following.get("users") and isinstance(following["users"], list):
# 新格式:users 是数组
for user in following["users"]:
uid = user.get("uid")
nickname = user.get("nickname", user.get("name", ""))
folder = user.get("folder", nickname or uid) # 使用 folder 字段或 nickname
if not uid:
continue
# 从根目录扫描结果中筛选该用户的视频(按 folder 匹配)
user_videos = [v for v in all_videos if v["folder"] == folder]
user_stats = calculate_user_stats(user_videos)
data["users"].append({
"uid": uid,
"name": nickname,
"folder": folder,
"avatar_url": user.get("avatar_url", ""),
"video_count": len(user_videos),
"stats": user_stats
})
else:
# 旧格式:单个用户对象,uid 作为键
for uid, user_info in following.items():
# 跳过非用户字段(如"说明")
if isinstance(user_info, dict) and user_info.get("uid"):
nickname = user_info.get("nickname", user_info.get("name", ""))
folder = user_info.get("folder", nickname or uid)
# 从根目录扫描结果中筛选该用户的视频(按 folder 匹配)
user_videos = [v for v in all_videos if v["folder"] == folder]
# 同时从用户目录扫描(如果存在)
subdir_videos = scan_user_videos(folder, metadata)
user_videos.extend(subdir_videos)
user_stats = calculate_user_stats(user_videos)
data["users"].append({
"uid": uid,
"name": nickname,
"folder": folder,
"avatar_url": user_info.get("avatar_url", ""),
"video_count": len(user_videos),
"stats": user_stats
})
data["videos"] = all_videos
# 6. 计算总大小和总统计
total_size = sum(v["size"] for v in data["videos"])
videos_with_stats = sum(1 for v in data["videos"] if "stats" in v)
total_diggs = sum(v.get("stats", {}).get("digg_count", 0) for v in data["videos"])
# 7. 写入 data.js
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write("// 自动生成 - " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n")
f.write(f"// 视频总数: {len(data['videos'])}, 有统计: {videos_with_stats}, 总点赞: {format_number(total_diggs)}\n")
f.write("window.APP_DATA = ")
json.dump(data, f, ensure_ascii=False, indent=2)
f.write(";\n")
# 8. 复制 index.html 模板
copied = copy_index_template()
print(f"✅ 数据已生成: {OUTPUT_PATH}")
print(f" 下载目录: {DOWNLOADS_PATH}")
print(f" 博主: {len(data['users'])}")
print(f" 视频: {len(data['videos'])}")
print(f" 有统计数据的视频: {videos_with_stats}")
print(f" 总大小: {format_size(total_size)}")
print(f" 总点赞: {format_number(total_diggs)}")
print("\n提示: 直接用浏览器打开 index.html 即可")
print(f" {DOWNLOADS_PATH / 'index.html'}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音扫码登录工具(精简版)
只负责打开浏览器和获取 cookies
"""
import asyncio
import sys
from pathlib import Path
import os
try:
from playwright.async_api import async_playwright
except ImportError:
print("❌ Playwright 未安装,请先安装:")
print(" pip install playwright")
print(" playwright install chromium")
sys.exit(1)
# 强制使用脚本所在目录作为工作目录
SKILL_DIR = Path(__file__).parent.parent.resolve()
# 切换到脚本目录(确保相对路径正确)
os.chdir(SKILL_DIR)
async def douyin_login(cookies_path: str = None, persist: bool = False):
"""
打开浏览器等待用户扫码登录抖音,然后获取 cookies
Args:
cookies_path: 保存 cookies 的配置文件路径
persist: 是否启用持久化模式(保存登录状态,下次无需重新扫码)
"""
print("📱 抖音扫码登录")
print("=" * 40)
browser = None
try:
# 创建 Playwright 实例并使用 async with
async with async_playwright() as p:
# 持久化数据目录(保存登录状态)
user_data_dir = Path(__file__).parent.parent / ".playwright-data"
if persist:
# 持久化模式:使用持久化上下文,自动保存登录状态
context = await p.chromium.launch_persistent_context(
user_data_dir=str(user_data_dir),
headless=False,
args=[
"--no-sandbox",
"--disable-web-security",
"--disable-blink-features=AutomationControlled",
]
)
browser = context # 持久化模式中 browser 就是 context
else:
# 普通模式:每次重新扫码
browser = await p.chromium.launch(
headless=False,
args=[
"--no-sandbox",
"--disable-web-security",
"--disable-blink-features=AutomationControlled",
]
)
context = await browser.new_context(
viewport={"width": 1280, "height": 800}
)
# 获取页面对象
if persist:
# 持久化模式:使用现有页面或创建新页面
if len(context.pages) > 0:
page = context.pages[0]
else:
page = await context.new_page()
else:
# 普通模式:创建新页面
page = await context.new_page()
# 导航到抖音首页
douyin_url = "https://www.douyin.com"
await page.goto(douyin_url)
# 持久化模式提示
if persist:
print(" 💾 持久化模式已启用,登录状态将自动保存")
print(f" 数据目录:{user_data_dir}")
print(" 💡 首次使用需要扫码,后续启动将自动使用已保存的登录状态")
else:
print(" 登录成功后脚本会自动检测并获取 cookies")
print(" 最久等待 5 分钟,按 Ctrl+C 可取消\n")
# 等待登录(严格检测:需要同时有多个登录特征 cookie)
logged_in = False
cookies = None
for i in range(60):
await asyncio.sleep(2)
# 获取当前 cookies 进行检查
cookies = await context.cookies()
cookie_names = {c["name"] for c in cookies}
# 严格检测:需要同时有 sessionid 和其他登录 cookie
has_strong_login = (
"sessionid" in cookie_names and
("passport_csrf_token" in cookie_names or "sid_guard" in cookie_names)
)
if has_strong_login:
print("\n✅ 登录成功!")
logged_in = True
break
else:
# 每 10 秒显示一次进度(每 2 秒检查一次)
if (i + 1) % 5 == 0:
print(f" 等待登录中... ({i+1}/60)")
# 提取抖音相关的 cookies
dy_cookies = {}
if cookies:
for cookie in cookies:
if "douyin.com" in cookie.get("domain", "") or ".douyin.com" in cookie.get("domain", ""):
dy_cookies[cookie["name"]] = cookie["value"]
print(f"\n 获取到 {len(dy_cookies)} 个 cookies")
# 关闭浏览器(在 async with 块内)
if not persist:
await browser.close()
browser = None # 标记已关闭
else:
print(" 💾 持久化模式:浏览器状态已保存,下次无需扫码")
print(" 提示:首次使用需要扫码,后续将自动使用已保存的登录状态")
# 生成 cookies 字符串(在 async with 块外)
cookie_str = "; ".join([f"{k}={v}" for k, v in dy_cookies.items()])
print("\n" + "=" * 40)
print("✅ Cookies 已获取!")
print("=" * 40)
# 保存到文件
if cookies_path:
config_dir = Path(cookies_path).parent
config_dir.mkdir(parents=True, exist_ok=True)
# 读取现有配置
import yaml
config = {}
if Path(cookies_path).exists():
with open(cookies_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
# 更新 cookies
config["cookie"] = cookie_str
# 保存
with open(cookies_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, allow_unicode=True)
print(f" 💾 已保存到:{cookies_path}")
# 输出 cookies 字符串
print("\n📋 Cookies 字符串(可直接复制):")
print("-" * 40)
print(cookie_str)
print("-" * 40)
return True
except Exception as e:
print(f"\n❌ 发生错误:{e}")
print("\n📋 详细错误:")
# 注意:持久化模式下不需要手动关闭 browser
# 只在非持久化模式下才尝试关闭
if 'browser' in locals() and browser and hasattr(browser, 'close'):
try:
await browser.close()
except:
pass
return False
async def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(
description="抖音扫码登录工具 - 打开浏览器获取登录态 Cookies",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--output", "-o",
help="输出配置文件路径(保存到 config.cookie 字段)"
)
parser.add_argument(
"--persist", "-p",
action="store_true",
help="启用持久化模式(保存登录状态,下次无需重新扫码)"
)
args = parser.parse_args()
# 默认配置文件路径
cookies_path = args.output
if not cookies_path:
# 尝试使用默认配置
config_path = Path(__file__).parent.parent / "config" / "config.yaml"
# 无论如何都使用默认路径(不需要等待 config.yaml 存在)
cookies_path = str(config_path)
try:
success = await douyin_login(cookies_path, args.persist)
sys.exit(0 if success else 1)
except KeyboardInterrupt:
print("\n\n⚠️ 已取消")
sys.exit(1)
except Exception as e:
print(f"\n❌ 发生错误:{e}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
# utils 模块
Related skills
How it compares
Use douyin-batch-download for F2-based Douyin creator pipelines; use generic yt-dlp skills for non-Douyin platforms.
FAQ
What version is douyin-batch-download?
douyin-batch-download is version 1.8.0 in cat-xierluo/legal-skills and implements Douyin batch downloading on the F2 framework under the MIT license.
How does douyin-batch-download avoid re-downloads?
douyin-batch-download skips videos already stored by aweme_id, maintains following.json for creators, and supports delta updates for new homepage posts only.