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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ douyin-dl = douyin_downloader.cli:main
@@ -0,0 +1,7 @@
1
+ requests>=2.28.0
2
+
3
+ [dev]
4
+ pytest>=7.0.0
5
+ pytest-asyncio>=0.21.0
6
+ black>=23.0.0
7
+ mypy>=1.0.0
@@ -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
+ }