DouyinSolver 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- douyinsolver-1.0.0/PKG-INFO +212 -0
- douyinsolver-1.0.0/README.md +181 -0
- douyinsolver-1.0.0/pyproject.toml +64 -0
- douyinsolver-1.0.0/setup.cfg +4 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/PKG-INFO +212 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/SOURCES.txt +13 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/dependency_links.txt +1 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/entry_points.txt +2 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/requires.txt +7 -0
- douyinsolver-1.0.0/src/DouyinSolver.egg-info/top_level.txt +1 -0
- douyinsolver-1.0.0/src/douyin_downloader/__init__.py +28 -0
- douyinsolver-1.0.0/src/douyin_downloader/cli.py +75 -0
- douyinsolver-1.0.0/src/douyin_downloader/downloader.py +203 -0
- douyinsolver-1.0.0/src/douyin_downloader/models.py +90 -0
- douyinsolver-1.0.0/src/douyin_downloader/utils.py +123 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: DouyinSolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 抖音视频无水印下载库 - 无需 Cookie,支持视频和图集
|
|
5
|
+
Author-email: ksduqiao <ksduqiao@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/moehans-official/DouyinSolver
|
|
8
|
+
Project-URL: Repository, https://github.com/moehans-official/DouyinSolver
|
|
9
|
+
Project-URL: Issues, https://github.com/moehans-official/DouyinSolver/issues
|
|
10
|
+
Keywords: douyin,tiktok,download,video,无水印,抖音
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: requests>=2.28.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# DouyinSolver
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://img.shields.io/badge/Python-3.8%2B-blue?style=flat-square&logo=python" alt="Python 3.8+">
|
|
36
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="MIT License">
|
|
37
|
+
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform">
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<b>抖音视频无水印下载工具</b><br>
|
|
42
|
+
无需 Cookie · 无需 X-Bogus · 支持视频和图集
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 特性
|
|
48
|
+
|
|
49
|
+
- **零配置使用** - 无需登录,无需 Cookie,开箱即用
|
|
50
|
+
- **无水印下载** - 自动转换带水印地址为无水印地址
|
|
51
|
+
- **视频 + 图集** - 支持视频和图集两种模式
|
|
52
|
+
- **移动端策略** - 使用 iPhone User-Agent 绕过反爬虫
|
|
53
|
+
- **Python 库** - 可作为库集成到你的项目中
|
|
54
|
+
- **命令行工具** - 提供 `douyin-dl` 命令行工具
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 安装
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 从 PyPI 安装(发布后)
|
|
62
|
+
pip install douyin-downloader
|
|
63
|
+
|
|
64
|
+
# 从源码安装
|
|
65
|
+
git clone https://github.com/moehans-official/DouyinSolver.git
|
|
66
|
+
cd DouyinSolver
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 快速开始
|
|
73
|
+
|
|
74
|
+
### 命令行
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 解析视频信息
|
|
78
|
+
douyin-dl "https://www.douyin.com/video/xxxxxxxxxx"
|
|
79
|
+
|
|
80
|
+
# JSON 输出
|
|
81
|
+
douyin-dl "xxxxxxxxxx" --json
|
|
82
|
+
|
|
83
|
+
# 下载视频
|
|
84
|
+
douyin-dl "xxxxxxxxxx" --download --output ./downloads
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Python API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from douyin_downloader import DouyinDownloader
|
|
91
|
+
|
|
92
|
+
# 创建下载器
|
|
93
|
+
dl = DouyinDownloader()
|
|
94
|
+
|
|
95
|
+
# 解析视频
|
|
96
|
+
info = dl.parse("https://v.douyin.com/xxxxx")
|
|
97
|
+
|
|
98
|
+
# 查看信息
|
|
99
|
+
print(f"标题: {info.title}")
|
|
100
|
+
print(f"作者: {info.author}")
|
|
101
|
+
print(f"无水印地址: {info.play_url}")
|
|
102
|
+
|
|
103
|
+
# 下载视频
|
|
104
|
+
dl.download_video(info, "./downloads")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## API 参考
|
|
110
|
+
|
|
111
|
+
### DouyinDownloader
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
class DouyinDownloader(
|
|
115
|
+
timeout: int = 15,
|
|
116
|
+
session: Optional[requests.Session] = None
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| 方法 | 说明 |
|
|
121
|
+
|------|------|
|
|
122
|
+
| `parse(url: str) -> VideoInfo` | 解析视频/图集信息 |
|
|
123
|
+
| `download_video(info: VideoInfo, output_dir=".", filename=None) -> Path` | 下载视频 |
|
|
124
|
+
| `download_gallery(info: VideoInfo, output_dir=".") -> list[Path]` | 下载图集 |
|
|
125
|
+
| `download(url: str, output_path: Path, progress_callback=None) -> Path` | 下载任意 URL |
|
|
126
|
+
|
|
127
|
+
### VideoInfo
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
@dataclass
|
|
131
|
+
class VideoInfo:
|
|
132
|
+
aweme_id: str # 视频 ID
|
|
133
|
+
title: str # 标题/描述
|
|
134
|
+
author: str # 作者昵称
|
|
135
|
+
author_id: str # 作者抖音号
|
|
136
|
+
avatar: str # 作者头像 URL
|
|
137
|
+
duration: int # 时长(毫秒)
|
|
138
|
+
cover: str # 封面 URL
|
|
139
|
+
play_url: str # 无水印视频地址
|
|
140
|
+
play_url_watermark: str # 带水印视频地址
|
|
141
|
+
images: list[str] # 图集图片列表
|
|
142
|
+
music: Optional[dict] # 音乐信息
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_gallery: bool # 是否为图集
|
|
146
|
+
@property
|
|
147
|
+
def is_video: bool # 是否为视频
|
|
148
|
+
|
|
149
|
+
def to_dict() -> dict # 转为字典
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 高级用法
|
|
155
|
+
|
|
156
|
+
### 自定义 Session
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import requests
|
|
160
|
+
from douyin_downloader import DouyinDownloader
|
|
161
|
+
|
|
162
|
+
session = requests.Session()
|
|
163
|
+
session.headers.update({"User-Agent": "Custom/1.0"})
|
|
164
|
+
|
|
165
|
+
dl = DouyinDownloader(session=session)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 下载进度回调
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
def on_progress(progress):
|
|
172
|
+
print(f"{progress.percentage:.1f}%")
|
|
173
|
+
|
|
174
|
+
info = dl.parse("https://...")
|
|
175
|
+
dl.download(info.play_url, "video.mp4", progress_callback=on_progress)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 批量下载
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
urls = ["url1", "url2", "url3"]
|
|
182
|
+
|
|
183
|
+
for url in urls:
|
|
184
|
+
try:
|
|
185
|
+
info = dl.parse(url)
|
|
186
|
+
dl.download_video(info, "./downloads")
|
|
187
|
+
print(f"✓ {info.aweme_id}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"✗ {url}: {e}")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 技术原理
|
|
195
|
+
|
|
196
|
+
1. **移动端 User-Agent**: 使用 iPhone Safari 的 UA,避免桌面端的反爬虫机制
|
|
197
|
+
2. **SSR 数据提取**: 从分享页的 `window._ROUTER_DATA` 解析视频元数据
|
|
198
|
+
3. **无水印转换**: 将 `playwm` 替换为 `play` 获取无水印地址
|
|
199
|
+
|
|
200
|
+
详细实现过程请参阅 [IMPLEMENTATION.md](IMPLEMENTATION.md)
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 免责声明
|
|
205
|
+
|
|
206
|
+
本工具仅供学习研究使用,请遵守相关法律法规和平台使用条款。使用者应确保已获得必要授权,产生的任何后果均由使用者自行承担。
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT License - 详见 [LICENSE](LICENSE)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# DouyinSolver
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://img.shields.io/badge/Python-3.8%2B-blue?style=flat-square&logo=python" alt="Python 3.8+">
|
|
5
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="MIT License">
|
|
6
|
+
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<b>抖音视频无水印下载工具</b><br>
|
|
11
|
+
无需 Cookie · 无需 X-Bogus · 支持视频和图集
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 特性
|
|
17
|
+
|
|
18
|
+
- **零配置使用** - 无需登录,无需 Cookie,开箱即用
|
|
19
|
+
- **无水印下载** - 自动转换带水印地址为无水印地址
|
|
20
|
+
- **视频 + 图集** - 支持视频和图集两种模式
|
|
21
|
+
- **移动端策略** - 使用 iPhone User-Agent 绕过反爬虫
|
|
22
|
+
- **Python 库** - 可作为库集成到你的项目中
|
|
23
|
+
- **命令行工具** - 提供 `douyin-dl` 命令行工具
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 从 PyPI 安装(发布后)
|
|
31
|
+
pip install douyin-downloader
|
|
32
|
+
|
|
33
|
+
# 从源码安装
|
|
34
|
+
git clone https://github.com/moehans-official/DouyinSolver.git
|
|
35
|
+
cd DouyinSolver
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 快速开始
|
|
42
|
+
|
|
43
|
+
### 命令行
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# 解析视频信息
|
|
47
|
+
douyin-dl "https://www.douyin.com/video/xxxxxxxxxx"
|
|
48
|
+
|
|
49
|
+
# JSON 输出
|
|
50
|
+
douyin-dl "xxxxxxxxxx" --json
|
|
51
|
+
|
|
52
|
+
# 下载视频
|
|
53
|
+
douyin-dl "xxxxxxxxxx" --download --output ./downloads
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Python API
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from douyin_downloader import DouyinDownloader
|
|
60
|
+
|
|
61
|
+
# 创建下载器
|
|
62
|
+
dl = DouyinDownloader()
|
|
63
|
+
|
|
64
|
+
# 解析视频
|
|
65
|
+
info = dl.parse("https://v.douyin.com/xxxxx")
|
|
66
|
+
|
|
67
|
+
# 查看信息
|
|
68
|
+
print(f"标题: {info.title}")
|
|
69
|
+
print(f"作者: {info.author}")
|
|
70
|
+
print(f"无水印地址: {info.play_url}")
|
|
71
|
+
|
|
72
|
+
# 下载视频
|
|
73
|
+
dl.download_video(info, "./downloads")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## API 参考
|
|
79
|
+
|
|
80
|
+
### DouyinDownloader
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
class DouyinDownloader(
|
|
84
|
+
timeout: int = 15,
|
|
85
|
+
session: Optional[requests.Session] = None
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| 方法 | 说明 |
|
|
90
|
+
|------|------|
|
|
91
|
+
| `parse(url: str) -> VideoInfo` | 解析视频/图集信息 |
|
|
92
|
+
| `download_video(info: VideoInfo, output_dir=".", filename=None) -> Path` | 下载视频 |
|
|
93
|
+
| `download_gallery(info: VideoInfo, output_dir=".") -> list[Path]` | 下载图集 |
|
|
94
|
+
| `download(url: str, output_path: Path, progress_callback=None) -> Path` | 下载任意 URL |
|
|
95
|
+
|
|
96
|
+
### VideoInfo
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
@dataclass
|
|
100
|
+
class VideoInfo:
|
|
101
|
+
aweme_id: str # 视频 ID
|
|
102
|
+
title: str # 标题/描述
|
|
103
|
+
author: str # 作者昵称
|
|
104
|
+
author_id: str # 作者抖音号
|
|
105
|
+
avatar: str # 作者头像 URL
|
|
106
|
+
duration: int # 时长(毫秒)
|
|
107
|
+
cover: str # 封面 URL
|
|
108
|
+
play_url: str # 无水印视频地址
|
|
109
|
+
play_url_watermark: str # 带水印视频地址
|
|
110
|
+
images: list[str] # 图集图片列表
|
|
111
|
+
music: Optional[dict] # 音乐信息
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_gallery: bool # 是否为图集
|
|
115
|
+
@property
|
|
116
|
+
def is_video: bool # 是否为视频
|
|
117
|
+
|
|
118
|
+
def to_dict() -> dict # 转为字典
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 高级用法
|
|
124
|
+
|
|
125
|
+
### 自定义 Session
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import requests
|
|
129
|
+
from douyin_downloader import DouyinDownloader
|
|
130
|
+
|
|
131
|
+
session = requests.Session()
|
|
132
|
+
session.headers.update({"User-Agent": "Custom/1.0"})
|
|
133
|
+
|
|
134
|
+
dl = DouyinDownloader(session=session)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 下载进度回调
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
def on_progress(progress):
|
|
141
|
+
print(f"{progress.percentage:.1f}%")
|
|
142
|
+
|
|
143
|
+
info = dl.parse("https://...")
|
|
144
|
+
dl.download(info.play_url, "video.mp4", progress_callback=on_progress)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 批量下载
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
urls = ["url1", "url2", "url3"]
|
|
151
|
+
|
|
152
|
+
for url in urls:
|
|
153
|
+
try:
|
|
154
|
+
info = dl.parse(url)
|
|
155
|
+
dl.download_video(info, "./downloads")
|
|
156
|
+
print(f"✓ {info.aweme_id}")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print(f"✗ {url}: {e}")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 技术原理
|
|
164
|
+
|
|
165
|
+
1. **移动端 User-Agent**: 使用 iPhone Safari 的 UA,避免桌面端的反爬虫机制
|
|
166
|
+
2. **SSR 数据提取**: 从分享页的 `window._ROUTER_DATA` 解析视频元数据
|
|
167
|
+
3. **无水印转换**: 将 `playwm` 替换为 `play` 获取无水印地址
|
|
168
|
+
|
|
169
|
+
详细实现过程请参阅 [IMPLEMENTATION.md](IMPLEMENTATION.md)
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 免责声明
|
|
174
|
+
|
|
175
|
+
本工具仅供学习研究使用,请遵守相关法律法规和平台使用条款。使用者应确保已获得必要授权,产生的任何后果均由使用者自行承担。
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT License - 详见 [LICENSE](LICENSE)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "DouyinSolver"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "抖音视频无水印下载库 - 无需 Cookie,支持视频和图集"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "ksduqiao", email = "ksduqiao@gmail.com"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["douyin", "tiktok", "download", "video", "无水印", "抖音"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
27
|
+
"Topic :: Multimedia :: Video",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"requests>=2.28.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.0.0",
|
|
37
|
+
"pytest-asyncio>=0.21.0",
|
|
38
|
+
"black>=23.0.0",
|
|
39
|
+
"mypy>=1.0.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
douyin-dl = "douyin_downloader.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/moehans-official/DouyinSolver"
|
|
47
|
+
Repository = "https://github.com/moehans-official/DouyinSolver"
|
|
48
|
+
Issues = "https://github.com/moehans-official/DouyinSolver/issues"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-dir]
|
|
54
|
+
"" = "src"
|
|
55
|
+
|
|
56
|
+
[tool.black]
|
|
57
|
+
line-length = 100
|
|
58
|
+
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.8"
|
|
62
|
+
warn_return_any = true
|
|
63
|
+
warn_unused_configs = true
|
|
64
|
+
disallow_untyped_defs = true
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: DouyinSolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 抖音视频无水印下载库 - 无需 Cookie,支持视频和图集
|
|
5
|
+
Author-email: ksduqiao <ksduqiao@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/moehans-official/DouyinSolver
|
|
8
|
+
Project-URL: Repository, https://github.com/moehans-official/DouyinSolver
|
|
9
|
+
Project-URL: Issues, https://github.com/moehans-official/DouyinSolver/issues
|
|
10
|
+
Keywords: douyin,tiktok,download,video,无水印,抖音
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: requests>=2.28.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# DouyinSolver
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://img.shields.io/badge/Python-3.8%2B-blue?style=flat-square&logo=python" alt="Python 3.8+">
|
|
36
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="MIT License">
|
|
37
|
+
<img src="https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform">
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<b>抖音视频无水印下载工具</b><br>
|
|
42
|
+
无需 Cookie · 无需 X-Bogus · 支持视频和图集
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 特性
|
|
48
|
+
|
|
49
|
+
- **零配置使用** - 无需登录,无需 Cookie,开箱即用
|
|
50
|
+
- **无水印下载** - 自动转换带水印地址为无水印地址
|
|
51
|
+
- **视频 + 图集** - 支持视频和图集两种模式
|
|
52
|
+
- **移动端策略** - 使用 iPhone User-Agent 绕过反爬虫
|
|
53
|
+
- **Python 库** - 可作为库集成到你的项目中
|
|
54
|
+
- **命令行工具** - 提供 `douyin-dl` 命令行工具
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 安装
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 从 PyPI 安装(发布后)
|
|
62
|
+
pip install douyin-downloader
|
|
63
|
+
|
|
64
|
+
# 从源码安装
|
|
65
|
+
git clone https://github.com/moehans-official/DouyinSolver.git
|
|
66
|
+
cd DouyinSolver
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 快速开始
|
|
73
|
+
|
|
74
|
+
### 命令行
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 解析视频信息
|
|
78
|
+
douyin-dl "https://www.douyin.com/video/xxxxxxxxxx"
|
|
79
|
+
|
|
80
|
+
# JSON 输出
|
|
81
|
+
douyin-dl "xxxxxxxxxx" --json
|
|
82
|
+
|
|
83
|
+
# 下载视频
|
|
84
|
+
douyin-dl "xxxxxxxxxx" --download --output ./downloads
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Python API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from douyin_downloader import DouyinDownloader
|
|
91
|
+
|
|
92
|
+
# 创建下载器
|
|
93
|
+
dl = DouyinDownloader()
|
|
94
|
+
|
|
95
|
+
# 解析视频
|
|
96
|
+
info = dl.parse("https://v.douyin.com/xxxxx")
|
|
97
|
+
|
|
98
|
+
# 查看信息
|
|
99
|
+
print(f"标题: {info.title}")
|
|
100
|
+
print(f"作者: {info.author}")
|
|
101
|
+
print(f"无水印地址: {info.play_url}")
|
|
102
|
+
|
|
103
|
+
# 下载视频
|
|
104
|
+
dl.download_video(info, "./downloads")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## API 参考
|
|
110
|
+
|
|
111
|
+
### DouyinDownloader
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
class DouyinDownloader(
|
|
115
|
+
timeout: int = 15,
|
|
116
|
+
session: Optional[requests.Session] = None
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| 方法 | 说明 |
|
|
121
|
+
|------|------|
|
|
122
|
+
| `parse(url: str) -> VideoInfo` | 解析视频/图集信息 |
|
|
123
|
+
| `download_video(info: VideoInfo, output_dir=".", filename=None) -> Path` | 下载视频 |
|
|
124
|
+
| `download_gallery(info: VideoInfo, output_dir=".") -> list[Path]` | 下载图集 |
|
|
125
|
+
| `download(url: str, output_path: Path, progress_callback=None) -> Path` | 下载任意 URL |
|
|
126
|
+
|
|
127
|
+
### VideoInfo
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
@dataclass
|
|
131
|
+
class VideoInfo:
|
|
132
|
+
aweme_id: str # 视频 ID
|
|
133
|
+
title: str # 标题/描述
|
|
134
|
+
author: str # 作者昵称
|
|
135
|
+
author_id: str # 作者抖音号
|
|
136
|
+
avatar: str # 作者头像 URL
|
|
137
|
+
duration: int # 时长(毫秒)
|
|
138
|
+
cover: str # 封面 URL
|
|
139
|
+
play_url: str # 无水印视频地址
|
|
140
|
+
play_url_watermark: str # 带水印视频地址
|
|
141
|
+
images: list[str] # 图集图片列表
|
|
142
|
+
music: Optional[dict] # 音乐信息
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_gallery: bool # 是否为图集
|
|
146
|
+
@property
|
|
147
|
+
def is_video: bool # 是否为视频
|
|
148
|
+
|
|
149
|
+
def to_dict() -> dict # 转为字典
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 高级用法
|
|
155
|
+
|
|
156
|
+
### 自定义 Session
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import requests
|
|
160
|
+
from douyin_downloader import DouyinDownloader
|
|
161
|
+
|
|
162
|
+
session = requests.Session()
|
|
163
|
+
session.headers.update({"User-Agent": "Custom/1.0"})
|
|
164
|
+
|
|
165
|
+
dl = DouyinDownloader(session=session)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 下载进度回调
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
def on_progress(progress):
|
|
172
|
+
print(f"{progress.percentage:.1f}%")
|
|
173
|
+
|
|
174
|
+
info = dl.parse("https://...")
|
|
175
|
+
dl.download(info.play_url, "video.mp4", progress_callback=on_progress)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 批量下载
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
urls = ["url1", "url2", "url3"]
|
|
182
|
+
|
|
183
|
+
for url in urls:
|
|
184
|
+
try:
|
|
185
|
+
info = dl.parse(url)
|
|
186
|
+
dl.download_video(info, "./downloads")
|
|
187
|
+
print(f"✓ {info.aweme_id}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"✗ {url}: {e}")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 技术原理
|
|
195
|
+
|
|
196
|
+
1. **移动端 User-Agent**: 使用 iPhone Safari 的 UA,避免桌面端的反爬虫机制
|
|
197
|
+
2. **SSR 数据提取**: 从分享页的 `window._ROUTER_DATA` 解析视频元数据
|
|
198
|
+
3. **无水印转换**: 将 `playwm` 替换为 `play` 获取无水印地址
|
|
199
|
+
|
|
200
|
+
详细实现过程请参阅 [IMPLEMENTATION.md](IMPLEMENTATION.md)
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 免责声明
|
|
205
|
+
|
|
206
|
+
本工具仅供学习研究使用,请遵守相关法律法规和平台使用条款。使用者应确保已获得必要授权,产生的任何后果均由使用者自行承担。
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT License - 详见 [LICENSE](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/DouyinSolver.egg-info/PKG-INFO
|
|
4
|
+
src/DouyinSolver.egg-info/SOURCES.txt
|
|
5
|
+
src/DouyinSolver.egg-info/dependency_links.txt
|
|
6
|
+
src/DouyinSolver.egg-info/entry_points.txt
|
|
7
|
+
src/DouyinSolver.egg-info/requires.txt
|
|
8
|
+
src/DouyinSolver.egg-info/top_level.txt
|
|
9
|
+
src/douyin_downloader/__init__.py
|
|
10
|
+
src/douyin_downloader/cli.py
|
|
11
|
+
src/douyin_downloader/downloader.py
|
|
12
|
+
src/douyin_downloader/models.py
|
|
13
|
+
src/douyin_downloader/utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
douyin_downloader
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
抖音视频无水印下载库
|
|
3
|
+
|
|
4
|
+
使用移动端 User-Agent 解析抖音分享页 SSR 数据,
|
|
5
|
+
无需 Cookie 和 X-Bogus 签名即可获取无水印视频地址。
|
|
6
|
+
|
|
7
|
+
示例:
|
|
8
|
+
>>> from douyin_downloader import DouyinDownloader
|
|
9
|
+
>>> dl = DouyinDownloader()
|
|
10
|
+
>>> info = dl.parse("https://www.douyin.com/video/xxxxxx")
|
|
11
|
+
>>> print(info.play_url)
|
|
12
|
+
|
|
13
|
+
作者: OpenCode
|
|
14
|
+
许可证: MIT
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .downloader import DouyinDownloader
|
|
18
|
+
from .models import VideoInfo, ParseError
|
|
19
|
+
from .utils import normalize_url, extract_video_id
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DouyinDownloader",
|
|
24
|
+
"VideoInfo",
|
|
25
|
+
"ParseError",
|
|
26
|
+
"normalize_url",
|
|
27
|
+
"extract_video_id",
|
|
28
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""命令行接口"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .downloader import DouyinDownloader
|
|
9
|
+
from .models import ParseError, VideoInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_output(info: VideoInfo, output_json: bool = False) -> str:
|
|
13
|
+
"""格式化输出"""
|
|
14
|
+
if output_json:
|
|
15
|
+
return json.dumps(info.to_dict(), ensure_ascii=False, indent=2)
|
|
16
|
+
|
|
17
|
+
lines = [
|
|
18
|
+
f"视频 ID: {info.aweme_id}",
|
|
19
|
+
f"标题: {info.title[:60]}{'...' if len(info.title) > 60 else ''}",
|
|
20
|
+
f"作者: {info.author} (@{info.author_id})",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
if info.is_gallery:
|
|
24
|
+
lines.append(f"类型: 图集 ({len(info.images)} 张)")
|
|
25
|
+
for i, img in enumerate(info.images[:5], 1):
|
|
26
|
+
lines.append(f" [{i}] {img}")
|
|
27
|
+
if len(info.images) > 5:
|
|
28
|
+
lines.append(f" ... 还有 {len(info.images) - 5} 张")
|
|
29
|
+
else:
|
|
30
|
+
lines.extend([
|
|
31
|
+
f"类型: 视频",
|
|
32
|
+
f"无水印地址: {info.play_url}",
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
if info.music:
|
|
36
|
+
lines.append(f"背景音乐: {info.music['title']}")
|
|
37
|
+
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
"""主入口"""
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="douyin-dl",
|
|
45
|
+
description="抖音视频无水印下载工具",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("input", help="视频链接或ID")
|
|
48
|
+
parser.add_argument("-j", "--json", action="store_true", help="JSON 输出")
|
|
49
|
+
parser.add_argument("-d", "--download", action="store_true", help="下载视频/图集")
|
|
50
|
+
parser.add_argument("-o", "--output", default=".", help="输出目录")
|
|
51
|
+
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
dl = DouyinDownloader()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
info = dl.parse(args.input)
|
|
58
|
+
print(format_output(info, args.json))
|
|
59
|
+
|
|
60
|
+
if args.download and not args.json:
|
|
61
|
+
print(f"\n开始下载...")
|
|
62
|
+
if info.is_gallery:
|
|
63
|
+
paths = dl.download_gallery(info, args.output)
|
|
64
|
+
print(f"图集已下载到: {paths[0].parent}")
|
|
65
|
+
else:
|
|
66
|
+
path = dl.download_video(info, args.output)
|
|
67
|
+
print(f"视频已下载到: {path}")
|
|
68
|
+
|
|
69
|
+
except ParseError as e:
|
|
70
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""抖音下载器核心类"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .models import DownloadProgress, ParseError, VideoInfo
|
|
11
|
+
from .utils import get_mobile_headers, normalize_url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DouyinDownloader:
|
|
15
|
+
"""
|
|
16
|
+
抖音视频/图集下载器
|
|
17
|
+
|
|
18
|
+
使用移动端 User-Agent 访问分享页,无需 Cookie 即可获取无水印视频。
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
>>> dl = DouyinDownloader()
|
|
22
|
+
>>> info = dl.parse("https://www.douyin.com/video/xxxx")
|
|
23
|
+
>>> print(info.play_url)
|
|
24
|
+
>>> dl.download(info.play_url, "video.mp4")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
BASE_URL = "https://www.iesdouyin.com/share/video/{aweme_id}/"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
timeout: int = 15,
|
|
32
|
+
session: Optional[requests.Session] = None,
|
|
33
|
+
):
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self.session = session or requests.Session()
|
|
36
|
+
self.session.headers.update(get_mobile_headers())
|
|
37
|
+
|
|
38
|
+
def parse(self, url: str) -> VideoInfo:
|
|
39
|
+
"""解析抖音视频/图集信息"""
|
|
40
|
+
aweme_id = normalize_url(url)
|
|
41
|
+
data = self._fetch_share_page(aweme_id)
|
|
42
|
+
return self._parse_video_info(data, aweme_id)
|
|
43
|
+
|
|
44
|
+
def _fetch_share_page(self, aweme_id: str) -> dict:
|
|
45
|
+
"""获取分享页 SSR 数据"""
|
|
46
|
+
url = self.BASE_URL.format(aweme_id=aweme_id)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
resp = self.session.get(url, timeout=self.timeout)
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
except requests.RequestException as e:
|
|
52
|
+
raise ParseError(f"分享页请求失败: {e}", code=500)
|
|
53
|
+
|
|
54
|
+
pattern = r'window\._ROUTER_DATA\s*=\s*(.*?)</script>'
|
|
55
|
+
matches = re.search(pattern, resp.text, re.DOTALL)
|
|
56
|
+
|
|
57
|
+
if not matches:
|
|
58
|
+
raise ParseError("未找到 SSR 数据", code=404)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(matches.group(1).strip())
|
|
62
|
+
except json.JSONDecodeError as e:
|
|
63
|
+
raise ParseError(f"JSON 解析失败: {e}", code=500)
|
|
64
|
+
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
def _parse_video_info(self, data: dict, aweme_id: str) -> VideoInfo:
|
|
68
|
+
"""从 SSR 数据解析视频信息"""
|
|
69
|
+
loader_data = data.get("loaderData", {})
|
|
70
|
+
video_page = loader_data.get("video_(id)/page", {})
|
|
71
|
+
|
|
72
|
+
if not video_page:
|
|
73
|
+
raise ParseError("未找到视频页面数据", code=404)
|
|
74
|
+
|
|
75
|
+
video_info_res = video_page.get("videoInfoRes", {})
|
|
76
|
+
item_list = video_info_res.get("item_list", [])
|
|
77
|
+
|
|
78
|
+
if not item_list:
|
|
79
|
+
raise ParseError("视频不存在或已删除", code=404)
|
|
80
|
+
|
|
81
|
+
item = item_list[0]
|
|
82
|
+
|
|
83
|
+
# 作者信息
|
|
84
|
+
author_info = item.get("author", {})
|
|
85
|
+
author = author_info.get("nickname", "")
|
|
86
|
+
author_id = author_info.get("unique_id", "")
|
|
87
|
+
avatar = author_info.get("avatar_medium", {}).get("url_list", [""])[0]
|
|
88
|
+
|
|
89
|
+
# 视频信息
|
|
90
|
+
video_data = item.get("video", {})
|
|
91
|
+
play_addr = video_data.get("play_addr", {})
|
|
92
|
+
url_list = play_addr.get("url_list", [])
|
|
93
|
+
|
|
94
|
+
play_url_wm = url_list[0] if url_list else ""
|
|
95
|
+
play_url = play_url_wm.replace("playwm", "play") if play_url_wm else ""
|
|
96
|
+
cover = video_data.get("cover", {}).get("url_list", [""])[0]
|
|
97
|
+
|
|
98
|
+
# 图集模式
|
|
99
|
+
images = []
|
|
100
|
+
img_list = item.get("images", [])
|
|
101
|
+
for img in img_list:
|
|
102
|
+
urls = img.get("url_list", [])
|
|
103
|
+
if urls:
|
|
104
|
+
images.append(urls[0])
|
|
105
|
+
|
|
106
|
+
# 音乐信息
|
|
107
|
+
music_info = item.get("music")
|
|
108
|
+
music = None
|
|
109
|
+
if music_info:
|
|
110
|
+
music = {
|
|
111
|
+
"title": music_info.get("title", ""),
|
|
112
|
+
"author": music_info.get("author", ""),
|
|
113
|
+
"url": music_info.get("play_url", {}).get("url_list", [""])[0],
|
|
114
|
+
"cover": music_info.get("cover_large", {}).get("url_list", [""])[0],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return VideoInfo(
|
|
118
|
+
aweme_id=aweme_id,
|
|
119
|
+
title=item.get("desc", ""),
|
|
120
|
+
author=author,
|
|
121
|
+
author_id=author_id,
|
|
122
|
+
avatar=avatar,
|
|
123
|
+
duration=video_data.get("duration", 0),
|
|
124
|
+
cover=cover,
|
|
125
|
+
play_url=play_url,
|
|
126
|
+
play_url_watermark=play_url_wm,
|
|
127
|
+
images=images,
|
|
128
|
+
music=music,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def download(
|
|
132
|
+
self,
|
|
133
|
+
url: str,
|
|
134
|
+
output_path: Union[str, Path],
|
|
135
|
+
progress_callback: Optional[Callable[[DownloadProgress], None]] = None,
|
|
136
|
+
) -> Path:
|
|
137
|
+
"""下载视频/图片"""
|
|
138
|
+
output_path = Path(output_path)
|
|
139
|
+
|
|
140
|
+
resp = self.session.get(url, stream=True, timeout=self.timeout)
|
|
141
|
+
resp.raise_for_status()
|
|
142
|
+
|
|
143
|
+
total_size = int(resp.headers.get("content-length", 0))
|
|
144
|
+
downloaded = 0
|
|
145
|
+
|
|
146
|
+
with open(output_path, "wb") as f:
|
|
147
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
148
|
+
if chunk:
|
|
149
|
+
f.write(chunk)
|
|
150
|
+
downloaded += len(chunk)
|
|
151
|
+
|
|
152
|
+
if progress_callback and total_size > 0:
|
|
153
|
+
progress = DownloadProgress(
|
|
154
|
+
downloaded=downloaded,
|
|
155
|
+
total=total_size,
|
|
156
|
+
percentage=(downloaded / total_size) * 100,
|
|
157
|
+
)
|
|
158
|
+
progress_callback(progress)
|
|
159
|
+
|
|
160
|
+
return output_path
|
|
161
|
+
|
|
162
|
+
def download_video(
|
|
163
|
+
self,
|
|
164
|
+
info: VideoInfo,
|
|
165
|
+
output_dir: Union[str, Path] = ".",
|
|
166
|
+
filename: Optional[str] = None,
|
|
167
|
+
) -> Path:
|
|
168
|
+
"""下载视频"""
|
|
169
|
+
if info.is_gallery:
|
|
170
|
+
raise ParseError("该链接为图集")
|
|
171
|
+
|
|
172
|
+
if not info.play_url:
|
|
173
|
+
raise ParseError("无水印视频地址为空")
|
|
174
|
+
|
|
175
|
+
output_dir = Path(output_dir)
|
|
176
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
if not filename:
|
|
179
|
+
filename = f"{info.aweme_id}_{info.author}"
|
|
180
|
+
|
|
181
|
+
output_path = output_dir / f"{filename}.mp4"
|
|
182
|
+
return self.download(info.play_url, output_path)
|
|
183
|
+
|
|
184
|
+
def download_gallery(
|
|
185
|
+
self,
|
|
186
|
+
info: VideoInfo,
|
|
187
|
+
output_dir: Union[str, Path] = ".",
|
|
188
|
+
) -> list[Path]:
|
|
189
|
+
"""下载图集"""
|
|
190
|
+
if not info.is_gallery:
|
|
191
|
+
raise ParseError("该链接不是图集")
|
|
192
|
+
|
|
193
|
+
output_dir = Path(output_dir) / info.aweme_id
|
|
194
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
|
|
196
|
+
paths = []
|
|
197
|
+
for i, url in enumerate(info.images, 1):
|
|
198
|
+
ext = url.split("?")[0].split(".")[-1] or "jpg"
|
|
199
|
+
output_path = output_dir / f"{i:02d}.{ext}"
|
|
200
|
+
self.download(url, output_path)
|
|
201
|
+
paths.append(output_path)
|
|
202
|
+
|
|
203
|
+
return paths
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""数据模型定义"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class VideoInfo:
|
|
9
|
+
"""
|
|
10
|
+
抖音视频/图集信息
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
aweme_id: 视频唯一ID
|
|
14
|
+
title: 视频标题/描述
|
|
15
|
+
author: 作者昵称
|
|
16
|
+
author_id: 作者抖音号
|
|
17
|
+
avatar: 作者头像URL
|
|
18
|
+
duration: 视频时长(毫秒)
|
|
19
|
+
cover: 封面图URL
|
|
20
|
+
play_url: 无水印视频地址
|
|
21
|
+
play_url_watermark: 带水印视频地址
|
|
22
|
+
music: 背景音乐信息
|
|
23
|
+
images: 图集图片URL列表(仅图集模式)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
aweme_id: str
|
|
27
|
+
title: str = ""
|
|
28
|
+
author: str = ""
|
|
29
|
+
author_id: str = ""
|
|
30
|
+
avatar: str = ""
|
|
31
|
+
duration: int = 0
|
|
32
|
+
cover: str = ""
|
|
33
|
+
play_url: str = "" # 无水印视频地址
|
|
34
|
+
play_url_watermark: str = "" # 带水印地址
|
|
35
|
+
music: Optional[dict] = None
|
|
36
|
+
images: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_gallery(self) -> bool:
|
|
40
|
+
"""是否为图集模式"""
|
|
41
|
+
return len(self.images) > 0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_video(self) -> bool:
|
|
45
|
+
"""是否为视频模式"""
|
|
46
|
+
return not self.is_gallery and bool(self.play_url)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
"""转换为字典格式"""
|
|
50
|
+
return {
|
|
51
|
+
"aweme_id": self.aweme_id,
|
|
52
|
+
"title": self.title,
|
|
53
|
+
"author": self.author,
|
|
54
|
+
"author_id": self.author_id,
|
|
55
|
+
"avatar": self.avatar,
|
|
56
|
+
"duration": self.duration,
|
|
57
|
+
"cover": self.cover,
|
|
58
|
+
"play_url": self.play_url,
|
|
59
|
+
"play_url_watermark": self.play_url_watermark,
|
|
60
|
+
"music": self.music,
|
|
61
|
+
"images": self.images,
|
|
62
|
+
"is_gallery": self.is_gallery,
|
|
63
|
+
"is_video": self.is_video,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ParseError(Exception):
|
|
68
|
+
"""解析错误"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, code: int = 0):
|
|
71
|
+
self.message = message
|
|
72
|
+
self.code = code
|
|
73
|
+
super().__init__(self.message)
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
return f"[{self.code}] {self.message}" if self.code else self.message
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class DownloadProgress:
|
|
81
|
+
"""下载进度信息"""
|
|
82
|
+
|
|
83
|
+
downloaded: int = 0
|
|
84
|
+
total: int = 0
|
|
85
|
+
percentage: float = 0.0
|
|
86
|
+
speed: str = "0 B/s"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def completed(self) -> bool:
|
|
90
|
+
return self.total > 0 and self.downloaded >= self.total
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""工具函数"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .models import ParseError
|
|
10
|
+
|
|
11
|
+
# 正则表达式模式
|
|
12
|
+
PURE_ID_PATTERN = re.compile(r"^\d{15,20}$")
|
|
13
|
+
LONG_LINK_PATTERN = re.compile(r"/video/(\d+)")
|
|
14
|
+
SHORT_LINK_PATTERN = re.compile(r"v\.douyin\.com/([\w-]+)")
|
|
15
|
+
|
|
16
|
+
# 移动端 User-Agent (关键)
|
|
17
|
+
MOBILE_USER_AGENT = (
|
|
18
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) "
|
|
19
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 "
|
|
20
|
+
"Mobile/15E148 Safari/604.1"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_video_id(url: str) -> Optional[str]:
|
|
25
|
+
"""
|
|
26
|
+
从各种格式的链接中提取视频ID
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: 抖音链接 (短链/长链/分享页)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
视频ID或None
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> extract_video_id("https://www.douyin.com/video/123456")
|
|
36
|
+
'123456'
|
|
37
|
+
>>> extract_video_id("https://v.douyin.com/xxxxx")
|
|
38
|
+
'123456' # 会跟随重定向
|
|
39
|
+
"""
|
|
40
|
+
url = url.strip()
|
|
41
|
+
|
|
42
|
+
# 纯ID
|
|
43
|
+
if PURE_ID_PATTERN.match(url):
|
|
44
|
+
return url
|
|
45
|
+
|
|
46
|
+
# 补全协议
|
|
47
|
+
if "://" not in url:
|
|
48
|
+
url = "https://" + url
|
|
49
|
+
|
|
50
|
+
parsed = urlparse(url)
|
|
51
|
+
path = parsed.path or ""
|
|
52
|
+
|
|
53
|
+
# 长链: /video/xxxx
|
|
54
|
+
m = LONG_LINK_PATTERN.search(path)
|
|
55
|
+
if m:
|
|
56
|
+
return m.group(1)
|
|
57
|
+
|
|
58
|
+
# 分享页: /share/video/xxxx
|
|
59
|
+
m = re.search(r"/share/video/(\d+)", path)
|
|
60
|
+
if m:
|
|
61
|
+
return m.group(1)
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_url(raw: str) -> str:
|
|
67
|
+
"""
|
|
68
|
+
将各种输入格式统一转换为 aweme_id
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
raw: 短链/长链/纯ID
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
aweme_id
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ParseError: 无法识别的格式
|
|
78
|
+
"""
|
|
79
|
+
# 纯ID
|
|
80
|
+
if PURE_ID_PATTERN.match(raw.strip()):
|
|
81
|
+
return raw.strip()
|
|
82
|
+
|
|
83
|
+
# 从链接提取
|
|
84
|
+
video_id = extract_video_id(raw)
|
|
85
|
+
if video_id:
|
|
86
|
+
return video_id
|
|
87
|
+
|
|
88
|
+
# 短链重定向
|
|
89
|
+
if "v.douyin.com" in raw or "iesdouyin.com" in raw:
|
|
90
|
+
return _resolve_short_link(raw)
|
|
91
|
+
|
|
92
|
+
raise ParseError(f"无法识别的输入格式: {raw}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_short_link(url: str) -> str:
|
|
96
|
+
"""跟随短链重定向获取视频ID"""
|
|
97
|
+
url = url.strip()
|
|
98
|
+
if "://" not in url:
|
|
99
|
+
url = "https://" + url
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
resp = requests.get(
|
|
103
|
+
url,
|
|
104
|
+
headers={"User-Agent": MOBILE_USER_AGENT},
|
|
105
|
+
allow_redirects=True,
|
|
106
|
+
timeout=15,
|
|
107
|
+
)
|
|
108
|
+
video_id = extract_video_id(resp.url)
|
|
109
|
+
if video_id:
|
|
110
|
+
return video_id
|
|
111
|
+
except requests.RequestException as e:
|
|
112
|
+
raise ParseError(f"短链解析失败: {e}")
|
|
113
|
+
|
|
114
|
+
raise ParseError(f"无法从短链提取视频ID")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_mobile_headers() -> dict:
|
|
118
|
+
"""获取移动端请求头"""
|
|
119
|
+
return {
|
|
120
|
+
"User-Agent": MOBILE_USER_AGENT,
|
|
121
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
122
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
123
|
+
}
|