├── server ├── course_helper │ ├── __init__.py │ ├── routers │ │ ├── __init__.py │ │ ├── file.py │ │ ├── websocket.py │ │ ├── user.py │ │ └── course.py │ ├── common.py │ ├── xmu_slider.py │ ├── logger.py │ └── download.py ├── bin │ ├── get_file_paths.py │ └── main.py ├── requirements.txt └── .gitignore ├── app ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── assets │ │ ├── cat.jpg │ │ ├── girl.jpg │ │ └── logo.png │ ├── style │ │ ├── common.css │ │ └── scrollbar.css │ ├── main.js │ ├── utils │ │ ├── common.js │ │ ├── ipcHelper.js │ │ ├── api.js │ │ ├── wsHelper.js │ │ └── encrypt.js │ ├── components │ │ ├── NavButton.vue │ │ ├── CourseItem.vue │ │ ├── TopBar.vue │ │ ├── UserInfo.vue │ │ ├── DownloadModal.vue │ │ ├── DownloadQueueItem.vue │ │ ├── HomeworkItem.vue │ │ ├── DownloadRecordsItem.vue │ │ ├── NavigationBar.vue │ │ ├── LoginForm.vue │ │ ├── WangEditor.vue │ │ ├── HomeworkDetailsItem.vue │ │ └── CourseResourcePane.vue │ ├── router │ │ └── index.js │ ├── preload.js │ ├── views │ │ ├── Login.vue │ │ ├── CourseList.vue │ │ ├── HomeworkDetails.vue │ │ ├── Download.vue │ │ └── Course.vue │ ├── background.js │ ├── App.vue │ └── store │ │ └── index.js ├── babel.config.js ├── jsconfig.json ├── .gitignore ├── vue.config.js └── package.json ├── LICENSE └── README.md /server/course_helper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/course_helper/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiChase/Course-Helper/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /app/src/assets/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiChase/Course-Helper/HEAD/app/src/assets/cat.jpg -------------------------------------------------------------------------------- /app/src/assets/girl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiChase/Course-Helper/HEAD/app/src/assets/girl.jpg -------------------------------------------------------------------------------- /app/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiChase/Course-Helper/HEAD/app/src/assets/logo.png -------------------------------------------------------------------------------- /app/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /app/src/style/common.css: -------------------------------------------------------------------------------- 1 | .no-select { 2 | user-select: none; 3 | } 4 | 5 | .no-drag { 6 | -webkit-user-drag: none; 7 | } 8 | 9 | div.n-empty__description{ 10 | user-select: none; 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main.js: -------------------------------------------------------------------------------- 1 | import {createApp} from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | 6 | createApp(App) 7 | .use(store) 8 | .use(router) 9 | .mount('#app') 10 | 11 | -------------------------------------------------------------------------------- /app/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "baseUrl": "./", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ] 11 | }, 12 | "lib": [ 13 | "esnext", 14 | "dom", 15 | "dom.iterable", 16 | "scripthost" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/bin/get_file_paths.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | out = [] 5 | 6 | g = os.walk(r'..\course_helper') 7 | for path, dir_list, file_list in g: 8 | for file_name in file_list: 9 | t = os.path.abspath(os.path.join(path, file_name)) 10 | if t.find('__pycache') > -1: 11 | continue 12 | out.append(t) 13 | print(json.dumps(out)) 14 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | #Electron-builder output 26 | /dist_electron 27 | 28 | #Custom 29 | *.pid 30 | -------------------------------------------------------------------------------- /app/src/style/scrollbar.css: -------------------------------------------------------------------------------- 1 | .scrollbar { 2 | overflow-y: hidden !important; 3 | } 4 | 5 | .scrollbar:hover { 6 | overflow-y: auto !important; 7 | } 8 | 9 | .scrollbar::-webkit-scrollbar { 10 | display: block; 11 | width: 5px; 12 | height: 5px; 13 | } 14 | 15 | .scrollbar::-webkit-scrollbar-thumb { 16 | border-radius: 3px; 17 | background-color: rgba(0, 0, 0, 0.25); 18 | } 19 | 20 | .scrollbar::-webkit-scrollbar-thumb:hover { 21 | background-color: rgba(0, 0, 0, 0.4); 22 | } 23 | -------------------------------------------------------------------------------- /app/src/utils/common.js: -------------------------------------------------------------------------------- 1 | export default { 2 | sendMsg(message, text, type = 'default', duration = 2500, otherOptions = {}) { 3 | return message.create(text, { 4 | type, 5 | duration, 6 | closable: true, 7 | keepAliveOnHover: true, 8 | ...otherOptions 9 | }) 10 | }, 11 | showLoading(loadingFlag) { 12 | loadingFlag.value = true 13 | }, 14 | hideLoading(loadingFlag) { 15 | loadingFlag.value = false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.2 2 | anyio==3.5.0 3 | asgiref==3.5.0 4 | certifi==2021.10.8 5 | charset-normalizer==2.0.12 6 | click==8.1.2 7 | colorama==0.4.4 8 | fastapi==0.75.2 9 | future==0.18.2 10 | h11==0.13.0 11 | idna==3.3 12 | loguru==0.6.0 13 | lxml==4.8.0 14 | nanoid==2.0.0 15 | pefile==2021.9.3 16 | Pillow==9.1.0 17 | psutil==5.9.1 18 | pydantic==1.9.0 19 | pyinstaller==5.1 20 | pyinstaller-hooks-contrib==2022.5 21 | pywin32-ctypes==0.2.0 22 | requests==2.27.1 23 | sniffio==1.2.0 24 | starlette==0.17.1 25 | typing_extensions==4.2.0 26 | urllib3==1.26.9 27 | uvicorn==0.17.6 28 | websockets==10.3 29 | win32-setctime==1.1.0 30 | -------------------------------------------------------------------------------- /server/course_helper/common.py: -------------------------------------------------------------------------------- 1 | def success_info(msg: str, success: int = 1, **kwargs) -> dict: 2 | out = { 3 | 'msg': msg, 4 | 'success': success 5 | } 6 | out.update(kwargs) 7 | return out 8 | 9 | 10 | def error_info(msg: str, success: int = 0, **kwargs) -> dict: 11 | out = { 12 | 'msg': msg, 13 | 'success': success 14 | } 15 | out.update(kwargs) 16 | return out 17 | 18 | 19 | class CourseHelperException(Exception): 20 | """ 21 | 自定义异常 22 | """ 23 | data: [dict, str] 24 | 25 | def __init__(self, data): 26 | self.data = data 27 | 28 | def __str__(self): 29 | return self.data 30 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 18 | 19 | 20 | 24 |
25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/components/NavButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 如初 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 | -------------------------------------------------------------------------------- /app/src/router/index.js: -------------------------------------------------------------------------------- 1 | import {createRouter, createWebHashHistory} from 'vue-router' 2 | import Login from "@/views/Login" 3 | import CourseList from "@/views/CourseList" 4 | import Course from "@/views/Course" 5 | import HomeworkDetails from "@/views/HomeworkDetails" 6 | import Download from "@/views/Download" 7 | 8 | const routes = [ 9 | { 10 | path: '/', 11 | name: 'login', 12 | component: Login 13 | }, 14 | { 15 | path: '/courseList', 16 | name: 'courseList', 17 | component: CourseList 18 | }, 19 | { 20 | path: '/course/:id?/:name?', 21 | name: 'course', 22 | component: Course, 23 | }, 24 | { 25 | path: '/download', 26 | name: 'download', 27 | component: Download, 28 | }, 29 | { 30 | path: '/homeworkDetails/:activeId?/:courseName?', 31 | name: 'homeworkDetails', 32 | component: HomeworkDetails, 33 | } 34 | ] 35 | 36 | const router = createRouter({ 37 | routes, 38 | history: createWebHashHistory() 39 | }) 40 | 41 | // 暴露给api模块 42 | window.$routerPush = router.push 43 | 44 | export default router 45 | -------------------------------------------------------------------------------- /app/vue.config.js: -------------------------------------------------------------------------------- 1 | const {defineConfig} = require('@vue/cli-service') 2 | module.exports = defineConfig({ 3 | transpileDependencies: true, 4 | pluginOptions: { 5 | electronBuilder: { 6 | preload: "src/preload.js", 7 | builderOptions: { 8 | "appId": "com.ruchuby.course", 9 | "productName": "CourseHelper", 10 | "extraResources":[{ 11 | "from": "../server/dist/server.exe", 12 | "to": "./server.exe", 13 | "filter": ["**/*", "!foo/*.js"] 14 | }], 15 | "win": { 16 | "icon": "dist_electron/icons/icon.png" 17 | }, 18 | "nsis": { 19 | "oneClick": false, 20 | "allowElevation": true, 21 | "allowToChangeInstallationDirectory": true, 22 | "deleteAppDataOnUninstall":true, 23 | "shortcutName":'course助手', 24 | "installerIcon":'dist_electron/icons/installer.ico', 25 | "uninstallerIcon":'dist_electron/icons/uninstaller.ico' 26 | } 27 | } 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /server/course_helper/xmu_slider.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | from PIL import Image 4 | 5 | 6 | def xmu_slider_code(base64_img): 7 | """ 8 | 获取滑块验证码 滑块相对坐标w 9 | """ 10 | 11 | # base64转pil 12 | img = base64_pil(base64_img[base64_img.find(";base64,") + 8:]) 13 | p = get_img_border(img, True) 14 | min_len = min([len(t) for t in p if len(t) > 0]) 15 | index = [len(t) for t in p].index(min_len) 16 | row = p[index] 17 | return row[int(min_len / 2 if min_len % 2 == 0 else (min_len + 1) / 2)] - 20 # 20为滑块宽度 18 | 19 | 20 | def base64_pil(base64_str) -> Image.Image: 21 | """ 22 | base64转pil图片 23 | """ 24 | image = base64.b64decode(base64_str) 25 | image = BytesIO(image) 26 | image = Image.open(image) 27 | return image 28 | 29 | 30 | def get_img_border(img, reverse=False) -> list: 31 | """ 32 | 滑块背景图片处理 33 | """ 34 | x, y = img.size 35 | pixel_map = img.load() 36 | out = [] 37 | for i in range(y): 38 | row = [] 39 | for j in range(x): 40 | t = pixel_map[(j, i)] 41 | if reverse: 42 | if t[3] < 255: # 说明是半透明点 43 | row.append(j) 44 | else: 45 | if t[3] > 0: # 说明是不透明点 46 | row.append(j) 47 | out.append(row) 48 | 49 | return out 50 | -------------------------------------------------------------------------------- /app/src/utils/ipcHelper.js: -------------------------------------------------------------------------------- 1 | import {app, dialog, ipcMain, shell} from 'electron' 2 | import path from 'path' 3 | import {exec} from "child_process"; 4 | 5 | export default (win) => { 6 | ipcMain.on('win:minimize', () => win.minimize()) 7 | ipcMain.on('win:maximize', () => win.isMaximized() ? win.unmaximize() : win.maximize()) 8 | ipcMain.on('win:close', () => win.destroy()) 9 | ipcMain.on('win:devTools', () => win.webContents.isDevToolsOpened() ? 10 | win.webContents.closeDevTools() : win.webContents.openDevTools()) 11 | 12 | ipcMain.handle('dialog:showOpenDialog', async (e, options) => dialog.showOpenDialog(options)) 13 | 14 | ipcMain.handle('app:getPath', async (e, name) => app.getPath(name)) 15 | ipcMain.handle('app:getFileIconUrl', async (e, filePath) => 16 | (await app.getFileIcon(filePath, {size: 'large'})).toDataURL()) 17 | 18 | ipcMain.handle('shell:showItemInFolder', async (e, filePath) => shell.showItemInFolder(filePath)) 19 | 20 | ipcMain.handle('download:downloadURL', async (e, url) => win.webContents.downloadURL(url)) 21 | 22 | ipcMain.handle('open:server', async (e, cmd) => { 23 | const serverPath = process.env.NODE_ENV !== 'production' ? 24 | path.join(__dirname, "../../server/dist/server.exe") : path.join(process.cwd(), "/resources/server.exe") 25 | exec(`start ${serverPath} ${cmd}`) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "course_helper", 3 | "version": "0.1.5", 4 | "private": true, 5 | "author": "ruchuby", 6 | "description": "XMU Course Helper!", 7 | "scripts": { 8 | "serve": "vue-cli-service serve", 9 | "build": "vue-cli-service build", 10 | "electron:build": "vue-cli-service electron:build", 11 | "electron:serve": "vue-cli-service electron:serve", 12 | "postinstall": "electron-builder install-app-deps", 13 | "postuninstall": "electron-builder install-app-deps" 14 | }, 15 | "main": "background.js", 16 | "dependencies": { 17 | "@wangeditor/editor": "^5.1.1", 18 | "@wangeditor/editor-for-vue": "^5.1.11", 19 | "@wangeditor/plugin-upload-attachment": "^1.0.0", 20 | "axios": "^0.27.2", 21 | "core-js": "^3.8.3", 22 | "electron-store": "^8.0.1", 23 | "vue": "^3.2.13", 24 | "vue-router": "^4.0.3", 25 | "vuex": "^4.0.0" 26 | }, 27 | "devDependencies": { 28 | "@vicons/fa": "^0.12.0", 29 | "@vicons/ionicons5": "^0.12.0", 30 | "@vicons/tabler": "^0.12.0", 31 | "@vue/cli-plugin-babel": "~5.0.0", 32 | "@vue/cli-plugin-router": "~5.0.0", 33 | "@vue/cli-plugin-vuex": "~5.0.0", 34 | "@vue/cli-service": "~5.0.0", 35 | "electron": "13.0.0", 36 | "electron-builder": "^23.0.3", 37 | "electron-devtools-installer": "^3.1.0", 38 | "naive-ui": "^2.28.2", 39 | "vue-cli-plugin-electron-builder": "^2.1.1" 40 | }, 41 | "browserslist": [ 42 | "> 1%", 43 | "last 2 versions", 44 | "not dead", 45 | "not ie 11" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /server/course_helper/logger.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from enum import Enum, unique 3 | 4 | 5 | class Logger: 6 | """ 7 | loguru二次封装,实现不同名称的logger对应不同的消息前缀 8 | """ 9 | logger_dict: dict = {} 10 | 11 | @unique 12 | class Level(Enum): 13 | DEBUG = 1 14 | INFO = 2 15 | SUCCESS = 3 16 | WARNING = 4 17 | ERROR = 5 18 | 19 | @classmethod 20 | def __add(cls, _logger): 21 | cls.logger_dict[_logger.log_name] = _logger 22 | 23 | @classmethod 24 | def get_logger(cls, name: str): 25 | if name not in cls.logger_dict: 26 | raise Exception(f"the logger with name '{name}' doesn't exists") 27 | return cls.logger_dict[name] 28 | 29 | def __init__(self, log_name: str): 30 | if log_name in Logger.logger_dict: 31 | raise Exception(f"the logger with name '{log_name}' already exists") 32 | 33 | self.log_name = log_name 34 | # 添加到Logger字典中 35 | Logger.__add(self) 36 | 37 | def debug(self, msg, *args, **kwargs): 38 | return logger.debug(f'[{self.log_name}]\t{msg}', __log_name=self.log_name, *args, **kwargs) 39 | 40 | def info(self, msg, *args, **kwargs): 41 | return logger.info(f'[{self.log_name}]\t{msg}', __log_name=self.log_name, *args, **kwargs) 42 | 43 | def success(self, msg, *args, **kwargs): 44 | return logger.success(f'[{self.log_name}]\t{msg}', __log_name=self.log_name, *args, **kwargs) 45 | 46 | def warning(self, msg, *args, **kwargs): 47 | return logger.warning(f'[{self.log_name}]\t{msg}', __log_name=self.log_name, *args, **kwargs) 48 | 49 | def error(self, msg, *args, **kwargs): 50 | return logger.error(f'[{self.log_name}]\t{msg}', __log_name=self.log_name, *args, **kwargs) 51 | -------------------------------------------------------------------------------- /app/src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import store from "@/store" 3 | 4 | 5 | function noLoginCheck(e, reject) { 6 | if (e?.response?.data?.detail?.msg === '用户未登录') { 7 | store.dispatch('logout').then(() => { 8 | window.$routerPush({name: 'login'}).then(() => { 9 | reject('用户未登录') 10 | }) 11 | }) 12 | } else if (e?.message.indexOf('timeout') > -1) reject('请求超时') 13 | else reject(e?.response?.data?.detail?.msg ?? '未知错误,请求失败') 14 | } 15 | 16 | export default { 17 | get(url, params = {}, timeout=5000) { 18 | return new Promise((resolve, reject) => { 19 | if (!store.state.connectState) { 20 | reject('服务端未连接!') 21 | return 22 | } 23 | axios.get(url, {timeout, params}).then(res => { 24 | if (!res.data?.success) { 25 | console.error('api get请求失败', res.data) 26 | reject(res.data.detail.msg ?? '未知错误,请求失败') 27 | } else { 28 | resolve(res.data) 29 | } 30 | }).catch(e => { 31 | console.error('api get请求失败', e) 32 | noLoginCheck(e, reject) 33 | }) 34 | }) 35 | }, 36 | post(url, data, timeout=5000) { 37 | return new Promise((resolve, reject) => { 38 | if (!store.state.connectState) { 39 | reject('服务端未连接!') 40 | return 41 | } 42 | axios.post(url, data, {timeout}).then(res => { 43 | if (!res.data?.success) { 44 | console.error('api post请求失败', res.data) 45 | reject(res.data.detail.msg ?? '未知错误,请求失败') 46 | } else { 47 | resolve(res.data) 48 | } 49 | }).catch(e => { 50 | console.error('api post请求失败', e) 51 | noLoginCheck(e, reject) 52 | }) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/preload.js: -------------------------------------------------------------------------------- 1 | import {contextBridge, ipcRenderer, clipboard} from 'electron' 2 | import path from "path"; 3 | import fs from "fs"; 4 | 5 | const Store = require('electron-store'); 6 | const electronStore = new Store() 7 | 8 | 9 | contextBridge.exposeInMainWorld('$electron', { 10 | win: { 11 | minimize: () => ipcRenderer.send('win:minimize'), 12 | maximize: () => ipcRenderer.send('win:maximize'), 13 | close: () => ipcRenderer.send('win:close'), 14 | devTools: () => ipcRenderer.send('win:devTools'), 15 | }, 16 | store: { 17 | set: (key, val) => electronStore.set(key, val), 18 | del: (key) => electronStore.delete(key), 19 | get: (key, defaultValue = undefined) => electronStore.get(key, defaultValue), 20 | has: (key) => electronStore.has(key), 21 | setWithObj: (obj) => electronStore.set(obj) 22 | }, 23 | utils: { 24 | mkDir: (...args) => { 25 | const finalPath = path.join(...args) 26 | if (!fs.existsSync(finalPath)) fs.mkdirSync(finalPath, {recursive: true}) 27 | return finalPath 28 | }, 29 | fExists: (path) => fs.existsSync(path), 30 | server: async (cmd) => await ipcRenderer.invoke('open:server', cmd), 31 | clipboard: { 32 | writeText: (text) => clipboard.writeText(text) 33 | }, 34 | shell: { 35 | showItemInFolder: async (path) => await ipcRenderer.invoke('shell:showItemInFolder', path), 36 | }, 37 | app: { 38 | getPath: async (name) => await ipcRenderer.invoke('app:getPath', name), 39 | getFileIcon: async (filePath) => await ipcRenderer.invoke('app:getFileIconUrl', filePath), 40 | }, 41 | dialog: { 42 | showOpenDialog: async (options) => await ipcRenderer.invoke('dialog:showOpenDialog', options) 43 | } 44 | } 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /app/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 63 | 64 | 82 | -------------------------------------------------------------------------------- /app/src/components/CourseItem.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 56 | 57 | 81 | -------------------------------------------------------------------------------- /app/src/components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | 61 | 91 | -------------------------------------------------------------------------------- /app/src/background.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {app, BrowserWindow, protocol, screen} from 'electron' 4 | import {createProtocol} from 'vue-cli-plugin-electron-builder/lib' 5 | import path from 'path' 6 | 7 | import ipcHelper from "@/utils/ipcHelper" 8 | import {exec} from "child_process"; 9 | 10 | 11 | const isDevelopment = process.env.NODE_ENV !== 'production' 12 | 13 | protocol.registerSchemesAsPrivileged([{scheme: 'app', privileges: {secure: true}}]) 14 | 15 | 16 | let win //全局 BrowserWindow 17 | 18 | async function createWindow() { 19 | const screenArea = screen.getPrimaryDisplay().workAreaSize 20 | win = new BrowserWindow({ 21 | width: Math.round(screenArea.width * 0.6), 22 | height: Math.round(screenArea.width * 0.45), 23 | minHeight: 600, 24 | minWidth: 800, 25 | frame: false, //关闭默认标题栏 26 | transparent: true, 27 | webPreferences: { 28 | preload: path.join(__dirname, "preload.js") 29 | } 30 | }) 31 | 32 | const Store = require('electron-store'); 33 | Store.initRenderer(); 34 | 35 | 36 | if (process.env.WEBPACK_DEV_SERVER_URL) { 37 | // Load the url of the dev server if in development mode 38 | await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) 39 | } else { 40 | createProtocol('app') 41 | // Load the index.html when not in development 42 | await win.loadURL('app://./index.html') 43 | } 44 | if (isDevelopment) win.webContents.openDevTools() //打开开发者工具 45 | } 46 | 47 | app.on('window-all-closed', () => { 48 | if (process.platform !== 'darwin') { 49 | app.quit() 50 | } 51 | }) 52 | 53 | app.on('activate', () => { 54 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 55 | }) 56 | 57 | app.on('ready', () => { 58 | createWindow().then(async () => { 59 | ipcHelper(win) 60 | const serverPath = isDevelopment ? 61 | path.join(__dirname, "../../server/dist/server.exe") : path.join(process.cwd(), "/resources/server.exe") 62 | exec(`start ${serverPath}`) 63 | }) 64 | }) 65 | 66 | if (isDevelopment) { 67 | if (process.platform === 'win32') { 68 | process.on('message', (data) => { 69 | if (data === 'graceful-exit') { 70 | app.quit() 71 | } 72 | }) 73 | } else { 74 | process.on('SIGTERM', () => { 75 | app.quit() 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /server/bin/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import signal 3 | 4 | import psutil 5 | from fastapi import FastAPI 6 | 7 | import sys 8 | import os 9 | 10 | # 添加项目路径进入环境变量,避免找不到模块 11 | sys.path.append(os.path.split(os.path.abspath(os.path.dirname(__file__)))[0]) 12 | 13 | from starlette.middleware.cors import CORSMiddleware 14 | from course_helper.routers import user, websocket, course, file 15 | 16 | # 神奇地修复了CMD下颜色乱码的问题 17 | os.system('') 18 | 19 | 20 | def pid_lock(): 21 | if os.path.exists('server.pid'): 22 | with open('server.pid') as f: 23 | pid = f.read() 24 | if psutil.pid_exists(int(pid)): 25 | sys.exit(0) 26 | with open('server.pid', 'w') as f: 27 | f.write(str(os.getpid())) 28 | 29 | 30 | def close_server(): 31 | if os.path.exists('server.pid'): 32 | with open('server.pid') as f: 33 | pid = f.read() 34 | # 虽然能结束进程,但是有莫名其妙的错误 35 | try: 36 | p = psutil.Process(int(pid)) 37 | p.send_signal(signal.CTRL_C_EVENT) 38 | except: 39 | pass 40 | 41 | 42 | # 命令行参数 43 | parser = argparse.ArgumentParser(description='CourseHelper server!') 44 | parser.add_argument('cmd', type=str, nargs='?', default='start', help='start (default), stop, restart') 45 | args = parser.parse_args() 46 | 47 | if args.cmd == 'stop': 48 | close_server() 49 | sys.exit(0) 50 | elif args.cmd == 'restart': 51 | close_server() 52 | # 保证锁文件已清除 53 | if os.path.exists('server.pid'): 54 | os.remove('server.pid') 55 | 56 | # 开启程序单实例锁 57 | pid_lock() 58 | 59 | app = FastAPI() 60 | 61 | # 设置跨域传参 62 | app.add_middleware( 63 | CORSMiddleware, 64 | allow_origins=['*'], # 设置允许访问的域名 "*",即为所有。 65 | allow_credentials=True, 66 | allow_methods=["*"], # 设置允许跨域的http方法,比如 get、post、put等。 67 | allow_headers=["*"] # 允许跨域的headers,可以用来鉴别来源等作用。 68 | ) 69 | 70 | 71 | @app.on_event("startup") 72 | async def __init(): 73 | # WebSocket路由 74 | app.include_router(websocket.router, prefix="/websocket") 75 | # 用户路由 76 | app.include_router(user.router, prefix="/user") 77 | # 课程路由 78 | app.include_router(course.router, prefix="/course") 79 | # 文件路由 80 | app.include_router(file.router, prefix="/file") 81 | 82 | 83 | @app.get('/') 84 | async def hello_world(): 85 | return 'Hello world' 86 | 87 | 88 | if __name__ == "__main__": 89 | import uvicorn 90 | 91 | # noinspection PyTypeChecker 92 | uvicorn.run(app, host='127.0.0.1', port=6498) 93 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # custom 132 | .idea** 133 | *.pid 134 | -------------------------------------------------------------------------------- /app/src/components/UserInfo.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 67 | 68 | 119 | -------------------------------------------------------------------------------- /app/src/components/DownloadModal.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 100 | 101 | 104 | -------------------------------------------------------------------------------- /app/src/views/CourseList.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 88 | 89 | 109 | 110 | 116 | -------------------------------------------------------------------------------- /app/src/components/DownloadQueueItem.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 71 | 72 | 128 | -------------------------------------------------------------------------------- /app/src/components/HomeworkItem.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 88 | 89 | 126 | -------------------------------------------------------------------------------- /app/src/App.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 84 | 85 | 86 | 126 | -------------------------------------------------------------------------------- /app/src/components/DownloadRecordsItem.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 81 | 82 | 155 | -------------------------------------------------------------------------------- /server/course_helper/routers/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastapi import APIRouter, HTTPException, BackgroundTasks, Response 4 | from pydantic import BaseModel 5 | 6 | from .user import User 7 | from ..common import success_info, CourseHelperException, error_info 8 | from ..logger import Logger 9 | from ..download import Downloader 10 | 11 | router = APIRouter() 12 | logger: Logger 13 | 14 | 15 | class DownloadModel(BaseModel): 16 | file_id: str 17 | dir_path: str 18 | 19 | 20 | class UploadFileModel(BaseModel): 21 | file_path: str 22 | 23 | 24 | @router.on_event("startup") 25 | async def __init(): 26 | # 获取默认日志 27 | global logger 28 | logger = Logger('文件模块') 29 | 30 | 31 | @router.post('/downloadFile') 32 | async def download_file(data: DownloadModel, background_tasks: BackgroundTasks): 33 | """ 34 | 下载course网站内openfile指向的文件 35 | """ 36 | try: 37 | s = await User.get_login_session() 38 | r = s.get(f'https://course2.xmu.edu.cn/meol/common/ckeditor/openfile.jsp?id={data.file_id}', stream=True) 39 | file_size = int(r.headers['content-length']) # 文件大小 Byte 40 | file_name = Downloader.get_headers_file_name(r.headers.get("Content-Disposition")) 41 | while True: 42 | # 拼接目录 子目录 文件名 43 | file_path = os.path.abspath(os.path.join(data.dir_path, file_name)) 44 | if os.path.exists(file_path): 45 | # 文件名重复则添加一个# 46 | file_name_no_ext = file_name[0:file_name.rfind('.')] 47 | file_ext = file_name[file_name.rfind('.') + 1:] 48 | file_name = f"{file_name_no_ext}#.{file_ext}" 49 | else: 50 | break 51 | 52 | background_tasks.add_task(Downloader.download_open_in_folder, file_path, r) 53 | return success_info('开始下载!下载完成后将在文件夹显示', data={ 54 | 'file_name': file_name, 55 | 'file_path': file_path, 56 | 'file_size': Downloader.byte_to_suitable_size(file_size) 57 | }) 58 | except CourseHelperException as e: 59 | logger.warning(f'文件下载失败 - 失败原因:{e}') 60 | raise HTTPException(400, detail=error_info(e.data)) 61 | except Exception as e: 62 | logger.debug(f'文件下载失败 e-{e}') 63 | raise HTTPException(400, detail=error_info('文件下载失败')) 64 | 65 | 66 | @router.get('/openFile/{file_id}') 67 | async def open_file(file_id: str): 68 | """ 69 | 转发course网站内openfile指向的文件请求(用于显示图片) 70 | """ 71 | try: 72 | session = await User.get_login_session() 73 | res = session.get(f'https://course2.xmu.edu.cn/meol/common/ckeditor/openfile.jsp?id={file_id}') 74 | 75 | headers = dict(res.headers) 76 | return Response(content=res.content, headers=headers) 77 | except CourseHelperException as e: 78 | logger.warning(f'打开资源失败 - 失败原因:{e}') 79 | raise HTTPException(400, detail=error_info(e.data)) 80 | except Exception as e: 81 | logger.debug(f'打开资源失败 e-{e}') 82 | raise HTTPException(400, detail=error_info('打开资源失败')) 83 | 84 | 85 | @router.post('/uploadFile') 86 | async def open_file(data: UploadFileModel): 87 | try: 88 | if not os.path.exists(data.file_path): 89 | raise CourseHelperException('文件不存在') 90 | session = await User.get_login_session() 91 | file_name = os.path.basename(data.file_path) 92 | files = {'file': (file_name, open(data.file_path, 'rb'))} 93 | file_size = os.path.getsize(data.file_path) 94 | 95 | res = session.post('https://course2.xmu.edu.cn/meol/servlet/SerUpload', 96 | files=files) 97 | if not res.status_code == 200: 98 | raise CourseHelperException('文件上传失败') 99 | 100 | return success_info('文件上传成功', data={ 101 | 'file_url': 'http://127.0.0.1:6498/file/openFile/' + res.text[res.text.find('id=') + 3:], 102 | 'file_name': file_name, 103 | 'file_size': Downloader.byte_to_suitable_size(file_size), 104 | 'file_path': data.file_path 105 | }) 106 | except CourseHelperException as e: 107 | logger.warning(f'文件上传失败 - 失败原因:{e}') 108 | raise HTTPException(400, detail=error_info(e.data)) 109 | except Exception as e: 110 | logger.debug(f'文件上传失败 e-{e}') 111 | raise HTTPException(400, detail=error_info('文件上传失败')) 112 | -------------------------------------------------------------------------------- /server/course_helper/routers/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from json import JSONDecodeError 4 | from threading import Lock 5 | 6 | import nanoid 7 | from fastapi import APIRouter, WebSocket 8 | 9 | from course_helper.logger import Logger 10 | 11 | router = APIRouter() 12 | logger: Logger 13 | 14 | 15 | class ConnectionManager: 16 | active_connections: dict = {} 17 | wait_reply_dict: dict = {} 18 | lock = Lock() 19 | 20 | class Utils: 21 | @staticmethod 22 | async def js_encrypt(data: dict, client_id: str): 23 | """ 24 | 请求客户端 执行js 并返回结果 25 | """ 26 | return await ConnectionManager.send_message_wait_reply({ 27 | 'cmd': 'js_encrypt', 28 | 'params': data 29 | }, client_id) 30 | 31 | @classmethod 32 | async def connect(cls, ws: WebSocket, client_id: str): 33 | """ 34 | 等待连接,并添加连接对象 35 | """ 36 | await ws.accept() 37 | cls.active_connections[client_id] = ws 38 | logger.info(f"WS已连接 client_id:{client_id}") 39 | 40 | @classmethod 41 | def disconnect(cls, client_id: str): 42 | """ 43 | 移除连接对象 44 | """ 45 | cls.active_connections.pop(client_id) 46 | logger.info(f"WS已断开 client_id:{client_id}") 47 | 48 | @classmethod 49 | async def send_message(cls, data, client_id: str): 50 | """ 51 | 给某个连接发送消息 52 | """ 53 | message_id = nanoid.generate() 54 | await cls.active_connections[client_id].send_text(json.dumps({ 55 | 'message_id': message_id, 56 | 'data': data 57 | })) 58 | 59 | @classmethod 60 | async def send_message_wait_reply(cls, data, client_id: str): 61 | """ 62 | 给某个连接发送消息并等待回复 63 | """ 64 | message_id = nanoid.generate() 65 | await cls.active_connections[client_id].send_text(json.dumps({ 66 | 'reply': True, 67 | 'message_id': message_id, 68 | 'data': data 69 | })) 70 | # 等待回复 71 | reply = await cls.wait_reply(message_id) 72 | cls.wait_reply_dict.pop(message_id) 73 | logger.debug(f"发送消息给 client_id:{client_id} message_id:{message_id}") 74 | 75 | return reply 76 | 77 | @classmethod 78 | async def wait_reply(cls, message_id: str): 79 | """ 80 | 等待指定message_id的回复 81 | """ 82 | future = asyncio.Future() 83 | cls.wait_reply_dict[message_id] = future 84 | # 等待 future 结果,然后返回 85 | return await future 86 | 87 | @classmethod 88 | async def on_json_message(cls, message, client_id: str): 89 | """ 90 | 收到任何消息的触发函数 91 | """ 92 | if isinstance(message, dict): 93 | logger.debug(f"收到消息 client_id:{client_id}") 94 | if 'reply' in message: 95 | # 存在回复 96 | for _ in range(3): 97 | # 防止回复过快,future还未添加 98 | if message['message_id'] in cls.wait_reply_dict: 99 | future = cls.wait_reply_dict[message['message_id']] 100 | # 设定future结果,从而结束 wait_reply 101 | future.set_result(message) 102 | break 103 | else: 104 | await asyncio.sleep(0.1) 105 | elif message == 'heartCheck': 106 | # logger.debug(f"收到心跳包") 107 | # 心跳包回复 108 | await ConnectionManager.active_connections[client_id].send_text('heartCheck') 109 | 110 | # 不满足条件的消息,不做处理 111 | 112 | 113 | @router.on_event("startup") 114 | async def __init(): 115 | # 获取默认日志 116 | global logger 117 | logger = Logger('WebSocket模块') 118 | 119 | 120 | @router.websocket("/connect/{client_id}") 121 | async def websocket_endpoint(websocket: WebSocket, client_id: str): 122 | """ 123 | websocket连接 124 | """ 125 | await ConnectionManager.connect(websocket, client_id) 126 | try: 127 | while True: 128 | data = await websocket.receive_json() 129 | await ConnectionManager.on_json_message(data, client_id) 130 | 131 | except JSONDecodeError: 132 | logger.debug(f"收到 client_id:{client_id} 非json格式消息 已忽略") 133 | except: 134 | ConnectionManager.disconnect(client_id) 135 | -------------------------------------------------------------------------------- /app/src/views/HomeworkDetails.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 118 | 119 | 143 | -------------------------------------------------------------------------------- /app/src/utils/wsHelper.js: -------------------------------------------------------------------------------- 1 | import encrypt from '@/utils/encrypt' 2 | import store from "@/store" 3 | 4 | const cmd = { 5 | 'js_encrypt': async (params) => encrypt(params.data, params.key), 6 | 'update_download_progress': async (params) => { 7 | if ('finished' in params) { 8 | const res = await store.dispatch('updateDownloadProgress', { 9 | downloadId: params['download_id'], 10 | finished: true 11 | }) 12 | that.sendMsg(`下载成功: ${res}`, 'success') 13 | } else if ('error' in params) { 14 | const res = await store.dispatch('updateDownloadProgress', { 15 | downloadId: params['download_id'], 16 | error: true 17 | }) 18 | that.sendMsg(`下载失败: ${res}`, 'error') 19 | } else { 20 | return store.dispatch('updateDownloadProgress', { 21 | downloadId: params['download_id'], 22 | speed: params['speed'], 23 | timeRemain: params['time_remain'], 24 | downSize: params['down_size'], 25 | downSizeRaw: params['down_size_raw'], 26 | }) 27 | } 28 | } 29 | } 30 | 31 | 32 | const that = { 33 | ws: null, 34 | lockReconnect: false, //是否正在重连 35 | timeout: 30 * 1000, //心跳间隔 36 | heartTimeId: null, //心跳倒计时 37 | serverHeartTimeId: null, //服务器的心跳回复倒计时,超时则关闭连接 38 | reconnectTimeId: null, //断开 重连倒计时 39 | 40 | sendMsg: null, // 用于发送消息提示 41 | callback: { 42 | connect: null, 43 | disconnect: null 44 | }, 45 | 46 | injectCallback(connect, disconnect) { 47 | that.callback = {connect, disconnect} 48 | }, 49 | injectMessage(sendMsg) { 50 | that.sendMsg = sendMsg 51 | }, 52 | onopen() { 53 | console.log('连接成功') 54 | if (that.callback.connect !== null) { 55 | that.callback.connect() 56 | } 57 | that.start() //开启心跳 58 | }, 59 | onclose() { 60 | console.log("连接关闭") 61 | if (that.callback.disconnect !== null) { 62 | that.callback.disconnect() 63 | } 64 | that.reconnect() //重连 65 | }, 66 | async onmessage(event) { 67 | if (event.data !== 'heartCheck') { 68 | let msg = JSON.parse(event.data) 69 | let resData = 'ERROR' 70 | if ('data' in msg && 'cmd' in msg.data) { 71 | let data = msg.data 72 | if (data.cmd in cmd) { 73 | try { 74 | resData = await cmd[data.cmd](data.params) 75 | } catch (e) { 76 | console.log('消息错误', e) 77 | } 78 | } 79 | } 80 | 81 | if (msg.reply) { 82 | const out = { 83 | message_id: msg.message_id, 84 | reply: true, 85 | data: resData 86 | } 87 | that.ws.send(JSON.stringify(out)) 88 | } 89 | } 90 | that.reset() //收到服务器信息,心跳重置 91 | }, 92 | reset() { 93 | clearTimeout(that.heartTimeId) 94 | clearTimeout(that.serverHeartTimeId) 95 | that.start(); //启动下一次心跳 96 | }, 97 | connect() { 98 | that.ws = new WebSocket('ws://localhost:6498/websocket/connect/course-helper') 99 | that.ws.onopen = that.onopen 100 | that.ws.onmessage = that.onmessage 101 | that.ws.onclose = that.onclose 102 | }, 103 | start() { 104 | // 存在两种心跳计时则清空 105 | that.heartTimeId && clearTimeout(that.heartTimeId); 106 | that.serverHeartTimeId && clearTimeout(that.serverHeartTimeId); 107 | 108 | that.heartTimeId = setTimeout(() => { 109 | if (that.ws.readyState === 1) { //若连接正常则发送心跳包 110 | that.ws.send('"heartCheck"') 111 | } else { //否则重连 112 | that.reconnect() 113 | } 114 | 115 | // 服务器心跳回复超时则关闭连接 116 | that.serverHeartTimeId = setTimeout(() => that.ws.close(), that.timeout); 117 | }, that.timeout) 118 | }, 119 | reconnect() { 120 | if (that.lockReconnect) return 121 | that.lockReconnect = true // 正在尝试重连 122 | console.log('2s后尝试重连') 123 | //设置延迟5s再尝试重连,避免重复请求重连 124 | that.reconnectTimeId && clearTimeout(that.reconnectTimeId); 125 | that.reconnectTimeId = setTimeout(() => { 126 | that.connect();//新连接 127 | that.lockReconnect = false; 128 | }, 2000) 129 | } 130 | } 131 | 132 | 133 | export default that 134 | -------------------------------------------------------------------------------- /app/src/views/Download.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 118 | 119 | 154 | -------------------------------------------------------------------------------- /app/src/components/NavigationBar.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 143 | 144 | 173 | -------------------------------------------------------------------------------- /app/src/components/LoginForm.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 164 | 165 | 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Course Helper 2 | 3 | 厦门大学course平台第三方客户端,一个Electron+Vue3作为前端,Python作为本地后端的桌面端软件。 4 | 5 | 觉得还不错的点个Star⭐吧,这是对本人最大的鼓励! 6 | 7 | [演示视频](https://www.bilibili.com/video/BV17S4y1v7R6?share_source=copy_web) 8 | 9 | 10 | [TOC] 11 | 12 | Electron前端 13 | 14 | 15 | 16 | python后端 17 | 18 | 19 | 20 | ## 开始使用 21 | 22 | 1. **下载** 23 | 24 | Github Release: [CourseHelper](https://github.com/ruchuby/Course-Helper/releases/) 25 | 26 | 蓝奏云: [蓝奏云外链](https://wwi.lanzoup.com/b01ji7zne ) 密码:fd85 27 | 28 | 2. **安装** 29 | 30 | 解压后双击`CourseHelper Setup.exe`,安装到任意位置 31 | 32 | 3. **使用** 33 | 34 | 双击桌面快捷方式,启动软件 35 | 36 | 软件启动时会尝试启动后端服务(黑框框) 37 | 38 | **启动失败**,请尝试在软件内启动后端,操作如下图 39 | 40 | 启动后端 41 | 42 | 43 | 44 | **若仍启动失败**,请手动运行`xxx安装目录\resources\server.exe` 45 | 46 | ------ 47 | 48 | 49 | 50 | ## 需求分析 51 | 52 | 为进一步深化教育改革,加快我校优质教学资源的共建共享,我校引进了清华大学教育技术研究所研发的支撑教与学的网络支撑环境的综合平台,建立了厦门大学课程中心,即[couse平台](http://course.xmu.edu.cn/)。 53 | 54 | course平台已有十年的使用历史,由于缺少更新、维护,使用起来非常不方便。但是由于学校强制要求教师学生使用course平台,我们不得不在平台上查看、下载课件,上传作业等等。加上近年主流浏览器都已经停止对Flash的支持、course平台登录添加伪VPN验证等等问题,这个平台的变得愈发不方便。 55 | 56 | 由此,个人决定做一款PC端course助手,方便同学们使用course网站。 57 | 58 | 59 | 60 | ## 软件功能与特点 61 | 62 | ### 1. 快捷登录 ✔️ 63 | 64 | 用户在保存账号密码到本地后,启动即可快速登录 Course 网站。无需输入密码、拖动滑块验证码 65 | 66 | ### 2. UI界面 ✔️ 67 | 68 | ~~功能可以差点,UI必须好看~~ 69 | 70 | ### 3. 课程列表查看 ✔️ 71 | 72 | 可以查看课程列表与课程基本信息 73 | 74 | ### 4. 课程资源下载 ✔️ 75 | 76 | 进入某课程后,可以勾选需要下载的课程资源文件,批量下载 77 | 78 | ### 5. 作业查看 ✔️ 79 | 80 | 爬取课程的作业列表,作业详情,简单高效地查看作业内容 81 | 82 | ### 6. 作业提交 ✔️ 83 | 84 | 使用[wangEditor](https://www.wangeditor.com/)富文本编辑器进行作业内容编辑与提交 85 | 86 | 87 | 88 | ## 难点分析与解决 89 | 90 | ### 前后端通信 ⭐⭐⭐⭐ 91 | 92 | 通信方式的选择:最早打算使用RPC等通信,但是问题很多,最后还是决定主体使用本地HTTP通信(fastapi) 93 | 94 | 虽然HTTP通信速度上不如RPC通信,但是用于本地HTTP通信,小小的速度差异还是可以接受的。 95 | 96 | 97 | 98 | 此外,本来不想使用其他通信方式的,但是碰到了技术上的难点。 99 | 100 | 某些功能需要双向通信,(后端能够主动向前端发送请求),不得不额外使用了WebSocket。 101 | 102 | 然后在使用WebSocket时又出现了新的问题,因为**WS通信不像HTTP能有每个请求的回复**,需要进一步处理。 103 | 104 | 最后通过**添加消息id**判断出每个消息所属的请求,并且使用`asyncio.Future`来**等待消息回复**,成功拿到回复。 105 | 106 | 后续可以进一步添加**超时时间**,但是目前对超时判断的需求不大。 107 | 108 | 109 | 110 | ### 登录 ⭐⭐⭐⭐⭐ 111 | 112 | 存在诸多阻碍,统一身份认证和VPN验证,网站频繁的重定向,对爬虫非常非常不友好。 113 | 114 | 本来打算用`selenium`或`pyppeteer`蒙混过关。 115 | 116 | 但万幸,~~某智教育公司、某瑞达公司~~没把纯`requests`的路给堵死 117 | 118 | **重难点:** 119 | 120 | 1. vpn滑块验证码 121 | 122 | 获取滑块验证码的图片,PIL解析滑块图片,post通过 123 | 124 | 125 | 126 | 2. 统一身份认证的请求加密 127 | 128 | key藏在页面源码内里,简单找一找就行,但是用于加密的js代码比较麻烦。 129 | 130 | js源码可以取到,但是不方便直接用python调用(考虑到用户的电脑不一定装了nodejs) 131 | 132 | 所以只能用最稳妥的前后端通信的方式,让前端把加密代码执行后返回给后端 133 | 134 | (~~感觉多此一举,但是谁让这是Python的大作业,Electron前端只负责展示数据~~) 135 | 136 | 137 | 138 | 2. 登录状态的维护 139 | 140 | 使用同一个request.Session进行请求,维持前后的cookie等缓存 141 | 142 | 并且再每次请求前检查登录状态,及时重新登录 143 | 144 | 145 | 146 | 理论上虽然能保证登录状态,但是偶尔Session对course网站突然无响应的情况依然存在, 147 | 148 | 暂时没做更进一步的登录维护,如果**出现无法连接的解决方案**: 149 | 150 | 1. 退出登录,重新登录(会重置Session) 151 | 2. 重启后端 152 | 3. 重启前后端 153 | 154 | 155 | 156 | ### UI设计与实现 ⭐⭐⭐⭐⭐ 157 | 158 | 第一次使用 `Electron + Vue3`,不得不说这俩虽然开发起来很简单,但是真的会遇到**非常多问题**。 159 | 160 | ​ **Electron 真的很多问题** 161 | 162 | 按时间顺序列举一下**从迈出第一步**到**比较流畅地开发**的这段历程: 163 | 164 | 1. 解决electron环境配置问题 165 | 2. electron的不同进程通信,简单入门 166 | 3. Vue2的学习 167 | 4. Vue2迁移到Vue3的学习 168 | 5. electron使用vue的配置 169 | 6. 简单使用electron+vue3 170 | 7. 各种组件通信 171 | 8. Vuex,Vue Router的学习和使用 172 | 9. UI组件库使用(Native UI) 173 | 10. 解决electron打包的各种问题 174 | 175 | 176 | 177 | ### 信息爬取⭐⭐⭐ 178 | 179 | course使用**jsp构建的动态网页**,使用`正则 + lxml`提取需要的信息 180 | 181 | 总有个别页面的信息提取格外**繁琐** 182 | 183 | 比如课程资源的**文件树**(需要递归)、**作业详情**(藏在input内的纯html) 184 | 185 | 186 | 187 | ### 作业提交⭐⭐⭐⭐ 188 | 189 | course网站使用的还是古董级别的ckeditor 3.6(最新版本已经到ckeditor 5) 190 | 191 | 一系列flash相关问题也来自这个老旧的富文本编辑框 192 | 193 | Course Helper使用wangEditor作为作业编辑器,并实现原编辑器的文件、图片上传功能 194 | 195 | **难点**: 196 | 197 | 1. 文件上传、图片上传 198 | 2. wangEditor输出的纯HTML不带有内联样式,直接提交到course中无法看到样式 199 | 200 | **解决**: 201 | 202 | 1. 通过抓包找到原编辑器的图片、文件上传接口,利用模拟post实现文件、图片上传 203 | 204 | 2. 提交HTML时,附带预先写好的` 199 | -------------------------------------------------------------------------------- /app/src/components/WangEditor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 185 | 186 | 189 | -------------------------------------------------------------------------------- /app/src/components/HomeworkDetailsItem.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 185 | 186 | 221 | -------------------------------------------------------------------------------- /app/src/store/index.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'vuex' 2 | 3 | export default createStore({ 4 | state: { 5 | themeValue: 'default', 6 | connectState: false, 7 | loginState: false, 8 | userInfo: { 9 | id: null, 10 | name: '', 11 | college: '' 12 | }, 13 | downloadQueue: [], 14 | downloadRecords: [], 15 | homeworkTabs: [] 16 | }, 17 | getters: {}, 18 | mutations: { 19 | SET_THEME_VARS(state, payload) { 20 | state.themeValue = payload.themeValue 21 | }, 22 | SET_CONNECT_STATE(state, payload) { 23 | state.connectState = payload.state 24 | }, 25 | SET_LOGIN_STATE(state, payload) { 26 | state.loginState = payload.state 27 | }, 28 | SET_USER_INFO(state, payload) { 29 | state.userInfo = payload.info 30 | }, 31 | SPLICE_DOWNLOAD_QUEUE(state, payload) { 32 | state.downloadQueue.splice(payload.index, 1) 33 | }, 34 | SPLICE_DOWNLOAD_RECORDS(state, payload) { 35 | if ('index' in payload) { 36 | state.downloadRecords.splice(payload.index, 1) 37 | } else { 38 | state.downloadRecords.splice(0) // 全部清空 39 | } 40 | }, 41 | PUSH_DOWNLOAD_QUEUE(state, payload) { 42 | state.downloadQueue.push(...payload.data) 43 | }, 44 | SET_DOWNLOAD_QUEUE(state, payload) { 45 | if ('index' in payload) { 46 | state.downloadQueue[payload.index] = payload.data 47 | } else { 48 | state.downloadQueue = payload.data 49 | } 50 | }, 51 | ADD_DOWNLOAD_RECORDS(state, payload) { 52 | state.downloadRecords.push(...payload.data) 53 | }, 54 | SET_DOWNLOAD_RECORDS(state, payload) { 55 | if ('index' in payload) { 56 | state.downloadRecords[payload.index] = payload.data 57 | } else { 58 | state.downloadRecords = payload.data 59 | } 60 | }, 61 | ADD_HOMEWORK_TABS(state, payload) { 62 | state.homeworkTabs.push(payload.data) 63 | }, 64 | SET_HOMEWORK_TABS(state, payload) { 65 | state.homeworkTabs[payload.index] = payload.data 66 | }, 67 | SPLICE_HOMEWORK_TABS(state, payload) { 68 | if ('index' in payload) { 69 | state.homeworkTabs.splice(payload.index, 1) 70 | } else { 71 | state.homeworkTabs.splice(0) // 全部清空 72 | } 73 | }, 74 | }, 75 | actions: { 76 | saveLoginInfo({commit}, info) { 77 | commit('SET_LOGIN_STATE', {state: true}) 78 | commit('SET_USER_INFO', {info}) 79 | }, 80 | logout({commit}) { 81 | commit('SET_LOGIN_STATE', {state: false}) 82 | commit('SET_USER_INFO', {info: {id: null, name: '', college: ''}}) 83 | }, 84 | updateDownloadProgress({commit, state}, data) { 85 | const index = state.downloadQueue.findIndex(item => item.downloadId === data.downloadId) 86 | if (index === -1) { //若下载队列中无记录则忽略 87 | console.log('不存在下载项:', data.downloadId) 88 | return 89 | } 90 | 91 | let record = state.downloadQueue[index] 92 | if ('finished' in data) { 93 | commit('SPLICE_DOWNLOAD_QUEUE', {index}) //从下载队列删除 94 | record = { 95 | state: 'finished', 96 | fileName: record.fileName, 97 | filePath: record.filePath, 98 | fileExt: record.fileExt, 99 | fileSize: record.fileSize, 100 | fileSizeRaw: record.fileSizeRaw, 101 | downloadId: record.downloadId, 102 | } 103 | commit('ADD_DOWNLOAD_RECORDS', {data: [record]}) // 添加到下载记录 104 | return record.fileName 105 | } else if ('error' in data) { 106 | commit('SPLICE_DOWNLOAD_QUEUE', {index}) //从下载队列删除 107 | record = { 108 | state: 'error', 109 | fileName: record.fileName, 110 | filePath: record.filePath, 111 | fileExt: record.fileExt, 112 | fileSize: record.fileSize, 113 | fileSizeRaw: record.fileSizeRaw, 114 | downloadId: record.downloadId, 115 | } 116 | commit('ADD_DOWNLOAD_RECORDS', {data: [record]}) // 添加到下载记录 117 | return record.fileName 118 | } else { 119 | record.state = 'downloading' 120 | record = { 121 | ...record, 122 | ...data 123 | } 124 | commit('SET_DOWNLOAD_QUEUE', {index, data: record}) //更新下载队列中下载进度 125 | } 126 | }, 127 | push_download_queue({commit, state}, data) { 128 | const newData = [] 129 | data.forEach(item => { 130 | if (item.success === true) { 131 | newData.push({ 132 | state: 'waiting', 133 | fileName: item['file_name'], 134 | filePath: item['file_path'], 135 | fileExt: item['file_ext'], 136 | fileSize: item['file_size'], 137 | fileSizeRaw: item['file_size_raw'], 138 | downloadId: item['download_id'], 139 | }) 140 | } 141 | }) 142 | commit('PUSH_DOWNLOAD_QUEUE', {data: newData}) 143 | }, 144 | removeDownloadRecord({commit, state}, data) { 145 | if ('downloadId' in data) { 146 | const index = state.downloadRecords.findIndex(item => item.downloadId === data.downloadId) 147 | if (index === -1) { 148 | console.log('不存在下载记录:', data.downloadId) 149 | return 150 | } 151 | commit('SPLICE_DOWNLOAD_RECORDS', {index}) 152 | } else { 153 | commit('SPLICE_DOWNLOAD_RECORDS', {}) 154 | } 155 | }, 156 | addHomeworkTabs({commit, state}, data) { 157 | const index = state.homeworkTabs.findIndex(item => item['hw_id'] === data['hw_id']) 158 | if (index === -1) { 159 | commit('ADD_HOMEWORK_TABS', {data}) 160 | } else { 161 | console.log('已存在该课程', data['hw_id']) 162 | } 163 | }, 164 | updateHomeworkTabs({commit, state}, {hwId, data}) { 165 | const index = state.homeworkTabs.findIndex(item => item['hw_id'] === hwId) 166 | if (index !== -1) { 167 | let homeworkTab = state.homeworkTabs[index] 168 | homeworkTab = { 169 | ...homeworkTab, 170 | ...data 171 | } 172 | commit('SET_HOMEWORK_TABS', {index, data: homeworkTab}) //更新下载队列中下载进度 173 | } else { 174 | console.log('未找到此id作业', hwId) 175 | } 176 | 177 | }, 178 | removeHomeworkTabs({commit, state}, hwId) { 179 | const index = state.homeworkTabs.findIndex(item => item['hw_id'] === hwId) 180 | if (index !== -1) { 181 | commit('SPLICE_HOMEWORK_TABS', {index}) 182 | } 183 | return index 184 | } 185 | }, 186 | modules: {} 187 | }) 188 | -------------------------------------------------------------------------------- /server/course_helper/routers/user.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | 4 | import requests 5 | 6 | from fastapi import APIRouter, HTTPException 7 | from lxml import etree 8 | from pydantic import BaseModel 9 | from requests import Session 10 | 11 | from ..xmu_slider import xmu_slider_code 12 | from ..common import success_info, CourseHelperException, error_info 13 | from ..logger import Logger 14 | from ..routers.websocket import ConnectionManager 15 | 16 | router = APIRouter() 17 | logger: Logger 18 | 19 | 20 | class LoginModel(BaseModel): 21 | user_id: str 22 | user_pw: str 23 | vpn_id: str 24 | vpn_pw: str 25 | 26 | 27 | class User: 28 | login_flag: bool = False 29 | login_model: LoginModel = None 30 | session: requests.Session = None 31 | 32 | @classmethod 33 | async def get_login_session(cls) -> requests.Session: 34 | # 需要检查登录状态是否有效 35 | if cls.login_flag: 36 | if not cls.check_login(): 37 | # 登录状态已退出则重新登录 38 | logger.debug('登录状态异常, 重新登录') 39 | await course_login(cls.session, cls.login_model) 40 | return cls.session 41 | else: 42 | raise CourseHelperException('用户未登录') 43 | 44 | @classmethod 45 | async def get_session(cls) -> requests.Session: 46 | if cls.session is None: 47 | return cls.reset_session() 48 | return cls.session 49 | 50 | @classmethod 51 | def reset_session(cls): 52 | cls.session = requests.Session() 53 | cls.session.headers = { 54 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' 55 | 'Chrome/101.0.4951.54 Safari/537.36 ' 56 | } 57 | return cls.session 58 | 59 | @classmethod 60 | def check_login(cls) -> bool: 61 | session = cls.session 62 | url = 'https://course.xmu.edu.cn/meol/oauth/callback.jsp' 63 | res = session.get(url=url) 64 | return res.url.find('main.jsp') > -1 65 | 66 | 67 | @router.on_event("startup") 68 | async def __init(): 69 | # 获取默认日志 70 | global logger 71 | logger = Logger('用户模块') 72 | 73 | 74 | @router.post("/login") 75 | async def login(data: LoginModel): 76 | try: 77 | session = await User.get_session() 78 | await course_login(session, data=data) 79 | 80 | User.login_flag = True 81 | User.login_model = data 82 | 83 | user_info = get_user_info(session) 84 | return success_info(msg='登录成功!', data=user_info) 85 | except CourseHelperException as e: 86 | logger.warning(f'登录失败 - 失败原因:{e}') 87 | raise HTTPException(400, detail=error_info(e.data)) 88 | except Exception as e: 89 | logger.debug(f'登录失败 e-{e}') 90 | raise HTTPException(400, detail=error_info('登录失败')) 91 | 92 | 93 | @router.get("/logout") 94 | async def logout(): 95 | try: 96 | url = 'https://course2.xmu.edu.cn/meol/ext/xmu/logout.jsp' 97 | (await User.get_session()).get(url=url) 98 | 99 | # course的退出不能将统一身份、vpn的退出,所以这里重置session 100 | User.reset_session() 101 | User.login_flag = False 102 | User.login_model = None 103 | return success_info(msg='已退出登录') 104 | 105 | except Exception as e: 106 | logger.debug(f'退出失败 e-{e}') 107 | raise HTTPException(400, detail=error_info('退出失败')) 108 | 109 | 110 | async def course_login(session: requests.Session, data: LoginModel): 111 | url = 'https://course.xmu.edu.cn/meol/oauth/callback.jsp' 112 | res = session.get(url=url) 113 | if res.url.find('main.jsp') == -1: 114 | if res.url.find('authserver') > -1: 115 | # 统一身份登录 116 | res = await login_by_ids(session, data.user_id, data.user_pw, login_url=res.url) 117 | logger.debug('统一身份登录成功') 118 | 119 | if res.url.find('wengine-auth') > -1: 120 | # vpn登录 121 | res = await login_vpn(session, data.vpn_id, data.vpn_pw) 122 | logger.debug('VPN登录成功') 123 | 124 | if res.url.find('main.jsp') == -1: 125 | raise CourseHelperException('Course平台进入失败') 126 | 127 | 128 | async def login_vpn(session: requests.Session, account: str, pw: str) -> requests.Response: 129 | base_url = 'https://applg.xmu.edu.cn/wengine-auth/' 130 | 131 | # 获取滑块验证码 延迟0.5s避免请求502 132 | await asyncio.sleep(0.5) 133 | img_res = session.get(url=base_url + 'login/image') 134 | if img_res.status_code != 200: 135 | raise CourseHelperException('滑块验证码加载失败') 136 | 137 | w = xmu_slider_code(img_res.json()['p']) 138 | 139 | # 验证验证码 140 | data = { 141 | 'w': w, 142 | 't': 0, 143 | 'locations': [{'x': 156, 'y': 572}, {'x': 156 + w, 'y': 479}] # 数字不重要,关键是 w 144 | } 145 | code_res = session.post(url=base_url + 'login/verify', data=data) 146 | if code_res.status_code != 200 or not code_res.json()['success']: 147 | raise CourseHelperException('滑块验证码验证失败') 148 | 149 | # 登录 150 | login_res = session.post(url=base_url + 'do-login', data={ 151 | 'auth_type': 'local', 152 | 'username': account, 153 | 'sms_code': '', 154 | 'password': pw 155 | }) 156 | 157 | login_res_json = login_res.json() 158 | if 'success' in login_res_json and login_res_json['success'] is True: 159 | return session.get(url=login_res_json['url']) 160 | else: 161 | if 'message' in login_res_json: 162 | raise CourseHelperException(f"vpn登录失败: {login_res_json['message']}") 163 | else: 164 | raise CourseHelperException('vpn登录失败') 165 | 166 | 167 | async def login_by_ids(session: requests.Session, account: str, pw: str, login_url: str) -> requests.Response: 168 | """ 169 | 通过账号密码登录 170 | :param login_url: 登录链接, 方便跳转 171 | :param session: requests.Session 172 | :param account: 账号 173 | :param pw: 密码 174 | :return: 统一身份登录响应 175 | """ 176 | if not len(ConnectionManager.active_connections): 177 | raise CourseHelperException('未连接客户端') 178 | 179 | res = session.get(login_url, timeout=5) 180 | if res.status_code != 200: 181 | raise CourseHelperException('统一身份登录网页加载失败') 182 | 183 | info = {} 184 | for name in ('lt', 'dllt', 'execution', '_eventId', 'rmShown', 'pwdDefaultEncryptSalt'): 185 | key = 'name' if name != 'pwdDefaultEncryptSalt' else 'id' 186 | result = re.search(r'{}="{}" value="([\s\S]*?)"'.format(key, name), res.text) 187 | if result is None: 188 | raise CourseHelperException('正则获取统一身份登录网页参数失败') 189 | info[name] = result.group(1) 190 | 191 | # 调用前端执行js加密代码 192 | res = await ConnectionManager.Utils.js_encrypt({ 193 | 'data': pw, 194 | 'key': info['pwdDefaultEncryptSalt'] 195 | }, client_id=tuple(ConnectionManager.active_connections.keys())[0]) 196 | password = res['data'] 197 | 198 | # 登录 199 | data = { 200 | 'username': account, 201 | 'password': password, 202 | 'lt': info['lt'], 203 | 'dllt': info['dllt'], 204 | 'execution': info['execution'], 205 | '_eventId': info['_eventId'], 206 | 'rmShown': info['rmShown'] 207 | } 208 | 209 | login_res = session.post(login_url, data=data) 210 | if login_res.status_code != 200 or login_res.text.find('您提供的用户名或者密码有误') > -1 or login_res.text.find('请输入验证码') > -1: 211 | raise CourseHelperException('统一身份登录失败') 212 | return login_res 213 | 214 | 215 | def get_user_info(s: Session) -> dict: 216 | res = s.get('https://course2.xmu.edu.cn/meol/welcomepage/student/index.jsp') 217 | sid = re.search(r'viewstudent_info.jsp\?SID=(\d.*?)&', res.text).group(1) 218 | 219 | res = s.get(f'https://course2.xmu.edu.cn/meol/popups/viewstudent_info.jsp?SID={sid}&from=welcomepage') 220 | html = etree.HTML(res.text) 221 | table = html.xpath("//table[@class='infotable']")[0] 222 | result = [x.text.strip() for x in table.xpath(".//td")] 223 | 224 | out = { 225 | 'id': result[1], 226 | 'name': result[2], 227 | 'college': result[3] 228 | } 229 | 230 | logger.debug('用户信息', out) 231 | 232 | return out 233 | -------------------------------------------------------------------------------- /server/course_helper/download.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import time 4 | from queue import Queue 5 | from urllib import parse 6 | 7 | from requests import Session 8 | 9 | from .routers.user import User 10 | from .routers.websocket import ConnectionManager 11 | 12 | 13 | class Downloader: 14 | file_queue = Queue() 15 | running = False 16 | 17 | @staticmethod 18 | def get_headers_file_name(file_name_raw: str) -> str: 19 | """ 20 | 从请求头中获取文件名,修复中文乱码 21 | """ 22 | file_name = ( 23 | file_name_raw.encode("unicode_escape").decode("utf-8").replace("\\x", "%") 24 | ) 25 | file_name = parse.unquote(file_name, encoding="gbk") 26 | pos = file_name.find('filename="') 27 | return file_name[pos + len('filename="'): -1] 28 | 29 | @staticmethod 30 | def byte_to_suitable_size(byte_size: int) -> str: 31 | """ 32 | 字节大小转合适单位大小 33 | """ 34 | if byte_size <= 1024: 35 | return f'{byte_size}B' 36 | elif byte_size <= 1024 ** 2: 37 | return f'{round(byte_size / 1024, 2)}KB' 38 | elif byte_size <= 1024 ** 3: 39 | return f'{round(byte_size / 1024 ** 2, 2)}M' 40 | else: 41 | return f'{round(byte_size / 1024 ** 3, 2)}G' 42 | 43 | @staticmethod 44 | def _sec_to_suitable_time(sec: int): 45 | """ 46 | 秒转 时:分:秒 47 | """ 48 | return time.strftime("%H:%M:%S", time.gmtime(sec)) 49 | 50 | @classmethod 51 | async def get_file_info(cls, session: Session, file_id, res_id): 52 | """ 53 | 获取下载文件名、大小、后缀等信息 54 | """ 55 | try: 56 | with session.get( 57 | f'https://course2.xmu.edu.cn/meol/common/script/download.jsp?fileid={file_id}&resid={res_id}', 58 | stream=True) as r: 59 | content_size = int(r.headers['content-length']) 60 | file_name = cls.get_headers_file_name(r.headers.get("Content-Disposition")) 61 | file_name_no_ext = file_name[0:file_name.rfind('.')] 62 | 63 | file_ext = file_name[file_name.rfind('.') + 1:].lower() 64 | file_name = f'{file_name_no_ext}.{file_ext}' 65 | file_size = cls.byte_to_suitable_size(content_size) 66 | return { 67 | 'success': True, 68 | 'file_name': file_name, 69 | 'file_ext': file_ext, 70 | 'file_size': file_size, 71 | 'file_size_raw': content_size 72 | } 73 | except: 74 | return {'success': False} 75 | 76 | @classmethod 77 | async def _update_download_progress(cls, download_id: str, speed_str: str, time_remain_str: str, 78 | down_size: int): 79 | """ 80 | 发送ws消息更新前端的下载进度 81 | """ 82 | await ConnectionManager.send_message({ 83 | 'cmd': 'update_download_progress', 84 | 'params': { 85 | 'download_id': download_id, 86 | 'speed': speed_str, 87 | 'time_remain': time_remain_str, 88 | 'down_size': cls.byte_to_suitable_size(down_size), 89 | 'down_size_raw': down_size 90 | } 91 | }, client_id=tuple(ConnectionManager.active_connections.keys())[0]) 92 | 93 | @classmethod 94 | async def _download_finished(cls, download_id: str): 95 | """ 96 | 发送ws消息提示前端下载完毕 97 | """ 98 | await ConnectionManager.send_message({ 99 | 'cmd': 'update_download_progress', 100 | 'params': { 101 | 'download_id': download_id, 102 | 'finished': True 103 | } 104 | }, client_id=tuple(ConnectionManager.active_connections.keys())[0]) 105 | 106 | @classmethod 107 | async def _download_error(cls, download_id: str): 108 | """ 109 | 发送ws消息提示前端下载错误 110 | """ 111 | await ConnectionManager.send_message({ 112 | 'cmd': 'update_download_progress', 113 | 'params': { 114 | 'download_id': download_id, 115 | 'error': True 116 | } 117 | }, client_id=tuple(ConnectionManager.active_connections.keys())[0]) 118 | 119 | @staticmethod 120 | def download_file_dir_check(file_path: str): 121 | """ 122 | 检查下载文件夹是否存在,不存在则递归创建 123 | """ 124 | dir_path = os.path.dirname(file_path) 125 | if not os.path.exists(dir_path): 126 | os.makedirs(dir_path) 127 | 128 | @classmethod 129 | async def add_download_task(cls, download_id: str, file_id: str, res_id: str, path: str): 130 | """ 131 | 提交下载任务到队列(生产者) 132 | """ 133 | cls.file_queue.put({ 134 | 'download_id': download_id, 135 | 'file_id': file_id, 136 | 'res_id': res_id, 137 | 'path': path 138 | }) 139 | 140 | @classmethod 141 | async def run(cls): 142 | """ 143 | 开启下载队列(消费者) 144 | """ 145 | 146 | # 判断队列是否已在下载 147 | if cls.running: 148 | return 149 | 150 | # 标识正在下载 151 | cls.running = True 152 | while True: 153 | if Downloader.file_queue.empty(): 154 | break 155 | file_info = Downloader.file_queue.get() 156 | download_id = file_info['download_id'] 157 | try: 158 | s = await User.get_login_session() 159 | with s.get('https://course2.xmu.edu.cn/meol/common/script/download.jsp?' 160 | f'fileid={file_info["file_id"]}&resid={file_info["res_id"]}', stream=True) as r: 161 | file_size = int(r.headers['content-length']) # 文件大小 Byte 162 | 163 | # 检查path所在文件夹 164 | cls.download_file_dir_check(file_info['path']) 165 | with open(file_info['path'], "wb") as file: 166 | down_size = 0 # 已下载字节数 167 | old_down_size = 0 # 上一次已下载字节数 168 | now = time.time() 169 | await cls._update_download_progress(download_id, '下载开始', '--', 0) 170 | for chunk in r.iter_content(chunk_size=1024): # 每次下载1B 171 | if chunk: 172 | file.write(chunk) 173 | down_size += len(chunk) 174 | 175 | if time.time() - now >= 0.5: # 每0.5s计算一次下载速度 176 | speed = round((down_size - old_down_size) / 0.5) 177 | time_remain = round((file_size - down_size) / speed) 178 | speed_str = cls.byte_to_suitable_size(speed) + '/s' 179 | time_remain_str = cls._sec_to_suitable_time(time_remain) 180 | 181 | # ws发送消息更新下载进度 182 | await cls._update_download_progress(download_id, speed_str, 183 | time_remain_str, down_size) 184 | # 避免阻塞ws通知的线程 185 | await asyncio.sleep(0.01) 186 | old_down_size = down_size 187 | now = time.time() 188 | # 此文件下载结束ws消息 189 | await cls._download_finished(download_id) 190 | # 避免请求过频繁 191 | await asyncio.sleep(0.5) 192 | except Exception as e: 193 | print('下载发生错误', e) 194 | # 原文件存在则删除 195 | try: 196 | os.remove(file_info['path']) 197 | except: 198 | pass 199 | # 发送下载错误通知 200 | await cls._download_error(download_id) 201 | await asyncio.sleep(0.01) 202 | 203 | cls.running = False 204 | 205 | @classmethod 206 | async def download_open_in_folder(cls, file_path, res): 207 | """ 208 | 下载响应内容为文件,下载完毕后在文件夹打开 209 | """ 210 | 211 | # 检查path所在文件夹 212 | cls.download_file_dir_check(file_path) 213 | with open(file_path, "wb") as file: 214 | for chunk in res.iter_content(chunk_size=1024): # 每次下载1B 215 | if chunk: 216 | file.write(chunk) 217 | res.close() 218 | 219 | os.system(f'explorer /select, "{file_path}"') 220 | -------------------------------------------------------------------------------- /app/src/utils/encrypt.js: -------------------------------------------------------------------------------- 1 | var CryptoJS=CryptoJS||function(u,p){var d={},l=d.lib={},s=function(){},t=l.Base={extend:function(a){s.prototype=this;var c=new s;a&&c.mixIn(a);c.hasOwnProperty("init")||(c.init=function(){c.$super.init.apply(this,arguments)});c.init.prototype=c;c.$super=this;return c},create:function(){var a=this.extend();a.init.apply(a,arguments);return a},init:function(){},mixIn:function(a){for(var c in a)a.hasOwnProperty(c)&&(this[c]=a[c]);a.hasOwnProperty("toString")&&(this.toString=a.toString)},clone:function(){return this.init.prototype.extend(this)}}, 2 | r=l.WordArray=t.extend({init:function(a,c){a=this.words=a||[];this.sigBytes=c!=p?c:4*a.length},toString:function(a){return(a||v).stringify(this)},concat:function(a){var c=this.words,e=a.words,j=this.sigBytes;a=a.sigBytes;this.clamp();if(j%4)for(var k=0;k>>2]|=(e[k>>>2]>>>24-8*(k%4)&255)<<24-8*((j+k)%4);else if(65535>>2]=e[k>>>2];else c.push.apply(c,e);this.sigBytes+=a;return this},clamp:function(){var a=this.words,c=this.sigBytes;a[c>>>2]&=4294967295<< 3 | 32-8*(c%4);a.length=u.ceil(c/4)},clone:function(){var a=t.clone.call(this);a.words=this.words.slice(0);return a},random:function(a){for(var c=[],e=0;e>>2]>>>24-8*(j%4)&255;e.push((k>>>4).toString(16));e.push((k&15).toString(16))}return e.join("")},parse:function(a){for(var c=a.length,e=[],j=0;j>>3]|=parseInt(a.substr(j, 4 | 2),16)<<24-4*(j%8);return new r.init(e,c/2)}},b=w.Latin1={stringify:function(a){var c=a.words;a=a.sigBytes;for(var e=[],j=0;j>>2]>>>24-8*(j%4)&255));return e.join("")},parse:function(a){for(var c=a.length,e=[],j=0;j>>2]|=(a.charCodeAt(j)&255)<<24-8*(j%4);return new r.init(e,c)}},x=w.Utf8={stringify:function(a){try{return decodeURIComponent(escape(b.stringify(a)))}catch(c){throw Error("Malformed UTF-8 data");}},parse:function(a){return b.parse(unescape(encodeURIComponent(a)))}}, 5 | q=l.BufferedBlockAlgorithm=t.extend({reset:function(){this._data=new r.init;this._nDataBytes=0},_append:function(a){"string"==typeof a&&(a=x.parse(a));this._data.concat(a);this._nDataBytes+=a.sigBytes},_process:function(a){var c=this._data,e=c.words,j=c.sigBytes,k=this.blockSize,b=j/(4*k),b=a?u.ceil(b):u.max((b|0)-this._minBufferSize,0);a=b*k;j=u.min(4*a,j);if(a){for(var q=0;q>>2]>>>24-8*(r%4)&255)<<16|(l[r+1>>>2]>>>24-8*((r+1)%4)&255)<<8|l[r+2>>>2]>>>24-8*((r+2)%4)&255,v=0;4>v&&r+0.75*v>>6*(3-v)&63));if(l=t.charAt(64))for(;d.length%4;)d.push(l);return d.join("")},parse:function(d){var l=d.length,s=this._map,t=s.charAt(64);t&&(t=d.indexOf(t),-1!=t&&(l=t));for(var t=[],r=0,w=0;w< 9 | l;w++)if(w%4){var v=s.indexOf(d.charAt(w-1))<<2*(w%4),b=s.indexOf(d.charAt(w))>>>6-2*(w%4);t[r>>>2]|=(v|b)<<24-8*(r%4);r++}return p.create(t,r)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="}})(); 10 | (function(u){function p(b,n,a,c,e,j,k){b=b+(n&a|~n&c)+e+k;return(b<>>32-j)+n}function d(b,n,a,c,e,j,k){b=b+(n&c|a&~c)+e+k;return(b<>>32-j)+n}function l(b,n,a,c,e,j,k){b=b+(n^a^c)+e+k;return(b<>>32-j)+n}function s(b,n,a,c,e,j,k){b=b+(a^(n|~c))+e+k;return(b<>>32-j)+n}for(var t=CryptoJS,r=t.lib,w=r.WordArray,v=r.Hasher,r=t.algo,b=[],x=0;64>x;x++)b[x]=4294967296*u.abs(u.sin(x+1))|0;r=r.MD5=v.extend({_doReset:function(){this._hash=new w.init([1732584193,4023233417,2562383102,271733878])}, 11 | _doProcessBlock:function(q,n){for(var a=0;16>a;a++){var c=n+a,e=q[c];q[c]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360}var a=this._hash.words,c=q[n+0],e=q[n+1],j=q[n+2],k=q[n+3],z=q[n+4],r=q[n+5],t=q[n+6],w=q[n+7],v=q[n+8],A=q[n+9],B=q[n+10],C=q[n+11],u=q[n+12],D=q[n+13],E=q[n+14],x=q[n+15],f=a[0],m=a[1],g=a[2],h=a[3],f=p(f,m,g,h,c,7,b[0]),h=p(h,f,m,g,e,12,b[1]),g=p(g,h,f,m,j,17,b[2]),m=p(m,g,h,f,k,22,b[3]),f=p(f,m,g,h,z,7,b[4]),h=p(h,f,m,g,r,12,b[5]),g=p(g,h,f,m,t,17,b[6]),m=p(m,g,h,f,w,22,b[7]), 12 | f=p(f,m,g,h,v,7,b[8]),h=p(h,f,m,g,A,12,b[9]),g=p(g,h,f,m,B,17,b[10]),m=p(m,g,h,f,C,22,b[11]),f=p(f,m,g,h,u,7,b[12]),h=p(h,f,m,g,D,12,b[13]),g=p(g,h,f,m,E,17,b[14]),m=p(m,g,h,f,x,22,b[15]),f=d(f,m,g,h,e,5,b[16]),h=d(h,f,m,g,t,9,b[17]),g=d(g,h,f,m,C,14,b[18]),m=d(m,g,h,f,c,20,b[19]),f=d(f,m,g,h,r,5,b[20]),h=d(h,f,m,g,B,9,b[21]),g=d(g,h,f,m,x,14,b[22]),m=d(m,g,h,f,z,20,b[23]),f=d(f,m,g,h,A,5,b[24]),h=d(h,f,m,g,E,9,b[25]),g=d(g,h,f,m,k,14,b[26]),m=d(m,g,h,f,v,20,b[27]),f=d(f,m,g,h,D,5,b[28]),h=d(h,f, 13 | m,g,j,9,b[29]),g=d(g,h,f,m,w,14,b[30]),m=d(m,g,h,f,u,20,b[31]),f=l(f,m,g,h,r,4,b[32]),h=l(h,f,m,g,v,11,b[33]),g=l(g,h,f,m,C,16,b[34]),m=l(m,g,h,f,E,23,b[35]),f=l(f,m,g,h,e,4,b[36]),h=l(h,f,m,g,z,11,b[37]),g=l(g,h,f,m,w,16,b[38]),m=l(m,g,h,f,B,23,b[39]),f=l(f,m,g,h,D,4,b[40]),h=l(h,f,m,g,c,11,b[41]),g=l(g,h,f,m,k,16,b[42]),m=l(m,g,h,f,t,23,b[43]),f=l(f,m,g,h,A,4,b[44]),h=l(h,f,m,g,u,11,b[45]),g=l(g,h,f,m,x,16,b[46]),m=l(m,g,h,f,j,23,b[47]),f=s(f,m,g,h,c,6,b[48]),h=s(h,f,m,g,w,10,b[49]),g=s(g,h,f,m, 14 | E,15,b[50]),m=s(m,g,h,f,r,21,b[51]),f=s(f,m,g,h,u,6,b[52]),h=s(h,f,m,g,k,10,b[53]),g=s(g,h,f,m,B,15,b[54]),m=s(m,g,h,f,e,21,b[55]),f=s(f,m,g,h,v,6,b[56]),h=s(h,f,m,g,x,10,b[57]),g=s(g,h,f,m,t,15,b[58]),m=s(m,g,h,f,D,21,b[59]),f=s(f,m,g,h,z,6,b[60]),h=s(h,f,m,g,C,10,b[61]),g=s(g,h,f,m,j,15,b[62]),m=s(m,g,h,f,A,21,b[63]);a[0]=a[0]+f|0;a[1]=a[1]+m|0;a[2]=a[2]+g|0;a[3]=a[3]+h|0},_doFinalize:function(){var b=this._data,n=b.words,a=8*this._nDataBytes,c=8*b.sigBytes;n[c>>>5]|=128<<24-c%32;var e=u.floor(a/ 15 | 4294967296);n[(c+64>>>9<<4)+15]=(e<<8|e>>>24)&16711935|(e<<24|e>>>8)&4278255360;n[(c+64>>>9<<4)+14]=(a<<8|a>>>24)&16711935|(a<<24|a>>>8)&4278255360;b.sigBytes=4*(n.length+1);this._process();b=this._hash;n=b.words;for(a=0;4>a;a++)c=n[a],n[a]=(c<<8|c>>>24)&16711935|(c<<24|c>>>8)&4278255360;return b},clone:function(){var b=v.clone.call(this);b._hash=this._hash.clone();return b}});t.MD5=v._createHelper(r);t.HmacMD5=v._createHmacHelper(r)})(Math); 16 | (function(){var u=CryptoJS,p=u.lib,d=p.Base,l=p.WordArray,p=u.algo,s=p.EvpKDF=d.extend({cfg:d.extend({keySize:4,hasher:p.MD5,iterations:1}),init:function(d){this.cfg=this.cfg.extend(d)},compute:function(d,r){for(var p=this.cfg,s=p.hasher.create(),b=l.create(),u=b.words,q=p.keySize,p=p.iterations;u.length>>2]&255}};d.BlockCipher=v.extend({cfg:v.cfg.extend({mode:b,padding:q}),reset:function(){v.reset.call(this);var a=this.cfg,b=a.iv,a=a.mode;if(this._xformMode==this._ENC_XFORM_MODE)var c=a.createEncryptor;else c=a.createDecryptor,this._minBufferSize=1;this._mode=c.call(a, 22 | this,b&&b.words)},_doProcessBlock:function(a,b){this._mode.processBlock(a,b)},_doFinalize:function(){var a=this.cfg.padding;if(this._xformMode==this._ENC_XFORM_MODE){a.pad(this._data,this.blockSize);var b=this._process(!0)}else b=this._process(!0),a.unpad(b);return b},blockSize:4});var n=d.CipherParams=l.extend({init:function(a){this.mixIn(a)},toString:function(a){return(a||this.formatter).stringify(this)}}),b=(p.format={}).OpenSSL={stringify:function(a){var b=a.ciphertext;a=a.salt;return(a?s.create([1398893684, 23 | 1701076831]).concat(a).concat(b):b).toString(r)},parse:function(a){a=r.parse(a);var b=a.words;if(1398893684==b[0]&&1701076831==b[1]){var c=s.create(b.slice(2,4));b.splice(0,4);a.sigBytes-=16}return n.create({ciphertext:a,salt:c})}},a=d.SerializableCipher=l.extend({cfg:l.extend({format:b}),encrypt:function(a,b,c,d){d=this.cfg.extend(d);var l=a.createEncryptor(c,d);b=l.finalize(b);l=l.cfg;return n.create({ciphertext:b,key:c,iv:l.iv,algorithm:a,mode:l.mode,padding:l.padding,blockSize:a.blockSize,formatter:d.format})}, 24 | decrypt:function(a,b,c,d){d=this.cfg.extend(d);b=this._parse(b,d.format);return a.createDecryptor(c,d).finalize(b.ciphertext)},_parse:function(a,b){return"string"==typeof a?b.parse(a,this):a}}),p=(p.kdf={}).OpenSSL={execute:function(a,b,c,d){d||(d=s.random(8));a=w.create({keySize:b+c}).compute(a,d);c=s.create(a.words.slice(b),4*c);a.sigBytes=4*b;return n.create({key:a,iv:c,salt:d})}},c=d.PasswordBasedCipher=a.extend({cfg:a.cfg.extend({kdf:p}),encrypt:function(b,c,d,l){l=this.cfg.extend(l);d=l.kdf.execute(d, 25 | b.keySize,b.ivSize);l.iv=d.iv;b=a.encrypt.call(this,b,c,d.key,l);b.mixIn(d);return b},decrypt:function(b,c,d,l){l=this.cfg.extend(l);c=this._parse(c,l.format);d=l.kdf.execute(d,b.keySize,b.ivSize,c.salt);l.iv=d.iv;return a.decrypt.call(this,b,c,d.key,l)}})}(); 26 | (function(){for(var u=CryptoJS,p=u.lib.BlockCipher,d=u.algo,l=[],s=[],t=[],r=[],w=[],v=[],b=[],x=[],q=[],n=[],a=[],c=0;256>c;c++)a[c]=128>c?c<<1:c<<1^283;for(var e=0,j=0,c=0;256>c;c++){var k=j^j<<1^j<<2^j<<3^j<<4,k=k>>>8^k&255^99;l[e]=k;s[k]=e;var z=a[e],F=a[z],G=a[F],y=257*a[k]^16843008*k;t[e]=y<<24|y>>>8;r[e]=y<<16|y>>>16;w[e]=y<<8|y>>>24;v[e]=y;y=16843009*G^65537*F^257*z^16843008*e;b[k]=y<<24|y>>>8;x[k]=y<<16|y>>>16;q[k]=y<<8|y>>>24;n[k]=y;e?(e=z^a[a[a[G^z]]],j^=a[a[j]]):e=j=1}var H=[0,1,2,4,8, 27 | 16,32,64,128,27,54],d=d.AES=p.extend({_doReset:function(){for(var a=this._key,c=a.words,d=a.sigBytes/4,a=4*((this._nRounds=d+6)+1),e=this._keySchedule=[],j=0;j>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255]):(k=k<<8|k>>>24,k=l[k>>>24]<<24|l[k>>>16&255]<<16|l[k>>>8&255]<<8|l[k&255],k^=H[j/d|0]<<24);e[j]=e[j-d]^k}c=this._invKeySchedule=[];for(d=0;dd||4>=j?k:b[l[k>>>24]]^x[l[k>>>16&255]]^q[l[k>>> 28 | 8&255]]^n[l[k&255]]},encryptBlock:function(a,b){this._doCryptBlock(a,b,this._keySchedule,t,r,w,v,l)},decryptBlock:function(a,c){var d=a[c+1];a[c+1]=a[c+3];a[c+3]=d;this._doCryptBlock(a,c,this._invKeySchedule,b,x,q,n,s);d=a[c+1];a[c+1]=a[c+3];a[c+3]=d},_doCryptBlock:function(a,b,c,d,e,j,l,f){for(var m=this._nRounds,g=a[b]^c[0],h=a[b+1]^c[1],k=a[b+2]^c[2],n=a[b+3]^c[3],p=4,r=1;r>>24]^e[h>>>16&255]^j[k>>>8&255]^l[n&255]^c[p++],s=d[h>>>24]^e[k>>>16&255]^j[n>>>8&255]^l[g&255]^c[p++],t= 29 | d[k>>>24]^e[n>>>16&255]^j[g>>>8&255]^l[h&255]^c[p++],n=d[n>>>24]^e[g>>>16&255]^j[h>>>8&255]^l[k&255]^c[p++],g=q,h=s,k=t;q=(f[g>>>24]<<24|f[h>>>16&255]<<16|f[k>>>8&255]<<8|f[n&255])^c[p++];s=(f[h>>>24]<<24|f[k>>>16&255]<<16|f[n>>>8&255]<<8|f[g&255])^c[p++];t=(f[k>>>24]<<24|f[n>>>16&255]<<16|f[g>>>8&255]<<8|f[h&255])^c[p++];n=(f[n>>>24]<<24|f[g>>>16&255]<<16|f[h>>>8&255]<<8|f[k&255])^c[p++];a[b]=q;a[b+1]=s;a[b+2]=t;a[b+3]=n},keySize:8});u.AES=p._createHelper(d)})();function _gas(data,key0, 30 | iv0){key0=key0.replace(/(^\s+)|(\s+$)/g, "");var key = CryptoJS.enc.Utf8.parse(key0);var iv = CryptoJS.enc.Utf8.parse(iv0);var encrypted =CryptoJS.AES.encrypt(data,key,{iv:iv,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.Pkcs7});return encrypted.toString();}function encryptAES(data,_p1){if(!_p1){return data;}var encrypted =_gas(_rds(64)+data,_p1,_rds(16));return encrypted;}function _ep(p0,p1) {try{return encryptAES(p0,p1);}catch(e){}return p0; 31 | }var $_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';var _chars_len = $_chars.length;function _rds(len) {var retStr = '';for (var i = 0; i < len; i++) {retStr += $_chars.charAt(Math.floor(Math.random() * _chars_len));}return retStr;} 32 | 33 | // 修改后的加密js 34 | export default encryptAES 35 | -------------------------------------------------------------------------------- /server/course_helper/routers/course.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import re 4 | 5 | import nanoid 6 | from fastapi import APIRouter, HTTPException, BackgroundTasks 7 | from lxml import etree 8 | from pydantic import BaseModel 9 | 10 | from .user import User 11 | from ..common import success_info, CourseHelperException, error_info 12 | from ..logger import Logger 13 | from ..download import Downloader 14 | 15 | router = APIRouter() 16 | logger: Logger 17 | 18 | 19 | class FileModel(BaseModel): 20 | file_id: str 21 | res_id: str 22 | file_dir: str = './' 23 | 24 | 25 | class DownloadFilesModel(BaseModel): 26 | file_list: list[FileModel] 27 | dir_path: str 28 | 29 | 30 | class HomeworkSubmitModel(BaseModel): 31 | hw_id: str 32 | content: str 33 | 34 | 35 | @router.on_event("startup") 36 | async def __init(): 37 | # 获取默认日志 38 | global logger 39 | logger = Logger('课程模块') 40 | 41 | 42 | @router.get('/getCourseList') 43 | async def get_course_list(): 44 | """ 45 | 获取课程列表 46 | """ 47 | try: 48 | session = await User.get_login_session() 49 | res = session.get('https://course2.xmu.edu.cn/meol/lesson/blen.student.lesson.list.jsp') 50 | html = etree.HTML(res.text) 51 | tr_list = html.xpath("//table[@id='table2']//tr") 52 | tr_list.pop(0) 53 | 54 | course_list = [] 55 | for tr in tr_list: 56 | td_list = tr.xpath('.//td') 57 | 58 | a = td_list[0].xpath('./a')[0] 59 | href = a.attrib['href'] 60 | course_list.append({ 61 | 'name': a.text.strip(), 62 | 'college': td_list[1].text.strip(), 63 | 'teacher': td_list[2].text.strip(), 64 | 'course_id': href[href.find('?lid=') + 5:] 65 | }) 66 | return success_info(msg='获取课程列表成功!', data=course_list) 67 | 68 | except CourseHelperException as e: 69 | logger.warning(f'获取课程列表失败 - 失败原因:{e}') 70 | raise HTTPException(400, detail=error_info(e.data)) 71 | except Exception as e: 72 | logger.debug(f'获取课程列表失败 e-{e}') 73 | raise HTTPException(400, detail=error_info('获取课程列表失败')) 74 | 75 | 76 | @router.get('/getCourseIntroduction/{course_id}') 77 | async def get_course_introduction(course_id: str): 78 | """ 79 | 获取课程介绍 80 | """ 81 | try: 82 | session = await User.get_login_session() 83 | res = session.get(f'https://course2.xmu.edu.cn/meol/lesson/coursesum.jsp?lid={course_id}') 84 | html = etree.HTML(res.text) 85 | nodes = html.xpath("//td[@class='text']//input[@type='hidden']") 86 | content = nodes[0].attrib['value'] 87 | return success_info(msg='获取课程介绍成功', data={ 88 | 'course_id': course_id, 89 | 'content': content 90 | }) 91 | 92 | except CourseHelperException as e: 93 | logger.warning(f'获取课程介绍失败 - 失败原因:{e}') 94 | raise HTTPException(400, detail=error_info(e.data)) 95 | except Exception as e: 96 | logger.debug(f'获取课程介绍失败 e-{e}') 97 | raise HTTPException(400, detail=error_info('获取课程介绍失败')) 98 | 99 | 100 | @router.get('/getHomeworkCommittableState') 101 | async def get_homework_committable_state(course_id: str, hw_id: str): 102 | """ 103 | 检查作业可提交状态 104 | """ 105 | try: 106 | session = await User.get_login_session() 107 | session.get(f'https://course2.xmu.edu.cn/meol/jpk/course/layout/newpage/index.jsp?courseId={course_id}') 108 | res = session.get(f'https://course2.xmu.edu.cn/meol/common/hw/student/hwtask.view.jsp?hwtid={hw_id}') 109 | html = etree.HTML(res.text) 110 | return success_info(msg='检验作业可提交状态成功', data={ 111 | 'hw_id': hw_id, 112 | 'committable': len(html.xpath("//div[@class='buttonc']/input[@value='提交作业']")) > 0 113 | }) 114 | 115 | except CourseHelperException as e: 116 | logger.warning(f'检验作业是否可提交失败 - 失败原因:{e}') 117 | raise HTTPException(400, detail=error_info(e.data)) 118 | except Exception as e: 119 | logger.debug(f'检验作业是否可提交失败 e-{e}') 120 | raise HTTPException(400, detail=error_info('检验作业是否可提交失败')) 121 | 122 | 123 | @router.get('/getCourseHomework/{course_id}') 124 | async def get_course_homework(course_id: str): 125 | """ 126 | 获取课程作业 127 | """ 128 | try: 129 | session = await User.get_login_session() 130 | session.get(f'https://course2.xmu.edu.cn/meol/jpk/course/layout/newpage/index.jsp?courseId={course_id}') 131 | res = session.get('https://course2.xmu.edu.cn/meol/common/hw/student/hwtask.jsp') 132 | html = etree.HTML(res.text) 133 | nodes: list = html.xpath("//table[@class='valuelist']//tr") 134 | nodes.pop(0) 135 | 136 | homeworks = [] 137 | for node in nodes: 138 | cols = node.xpath("./td") 139 | ele_a = cols[0].xpath("./a[1]")[0] 140 | hw_obj = { 141 | 'hw_id': ele_a.attrib['href'][ele_a.attrib['href'].find('=') + 1:], 142 | 'title': ele_a.text.strip() 143 | } 144 | 145 | for index, name in enumerate(['end_date', 'score', 'publisher'], 1): 146 | hw_obj[name] = cols[index].text.strip() 147 | 148 | # 无法继续提交则为false(可能是超时、不可重复提交) 149 | hw_obj['committable'] = len(cols[5].xpath('./a')) > 0 150 | homeworks.append(hw_obj) 151 | 152 | return success_info(msg='获取课程作业成功', data={ 153 | 'course_id': course_id, 154 | 'homeworks': homeworks 155 | }) 156 | 157 | except CourseHelperException as e: 158 | logger.warning(f'获取课程作业失败 - 失败原因:{e}') 159 | raise HTTPException(400, detail=error_info(e.data)) 160 | except Exception as e: 161 | logger.debug(f'获取课程作业失败 e-{e}') 162 | raise HTTPException(400, detail=error_info('获取课程作业失败')) 163 | 164 | 165 | @router.get('/getHomeworkDetails/{hw_id}') 166 | async def get_homework_details(hw_id: str): 167 | """ 168 | 获取作业详情 169 | """ 170 | try: 171 | session = await User.get_login_session() 172 | res = session.get(f'https://course2.xmu.edu.cn/meol/common/hw/student/taskanswer.jsp?hwtid={hw_id}') 173 | html = etree.HTML(res.text) 174 | 175 | content = {} 176 | tables = html.xpath("//table[@class='infotable']") 177 | 178 | for index, name in enumerate(['title', 'end_date', 'scoring_method', 'score'], 1): 179 | content[name] = tables[0].xpath(f".//tr[{index}]/td")[0].text.strip() 180 | 181 | content['scoring_method'] = content['scoring_method'].replace('打分制:', '') 182 | 183 | nodes = ( 184 | tables[0].xpath(".//tr[5]/td/input"), 185 | tables[1].xpath('.//tr[2]/td/input'), 186 | tables[2].xpath('.//tr[2]/td/input'), 187 | ) 188 | names = ('content', 'answer', 'result', 'comment') 189 | 190 | for index in range(3): 191 | if len(nodes[index]) > 0: 192 | value = re.sub(r"(http.*/meol|/meol)(.*?openfile.jsp\?id=)", 193 | "http://127.0.0.1:6498/file/openFile/", 194 | nodes[index][0].attrib['value']) 195 | 196 | value = re.sub(r'(.*?)', 197 | r'''\g<2>''', 198 | value) 199 | 200 | content[names[index]] = value 201 | else: 202 | content[names[index]] = '' 203 | 204 | content['comment'] = tables[3].xpath('.//tr[2]/td')[0].text.strip() 205 | 206 | return success_info(msg='获取课程作业成功', data={ 207 | 'hw_id': hw_id, 208 | 'content': content 209 | }) 210 | 211 | except CourseHelperException as e: 212 | logger.warning(f'获取作业详情失败 - 失败原因:{e}') 213 | raise HTTPException(400, detail=error_info(e.data)) 214 | except Exception as e: 215 | logger.debug(f'获取作业详情失败 e-{e}') 216 | raise HTTPException(400, detail=error_info('获取课程详情失败')) 217 | 218 | 219 | @router.post('/submitHomework') 220 | async def submit_homework(data: HomeworkSubmitModel): 221 | try: 222 | session = await User.get_login_session() 223 | res = session.post('https://course2.xmu.edu.cn/meol/common/hw/student/write.do.jsp', { 224 | 'hwtid': data.hw_id, 225 | 'IPT_BODY': data.content.encode('gbk'), 226 | }, allow_redirects=False) 227 | 228 | if res.status_code != 302: 229 | raise CourseHelperException('作业提交失败') 230 | 231 | return success_info('作业提交成功') 232 | except CourseHelperException as e: 233 | logger.warning(f'作业提交失败 - 失败原因:{e}') 234 | raise HTTPException(400, detail=error_info(e.data)) 235 | except Exception as e: 236 | logger.debug(f'作业提交失败 e-{e}') 237 | raise HTTPException(400, detail=error_info('作业提交失败')) 238 | 239 | 240 | @router.get('/getCourseResource/{course_id}') 241 | async def get_course_resource(course_id: str, folder_id: str = '0', deep: bool = False): 242 | """ 243 | 获取课程资源树状结构 244 | """ 245 | try: 246 | session = await User.get_login_session() 247 | content = get_resource_in_folder(course_id, folder_id, session, deep_flag=deep) 248 | return success_info(msg='获取课程资源成功', data={ 249 | 'course_id': course_id, 250 | 'content': content 251 | }) 252 | 253 | except CourseHelperException as e: 254 | logger.warning(f'获取课程资源失败 - 失败原因:{e}') 255 | raise HTTPException(400, detail=error_info(e.data)) 256 | except Exception as e: 257 | logger.debug(f'获取课程资源失败 e-{e}') 258 | raise HTTPException(400, detail=error_info('获取课程资源失败')) 259 | 260 | 261 | @router.get('/getCourseResourceInfo') 262 | async def get_course_resource_info(file_id: str, res_id: str): 263 | """ 264 | 获取课程资源信息: 文件名、文件大小 265 | """ 266 | try: 267 | session = await User.get_login_session() 268 | res = session.get('https://course2.xmu.edu.cn/meol/common/script/preview/' 269 | f'download_preview.jsp?fileid={file_id}&resid={res_id}') 270 | html = etree.HTML(res.text) 271 | elements = html.xpath('//div[@class="h1-title"]//span') 272 | info = { 273 | 'file_name': elements[1].text.strip(), 274 | 'file_size': elements[2].text.strip().strip('()\n') 275 | } 276 | return success_info('获取课程资源信息成功', data=info) 277 | 278 | except CourseHelperException as e: 279 | logger.warning(f'获取课程资源信息失败 - 失败原因:{e}') 280 | raise HTTPException(400, detail=error_info(e.data)) 281 | except Exception as e: 282 | logger.debug(f'获取课程资源信息失败 e-{e}') 283 | raise HTTPException(400, detail=error_info('获取课程资源信息失败')) 284 | 285 | 286 | @router.post('/downloadCourseResource') 287 | async def download_course_resource(data: DownloadFilesModel, background_tasks: BackgroundTasks): 288 | """ 289 | 下载课程资源文件 290 | """ 291 | try: 292 | session = await User.get_login_session() 293 | 294 | file_list = data.file_list 295 | download_info = [] 296 | 297 | for item in file_list: 298 | file_info = await Downloader.get_file_info(session, item.file_id, item.res_id) 299 | if file_info['success']: 300 | while True: 301 | # 拼接目录 子目录 文件名 302 | file_path = os.path.abspath(os.path.join(data.dir_path, item.file_dir, file_info['file_name'])) 303 | if os.path.exists(file_path): 304 | # 文件名重复则添加一个# 305 | file_name_no_ext = file_info['file_name'][0:file_info['file_name'].rfind('.')] 306 | file_info['file_name'] = f"{file_name_no_ext}#.{file_info['file_ext']}" 307 | else: 308 | break 309 | 310 | download_id = nanoid.generate() 311 | file_info['download_id'] = download_id 312 | file_info['file_path'] = file_path 313 | # 提交BackgroundTasks 避免阻塞当前进程 314 | background_tasks.add_task( 315 | Downloader.add_download_task, download_id, item.file_id, item.res_id, file_path 316 | ) 317 | await asyncio.sleep(0.01) # 避免阻塞ws通知的线程 318 | download_info.append(file_info) 319 | logger.success(f'下载任务创建成功 download_id:{download_id} size:{file_info["file_size"]} path:{file_path}') 320 | # 降低请求频率 321 | await asyncio.sleep(0.35) 322 | 323 | # 1s后开启下载队列(通知消费者) 324 | async def tmp(): 325 | await asyncio.sleep(1) 326 | await Downloader.run() 327 | 328 | background_tasks.add_task(tmp) 329 | 330 | return success_info('文件已添加到下载列表', data=download_info) 331 | 332 | except CourseHelperException as e: 333 | logger.warning(f'文件下载失败 - 失败原因:{e}') 334 | raise HTTPException(400, detail=error_info(e.data)) 335 | except Exception as e: 336 | logger.debug(f'文件下载失败 e-{e}') 337 | raise HTTPException(400, detail=error_info('文件下载失败')) 338 | 339 | 340 | def get_resource_in_folder(course_id, folder_id, s, deep_flag=False) -> list: 341 | """ 342 | 递归获取课程资源树状结构 343 | """ 344 | res = s.get(f'https://course2.xmu.edu.cn/meol/common/script/listview.jsp?folderid={folder_id}&lid={course_id}') 345 | html = etree.HTML(res.text) 346 | nodes: list = html.xpath("//table[@class='valuelist']//tr") 347 | content = [] 348 | for row in nodes: 349 | # 表头栏跳过(可能出现多个表头) 350 | if len(row.xpath("./th")) > 0: 351 | continue 352 | img_url = row.xpath(".//img")[0].attrib['src'] 353 | type_name = img_url[img_url.rfind('/') + 1:img_url.rfind('.')] 354 | res_ele = row.xpath(".//a")[0] 355 | res_url = res_ele.attrib['href'] 356 | res_name = res_ele.text.strip() 357 | 358 | res_obj = {'type_name': type_name, 'res_name': res_name, 'key': nanoid.generate()} 359 | if type_name == 'folder': 360 | res_obj['folder_id'] = re.search(r'folderid=(\d*)', res_url).group(1) 361 | if deep_flag: 362 | res_obj['children'] = get_resource_in_folder(course_id, res_obj['folder_id'], s, deep_flag) 363 | elif type_name == 'link': 364 | res_id = re.search(r'resid=(\d*)', res_url).group(1) 365 | link_res = s.get(f'https://course2.xmu.edu.cn/meol/common/script/openurl.jsp?resid={res_id}') 366 | res_obj['link'] = re.search(r"location.href='(.*?)'", link_res.text).group(1) 367 | else: 368 | res_obj['file_id'], res_obj['res_id'] = re.search(r'fileid=(\d*).*?resid=(\d*)', res_url).group(1, 2) 369 | content.append(res_obj) 370 | return content 371 | -------------------------------------------------------------------------------- /app/src/components/CourseResourcePane.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 452 | 453 | 456 | --------------------------------------------------------------------------------