抖音分目录下载无水印python实现

准备工作

在开始之前,请确保你的电脑已经安装了 Python 3.8 或以上版本

第一步:下载项目源码

打开你的终端(Windows 使用 CMD 或 PowerShell,Mac/Linux 使用 Terminal),执行以下命令克隆项目到本地:

1
2
git clone https://github.com/jiji262/douyin-downloader.git
cd douyin-downloader

(注:如果电脑没装 Git,也可以直接去 GitHub 网页点击绿色的 "Code" -> "Download ZIP" 下载压缩包并解压,然后用终端进入该解压文件夹。)

方法一:使用国内镜像克隆(最省事,直接复制命令)

既然官方链接连不上,我们可以通过国内的 GitHub 镜像源来下载。在你的终端里直接输入以下命令:

1
git clone https://gitclone.com/github.com/jiji262/douyin-downloader.git

或者使用另一个镜像:

1
git clone https://mirror.ghproxy.com/https://github.com/jiji262/douyin-downloader.git

这两个镜像会把代码同步过来,速度很快且不需要挂代理。克隆完成后,照样用 cd douyin-downloader 进入文件夹即可。

方法二:直接去网页下载 ZIP 压缩包(100% 成功)

如果命令行镜像也偶尔抽风,那就直接用浏览器下载:

  1. 打开浏览器,直接访问:https://github.com/jiji262/douyin-downloader
  2. 点击绿色的 Code 按钮。
  3. 在弹出的菜单里点击 Download ZIP
  4. 下载后解压到你的 C:\douyin-downloader cli\ 目录下。
  5. 在终端里用 cd 命令进入你解压出来的文件夹,就可以继续进行后续的 pip install 步骤了。

方法三:如果你开了代理(VPN)

如果你电脑上其实开着代理软件(如 Clash、V2ray 等),Git 默认是不会走代理的,你需要给 Git 挂上代理。

假设你的代理本地端口是 7890,在终端运行这两行(运行完后再去用你原本的 git clone 命令):

1
2
3
# 设置 Git 走本地代理
git config --global http.proxy http://127.0.0.1:7890
git config --global https.proxy http://127.0.0.1:7890

(注:如果以后不需要了,可以用 git config --global --unset http.proxy 随时取消设置。)

第二步:安装依赖环境

在项目根目录下,依次运行以下命令安装所需的 Python 依赖库,以及用于自动抓取 Cookie 的浏览器组件:

1
2
3
4
5
6
# 1. 安装基础依赖
pip install -r requirements.txt

# 2. 安装自动获取 Cookie 所需的浏览器自动化工具
pip install playwright
python -m playwright install chromium

第三步:配置与获取 Cookie(关键步骤)

由于抖音有反爬机制,批量下载必须依赖你自己的登录状态(Cookie)。项目内自带了自动获取工具:

  1. 复制配置文件:

    1
    copy config.example.yml config.yml #Mac用cp
  2. 运行 Cookie 获取脚本:

    1
    python -m tools.cookie_fetcher --config config.yml
  3. 完成登录: 此时系统会自动弹出一个浏览器窗口。请在弹出的浏览器中手动登录你的抖音账号。登录成功后回到终端,按下回车键(Enter),脚本会自动把你的 Cookie 写入到 config.yml 配置文件中。

第四步:修改配置实现“批量下载”

使用任意文本编辑器(如记事本、VS Code)打开项目根目录下的 config.yml 文件。

找到并修改以下几个核心参数来实现批量下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. 填入你要批量下载的抖音主页链接(支持填入多个)
link:
- https://www.douyin.com/user/MS4wLjABAAAAxxxxxxx

# 2. 设置下载文件保存的路径
path: ./Downloaded/

# 3. 设置下载模式(post=主页作品, like=喜欢列表, mix=合集, music=原声)
# 如果你想同时下载他的作品和喜欢,可以都写上
mode:
- post

# 4. 设置下载数量限制
number:
post: 0 # 0 代表下载该作者主页的【全部】视频;若只想下载最新的50条,改写为 50
like: 0
collect: 0
collectmix: 0

# 5. 同时下载的并发数(默认5)
thread: 5

第五步:运行下载

全部配置完成后,在终端输入以下命令,程序就会读取 config.yml 里的配置,自动开始批量下载:

1
python run.py -c config.yml

💡 进阶小技巧(通过命令行直接下载): 如果你不想每次都去改 config.yml 文件,你也可以在配置文件中只保留 Cookie,然后直接在命令行里追加参数来下载。例如:

1
2
# 批量下载指定主页的 50 个作品
python run.py -c config.yml -u "https://www.douyin.com/user/这里换成主页链接"

这样的话需要每次运行(下载)时都要改动配置文件,于是写个程序或批处理,可以询问用户输入抖音用户地址,要下载什么内容。我们可以用Python写一个交互式引导脚本

我会为你提供一个 Python 自动化脚本 以及一个 Windows 批处理(.bat)快捷启动文件。它会自动询问你输入链接、选择下载类型,接着自动帮你修改 config.yml,最后直接启动下载。

第一步:创建 Python 自动化脚本

在你的 douyin-downloader 文件夹内,新建一个文本文档,将其命名为 smart_download.py(注意后缀是 .py,不是 .txt),然后把以下代码复制进去并保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import os
import subprocess
import yaml
import re # 引入正则表达式库,专门用来提取网址

def main():
config_file = "config.yml"

if not os.path.exists(config_file):
print("【错误】未找到 config.yml 文件!")
print("请先确保你运行过 'python -m tools.cookie_fetcher --config config.yml' 并成功保存了 Cookie。")
input("\n按回车键退出...")
return

# 1. 询问用户输入地址(支持直接粘贴整段分享文本)
print("=" * 60)
print(" 抖音批量/单条下载交互助手 (智能粘贴版)")
print("=" * 60)
raw_input = input("请直接粘贴抖音分享的【整段文字】或【链接】:\n> ").strip()

if not raw_input:
print("输入不能为空!")
input("\n按回车键退出...")
return

# 使用正则表达式从繁琐的文本中提取出 http:// 或 https:// 开头的网址
url_match = re.search(r"https?://[^\s]+", raw_input)

if url_match:
url = url_match.group(0)
print(f"[智能识别] 成功提取到有效链接: {url}")
else:
# 如果实在没匹配到,就把用户输入的内容原样保留(死马当活马医)
url = raw_input
print("[提示] 未提取到标准网址,将尝试直接使用原输入内容。")

# 2. 询问用户下载类型
print("\n请选择你要下载的内容类型:")
print("[1] 作者发布的所有作品 (post) - 【批量推荐】")
print("[2] 作者点赞过的作品 (like)")
print("[3] 整个合集/系列视频 (mix)")
print("[4] 音乐原声关联视频 (music)")
print("[5] 单个视频或图集作品 (single) - 【单条推荐】")
choice = input("请输入对应数字 (直接回车默认选择 1):\n> ").strip()

is_single = (choice == "5")

mode = "post"
if choice == "2": mode = "like"
elif choice == "3": mode = "mix"
elif choice == "4": mode = "music"

# 3. 如果不是单条下载,才询问下载数量
download_num = 0
if not is_single:
print("\n请输入你想下载的数量:")
print("输入 0 代表下载【全部】作品 - 【直接回车即可】")
num_choice = input("请输入数量 (直接回车默认下载全部):\n> ").strip()
try:
download_num = int(num_choice) if num_choice else 0
except ValueError:
print("输入格式不正确,已自动设置为下载【全部】。")
download_num = 0

# 4. 安全读写 config.yml
print("\n[正在配置参数...]")
try:
with open(config_file, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)

if config_data is None:
config_data = {}

config_data['link'] = [url]
config_data['mode'] = [mode]

if 'number' not in config_data or not isinstance(config_data['number'], dict):
config_data['number'] = {}

config_data['number'][mode] = 1 if is_single else download_num

with open(config_file, "w", encoding="utf-8") as f:
yaml.safe_dump(config_data, f, allow_unicode=True, default_flow_style=False)

except Exception as e:
print(f"\n【错误】修改 config.yml 失败: {e}")
input("\n按回车键退出...")
return

# 5. 启动下载
print("[参数配置成功!正在启动下载引擎...]\n")
print("-" * 60)
try:
subprocess.run(["python", "run.py", "-c", config_file], check=True)
except Exception as e:
print(f"\n运行出错: {e}")

print("-" * 60)
input("\n下载流程结束,按回车键退出...")

if __name__ == "__main__":
main()

第二步:创建双击启动的批处理(.bat)

为了让你连终端命令都不用敲,在同一个文件夹下,再新建一个文本文档,命名为 双击运行批量下载.bat(注意后缀是 .bat),将以下内容复制进去并保存:

1
2
3
4
5
6
7
@off
:: 确保编码为UTF-8防中文乱码
chcp 65001 >nul
cls
cd /d "%~dp0"
python smart_download.py
pause

