├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── download_sample.png ├── ilms ├── __init__.py ├── cli.py ├── core.py ├── exception.py ├── parser.py ├── route.py └── utils.py ├── setup.cfg └── setup.py /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | .vscode/ 92 | download/ 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Salas 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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iLMS NTHU 2 | 3 | 專為 學生/助教/開發者 所寫的 iLMS 通用 API/command-line 環境 4 | 5 | - 列出 修課課程 / 作業 / 上課教材 6 | - 下載 作業 / 上課教材 7 | - 上傳 `csv` 檔登記作業分數 8 | 9 | ## 安裝 10 | 11 | - 從 `PyPI` 上安裝 12 | ```bash 13 | pip install -U ilms-nthu 14 | ``` 15 | - 從本專案原始碼安裝最新版 16 | ```bash 17 | pip install git+https://github.com/leVirve/iLms-nthu-API 18 | ``` 19 | 20 | Note: 本專案開發測試在 Python3.5+ 21 | 22 | ## 指令 23 | 24 | ### 列出 修課課程 / 作業 / 上課教材 25 | 26 | - 列出本學期所有課程 27 | ```bash 28 | ilms view course 29 | ``` 30 | 31 | - 列出所有修過課程 32 | ```bash 33 | ilms view course --semester_id all 34 | ``` 35 | 36 | - 列出某學期修過課程, e.g. 37 | ```bash 38 | ilms view course --semester_id 1051 39 | ``` 40 | 41 | - 列出某課程所有作業, e.g. 42 | ```bash 43 | # 只需輸入 課號 (course id)/課程中英文 關鍵字 44 | ilms view homework --course CS65500 45 | ilms view homework --course Vision 46 | ilms view homework --course 電腦視覺 47 | ``` 48 | 49 | - 完整指令 50 | ```bash 51 | Usage: ilms view [OPTIONS] 查詢項目 52 | 53 | 選擇查詢項目 課程 / 作業 / 上課教材 ['course', 'homework', 'material'] 54 | 55 | Options: 56 | --semester_id TEXT 學期 57 | --course TEXT 課程關鍵字 58 | --verbose 顯示詳細資訊 59 | --help Show this message and exit. 60 | ``` 61 | 62 | ### 下載 作業 / 上課教材 63 | 64 | - 下載所有上課教材, e.g. 65 | 66 | ```bash 67 | # 只需輸入 課號 (course id)/課程中英文 關鍵字 68 | ilms download material --course 35700 69 | ilms download material --course 多媒體 70 | ilms download material --course Visual Effects 71 | ``` 72 | 73 | - [助教模式 TA mode] 下載所有學生作業, e.g. 74 | 75 | ```bash 76 | # --course 課號 (course id)/課程中英文 關鍵字 77 | # --hw_title 作業標題 關鍵字 78 | ilms download handin --course CS35700 --hw_title Homework1 79 | ``` 80 | 81 | - 完整指令 82 | ```bash 83 | Usage: ilms download [OPTIONS] 下載項目 84 | 85 | 選擇下載項目 上課教材 / 繳交作業 (助教) ['material', 'handin'] 86 | 87 | Options: 88 | --course TEXT 課程關鍵字 89 | --hw_title TEXT 作業標題 90 | --folder TEXT 下載至...資料夾 91 | --help Show this message and exit. 92 | ``` 93 | 94 | ### 登記成績 95 | 96 | - [助教模式 TA mode] 透過上傳分數 `csv` 檔登記分數, e.g. 97 | 98 | ```bash 99 | # --course 課號 (course id)/課程中英文 關鍵字 100 | # --hw_title 作業標題 關鍵字 101 | ilms score --course CS35700 --hw_title Homework1 --score_csv hw1-cs3570.csv 102 | ``` 103 | 104 | - 完整指令 105 | ```bash 106 | Usage: ilms score [OPTIONS] 107 | 108 | Options: 109 | --course TEXT 課程關鍵字 110 | --hw_title TEXT 作業標題 111 | --csv TEXT CSV 成績表 112 | --help Show this message and exit. 113 | ``` 114 | 115 | ### 登出 iLMS-NTHU API 116 | 117 | ```bash 118 | ilms logout 119 | ``` 120 | 121 | ## 範例程式 API Demo 122 | 123 | ### 登入 iLms 124 | 125 | - You need login for any operations that need privileges. 126 | - login with helper function `get_account()` 127 | 128 | ```python 129 | from ilms.core import User 130 | from ilms.core import Core as iLms 131 | from ilms.utils import get_account 132 | 133 | user = User(*get_account()) 134 | assert user.login() 135 | 136 | # You can take your profile 137 | profile = ilms.get_profile() 138 | 139 | ilms = iLms(user) 140 | ``` 141 | 142 | ### 查詢/搜尋課程 143 | 144 | ```python 145 | # iterate through courses with loop 146 | for cou in ilms.get_courses(): 147 | cou.course_id 148 | print(cou) 149 | 150 | # query with 'keyword', can be coures_id or partial course name in `en` or `zh` 151 | courses = ilms.get_courses() 152 | cou = courses.find(course_id='CS35700') 153 | cou = courses.find(name='視覺特效') 154 | cou = courses.find(name='Pattern Recog') 155 | 156 | ``` 157 | 158 | ### 下載所有上課教材 159 | 160 | ```python 161 | for material in cou.get_materials(): 162 | print(material) 163 | material.download(root_folder='download/cvfx/') 164 | ``` 165 | 166 | ### 下載所有繳交作業檔案 167 | 168 | ```python 169 | from ilms.utils import load_score_csv 170 | 171 | homeworks = cou.get_homeworks() 172 | hw1 = homeworks.find(title='Homework1') 173 | 174 | hw1.download_handins() 175 | ``` 176 | 177 | ### 為作業登記分數 178 | 179 | - Use helper function `load_score_csv()` to load the scores in csv file (contains only two columns, `student id` and `score`) 180 | - Can do some processes on the `score_map`, and then use `score_hanins` method to grading in bulk. 181 | 182 | ```python 183 | from ilms.utils import load_score_csv 184 | 185 | homeworks = cou.get_homeworks() 186 | hw1 = homeworks.find(title='Homework1') 187 | 188 | score_map = load_score_csv('hw1-cs35700.csv') 189 | score_map = { 190 | student_id: math.ceil(score) 191 | for student_id, score in score_map.items()} 192 | 193 | hw1.score_handins(score_map) 194 | ``` 195 | 196 | ### 完整範例 197 | 198 | ```python 199 | from ilms.core import User 200 | from ilms.core import Core as iLms 201 | 202 | 203 | ''' 1. get your profile ''' 204 | profile = ilms.get_profile() 205 | 206 | ''' 2. iterate through all your courses ''' 207 | for cou in ilms.get_courses(): 208 | 209 | ''' 2.a find out all homewrok information ''' 210 | for homework in cou.get_homeworks(): 211 | 212 | ''' 3. if you're TA, you should get this feature 213 | to explore all the students' works ! 214 | [View the detail / Download files] 215 | ''' 216 | for handin in homework.handin_list: 217 | pprint(handin.detail) 218 | handin.download() 219 | 220 | ''' 4. You can download all materials in few lines ! ''' 221 | for material in cou.get_materials(): 222 | print(material.detail) 223 | material.download() 224 | 225 | print(cou.get_forum_list().result) 226 | ``` 227 | 228 | ### 智慧查詢資料結構 Smart query container 229 | 230 | Even, with `smart query` feature 231 | 232 | ```python 233 | 234 | courses = ilms.get_courses() 235 | 236 | ''' get the specific course with keyword ''' 237 | course = courses.find(course_id='CS35700') 238 | 239 | homeworks = course.get_homeworks() 240 | 241 | ''' get the specific homework (in two ways) ''' 242 | hw1 = homeworks.get(0) 243 | hw1 = homeworks.find(title='Homework1') 244 | 245 | ''' get the specific haned_in homework ''' 246 | handin = hw1.get(87) 247 | handin = hw1.handin_list.find(authour='王曉明') 248 | handin = hw1.handin_list.find(date='2017-03-25') 249 | 250 | ``` 251 | -------------------------------------------------------------------------------- /download_sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leVirve/iLms-nthu-API/0b57fad9782716d280c444d0ef8ae525359d921d/download_sample.png -------------------------------------------------------------------------------- /ilms/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | _base_dir = os.path.expanduser('~') 5 | _ilms_dir = os.path.join(_base_dir, '.ilms') 6 | if not os.path.exists(_ilms_dir): 7 | os.makedirs(_ilms_dir) 8 | 9 | _config_file = os.path.expanduser(os.path.join(_ilms_dir, 'ilms.json')) 10 | 11 | __version__ = '0.2.8' 12 | -------------------------------------------------------------------------------- /ilms/cli.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pprint 3 | from functools import partial 4 | 5 | import click 6 | 7 | from ilms.core import User, Core as iLms 8 | from ilms.utils import get_account, load_score_csv, remove_account_file 9 | 10 | 11 | def aquire_core(): 12 | _account, _password = get_account() 13 | user = User(_account, _password) 14 | assert user.login() 15 | return iLms(user) 16 | 17 | 18 | def query_helper(container, query, prompt, strict=False): 19 | key, value = list(query.items())[0] 20 | if not value: 21 | value = input('%s: ' % prompt) 22 | for k, v in query.items(): 23 | query[k] = value 24 | return container.find(**query, strict=strict) 25 | 26 | 27 | def _heuristic_find_course(ilms, semester_id, course_kw): 28 | possible_query = {'course_id': course_kw, 'name': course_kw} 29 | _find_course = partial(query_helper, query=possible_query, prompt='輸入課程關鍵字') 30 | 31 | if semester_id: 32 | cou = _find_course(ilms.all_courses[semester_id]) 33 | else: 34 | cou = _find_course(ilms.courses) 35 | 36 | if cou is None: 37 | for sem, courses in ilms.all_courses.items(): 38 | cou = _find_course(courses) 39 | if cou: 40 | break 41 | 42 | ''' No such course ''' 43 | if cou is None: 44 | print('查無此課程!') 45 | exit() 46 | 47 | print('\n=== 課程 ===\n', cou) 48 | return cou 49 | 50 | 51 | @click.command() 52 | @click.argument('name') 53 | @click.option('--semester_id', default=None, help='學期') 54 | @click.option('--course', default='', help='課號關鍵字') 55 | @click.option('--verbose', is_flag=True, help='顯示詳細資訊') 56 | def view(name, semester_id, course, verbose): 57 | ''' 選擇查詢項目 課程 / 作業 / 上課教材 58 | ''' 59 | 60 | def print_course_list(ilms): 61 | if semester_id is None: 62 | for cou in ilms.courses: 63 | print(cou) 64 | return 65 | if semester_id == 'all': 66 | for sem, courses in ilms.all_courses.items(): 67 | print('-- %s --' % sem) 68 | for cou in courses: 69 | print(cou) 70 | else: 71 | for cou in ilms.all_courses[semester_id]: 72 | print(cou) 73 | 74 | def print_homework_list(ilms): 75 | cou = _heuristic_find_course(ilms, semester_id, course) 76 | for hw in cou.get_homeworks(): 77 | verbose and pprint.pprint(hw.detail) or print(hw) 78 | 79 | def print_material_list(ilms): 80 | cou = _heuristic_find_course(ilms, semester_id, course) 81 | for mat in cou.get_materials(): 82 | verbose and pprint.pprint(mat.detail) or print(mat) 83 | 84 | core = aquire_core() 85 | { 86 | 'course': print_course_list, 87 | 'homework': print_homework_list, 88 | 'material': print_material_list, 89 | }[name](core) 90 | 91 | 92 | @click.command() 93 | @click.argument('name') 94 | @click.option('--course', default='', help='課程關鍵字') 95 | @click.option('--hw_title', default='', help='作業標題') 96 | @click.option('--folder', default='', help='下載至...資料夾') 97 | def download(name, course, hw_title, folder): 98 | ''' 選擇下載項目 上課教材 / 繳交作業 (助教) 99 | ''' 100 | 101 | def download_handins(ilms): 102 | cou = _heuristic_find_course(ilms, None, course) 103 | hw = query_helper(cou.get_homeworks(), {'title': hw_title}, prompt='作業標題', strict=True) 104 | root_folder = folder or 'download/%s/' % hw.title 105 | print(hw, '-> into', root_folder) 106 | hw.download_handins(root_folder) 107 | # if more specific options to download single file 108 | 109 | def download_materials(ilms): 110 | cou = _heuristic_find_course(ilms, None, course) 111 | for material in cou.get_materials(): 112 | root_folder = folder or 'download/%s/' % cou.course_id 113 | print(material, '-> into', root_folder) 114 | material.download(root_folder=root_folder) 115 | 116 | core = aquire_core() 117 | { 118 | 'handin': download_handins, 119 | 'material': download_materials, 120 | }[name](core) 121 | 122 | 123 | @click.command() 124 | @click.option('--course', default='', help='課程關鍵字') 125 | @click.option('--hw_title', default='', help='作業標題') 126 | @click.option('--csv', default='', help='CSV 成績表') 127 | def score(course, hw_title, csv): 128 | ''' 登記分數 ''' 129 | core = aquire_core() 130 | 131 | cou = _heuristic_find_course(core, None, course) 132 | hw = query_helper(cou.get_homeworks(), title=hw_title) 133 | 134 | score_map = load_score_csv(csv or input('Path to csv sheet: ')) 135 | score_map = { 136 | student_id: math.ceil(score) 137 | for student_id, score in score_map.items()} 138 | 139 | hw.score_handins(score_map) 140 | 141 | 142 | @click.command() 143 | def logout(): 144 | ''' 登出 iLMS-NTHU API ''' 145 | try: 146 | remove_account_file() 147 | except Exception as e: 148 | print('無法登出:', e) 149 | finally: 150 | print('成功登出') 151 | 152 | 153 | @click.group() 154 | def main(): 155 | pass 156 | 157 | 158 | main.add_command(view) 159 | main.add_command(score) 160 | main.add_command(download) 161 | main.add_command(logout) 162 | -------------------------------------------------------------------------------- /ilms/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import requests 5 | 6 | from ilms import parser 7 | from ilms import exception 8 | from ilms.route import route 9 | from ilms.utils import ( 10 | unzip_all, check_is_download, stream_download, 11 | json_dump, json_load, safe_str) 12 | 13 | session = requests.Session() 14 | 15 | 16 | class User: 17 | ''' 18 | profile: dict() of login return status, containing 'email', 'name', 19 | 'phone', 'info', 'divName', 'divCode' 20 | ''' 21 | 22 | def __init__(self, user, pwd): 23 | self.user = user 24 | self.pwd = pwd 25 | self.session = session 26 | self.session.headers = { 27 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 28 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' 29 | 'Chrome/67.0.3379.0 Safari/537.36', 30 | 'Refer': route.home, 31 | } 32 | self.profile = None 33 | 34 | def login(self): 35 | r = self.session.post( 36 | route.login_submit, 37 | data={'account': self.user, 'password': self.pwd} 38 | ) 39 | r = r.json()['ret'] 40 | 41 | status = r.pop('status') 42 | if status == 'false': 43 | raise exception.LoginError(r['msg']) 44 | 45 | self.profile = r 46 | return True 47 | 48 | 49 | class Item(): 50 | 51 | def __init__(self, raw, addtional): 52 | self.raw = raw 53 | self.uid = raw['id'] 54 | self.insert_attrs(raw) 55 | self.insert_attrs(addtional) 56 | 57 | def insert_attrs(self, attrs): 58 | for key, val in attrs.items(): 59 | setattr(self, key, val) 60 | 61 | def download(self): 62 | for target in self.detail: 63 | download(target['id']) 64 | 65 | 66 | class ItemContainer(): 67 | 68 | def __init__(self, elements, base_item, addtional={}): 69 | self.items = [base_item(e, addtional) for e in elements] 70 | 71 | def __getitem__(self, x): 72 | return self.items[x] 73 | 74 | def __iter__(self): 75 | return self.items.__iter__() 76 | 77 | def get(self, x): 78 | return self.items[x] 79 | 80 | def find(self, strict=False, **query_kws): 81 | for item in self.items: 82 | for query_k, query_v in query_kws.items(): 83 | if not query_v: 84 | break 85 | targ = getattr(item, query_k) 86 | 87 | if targ and isinstance(targ, str): 88 | if query_v not in targ and strict: 89 | break 90 | elif query_v in targ and not strict: 91 | return item 92 | 93 | if targ and isinstance(targ, dict): 94 | for targ_k, targ_v in targ.items(): 95 | if query_v in targ_v: 96 | return item 97 | else: 98 | break 99 | else: 100 | return item 101 | 102 | 103 | class Handin(Item): 104 | 105 | @property 106 | def detail(self): 107 | if not hasattr(self, '_detail'): 108 | r = session.get(route.course(self.course_id).document(self.uid)) 109 | self._detail = parser.parse_homework_handin_detail(r) 110 | return self._detail 111 | 112 | def download(self, root_folder): 113 | folder_name = os.path.join(root_folder, '%s-%s' % (self.account_id, self.authour)) 114 | if check_is_download(folder_name, ['*.zip', '*.rar']): 115 | return 116 | for target in self.detail: 117 | download(target['id'], folder=folder_name) 118 | unzip_all(folder_name) 119 | 120 | def set_score(self, score): 121 | if self.is_group: 122 | r = session.get(route.query_group.format( 123 | course_id=self.course_id, 124 | folder_id=self.score['folder_id'], 125 | team_id=self.score['team_id'])) 126 | # import pdb; pdb.set_trace() 127 | raise Exception('not implemented yet 2018/03/28') 128 | 129 | score_id = self.score.get('score_id') 130 | assert int(score_id) 131 | 132 | params = {'score': score, 'id': score_id} 133 | r = session.post(route.score, params=params) 134 | 135 | assert r.ok 136 | return r 137 | 138 | def __str__(self): 139 | return safe_str('' % (self.account_id, self.authour)) 140 | 141 | 142 | class Homework(Item): 143 | 144 | @property 145 | def detail(self): 146 | if not hasattr(self, '_detail'): 147 | r = session.get(route.course(self.course_id).homework(self.uid)) 148 | self._detail = parser.parse_homework_detail(r) 149 | return self._detail 150 | 151 | @property 152 | def handins(self): 153 | if not hasattr(self, '_handins'): 154 | r = session.get(route.course(self.course_id).homework_handin_list(self.uid)) 155 | is_group = self.detail['extra']['屬性'] == '分組作業' # or '個人作業' 156 | self._handins = ItemContainer( 157 | parser.parse_homework_handin_list(r, is_group), 158 | Handin, {'course_id': self.course_id, 'is_group': is_group}) 159 | return self._handins 160 | 161 | def score_handins(self, score_map): 162 | for handin in self.handins[1:2]: 163 | try: 164 | account = handin.account_id 165 | if handin.is_group: 166 | account = re.findall('\d+', account)[0] 167 | score = score_map[account] 168 | result = handin.set_score(score) 169 | print(handin, result.json()['ret']['msg']) 170 | except KeyError: 171 | print('缺少 %s 的分數' % handin.account_id) 172 | except Exception as e: 173 | print('Catch exception', e, 'while scoring', handin) 174 | 175 | def download_handins(self, root_folder): 176 | meta_path = os.path.join(root_folder, 'meta.json') 177 | done_lut = json_load(meta_path) 178 | for handin in self.handins: 179 | try: 180 | metadata = done_lut.get(handin.id) 181 | if (metadata 182 | and metadata.get('last_update') 183 | and handin.date_string >= metadata['last_update']): 184 | continue 185 | print(handin) 186 | handin.download(root_folder=root_folder) 187 | done_lut[handin.id] = {'last_update': handin.date_string} 188 | except Exception as e: 189 | print('Catch exception', e, 'while downloading') 190 | finally: 191 | json_dump(done_lut, meta_path) 192 | 193 | def __str__(self): 194 | return '' % (self.title) 195 | 196 | 197 | class Material(Item): 198 | 199 | @property 200 | def detail(self): 201 | if not hasattr(self, '_detail'): 202 | r = session.get(route.course(self.course_id).document(self.uid)) 203 | self._detail = parser.parse_material_detail(r) 204 | return self._detail 205 | 206 | def download(self, root_folder): 207 | folder_name = os.path.join(root_folder, '%s' % self.標題) 208 | if check_is_download(folder_name, ['*.pdf', '*.ppt', '*.pptx']): 209 | return 210 | for target in self.detail: 211 | download(target['id'], folder=folder_name) 212 | 213 | def __str__(self): 214 | return '' % (self.標題, self.人氣) 215 | 216 | 217 | class Course(Item): 218 | 219 | def get_homeworks(self): 220 | r = session.get(route.course(self.uid).homework()) 221 | self.homeworks = ItemContainer( 222 | parser.parse_homework_list(r), 223 | Homework, {'course_id': self.uid} 224 | ) 225 | return self.homeworks 226 | 227 | def get_materials(self, download=False): 228 | r = session.get(route.course(self.uid).document()) 229 | self.materials = ItemContainer( 230 | parser.parse_material_list(r), 231 | Material, {'course_id': self.uid} 232 | ) 233 | return self.materials 234 | 235 | def get_forum_list(self, page=1): 236 | resp = session.get( 237 | route.course(self.uid).forum() + '&page=%d' % page) 238 | return parser.parse_forum_list(resp.text) 239 | 240 | def get_group_list(self): 241 | resp = session.get( 242 | route.course(self.uid).forum() + '&page=%d' % page) 243 | return parser.parse_group_list(resp.text) 244 | 245 | def __str__(self): 246 | return '' % (self.course_id, self.name.get('zh')) 247 | 248 | 249 | class Core(): 250 | 251 | def __init__(self, user): 252 | self.user = user 253 | self._courses = None 254 | self._all_courses = None 255 | 256 | @property 257 | def courses(self) -> ItemContainer: 258 | if self._courses is None: 259 | r = session.get(route.home) 260 | parsed = parser.parse_course_list(r) 261 | self._courses = ItemContainer(parsed, Course) 262 | return self._courses 263 | 264 | @property 265 | def all_courses(self) -> dict: 266 | if self._all_courses is None: 267 | r = session.get('%s?f=allcourse' % route.home) 268 | parsed = parser.parse_all_course_list(r) 269 | self._all_courses = { 270 | key: ItemContainer(cous, Course) 271 | for key, cous in parsed.items() 272 | } 273 | return self._all_courses 274 | 275 | def get_post_detail(self, post_id): 276 | resp = session.post(route.post, data={'id': post_id}) 277 | return parser.parse_post_detail(resp.json()) 278 | 279 | 280 | def download(attach_id, folder='download'): 281 | resp = session.get(route.attach.format(attach_id=attach_id), stream=True) 282 | stream_download(resp, folder) 283 | -------------------------------------------------------------------------------- /ilms/exception.py: -------------------------------------------------------------------------------- 1 | class PermissionDenied(Exception): 2 | pass 3 | 4 | 5 | class LoginError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /ilms/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | from collections import defaultdict 4 | from bs4 import BeautifulSoup 5 | from pyquery import PyQuery 6 | 7 | from ilms import exception 8 | 9 | 10 | class ParseResult: 11 | 12 | def __init__(self, body=None): 13 | self.soup = BeautifulSoup(body, 'lxml') if body else None 14 | self.result = [] 15 | self.extra = {} 16 | 17 | 18 | class ParsedReseponse: 19 | 20 | def __init__(self, resp): 21 | self.resp = resp 22 | self._html = None 23 | self._soup = None 24 | 25 | @property 26 | def html(self): 27 | if self._html is None: 28 | self._html = self._make_pyquery(self.resp) 29 | return self._html 30 | 31 | @property 32 | def soup(self): 33 | if self._soup is None: 34 | self._soup = self._make_beautifulsoup(self.resp) 35 | return self._soup 36 | 37 | @classmethod 38 | def _make_pyquery(cls, resp): 39 | resp.encoding = 'utf-8' 40 | return PyQuery(resp.text) 41 | 42 | @classmethod 43 | def _make_beautifulsoup(cls, resp): 44 | resp.encoding = 'utf-8' 45 | return BeautifulSoup(resp.text, 'lxml') 46 | 47 | 48 | def need_login_check(f): 49 | def wrap(body, *args, **kwargs): 50 | if '權限不足' in body or 'No Permission!' in body: 51 | raise exception.PermissionDenied('尚未登入') 52 | return f(body, *args, **kwargs) 53 | return wrap 54 | 55 | 56 | def parse_zh_en_course_name(course_name): 57 | course_name_en = re.findall('[A-Za-z()0-9 ]+', course_name)[-1] 58 | course_name_zh = course_name.replace(course_name_en, '') 59 | return {'en': course_name_en, 'zh': course_name_zh} 60 | 61 | 62 | def parse_datetime(date_string): 63 | return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S') 64 | 65 | 66 | course_id_in_link = re.compile('/course/(\d+)') 67 | 68 | 69 | @need_login_check 70 | def parse_course_list(r): 71 | pr = ParsedReseponse(r) 72 | 73 | result = [] 74 | for item in pr.html('.mnu .mnuItem'): 75 | course_a = item.find('a') 76 | match = course_id_in_link.match(course_a.get('href')) 77 | if not match: 78 | continue 79 | course_id = re.sub('[()]', '', item.find('span').text) 80 | result.append({ 81 | 'id': match.group(1), 82 | 'course_id': course_id, 83 | 'course_link': course_a.get('href'), 84 | 'name': parse_zh_en_course_name(course_a.text), 85 | }) 86 | return result 87 | 88 | 89 | @need_login_check 90 | def parse_all_course_list(r): 91 | pr = ParsedReseponse(r) 92 | result = defaultdict(list) 93 | 94 | def parse_row(i, row, target): 95 | cols = [e for e in PyQuery(row)('td').items()] 96 | result[target].append({ 97 | 'id': course_id_in_link.match(cols[1]('a').attr('href')).group(1), 98 | 'course_id': cols[0].text(), 99 | 'course_link': cols[1]('a').attr('href'), 100 | 'name': parse_zh_en_course_name(cols[1].text()), 101 | 'teacher': cols[2].text(), 102 | 'credit': cols[3].text(), 103 | 'grade': cols[4].text(), 104 | }) 105 | 106 | for title, table in zip( 107 | pr.html('.tblTitle div:first'), pr.html('table').items()): 108 | table('tr:not(:first-child)').each(lambda i, e: parse_row(i, e, title.text)) 109 | 110 | return result 111 | 112 | 113 | @need_login_check 114 | def parse_homework_list(r): 115 | pr = ParsedReseponse(r) 116 | result = [] 117 | 118 | main = pr.soup.select_one('#main') 119 | if '目前尚無資料' in main.text: 120 | return result 121 | 122 | for row in main.select('tr')[1:]: 123 | td = row.find_all('td') 124 | href = td[1].select_one('a').get('href') 125 | date = td[4].find('span').get('title') 126 | result.append({ 127 | 'id': re.match('.*hw=(\d+).*', href).group(1), 128 | 'title': td[1].text.strip(), 129 | 'date_string': date, 130 | 'date': parse_datetime(date) 131 | }) 132 | 133 | return result 134 | 135 | 136 | @need_login_check 137 | def parse_homework_detail(r): 138 | pr = ParsedReseponse(r) 139 | tr = pr.soup.select('tr') 140 | result = [] 141 | 142 | def trs_helper(trs): 143 | for row in trs: 144 | k, v = row.select('td') 145 | yield k.text, v.text 146 | 147 | result = {'title': pr.soup.select_one('#main span.curr').text.strip()} 148 | result['extra'] = { 149 | k: v 150 | for i, (k, v) in enumerate(trs_helper(tr)) 151 | if i not in [0, 5, 6, 7] 152 | # header, date, description, attachments 153 | } 154 | 155 | date = tr[5].select('td')[1].text + ':00' 156 | result['date_string'] = date 157 | result['date'] = parse_datetime(date) 158 | 159 | result['content'] = tr[6].select('td')[1].text # not rich text 160 | result['links'] = [a.get('href') for a in tr[7].select('a')] 161 | 162 | td = tr[7].select('td')[1] 163 | attach_id_regex = re.compile('.*id=(\d+).*') 164 | result['attachments'] = [ 165 | {'name': a.text.strip(), 166 | 'id': attach_id_regex.match(a.get('href')).group(1), 167 | 'size': re.sub('[()]', '', span.text)} 168 | for a, span in zip(td.select('a'), td.select('span')) 169 | ] 170 | return result 171 | 172 | 173 | @need_login_check 174 | def parse_homework_handin_list(r, is_group=False): 175 | pr = ParsedReseponse(r) 176 | result = [] 177 | 178 | main = pr.soup.select_one('#main') 179 | if '目前尚無資料' in main.text: 180 | return result 181 | 182 | # TODO: in not TA mode, some attrs will fail 183 | score_id_regex = re.compile('\d+score_(\d+)') 184 | folder_id_pattern = re.compile('folderID=(\d+)') 185 | team_id_pattern = re.compile('editGroupScore\("(\d+)"\)') 186 | 187 | for row in main.select('tr')[1:]: 188 | td = row.select('td') 189 | href = td[1].select_one('a').get('href') 190 | date = td[5].find('span').get('title') 191 | status = { 192 | 'status_id': td[6].find('span').get('id'), 193 | 'text': td[6].text 194 | } 195 | if is_group: 196 | score = { 197 | 'folder_id': folder_id_pattern.findall(pr.html.text())[0], 198 | 'team_id': team_id_pattern.findall(td[7].select('a')[0].get('href'))[0] 199 | } 200 | else: 201 | score_id = td[7].select('.hidden div a')[0].get('id') 202 | score = { 203 | 'score_id': score_id_regex.match(score_id).group(1), 204 | 'score_atag': td[7].select('.hidden div')[0].a 205 | } 206 | result.append({ 207 | 'id': re.match('.*cid=(\d+).*', href).group(1), 208 | 'title': td[1].text.strip(), 209 | 'account_id': td[2].text, 210 | 'authour': td[3].text, 211 | 'status': status, 212 | 'score': score, 213 | 'date_string': date, 214 | 'date': parse_datetime(date) 215 | }) 216 | return result 217 | 218 | 219 | @need_login_check 220 | def parse_homework_handin_detail(r): 221 | pr = ParsedReseponse(r) 222 | result = [] 223 | 224 | main = pr.soup.select_one('#doc') 225 | if '目前尚無資料' in main.text: 226 | return result 227 | 228 | attaches = main.select_one('.attach .block').select('div') 229 | for attach in attaches: 230 | a = attach.select('a')[1] 231 | hint = attach.select('.hint')[0] 232 | result.append({ 233 | 'filename': a.get('title'), 234 | 'id': a.get('href').split('=')[-1], 235 | 'filesize': hint.text[1:-2] 236 | }) 237 | 238 | return result 239 | 240 | 241 | @need_login_check 242 | def parse_forum_list(body): 243 | pr = ParseResult(body) 244 | main = pr.soup.select_one('#main') 245 | if '目前尚無資料' in main.text: 246 | return pr 247 | 248 | for tr in main.select('tr')[1::2]: 249 | td = tr.select('td') 250 | pr.result.append({ 251 | 'id': td[0].text.strip(), 252 | 'title': td[1].text.strip(), 253 | 'count': td[2].find('span').text, 254 | 'subtitle': td[3].text.strip() 255 | }) 256 | 257 | page_info = pr.soup.select('.page span') 258 | if page_info: 259 | pages = len(page_info) - 2 260 | curr_page = pr.soup.select_one('.page .curr').text 261 | pr.extra = { 262 | 'pages': pages if pages > 0 else 1, 263 | 'curr_page': curr_page if pages > 0 else 1 264 | } 265 | 266 | return pr 267 | 268 | 269 | @need_login_check 270 | def parse_post_detail(json): 271 | pr = ParseResult() 272 | for item in json['posts']['items']: 273 | comment = { 274 | 'name': item['name'], 275 | 'date': item['date'], 276 | # 'note': html2text(item['note']) 277 | 'note': item['note'] 278 | } 279 | comment.update({ 280 | 'attachments': [(e['id'], e['srcName']) for e in item['attach']] 281 | }) if item['attach'] else None 282 | pr.result.append(comment) 283 | return pr 284 | 285 | 286 | @need_login_check 287 | def parse_material_list(r): 288 | pr = ParsedReseponse(r) 289 | result = [] 290 | 291 | rows = pr.soup.select('tr') 292 | head = [e.text for e in rows[0].select('td')] 293 | for row in rows[1:]: 294 | item = { 295 | k: v.text.strip() 296 | for k, v in zip(head, row.select('td')) 297 | } 298 | item['id'] = row.select_one('a').get('href').split('=')[-1] 299 | result.append(item) 300 | 301 | return result 302 | 303 | 304 | @need_login_check 305 | def parse_material_detail(r): 306 | pr = ParsedReseponse(r) 307 | 308 | # title = pr.soup.select_one('.doc .title').text.strip() 309 | # content = pr.soup.select_one('.doc .article').text.strip() 310 | # extra = {'title': title, 'content': content} 311 | 312 | attach = pr.soup.select_one('.attach .block') 313 | result = [ 314 | { 315 | 'filename': a.get('title'), 316 | 'id': a.get('href').split('=')[-1], 317 | 'filesize': hint.text[1:-2] 318 | } 319 | for a, hint in zip( 320 | attach.select('a')[::2], attach.select('.hint')) 321 | ] 322 | return result 323 | -------------------------------------------------------------------------------- /ilms/route.py: -------------------------------------------------------------------------------- 1 | from functools import partialmethod 2 | 3 | base = 'http://lms.nthu.edu.tw' 4 | 5 | 6 | class CourseRoute(): 7 | 8 | rules = { 9 | 'forum': 'forumlist', 10 | 'homework': 'hwlist', 11 | 'document': 'doclist', 12 | 'forum_detail': 'forumlist', 13 | 'homework_detail': 'hw', 14 | 'document_detail': 'doc', 15 | 'homework_handin_list_detail': 'hw_doclist', 16 | } 17 | 18 | rule_key = { 19 | 'forum_detail': 'tid', 20 | 'homework_detail': 'hw', 21 | 'document_detail': 'cid', 22 | 'homework_handin_list_detail': 'hw', 23 | } 24 | 25 | def __init__(self, course_id): 26 | self.base = '{}/course.php?courseID={}'.format(base, course_id) 27 | 28 | def gen_rule(self, func, uid=None): 29 | if uid: 30 | func += '_detail' 31 | path = '%s&f=%s' % (self.base, self.rules[func]) 32 | if uid: 33 | path += '&{}={}'.format(self.rule_key[func], uid) 34 | return path 35 | 36 | homework = partialmethod(gen_rule, 'homework') 37 | homework_handin_list = partialmethod(gen_rule, 'homework_handin_list') 38 | document = partialmethod(gen_rule, 'document') 39 | forum = partialmethod(gen_rule, 'forum') 40 | 41 | 42 | class Routes(): 43 | 44 | rules = { 45 | 'home': 'home.php', 46 | 'profile': 'home/profile.php', 47 | 'attach': 'sys/read_attach.php?id={attach_id}', 48 | 'login_submit': 'sys/lib/ajax/login_submit.php', 49 | 'score': 'course/http_hw_score.php', 50 | 'query_group': 'course/hw_group_score.php?courseID={course_id}&folderID={folder_id}&teamID={team_id}', 51 | 'group_score': 'update_group_score', 52 | 'post': 'sys/lib/ajax/post.php' 53 | } 54 | 55 | def __init__(self): 56 | for rule, value in self.rules.items(): 57 | setattr(self, rule, '%s/%s' % (base, value)) 58 | self.course = CourseRoute 59 | 60 | 61 | route = Routes() 62 | -------------------------------------------------------------------------------- /ilms/utils.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import getpass 3 | import glob 4 | import json 5 | import os 6 | import pickle 7 | import re 8 | import sys 9 | import urllib.parse 10 | import zipfile 11 | 12 | import tqdm 13 | 14 | import ilms 15 | 16 | mime_filename_pattern = re.compile('.*?filename="(.+)"') 17 | 18 | 19 | def get_account(): 20 | if os.path.exists(ilms._config_file): 21 | _config = json.load(open(ilms._config_file)) 22 | _account = _config.get('account') 23 | _password = _config.get('password') 24 | if _account and _password: 25 | return _account, _password 26 | 27 | _config = {'account': input('iLMS account: '), 28 | 'password': getpass.getpass(prompt='Password: ')} 29 | _account = _config.get('account') 30 | _password = _config.get('password') 31 | with open(ilms._config_file, 'w') as f: 32 | f.write(json.dumps(_config, indent=4)) 33 | 34 | return _account, _password 35 | 36 | 37 | def remove_account_file(): 38 | if os.path.exists(ilms._config_file): 39 | os.remove(ilms._config_file) 40 | 41 | 42 | def unzip_all(folder_name): 43 | for zip_file in glob.glob('%s/*.zip' % folder_name): 44 | unzip(zip_file, folder_name) 45 | 46 | 47 | def unzip(filepath, dest_folder): 48 | if filepath.endswith('.zip'): 49 | zip_ref = zipfile.ZipFile(filepath, 'r') 50 | zip_ref.extractall(dest_folder) 51 | zip_ref.close() 52 | 53 | 54 | def check_is_download(folder, file_types): 55 | files = [] 56 | for ftype in file_types: 57 | files.extend(glob.glob('%s/%s' % (folder, ftype))) 58 | return files 59 | 60 | 61 | def get_home_dir(): 62 | _base_dir = os.path.expanduser('~') 63 | _ilms_dir = os.path.join(_base_dir, '.ilms') 64 | return _ilms_dir 65 | 66 | 67 | def save_session(sess): 68 | base = ilms._ilms_dir 69 | with open(os.path.join(base, 'sess.pickle'), 'wb') as f: 70 | pickle.dump(sess, f, pickle.HIGHEST_PROTOCOL) 71 | 72 | 73 | def load_session(): 74 | base = ilms._ilms_dir 75 | filepath = os.path.join(base, 'sess.pickle') 76 | if not os.path.exists(filepath): 77 | return None 78 | with open(filepath, 'rb') as f: 79 | return pickle.load(f) 80 | 81 | 82 | def json_load(filename): 83 | try: 84 | with open(filename) as f: 85 | return json.load(f) 86 | except: 87 | return {} 88 | 89 | 90 | def json_dump(data, filename): 91 | os.makedirs(os.path.dirname(filename), exist_ok=True) 92 | with open(filename, 'w') as f: 93 | json.dump(data, f, indent=4) 94 | 95 | 96 | def load_score_csv(filepath): 97 | with open(filepath, newline='', encoding='utf8') as csvfile: 98 | spamreader = csv.reader(csvfile, delimiter=',') 99 | rows = [row for row in spamreader] 100 | 101 | score_map = {} 102 | for entry in rows: 103 | assert len(entry) == 2 104 | student_id, score = entry 105 | score_map[student_id] = float(score) 106 | 107 | return score_map 108 | 109 | 110 | def stream_download(r, folder='download'): 111 | filesize = int(r.headers['content-length']) 112 | filename = r.headers['content-disposition'] 113 | filename = mime_filename_pattern.match(filename).group(1) 114 | 115 | os.makedirs(folder, exist_ok=True) 116 | filename = urllib.parse.unquote(filename) 117 | path = os.path.join(folder, filename) 118 | 119 | chunk_size = 2048 120 | with tqdm.tqdm(total=filesize // chunk_size, ascii=True, leave=False) as p: 121 | with open(path, 'wb') as f: 122 | for chunk in r.iter_content(chunk_size=chunk_size): 123 | if chunk: 124 | f.write(chunk) 125 | p.update(1) 126 | return filename 127 | 128 | 129 | def safe_str(text): 130 | encoding = sys.stdout.encoding 131 | return text.encode(encoding, 'ignore').decode(encoding) 132 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 100 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def version(): 5 | with open('ilms/__init__.py') as f: 6 | for line in f: 7 | if line.startswith('__version__'): 8 | return line.replace("'", '').split()[-1] 9 | 10 | 11 | setup( 12 | name='ilms-nthu', 13 | packages=['ilms'], 14 | install_requires=[ 15 | 'requests', 16 | 'beautifulsoup4', 17 | 'pyquery', 18 | 'tqdm', 19 | 'lxml', 20 | 'click' 21 | ], 22 | entry_points={ 23 | 'console_scripts': ['ilms=ilms.cli:main'], 24 | }, 25 | version=version(), 26 | description='iLms-NTHU API. An iLMS client for students, assistants and developers.', 27 | author='leVirve', 28 | author_email='gae.m.project@gmail.com', 29 | url='https://github.com/leVirve/iLms-nthu-API', 30 | license='MIT', 31 | platforms='any', 32 | keywords=['iLms', 'NTHU', 'API'], 33 | classifiers=[ 34 | 'Development Status :: 3 - Alpha', 35 | 'Intended Audience :: Developers', 36 | 'Intended Audience :: Customer Service', 37 | 'Intended Audience :: System Administrators', 38 | 'Topic :: Internet :: WWW/HTTP', 39 | 'Topic :: Software Development :: Libraries', 40 | 'Topic :: Text Processing', 41 | 'Topic :: Utilities', 42 | 'Programming Language :: Python', 43 | 'Programming Language :: Python :: 3.5', 44 | 'Programming Language :: Python :: 3.6' 45 | ],) 46 | --------------------------------------------------------------------------------