├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt ├── res └── PixPin_2025-03-14_23-44-26.png ├── src ├── favicon_223x223.png ├── favicon_48x48.ico └── tchMaterial-parser.pyw ├── tchMaterial-parser.spec └── version.txt /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main", "pre-release" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: windows-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: "3.10" 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 肥宅水水呀 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [国家中小学智慧教育平台 电子课本](https://basic.smartedu.cn/tchMaterial/)下载工具 2 | 3 | ![Python Version](https://img.shields.io/badge/Python-3.x-blue.svg) 4 | ![License](https://img.shields.io/badge/License-MIT-green.svg) 5 | ![Made With Love❤️](https://img.shields.io/badge/Made_With-%E2%9D%A4-red.svg) 6 | 7 | > [!TIP] 8 | > 🚀最新版本 v3.1 现已发布,该版本在未设置 Access Token 时也可下载电子课本,欢迎体验! 9 | 10 | 本工具可以帮助您从[**国家中小学智慧教育平台**](https://basic.smartedu.cn/)获取电子课本的 PDF 文件网址并进行下载,让您更方便地获取课本内容。 11 | 12 | ## ✨工具特点 13 | 14 | - 🔑**支持 Access Token 登录**:支持用户手动输入 Access Token,在 Windows 操作系统下会存入注册表,Linux 操作系统下会存入 `~/.config/tchMaterial-parser` 文件夹,下次启动可自动加载。 15 | - 📚**支持批量下载**:一次输入多个电子课本预览页面网址,即可批量下载 PDF 课本文件。 16 | - 📂**自动文件命名**:程序会自动使用教材名称作为文件名,方便管理下载的课本文件。 17 | - 🖥️**高 DPI 适配**:优化 UI 以适配高分辨率屏幕,避免界面模糊问题。 18 | - 📊**下载进度可视化**:实时显示下载进度,支持暂停/恢复操作。 19 | - 💻**跨平台支持**:支持 Windows、Linux、macOS 等操作系统(需要图形界面)。 20 | 21 | ![程序截图](./res/PixPin_2025-03-14_23-44-26.png) 22 | 23 | ## 📥下载与安装方法 24 | 25 | ### GitHub Releases 页面 26 | 27 | 由于我们的精力有限,本项目的 [GitHub Releases 页面](https://github.com/happycola233/tchMaterial-parser/releases)**仅会发布适用于 Windows 与 Linux 操作系统的 x64 架构**的程序。 28 | 29 | 在下载完成之后,即可运行本程序,不需要额外的安装步骤。 30 | 31 | ### Arch 用户软件仓库(AUR) 32 | 33 | 对于 **Arch Linux** 操作系统,本程序已发布至[Arch 用户软件仓库](https://aur.archlinux.org/packages/tchmaterial-parser),因此您还可以通过在终端中输入以下命令安装: 34 | 35 | ```sh 36 | yay -S tchmaterial-parser 37 | ``` 38 | 39 | 感谢 [@iamzhz](https://github.com/iamzhz) 为本工具制作了发行包([#26](../../issues/26))! 40 | 41 | ## 🛠️使用方法 42 | 43 | ### 1. 输入教材链接⌨️ 44 | 45 | 将电子课本的**预览页面网址**粘贴到程序文本框中,支持多个 URL(每行一个)。 46 | 47 | **示例网址**: 48 | 49 | ```text 50 | https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=XXXXXX&catalogType=tchMaterial&subCatalog=tchMaterial 51 | ``` 52 | 53 | ### 2. 设置 Access Token🔑 54 | 55 | > [!TIP] 56 | > 自 v3.1 版本起,这一步操作已经**不再必要**,当未设置 Access Token 时程序会使用其他方法下载资源。然而,这一方法**并不长期有效,且对于部分资源无效**,因此仍然建议您进行这一步操作。 57 | 58 | 1. **打开浏览器**,访问[国家中小学智慧教育平台](https://auth.smartedu.cn/uias/login)并**登录账号**。 59 | 2. 按下 **F12** 或 **Ctrl+Shift+I**,或右键——检查(审查元素)打开**开发者工具**,选择**控制台(Console)**。 60 | 3. 在控制台粘贴以下代码后回车(Enter): 61 | 62 | ```js 63 | (function() { 64 | const authKey = Object.keys(localStorage).find(key => key.startsWith("ND_UC_AUTH")); 65 | if (!authKey) { 66 | console.error("未找到 Access Token,请确保已登录!"); 67 | return; 68 | } 69 | const tokenData = JSON.parse(localStorage.getItem(authKey)); 70 | const accessToken = JSON.parse(tokenData.value).access_token; 71 | console.log("%cAccess Token:", "color: green; font-weight: bold", accessToken); 72 | })(); 73 | ``` 74 | 75 | 4. 复制控制台输出的 **Access Token**,然后在本程序中点击 “**设置 Token**” 按钮,粘贴并保存 Token。 76 | 77 | > [!NOTE] 78 | > Access Token 可能会过期,若下载失败提示 **401 Unauthorized** 或 **403 Forbidden**,请重新获取并设置新的 Token。 79 | 80 | ### 3. 开始下载🚀 81 | 82 | 点击 “**下载**” 按钮,程序将自动解析并下载 PDF 课本。 83 | 84 | 本工具支持**批量下载**,所有 PDF 文件会自动按课本名称命名并保存在选定目录中。 85 | 86 | ## ❓常见问题 87 | 88 | ### 1. 为什么下载失败?⚠️ 89 | 90 | - 如果您没有设置 Access Token,可能是本程序使用的方法失效了,请[**设置 Access Token**](#2-设置-access-token)🔑。 91 | - 如果您设置了 Access Token,由于其具有时效性(一般为 7 天),因此极有可能是 Access Token 过期了,请重新获取新的 Access Token。 92 | - **确认网络连接是否正常**🌐,有时网络不稳定可能导致下载失败。 93 | - **确保输入的网址有效**🔗,部分旧资源可能已被移除。 94 | 95 | ### 2. Access Token 保存在哪里?💾 96 | 97 | - **Windows 操作系统**:Token 会存储在**注册表** `HKEY_CURRENT_USER\Software\tchMaterial-parser` 项中的 `AccessToken` 值。 98 | - **Linux 操作系统**: Token 会存储在**文件** `~/.config/tchMaterial-parser/data.json` 中。 99 | - **macOS 等操作系统**:Token 仅在运行时临时存储于内存,不会自动保存,程序重启后需重新输入,目前我们正在努力改进该功能。 100 | 101 | ### 3. Token 会不会泄露?🔐 102 | 103 | - 本程序**不会上传** Token,也不会存储在云端,仅用于本地请求授权。 104 | - **请勿在公开场合分享 Token**,以免您的账号被他人使用,造成严重后果。 105 | 106 | ## ⭐Star History 107 | 108 | [![Star History Chart](https://api.star-history.com/svg?repos=happycola233/tchMaterial-parser&type=Date)](https://star-history.com/#happycola233/tchMaterial-parser&Date) 109 | 110 | ## 🤝贡献指南 111 | 112 | 如果您发现 Bug 或有改进建议,欢迎提交 **Issue** 或 **Pull Request**,让我们一起完善本工具! 113 | 114 | ## 📜许可证 115 | 116 | 本项目基于 [MIT 许可证](LICENSE),欢迎自由使用和二次开发。 117 | 118 | ## 💌友情链接 119 | 120 | - 📚您也可以在 [ChinaTextbook](https://github.com/TapXWorld/ChinaTextbook) 项目中下载归档的教材 PDF。 121 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil==6.1.0 2 | pywin32==308 3 | requests==2.32.3 4 | -------------------------------------------------------------------------------- /res/PixPin_2025-03-14_23-44-26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happycola233/tchMaterial-parser/01babb2531d3e195d92d4ef3d3a6c79f1f1c386f/res/PixPin_2025-03-14_23-44-26.png -------------------------------------------------------------------------------- /src/favicon_223x223.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happycola233/tchMaterial-parser/01babb2531d3e195d92d4ef3d3a6c79f1f1c386f/src/favicon_223x223.png -------------------------------------------------------------------------------- /src/favicon_48x48.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happycola233/tchMaterial-parser/01babb2531d3e195d92d4ef3d3a6c79f1f1c386f/src/favicon_48x48.ico -------------------------------------------------------------------------------- /src/tchMaterial-parser.pyw: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 国家中小学智慧教育平台 资源下载工具 v3.1 3 | # 项目地址:https://github.com/happycola233/tchMaterial-parser 4 | # 作者:肥宅水水呀(https://space.bilibili.com/324042405)以及其他为本工具作出贡献的用户 5 | # 最近更新于:2025-05-18 6 | 7 | # 导入相关库 8 | import tkinter as tk 9 | from tkinter import ttk, messagebox, filedialog 10 | import os, platform 11 | import sys 12 | from functools import partial 13 | import base64, tempfile, pyperclip 14 | import threading, requests, psutil 15 | import json, re 16 | 17 | os_name = platform.system() # 获取操作系统类型 18 | 19 | if os_name == "Windows": # 如果是 Windows 操作系统,导入 Windows 相关库 20 | import win32print, win32gui, win32con, win32api, ctypes, winreg 21 | 22 | def parse(url: str) -> tuple[str, str, str] | tuple[None, None, None]: # 解析 URL 23 | try: 24 | content_id, content_type, resource_url = None, None, None 25 | 26 | # 简单提取 URL 中的 contentId 与 contentType(这种方法不严谨,但为了减少导入的库只能这样了) 27 | for q in url[url.find("?") + 1:].split("&"): 28 | if q.split("=")[0] == "contentId": 29 | content_id = q.split("=")[1] 30 | break 31 | if not content_id: 32 | return None, None, None 33 | 34 | for q in url[url.find("?") + 1:].split("&"): 35 | if q.split("=")[0] == "contentType": 36 | content_type = q.split("=")[1] 37 | break 38 | if not content_type: 39 | content_type = "assets_document" 40 | 41 | # 获得该 contentId 下资源的信息,返回数据示例: 42 | """ 43 | { 44 | "id": "4f64356a-8df7-4579-9400-e32c9a7f6718", 45 | // ... 46 | "ti_items": [ 47 | { 48 | // ... 49 | "ti_storages": [ // 资源文件地址 50 | "https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf", 51 | "https://r2-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf", 52 | "https://r3-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/4f64356a-8df7-4579-9400-e32c9a7f6718.pkg/pdf.pdf" 53 | ], 54 | // ... 55 | }, 56 | { 57 | // ...(和上一个元素组成一样) 58 | } 59 | ] 60 | } 61 | """ 62 | # 其中 $.ti_items 的每一项对应一个资源 63 | 64 | if re.search(r"^https?://([^/]+)/syncClassroom/basicWork/detail", url): # 对于 “基础性作业” 的解析 65 | response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/resources/details/{content_id}.json") 66 | else: # 对于课本的解析 67 | if content_type == "thematic_course": # 对专题课程(含电子课本、视频等)的解析 68 | response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/resources/details/{content_id}.json") 69 | else: # 对普通电子课本的解析 70 | response = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{content_id}.json") 71 | 72 | data = response.json() 73 | for item in list(data["ti_items"]): 74 | if item["lc_ti_format"] == "pdf": # 找到存有 PDF 链接列表的项 75 | resource_url: str = item["ti_storages"][0] # 获取并构造 PDF 的 URL 76 | if not access_token: # 未登录时,通过一个不可靠的方法构造可直接下载的 URL 77 | 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) 78 | break 79 | 80 | if not resource_url: 81 | if content_type == "thematic_course": # 专题课程 82 | resources_resp = session.get(f"https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/special_edu/thematic_course/{content_id}/resources/list.json") 83 | resources_data = resources_resp.json() 84 | for resource in list(resources_data): 85 | if resource["resource_type_code"] == "assets_document": 86 | for item in list(resource["ti_items"]): 87 | if item["lc_ti_format"] == "pdf": 88 | resource_url: str = item["ti_storages"][0] 89 | if not access_token: 90 | 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) 91 | break 92 | if not resource_url: 93 | return None, None, None 94 | else: 95 | return None, None, None 96 | 97 | return resource_url, content_id, data["title"] 98 | except Exception: 99 | return None, None, None # 如果解析失败,返回 None 100 | 101 | def download_file(url: str, save_path: str) -> None: # 下载文件 102 | global download_states 103 | current_state = { "download_url": url, "save_path": save_path, "downloaded_size": 0, "total_size": 0, "finished": False, "failed_reason": None } 104 | download_states.append(current_state) 105 | 106 | response = session.get(url, headers=headers, stream=True) 107 | 108 | # 服务器返回 401 或 403 状态码 109 | if response.status_code == 401 or response.status_code == 403: 110 | current_state["finished"] = True 111 | current_state["failed_reason"] = "授权失败,Access Token 可能已过期或无效,请重新设置" 112 | elif response.status_code >= 400: 113 | current_state["finished"] = True 114 | current_state["failed_reason"] = f"服务器返回状态码 {response.status_code}" 115 | else: 116 | current_state["total_size"] = int(response.headers.get("Content-Length", 0)) 117 | 118 | try: 119 | with open(save_path, "wb") as file: 120 | for chunk in response.iter_content(chunk_size=131072): # 分块下载,每次下载 131072 字节(128 KB) 121 | file.write(chunk) 122 | current_state["downloaded_size"] += len(chunk) 123 | all_downloaded_size = sum(state["downloaded_size"] for state in download_states) 124 | all_total_size = sum(state["total_size"] for state in download_states) 125 | downloaded_number = len([state for state in download_states if state["finished"]]) 126 | total_number = len(download_states) 127 | 128 | if all_total_size > 0: # 防止下面一行代码除以 0 而报错 129 | download_progress = (all_downloaded_size / all_total_size) * 100 130 | # 更新进度条 131 | download_progress_bar["value"] = download_progress 132 | # 更新标签以显示当前下载进度 133 | progress_label.config(text=f"{format_bytes(all_downloaded_size)}/{format_bytes(all_total_size)} ({download_progress:.2f}%) 已下载 {downloaded_number}/{total_number}") # 更新标签 134 | 135 | current_state["downloaded_size"] = current_state["total_size"] 136 | current_state["finished"] = True 137 | except Exception as e: 138 | current_state["downloaded_size"], current_state["total_size"] = 0, 0 139 | current_state["finished"] = True 140 | current_state["failed_reason"] = str(e) 141 | 142 | if all(state["finished"] for state in download_states): 143 | download_progress_bar["value"] = 0 # 重置进度条 144 | progress_label.config(text="等待下载") # 清空进度标签 145 | download_btn.config(state="normal") # 设置下载按钮为启用状态 146 | 147 | failed_states = [state for state in download_states if state["failed_reason"]] 148 | if len(failed_states) > 0: 149 | messagebox.showwarning("下载完成", f"文件已下载到:{os.path.dirname(save_path)}\n以下链接下载失败:\n{"\n".join(f"{state["download_url"]},原因:{state["failed_reason"]}" for state in failed_states)}") 150 | else: 151 | messagebox.showinfo("下载完成", f"文件已下载到:{os.path.dirname(save_path)}") # 显示完成对话框 152 | 153 | def format_bytes(size: float) -> str: # 将数据单位进行格式化,返回以 KB、MB、GB、TB 为单位的数据大小 154 | for x in ["字节", "KB", "MB", "GB", "TB"]: 155 | if size < 1024.0: 156 | return f"{size:3.1f} {x}" 157 | size /= 1024.0 158 | return f"{size:3.1f} PB" 159 | 160 | def parse_and_copy() -> None: # 解析并复制链接 161 | urls = [line.strip() for line in url_text.get("1.0", tk.END).splitlines() if line.strip()] # 获取所有非空行 162 | resource_links = [] 163 | failed_links = [] 164 | 165 | for url in urls: 166 | resource_url = parse(url)[0] 167 | if not resource_url: 168 | failed_links.append(url) # 添加到失败链接 169 | continue 170 | resource_links.append(resource_url) 171 | 172 | if failed_links: 173 | messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join(failed_links)) # 显示警告对话框 174 | 175 | if resource_links: 176 | pyperclip.copy("\n".join(resource_links)) # 将链接复制到剪贴板 177 | messagebox.showinfo("提示", "资源链接已复制到剪贴板") 178 | 179 | def download() -> None: # 下载资源文件 180 | global download_states 181 | download_btn.config(state="disabled") # 设置下载按钮为禁用状态 182 | download_states = [] # 初始化下载状态 183 | urls = [line.strip() for line in url_text.get("1.0", tk.END).splitlines() if line.strip()] # 获取所有非空行 184 | failed_links = [] 185 | 186 | if len(urls) > 1: 187 | messagebox.showinfo("提示", "您选择了多个链接,将在选定的文件夹中使用教材名称作为文件名进行下载。") 188 | dir_path = filedialog.askdirectory() # 选择文件夹 189 | if os_name == "Windows": 190 | dir_path = dir_path.replace("/", "\\") 191 | if not dir_path: 192 | download_btn.config(state="normal") # 设置下载按钮为启用状态 193 | return 194 | else: 195 | dir_path = None 196 | 197 | for url in urls: 198 | resource_url, content_id, title = parse(url) 199 | if not resource_url: 200 | failed_links.append(url) # 添加到失败链接 201 | continue 202 | 203 | if dir_path: 204 | default_filename = title or "download" 205 | save_path = os.path.join(dir_path, f"{default_filename}.pdf") # 构造完整路径 206 | else: 207 | default_filename = title or "download" 208 | save_path = filedialog.asksaveasfilename(defaultextension=".pdf", filetypes=[("PDF 文件", "*.pdf"), ("所有文件", "*.*")], initialfile = default_filename) # 选择保存路径 209 | if not save_path: # 用户取消了文件保存操作 210 | download_btn.config(state="normal") # 设置下载按钮为启用状态 211 | return 212 | if os_name == "Windows": 213 | save_path = save_path.replace("/", "\\") 214 | 215 | thread_it(download_file, (resource_url, save_path)) # 开始下载(多线程,防止窗口卡死) 216 | 217 | if failed_links: 218 | messagebox.showwarning("警告", "以下 “行” 无法解析:\n" + "\n".join(failed_links)) # 显示警告对话框 219 | download_btn.config(state="normal") # 设置下载按钮为启用状态 220 | 221 | if not urls and not failed_links: 222 | download_btn.config(state="normal") # 设置下载按钮为启用状态 223 | 224 | def show_access_token_window() -> None: # 打开输入 Access Token 的窗口 225 | token_window = tk.Toplevel(root) 226 | token_window.title("设置 Access Token") 227 | # 让窗口自动根据控件自适应尺寸;如需最小尺寸可用 token_window.minsize(...) 228 | 229 | token_window.focus_force() # 自动获得焦点 230 | token_window.grab_set() # 阻止主窗口操作 231 | token_window.bind("", lambda event: token_window.destroy()) # 绑定 Esc 键关闭窗口 232 | 233 | # 设置一个 Frame 用于留白、布局更美观 234 | frame = ttk.Frame(token_window, padding=20) 235 | frame.pack(fill="both", expand=True) 236 | 237 | # 提示文本 238 | label = ttk.Label(frame, text="请粘贴从浏览器获取的 Access Token:", font=("微软雅黑", 10)) 239 | label.pack(pady=5) 240 | 241 | # 多行 Text 替代原先 Entry,并绑定右键菜单 242 | token_text = tk.Text(frame, width=50, height=4, wrap="word", font=("微软雅黑", 9)) 243 | token_text.pack(pady=5) 244 | 245 | # 若已存在全局 token,则填入 246 | if access_token: 247 | token_text.insert("1.0", access_token) 248 | 249 | # 创建右键菜单,支持剪切、复制、粘贴 250 | token_context_menu = tk.Menu(token_text, tearoff=0) 251 | token_context_menu.add_command(label="剪切 (Ctrl+X)", command=lambda: token_text.event_generate("<>")) 252 | token_context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: token_text.event_generate("<>")) 253 | token_context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: token_text.event_generate("<>")) 254 | 255 | # 绑定右键点击事件 256 | def show_token_menu(event): 257 | token_context_menu.post(event.x_root, event.y_root) 258 | token_context_menu.bind("", lambda e: token_context_menu.unpost()) 259 | root.bind("", lambda e: token_context_menu.unpost(), add="+") 260 | 261 | token_text.bind("", show_token_menu) 262 | 263 | # 按下 Enter 键即可保存 token,并屏蔽换行事件 264 | def return_save_token(event): 265 | save_token() 266 | return "break" 267 | 268 | token_text.bind("", return_save_token) # 按下 Enter 键,保存 Access Token 269 | token_text.bind("", lambda e: "break") # 按下 Shift+Enter 也不换行,直接屏蔽 270 | 271 | # 保存按钮 272 | def save_token(): 273 | user_token = token_text.get("1.0", tk.END).strip() 274 | tip_info = set_access_token(user_token) 275 | # 重新启用下载按钮,并提示用户 276 | download_btn.config(state="normal") 277 | # 显示提示 278 | messagebox.showinfo("提示", tip_info) 279 | 280 | token_window.destroy() 281 | 282 | save_btn = ttk.Button(frame, text="保存", command=save_token) 283 | save_btn.pack(pady=5) 284 | 285 | # 帮助按钮 286 | def show_token_help(): 287 | help_win = tk.Toplevel(token_window) 288 | help_win.title("获取 Access Token 方法") 289 | 290 | help_win.focus_force() # 自动获得焦点 291 | help_win.grab_set() # 阻止主窗口操作 292 | help_win.bind("", lambda event: help_win.destroy()) # 绑定 Esc 键关闭窗口 293 | 294 | help_frame = ttk.Frame(help_win, padding=20) 295 | help_frame.pack(fill="both", expand=True) 296 | 297 | help_text = """\ 298 | 国家中小学智慧教育平台需要登录后才可获取教材,因此要使用本程序下载教材,您需要在平台内登录账号(如没有需注册),然后获得登录凭据(Access Token)。本程序仅保存该凭据至本地。 299 | 300 | 获取方法如下: 301 | 1. 打开浏览器,访问国家中小学智慧教育平台(https://auth.smartedu.cn/uias/login)并登录账号。 302 | 2. 按下 F12 或 Ctrl+Shift+I,或右键——检查(审查元素)打开开发者工具,选择控制台(Console)。 303 | 3. 在控制台粘贴以下代码后回车(Enter): 304 | --------------------------------------------------------- 305 | (function() { 306 | const authKey = Object.keys(localStorage).find(key => key.startsWith("ND_UC_AUTH")); 307 | if (!authKey) { 308 | console.error("未找到 Access Token,请确保已登录!"); 309 | return; 310 | } 311 | const tokenData = JSON.parse(localStorage.getItem(authKey)); 312 | const accessToken = JSON.parse(tokenData.value).access_token; 313 | console.log("%cAccess Token:", "color: green; font-weight: bold", accessToken); 314 | })(); 315 | --------------------------------------------------------- 316 | 然后在控制台输出中即可看到 Access Token。将其复制后粘贴到本程序中。""" 317 | 318 | # 只读文本区,支持选择复制 319 | txt = tk.Text(help_frame, wrap="word", font=("微软雅黑", 9)) 320 | txt.insert("1.0", help_text) 321 | txt.config(state="disabled") 322 | txt.pack(fill="both", expand=True) 323 | 324 | # 同样可给帮助文本区绑定右键菜单 325 | help_menu = tk.Menu(txt, tearoff=0) 326 | help_menu.add_command(label="复制 (Ctrl+C)", command=lambda: txt.event_generate("<>")) 327 | def show_help_menu(event): 328 | help_menu.post(event.x_root, event.y_root) 329 | help_menu.bind("", lambda e: help_menu.unpost()) 330 | root.bind("", lambda e: help_menu.unpost(), add="+") 331 | 332 | txt.bind("", show_help_menu) 333 | 334 | help_btn = ttk.Button(frame, text="如何获取?", command=show_token_help) 335 | help_btn.pack(pady=5) 336 | 337 | # 让弹窗居中 338 | token_window.update_idletasks() 339 | w = token_window.winfo_width() 340 | h = token_window.winfo_height() 341 | ws = token_window.winfo_screenwidth() 342 | hs = token_window.winfo_screenheight() 343 | x = (ws // 2) - (w // 2) 344 | y = (hs // 2) - (h // 2) 345 | token_window.geometry(f"{w}x{h}+{x}+{y}") 346 | token_window.lift() # 置顶可见 347 | 348 | class resource_helper: # 获取网站上资源的数据 349 | def parse_hierarchy(self, hierarchy): # 解析层级数据 350 | if not hierarchy: # 如果没有层级数据,返回空 351 | return None 352 | 353 | parsed = {} 354 | for h in hierarchy: 355 | for ch in h["children"]: 356 | parsed[ch["tag_id"]] = { "display_name": ch["tag_name"], "children": self.parse_hierarchy(ch["hierarchies"]) } 357 | return parsed 358 | 359 | def fetch_book_list(self): # 获取课本列表 360 | # 获取电子课本层级数据 361 | tags_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/tags/tch_material_tag.json") 362 | tags_data = tags_resp.json() 363 | parsed_hier = self.parse_hierarchy(tags_data["hierarchies"]) 364 | 365 | # 获取电子课本 URL 列表 366 | list_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/resources/tch_material/version/data_version.json") 367 | list_data: list[str] = list_resp.json()["urls"].split(",") 368 | 369 | # 获取电子课本列表 370 | for url in list_data: 371 | book_resp = session.get(url) 372 | book_data: list[dict] = book_resp.json() 373 | for book in book_data: 374 | if len(book["tag_paths"]) > 0: # 某些非课本资料的 tag_paths 属性为空数组 375 | # 解析课本层级数据 376 | tag_paths: list[str] = book["tag_paths"][0].split("/")[2:] # 电子课本 tag_paths 的前两项为“教材”、“电子教材” 377 | 378 | # 如果课本层级数据不在层级数据中,跳过 379 | temp_hier = parsed_hier[book["tag_paths"][0].split("/")[1]] 380 | if not tag_paths[0] in temp_hier["children"]: 381 | continue 382 | 383 | # 分别解析课本层级 384 | for p in tag_paths: 385 | if temp_hier["children"] and temp_hier["children"].get(p): 386 | temp_hier = temp_hier["children"].get(p) 387 | if not temp_hier["children"]: 388 | temp_hier["children"] = {} 389 | 390 | book["display_name"] = book["title"] if "title" in book else book["name"] if "name" in book else f"(未知电子课本 {book["id"]})" 391 | 392 | temp_hier["children"][book["id"]] = book 393 | 394 | return parsed_hier 395 | 396 | def fetch_lesson_list(self): # 获取课件列表 397 | # 获取课件层级数据 398 | tags_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/tags/national_lesson_tag.json") 399 | tags_data = tags_resp.json() 400 | parsed_hier = self.parse_hierarchy([{ "children": [{ "tag_id": "__internal_national_lesson", "hierarchies": tags_data["hierarchies"], "tag_name": "课件资源" }] }]) 401 | 402 | # 获取课件 URL 列表 403 | list_resp = session.get("https://s-file-1.ykt.cbern.com.cn/zxx/ndrs/national_lesson/teachingmaterials/version/data_version.json") 404 | list_data: list[str] = list_resp.json()["urls"] 405 | 406 | # 获取课件列表 407 | for url in list_data: 408 | lesson_resp = session.get(url) 409 | lesson_data: list[dict] = lesson_resp.json() 410 | for lesson in lesson_data: 411 | if len(lesson["tag_list"]) > 0: 412 | # 解析课件层级数据 413 | tag_paths: list[str] = [tag["tag_id"] for tag in sorted(lesson["tag_list"], key=lambda tag: tag["order_num"])] 414 | 415 | # 分别解析课件层级 416 | temp_hier = parsed_hier["__internal_national_lesson"] 417 | for p in tag_paths: 418 | if temp_hier["children"] and temp_hier["children"].get(p): 419 | temp_hier = temp_hier["children"].get(p) 420 | if not temp_hier["children"]: 421 | temp_hier["children"] = {} 422 | 423 | lesson["display_name"] = lesson["title"] if "title" in lesson else lesson["name"] if "name" in lesson else f"(未知课件 {lesson["id"]})" 424 | 425 | temp_hier["children"][lesson["id"]] = lesson 426 | 427 | return parsed_hier 428 | 429 | def fetch_resource_list(self): # 获取资源列表 430 | book_hier = self.fetch_book_list() 431 | # lesson_hier = self.fetch_lesson_list() # 目前此函数代码存在问题 432 | return { **book_hier } 433 | 434 | def thread_it(func, args: tuple = ()) -> None: # args 为元组,且默认值是空元组 435 | # 打包函数到线程 436 | t = threading.Thread(target=func, args=args) 437 | # t.daemon = True 438 | t.start() 439 | 440 | # 初始化请求 441 | session = requests.Session() 442 | # 初始化下载状态 443 | download_states = [] 444 | # 设置请求头部,包含认证信息 445 | access_token = None 446 | headers = { "X-ND-AUTH": 'MAC id="0",nonce="0",mac="0"' } # “MAC id”等同于“access_token”,“nonce”和“mac”不可缺省但无需有效 447 | session.proxies = { "http": None, "https": None } # 全局忽略代理 448 | 449 | def load_access_token() -> None: # 读取本地存储的 Access Token 450 | global access_token 451 | try: 452 | if os_name == "Windows": # 在 Windows 上,从注册表读取 453 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\tchMaterial-parser", 0, winreg.KEY_READ) as key: 454 | token, _ = winreg.QueryValueEx(key, "AccessToken") 455 | if token: 456 | access_token = token 457 | # 更新请求头 458 | headers["X-ND-AUTH"] = f'MAC id="{access_token}",nonce="0",mac="0"' 459 | elif os_name == "Linux": # 在 Linux 上,从 ~/.config/tchMaterial-parser/data.json 文件读取 460 | # 构建文件路径 461 | target_file = os.path.join( 462 | os.path.expanduser("~"), # 获取当前用户主目录 463 | ".config", 464 | "tchMaterial-parser", 465 | "data.json" 466 | ) 467 | if not os.path.exists(target_file): # 文件不存在则不做处理 468 | return 469 | 470 | # 读取 JSON 文件 471 | with open(target_file, "r") as f: 472 | data = json.load(f) 473 | # 提取 access_token 字段 474 | access_token = data["access_token"] 475 | 476 | except Exception: 477 | pass # 读取失败则不做处理 478 | 479 | def set_access_token(token: str) -> str: # 设置并更新 Access Token 480 | global access_token 481 | access_token = token 482 | headers["X-ND-AUTH"] = f'MAC id="{access_token}",nonce="0",mac="0"' 483 | 484 | try: 485 | if os_name == "Windows": # 在 Windows 上,将 Access Token 写入注册表 486 | with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Software\\tchMaterial-parser") as key: 487 | winreg.SetValueEx(key, "AccessToken", 0, winreg.REG_SZ, token) 488 | return "Access Token 已保存!\n已写入注册表:HKEY_CURRENT_USER\\Software\\tchMaterial-parser\\AccessToken" 489 | elif os_name == "Linux": # 在 Linux 上,将 Access Token 保存至 ~/.config/tchMaterial-parser/data.json 文件中 490 | # 构建目标目录和文件路径 491 | target_dir = os.path.join( 492 | os.path.expanduser("~"), 493 | ".config", 494 | "tchMaterial-parser" 495 | ) 496 | target_file = os.path.join(target_dir, "data.json") 497 | # 创建目录(如果不存在) 498 | os.makedirs(target_dir, exist_ok=True) 499 | 500 | # 构建要保存的数据字典 501 | data = { "access_token": token } 502 | # 写入 JSON 文件 503 | with open(target_file, "w") as f: 504 | json.dump(data, f, indent=4) 505 | 506 | return "Access Token 已保存!\n已写入文件:~/.config/tchMaterial-parser/data.json" 507 | else: 508 | return "Access Token 已保存!" 509 | except Exception: 510 | return "Access Token 已保存!" 511 | 512 | # 立即尝试加载已存的 Access Token(如果有的话) 513 | load_access_token() 514 | 515 | # 获取资源列表 516 | try: 517 | resource_list = resource_helper().fetch_resource_list() 518 | except Exception: 519 | resource_list = {} 520 | messagebox.showwarning("警告", "获取资源列表失败,请手动填写资源链接,或重新打开本程序") # 弹出警告窗口 521 | 522 | # GUI 523 | root = tk.Tk() 524 | 525 | # 高 DPI 适配 526 | if os_name == "Windows": 527 | scale: float = round(win32print.GetDeviceCaps(win32gui.GetDC(0), win32con.DESKTOPHORZRES) / win32api.GetSystemMetrics(0), 2) # 获取当前的缩放因子 528 | 529 | # 调用 API 设置成由应用程序缩放 530 | try: # Windows 8.1 或更新 531 | ctypes.windll.shcore.SetProcessDpiAwareness(2) 532 | except Exception: # Windows 8 或更老 533 | ctypes.windll.user32.SetProcessDPIAware() 534 | else: # 在非 Windows 操作系统上,通过 Tkinter 估算缩放因子 535 | try: 536 | scale: float = round(root.winfo_fpixels("1i") / 96.0, 2) 537 | except Exception: 538 | scale = 1.0 539 | 540 | root.tk.call("tk", "scaling", scale / 0.75) # 设置缩放因子 541 | 542 | root.title("国家中小学智慧教育平台 资源下载工具 v3.1") # 设置窗口标题 543 | # root.geometry("900x600") # 设置窗口大小 544 | 545 | def set_icon() -> None: # 设置窗口图标 546 | icon = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAN8AAADfCAYAAAEB/ja6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAE7mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDIgNzkuYTZhNjM5NiwgMjAyNC8wMy8xMi0wNzo0ODoyMyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjkgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNC0wOC0xOVQxNDozNzo1MyswODowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDgtMTlUMTQ6Mzg6MjQrMDg6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmRjMWFiMTUxLTkzYzUtMGI0MS1hYWNiLTYxYzFhMmIyNTczOSIgeG1wTU06RG9jdW1lbnRJRD0i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") 547 | with open(tempfile.gettempdir() + "/icon.png", "wb") as f: 548 | f.write(icon) 549 | 550 | icon = tk.PhotoImage(file=tempfile.gettempdir() + "/icon.png") 551 | root.iconphoto(True, icon) 552 | root._icon_ref = icon # 为防止图片被垃圾回收,保存引用 553 | 554 | set_icon() # 设置窗口图标 555 | 556 | def on_closing() -> None: # 处理窗口关闭事件 557 | if not all(state["finished"] for state in download_states): # 当正在下载时,询问用户 558 | if not messagebox.askokcancel("提示", "下载任务未完成,是否退出?"): 559 | return 560 | 561 | current_process = psutil.Process(os.getpid()) # 获取自身的进程 ID 562 | child_processes = current_process.children(recursive=True) # 获取自身的所有子进程 563 | 564 | for child in child_processes: # 结束所有子进程 565 | try: 566 | child.terminate() # 结束进程 567 | except Exception: # 进程可能已经结束 568 | pass 569 | 570 | # 结束自身进程 571 | sys.exit(0) 572 | 573 | root.protocol("WM_DELETE_WINDOW", on_closing) # 注册窗口关闭事件的处理函数 574 | 575 | # 创建一个容器框架 576 | container_frame = ttk.Frame(root) 577 | container_frame.pack(anchor="center", expand="yes", padx=int(40 * scale), pady=int(20 * scale)) # 在容器的中心位置放置,允许组件在容器中扩展,水平外边距 40,垂直外边距 40 578 | 579 | title_label = ttk.Label(container_frame, text="国家中小学智慧教育平台 资源下载工具", font=("微软雅黑", 16, "bold")) # 添加标题标签 580 | title_label.pack(pady=int(5 * scale)) # 设置垂直外边距(跟随缩放) 581 | 582 | description = """\ 583 | 📌 请在下面的文本框中输入一个或多个资源页面的网址(每个网址一行)。 584 | 🔗 资源页面网址示例: 585 | https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=... 586 | 📝 您也可以直接在下方的选项卡中选择教材。 587 | 📥 点击 “下载” 按钮后,程序会解析并下载资源。 588 | ⚠️ 注:为了更可靠地下载,建议点击 “设置 Token” 按钮,参照里面的说明完成设置。""" 589 | description_label = ttk.Label(container_frame, text=description, justify="left", font=("微软雅黑", 9)) # 添加描述标签 590 | description_label.pack(pady=int(5 * scale)) # 设置垂直外边距(跟随缩放) 591 | 592 | url_text = tk.Text(container_frame, width=70, height=12, font=("微软雅黑", 9)) # 添加 URL 输入框,长度和宽度不使用缩放!!! 593 | url_text.pack(padx=int(15 * scale), pady=int(15 * scale)) # 设置水平外边距、垂直外边距(跟随缩放) 594 | 595 | # 创建右键菜单 596 | context_menu = tk.Menu(root, tearoff=0) 597 | context_menu.add_command(label="剪切 (Ctrl+X)", command=lambda: url_text.event_generate("<>")) 598 | context_menu.add_command(label="复制 (Ctrl+C)", command=lambda: url_text.event_generate("<>")) 599 | context_menu.add_command(label="粘贴 (Ctrl+V)", command=lambda: url_text.event_generate("<>")) 600 | 601 | def show_context_menu(event): 602 | context_menu.post(event.x_root, event.y_root) 603 | # 绑定失焦事件,失焦时自动关闭菜单 604 | context_menu.bind("", lambda e: context_menu.unpost()) 605 | # 绑定左键点击事件,点击其他地方也关闭菜单 606 | root.bind("", lambda e: context_menu.unpost(), add="+") 607 | 608 | # 绑定右键菜单到文本框(3 代表鼠标的右键按钮) 609 | url_text.bind("", show_context_menu) 610 | 611 | options = [["---"] + [resource_list[k]["display_name"] for k in resource_list], ["---"], ["---"], ["---"], ["---"], ["---"], ["---"], ["---"]] # 构建选择项 612 | 613 | 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)] 614 | 615 | # 处理用户选择事件 616 | event_flag = False # 防止事件循环调用 617 | def selection_handler(index: int, *args) -> None: 618 | global event_flag 619 | 620 | if event_flag: 621 | event_flag = False # 检测到循环调用,重置标志位并返回 622 | return 623 | 624 | if variables[index].get() == "---": # 重置后面的选择项 625 | for i in range(index + 1, len(drops)): 626 | drops[i]["menu"].delete(0, "end") 627 | drops[i]["menu"].add_command(label="---", command=tk._setit(variables[i], "---")) 628 | 629 | event_flag = True 630 | variables[i].set("---") 631 | # drops[i]["menu"].configure(state="disabled") 632 | return 633 | 634 | if index < len(drops) - 1: # 更新选择项 635 | current_drop = drops[index + 1] 636 | 637 | current_hier = resource_list 638 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[0].get()][0] 639 | current_hier = current_hier[current_id]["children"] 640 | 641 | end_flag = False # 是否到达最终目标 642 | for i in range(index): 643 | try: 644 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[i + 1].get()][0] 645 | current_hier = current_hier[current_id]["children"] 646 | except KeyError: # 无法继续向下选择,说明已经到达最终目标 647 | end_flag = True 648 | break 649 | 650 | if not current_hier or end_flag: 651 | current_options = ["---"] 652 | else: 653 | current_options = ["---"] + [current_hier[k]["display_name"] for k in current_hier.keys()] 654 | 655 | current_drop["menu"].delete(0, "end") 656 | for choice in current_options: 657 | current_drop["menu"].add_command(label=choice, command=tk._setit(variables[index + 1], choice)) 658 | 659 | if end_flag: # 到达目标,显示 URL 660 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[index].get()][0] 661 | resource_type = current_hier[current_id]["resource_type_code"] or "assets_document" 662 | if url_text.get("1.0", tk.END) == "\n": # URL 输入框为空的时候,插入的内容前面不加换行 663 | url_text.insert("end", f"https://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial") 664 | else: 665 | url_text.insert("end", f"\nhttps://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial") 666 | drops[-1]["menu"].delete(0, "end") 667 | drops[-1]["menu"].add_command(label="---", command=tk._setit(variables[-1], "---")) 668 | variables[-1].set("---") 669 | 670 | for i in range(index + 2, len(drops)): # 重置后面的选择项 671 | drops[i]["menu"].delete(0, "end") 672 | drops[i]["menu"].add_command(label="---", command=tk._setit(variables[i], "---")) 673 | # drops[i]["menu"].configure(state="disabled") 674 | 675 | for i in range(index + 1, len(drops)): 676 | event_flag = True 677 | variables[i].set("---") 678 | 679 | else: # 最后一项,必为最终目标,显示 URL 680 | if variables[-1].get() == "---": 681 | return 682 | 683 | current_hier = resource_list 684 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[0].get()][0] 685 | current_hier = current_hier[current_id]["children"] 686 | for i in range(index - 1): 687 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[i + 1].get()][0] 688 | current_hier = current_hier[current_id]["children"] 689 | 690 | current_id = [element for element in current_hier if current_hier[element]["display_name"] == variables[index].get()][0] 691 | resource_type = current_hier[current_id]["resource_type_code"] or "assets_document" 692 | if url_text.get("1.0", tk.END) == "\n": # URL 输入框为空的时候,插入的内容前面不加换行 693 | url_text.insert("end", f"https://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial") 694 | else: 695 | url_text.insert("end", f"\nhttps://basic.smartedu.cn/tchMaterial/detail?contentType={resource_type}&contentId={current_id}&catalogType=tchMaterial&subCatalog=tchMaterial") 696 | 697 | for index in range(8): # 绑定事件 698 | variables[index].trace_add("write", partial(selection_handler, index)) 699 | 700 | # 添加 Container 701 | dropdown_frame = ttk.Frame(root) 702 | dropdown_frame.pack(padx=int(10 * scale), pady=int(10 * scale)) 703 | 704 | drops = [] 705 | 706 | # 添加菜单栏 707 | for i in range(8): 708 | drop = ttk.OptionMenu(dropdown_frame, variables[i], *options[i]) 709 | drop.config(state="active") # 配置下拉菜单为始终活跃状态,保证下拉菜单一直有形状 710 | drop.bind("", lambda e: "break") # 绑定鼠标移出事件,当鼠标移出下拉菜单时,执行 lambda 函数,“break”表示中止事件传递 711 | drop.grid(row=i // 4, column=i % 4, padx=int(15 * scale), pady=int(15 * scale)) # 设置位置,2 行 4 列(跟随缩放) 712 | variables[i].set("---") 713 | drops.append(drop) 714 | 715 | # 按钮:设置 Token 716 | token_btn = ttk.Button(container_frame, text="设置 Token", command=show_access_token_window) 717 | token_btn.pack(side="left", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale)) 718 | 719 | # 按钮:下载 720 | download_btn = ttk.Button(container_frame, text="下载", command=download) 721 | download_btn.pack(side="right", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale)) 722 | 723 | # 按钮:解析并复制 724 | copy_btn = ttk.Button(container_frame, text="解析并复制", command=parse_and_copy) 725 | copy_btn.pack(side="right", padx=int(5 * scale), pady=int(5 * scale), ipady=int(5 * scale)) 726 | 727 | # 下载进度条 728 | download_progress_bar = ttk.Progressbar(container_frame, length=(125 * scale), mode="determinate") # 添加下载进度条 729 | download_progress_bar.pack(side="bottom", padx=int(40 * scale), pady=int(10 * scale), ipady=int(5 * scale)) # 设置水平外边距、垂直外边距(跟随缩放),设置进度条高度(跟随缩放) 730 | 731 | # 下载进度标签 732 | progress_label = ttk.Label(container_frame, text="等待下载", anchor="center") # 初始时文本为空,居中 733 | progress_label.pack(side="bottom", padx=int(5 * scale), pady=int(5 * scale)) # 设置水平外边距、垂直外边距(跟随缩放),设置标签高度(跟随缩放) 734 | 735 | root.mainloop() # 开始主循环 736 | -------------------------------------------------------------------------------- /tchMaterial-parser.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['src/tchMaterial-parser.pyw'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='tchMaterial-parser', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=False, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | version='version.txt', 39 | icon=['src/favicon_48x48.ico'], 40 | ) 41 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | VSVersionInfo( 2 | ffi=FixedFileInfo( 3 | filevers=(3, 1, 0, 0), 4 | prodvers=(3, 1, 0, 0), 5 | mask=0x3f, 6 | flags=0x0, 7 | OS=0x4, 8 | fileType=0x1, 9 | subtype=0x0, 10 | date=(0, 0) 11 | ), 12 | kids=[ 13 | StringFileInfo([ 14 | StringTable( 15 | '080404B0', 16 | [ 17 | StringStruct('CompanyName', '肥宅水水呀'), 18 | StringStruct('FileDescription', '国家中小学智慧教育平台 资源下载工具'), 19 | StringStruct('FileVersion', '3.1.0.0'), 20 | StringStruct('InternalName', 'tchMaterial-parser'), 21 | StringStruct('LegalCopyright', 'Copyright © 2025 肥宅水水呀'), 22 | StringStruct('OriginalFilename', 'tchMaterial-parser.exe'), 23 | StringStruct('ProductName', '国家中小学智慧教育平台 资源下载工具'), 24 | StringStruct('ProductVersion', '3.1.0.0'), 25 | StringStruct('Comments', '国中小学智慧教育平台 资源下载工具') 26 | ] 27 | ) 28 | ]), 29 | VarFileInfo([VarStruct('Translation', [2052, 1200])]) 30 | ] 31 | ) 32 | --------------------------------------------------------------------------------