mirror of
https://github.com/happycola233/tchMaterial-parser.git
synced 2026-10-23 10:22:26 +08:00
eb81e4acff
Signed-off-by: 晨叶梦春 <wuziqian211@126.com>
933 lines
58 KiB
Python
933 lines
58 KiB
Python
# -*- coding: utf-8 -*-
|
||
# 国家中小学智慧教育平台 资源下载工具 v3.3.2
|
||
# 项目地址:https://github.com/happycola233/tchMaterial-parser
|
||
# 作者:肥宅水水呀(https://space.bilibili.com/324042405)以及其他为本工具作出贡献的用户
|
||
|
||
VERSION = "v3.3.2"
|
||
|
||
# 导入相关库
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, filedialog
|
||
import tkinter.font as tkfont
|
||
import os, sys, platform
|
||
from functools import partial
|
||
import threading, psutil, tempfile, pyperclip
|
||
import base64, json, re, requests
|
||
from pypdf import PdfReader, PdfWriter
|
||
import traceback
|
||
|
||
def print_error(e: Exception) -> None: # 打印错误信息到控制台
|
||
if sys.stderr: # 无控制台运行时 sys.stderr 可能为 None
|
||
traceback.print_exception(e)
|
||
|
||
os_name = platform.system() # 获取操作系统类型
|
||
|
||
if os_name == "Windows": # 在 Windows 操作系统下,导入 Windows 相关库
|
||
try:
|
||
import win32print, win32gui, win32con, win32api, ctypes, winreg
|
||
except Exception as e:
|
||
print_error(e)
|
||
win32print = win32gui = win32con = win32api = ctypes = winreg = None
|
||
else:
|
||
win32print = win32gui = win32con = win32api = ctypes = winreg = None
|
||
|
||
def parse(url: str, bookmarks: bool) -> tuple[str, str, list] | tuple[None, None, None]: # 解析资源,获取资源下载链接
|
||
try:
|
||
# 1. 简单提取 URL 中的 contentId 与 contentType(为了减少导入的库,使用了不严谨的方法)
|
||
content_id: str | None = None
|
||
content_type: str | None = None
|
||
resource_url: str | None = None
|
||
chapters: list = []
|
||
|
||
for q in url[url.find("?") + 1:].split("&"):
|
||
if q.split("=")[0] == "contentId":
|
||
content_id = q.split("=")[1]
|
||
break
|
||
if not content_id:
|
||
return None, None, None
|
||
|
||
for q in url[url.find("?") + 1:].split("&"):
|
||
if q.split("=")[0] == "contentType":
|
||
content_type = q.split("=")[1]
|
||
break
|
||
if not content_type:
|
||
content_type = "assets_document"
|
||
|
||
# 2. 获取资源的信息
|
||
# 返回数据示例:
|
||
"""
|
||
{
|
||
"id": "4f64356a-8df7-4579-9400-e32c9a7f6718",
|
||
// ...
|
||
"ti_items": [
|
||
{
|
||
"ti_md5": "497110473b106d28651c41c14aa6d942",
|
||
"ti_size": 13075391,
|
||
"ti_storage": "cs_path:${ref-path}/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/义务教育教科书 语文 八年级 上册_1756191813436.pdf", // 资源文件地址
|
||
"ti_storages": [
|
||
"https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/义务教育教科书 语文 八年级 上册_1756191813436.pdf",
|
||
"https://r2-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/义务教育教科书 语文 八年级 上册_1756191813436.pdf",
|
||
"https://r3-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/义务教育教科书 语文 八年级 上册_1756191813436.pdf"
|
||
],
|
||
"ti_file_flag": "source",
|
||
"ti_is_source_file": true,
|
||
// ...
|
||
"ti_format": "pdf",
|
||
// ...
|
||
},
|
||
{
|
||
// ...(和上一个元素组成一样)
|
||
}
|
||
],
|
||
// ...
|
||
"title": "(根据2022年版课程标准修订)义务教育教科书·语文八年级上册",
|
||
// ...
|
||
}
|
||
"""
|
||
# 其中 $.ti_items 的每一项对应一个资源
|
||
|
||
if re.search(r"^https?://([^/]+)/syncClassroom/basicWork/detail", url): # 对基础性作业的解析
|
||
response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/resources/details/{content_id}.json")
|
||
else: # 对课本的解析
|
||
if content_type == "thematic_course": # 对专题课程(含电子课本、视频等)的解析
|
||
response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/resources/details/{content_id}.json")
|
||
else: # 对普通电子课本的解析
|
||
response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{content_id}.json")
|
||
|
||
data = response.json()
|
||
title = data.get("title")
|
||
|
||
# 3. 获取资源下载链接
|
||
for item in data["ti_items"]: # 寻找存有资源链接列表的项
|
||
if item["ti_is_source_file"]: # 获取并构造资源的 URL
|
||
resource_url = item.get("ti_storage")
|
||
if resource_url:
|
||
resource_url = resource_url.replace("cs_path:${ref-path}", "https://r1-ndr-private.ykt.cbern.com.cn" if access_token else "https://c1.ykt.cbern.com.cn")
|
||
else:
|
||
resource_url = next((url for url in item["ti_storages"] if url), None)
|
||
if not resource_url:
|
||
continue
|
||
if not access_token: # 未登录时,通过一个不可靠的方法构造可直接下载的 URL
|
||
resource_url = re.sub(r"^https?://(?:.+).ykt.cbern.com.cn/(.+)$", r"https://c1.ykt.cbern.com.cn/\1", resource_url)
|
||
break
|
||
|
||
if not resource_url:
|
||
if content_type == "thematic_course": # 专题课程
|
||
resources_resp = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/thematic_course/{content_id}/resources/list.json")
|
||
resources_data = resources_resp.json()
|
||
outer_break = False
|
||
for resource in resources_data:
|
||
if resource["resource_type_code"] == "assets_document": # 在资源列表中选择文档
|
||
for item in resource["ti_items"]: # 寻找存有资源链接列表的项
|
||
if item["ti_is_source_file"]:
|
||
resource_url = item.get("ti_storage")
|
||
if resource_url:
|
||
resource_url = resource_url.replace("cs_path:${ref-path}", "https://r1-ndr-private.ykt.cbern.com.cn" if access_token else "https://c1.ykt.cbern.com.cn")
|
||
else:
|
||
resource_url = next((url for url in item["ti_storages"] if url), None)
|
||
if not resource_url:
|
||
continue
|
||
if not access_token:
|
||
resource_url = re.sub(r"^https?://(?:.+).ykt.cbern.com.cn/(.+)$", r"https://c1.ykt.cbern.com.cn/\1", resource_url)
|
||
outer_break = True # 跳出外层循环
|
||
break
|
||
if outer_break:
|
||
break
|
||
if not resource_url:
|
||
return None, None, None
|
||
else:
|
||
return None, None, None
|
||
|
||
# 4. 通过 ebook_mapping + tree 接口组合获取章节目录
|
||
if bookmarks:
|
||
try:
|
||
mapping_url: str | None = None
|
||
for item in data["ti_items"]:
|
||
if item["ti_file_flag"] == "ebook_mapping":
|
||
mapping_url = item.get("ti_storage") # 形如 https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/*.pkg/ebook_mapping.txt
|
||
if mapping_url:
|
||
mapping_url = mapping_url.replace("cs_path:${ref-path}", "https://r1-ndr-private.ykt.cbern.com.cn")
|
||
else:
|
||
mapping_url = next((url for url in item["ti_storages"] if url), None)
|
||
break
|
||
|
||
if mapping_url:
|
||
# a. 下载 mapping 文件获取页码和 ebook_id
|
||
map_resp = session.get(mapping_url)
|
||
map_data = map_resp.json()
|
||
ebook_id = map_data.get("ebook_id")
|
||
|
||
# 构建 node_id 到 page_number 的映射字典
|
||
# 格式: [{ "node_id": "...", "page_number": 1 }, ...]
|
||
page_map = []
|
||
if map_data.get("mappings"):
|
||
for m in map_data["mappings"]:
|
||
page_map.append({ "node_id": m["node_id"], "page_number": m.get("page_number", 1) })
|
||
|
||
# b. 如果有 ebook_id,在课程接口下载完整的目录树(tree API)
|
||
if ebook_id:
|
||
tree_resp = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrv2/national_lesson/trees/{ebook_id}.json", headers=headers)
|
||
tree_data = tree_resp.json()
|
||
|
||
# 递归函数:合并 tree 的标题和 mapping 的页码
|
||
def process_tree_nodes(nodes):
|
||
result = []
|
||
for node in nodes:
|
||
# 从 page_map 中找页码,找不到为 None
|
||
page_num = next((m["page_number"] for m in page_map if m["node_id"] == node["id"]), None)
|
||
chapter_item = {
|
||
"title": node["title"],
|
||
"page_index": page_num
|
||
}
|
||
|
||
# 如果有子节点,递归处理
|
||
if node.get("child_nodes"):
|
||
chapter_item["children"] = process_tree_nodes(node["child_nodes"])
|
||
|
||
result.append(chapter_item)
|
||
return result
|
||
|
||
# 开始解析
|
||
if isinstance(tree_data, list):
|
||
chapters = process_tree_nodes(tree_data)
|
||
elif isinstance(tree_data, dict) and tree_data.get("child_nodes"):
|
||
chapters = process_tree_nodes(tree_data["child_nodes"])
|
||
|
||
# c. 兜底方案:如果获取 tree 失败,仅使用 mapping 生成纯页码索引
|
||
if not chapters:
|
||
page_map.sort(key=lambda x: x["page_number"])
|
||
for i, m in enumerate(page_map):
|
||
chapters.append({
|
||
"title": f"第 {i+1} 节 (P{m['page_number']})",
|
||
"page_index": m["page_number"]
|
||
})
|
||
|
||
except Exception as e:
|
||
print_error(e)
|
||
chapters = []
|
||
|
||
return resource_url, title, chapters
|
||
|
||
except Exception as e:
|
||
print_error(e)
|
||
return None, None, None
|
||
|
||
def parse_and_copy() -> None: # 解析并复制链接
|
||
urls = [line.strip() for line in url_text.get("1.0", tk.END).splitlines() if line.strip()] # 获取所有非空行
|
||
resource_links = []
|
||
failed_links = []
|
||
|
||
for url in urls:
|
||
resource_url = parse(url, False)[0]
|
||
if not resource_url:
|
||
failed_links.append(url) # 添加到失败链接
|
||
continue
|
||
resource_links.append(resource_url)
|
||
|
||
if failed_links:
|
||
messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join(failed_links))
|
||
|
||
if resource_links:
|
||
pyperclip.copy("\n".join(resource_links)) # 将链接复制到剪贴板
|
||
messagebox.showinfo("提示", "资源链接已复制到剪贴板")
|
||
|
||
def download() -> None: # 下载资源文件
|
||
global download_states
|
||
download_btn.config(state="disabled") # 设置下载按钮为禁用状态
|
||
download_states = [] # 初始化下载状态
|
||
urls = [{ "url": line.strip(), "invalid": False } for line in url_text.get("1.0", tk.END).splitlines() if line.strip()] # 获取所有非空行
|
||
|
||
if len(urls) > 1:
|
||
messagebox.showinfo("提示", "您选择了多个链接,将在选定的文件夹中使用教材名称作为文件名进行下载。")
|
||
dir_path = filedialog.askdirectory() # 选择文件夹
|
||
if not dir_path: # 用户取消或关闭对话框
|
||
download_btn.config(state="normal") # 恢复下载按钮为启用状态
|
||
return
|
||
dir_path = os.path.normpath(dir_path)
|
||
else:
|
||
dir_path = None
|
||
|
||
for url in urls:
|
||
resource_url, title, chapters = parse(url["url"], bookmark_var.get())
|
||
if not resource_url:
|
||
url["invalid"] = True
|
||
continue
|
||
|
||
default_filename = title or "download"
|
||
if dir_path:
|
||
save_path = os.path.join(dir_path, f"{default_filename}.pdf") # 构造完整路径
|
||
else:
|
||
save_path = filedialog.asksaveasfilename(defaultextension=".pdf", filetypes=[("PDF 文件", "*.pdf"), ("所有文件", "*.*")], initialfile = default_filename) # 选择保存路径
|
||
if not save_path: # 用户取消了文件保存操作
|
||
download_btn.config(state="normal") # 恢复下载按钮为启用状态
|
||
return
|
||
save_path = os.path.normpath(save_path)
|
||
|
||
thread_it(download_file, (resource_url, save_path, chapters)) # 开始下载(多线程,防止窗口卡死)
|
||
|
||
if any(url["invalid"] for url in urls):
|
||
messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join([url["url"] for url in urls if url["invalid"]])) # 显示警告对话框
|
||
|
||
if all(url["invalid"] for url in urls):
|
||
download_btn.config(state="normal") # 设置下载按钮为启用状态
|
||
|
||
def download_file(url: str, save_path: str, chapters: list | None = None) -> None: # 下载文件
|
||
current_state = { "download_url": url, "save_path": save_path, "downloaded_size": 0, "total_size": 0, "finished": False, "failed_reason": None }
|
||
download_states.append(current_state)
|
||
|
||
try:
|
||
response = session.get(url, headers=headers, stream=True)
|
||
|
||
if not response.ok: # 服务器返回表示错误的 HTTP 状态码
|
||
current_state["finished"] = True
|
||
current_state["failed_reason"] = f"服务器返回 HTTP 状态码 {response.status_code}" + ",Access Token 可能已过期或无效,请重新设置" if response.status_code == 401 or response.status_code == 403 else ""
|
||
else:
|
||
current_state["total_size"] = int(response.headers.get("Content-Length", 0))
|
||
|
||
with open(save_path, "wb") as file:
|
||
for chunk in response.iter_content( # 分块下载
|
||
chunk_size=131072 if current_state["total_size"] < 20971520 else 262144 if current_state["total_size"] < 52428800 else 524288
|
||
):
|
||
file.write(chunk)
|
||
current_state["downloaded_size"] += len(chunk)
|
||
all_downloaded_size = sum(state["downloaded_size"] for state in download_states)
|
||
all_total_size = sum(state["total_size"] for state in download_states)
|
||
downloaded_number = len([state for state in download_states if state["finished"]])
|
||
total_number = len(download_states)
|
||
|
||
if all_total_size > 0: # 防止下面一行代码除以 0 而报错
|
||
download_progress = (all_downloaded_size / all_total_size) * 100
|
||
ui_call(download_progress_bar.config, value=download_progress) # 更新进度条
|
||
ui_call(progress_label.config, text=f"{format_bytes(all_downloaded_size)}/{format_bytes(all_total_size)} ({download_progress:.2f}%) 已下载 {downloaded_number}/{total_number}") # 更新标签以显示当前下载进度
|
||
|
||
current_state["downloaded_size"] = current_state["total_size"]
|
||
|
||
if chapters: # 添加书签
|
||
ui_call(progress_label.config, text="添加书签")
|
||
add_bookmarks(save_path, chapters)
|
||
|
||
current_state["finished"] = True
|
||
|
||
except Exception as e:
|
||
print_error(e)
|
||
current_state["downloaded_size"], current_state["total_size"] = 0, 0
|
||
current_state["finished"] = True
|
||
current_state["failed_reason"] = traceback.format_exc().rstrip()
|
||
|
||
if all(state["finished"] for state in download_states): # 所有文件下载完成
|
||
ui_call(download_progress_bar.config, value=0) # 重置进度条
|
||
ui_call(progress_label.config, text="等待下载") # 清空进度标签
|
||
ui_call(download_btn.config, state="normal") # 设置下载按钮为启用状态
|
||
|
||
failed_states = [state for state in download_states if state["failed_reason"]]
|
||
if failed_states: # 存在下载失败的文件
|
||
failed_message = "\n\n".join(
|
||
f"{state['download_url']}\n{state['failed_reason']}"
|
||
for state in failed_states
|
||
)
|
||
ui_call(
|
||
messagebox.showwarning,
|
||
"下载完成",
|
||
f"文件已下载到:{os.path.dirname(save_path)}\n以下文件下载失败:\n{failed_message}",
|
||
)
|
||
else:
|
||
ui_call(messagebox.showinfo, "下载完成", f"文件已下载到:{os.path.dirname(save_path)}")
|
||
|
||
def format_bytes(size: float) -> str: # 将数据单位进行格式化,返回以 KB、MB、GB、TB、PB 为单位的数据大小
|
||
for x in ["字节", "KB", "MB", "GB", "TB"]:
|
||
if size < 1024.0:
|
||
return f"{size:3.1f} {x}"
|
||
size /= 1024.0
|
||
return f"{size:3.1f} PB"
|
||
|
||
def add_bookmarks(pdf_path: str, chapters: list) -> None: # 给 PDF 添加书签
|
||
try:
|
||
if not chapters:
|
||
return
|
||
reader = PdfReader(pdf_path)
|
||
writer = PdfWriter()
|
||
writer.append_pages_from_reader(reader)
|
||
|
||
def add_chapter(chapter_list, parent=None): # 递归添加书签的内部函数
|
||
for chapter in chapter_list:
|
||
title = chapter.get("title", "未知章节")
|
||
p_index = chapter.get("page_index")
|
||
if p_index is None: # 如果值为 None 或者不存在,跳过这个书签
|
||
print_error(ValueError(f"章节 “{title}” 的页码索引无效,已跳过此处书签添加"))
|
||
continue
|
||
|
||
try: # 尝试将其转为整数并减 1(pypdf 页码从 0 开始)
|
||
page_num = int(p_index) - 1
|
||
except (ValueError, TypeError) as e: # 如果转换失败,跳过这个书签
|
||
print_error(e)
|
||
continue
|
||
|
||
if page_num < 0 or page_num >= len(writer.pages):
|
||
continue
|
||
|
||
# 添加书签,其中 parent 是父级书签对象,用于处理多级目录
|
||
bookmark = writer.add_outline_item(title, page_num, parent=parent)
|
||
|
||
# 如果有子章节(children),递归添加
|
||
if chapter.get("children"):
|
||
add_chapter(chapter["children"], parent=bookmark)
|
||
|
||
# 开始处理章节数据
|
||
add_chapter(chapters)
|
||
|
||
# 保存修改后的文件
|
||
with open(pdf_path, "wb") as f:
|
||
writer.write(f)
|
||
|
||
except Exception as e:
|
||
print_error(e)
|
||
|
||
def show_access_token_window() -> None: # 打开输入 Access Token 的窗口
|
||
token_window = tk.Toplevel(root)
|
||
token_window.title("设置 Access Token")
|
||
# 让窗口自动根据控件自适应尺寸;如需最小尺寸可用 token_window.minsize(...)
|
||
|
||
token_window.focus_force() # 自动获得焦点
|
||
token_window.grab_set() # 阻止主窗口操作
|
||
token_window.bind("<Escape>", lambda event: token_window.destroy()) # 绑定 Esc 键关闭窗口
|
||
|
||
# 设置一个 Frame 用于留白,使布局更美观
|
||
frame = ttk.Frame(token_window, padding=20)
|
||
frame.pack(fill="both", expand=True)
|
||
|
||
# 提示文本
|
||
label = ttk.Label(frame, text="请粘贴从浏览器获取的 Access Token:", font=(ui_font_family, 10))
|
||
label.pack(pady=5)
|
||
|
||
# 创建多行 Text
|
||
token_text = tk.Text(frame, width=50, height=4, wrap="word", font=(ui_font_family, 9))
|
||
token_text.pack(pady=5)
|
||
|
||
# 若已存在全局 token,则填入
|
||
if access_token:
|
||
token_text.insert("1.0", access_token)
|
||
|
||
# 创建右键菜单,支持剪切、复制、粘贴
|
||
token_context_menu = tk.Menu(token_text, tearoff=0)
|
||
token_context_menu.add_command(label="剪切 (Ctrl+X)", command=lambda: token_text.event_generate("<<Cut>>"))
|
||
token_context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: token_text.event_generate("<<Copy>>"))
|
||
token_context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: token_text.event_generate("<<Paste>>"))
|
||
|
||
# 绑定右键点击事件
|
||
def show_token_menu(event):
|
||
token_context_menu.post(event.x_root, event.y_root)
|
||
token_context_menu.bind("<FocusOut>", lambda e: token_context_menu.unpost())
|
||
root.bind("<Button-1>", lambda e: token_context_menu.unpost(), add="+")
|
||
|
||
token_text.bind("<Button-3>", show_token_menu)
|
||
if os_name == "Darwin":
|
||
token_text.bind("<Control-Button-1>", show_token_menu)
|
||
token_text.bind("<Button-2>", show_token_menu)
|
||
|
||
# 按下 Enter 键,保存 Access Token,并屏蔽换行事件
|
||
def return_save_token(event):
|
||
save_token()
|
||
return "break"
|
||
|
||
token_text.bind("<Return>", return_save_token)
|
||
token_text.bind("<Shift-Return>", lambda e: "break") # 按下 Shift+Enter 也不换行,直接屏蔽
|
||
|
||
# 保存按钮
|
||
def save_token():
|
||
user_token = token_text.get("1.0", tk.END).strip()
|
||
tip_info = set_access_token(user_token)
|
||
download_btn.config(state="normal") # 重新启用下载按钮
|
||
messagebox.showinfo("保存成功", tip_info)
|
||
|
||
token_window.destroy()
|
||
|
||
save_btn = ttk.Button(frame, text="保存", command=save_token)
|
||
save_btn.pack(pady=5)
|
||
|
||
# 帮助按钮
|
||
def show_token_help():
|
||
help_win = tk.Toplevel(token_window)
|
||
help_win.title("获取 Access Token 方法")
|
||
|
||
help_win.focus_force() # 自动获得焦点
|
||
help_win.grab_set() # 阻止主窗口操作
|
||
help_win.bind("<Escape>", lambda event: help_win.destroy()) # 绑定 Esc 键关闭窗口
|
||
|
||
help_frame = ttk.Frame(help_win, padding=20)
|
||
help_frame.pack(fill="both", expand=True)
|
||
|
||
help_text = """\
|
||
国家中小学智慧教育平台需要登录后才可获取教材,因此要使用本程序下载教材,您需要在平台内登录账号(如没有需注册),然后获得登录凭据(Access Token)。本程序仅保存该凭据至本地。
|
||
|
||
获取方法如下:
|
||
1. 打开浏览器,访问国家中小学智慧教育平台(https://auth.smartedu.cn/uias/login)并登录账号。
|
||
2. 按下 F12 或 Ctrl+Shift+I,或右键——检查(审查元素)打开开发者工具,选择控制台(Console)。
|
||
3. 在控制台粘贴以下代码后回车(Enter):
|
||
---------------------------------------------------------
|
||
(function() {
|
||
const authKey = Object.keys(localStorage).find(key => key.startsWith("ND_UC_AUTH"));
|
||
if (!authKey) {
|
||
console.error("未找到 Access Token,请确保已登录!");
|
||
return;
|
||
}
|
||
const tokenData = JSON.parse(localStorage.getItem(authKey));
|
||
const accessToken = JSON.parse(tokenData.value).access_token;
|
||
console.log("%cAccess Token:", "color: green; font-weight: bold", accessToken);
|
||
})();
|
||
---------------------------------------------------------
|
||
然后在控制台输出中即可看到 Access Token。将其复制后粘贴到本程序中。"""
|
||
|
||
# 只读文本区,支持选择复制
|
||
txt = tk.Text(help_frame, wrap="word", font=(ui_font_family, 9))
|
||
txt.insert("1.0", help_text)
|
||
txt.config(state="disabled")
|
||
txt.pack(fill="both", expand=True)
|
||
|
||
# 同样可给帮助文本区绑定右键菜单
|
||
help_menu = tk.Menu(txt, tearoff=0)
|
||
help_menu.add_command(label="复制 (Ctrl+C)", command=lambda: txt.event_generate("<<Copy>>"))
|
||
def show_help_menu(event):
|
||
help_menu.post(event.x_root, event.y_root)
|
||
help_menu.bind("<FocusOut>", lambda e: help_menu.unpost())
|
||
root.bind("<Button-1>", lambda e: help_menu.unpost(), add="+")
|
||
|
||
txt.bind("<Button-3>", show_help_menu)
|
||
if os_name == "Darwin":
|
||
txt.bind("<Control-Button-1>", show_help_menu)
|
||
txt.bind("<Button-2>", show_help_menu)
|
||
|
||
help_btn = ttk.Button(frame, text="如何获取?", command=show_token_help)
|
||
help_btn.pack(pady=5)
|
||
|
||
# 让弹窗居中
|
||
token_window.update_idletasks()
|
||
w = token_window.winfo_width()
|
||
h = token_window.winfo_height()
|
||
ws = token_window.winfo_screenwidth()
|
||
hs = token_window.winfo_screenheight()
|
||
x = (ws // 2) - (w // 2)
|
||
y = (hs // 2) - (h // 2)
|
||
token_window.geometry(f"{w}x{h}+{x}+{y}")
|
||
token_window.lift() # 置顶可见
|
||
|
||
def load_access_token() -> None: # 读取本地存储的 Access Token
|
||
global access_token
|
||
try:
|
||
if os_name == "Windows": # 在 Windows 上,从注册表读取
|
||
if not winreg:
|
||
return
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\tchMaterial-parser", 0, winreg.KEY_READ) as key:
|
||
token, _ = winreg.QueryValueEx(key, "AccessToken")
|
||
if token:
|
||
access_token = token
|
||
# 更新请求头
|
||
headers["X-ND-AUTH"] = f'MAC id="{access_token}",nonce="0",mac="0"'
|
||
elif os_name == "Linux": # 在 Linux 上,从 ~/.config/tchMaterial-parser/data.json 文件读取
|
||
# 构建文件路径
|
||
target_file = os.path.join(
|
||
os.path.expanduser("~"), # 获取当前用户主目录
|
||
".config",
|
||
"tchMaterial-parser",
|
||
"data.json"
|
||
)
|
||
if not os.path.exists(target_file): # 文件不存在则不做处理
|
||
return
|
||
|
||
# 读取 JSON 文件
|
||
with open(target_file, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
# 提取 access_token 字段
|
||
access_token = data["access_token"]
|
||
elif os_name == "Darwin": # 在 macOS 上,从 ~/Library/Application Support/tchMaterial-parser/data.json 文件读取
|
||
target_file = os.path.join(
|
||
os.path.expanduser("~"),
|
||
"Library",
|
||
"Application Support",
|
||
"tchMaterial-parser",
|
||
"data.json"
|
||
)
|
||
if not os.path.exists(target_file):
|
||
return
|
||
|
||
with open(target_file, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
access_token = data["access_token"]
|
||
|
||
except Exception:
|
||
pass # 读取失败则不做处理
|
||
|
||
def set_access_token(token: str) -> str: # 设置并更新 Access Token
|
||
global access_token
|
||
access_token = token
|
||
headers["X-ND-AUTH"] = f'MAC id="{access_token}",nonce="0",mac="0"'
|
||
|
||
try:
|
||
if os_name == "Windows": # 在 Windows 上,将 Access Token 写入注册表
|
||
if not winreg:
|
||
return "Access Token 已保存!"
|
||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Software\\tchMaterial-parser") as key:
|
||
winreg.SetValueEx(key, "AccessToken", 0, winreg.REG_SZ, token)
|
||
return "Access Token 已保存!\n已写入注册表:HKEY_CURRENT_USER\\Software\\tchMaterial-parser\\AccessToken"
|
||
elif os_name == "Linux": # 在 Linux 上,将 Access Token 保存至 ~/.config/tchMaterial-parser/data.json 文件中
|
||
# 构建目标目录和文件路径
|
||
target_dir = os.path.join(
|
||
os.path.expanduser("~"),
|
||
".config",
|
||
"tchMaterial-parser"
|
||
)
|
||
target_file = os.path.join(target_dir, "data.json")
|
||
# 创建目录(如果不存在)
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
|
||
# 构建要保存的数据字典
|
||
data = { "access_token": token }
|
||
# 写入 JSON 文件
|
||
with open(target_file, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=4)
|
||
|
||
return "Access Token 已保存!\n已写入文件:~/.config/tchMaterial-parser/data.json"
|
||
elif os_name == "Darwin": # 在 macOS 上,将 Access Token 保存至 ~/Library/Application Support/tchMaterial-parser/data.json 文件中
|
||
target_dir = os.path.join(
|
||
os.path.expanduser("~"),
|
||
"Library",
|
||
"Application Support",
|
||
"tchMaterial-parser"
|
||
)
|
||
target_file = os.path.join(target_dir, "data.json")
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
|
||
data = { "access_token": token }
|
||
with open(target_file, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=4)
|
||
|
||
return "Access Token 已保存!\n已写入文件:~/Library/Application Support/tchMaterial-parser/data.json"
|
||
else:
|
||
return "Access Token 已保存!\n本工具尚未支持该操作系统下 Access Token 的持久化,下次启动时仍需手动输入 Access Token。"
|
||
|
||
except Exception as e:
|
||
print_error(e)
|
||
return "Access Token 已保存!\n因出现错误而无法持久化,下次启动时仍需手动输入 Access Token。"
|
||
|
||
class resource_helper: # 获取网站上资源的数据
|
||
def parse_hierarchy(self, hierarchy) -> dict: # 解析层级数据
|
||
if not hierarchy: # 如果没有层级数据,返回空字典
|
||
return {}
|
||
|
||
parsed = {}
|
||
for h in hierarchy:
|
||
for ch in h["children"]:
|
||
parsed[ch["tag_id"]] = { "display_name": ch["tag_name"], "children": self.parse_hierarchy(ch["hierarchies"]) }
|
||
return parsed
|
||
|
||
def fetch_book_list(self): # 获取课本列表
|
||
# 获取电子课本层级数据
|
||
tags_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/tags/tch_material_tag.json")
|
||
tags_data = tags_resp.json()
|
||
parsed_hier = self.parse_hierarchy(tags_data["hierarchies"])
|
||
|
||
# 获取电子课本 URL 列表
|
||
list_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/resources/tch_material/version/data_version.json")
|
||
list_data: list[str] = list_resp.json()["urls"].split(",")
|
||
|
||
# 获取电子课本列表
|
||
for url in list_data:
|
||
book_resp = session.get(url)
|
||
book_data: list[dict] = book_resp.json()
|
||
for book in book_data:
|
||
if book.get("tag_paths"): # 某些非课本资料的 tag_paths 属性为空数组
|
||
# 解析课本层级数据
|
||
tag_paths: list[str] = book["tag_paths"][0].split("/")[2:] # 电子课本 tag_paths 的前两项为“教材”、“电子教材”
|
||
|
||
# 如果课本层级数据不在层级数据中,跳过
|
||
temp_hier = parsed_hier[book["tag_paths"][0].split("/")[1]]
|
||
if not tag_paths[0] in temp_hier["children"]:
|
||
continue
|
||
|
||
# 分别解析课本层级
|
||
for p in tag_paths:
|
||
if temp_hier["children"] and temp_hier["children"].get(p):
|
||
temp_hier = temp_hier["children"].get(p)
|
||
if not temp_hier["children"]:
|
||
temp_hier["children"] = {}
|
||
|
||
book["display_name"] = book["title"] if "title" in book else book["name"] if "name" in book else f"(未知电子课本 {book['id']})"
|
||
|
||
temp_hier["children"][book["id"]] = book
|
||
|
||
return parsed_hier
|
||
|
||
def fetch_lesson_list(self): # 获取课件列表
|
||
# 获取课件层级数据
|
||
tags_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/tags/national_lesson_tag.json")
|
||
tags_data = tags_resp.json()
|
||
parsed_hier = self.parse_hierarchy([{ "children": [{ "tag_id": "__internal_national_lesson", "hierarchies": tags_data["hierarchies"], "tag_name": "课件资源" }] }])
|
||
|
||
# 获取课件 URL 列表
|
||
list_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/national_lesson/teachingmaterials/version/data_version.json")
|
||
list_data: list[str] = list_resp.json()["urls"]
|
||
|
||
# 获取课件列表
|
||
for url in list_data:
|
||
lesson_resp = session.get(url)
|
||
lesson_data: list[dict] = lesson_resp.json()
|
||
for lesson in lesson_data:
|
||
if lesson.get("tag_list"):
|
||
# 解析课件层级数据
|
||
tag_paths: list[str] = [tag["tag_id"] for tag in sorted(lesson["tag_list"], key=lambda tag: tag["order_num"])]
|
||
|
||
# 分别解析课件层级(tag_paths 为乱序)
|
||
def parse_tag_path(hier):
|
||
for p in tag_paths:
|
||
if hier["children"] and hier["children"].get(p):
|
||
return parse_tag_path(hier["children"].get(p))
|
||
return hier
|
||
|
||
hier = parse_tag_path(parsed_hier["__internal_national_lesson"])
|
||
if not hier["children"]:
|
||
hier["children"] = {}
|
||
|
||
lesson["display_name"] = lesson["title"] if "title" in lesson else lesson["name"] if "name" in lesson else f"(未知课件 {lesson['id']})"
|
||
|
||
hier["children"][lesson["id"]] = lesson
|
||
|
||
return parsed_hier
|
||
|
||
def fetch_resource_list(self): # 获取资源列表
|
||
book_hier = self.fetch_book_list()
|
||
# lesson_hier = self.fetch_lesson_list()
|
||
return { **book_hier }
|
||
|
||
def thread_it(func, args: tuple = ()) -> None: # 打包函数到线程
|
||
t = threading.Thread(target=func, args=args)
|
||
# t.daemon = True
|
||
t.start()
|
||
|
||
def ui_call(func, *args, **kwargs) -> None: # 在主线程执行 Tkinter UI 更新
|
||
root.after(0, lambda: func(*args, **kwargs))
|
||
|
||
def pick_ui_font_family() -> str: # 选择一个合适的字体
|
||
try:
|
||
available = set(tkfont.families(root)) # 获取所有字体的列表
|
||
except Exception:
|
||
return "TkDefaultFont"
|
||
|
||
for name in ("Microsoft YaHei UI", "微软雅黑", "PingFang SC", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "Arial Unicode MS"): # 在这些字体中选择一个可用的字体
|
||
if name in available:
|
||
return name
|
||
|
||
try: # 若上述字体都不可用,则返回默认字体
|
||
return tkfont.nametofont("TkDefaultFont").actual("family")
|
||
except Exception:
|
||
return "TkDefaultFont"
|
||
|
||
session = requests.Session() # 初始化请求
|
||
download_states = [] # 初始化下载状态
|
||
access_token = None
|
||
headers = { "X-ND-AUTH": 'MAC id="0",nonce="0",mac="0"' } # 设置请求头部,包含认证信息,其中 “MAC id” 即为 Access Token,“nonce” 和 “mac” 不可缺省但可为任意非空值
|
||
session.proxies = {} # 全局忽略代理
|
||
|
||
scale: float | None = None
|
||
|
||
# 在 Windows 上进行高 DPI 适配
|
||
if os_name == "Windows" and win32print and win32gui and win32con and win32api and ctypes:
|
||
scale = round(win32print.GetDeviceCaps(win32gui.GetDC(0), win32con.DESKTOPHORZRES) / win32api.GetSystemMetrics(0), 2) # 获取当前的缩放因子
|
||
|
||
# 调用 API 设置成由应用程序缩放
|
||
try: # Windows 8.1 或更新
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||
except Exception: # Windows 8 或更老
|
||
ctypes.windll.user32.SetProcessDPIAware()
|
||
|
||
# 尝试加载已保存的 Access Token
|
||
load_access_token()
|
||
|
||
# 获取资源列表
|
||
try:
|
||
resource_list = resource_helper().fetch_resource_list()
|
||
except Exception as e:
|
||
print_error(e)
|
||
resource_list = {}
|
||
messagebox.showwarning("警告", "获取资源列表失败,请手动填写资源链接,或重新打开本程序") # 弹出警告窗口
|
||
|
||
# GUI
|
||
root = tk.Tk()
|
||
|
||
ui_font_family = pick_ui_font_family()
|
||
|
||
if not scale: # 若获取缩放因子失败,通过 Tkinter 估算缩放因子
|
||
try:
|
||
scale: float = round(root.winfo_fpixels("1i") / 96.0, 2)
|
||
except Exception:
|
||
scale = 1.0
|
||
|
||
root.tk.call("tk", "scaling", scale / 0.75) # 设置缩放因子
|
||
|
||
root.title(f"国家中小学智慧教育平台 资源下载工具 {VERSION}") # 设置窗口标题
|
||
# root.geometry("900x600") # 设置窗口大小
|
||
|
||
def set_icon() -> None: # 设置窗口图标
|
||
icon = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAN8AAADfCAYAAAEB/ja6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAE7mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDIgNzkuYTZhNjM5NiwgMjAyNC8wMy8xMi0wNzo0ODoyMyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjkgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNC0wOC0xOVQxNDozNzo1MyswODowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmRjMWFiMTUxLTkzYzUtMGI0MS1hYWNiLTYxYzFhMmIyNTczOSIgeG1wTU06RG9jdW1lbnRJRD0i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")
|
||
icon_path = os.path.join(tempfile.gettempdir(), "icon.png")
|
||
with open(icon_path, "wb") as f:
|
||
f.write(icon)
|
||
|
||
icon = tk.PhotoImage(file=icon_path)
|
||
root.iconphoto(True, icon)
|
||
setattr(root, "_icon_ref", icon) # 为防止图片被垃圾回收,保存引用
|
||
|
||
set_icon() # 设置窗口图标
|
||
|
||
def on_closing() -> None: # 处理窗口关闭事件
|
||
if not all(state["finished"] for state in download_states): # 当正在下载时,询问用户
|
||
if not messagebox.askokcancel("提示", "下载任务未完成,是否退出?"):
|
||
return
|
||
|
||
current_process = psutil.Process(os.getpid()) # 获取自身的进程 ID
|
||
child_processes = current_process.children(recursive=True) # 获取自身的所有子进程
|
||
|
||
for child in child_processes: # 结束所有子进程
|
||
try:
|
||
child.terminate() # 结束进程
|
||
except Exception: # 进程可能已经结束
|
||
pass
|
||
|
||
# 结束自身进程
|
||
sys.exit(0)
|
||
|
||
root.protocol("WM_DELETE_WINDOW", on_closing) # 注册窗口关闭事件的处理函数
|
||
|
||
# 创建一个容器框架
|
||
container_frame = ttk.Frame(root)
|
||
container_frame.pack(anchor="center", expand=True, padx=int(40 * scale), pady=int(20 * scale)) # 在容器的中心位置放置,允许组件在容器中扩展,水平外边距 40,垂直外边距 40
|
||
|
||
title_label = ttk.Label(container_frame, text="国家中小学智慧教育平台 资源下载工具", font=(ui_font_family, 16, "bold")) # 添加标题标签
|
||
title_label.pack(pady=int(5 * scale)) # 设置垂直外边距(跟随缩放)
|
||
|
||
description = """\
|
||
📌 请在下面的文本框中输入一个或多个资源页面的网址(每个网址一行)。
|
||
🔗 资源页面网址示例:
|
||
https://basic.smartedu.cn/tchMaterial/detail?
|
||
contentType=assets_document&contentId=...
|
||
📝 您也可以直接在下方的选项卡中选择教材。
|
||
📥 点击 “下载” 按钮后,程序会解析并下载资源。
|
||
❗️ 注:为了更可靠地下载,建议点击 “设置 Token” 按钮,参照里面的说明完成设置。"""
|
||
description_label = ttk.Label(container_frame, text=description, justify="left", font=(ui_font_family, 9)) # 添加描述标签
|
||
description_label.pack(pady=int(5 * scale)) # 设置垂直外边距(跟随缩放)
|
||
|
||
url_text = tk.Text(container_frame, width=70, height=12, font=(ui_font_family, 9)) # 添加 URL 输入框,长度和宽度不使用缩放
|
||
url_text.pack(padx=int(15 * scale), pady=int(15 * scale)) # 设置水平外边距、垂直外边距(跟随缩放)
|
||
|
||
# 创建右键菜单
|
||
context_menu = tk.Menu(root, tearoff=0)
|
||
context_menu.add_command(label="剪切 (Ctrl+X)", command=lambda: url_text.event_generate("<<Cut>>"))
|
||
context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: url_text.event_generate("<<Copy>>"))
|
||
context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: url_text.event_generate("<<Paste>>"))
|
||
|
||
def show_context_menu(event):
|
||
context_menu.post(event.x_root, event.y_root)
|
||
context_menu.bind("<FocusOut>", lambda e: context_menu.unpost()) # 绑定失焦事件,失焦时自动关闭菜单
|
||
root.bind("<Button-1>", lambda e: context_menu.unpost(), add="+") # 绑定左键点击事件,点击其他地方也关闭菜单
|
||
|
||
# 绑定右键菜单到文本框(3 代表鼠标的右键按钮)
|
||
url_text.bind("<Button-3>", show_context_menu)
|
||
if os_name == "Darwin":
|
||
url_text.bind("<Control-Button-1>", show_context_menu)
|
||
url_text.bind("<Button-2>", show_context_menu)
|
||
|
||
options = [[resource_list[k]["display_name"] for k in resource_list], [], [], [], [], [], [], []] # 构建选择项
|
||
|
||
variables = [tk.StringVar(root, "资源类型"), tk.StringVar(root, "分类 1"), tk.StringVar(root, "分类 2"), tk.StringVar(root, "分类 3"), tk.StringVar(root, "分类 4"), tk.StringVar(root, "分类 5"), tk.StringVar(root, "分类 6"), tk.StringVar(root, "分类 7")]
|
||
|
||
# 处理用户选择事件
|
||
event_flag = False # 防止事件循环调用
|
||
def selection_handler(index: int, *args) -> None:
|
||
global event_flag
|
||
if event_flag: # 检测到循环调用
|
||
return
|
||
event_flag = True
|
||
|
||
current_hier = resource_list
|
||
end_flag = False # 是否到达最终目标
|
||
for i in range(index + 1): # 获取当前层级
|
||
try:
|
||
current_id = next(k for k, v in current_hier.items() if v["display_name"] == variables[i].get())
|
||
current_hier = current_hier[current_id]["children"]
|
||
except (StopIteration, KeyError): # 无法继续向下选择,说明已经到达最终目标
|
||
end_flag = True
|
||
break
|
||
|
||
if index < len(drops) - 1 and not end_flag: # 更新选择项
|
||
current_drop = drops[index + 1]
|
||
variables[index + 1].set(f"分类 {index + 1}")
|
||
current_drop["menu"].delete(0, "end") # 删除当前菜单中的所有选项
|
||
|
||
current_options = [current_hier[k]["display_name"] for k in current_hier.keys()]
|
||
for choice in current_options:
|
||
current_drop["menu"].add_command(label=choice, command=partial(lambda index, choice: variables[index + 1].set(choice), index, choice)) # 添加当前菜单的选项
|
||
|
||
current_drop.configure(state="active") # 恢复当前菜单
|
||
|
||
for i in range(index + 2, len(drops)): # 重置后面的选择项
|
||
drops[i].configure(state="disabled") # 禁用后面的菜单
|
||
variables[i].set(f"分类 {i}")
|
||
drops[i]["menu"].delete(0, "end")
|
||
|
||
if end_flag: # 到达目标,显示 URL
|
||
current_id = next(k for k, v in current_hier.items() if v["display_name"] == variables[index].get())
|
||
resource_type = current_hier[current_id]["resource_type_code"] or "assets_document"
|
||
if url_text.get("1.0", tk.END) == "\n": # URL 输入框为空的时候,插入的内容前面不加换行
|
||
url_text.insert("end", f"https://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial")
|
||
else:
|
||
url_text.insert("end", f"\nhttps://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial")
|
||
|
||
for i in range(index + 1, len(drops)): # 重置后面的选择项
|
||
drops[i].configure(state="disabled") # 禁用后面的菜单
|
||
variables[i].set(f"分类 {i}")
|
||
drops[i]["menu"].delete(0, "end")
|
||
|
||
event_flag = False
|
||
|
||
for index in range(8): # 绑定事件
|
||
variables[index].trace_add("write", partial(selection_handler, index))
|
||
|
||
# 添加 Container
|
||
dropdown_frame = ttk.Frame(root)
|
||
dropdown_frame.pack(padx=int(10 * scale), pady=int(10 * scale))
|
||
|
||
drops = []
|
||
|
||
# 添加菜单栏
|
||
for i in range(8):
|
||
drop = ttk.OptionMenu(dropdown_frame, variables[i], None, *options[i])
|
||
drop.configure(state="active" if i == 0 else "disabled") # 配置第一个下拉菜单为始终活跃状态,保证下拉菜单一直有形状
|
||
drop.bind("<Leave>", lambda e: "break") # 绑定鼠标移出事件,当鼠标移出下拉菜单时,执行 lambda 函数,“break” 表示中止事件传递
|
||
drop.grid(row=i // 4, column=i % 4, padx=int(15 * scale), pady=int(15 * scale)) # 设置位置,2 行 4 列(跟随缩放)
|
||
drops.append(drop)
|
||
|
||
# 按钮:设置 Token
|
||
token_btn = ttk.Button(container_frame, text="设置 Token", command=show_access_token_window)
|
||
token_btn.pack(side="left", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale))
|
||
|
||
# 复选框:添加书签
|
||
bookmark_var = tk.BooleanVar(value=True)
|
||
bookmark_checkbox = ttk.Checkbutton(container_frame, text="添加书签", variable=bookmark_var)
|
||
bookmark_checkbox.pack(side="left", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale))
|
||
|
||
# 按钮:下载
|
||
download_btn = ttk.Button(container_frame, text="下载", command=download)
|
||
download_btn.pack(side="right", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale))
|
||
|
||
# 按钮:解析并复制
|
||
copy_btn = ttk.Button(container_frame, text="解析并复制", command=parse_and_copy)
|
||
copy_btn.pack(side="right", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale))
|
||
|
||
# 下载进度条
|
||
download_progress_bar = ttk.Progressbar(container_frame, length=(125 * scale), mode="determinate") # 添加下载进度条
|
||
download_progress_bar.pack(side="bottom", padx=int(40 * scale), pady=int(10 * scale), ipady=int(5 * scale)) # 设置水平外边距、垂直外边距(跟随缩放),设置进度条高度(跟随缩放)
|
||
|
||
# 下载进度标签
|
||
progress_label = ttk.Label(container_frame, text="等待下载", anchor="center") # 初始时文本为空,居中
|
||
progress_label.pack(side="bottom", padx=int(5 * scale), pady=int(5 * scale)) # 设置水平外边距、垂直外边距(跟随缩放),设置标签高度(跟随缩放)
|
||
|
||
root.mainloop() # 开始主循环
|