diff --git a/.gitignore b/.gitignore index edd9d60..14cace7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ dist/ +__pycache__/ \ No newline at end of file diff --git a/README_MAC.md b/README_MAC.md new file mode 100644 index 0000000..5a1a17d --- /dev/null +++ b/README_MAC.md @@ -0,0 +1,134 @@ +# macOS 运行指南 + +## 系统要求 + +- **操作系统**: macOS 10.9 或更高版本 +- **Python**: 3.6 或更高版本 +- **图形界面**: 支持GUI的环境(Terminal.app、iTerm2等) + +## 安装步骤 + +### 1. 检查Python环境 + +```bash +python3 --version +``` + +如果没有Python 3,请先安装: +```bash +# 使用Homebrew安装(推荐) +brew install python + +# 或者从官网下载安装包 +# https://www.python.org/downloads/macos/ +``` + +### 2. 安装依赖 + +```bash +pip3 install -r requirements-mac.txt +``` + +### 3. 运行程序 + +```bash +python3 run_mac.py +``` + +或者直接执行: +```bash +./run_mac.py +``` + +## macOS 特殊说明 + +### Token 存储位置 + +在macOS系统中,Access Token **仅在运行时临时存储于内存**,程序重启后需要重新输入。这是当前版本的限制,未来版本会改进此功能。 + +### GUI 支持 + +- 程序使用Tkinter作为GUI框架,macOS原生支持 +- 如果遇到GUI问题,可能需要安装XQuartz: + ```bash + brew install --cask xquartz + ``` + +### 中文字体支持 + +程序已针对macOS优化中文显示,如果遇到字体问题: + +1. 确保系统语言设置为中文 +2. 检查终端的字符编码设置为UTF-8 + +## 使用方法 + +### 1. 获取 Access Token + +1. 打开浏览器,访问 [国家中小学智慧教育平台](https://auth.smartedu.cn/uias/login) 并登录 +2. 按 `F12` 或 `Cmd+Option+I` 打开开发者工具 +3. 选择 "Console"(控制台)标签 +4. 粘贴以下代码并按回车: + +```javascript +(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); +})(); +``` + +5. 复制输出的 Access Token + +### 2. 设置 Token + +1. 在程序界面点击 "设置 Token" 按钮 +2. 粘贴刚才复制的 Access Token +3. 点击 "保存" + +### 3. 下载教材 + +1. 在文本框中输入教材页面网址,或使用下拉菜单选择 +2. 点击 "下载" 按钮 +3. 选择保存位置 + +## 常见问题 + +### Q: 程序无法启动,提示 "No module named 'tkinter'" + +A: 安装Python的Tkinter支持: +```bash +brew install python-tk +``` + +### Q: 下载失败,提示 401 或 403 错误 + +A: Access Token 可能已过期,请重新获取并设置新的 Token。 + +### Q: 程序界面显示异常或中文乱码 + +A: 检查系统语言设置,确保终端支持UTF-8编码。 + +### Q: 无法保存 Access Token + +A: 这是macOS版本的已知限制。Token仅在程序运行期间有效,重启程序需要重新输入。 + +## 技术支持 + +如果遇到其他问题,请: + +1. 检查是否满足系统要求 +2. 确认所有依赖已正确安装 +3. 查看终端输出的错误信息 +4. 访问项目GitHub页面获取最新信息 + +## 版本信息 + +- 程序版本:v3.1 +- macOS适配版本:基于原版修改 +- 最后更新:2025-05-18 \ No newline at end of file diff --git a/requirements-mac.txt b/requirements-mac.txt new file mode 100644 index 0000000..49db7c8 --- /dev/null +++ b/requirements-mac.txt @@ -0,0 +1,3 @@ +psutil==6.1.0 +requests==2.32.3 +pyperclip==1.8.2 \ No newline at end of file diff --git a/run_mac.py b/run_mac.py new file mode 100755 index 0000000..d828d93 --- /dev/null +++ b/run_mac.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Mac系统专用启动脚本 +解决macOS下的兼容性问题 +""" + +import sys +import os +import platform + +def check_macos_compatibility(): + """检查macOS兼容性""" + if platform.system() != "Darwin": + print("警告:此脚本专为macOS设计") + return False + + # 检查Python版本 + if sys.version_info < (3, 6): + print("错误:需要Python 3.6或更高版本") + return False + + return True + +def setup_macos_environment(): + """设置macOS环境""" + # 设置环境变量以支持中文显示 + os.environ['LANG'] = 'zh_CN.UTF-8' + os.environ['LC_ALL'] = 'zh_CN.UTF-8' + + # 确保Tkinter可以正常工作 + try: + import tkinter as tk + import tkinter.ttk as ttk + # 创建一个测试窗口来验证Tkinter是否正常工作 + test_root = tk.Tk() + ttk.Style(test_root) + test_root.withdraw() # 隐藏测试窗口 + test_root.destroy() + print("✓ Tkinter GUI支持正常") + except ImportError: + print("错误:未找到Tkinter,请安装Python的Tkinter支持") + print("可以尝试:brew install python-tk") + return False + except Exception as e: + print(f"警告:Tkinter测试失败: {e}") + print("如果遇到GUI问题,请确保已安装XQuartz或在支持GUI的环境中运行") + + return True + +def main(): + """主函数""" + print("=== 国家中小学智慧教育平台 资源下载工具 (macOS版) ===") + print(f"Python版本: {sys.version}") + print(f"操作系统: {platform.system()} {platform.release()}") + print() + + # 检查兼容性 + if not check_macos_compatibility(): + sys.exit(1) + + # 设置环境 + if not setup_macos_environment(): + sys.exit(1) + + # 导入并运行主程序 + try: + print("正在启动程序...") + # 将src目录添加到Python路径 + project_root = os.path.dirname(os.path.abspath(__file__)) + src_path = os.path.join(project_root, 'src') + sys.path.insert(0, src_path) + + # 导入主程序 + import importlib.util + module_path = os.path.join(src_path, "tchMaterial_parser.py") + print(f"DEBUG: Attempting to load module from: {module_path}") + spec = importlib.util.spec_from_file_location( + "tchMaterial_parser", + module_path + ) + print(f"DEBUG: spec is {spec}") + main_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(main_module) + + except ImportError as e: + print(f"错误:导入模块失败: {e}") + print("请确保所有依赖已正确安装:pip3 install -r requirements-mac.txt") + sys.exit(1) + except Exception as e: + print(f"错误:程序运行失败: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/tchMaterial_parser.py b/src/tchMaterial_parser.py new file mode 100644 index 0000000..9eccc41 --- /dev/null +++ b/src/tchMaterial_parser.py @@ -0,0 +1,737 @@ +# -*- coding: utf-8 -*- +# 国家中小学智慧教育平台 资源下载工具 v3.1 +# 项目地址:https://github.com/happycola233/tchMaterial-parser +# 作者:肥宅水水呀(https://space.bilibili.com/324042405)以及其他为本工具作出贡献的用户 +# 最近更新于:2025-05-18 + +# 导入相关库 +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import os, platform +import sys +from functools import partial +import base64, tempfile, pyperclip +import threading, requests, psutil +import json, re +from typing import Union, Tuple # 添加导入 + +os_name = platform.system() # 获取操作系统类型 + +if os_name == "Windows": # 如果是 Windows 操作系统,导入 Windows 相关库 + import win32print, win32gui, win32con, win32api, ctypes, winreg + +def parse(url: str) -> Union[Tuple[str, str, str], Tuple[None, None, None]]: # 解析 URL + try: + content_id, content_type, resource_url = None, None, None + + # 简单提取 URL 中的 contentId 与 contentType(这种方法不严谨,但为了减少导入的库只能这样了) + 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" + + # 获得该 contentId 下资源的信息,返回数据示例: + """ + { + "id": "4f64356a-8df7-4579-9400-e32c9a7f6718", + // ... + "ti_items": [ + { + // ... + "ti_storages": [ // 资源文件地址 + "https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf", + "https://r2-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf", + "https://r3-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf" + ], + // ... + }, + { + // ...(和上一个元素组成一样) + } + ] + } + """ + # 其中 $.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() + for item in list(data["ti_items"]): + if item["lc_ti_format"] == "pdf": # 找到存有 PDF 链接列表的项 + resource_url: str = item["ti_storages"][0] # 获取并构造 PDF 的 URL + if not access_token: # 未登录时,通过一个不可靠的方法构造可直接下载的 URL + resource_url = re.sub(r"^https?://(.+)-private.ykt.cbern.com.cn/(.+)/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}).pkg/(?:.+)\.pdf$", r"https://\1.ykt.cbern.com.cn/\2/\3.pkg/pdf.pdf", 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() + for resource in list(resources_data): + if resource["resource_type_code"] == "assets_document": + for item in list(resource["ti_items"]): + if item["lc_ti_format"] == "pdf": + resource_url: str = item["ti_storages"][0] + if not access_token: + resource_url = re.sub(r"^https?://(.+)-private.ykt.cbern.com.cn/(.+)/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}).pkg/(?:.+)\.pdf$", r"https://\1.ykt.cbern.com.cn/\2/\3.pkg/pdf.pdf", resource_url) + break + if not resource_url: + return None, None, None + else: + return None, None, None + + return resource_url, content_id, data["title"] + except Exception: + return None, None, None # 如果解析失败,返回 None + +def download_file(url: str, save_path: str) -> None: # 下载文件 + global download_states + 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) + + response = session.get(url, headers=headers, stream=True) + + # 服务器返回 401 或 403 状态码 + if response.status_code == 401 or response.status_code == 403: + current_state["finished"] = True + current_state["failed_reason"] = "授权失败,Access Token 可能已过期或无效,请重新设置" + elif response.status_code >= 400: + current_state["finished"] = True + current_state["failed_reason"] = f"服务器返回状态码 {response.status_code}" + else: + current_state["total_size"] = int(response.headers.get("Content-Length", 0)) + + try: + with open(save_path, "wb") as file: + for chunk in response.iter_content(chunk_size=131072): # 分块下载,每次下载 131072 字节(128 KB) + 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 + # 更新进度条 + download_progress_bar["value"] = download_progress + # 更新标签以显示当前下载进度 + 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"] + current_state["finished"] = True + except Exception as e: + current_state["downloaded_size"], current_state["total_size"] = 0, 0 + current_state["finished"] = True + current_state["failed_reason"] = str(e) + + if all(state["finished"] for state in download_states): + download_progress_bar["value"] = 0 # 重置进度条 + progress_label.config(text="等待下载") # 清空进度标签 + download_btn.config(state="normal") # 设置下载按钮为启用状态 + + failed_states = [state for state in download_states if state["failed_reason"]] + if len(failed_states) > 0: + failed_messages = "\n".join(f'{state["download_url"]},原因:{state["failed_reason"]}' for state in failed_states) + messagebox.showwarning("下载完成", f"文件已下载到:{os.path.dirname(save_path)}\n以下链接下载失败:\n{failed_messages}") + else: + messagebox.showinfo("下载完成", f"文件已下载到:{os.path.dirname(save_path)}") # 显示完成对话框 + +def format_bytes(size: float) -> str: # 将数据单位进行格式化,返回以 KB、MB、GB、TB 为单位的数据大小 + 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 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)[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 = [line.strip() for line in url_text.get("1.0", tk.END).splitlines() if line.strip()] # 获取所有非空行 + failed_links = [] + + if len(urls) > 1: + messagebox.showinfo("提示", "您选择了多个链接,将在选定的文件夹中使用教材名称作为文件名进行下载。") + dir_path = filedialog.askdirectory() # 选择文件夹 + if os_name == "Windows": + dir_path = dir_path.replace("/", "\\") + if not dir_path: + download_btn.config(state="normal") # 设置下载按钮为启用状态 + return + else: + dir_path = None + + for url in urls: + resource_url, content_id, title = parse(url) + if not resource_url: + failed_links.append(url) # 添加到失败链接 + continue + + if dir_path: + default_filename = title or "download" + save_path = os.path.join(dir_path, f"{default_filename}.pdf") # 构造完整路径 + else: + default_filename = title or "download" + save_path = filedialog.asksaveasfilename(defaultextension=".pdf", filetypes=[("PDF 文件", "*.pdf"), ("所有文件", "*.*")], initialfile = default_filename) # 选择保存路径 + if not save_path: # 用户取消了文件保存操作 + download_btn.config(state="normal") # 设置下载按钮为启用状态 + return + if os_name == "Windows": + save_path = save_path.replace("/", "\\") + + thread_it(download_file, (resource_url, save_path)) # 开始下载(多线程,防止窗口卡死) + + if failed_links: + messagebox.showwarning("警告", "以下行无法解析:\n" + "\n".join(failed_links)) # 显示警告对话框 + download_btn.config(state="normal") # 设置下载按钮为启用状态 + + if not urls and not failed_links: + download_btn.config(state="normal") # 设置下载按钮为启用状态 + +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("", 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=("微软雅黑", 10)) + label.pack(pady=5) + + # 多行 Text 替代原先 Entry,并绑定右键菜单 + token_text = tk.Text(frame, width=50, height=4, wrap="word", font=("微软雅黑", 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("<>")) + token_context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: token_text.event_generate("<>")) + token_context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: token_text.event_generate("<>")) + + # 绑定右键点击事件 + def show_token_menu(event): + token_context_menu.post(event.x_root, event.y_root) + token_context_menu.bind("", lambda e: token_context_menu.unpost()) + root.bind("", lambda e: token_context_menu.unpost(), add="+") + + token_text.bind("", show_token_menu) + + # 按下 Enter 键即可保存 token,并屏蔽换行事件 + def return_save_token(event): + save_token() + return "break" + + token_text.bind("", return_save_token) # 按下 Enter 键,保存 Access Token + token_text.bind("", 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("", 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=("微软雅黑", 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("<>")) + def show_help_menu(event): + help_menu.post(event.x_root, event.y_root) + help_menu.bind("", lambda e: help_menu.unpost()) + root.bind("", lambda e: help_menu.unpost(), add="+") + + txt.bind("", 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() # 置顶可见 + +class resource_helper: # 获取网站上资源的数据 + def parse_hierarchy(self, hierarchy): # 解析层级数据 + if not hierarchy: # 如果没有层级数据,返回空 + return None + + 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 len(book["tag_paths"]) > 0: # 某些非课本资料的 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 len(lesson["tag_list"]) > 0: + # 解析课件层级数据 + tag_paths: list[str] = [tag["tag_id"] for tag in sorted(lesson["tag_list"], key=lambda tag: tag["order_num"])] + + # 分别解析课件层级 + temp_hier = parsed_hier["__internal_national_lesson"] + 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"] = {} + + lesson["display_name"] = lesson["title"] if "title" in lesson else lesson["name"] if "name" in lesson else f"(未知课件 {lesson['id']})" + + temp_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: # args 为元组,且默认值是空元组 + # 打包函数到线程 + t = threading.Thread(target=func, args=args) + # t.daemon = True + t.start() + +# 初始化请求 +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 = { "http": None, "https": None } # 全局忽略代理 + +def load_access_token() -> None: # 读取本地存储的 Access Token + global access_token + try: + if os_name == "Windows": # 在 Windows 上,从注册表读取 + 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") as f: + data = json.load(f) + # 提取 access_token 字段 + 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 写入注册表 + 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") as f: + json.dump(data, f, indent=4) + + return "Access Token 已保存!\n已写入文件:~/.config/tchMaterial-parser/data.json" + else: + return "Access Token 已保存!" + except Exception: + return "Access Token 已保存!" + +# 立即尝试加载已存的 Access Token(如果有的话) +load_access_token() + +# 获取资源列表 +try: + resource_list = resource_helper().fetch_resource_list() +except Exception: + resource_list = {} + messagebox.showwarning("警告", "获取资源列表失败,请手动填写资源链接,或重新打开本程序") # 弹出警告窗口 + +# GUI +root = tk.Tk() + +# 高 DPI 适配 +if os_name == "Windows": + scale: float = 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() +else: # 在非 Windows 操作系统上,通过 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("国家中小学智慧教育平台 资源下载工具 v3.1") # 设置窗口标题 +# root.geometry("900x600") # 设置窗口大小 + +def set_icon() -> None: # 设置窗口图标 + icon = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAN8AAADfCAYAAAEB/ja6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAE7mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDIgNzkuYTZhNjM5NiwgMjAyNC8wMy8xMi0wNzo0ODoyMyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjkgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNC0wOC0xOVQxNDozNzo1MyswODowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmRjMWFiMTUxLTkzYzUtMGI0MS1hYWNiLTYxYzFhMmIyNTczOSIgeG1wTU06RG9jdW1lbnRJRD0i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") + with open(tempfile.gettempdir() + "/icon.png", "wb") as f: + f.write(icon) + + icon = tk.PhotoImage(file=tempfile.gettempdir() + "/icon.png") + root.iconphoto(True, icon) + 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="yes", padx=int(40 * scale), pady=int(20 * scale)) # 在容器的中心位置放置,允许组件在容器中扩展,水平外边距 40,垂直外边距 40 + +title_label = ttk.Label(container_frame, text="国家中小学智慧教育平台 资源下载工具", font=("微软雅黑", 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=("微软雅黑", 9)) # 添加描述标签 +description_label.pack(pady=int(5 * scale)) # 设置垂直外边距(跟随缩放) + +url_text = tk.Text(container_frame, width=70, height=12, font=("微软雅黑", 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("<>")) +context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: url_text.event_generate("<>")) +context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: url_text.event_generate("<>")) + +def show_context_menu(event): + context_menu.post(event.x_root, event.y_root) + # 绑定失焦事件,失焦时自动关闭菜单 + context_menu.bind("", lambda e: context_menu.unpost()) + # 绑定左键点击事件,点击其他地方也关闭菜单 + root.bind("", lambda e: context_menu.unpost(), add="+") + +# 绑定右键菜单到文本框(3 代表鼠标的右键按钮) +url_text.bind("", show_context_menu) + +options = [["---"] + [resource_list[k]["display_name"] for k in resource_list], ["---"], ["---"], ["---"], ["---"], ["---"], ["---"], ["---"]] # 构建选择项 + +variables = [tk.StringVar(root), tk.StringVar(root), tk.StringVar(root), tk.StringVar(root), tk.StringVar(root), tk.StringVar(root), tk.StringVar(root), tk.StringVar(root)] + +# 处理用户选择事件 +event_flag = False # 防止事件循环调用 +def selection_handler(index: int, *args) -> None: + global event_flag + + if event_flag: + event_flag = False # 检测到循环调用,重置标志位并返回 + return + + if variables[index].get() == "---": # 重置后面的选择项 + for i in range(index + 1, len(drops)): + drops[i]["menu"].delete(0, "end") + drops[i]["menu"].add_command(label="---", command=tk._setit(variables[i], "---")) + + event_flag = True + variables[i].set("---") + # drops[i]["menu"].configure(state="disabled") + return + + if index < len(drops) - 1: # 更新选择项 + current_drop = drops[index + 1] + + current_hier = resource_list + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[0].get()][0] + current_hier = current_hier[current_id]["children"] + + end_flag = False # 是否到达最终目标 + for i in range(index): + try: + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[i + 1].get()][0] + current_hier = current_hier[current_id]["children"] + except KeyError: # 无法继续向下选择,说明已经到达最终目标 + end_flag = True + break + + if not current_hier or end_flag: + current_options = ["---"] + else: + current_options = ["---"] + [current_hier[k]["display_name"] for k in current_hier.keys()] + + current_drop["menu"].delete(0, "end") + for choice in current_options: + current_drop["menu"].add_command(label=choice, command=tk._setit(variables[index + 1], choice)) + + if end_flag: # 到达目标,显示 URL + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[index].get()][0] + 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") + drops[-1]["menu"].delete(0, "end") + drops[-1]["menu"].add_command(label="---", command=tk._setit(variables[-1], "---")) + variables[-1].set("---") + + for i in range(index + 2, len(drops)): # 重置后面的选择项 + drops[i]["menu"].delete(0, "end") + drops[i]["menu"].add_command(label="---", command=tk._setit(variables[i], "---")) + # drops[i]["menu"].configure(state="disabled") + + for i in range(index + 1, len(drops)): + event_flag = True + variables[i].set("---") + + else: # 最后一项,必为最终目标,显示 URL + if variables[-1].get() == "---": + return + + current_hier = resource_list + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[0].get()][0] + current_hier = current_hier[current_id]["children"] + for i in range(index - 1): + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[i + 1].get()][0] + current_hier = current_hier[current_id]["children"] + + current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[index].get()][0] + 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 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], *options[i]) + drop.config(state="active") # 配置下拉菜单为始终活跃状态,保证下拉菜单一直有形状 + drop.bind("", lambda e: "break") # 绑定鼠标移出事件,当鼠标移出下拉菜单时,执行 lambda 函数,"break"表示中止事件传递 + drop.grid(row=i // 4, column=i % 4, padx=int(15 * scale), pady=int(15 * scale)) # 设置位置,2 行 4 列(跟随缩放) + variables[i].set("---") + 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)) + +# 按钮:下载 +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() # 开始主循环