如何使用?

  1. 确保这两个新文件(smart_download.py双击运行批量下载.bat)都放在你的 douyin-downloader 主目录下(和 run.pyconfig.yml 在同一个地方)。
  2. 以后想下载时,直接双击运行 双击运行批量下载.bat
  3. 界面会弹出并提示你:
    • 粘贴抖音地址。
    • 输入数字选择下载类型(作品/点赞/合集)。
  4. 敲回车,它就会自动修改配置并默默开始批量下载。

注意生成的 config.yml 官方默认模板,理论上默认是 0(代表下载全部)。为了保险起见,你可以打开 config.yml 搜索检查一下:在这个项目的设定中,number 下面的数字代表“限制下载的数量”。因为你重置后的配置文件里 post 后面写的是 1,这就相当于你给下载器下达了死命令:“不管这个作者发了多少视频,探测到 1 个就立刻停止。”

1
2
number:
post: 0 # 确保这里是 0,如果是 1 就会只下载 1 个

现在,smart_download.py 不仅会自动改链接,还会在控制台里直接询问你想下载多少个视频(默认直接回车就是下载全部),且支持格式混乱的直接分享的抖音链接。


其实Github官方这个抖音下载提供了一款免费的桌面应用,如下图

image-20260711091252139

那为什么还要折腾呢?因为它不能批量下载(需要购买邀请码)

那我们接下来就用其提供的强大的CLI(命令行引擎),搭配 Python 自带的 Tkinter 图形界面库,自己拼装一个专属的高级批量下载桌面应用

创建桌面应用主程序

在你的 douyin-downloader 文件夹内,新建一个文本文档,命名为 my_douyin_app.py(注意后缀是 .py),把以下代码全部复制进去并保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
import os
import re
import yaml
import subprocess
import threading
import urllib.request
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter.scrolledtext import ScrolledText

class DouyinDownloaderApp:
def __init__(self, root):
self.root = root
self.root.title("抖音高级批量下载器 v8.0 (多阶段联动控制版)")
self.root.geometry("960x760")
self.root.resizable(True, True)

self.style = ttk.Style()
self.style.theme_use("clam")

self.config_file = "config.yml"
self.url_var = tk.StringVar()

# 缓存阶段性解析数据
self.resolved_url = ""
self.resolved_mode = "post"
self.scanned_items = [] # 存储扫描到的作品原始数据

self.create_widgets()

def create_widgets(self):
# ---- 上部:参数配置与格式选择(左右分栏) ----
top_frame = ttk.Frame(self.root, padding=10)
top_frame.pack(fill="x", padx=15, pady=(15, 5))

# 左侧:基本配置
left_config = ttk.LabelFrame(top_frame, text=" 1. 输入与识别控制 ", padding=10)
left_config.pack(side="left", fill="both", expand=True, padx=(0, 10))

ttk.Label(left_config, text="输入框 (粘贴任意分享文本/链接):").pack(anchor="w", pady=2)

input_btn_frame = ttk.Frame(left_config)
input_btn_frame.pack(fill="x", pady=5)

self.url_entry = ttk.Entry(input_btn_frame, textvariable=self.url_var)
self.url_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.url_entry.bind("<FocusIn>", lambda event: self.url_entry.selection_range(0, tk.END))

self.parse_btn = ttk.Button(input_btn_frame, text="🔍 1. 解析并锁定链接", command=self.start_parse_thread)
self.parse_btn.pack(side="right")

ttk.Label(left_config, text="已识别/锁定的内容类型:").pack(anchor="w", pady=2)
self.mode_combo = ttk.Combobox(left_config, values=[
"作者发布的所有作品 (post) - 【批量推荐】",
"作者点赞过的作品 (like)",
"整个合集/系列视频 (mix)",
"音乐原声关联视频 (music)",
"单个视频或图集作品 (single) - 【单条推荐】"
], state="readonly")
self.mode_combo.current(0)
self.mode_combo.pack(fill="x", pady=5)

# 右侧:高级格式选择与清单加载
right_config = ttk.LabelFrame(top_frame, text=" 2. 下载格式自定义与清单加载 ", padding=10)
right_config.pack(side="right", fill="both", padx=(10, 0))

self.chk_video = tk.BooleanVar(value=True)
self.chk_audio = tk.BooleanVar(value=False)
self.chk_image = tk.BooleanVar(value=False)

chk_frame = ttk.Frame(right_config)
chk_frame.pack(fill="x", pady=2)
ttk.Checkbutton(chk_frame, text="📹 视频 (MP4)", variable=self.chk_video).pack(side="left", padx=5)
ttk.Checkbutton(chk_frame, text="🎵 音频 (MP3)", variable=self.chk_audio).pack(side="left", padx=5)
ttk.Checkbutton(chk_frame, text="🖼️ 图集 (JPG)", variable=self.chk_image).pack(side="left", padx=5)

# 💡【修复 1】command直接绑定自含线程体系的 execute_fetch_list 函数,彻底解决崩溃
self.fetch_list_btn = ttk.Button(right_config, text="📋 2. 确认格式并获取作品列表", command=self.execute_fetch_list, state="disabled")
self.fetch_list_btn.pack(fill="x", pady=(12, 0))

# ---- 中部:可视化作品选择清单 ----
list_frame = ttk.LabelFrame(self.root, text=" 3. 作品精选控制台 (💡 单击第一列或双击任意行:切换状态) ", padding=10)
list_frame.pack(fill="both", expand=True, padx=15, pady=5)

columns = ("select", "index", "title", "type", "date", "url")
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings", selectmode="browse")

self.tree.heading("select", text="选择状态")
self.tree.heading("index", text="序号")
self.tree.heading("title", text="作品标题")
self.tree.heading("type", text="作品类型")
self.tree.heading("date", text="发布时间")
self.tree.heading("url", text="作品独立源地址")

self.tree.column("select", width=90, anchor="center")
self.tree.column("index", width=50, anchor="center")
self.tree.column("title", width=380, anchor="w")
self.tree.column("type", width=80, anchor="center")
self.tree.column("date", width=110, anchor="center")
self.tree.column("url", width=150, anchor="w")

self.tree.pack(fill="both", expand=True, side="left")

# 创建一个容纳批量按钮的水平框架
batch_frame = ttk.Frame(self.root)
batch_frame.pack(fill="x", padx=10, pady=5)

# 创建并放置【全选】按钮
self.btn_select_all = ttk.Button(
batch_frame,
text="🟩 一键全选",
command=self.select_all_items
)
self.btn_select_all.pack(side="left", padx=5)

# 创建并放置【全不选】按钮
self.btn_deselect_all = ttk.Button(
batch_frame,
text="🟥 一键全不选",
command=self.deselect_all_items
)
self.btn_deselect_all.pack(side="left", padx=5)

scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(fill="y", side="right")

# 绑定双击与单击事件
self.tree.bind("<Double-1>", self.on_tree_item_double_click)
self.tree.bind("<ButtonRelease-1>", self.on_tree_single_click)

self.clear_tree_items()
self.tree.insert("", "end", values=("💤 闲置", "--", "请先执行 [步骤1] 解析主链接...", "未知", "--", "--"))

# ---- 下部:控制与日志 ----
btn_frame = ttk.Frame(self.root, padding=5)
btn_frame.pack(fill="x", padx=15, pady=5)

self.status_label = ttk.Label(btn_frame, text="状态: 🟢 欢迎使用,请输入链接并点击【解析并锁定链接】", font=("微软雅黑", 10, "bold"), foreground="#2ed573")
self.status_label.pack(side="left", padx=5)

self.start_btn = ttk.Button(btn_frame, text="🚀 开始定制下载", command=self.start_download_thread, state="disabled")
self.start_btn.pack(side="right", padx=5)

log_frame = ttk.LabelFrame(self.root, text=" 实时控制台运行日志 ", padding=10)
log_frame.pack(fill="x", padx=15, pady=(5, 15))

self.log_text = ScrolledText(log_frame, height=5, wrap="word", background="#1e1e1e", foreground="#d4d4d4", font=("Consolas", 10))
self.log_text.pack(fill="both", expand=True)

def log(self, text):
self.log_text.insert(tk.END, text)
self.log_text.see(tk.END)

def clear_tree_items(self):
for item in self.tree.get_children():
self.tree.delete(item)

# 核心交互:双击表格某一行,切换勾选状态
def on_tree_item_double_click(self, event):
item_id = self.tree.identify_row(event.y)
if not item_id: return

current_values = list(self.tree.item(item_id, "values"))
if current_values[0].startswith("💤"): return # 闲置状态不处理

# 状态判定开关
if current_values[0].startswith("✅"):
current_values[0] = "❌ 取消下载"
else:
current_values[0] = "✅ 勾选下载"

self.tree.item(item_id, values=current_values)
self.log(f" -> 交互控件:已将序号 [{current_values[1]}] 的作品调整为 {current_values[0]}\n")

# ================== 阶段一:解析异步线程 ==================
def start_parse_thread(self):
raw_input = self.url_var.get().strip()
if not raw_input:
messagebox.showwarning("提示", "请输入有效的抖音分享内容!")
return
self.parse_btn.configure(state="disabled")
self.status_label.config(text="状态: ⏳ 正在识别目标主体...", foreground="#ffa500")
threading.Thread(target=self.execute_parse, args=(raw_input,), daemon=True).start()

