├── 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 |
5 |
13 |
14 |
15 |
16 |
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 |
2 | >>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<>>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
70 |
74 |