def execute_parse(self, raw_input):
self.log_text.delete("1.0", tk.END)
self.log("[🚀 阶段 1] 启动链接多维特征解密...\n")

url_match = re.search(r"https?://[^\s]+", raw_input)
if not url_match:
self.root.after(0, lambda: messagebox.showwarning("错误", "未检测到有效网址!"))
self.root.after(0, lambda: self.parse_btn.configure(state="normal"))
return

short_url = url_match.group(0)
real_url = short_url
try:
req = urllib.request.Request(short_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5) as response: real_url = response.geturl()
except:
pass

is_homepage = "user/" in real_url or "sec_uid" in real_url or "更多作品" in raw_input or "的主页" in raw_input

self.clear_tree_items()
if is_homepage:
self.root.after(0, lambda: self.mode_combo.current(0))
self.resolved_mode = "post"
self.tree.insert("", "end", values=("⏳ 待加载", "--", "主体锁定成功!请配置右上角格式并点击 [步骤2] 加载具体作品列表", "作者主页", "--", real_url))
else:
self.root.after(0, lambda: self.mode_combo.current(4))
self.resolved_mode = "single"
self.tree.insert("", "end", values=("⏳ 待加载", "--", "主体锁定成功!请配置右上角格式并点击 [步骤2] 加载具体作品列表", "单条视频", "--", real_url))

self.resolved_url = real_url
self.log("✨ 阶段 1 完成:目标主体类型已锁定!请在右上角勾选您需要的格式,然后点击【📋 2. 确认格式并获取作品列表】。\n")

self.root.after(0, lambda: self.parse_btn.configure(state="normal"))
self.root.after(0, lambda: self.fetch_list_btn.configure(state="normal"))
self.root.after(0, lambda: self.status_label.config(text="状态: 🟡 主体已锁定,请执行步骤 2", foreground="#ffa500"))

# ================== 阶段二:获取作品清单线程 ==================
def execute_fetch_list(self):
"""【步骤2真正执行逻辑】增强版:自动过滤文案、异步全量翻页、完成后激活下载按钮"""
import re
raw_text = self.url_var.get().strip()
if not raw_text:
self.log("⚠️ 错误:请输入有效的抖音链接或分享文本!\n")
return

url_match = re.search(r'https?://[^\s]+', raw_text)
url = url_match.group(0) if url_match else raw_text

self.log(f"\n[🚀 阶段 2] 开始解析链接并调用内部核心引擎获取真实作品列表...")
self.log(f"过滤后纯净链接: {url}\n")

self.clear_tree_items()
self.status_label.config(text="⏳ 正在通过核心引擎抓取远程数据...", foreground="#3B82F6")

def worker():
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

real_items = loop.run_until_complete(self.async_fetch_core_bridge(url))
loop.close()

if real_items:
self.scanned_items = real_items
for item in self.scanned_items:
self.tree.insert("", "end", values=(
item.get("status", "✅ 勾选下载"),
item.get("idx", "01"),
item.get("title", "未命名作品"),
item.get("type", "视频"),
item.get("date", "未知时间"),
item.get("url", "")
))
self.log(f"🎉 成功!共提取到 {len(real_items)} 个真实公开作品,已成功载入精选控制台!\n")
self.status_label.config(text="🟢 列表加载完成!请勾选精选作品后点击开始下载", foreground="#10B981")

# 💡【修复 2】精确对应 self.start_btn,完美亮起开始下载按钮!
self.start_btn.config(state="normal")
else:
self.log("⚠️ 未能提取到标准作品数据。\n")
self.status_label.config(text="⚠️ 未能加载到有效作品列表", foreground="#F59E0B")

except Exception as e:
import traceback
self.log(f"❌ 核心引擎对接发生故障: {str(e)}\n")
self.log(traceback.format_exc())
self.status_label.config(text="❌ 引擎对接失败", foreground="#EF4444")

import threading
threading.Thread(target=worker, daemon=True).start()

async def async_fetch_core_bridge(self, url):
"""【平级函数 3】⚡翻页增强版:支持自动化全量翻页拉取,突破18条限制"""
import os
import time
import inspect
from config import ConfigLoader
from auth import CookieManager
from core import DouyinAPIClient, URLParser, DownloaderFactory
from utils.validators import is_short_url, normalize_short_url
from storage import FileManager
from control import QueueManager, RateLimiter, RetryHandler

config_path = self.config_file if os.path.exists(self.config_file) else "config.yml"
config = ConfigLoader(config_path)
cookie_manager = CookieManager()
cookie_manager.set_cookies(config.get_cookies())

file_manager = FileManager(config.get("path") or "./Downloaded/")
rate_limiter = RateLimiter(max_per_second=float(config.get("rate_limit", 2) or 2))
retry_handler = RetryHandler(max_retries=config.get("retry_times", 3))
queue_manager = QueueManager(max_workers=int(config.get("thread", 5) or 5))

real_items = []
all_raw_awemes = [] # 用于累计多页合并后的所有原始作品数据

async with DouyinAPIClient(cookie_manager.get_cookies(), proxy=config.get("proxy")) as api_client:
if is_short_url(url):
self.log("🔄 检测到短链接,正在通过 API 客户端还原...\n")
resolved_url = await api_client.resolve_short_url(normalize_short_url(url))
if resolved_url: url = resolved_url

parsed = URLParser.parse(url)
if not parsed: return []

url_type = parsed.get("type", "unknown")
client_methods = [m for m in dir(api_client) if not m.startswith('_')]

explicit_mapping = {
"user": "get_user_post", "post": "get_user_post", "like": "get_user_like",
"video": "get_video_detail", "aweme": "get_video_detail"
}


# ... 前面代码不变 ...
api_target_method = explicit_mapping.get(url_type)
if not api_target_method or api_target_method not in client_methods:
return []

try:
method_func = getattr(api_client, api_target_method)
sig = inspect.signature(method_func)

# 初始化通用参数包装
base_kwargs = {}
if "sec_uid" in sig.parameters:
base_kwargs["sec_uid"] = parsed.get("sec_uid") or parsed.get("id") or parsed.get("unique_id")
if "aweme_id" in sig.parameters:
base_kwargs["aweme_id"] = parsed.get("id") or parsed.get("aweme_id")
if "parsed" in sig.parameters:
base_kwargs["parsed"] = parsed

# 💡【核心修复】:判断是否为单条作品,如果是,则不进入循环翻页逻辑
is_single = (url_type in ["video", "aweme", "single"])

if is_single:
# 单条直接请求一次
self.log(f"📡 正在获取单条作品详细信息...\n")
page_data = await method_func(**base_kwargs)
if page_data:
all_raw_awemes = [page_data] if not isinstance(page_data, list) else page_data
else:
# 原有的翻页循环逻辑
has_more = True
max_cursor = 0
page_index = 1
max_pages = 20 # 安全限制:最多连续抓取20页(约300-400个作品),防止大V主页无限死循环卡死

while has_more and page_index <= max_pages:
kwargs = base_kwargs.copy()
# 动态投喂翻页参数
if "max_cursor" in sig.parameters:
kwargs["max_cursor"] = max_cursor
elif "cursor" in sig.parameters:
kwargs["cursor"] = max_cursor

self.log(f"📡 正在拉取远程第 {page_index} 页数据 (指针偏移量: {max_cursor})...\n")
page_data = await method_func(**kwargs)

if not page_data:
break

# 提取当前页包含的列表
curr_list = []
if isinstance(page_data, list):
curr_list = page_data
elif isinstance(page_data, dict):
for k in ["aweme_list", "awemes", "data", "list", "items"]:
if k in page_data and isinstance(page_data[k], list):
curr_list = page_data[k]
break
else:
for attr in ["aweme_list", "awemes", "data", "list", "items"]:
if hasattr(page_data, attr) and isinstance(getattr(page_data, attr), list):
curr_list = getattr(page_data, attr)
break

if not curr_list:
break

all_raw_awemes.extend(curr_list)
self.log(f"📝 本页拉取成功,获得 {len(curr_list)} 条作品,累计已达 {len(all_raw_awemes)} 条。\n")

# 读取下一页的控制标记
if isinstance(page_data, dict):
curr_has_more = page_data.get("has_more") or page_data.get("has_more_user_post", False)
curr_cursor = page_data.get("max_cursor") or page_data.get("cursor", 0)
else:
curr_has_more = getattr(page_data, "has_more", False) or getattr(page_data, "has_more_user_post", False)
curr_cursor = getattr(page_data, "max_cursor", 0) or getattr(page_data, "cursor", 0)

has_more = bool(curr_has_more)
max_cursor = curr_cursor

if not has_more or not max_cursor or max_cursor == 0:
break

page_index += 1
time.sleep(0.3) # 极其轻微的延迟,保护接口流控

except Exception as inner_e:
self.log(f"❌ 调用核心翻页接口发生异常: {inner_e}\n")

# 清洗合并后的全量数据
if all_raw_awemes:
def get_val(obj, keys, default=""):
if isinstance(obj, dict):
for k in keys:
if k in obj: return obj[k]
else:
for k in keys:
if hasattr(obj, k): return getattr(obj, k)
return default

for i, x in enumerate(all_raw_awemes, 1):
title = get_val(x, ["desc", "title", "aweme_id"]) or f"作品_{i}"
aweme_id = get_val(x, ["aweme_id", "id"]) or str(i)
images = get_val(x, ["images", "image_list"])
item_type = "图集" if (images or get_val(x, ["aweme_type"]) == 2) else "视频"
date_val = get_val(x, ["create_time", "pub_time"]) or "近期"
if isinstance(date_val, (int, float)):
date_val = time.strftime("%Y-%m-%d", time.localtime(date_val))
video_url = get_val(x, ["share_url", "video_url"]) or f"https://www.douyin.com/video/{aweme_id}"

real_items.append({
"status": "✅ 勾选下载",
"idx": f"{i:02d}",
"title": str(title).strip().replace("\n", " ")[:60],
"type": item_type,
"date": str(date_val),
"url": video_url
})
return real_items

def on_tree_single_click(self, event):
"""【单击功能】鼠标单击第一列时,瞬间切换勾选状态(替代双击)"""
# 1. 确定鼠标点击的具体行 ID
item_id = self.tree.identify_row(event.y)
# 2. 确定鼠标点击的具体列编号(#1 代表第一列)
column = self.tree.identify_column(event.x)

# 当且仅当用户点击的是第一列(状态列)且点中了有效行时触发
if column == "#1" and item_id:
current_values = list(self.tree.item(item_id, "values"))
if not current_values or current_values[0].startswith("💤"):
return

# 状态翻转逻辑
if "✅" in current_values[0]:
current_values[0] = "❌ 取消下载"
else:
current_values[0] = "✅ 勾选下载"

# 瞬间更新该行数据
self.tree.item(item_id, values=current_values)

def select_all_items(self):
"""【批量功能】一键全选表格里的所有作品"""
for item_id in self.tree.get_children():
current_values = list(self.tree.item(item_id, "values"))
if current_values and not current_values[0].startswith("💤"):
current_values[0] = "✅ 勾选下载" # 修改第一列为勾选状态
self.tree.item(item_id, values=current_values) # 刷新显示
self.log("💡 操作提示:精选控制台中的所有作品已变更为【✅ 全部勾选】状态。\n")

def deselect_all_items(self):
"""【批量功能】一键取消勾选表格里的所有作品"""
for item_id in self.tree.get_children():
current_values = list(self.tree.item(item_id, "values"))
if current_values and not current_values[0].startswith("💤"):
current_values[0] = "❌ 取消下载" # 修改第一列为取消状态
self.tree.item(item_id, values=current_values) # 刷新显示
self.log("💡 操作提示:精选控制台中的所有作品已变更为【❌ 全部取消】状态。\n")

# ================== 阶段三:提取勾选状态并唤醒同步 ==================
def start_download_thread(self):
self.start_btn.configure(state="disabled")
self.fetch_list_btn.configure(state="disabled")
self.parse_btn.configure(state="disabled")
self.status_label.config(text="状态: ⏳ 正在同步过滤矩阵并唤醒底层下载引擎...", foreground="#ffa500")
threading.Thread(target=self.execute_download, daemon=True).start()

def execute_download(self):
self.log("\n[🚀 阶段 3] 正在对控制台的表格勾选状态进行最终扫描与矩阵过滤...\n")

# 提取用户真正勾选的行
selected_urls = []
total_count = 0

for item in self.tree.get_children():
values = self.tree.item(item, "values")
if values[0].startswith("💤"): continue
total_count += 1
if values[0].startswith("✅"):
selected_urls.append(values[5]) # 拿到选中的独立作品网址

if not selected_urls:
self.root.after(0, lambda: messagebox.showwarning("提示", "您排除掉了所有作品!请至少保留一个勾选下载的项目。"))
self.reset_ui_status("🔴 未选择任何下载项")
return

self.log(f"-> 扫描完毕:面板总项目数 {total_count} 个,用户精选保留 {len(selected_urls)} 个项目。\n")

# 【智能降维写入算法】
try:
with open(self.config_file, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f) or {}

if len(selected_urls) == total_count and self.resolved_mode == "post":
# 情况 A:用户全选了主页,走高效全量同步
config_data['link'] = [self.resolved_url]
config_data['mode'] = ["post"]
if 'number' not in config_data or not isinstance(config_data['number'], dict):
config_data['number'] = {}
config_data['number']["post"] = 0
self.log("-> 决策决策:全量保留,启动主页通配多线程并发同步模式。\n")
else:
# 情况 B:用户过滤了部分,或者本来就是单条,直接打包精选列表降维成 single 逐条精准下载
config_data['link'] = selected_urls
config_data['mode'] = ["single"]
if 'number' not in config_data or not isinstance(config_data['number'], dict):
config_data['number'] = {}
config_data['number']["single"] = 1
self.log("-> 决策决策:触发动态过滤,自动转为精选单条多链路精准包围圈模式。\n")

# 映射多选格式配置区
config_data['video'] = self.chk_video.get()
config_data['music'] = self.chk_audio.get()
config_data['cover'] = self.chk_image.get()

with open(self.config_file, "w", encoding="utf-8") as f:
yaml.safe_dump(config_data, f, allow_unicode=True, default_flow_style=False)

self.log("-> config.yml 矩阵配置重写锁定成功!\n")
except Exception as e:
self.log(f"【错误】写入配置文件失败: {e}\n")
self.reset_ui_status("🔴 配置重写崩溃")
return

self.log("-> 唤醒核心引擎中,请查看下方实时解密下载输出...\n")
self.log("=" * 60 + "\n")

current_env = os.environ.copy()
current_env["PYTHONIOENCODING"] = "utf-8"

try:
process = subprocess.Popen(
["python", "run.py", "-c", self.config_file],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", env=current_env
)
while True:
line = process.stdout.readline()
if not line: break
self.log(line)
process.wait()
self.reset_ui_status("✨ 筛选定制任务圆满完成!")
except Exception as e:
self.log(f"\n[运行异常]: {e}\n")
self.reset_ui_status("🔴 引擎突发性异常")

def reset_ui_status(self, status_text):
self.start_btn.configure(state="normal")
self.fetch_list_btn.configure(state="normal")
self.parse_btn.configure(state="normal")
if "完成" in status_text:
self.status_label.config(text=status_text, foreground="#2ed573")
else:
self.status_label.config(text=status_text, foreground="#ff4757")

if __name__ == "__main__":
root = tk.Tk()
app = DouyinDownloaderApp(root)
root.mainloop()

上一个程序已经可以下载单条、多条或作者的全部posts,但也有个小缺陷:没能按抖音作者主页的分类进行分类解析,更无法按分类(目录)下载,于是又改进了一版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
import os
import re
import yaml
import subprocess
import threading
import urllib.request
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter.scrolledtext import ScrolledText
from datetime import datetime
from collections import defaultdict

class DouyinDownloaderApp:
def __init__(self, root):
self.root = root
self.root.title("抖音高级批量下载器 v9.0 (真实标签页结构版)")
self.root.geometry("1100x800")
self.root.resizable(True, True)

self.style = ttk.Style()
self.style.theme_use("clam")

self.config_file = "config.yml"
self.url_var = tk.StringVar()

# 缓存阶段性解析数据
self.resolved_url = ""
self.resolved_mode = "post"
self.scanned_items = [] # 存储扫描到的作品原始数据
self.all_formatted_items = [] # 存储格式化后的所有作品数据

# 目录结构相关变量 - 按照抖音真实标签页结构
self.tab_structure = {} # {"作品": {"作品": [...], "合集1": [...], "合集2": [...], "短片": [...]}, "喜欢": [...], "推荐": [...]}
self.current_main_tab = "作品"
self.current_sub_tab = None
self.current_collection_id = None # 当前选中的合集ID
self.current_collection_name = None # 当前选中的合集名称

self.create_widgets()

def create_widgets(self):
# ---- 上部:参数配置与格式选择(左右分栏) ----
top_frame = ttk.Frame(self.root, padding=10)
top_frame.pack(fill="x", padx=15, pady=(15, 5))

# 左侧:基本配置
left_config = ttk.LabelFrame(top_frame, text=" 1. 输入与识别控制 ", padding=10)
left_config.pack(side="left", fill="both", expand=True, padx=(0, 10))

ttk.Label(left_config, text="输入框 (粘贴任意分享文本/链接):").pack(anchor="w", pady=2)

input_btn_frame = ttk.Frame(left_config)
input_btn_frame.pack(fill="x", pady=5)

self.url_entry = ttk.Entry(input_btn_frame, textvariable=self.url_var)
self.url_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.url_entry.bind("<FocusIn>", lambda event: self.url_entry.selection_range(0, tk.END))

self.parse_btn = ttk.Button(input_btn_frame, text="🔍 1. 解析并锁定链接", command=self.start_parse_thread)
self.parse_btn.pack(side="right")

ttk.Label(left_config, text="已识别/锁定的内容类型:").pack(anchor="w", pady=2)
self.mode_combo = ttk.Combobox(left_config, values=[
"作者发布的所有作品 (post) - 【批量推荐】",
"作者点赞过的作品 (like)",
"整个合集/系列视频 (mix)",
"音乐原声关联视频 (music)",
"单个视频或图集作品 (single) - 【单条推荐】"
], state="readonly")
self.mode_combo.current(0)
self.mode_combo.pack(fill="x", pady=5)

# 右侧:高级格式选择与清单加载
right_config = ttk.LabelFrame(top_frame, text=" 2. 下载格式自定义与清单加载 ", padding=10)
right_config.pack(side="right", fill="both", padx=(10, 0))

self.chk_video = tk.BooleanVar(value=True)
self.chk_audio = tk.BooleanVar(value=False)
self.chk_image = tk.BooleanVar(value=False)

chk_frame = ttk.Frame(right_config)
chk_frame.pack(fill="x", pady=2)
ttk.Checkbutton(chk_frame, text="📹 视频 (MP4)", variable=self.chk_video).pack(side="left", padx=5)
ttk.Checkbutton(chk_frame, text="🎵 音频 (MP3)", variable=self.chk_audio).pack(side="left", padx=5)
ttk.Checkbutton(chk_frame, text="🖼️ 图集 (JPG)", variable=self.chk_image).pack(side="left", padx=5)

self.fetch_list_btn = ttk.Button(right_config, text="📋 2. 确认格式并获取作品列表", command=self.execute_fetch_list, state="disabled")
self.fetch_list_btn.pack(fill="x", pady=(12, 0))

# ---- 中部:双栏布局 - 目录树 + 作品列表 ----
middle_frame = ttk.Frame(self.root)
middle_frame.pack(fill="both", expand=True, padx=15, pady=5)

# 左侧:目录树面板
dir_frame = ttk.LabelFrame(middle_frame, text=" 📂 作者主页标签结构 ", padding=5)
dir_frame.pack(side="left", fill="both", expand=False, padx=(0, 5))

self.dir_tree = ttk.Treeview(dir_frame, show="tree", selectmode="browse", height=22)
self.dir_tree.pack(fill="both", expand=True, side="left")

dir_scrollbar = ttk.Scrollbar(dir_frame, orient="vertical", command=self.dir_tree.yview)
self.dir_tree.configure(yscrollcommand=dir_scrollbar.set)
dir_scrollbar.pack(fill="y", side="right")

self.dir_tree.bind("<<TreeviewSelect>>", self.on_directory_select)

# 当前选择路径显示
self.current_path_label = ttk.Label(dir_frame, text="当前: 全部作品", font=("微软雅黑", 9), foreground="#666")
self.current_path_label.pack(fill="x", pady=(5, 0))

# 右侧:作品列表面板
list_frame = ttk.LabelFrame(middle_frame, text=" 📋 作品列表 (单击第一列或双击任意行:切换状态) ", padding=10)
list_frame.pack(side="right", fill="both", expand=True)

columns = ("select", "index", "title", "type", "date", "url")
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings", selectmode="browse")

self.tree.heading("select", text="选择状态")
self.tree.heading("index", text="序号")
self.tree.heading("title", text="作品标题")
self.tree.heading("type", text="作品类型")
self.tree.heading("date", text="发布时间")
self.tree.heading("url", text="作品独立源地址")

self.tree.column("select", width=90, anchor="center")
self.tree.column("index", width=50, anchor="center")
self.tree.column("title", width=380, anchor="w")
self.tree.column("type", width=80, anchor="center")
self.tree.column("date", width=110, anchor="center")
self.tree.column("url", width=150, anchor="w")

self.tree.pack(fill="both", expand=True, side="left")

scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(fill="y", side="right")

self.tree.bind("<Double-1>", self.on_tree_item_double_click)
self.tree.bind("<ButtonRelease-1>", self.on_tree_single_click)

self.clear_tree_items()
self.tree.insert("", "end", values=("💤 闲置", "--", "请先执行 [步骤1] 解析主链接...", "未知", "--", "--"))
self.clear_directory_tree()
self.dir_tree.insert("", "end", text="💤 等待解析链接...")

# ---- 批量操作栏 ----
batch_frame = ttk.Frame(self.root)
batch_frame.pack(fill="x", padx=10, pady=5)

batch_left = ttk.Frame(batch_frame)
batch_left.pack(side="left")

self.btn_select_all = ttk.Button(batch_left, text="🟩 一键全选", command=self.select_all_items)
self.btn_select_all.pack(side="left", padx=5)

self.btn_deselect_all = ttk.Button(batch_left, text="🟥 一键全不选", command=self.deselect_all_items)
self.btn_deselect_all.pack(side="left", padx=5)

self.btn_select_video = ttk.Button(batch_left, text="🎬 仅选视频", command=lambda: self.select_by_type("视频"))
self.btn_select_video.pack(side="left", padx=5)

self.btn_select_image = ttk.Button(batch_left, text="🖼️ 仅选图集", command=lambda: self.select_by_type("图集"))
self.btn_select_image.pack(side="left", padx=5)

batch_right = ttk.Frame(batch_frame)
batch_right.pack(side="right")

self.selection_count_label = ttk.Label(batch_right, text="已选择: 0 个作品", font=("微软雅黑", 10, "bold"), foreground="#e74c3c")
self.selection_count_label.pack(side="left", padx=10)

# ---- 下部:控制与日志 ----
btn_frame = ttk.Frame(self.root, padding=5)
btn_frame.pack(fill="x", padx=15, pady=5)

self.status_label = ttk.Label(btn_frame, text="状态: 🟢 欢迎使用,请输入链接并点击【解析并锁定链接】", font=("微软雅黑", 10, "bold"), foreground="#2ed573")
self.status_label.pack(side="left", padx=5)

self.start_btn = ttk.Button(btn_frame, text="🚀 开始定制下载", command=self.start_download_thread, state="disabled")
self.start_btn.pack(side="right", padx=5)

log_frame = ttk.LabelFrame(self.root, text=" 实时控制台运行日志 ", padding=10)
log_frame.pack(fill="x", padx=15, pady=(5, 15))

self.log_text = ScrolledText(log_frame, height=5, wrap="word", background="#1e1e1e", foreground="#d4d4d4", font=("Consolas", 10))
self.log_text.pack(fill="both", expand=True)

def log(self, text):
self.log_text.insert(tk.END, text)
self.log_text.see(tk.END)

def clear_tree_items(self):
for item in self.tree.get_children():
self.tree.delete(item)

def clear_directory_tree(self):
for item in self.dir_tree.get_children():
self.dir_tree.delete(item)

def update_selection_count(self):
count = 0
for item in self.tree.get_children():
values = self.tree.item(item, "values")
if values and values[0].startswith("✅"):
count += 1
self.selection_count_label.config(text=f"已选择: {count} 个作品")

def build_directory_tree_by_tabs(self):
"""按照抖音真实标签页和合集结构构建目录树"""
self.clear_directory_tree()

if not self.all_formatted_items:
return

total_count = len(self.all_formatted_items)

# 创建根节点
root_node = self.dir_tree.insert("", "end",
text=f"📱 作者主页 (共{total_count}个作品)",
open=True, tags=("__root__",))

works_node = None

# === 1. "作品"主标签页 ===
works_data = self.tab_structure.get("作品", {})
if isinstance(works_data, dict):
all_works = []
for sub_items in works_data.values():
if isinstance(sub_items, list):
all_works.extend(sub_items)
elif isinstance(sub_items, dict):
all_works.extend(sub_items.get("items", []))

works_node = self.dir_tree.insert(root_node, "end",
text=f"📁 作品 ({len(all_works)}个)",
open=True, tags=("作品", ""))

# 子标签: 作品(普通作品)
if "作品" in works_data:
normal_works = works_data["作品"]
count = len(normal_works) if isinstance(normal_works, list) else len(normal_works.get("items", []))
self.dir_tree.insert(works_node, "end",
text=f" 📹 作品 ({count}个)",
tags=("作品", "作品"))

# 子标签: 合集(可能有多个合集)
if "合集" in works_data:
collections = works_data["合集"]
if isinstance(collections, dict):
total_collection_count = 0
for coll_name, coll_data in collections.items():
if isinstance(coll_data, dict):
coll_items = coll_data.get("items", [])
coll_count = len(coll_items)
total_collection_count += coll_count
elif isinstance(coll_data, list):
coll_count = len(coll_data)
total_collection_count += coll_count

coll_node = self.dir_tree.insert(works_node, "end",
text=f" 📚 合集 ({total_collection_count}个)",
open=True, tags=("作品", "合集"))

# 为每个合集创建子节点
for coll_name, coll_data in collections.items():
if isinstance(coll_data, dict):
coll_items = coll_data.get("items", [])
coll_count = len(coll_items)
coll_id = coll_data.get("collection_id", coll_name)
elif isinstance(coll_data, list):
coll_items = coll_data
coll_count = len(coll_data)
coll_id = coll_name

self.dir_tree.insert(coll_node, "end",
text=f" 📁 {coll_name} ({coll_count}个)",
tags=("作品", "合集", coll_id, coll_name))
elif isinstance(collections, list):
count = len(collections)
self.dir_tree.insert(works_node, "end",
text=f" 📚 合集 ({count}个)",
tags=("作品", "合集", "default_collection", "合集"))

# 子标签: 短片
if "短片" in works_data:
shorts = works_data["短片"]
count = len(shorts) if isinstance(shorts, list) else len(shorts.get("items", []))
self.dir_tree.insert(works_node, "end",
text=f" 📱 短片 ({count}个)",
tags=("作品", "短片"))

# 其他可能的子标签
for sub_tab, sub_data in works_data.items():
if sub_tab not in ["作品", "合集", "短片"]:
count = len(sub_data) if isinstance(sub_data, list) else len(sub_data.get("items", []))
self.dir_tree.insert(works_node, "end",
text=f" 📋 {sub_tab} ({count}个)",
tags=("作品", sub_tab))

# === 2. "喜欢"标签页 ===
likes_items = self.tab_structure.get("喜欢", [])
if isinstance(likes_items, list) and likes_items:
self.dir_tree.insert(root_node, "end",
text=f"❤️ 喜欢 ({len(likes_items)}个)",
tags=("喜欢", ""))
elif isinstance(likes_items, dict):
likes_list = likes_items.get("items", [])
if likes_list:
self.dir_tree.insert(root_node, "end",
text=f"❤️ 喜欢 ({len(likes_list)}个)",
tags=("喜欢", ""))

# === 3. "推荐"标签页 ===
recommend_items = self.tab_structure.get("推荐", [])
if isinstance(recommend_items, list) and recommend_items:
self.dir_tree.insert(root_node, "end",
text=f"⭐ 推荐 ({len(recommend_items)}个)",
tags=("推荐", ""))
elif isinstance(recommend_items, dict):
recommend_list = recommend_items.get("items", [])
if recommend_list:
self.dir_tree.insert(root_node, "end",
text=f"⭐ 推荐 ({len(recommend_list)}个)",
tags=("推荐", ""))

# 默认选中"作品"主标签页
if works_node:
self.dir_tree.selection_set(works_node)
self.current_main_tab = "作品"
self.current_sub_tab = None
self.current_collection_id = None
self.current_collection_name = None
self.current_path_label.config(text="当前: 作品 (全部)")

def on_directory_select(self, event):
"""当目录树节点被选中时,切换显示对应标签页的作品"""
selection = self.dir_tree.selection()
if not selection:
return

item_id = selection[0]
item_tags = self.dir_tree.item(item_id, "tags")
item_text = self.dir_tree.item(item_id, "text")

if not item_tags:
return

main_tab = item_tags[0] if len(item_tags) > 0 else ""
sub_tab = item_tags[1] if len(item_tags) > 1 else ""
collection_id = item_tags[2] if len(item_tags) > 2 else None
collection_name = item_tags[3] if len(item_tags) > 3 else None

if main_tab.startswith("__"):
return

self.current_main_tab = main_tab
self.current_sub_tab = sub_tab if sub_tab else None
self.current_collection_id = collection_id
self.current_collection_name = collection_name

# 更新路径显示
path_text = main_tab
if sub_tab:
path_text += f" > {sub_tab}"
if collection_name and collection_name not in ["", "合集"]:
path_text += f" > {collection_name}"
self.current_path_label.config(text=f"当前: {path_text}")

# 获取要显示的作品列表
items_to_display = self.get_items_for_selection(main_tab, sub_tab, collection_id, collection_name)

# 更新右侧作品列表
self.clear_tree_items()
for item in items_to_display:
self.tree.insert("", "end", values=item)

self.log(f"📂 切换到: {path_text} (共 {len(items_to_display)} 个)\n")
self.update_selection_count()

def get_items_for_selection(self, main_tab, sub_tab, collection_id, collection_name=None):
"""根据选中的目录节点获取对应的作品列表"""
items = []

if main_tab == "作品":
works_data = self.tab_structure.get("作品", {})
if isinstance(works_data, dict):
if sub_tab == "合集" and collection_name and collection_name not in ["", "合集"]:
# 选中的是具体某个合集,只返回该合集的作品
collections = works_data.get("合集", {})
if isinstance(collections, dict):
if collection_name in collections:
coll_data = collections[collection_name]
if isinstance(coll_data, dict):
items = coll_data.get("items", [])
elif isinstance(coll_data, list):
items = coll_data
elif sub_tab == "合集":
# 选中"合集"节点,显示所有合集的作品
collections = works_data.get("合集", {})
if isinstance(collections, dict):
for coll_data in collections.values():
if isinstance(coll_data, dict):
items.extend(coll_data.get("items", []))
elif isinstance(coll_data, list):
items.extend(coll_data)
elif isinstance(collections, list):
items = collections
elif sub_tab and sub_tab in works_data:
# 选中具体子标签(作品、短片等)
sub_data = works_data[sub_tab]
if isinstance(sub_data, list):
items = sub_data
elif isinstance(sub_data, dict):
items = sub_data.get("items", [])
else:
# 选中"作品"主标签,显示所有子标签的作品
for key, sub_data in works_data.items():
if key == "合集":
collections = sub_data
if isinstance(collections, dict):
for coll_data in collections.values():
if isinstance(coll_data, dict):
items.extend(coll_data.get("items", []))
elif isinstance(coll_data, list):
items.extend(coll_data)
elif isinstance(collections, list):
items.extend(collections)
elif isinstance(sub_data, list):
items.extend(sub_data)
elif isinstance(sub_data, dict):
items.extend(sub_data.get("items", []))
elif main_tab == "喜欢":
likes_data = self.tab_structure.get("喜欢", [])
if isinstance(likes_data, list):
items = likes_data
elif isinstance(likes_data, dict):
items = likes_data.get("items", [])
elif main_tab == "推荐":
recommend_data = self.tab_structure.get("推荐", [])
if isinstance(recommend_data, list):
items = recommend_data
elif isinstance(recommend_data, dict):
items = recommend_data.get("items", [])

return items

def on_tree_item_double_click(self, event):
item_id = self.tree.identify_row(event.y)
if not item_id: return

current_values = list(self.tree.item(item_id, "values"))
if current_values[0].startswith("💤"): return

if current_values[0].startswith("✅"):
current_values[0] = "❌ 取消下载"
else:
current_values[0] = "✅ 勾选下载"

self.tree.item(item_id, values=current_values)
self.log(f" -> 已将序号 [{current_values[1]}] 的作品调整为 {current_values[0]}\n")
self.update_selection_count()

def on_tree_single_click(self, event):
"""单击第一列切换勾选状态"""
item_id = self.tree.identify_row(event.y)
column = self.tree.identify_column(event.x)

if column == "#1" and item_id:
current_values = list(self.tree.item(item_id, "values"))
if not current_values or current_values[0].startswith("💤"):
return

if "✅" in current_values[0]:
current_values[0] = "❌ 取消下载"
else:
current_values[0] = "✅ 勾选下载"

self.tree.item(item_id, values=current_values)
self.update_selection_count()

def select_all_items(self):
"""全选当前显示的所有作品(针对当前目录等级)"""
for item_id in self.tree.get_children():
current_values = list(self.tree.item(item_id, "values"))
if current_values and not current_values[0].startswith("💤"):
current_values[0] = "✅ 勾选下载"
self.tree.item(item_id, values=current_values)
self.update_selection_count()

path_text = self.current_path_label.cget("text").replace("当前: ", "")
self.log(f"💡 已全选 [{path_text}] 的所有作品\n")

def deselect_all_items(self):
"""取消全选当前显示的所有作品(针对当前目录等级)"""
for item_id in self.tree.get_children():
current_values = list(self.tree.item(item_id, "values"))
if current_values and not current_values[0].startswith("💤"):
current_values[0] = "❌ 取消下载"
self.tree.item(item_id, values=current_values)
self.update_selection_count()

path_text = self.current_path_label.cget("text").replace("当前: ", "")
self.log(f"💡 已取消 [{path_text}] 的所有选择\n")

def select_by_type(self, work_type):
"""按类型批量选择(针对当前目录等级)"""
for item_id in self.tree.get_children():
current_values = list(self.tree.item(item_id, "values"))
if current_values and not current_values[0].startswith("💤"):
if current_values[3] == work_type:
current_values[0] = "✅ 勾选下载"
else:
current_values[0] = "❌ 取消下载"
self.tree.item(item_id, values=current_values)
self.update_selection_count()

path_text = self.current_path_label.cget("text").replace("当前: ", "")
self.log(f"💡 在 [{path_text}] 中筛选所有{work_type}作品已勾选\n")

# ================== 阶段一:解析异步线程 ==================
def start_parse_thread(self):
raw_input = self.url_var.get().strip()
if not raw_input:
messagebox.showwarning("提示", "请输入有效的抖音分享内容!")
return
self.parse_btn.configure(state="disabled")
self.status_label.config(text="状态: ⏳ 正在识别目标主体...", foreground="#ffa500")
threading.Thread(target=self.execute_parse, args=(raw_input,), daemon=True).start()

def execute_parse(self, raw_input):
self.log_text.delete("1.0", tk.END)
self.log("[🚀 阶段 1] 启动链接多维特征解密...\n")

url_match = re.search(r"https?://[^\s]+", raw_input)
if not url_match:
self.root.after(0, lambda: messagebox.showwarning("错误", "未检测到有效网址!"))
self.root.after(0, lambda: self.parse_btn.configure(state="normal"))
return

short_url = url_match.group(0)
real_url = short_url
try:
req = urllib.request.Request(short_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=5) as response: real_url = response.geturl()
except:
pass

is_homepage = "user/" in real_url or "sec_uid" in real_url or "更多作品" in raw_input or "的主页" in raw_input

self.clear_tree_items()
self.clear_directory_tree()

if is_homepage:
self.root.after(0, lambda: self.mode_combo.current(0))
self.resolved_mode = "post"
self.tree.insert("", "end", values=("⏳ 待加载", "--", "主体锁定成功!请点击 [步骤2] 获取作品列表", "作者主页", "--", real_url))
self.dir_tree.insert("", "end", text="⏳ 作者主页已锁定...\n📁 作品\n 📹 作品\n 📚 合集\n 📱 短片\n❤️ 喜欢\n⭐ 推荐")
else:
self.root.after(0, lambda: self.mode_combo.current(4))
self.resolved_mode = "single"
self.tree.insert("", "end", values=("⏳ 待加载", "--", "主体锁定成功!请点击 [步骤2] 获取作品列表", "单条视频", "--", real_url))
self.dir_tree.insert("", "end", text="⏳ 单条视频已锁定...")

self.resolved_url = real_url
self.log("✨ 阶段 1 完成:目标主体类型已锁定!\n")
self.log(f"📎 解析后的真实链接: {real_url}\n")

self.root.after(0, lambda: self.parse_btn.configure(state="normal"))
self.root.after(0, lambda: self.fetch_list_btn.configure(state="normal"))
self.root.after(0, lambda: self.status_label.config(text="状态: 🟡 主体已锁定,请执行步骤 2", foreground="#ffa500"))

# ================== 阶段二:获取作品清单线程 ==================
def execute_fetch_list(self):
"""获取完整作品列表并按标签页结构分类"""
import re
raw_text = self.url_var.get().strip()
if not raw_text:
self.log("⚠️ 错误:请输入有效的抖音链接或分享文本!\n")
return

url_match = re.search(r'https?://[^\s]+', raw_text)
url = url_match.group(0) if url_match else raw_text

self.log(f"\n[🚀 阶段 2] 开始解析链接并调用内部核心引擎获取真实作品列表...")
self.log(f"过滤后纯净链接: {url}\n")

self.clear_tree_items()
self.status_label.config(text="⏳ 正在通过核心引擎抓取远程数据...", foreground="#3B82F6")

def worker():
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

all_items = loop.run_until_complete(self.async_fetch_all_tabs(url))
loop.close()

if all_items:
self.scanned_items = all_items

# 将数据转换为列表格式
self.all_formatted_items = []
for item in all_items:
self.all_formatted_items.append([
item.get("status", "✅ 勾选下载"),
item.get("idx", "01"),
item.get("title", "未命名作品"),
item.get("type", "视频"),
item.get("date", "未知时间"),
item.get("url", "")
])

# 按标签页和合集分类作品
self.categorize_by_tabs(all_items)

# 构建目录树
self.root.after(0, lambda: self.build_directory_tree_by_tabs())

# 默认显示"作品"标签页的全部作品
self.root.after(0, lambda: self.display_default_tab())

self.log(f"🎉 成功!共提取到 {len(all_items)} 个作品\n")

# 输出合集信息
works_data = self.tab_structure.get("作品", {})
if isinstance(works_data, dict):
collections = works_data.get("合集", {})
if isinstance(collections, dict) and collections:
self.log(f"\n📚 检测到的合集:\n")
for coll_name, coll_data in collections.items():
if isinstance(coll_data, dict):
coll_count = len(coll_data.get("items", []))
self.log(f" - {coll_name}: {coll_count} 个作品\n")

self.status_label.config(text="🟢 列表加载完成!请在左侧选择标签页或合集,勾选后点击下载", foreground="#10B981")

self.root.after(0, lambda: self.start_btn.config(state="normal"))
else:
self.log("⚠️ 未能提取到标准作品数据。\n")
self.status_label.config(text="⚠️ 未能加载到有效作品列表", foreground="#F59E0B")

except Exception as e:
import traceback
self.log(f"❌ 核心引擎对接发生故障: {str(e)}\n")
self.log(traceback.format_exc())
self.status_label.config(text="❌ 引擎对接失败", foreground="#EF4444")

import threading
threading.Thread(target=worker, daemon=True).start()

def categorize_by_tabs(self, all_items):
"""根据作品属性将其分类到不同的标签页和合集"""
self.tab_structure = {
"作品": {
"作品": [], # 普通作品
"合集": {}, # 合集字典,key为合集名称
"短片": [], # 短片作品
},
"喜欢": [],
"推荐": []
}

for item in all_items:
formatted_item = [
item.get("status", "✅ 勾选下载"),
item.get("idx", "01"),
item.get("title", "未命名作品"),
item.get("type", "视频"),
item.get("date", "未知时间"),
item.get("url", "")
]

# 检查是否属于某个合集
collection_info = item.get("collection_info")
if collection_info:
collection_name = collection_info.get("name", "未命名合集")
collection_id = collection_info.get("id", collection_name)

if collection_name not in self.tab_structure["作品"]["合集"]:
self.tab_structure["作品"]["合集"][collection_name] = {
"collection_id": collection_id,
"items": []
}
self.tab_structure["作品"]["合集"][collection_name]["items"].append(formatted_item)
elif item.get("is_short") or item.get("aweme_type") == 2:
self.tab_structure["作品"]["短片"].append(formatted_item)
else:
self.tab_structure["作品"]["作品"].append(formatted_item)

# 输出分类统计
self.log("\n📊 标签页分类结果:\n")
works_data = self.tab_structure["作品"]
normal_count = len(works_data["作品"])
short_count = len(works_data["短片"])
collections = works_data["合集"]

total_in_collections = 0
for coll_name, coll_data in collections.items():
coll_count = len(coll_data.get("items", []))
total_in_collections += coll_count
self.log(f" 📚 合集 - {coll_name}: {coll_count} 个\n")

total_works = normal_count + short_count + total_in_collections
self.log(f" 📁 作品总计: {total_works} 个\n")
self.log(f" 📹 普通作品: {normal_count} 个\n")
self.log(f" 📚 合集作品: {total_in_collections} 个 (共{len(collections)}个合集)\n")
self.log(f" 📱 短片: {short_count} 个\n")

def display_default_tab(self):
"""默认显示'作品'标签页的全部作品"""
self.clear_tree_items()

works_data = self.tab_structure.get("作品", {})
if isinstance(works_data, dict):
all_works = []
for key, sub_data in works_data.items():
if key == "合集":
collections = sub_data
if isinstance(collections, dict):
for coll_data in collections.values():
if isinstance(coll_data, dict):
all_works.extend(coll_data.get("items", []))
elif isinstance(coll_data, list):
all_works.extend(coll_data)
elif isinstance(sub_data, list):
all_works.extend(sub_data)
elif isinstance(sub_data, dict):
all_works.extend(sub_data.get("items", []))

for item in all_works:
self.tree.insert("", "end", values=item)

self.current_path_label.config(text="当前: 作品 (全部)")
self.update_selection_count()

async def async_fetch_all_tabs(self, url):
"""获取所有标签页的作品数据,包括合集信息"""
import os
import time
import inspect
from config import ConfigLoader
from auth import CookieManager
from core import DouyinAPIClient, URLParser
from utils.validators import is_short_url, normalize_short_url

config_path = self.config_file if os.path.exists(self.config_file) else "config.yml"
config = ConfigLoader(config_path)
cookie_manager = CookieManager()
cookie_manager.set_cookies(config.get_cookies())

all_items = []

async with DouyinAPIClient(cookie_manager.get_cookies(), proxy=config.get("proxy")) as api_client:
if is_short_url(url):
self.log("🔄 检测到短链接,正在还原...\n")
resolved_url = await api_client.resolve_short_url(normalize_short_url(url))
if resolved_url:
url = resolved_url

parsed = URLParser.parse(url)
if not parsed:
return []

sec_uid = parsed.get("sec_uid") or parsed.get("id") or parsed.get("unique_id")
self.log(f"📡 正在获取作者作品数据 (sec_uid: {sec_uid})...\n")

try:
# 获取"作品"标签页的数据(包含合集信息)
self.log("📡 正在获取「作品」标签页...\n")
post_items = await self.fetch_tab_data(api_client, "get_user_post", sec_uid, parsed)
if post_items:
all_items.extend(post_items)
self.log(f" ✅ 作品: {len(post_items)} 个\n")

# 尝试获取"喜欢"标签页的数据
try:
self.log("📡 正在尝试获取「喜欢」标签页...\n")
like_items = await self.fetch_tab_data(api_client, "get_user_like", sec_uid, parsed)
if like_items:
all_items.extend(like_items)
self.log(f" ✅ 喜欢: {len(like_items)} 个\n")
except Exception as e:
self.log(f" ⚠️ 喜欢标签页获取失败(可能需要登录): {e}\n")

except Exception as e:
self.log(f"❌ 获取作品数据失败: {e}\n")

return all_items

async def fetch_tab_data(self, api_client, method_name, sec_uid, parsed, max_pages=20):
"""获取指定标签页的数据,包括合集信息"""
import time
import inspect

items = []

if not hasattr(api_client, method_name):
return items

method_func = getattr(api_client, method_name)
sig = inspect.signature(method_func)

base_kwargs = {}
if "sec_uid" in sig.parameters:
base_kwargs["sec_uid"] = sec_uid
if "parsed" in sig.parameters:
base_kwargs["parsed"] = parsed

has_more = True
max_cursor = 0
page_index = 1

while has_more and page_index <= max_pages:
kwargs = base_kwargs.copy()
if "max_cursor" in sig.parameters:
kwargs["max_cursor"] = max_cursor
elif "cursor" in sig.parameters:
kwargs["cursor"] = max_cursor

try:
page_data = await method_func(**kwargs)

if not page_data:
break

# 提取作品列表
curr_list = []
if isinstance(page_data, list):
curr_list = page_data
elif isinstance(page_data, dict):
for k in ["aweme_list", "awemes", "data", "list", "items"]:
if k in page_data and isinstance(page_data[k], list):
curr_list = page_data[k]
break

if not curr_list:
break

# 格式化数据
for x in curr_list:
def get_val(obj, keys, default=""):
if isinstance(obj, dict):
for k in keys:
if k in obj: return obj[k]
else:
for k in keys:
if hasattr(obj, k): return getattr(obj, k)
return default

title = get_val(x, ["desc", "title", "aweme_id"]) or f"作品_{len(items)+1}"
aweme_id = get_val(x, ["aweme_id", "id"]) or str(len(items)+1)
images = get_val(x, ["images", "image_list"])
item_type = "图集" if (images or get_val(x, ["aweme_type"]) == 2) else "视频"
date_val = get_val(x, ["create_time", "pub_time"]) or "近期"
if isinstance(date_val, (int, float)):
date_val = time.strftime("%Y-%m-%d", time.localtime(date_val))
video_url = get_val(x, ["share_url", "video_url"]) or f"https://www.douyin.com/video/{aweme_id}"

# 合集信息检测
collection_info = None

# 检查多个可能的合集相关字段
collection_id = get_val(x, ["collection_id", "mix_id", "album_id"])
collection_name = get_val(x, ["collection_name", "mix_name", "album_name", "series_name"])
collection_episode = get_val(x, ["collection_episode", "mix_episode", "episode"])

if collection_id:
if not collection_name:
collection_name = f"合集_{collection_id[:8]}"
collection_info = {
"id": str(collection_id),
"name": str(collection_name),
"episode": str(collection_episode) if collection_episode else ""
}

# 检查mix_info字段
mix_info = get_val(x, ["mix_info", "collection_info"])
if mix_info and isinstance(mix_info, dict):
collection_info = {
"id": str(mix_info.get("mix_id", mix_info.get("id", ""))),
"name": str(mix_info.get("mix_name", mix_info.get("name", "未命名合集"))),
"episode": str(mix_info.get("episode", ""))
}

items.append({
"status": "✅ 勾选下载",
"idx": f"{len(items)+1:02d}",
"title": str(title).strip().replace("\n", " ")[:60],
"type": item_type,
"date": str(date_val),
"url": video_url,
"collection_info": collection_info,
"aweme_type": get_val(x, ["aweme_type"], 0),
"is_short": (get_val(x, ["aweme_type"], 0) == 2)
})

# 翻页控制
if isinstance(page_data, dict):
curr_has_more = page_data.get("has_more") or page_data.get("has_more_user_post", False)
curr_cursor = page_data.get("max_cursor") or page_data.get("cursor", 0)
else:
curr_has_more = getattr(page_data, "has_more", False)
curr_cursor = getattr(page_data, "max_cursor", 0)

has_more = bool(curr_has_more)
max_cursor = curr_cursor

if not has_more or not max_cursor:
break

page_index += 1
time.sleep(0.3)

except Exception as e:
self.log(f" ⚠️ 获取第{page_index}页数据时出错: {e}\n")
break

return items

# ================== 阶段三:提取勾选状态并唤醒同步 ==================
def start_download_thread(self):
self.start_btn.configure(state="disabled")
self.fetch_list_btn.configure(state="disabled")
self.parse_btn.configure(state="disabled")
self.status_label.config(text="状态: ⏳ 正在准备下载当前选择的作品...", foreground="#ffa500")
threading.Thread(target=self.execute_download, daemon=True).start()

def execute_download(self):
path_text = self.current_path_label.cget("text").replace("当前: ", "")
self.log(f"\n[🚀 阶段 3] 开始下载 [{path_text}] 中勾选的作品...\n")

selected_urls = []
selected_titles = []
total_displayed = 0

for item in self.tree.get_children():
values = self.tree.item(item, "values")
if values[0].startswith("💤"):
continue
total_displayed += 1
if values[0].startswith("✅"):
selected_urls.append(values[5])
selected_titles.append(values[2])

if not selected_urls:
self.root.after(0, lambda: messagebox.showwarning("提示", "请至少勾选一个作品!"))
self.reset_ui_status("🔴 未选择任何下载项")
return

self.log(f"📊 当前目录: {path_text},显示 {total_displayed} 个,勾选 {len(selected_urls)} 个\n")
for i, (url, title) in enumerate(zip(selected_urls, selected_titles), 1):
self.log(f" {i}. {title}\n {url}\n")

# ---------- 关键修复:保留原有配置 ----------
try:
# 1. 先读取现有配置(包含 cookies, path 等)
if os.path.exists(self.config_file):
with open(self.config_file, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
else:
config = {}

# 2. 只更新我们需要改变的字段
config['link'] = selected_urls
config['mode'] = ['single']
config['video'] = self.chk_video.get()
config['music'] = self.chk_audio.get()
config['cover'] = self.chk_image.get()
config['path'] = r"D:\Douzy Downloaded" # 👈 添加这一行。路径前面的 r 是为了防止反斜杠被当作转义字符,写成 r"D:\Douzy Downloaded" 或 "D:\\Douzy Downloaded" 都可以。确保 D:\Douzy Downloaded 这个文件夹已经存在,程序通常不会自动创建目录。
# 确保 number 结构存在
if 'number' not in config:
config['number'] = {}
config['number']['single'] = 1

# 3. 写回配置文件
with open(self.config_file, "w", encoding="utf-8") as f:
yaml.safe_dump(config, f, allow_unicode=True, default_flow_style=False)

self.log("✅ 配置文件已更新(cookies/path 等原有设置已保留)\n")
except Exception as e:
self.log(f"❌ 配置文件写入失败: {e}\n")
self.reset_ui_status("🔴 配置重写崩溃")
return

# 调用 run.py
self.log("⏳ 正在调用 run.py ...\n")
current_env = os.environ.copy()
current_env["PYTHONIOENCODING"] = "utf-8"

try:
process = subprocess.Popen(
["python", "run.py", "-c", self.config_file],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=current_env
)
# 实时输出 run.py 的日志
for line in process.stdout:
self.log(line)
process.wait()

if process.returncode == 0:
self.log(f"✅ run.py 执行完毕,返回码 0\n")
self.root.after(0, lambda: self.mark_downloaded_items())
self.reset_ui_status(f"✨ 下载完成!共 {len(selected_urls)} 个")
else:
self.log(f"❌ run.py 异常退出,返回码: {process.returncode}\n")
self.reset_ui_status("🔴 下载引擎异常,请检查上方日志")

except FileNotFoundError:
self.log("❌ 找不到 run.py,请将它放在程序根目录\n")
self.reset_ui_status("🔴 核心脚本缺失")
except Exception as e:
self.log(f"❌ 运行异常: {e}\n")
self.reset_ui_status("🔴 引擎突发性异常")




def mark_downloaded_items(self):
"""标记已下载的作品"""
for item in self.tree.get_children():
current_values = list(self.tree.item(item, "values"))
if current_values and current_values[0].startswith("✅"):
current_values[0] = "✅ 已下载"
self.tree.item(item, values=current_values)

def reset_ui_status(self, status_text):
self.start_btn.configure(state="normal")
self.fetch_list_btn.configure(state="normal")
self.parse_btn.configure(state="normal")
if "完成" in status_text or "圆满" in status_text:
self.status_label.config(text=status_text, foreground="#2ed573")
else:
self.status_label.config(text=status_text, foreground="#ff4757")

if __name__ == "__main__":
root = tk.Tk()
app = DouyinDownloaderApp(root)
root.mainloop()