├── user_agents.txt.gz ├── requirements.txt ├── .gitignore ├── HISTORY.md ├── main.py ├── autoelective ├── __init__.py ├── environ.py ├── rule.py ├── _internal.py ├── utils.py ├── course.py ├── iaaa.py ├── parser.py ├── cli.py ├── const.py ├── monitor.py ├── logger.py ├── client.py ├── exceptions.py ├── config.py ├── hook.py ├── elective.py └── loop.py ├── MIGRATION_GUIDE.md ├── Docker ├── latest.Dockerfile └── monitor.Dockerfile ├── DOCKER.md ├── .github └── ISSUE_TEMPLATE.md ├── LICENSE ├── MIGRATION_GUIDE_origin.md ├── README.md ├── config.sample.ini ├── HISTORY_origin.md └── README_origin.md /user_agents.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylelv2000/PKUAutoElective_updated/HEAD/user_agents.txt.gz -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Werkzeug==0.15.4 2 | lxml==4.3.0 3 | requests==2.22.0 4 | numpy==1.18.2 5 | ddddocr==1.4.2 6 | Flask==1.0.2 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | /log 4 | /cache 5 | /config 6 | /bin 7 | /archive 8 | /tmp 9 | config.ini 10 | user_agents.user.txt 11 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | Release History 2 | =============== 3 | 4 | v6.0.0.1 (2021-09-11) 5 | ------------------- 6 | - 更新了对选课网部分 API 的请求方法 (`SupplyCancel.do` -> `SupplyCancel.do?hx=`) 7 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: main.py 4 | # modified: 2019-09-11 5 | 6 | from autoelective.cli import run 7 | 8 | if __name__ == '__main__': 9 | run() 10 | -------------------------------------------------------------------------------- /autoelective/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: __init__.py 4 | # modified: 2019-09-13 5 | 6 | __version__ = "7.0.0" 7 | __date__ = "2022.02.20" 8 | __author__ = "qaz" 9 | -------------------------------------------------------------------------------- /MIGRATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | Migration Guide 2 | ==================== 3 | 4 | v5.0.1 -> 6.0.0.1 5 | ------------------ 6 | - 新的验证码识别模块依赖 opencv,安装命令 `pip3 install opencv-python`; 7 | - 新的验证码识别模块不再依赖于 joblib,并删除了对 simplejson 的可选依赖; 8 | - PyTorch 版本需保证大于等于 1.5.0,否则无法读取 CNN 模型。 9 | -------------------------------------------------------------------------------- /Docker/latest.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:slim 2 | 3 | LABEL maintainer="you.siki@outlook.com" 4 | 5 | RUN pip install --no-cache-dir \ 6 | -i https://pypi.tuna.tsinghua.edu.cn/simple \ 7 | lxml \ 8 | numpy \ 9 | Pillow \ 10 | sklearn \ 11 | requests \ 12 | simplejson 13 | 14 | ADD . /workspace 15 | 16 | VOLUME [ "/config" ] 17 | 18 | WORKDIR /workspace 19 | 20 | CMD [ \ 21 | "python", \ 22 | "main.py", \ 23 | "--config=/config/config.ini", \ 24 | "--course-csv-gbk=/config/course.gbk.csv", \ 25 | "--course-csv-utf8=/config/course.utf-8.csv" ] -------------------------------------------------------------------------------- /autoelective/environ.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: environ.py 4 | # modified: 2020-02-16 5 | 6 | from .utils import Singleton 7 | from collections import defaultdict 8 | import numpy as np 9 | 10 | class Environ(object, metaclass=Singleton): 11 | 12 | def __init__(self): 13 | self.config_ini = None 14 | self.with_monitor = None 15 | self.iaaa_loop = 0 16 | self.elective_loop = 0 17 | self.errors = defaultdict(lambda: 0) 18 | self.iaaa_loop_thread = None 19 | self.elective_loop_thread = None 20 | self.monitor_thread = None 21 | self.goals = [] # [Course] 22 | self.ignored = {} # {Course, reason} 23 | -------------------------------------------------------------------------------- /Docker/monitor.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:slim 2 | 3 | LABEL maintainer="you.siki@outlook.com" 4 | 5 | RUN pip install --no-cache-dir \ 6 | -i https://pypi.tuna.tsinghua.edu.cn/simple \ 7 | lxml \ 8 | numpy \ 9 | Pillow \ 10 | sklearn \ 11 | requests \ 12 | simplejson 13 | 14 | RUN pip install --no-cache-dir \ 15 | -i https://pypi.tuna.tsinghua.edu.cn/simple \ 16 | flask \ 17 | werkzeug 18 | 19 | ADD . /workspace 20 | 21 | VOLUME [ "/config" ] 22 | 23 | WORKDIR /workspace 24 | 25 | CMD [ \ 26 | "python", \ 27 | "main.py", \ 28 | "--with-monitor", \ 29 | "--config=/config/config.ini", \ 30 | "--course-csv-gbk=/config/course.gbk.csv", \ 31 | "--course-csv-utf8=/config/course.utf-8.csv" ] -------------------------------------------------------------------------------- /autoelective/rule.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: rule.py 4 | # modified: 2020-02-20 5 | 6 | class Mutex(object): 7 | 8 | __slots__ = ["_cids",] 9 | 10 | def __init__(self, cids): 11 | self._cids = cids 12 | 13 | @property 14 | def cids(self): 15 | return self._cids 16 | 17 | 18 | class Delay(object): 19 | 20 | __slots__ = ["_cid","_threshold"] 21 | 22 | def __init__(self, cid, threshold): 23 | assert threshold > 0 24 | self._cid = cid 25 | self._threshold = threshold 26 | 27 | @property 28 | def cid(self): 29 | return self._cid 30 | 31 | @property 32 | def threshold(self): 33 | return self._threshold 34 | 35 | -------------------------------------------------------------------------------- /autoelective/_internal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: _internal.py 4 | # modified: 2019-09-08 5 | 6 | import os 7 | import gzip 8 | 9 | def mkdir(path): 10 | if not os.path.exists(path): 11 | os.mkdir(path) 12 | 13 | def absp(*paths): 14 | return os.path.normpath(os.path.abspath(os.path.join(os.path.dirname(__file__), *paths))) 15 | 16 | def read_list(file, encoding='utf-8-sig', **kwargs): 17 | if file.endswith('.gz'): 18 | fp = gzip.open(file, 'rt', encoding=encoding, **kwargs) 19 | else: 20 | fp = open(file, 'r', encoding=encoding, **kwargs) 21 | try: 22 | return [ line.rstrip('\n') for line in fp if not line.isspace() ] 23 | finally: 24 | fp.close() 25 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # PKUAutoElective Docker Image 2 | 3 | 依赖于原项目v2.0.2(2019.09.09)版本。 4 | 5 | ## Tags 6 | 7 | 1. latest 8 | 2. monitor 9 | 10 | ## latest 11 | 12 | 包含python3,依赖库,以及项目源代码。 13 | 14 | ### 运行方法 15 | 16 | ``` bash 17 | docker run -d \ 18 | --name=pae \ 19 | -v /path/to/config/folder:/config \ 20 | yousiki/pkuautoelective:latest # 运行工具 21 | docker logs pae # 查看输出 22 | docker stop pae # 停止工具 23 | ``` 24 | 25 | ## monitor 26 | 27 | 额外包含Monitor运行依赖的库。 28 | 29 | ### 运行方法 30 | 31 | `config.ini`中的`host`值建议设为`0.0.0.0` 32 | 33 | ``` bash 34 | docker run -d \ 35 | --name=pae \ 36 | -p 7074:7074 \ 37 | -v /path/to/config/folder:/config \ 38 | yousiki/pkuautoelective:latest # 运行工具 39 | docker logs pae # 查看输出 40 | docker stop pae # 停止工具 41 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Check List 2 | 3 | - [ ] 我已经阅读了 [Readme](https://github.com/zhongxinghong/PKUAutoElective/blob/master/README.md), [Migration Guide](https://github.com/zhongxinghong/PKUAutoElective/blob/master/MIGRATION_GUIDE.md), [Realease History](https://github.com/zhongxinghong/PKUAutoElective/blob/master/HISTORY.md) ,但是并没有找到有用的信息 4 | - [ ] 我已经搜索了已有的 [Issues](https://github.com/zhongxinghong/PKUAutoElective/issues) ,但是没有找到相同的问题 5 | 6 | ### Version / Environment 7 | 8 | System infomation: [ ] \( Windows10 64bit, MacOS 10.13.6, Ubuntu 18.04.3 amd64, ... ) 9 | Python version: [ ] \( run `python3 --version` ) 10 | AutoElective version: [ ] \( run `python3 main.py --version` ) 11 | 12 | ### Config 13 | 14 | 除了学号/密码外的其他配置 15 | 16 | ### Issue Description 17 | 18 | #### What 19 | 20 | 遇到的问题 21 | 22 | #### Console Log 23 | 24 | 必要的终端输出信息 25 | 26 | #### Reproduce 27 | 28 | 如有必要,提供复现的步骤 29 | 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rabbit 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 | -------------------------------------------------------------------------------- /autoelective/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: utils.py 4 | # modified: 2019-09-09 5 | 6 | import os 7 | import pickle 8 | import gzip 9 | import hashlib 10 | from requests.compat import json 11 | 12 | 13 | def b(s): 14 | if isinstance(s, (str,int,float)): 15 | return str(s).encode("utf-8") 16 | elif isinstance(s, bytes): 17 | return s 18 | else: 19 | raise TypeError("unsupport type %s of %r" % (type(s), s)) 20 | 21 | def u(s): 22 | if isinstance(s, bytes): 23 | return s.decode("utf-8") 24 | elif isinstance(s, (str,int,float)): 25 | return str(s) 26 | else: 27 | raise TypeError("unsupport type %s of %r" % (type(s), s)) 28 | 29 | def xMD5(data): 30 | return hashlib.md5(b(data)).hexdigest() 31 | 32 | def xSHA1(data): 33 | return hashlib.sha1(b(data)).hexdigest() 34 | 35 | def json_load(file, *args, **kwargs): 36 | if not os.path.exists(file): 37 | return None 38 | with open(file, "r", encoding="utf-8-sig") as fp: 39 | try: 40 | return json.load(fp, *args, **kwargs) 41 | except json.JSONDecodeError: 42 | return None 43 | 44 | def json_dump(obj, file, *args, **kwargs): 45 | with open(file, "w", encoding="utf-8") as fp: 46 | json.dump(obj, fp, *args, **kwargs) 47 | 48 | def pickle_gzip_dump(obj, file): 49 | with gzip.open(file, "wb") as fp: 50 | pickle.dump(obj, fp) 51 | 52 | def pickle_gzip_load(file): 53 | with gzip.open(file, "rb") as fp: 54 | return pickle.load(fp) 55 | 56 | 57 | class Singleton(type): 58 | """ 59 | Singleton Metaclass 60 | @link https://github.com/jhao104/proxy_pool/blob/428359c8dada998481f038dbdc8d3923e5850c0e/Util/utilClass.py 61 | """ 62 | _inst = {} 63 | 64 | def __call__(cls, *args, **kwargs): 65 | if cls not in cls._inst: 66 | cls._inst[cls] = super(Singleton, cls).__call__(*args, **kwargs) 67 | return cls._inst[cls] 68 | -------------------------------------------------------------------------------- /autoelective/course.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: course.py 4 | # modified: 2019-09-08 5 | 6 | class Course(object): 7 | 8 | __slots__ = ['_name','_class_no','_school','_status','_href','_ident'] 9 | 10 | def __init__(self, name, class_no, school, status=None, href=None): 11 | self._name = name 12 | self._class_no = int(class_no) # 确保 01 与 1 为同班号,因为表格软件将 01 视为 1 13 | self._school = school 14 | self._status = status # (maxi, used) 限选 / 已选 15 | self._href = href # 选课链接 16 | self._ident = (self._name, self._class_no, self._school) 17 | 18 | @property 19 | def name(self): 20 | return self._name 21 | 22 | @property 23 | def class_no(self): 24 | return self._class_no 25 | 26 | @property 27 | def school(self): 28 | return self._school 29 | 30 | @property 31 | def status(self): 32 | return self._status 33 | 34 | @property 35 | def href(self): 36 | return self._href 37 | 38 | @property 39 | def max_quota(self): 40 | assert self._status is not None 41 | return self._status[0] 42 | 43 | @property 44 | def used_quota(self): 45 | assert self._status is not None 46 | return self._status[1] 47 | 48 | @property 49 | def remaining_quota(self): 50 | assert self._status is not None 51 | maxi, used = self._status 52 | return maxi - used 53 | 54 | def is_available(self): 55 | assert self._status is not None 56 | maxi, used = self._status 57 | return maxi > used 58 | 59 | def to_simplified(self): 60 | return Course(self._name, self._class_no, self._school) 61 | 62 | def __eq__(self, other): 63 | if not isinstance(other, self.__class__): 64 | return False 65 | return self._ident == other._ident 66 | 67 | def __hash__(self): 68 | return hash(self._ident) 69 | 70 | def __repr__(self): 71 | if self._status is not None: 72 | return "%s(%s, %s, %s, %d / %d)" % ( 73 | self.__class__.__name__, 74 | self._name, self._class_no, self._school, *self._status, 75 | ) 76 | else: 77 | return "%s(%s, %s, %s)" % ( 78 | self.__class__.__name__, 79 | self._name, self._class_no, self._school, 80 | ) 81 | -------------------------------------------------------------------------------- /autoelective/iaaa.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: iaaa.py 4 | # modified: 2019-09-10 5 | 6 | from urllib.parse import quote 7 | from .client import BaseClient 8 | from .hook import get_hooks, debug_print_request, check_status_code, check_iaaa_success 9 | from .const import IAAAURL, ElectiveURL 10 | 11 | _hooks_check_status_code = get_hooks( 12 | debug_print_request, 13 | check_status_code, 14 | ) 15 | 16 | _hooks_check_iaaa_success = get_hooks( 17 | debug_print_request, 18 | check_status_code, 19 | check_iaaa_success, 20 | ) 21 | 22 | 23 | class IAAAClient(BaseClient): 24 | 25 | default_headers = { 26 | "Accept": "application/json, text/javascript, */*; q=0.01", 27 | "Accept-Encoding": "gzip, deflate, br", 28 | "Accept-Language": "en-US,en;q=0.9", 29 | "Host": IAAAURL.Host, 30 | "Origin": "https://%s" % IAAAURL.Host, 31 | "Connection": "keep-alive", 32 | } 33 | 34 | def oauth_home(self, **kwargs): 35 | headers = kwargs.pop("headers", {}) 36 | headers["Referer"] = ElectiveURL.HomePage 37 | headers["Upgrade-Insecure-Requests"] = "1" 38 | 39 | r = self._get( 40 | url=IAAAURL.OauthHomePage, 41 | params={ 42 | "appID": "syllabus", 43 | "appName": "学生选课系统", 44 | "redirectUrl": ElectiveURL.SSOLoginRedirect, 45 | }, 46 | headers=headers, 47 | hooks=_hooks_check_status_code, 48 | **kwargs, 49 | ) 50 | return r 51 | 52 | def oauth_login(self, username, password, **kwargs): 53 | headers = kwargs.pop("headers", {}) 54 | headers["Referer"] = "%s?appID=syllabus&appName=%s&redirectUrl=%s" % ( 55 | IAAAURL.OauthHomePage, quote("学生选课系统"), ElectiveURL.SSOLoginRedirect) 56 | headers["X-Requested-With"] = "XMLHttpRequest" 57 | 58 | r = self._post( 59 | url=IAAAURL.OauthLogin, 60 | data={ 61 | "appid": "syllabus", 62 | "userName": username, 63 | "password": password, 64 | "randCode": "", 65 | "smsCode": "", 66 | "otpCode": "", 67 | "redirUrl": ElectiveURL.SSOLoginRedirect, 68 | }, 69 | headers=headers, 70 | hooks=_hooks_check_iaaa_success, 71 | **kwargs, 72 | ) 73 | return r 74 | -------------------------------------------------------------------------------- /autoelective/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: parser.py 4 | # modified: 2019-09-09 5 | 6 | import re 7 | from lxml import etree 8 | from .course import Course 9 | 10 | _regexBzfxSida = re.compile(r'\?sida=(\S+?)&sttp=(?:bzx|bfx)') 11 | 12 | 13 | def get_tree_from_response(r): 14 | return etree.HTML(r.text) # 不要用 r.content, 否则可能会以 latin-1 编码 15 | 16 | def get_tree(content): 17 | return etree.HTML(content) 18 | 19 | def get_tables(tree): 20 | return tree.xpath('.//table//table[@class="datagrid"]') 21 | 22 | def get_table_header(table): 23 | return table.xpath('.//tr[@class="datagrid-header"]/th/text()') 24 | 25 | def get_table_trs(table): 26 | return table.xpath('.//tr[@class="datagrid-odd" or @class="datagrid-even"]') 27 | 28 | def get_title(tree): 29 | title = tree.find('.//head/title') 30 | if title is None: # 双学位 sso_login 后先到 主修/辅双 选择页,这个页面没有 title 标签 31 | return None 32 | return title.text 33 | 34 | def get_errInfo(tree): 35 | tds = tree.xpath(".//table//table//table//td") 36 | assert len(tds) == 1 37 | td = tds[0] 38 | strong = td.getchildren()[0] 39 | assert strong.tag == 'strong' and strong.text in ('出错提示:', '提示:') 40 | return "".join(td.xpath('./text()')).strip() 41 | 42 | def get_tips(tree): 43 | tips = tree.xpath('.//td[@id="msgTips"]') 44 | if len(tips) == 0: 45 | return None 46 | td = tips[0].xpath('.//table//table//td')[1] 47 | return "".join(td.xpath('.//text()')).strip() 48 | 49 | def get_sida(r): 50 | return _regexBzfxSida.search(r.text).group(1) 51 | 52 | def get_courses(table): 53 | header = get_table_header(table) 54 | trs = get_table_trs(table) 55 | ixs = tuple(map(header.index, ["课程名","班号","开课单位"])) 56 | cs = [] 57 | for tr in trs: 58 | t = tr.xpath('./th | ./td') 59 | name, class_no, school = map(lambda ix: t[ix].xpath('.//text()')[0], ixs) 60 | c = Course(name, class_no, school) 61 | cs.append(c) 62 | return cs 63 | 64 | def get_courses_with_detail(table): 65 | header = get_table_header(table) 66 | trs = get_table_trs(table) 67 | ixs = tuple(map(header.index, ["课程名","班号","开课单位","限数/已选","补选"])) 68 | cs = [] 69 | for tr in trs: 70 | t = tr.xpath('./th | ./td') 71 | name, class_no, school, status, _ = map(lambda ix: t[ix].xpath('.//text()')[0], ixs) 72 | status = tuple(map(int, status.split("/"))) 73 | href = t[ixs[-1]].xpath('./a/@href')[0] 74 | c = Course(name, class_no, school, status, href) 75 | cs.append(c) 76 | return cs 77 | 78 | -------------------------------------------------------------------------------- /autoelective/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: cli.py 4 | # modified: 2020-02-20 5 | 6 | from optparse import OptionParser 7 | from threading import Thread 8 | from multiprocessing import Queue 9 | from . import __version__, __date__ 10 | 11 | 12 | def create_default_parser(): 13 | 14 | parser = OptionParser( 15 | description='PKU Auto-Elective Tool v%s (%s)' % (__version__, __date__), 16 | version=__version__, 17 | ) 18 | 19 | ## custom input files 20 | 21 | parser.add_option( 22 | '-c', 23 | '--config', 24 | dest='config_ini', 25 | metavar="FILE", 26 | help='custom config file encoded with utf8', 27 | ) 28 | 29 | ## boolean (flag) options 30 | 31 | parser.add_option( 32 | '-m', 33 | '--with-monitor', 34 | dest='with_monitor', 35 | action='store_true', 36 | default=False, 37 | help='run the monitor thread simultaneously', 38 | ) 39 | 40 | return parser 41 | 42 | 43 | def setup_default_environ(options, args, environ): 44 | 45 | environ.config_ini = options.config_ini 46 | environ.with_monitor = options.with_monitor 47 | 48 | 49 | def create_default_threads(options, args, environ): 50 | 51 | # import here to ensure the singleton `config` will be init later than parse_args() 52 | from autoelective.loop import run_iaaa_loop, run_elective_loop 53 | from autoelective.monitor import run_monitor 54 | 55 | tList = [] 56 | 57 | t = Thread(target=run_iaaa_loop, name="IAAA") 58 | environ.iaaa_loop_thread = t 59 | tList.append(t) 60 | 61 | t = Thread(target=run_elective_loop, name="Elective") 62 | environ.elective_loop_thread = t 63 | tList.append(t) 64 | 65 | if options.with_monitor: 66 | t = Thread(target=run_monitor, name="Monitor") 67 | environ.monitor_thread = t 68 | tList.append(t) 69 | 70 | return tList 71 | 72 | 73 | def run(): 74 | 75 | from .environ import Environ 76 | 77 | environ = Environ() 78 | 79 | parser = create_default_parser() 80 | options, args = parser.parse_args() 81 | 82 | setup_default_environ(options, args, environ) 83 | 84 | tList = create_default_threads(options, args, environ) 85 | 86 | for t in tList: 87 | t.daemon = True 88 | t.start() 89 | 90 | # 91 | # Don't use join() to block the main thread, or Ctrl + C in Windows can't work. 92 | # 93 | # for t in tList: 94 | # t.join() 95 | # 96 | try: 97 | Queue().get() 98 | except KeyboardInterrupt as e: 99 | pass 100 | -------------------------------------------------------------------------------- /autoelective/const.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: const.py 4 | # modified: 2019-09-11 5 | 6 | import os 7 | from ._internal import mkdir, absp, read_list 8 | 9 | CACHE_DIR = absp("../cache/") 10 | CAPTCHA_CACHE_DIR = absp("../cache/captcha/") 11 | LOG_DIR = absp("../log/") 12 | ERROR_LOG_DIR = absp("../log/error") 13 | REQUEST_LOG_DIR = absp("../log/request/") 14 | WEB_LOG_DIR = absp("../log/web/") 15 | 16 | CNN_MODEL_FILE = absp("../model/cnn.20210311.1.pt") 17 | USER_AGENTS_TXT_GZ = absp("../user_agents.txt.gz") 18 | USER_AGENTS_USER_TXT = absp("../user_agents.user.txt") 19 | DEFAULT_CONFIG_INI = absp("../config.ini") 20 | 21 | mkdir(CACHE_DIR) 22 | mkdir(CAPTCHA_CACHE_DIR) 23 | mkdir(LOG_DIR) 24 | mkdir(ERROR_LOG_DIR) 25 | mkdir(REQUEST_LOG_DIR) 26 | mkdir(WEB_LOG_DIR) 27 | 28 | if os.path.exists(USER_AGENTS_USER_TXT): 29 | USER_AGENT_LIST = read_list(USER_AGENTS_USER_TXT) 30 | else: 31 | USER_AGENT_LIST = read_list(USER_AGENTS_TXT_GZ) 32 | 33 | 34 | class IAAAURL(object): 35 | """ 36 | Host 37 | OauthHomePage 38 | OauthLogin 39 | """ 40 | Host = "iaaa.pku.edu.cn" 41 | OauthHomePage = "https://iaaa.pku.edu.cn/iaaa/oauth.jsp" 42 | OauthLogin = "https://iaaa.pku.edu.cn/iaaa/oauthlogin.do" 43 | 44 | 45 | class ElectiveURL(object): 46 | """ 47 | Host 48 | SSOLoginRedirect 重定向链接 49 | SSOLogin sso登录 50 | Logout 登出 51 | HelpController 选课帮助页 52 | ShowResults 选课结果页 53 | SupplyCancel 补退选页 54 | Supplement 补退选页第一页之后 55 | DrawServlet 获取一张验证码 56 | validate 补退选验证码校验接口 57 | """ 58 | Scheme = "https" 59 | Host = "elective.pku.edu.cn" 60 | HomePage = "https://elective.pku.edu.cn/elective2008/" 61 | SSOLoginRedirect = "http://elective.pku.edu.cn:80/elective2008/ssoLogin.do" 62 | SSOLogin = "https://elective.pku.edu.cn/elective2008/ssoLogin.do" 63 | Logout = "https://elective.pku.edu.cn/elective2008/logout.do" 64 | HelpController = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/help/HelpController.jpf" 65 | ShowResults = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/electiveWork/showResults.do" 66 | SupplyCancel = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/SupplyCancel.do" 67 | Supplement = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/supplement.jsp" 68 | DrawServlet = "https://elective.pku.edu.cn/elective2008/DrawServlet" 69 | Validate = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/validate.do" 70 | -------------------------------------------------------------------------------- /autoelective/monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: monitor.py 4 | # modified: 2019-09-11 5 | 6 | import logging 7 | import werkzeug._internal as _werkzeug_internal 8 | from flask import Flask, current_app, jsonify 9 | from flask.logging import default_handler 10 | from .environ import Environ 11 | from .config import AutoElectiveConfig 12 | from .logger import ConsoleLogger 13 | 14 | environ = Environ() 15 | config = AutoElectiveConfig() 16 | cout = ConsoleLogger("monitor") 17 | ferr = ConsoleLogger("monitor.error") 18 | 19 | monitor = Flask(__name__, static_folder=None) # disable static rule 20 | 21 | monitor.config["JSON_AS_ASCII"] = False 22 | monitor.config["JSON_SORT_KEYS"] = False 23 | 24 | _werkzeug_internal._logger = cout # custom _logger for werkzeug 25 | 26 | monitor.logger.removeHandler(default_handler) 27 | for logger in [cout, ferr]: 28 | for handler in logger.handlers: 29 | monitor.logger.addHandler(handler) 30 | 31 | 32 | @monitor.route("/", methods=["GET"]) 33 | @monitor.route("/rules", methods=["GET"]) 34 | @monitor.route("/stat", methods=["GET"], strict_slashes=False) 35 | def _root(): 36 | rules = [] 37 | for r in sorted(current_app.url_map.iter_rules(), key=lambda r: r.rule): 38 | line = "{method} {rule}".format( 39 | method=','.join( m for m in r.methods if m not in ("HEAD","OPTIONS") ), 40 | rule=r.rule 41 | ) 42 | rules.append(line) 43 | return jsonify({ 44 | "rules": rules, 45 | }) 46 | 47 | @monitor.route("/stat/loop", methods=["GET"]) 48 | def _stat_iaaa_loop(): 49 | it = environ.iaaa_loop_thread 50 | et = environ.elective_loop_thread 51 | it_alive = it is not None and it.is_alive() 52 | et_alive = et is not None and et.is_alive() 53 | finished = not it_alive and not et_alive 54 | error_encountered = not finished and ( not it_alive or not et_alive ) 55 | return jsonify({ 56 | "iaaa_loop": environ.iaaa_loop, 57 | "elective_loop": environ.elective_loop, 58 | "iaaa_loop_is_alive": it_alive, 59 | "elective_loop_is_alive": et_alive, 60 | "finished": finished, 61 | "error_encountered": error_encountered, 62 | }) 63 | 64 | @monitor.route("/stat/course", methods=["GET"]) 65 | def _stat_course(): 66 | goals = environ.goals # [course] 67 | ignored = environ.ignored # {course, reason} 68 | return jsonify({ 69 | "goals": [ str(c) for c in goals ], 70 | "current": [ str(c) for c in goals if c not in ignored ], 71 | "ignored": { str(c): r for c, r in ignored.items() }, 72 | }) 73 | 74 | @monitor.route("/stat/error", methods=["GET"]) 75 | def _stat_error(): 76 | return jsonify({ 77 | "errors": environ.errors, 78 | }) 79 | 80 | 81 | def run_monitor(): 82 | monitor.run( 83 | host=config.monitor_host, 84 | port=config.monitor_port, 85 | debug=True, 86 | use_reloader=False, 87 | ) 88 | -------------------------------------------------------------------------------- /autoelective/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: logger.py 4 | # modified: 2019-09-09 5 | 6 | import os 7 | import logging 8 | from logging import StreamHandler 9 | from logging.handlers import TimedRotatingFileHandler 10 | from .config import AutoElectiveConfig 11 | from .const import ERROR_LOG_DIR 12 | from ._internal import mkdir 13 | 14 | config = AutoElectiveConfig() 15 | 16 | _USER_ERROR_LOG_DIR = os.path.join(ERROR_LOG_DIR, config.get_user_subpath()) 17 | mkdir(_USER_ERROR_LOG_DIR) 18 | 19 | 20 | class BaseLogger(object): 21 | 22 | default_level = logging.DEBUG 23 | default_format = logging.Formatter("[%(levelname)s] %(name)s, %(asctime)s, %(message)s", "%H:%M:%S") 24 | 25 | def __init__(self, name, level=None, format=None): 26 | if self.__class__ is __class__: 27 | raise NotImplementedError 28 | self._name = name 29 | self._level = level if level is not None else self.__class__.default_level 30 | self._format = format if format is not None else self.__class__.default_format 31 | self._logger = logging.getLogger(self._name) 32 | self._logger.setLevel(self._level) 33 | self._logger.addHandler(self._get_handler()) 34 | 35 | @property 36 | def handlers(self): 37 | return self._logger.handlers 38 | 39 | def _get_handler(self): 40 | raise NotImplementedError 41 | 42 | def log(self, level, msg, *args, **kwargs): 43 | return self._logger.log(level, msg, *args, **kwargs) 44 | 45 | def debug(self, msg, *args, **kwargs): 46 | return self._logger.debug(msg, *args, **kwargs) 47 | 48 | def info(self, msg, *args, **kwargs): 49 | return self._logger.info(msg, *args, **kwargs) 50 | 51 | def warn(self, msg, *args, **kwargs): 52 | return self._logger.warn(msg, *args, **kwargs) 53 | 54 | def warning(self, msg, *args, **kwargs): 55 | return self._logger.warning(msg, *args, **kwargs) 56 | 57 | def error(self, msg, *args, **kwargs): 58 | return self._logger.error(msg, *args, **kwargs) 59 | 60 | def exception(self, msg, *args, **kwargs): 61 | kwargs.setdefault("exc_info", True) 62 | return self._logger.exception(msg, *args, **kwargs) 63 | 64 | def fatal(self, msg, *args, **kwargs): 65 | return self._logger.fatal(msg, *args, **kwargs) 66 | 67 | def critical(self, msg, *args, **kwargs): 68 | return self._logger.critical(msg, *args, **kwargs) 69 | 70 | 71 | class ConsoleLogger(BaseLogger): 72 | """ 控制台日志输出类 """ 73 | 74 | default_level = logging.DEBUG 75 | 76 | def _get_handler(self): 77 | handler = logging.StreamHandler() 78 | handler.setLevel(self._level) 79 | handler.setFormatter(self._format) 80 | return handler 81 | 82 | 83 | class FileLogger(BaseLogger): 84 | """ 文件日志输出类 """ 85 | 86 | default_level = logging.WARNING 87 | 88 | def _get_handler(self): 89 | file = os.path.join(_USER_ERROR_LOG_DIR, "%s.log" % self._name) 90 | handler = TimedRotatingFileHandler(file, when='d', interval=1, encoding="utf-8-sig") 91 | handler.setLevel(self._level) 92 | handler.setFormatter(self._format) 93 | return handler 94 | -------------------------------------------------------------------------------- /autoelective/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: client.py 4 | # modified: 2019-09-09 5 | 6 | from requests.models import Request 7 | from requests.sessions import Session 8 | from requests.cookies import extract_cookies_to_jar 9 | 10 | class BaseClient(object): 11 | 12 | default_headers = {} 13 | default_client_timeout = 10 14 | 15 | def __init__(self, *args, **kwargs): 16 | if self.__class__ is __class__: 17 | raise NotImplementedError 18 | self._timeout = kwargs.get("timeout", self.__class__.default_client_timeout) 19 | self._session = Session() 20 | self._session.headers.update(self.__class__.default_headers) 21 | 22 | @property 23 | def user_agent(self): 24 | return self._session.headers.get('User-Agent') 25 | 26 | def _request(self, method, url, 27 | params=None, data=None, headers=None, cookies=None, files=None, 28 | auth=None, timeout=None, allow_redirects=True, proxies=None, 29 | hooks=None, stream=None, verify=None, cert=None, json=None): 30 | 31 | # Extended from requests/sessions.py for '_client' kwargs 32 | 33 | req = Request( 34 | method=method.upper(), 35 | url=url, 36 | headers=headers, 37 | files=files, 38 | data=data or {}, 39 | json=json, 40 | params=params or {}, 41 | auth=auth, 42 | cookies=cookies, 43 | hooks=hooks, 44 | ) 45 | prep = self._session.prepare_request(req) 46 | prep._client = self # hold the reference to client 47 | 48 | 49 | proxies = proxies or {} 50 | 51 | settings = self._session.merge_environment_settings( 52 | prep.url, proxies, stream, verify, cert 53 | ) 54 | 55 | # Send the request. 56 | send_kwargs = { 57 | 'timeout': timeout or self._timeout, # set default timeout 58 | 'allow_redirects': allow_redirects, 59 | } 60 | send_kwargs.update(settings) 61 | resp = self._session.send(prep, **send_kwargs) 62 | 63 | return resp 64 | 65 | def _get(self, url, params=None, **kwargs): 66 | return self._request('GET', url, params=params, **kwargs) 67 | 68 | def _post(self, url, data=None, json=None, **kwargs): 69 | return self._request('POST', url, data=data, json=json, **kwargs) 70 | 71 | def set_user_agent(self, user_agent): 72 | self._session.headers["User-Agent"] = user_agent 73 | 74 | def persist_cookies(self, r): 75 | """ 76 | From requests/sessions.py, Session.send() 77 | 78 | Session.send() 方法会首先 dispatch_hook 然后再 extract_cookies_to_jar 79 | 80 | 在该项目中,对于返回信息异常的请求,在 hooks 校验时会将错误抛出,send() 之后的处理将不会执行。 81 | 遇到的错误往往是 SystemException / TipsException ,而这些客户端认为是错误的情况, 82 | 对于服务器端来说并不是错误请求,服务器端在该次请求结束后可能会要求 Set-Cookies 83 | 但是由于 send() 在 dispatch_hook 时遇到错误而中止,导致后面的 extract_cookies_to_jar 84 | 未能调用,因此 Cookies 并未更新。下一次再请求服务器的时候,就会遇到会话过期的情况。 85 | 86 | 在这种情况下,需要在捕获错误后手动更新 cookies 以确保能够保持会话 87 | 88 | """ 89 | if r.history: 90 | 91 | # If the hooks create history then we want those cookies too 92 | for resp in r.history: 93 | extract_cookies_to_jar(self._session.cookies, resp.request, resp.raw) 94 | 95 | extract_cookies_to_jar(self._session.cookies, r.request, r.raw) 96 | 97 | def clear_cookies(self): 98 | self._session.cookies.clear() 99 | -------------------------------------------------------------------------------- /MIGRATION_GUIDE_origin.md: -------------------------------------------------------------------------------- 1 | Migration Guide 2 | ==================== 3 | 4 | v5.0.1 -> 6.0.0 5 | ------------------ 6 | - 新的验证码识别模块依赖 opencv,安装命令 `pip3 install opencv-python` 7 | - 新的验证码识别模块不再依赖于 joblib,并删除了对 simplejson 的可选依赖 8 | - PyTorch 版本需保证大于 1.4.x,否则无法读取 CNN 模型 9 | 10 | v3.0.8 -> 5.0.1 11 | ------------------ 12 | - `config.ini` 中添加 `elective_client_max_life` 用于设置 elective 客户端的存活时间,到期后 elective 会话会被主动关闭 13 | - `config.ini` 中添加 `print_mutex_rules` 选项,可以选择是否打印完整的互斥规则列表 14 | 15 | v3.0.6 -> 3.0.8 16 | ------------------ 17 | - 添加了 [Issue #28](https://github.com/zhongxinghong/PKUAutoElective/issues/28) 所提的建议,引入了延迟规则的定义,如果你需要使用,请注意 [README.md](/README.md) 中 [延迟规则](/README.md#延迟规则) 小节 18 | 19 | 20 | v3.0.5 -> 3.0.6 21 | ------------------ 22 | - 修复了 [Issue #25](https://github.com/zhongxinghong/PKUAutoElective/issues/25) 所提的 bug,如果你在使用互斥规则,请你注意下 [README.md](/README.md) 中 [互斥规则](/README.md#互斥规则) 小节的更新 23 | 24 | 25 | v3.0.3 -> 3.0.5 26 | ------------------ 27 | - 现在定义在 `config.ini` 中的课程将会像原来那样保持其在文件中的先后顺序,相应的选课优先级按从上到下的顺序从高到低排 28 | 29 | 30 | v3.0.1 beta -> v3.0.3 31 | ------------------ 32 | - 旧版本的 iaaa 和 elective 的部分 API 已经失效,请赶紧更新到最新 v3.0.3 及以上版本,旧版本将不再维护 33 | 34 | 35 | v2.1.0 -> v3.0.1 beta 36 | ------------------ 37 | - 不再使用 `course.csv` 定义课程列表,而是合并到 `config.ini` 中,因此需要仔细查看手册的 [基本用法](/README.md#基本用法) 一节,以明确新的课程定义方法。最好对 `config.ini` 进行完全重写 38 | - 修改了 `config.ini` 中 `student_ID` 键名为 `student_id` 39 | - 新引入了自定义选课规则的功能,更多请查看手册的 [基本用法](/README.md#基本用法) 一节 40 | - 修改了监视器路由,详细查看手册的 [监视器](/README.md#监视器) 一节 41 | - 更多改动细节请查看 [HISTORY.md](/HISTORY.md) 并重新阅读手册 [README.md](/README.md) 42 | 43 | #### Development Related 44 | - 这版在诸多方面均有较大改动,详细请查看 [HISTORY.md](/HISTORY.md) 45 | - 这版开始我不打算再提供项目的架构细节说明,如果你需要了解项目的实现细节,可以自行阅读源码 46 | 47 | 48 | v2.0.9 -> v2.1.0 49 | ------------------ 50 | 51 | #### Development Related 52 | - Windows 在创建多进程时只能采用 `spawn` 方法,子进程创建后并不直接共享父进程已经设置好的的用户配置项,因此还需要将用户配置项 `userInfo` 在进程中共享。但是 `userInfo` 直接影响着最基本的 `config.py` ,为了让用户自定义配置 `userInfo` 能够在子进程中被正确应用,`userInfo` 的更新至子线程 `_internal.py` 和 `config` 单例的第一次初始化必须早于任何依赖于 `config` 单例的方法的调用。 53 | - 因此,这一版中对包调用的逻辑进行了大幅度的修改,删减了大部分包的在导入时即创建 `_config` 全局变量的逻辑,改成将 `config` 变量在函数使用时才创建,并且将 `loop.py` 和 `monitor.py` 中的所有全局变量和全局函数声明为局部 54 | - 个人觉得这个改动很丑陋,但是由于我的开发经验有限,一时想不到其他的写法,如果你对这个问题有更好的解决方法,欢迎发 Issue ! 55 | - 个人的一个改进想法是把多进程全部换成多线程,这样就不需要考虑资源共享的问题 56 | 57 | 58 | v2.0.6 -> v2.0.7 59 | ------------------ 60 | - monitor 进程中 `/loop` 路由细分为 `/main_loop` 和 `/login_loop` 两个路由 61 | - monitor 进程中 `/all` 路由添加了与错误捕获记录相关的键 62 | - monitor 进程中添加了 `/errors` 路由 63 | 64 | #### Development Related 65 | - 进程共享的 status 对象中的 `loop` 项细分为了 `main_loop` 和 `login_loop` 两个值,并添加了与错误捕获记录相关的键 66 | 67 | 68 | v2.0.4 -> v2.0.5 69 | ------------------ 70 | 71 | #### Development Related 72 | 73 | - 修改了错误类 `IAAANotSuccessError` 的全局错误码 74 | 75 | 76 | v2.0.3 -> v2.0.4 77 | ------------------ 78 | - `config.ini` 内添加了 elective 多会话相关的配置 79 | - `config.ini` 内删除了 `iaaa_relogin_interval` 字段 80 | 81 | #### Development Related 82 | 83 | - 为了应对选课网偶发的会话过期问题,为 elective 客户端引入了多会话机制,并删除了旧有的定时重登机制。具体见 README 中的 [运行流程](/README.md#运行流程) 小节 84 | 85 | 86 | v2.0.1 -> v2.0.2 87 | ------------------ 88 | - `config.ini` 内添加了 `client/supply_cancel_page` 值,以支持不处于选课计划第一页的课程 89 | 90 | 91 | v1.0.4 -> v2.0.1 92 | ------------------ 93 | - 新版对 `config.ini` 内的绝大多数配置项名称做了修改,需要用新提供 `config.sample.ini` 重新填写一遍配置 94 | - 添加了自定义 `config.ini` 和 `course.csv` 95 | - 添加了对 `Flask` 库的依赖,对于尚未安装该依赖的环境,还需额外运行 `pip3 install flask` 96 | 97 | #### For 'git pull' Comment 98 | 99 | 如果你使用了 `git` 命令更新该项目,在输入 `git pull origin master` 后,可能会报错 `error: Your local changes to the following files would be overwritten by merge:` ,这是因为新版删除了 `config.ini` 和 `course.*.csv` 文件,而改用 `config.sample.ini` 和 `course.*.sample.csv` 代替。只需要输入以下命令即可消除冲突: 100 | 101 | 在项目根目录下: 102 | ```console 103 | $ git checkout config.ini 104 | $ git checkout course.utf-8.csv 105 | $ git checkout course.gbk.csv 106 | ``` 107 | 108 | #### Development Related 109 | 110 | - 在 `BaseClient` 内添加了 `persist_cookies` 方法,会在 `hooks.py` 内被调用,以确保在一些特定的错误下仍然可以保持会话 111 | - 在 `elective.py` 的 `sso_login` 的请求头中添加了一个固定的无效 `Cookie` 以防止登录时报 `101` 状态码 112 | - 修改了 `IAAA` 的重新登录逻辑,由原来的遇到错误重登,变为每隔一段时间重登 113 | - 在 `loop.py` 中对 `elective.py` 的 `get_Validate` 方法的调用结果添加了一层错误捕获,以应对非 JSON 格式的响应体 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PKUAutoElective_updated 2 | 3 | 北大选课网 **补退选** 阶段自动选课小工具 v7.0.0.2 (2022.02.20) 4 | 5 | upd: 重新适配验证码,利用ddddocr库,似乎有的OCR会识别错误,不过好像没什么关系,原作者的鲁棒性好强诶 6 | 7 | ---------------------------------------------------------- 8 | 9 | 适配2022年秋季学期北大选课网环境,目前支持 `本科生(含辅双)` 和 `研究生` 选课 10 | 11 | 再次鸣谢 [zhongxinghong/PKUAutoElective](https://github.com/zhongxinghong/PKUAutoElective) 的杰出工作,这一项目使得第一周认真听课成为了可能 12 | 13 | ## 重要注意事项 14 | 15 | 特地将一些重要的说明提前写在这里,希望能得到足够的重视。 16 | 17 | 1. 不要使用过低的刷新间隔,以免对选课网服务器造成压力。建议时间间隔不小于 4 秒。(参见`refresh_interval`的参数说明) 18 | 2. 选课网存在 IP 级别的限流,**访问过于频繁可能会导致 IP 被封禁**。单个IP最多建立5个有效的会话。(参见`elective_client_pool_size`的参数说明) 19 | 3. ***不要*** 把**同班号、有空位,但是不想选**的课放入选课计划;***不要*** 在选课网遭遇突发流量的时候**拥挤选课**。否则很可能遭遇 ***未知错误*** ! 20 | 21 | ## 2021秋季学期v7.0.0.1版本 22 | 23 | 24 | ## 简单配置说明 25 | 26 | 这一部分内容简化自[zhongxinghong/PKUAutoElective/README.md](https://github.com/zhongxinghong/PKUAutoElective/blob/master/README.md),您也可以在本fork的`README_origin.md`中找到这一文件以获得详尽说明。 27 | 28 | ### 安装与环境配置 29 | 30 | #### 下载PKUAutoElective 31 | 32 | 点击右上角的 `Code -> Download ZIP` 即可下载这个项目至本地。对于 git 命令行: 33 | 34 | ```console 35 | git clone https://github.com/kylelv2000/PKUAutoElective_updated.git 36 | ``` 37 | 38 | #### 安装Python 39 | 40 | 可以从 [Python 官网](https://www.python.org/) 下载并安装。本项目的开发环境为Python 3.7.3。对于Linux系统: 41 | 42 | ```console 43 | $ apt-get install python3 44 | ``` 45 | 46 | #### 安装依赖的Packages 47 | 48 | 复制并以**管理员身份**运行命令,一次性安装运行包。示例使用**清华镜像源**进行下载加速: 49 | 50 | ```console 51 | pip3 install requests lxml Pillow ddddocr numpy flask -i https://pypi.tuna.tsinghua.edu.cn/simple 52 | ``` 53 | 54 | **或者使用requirements**,安装对应开发环境所使用包的版本 55 | 56 | ```console 57 | pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 58 | ``` 59 | 60 | 61 | 62 | 63 | ### 基础配置与使用 64 | 65 | 1. 复制 `config.sample.ini` 文件,所得的新文件重命名为 `config.ini`。用文本编辑器打开 `config.ini` 66 | - 直接复制文件,不要新建一个文件叫 `config.ini`,然后复制粘贴内容,否则可能会遇到编码问题。 67 | 2. 配置 `[user]`,填写`student_id`和`password`,详细见注释。 68 | - 替换` = `后的数据为真实值后,请妥善保管`config.ini`文件,避免泄露个人校园网账户密码。 69 | - 如果是双学位账号,设置 `dual_degree` 为 `true`,同时需要设置登录身份 `identity`,非双学位账号两者均保持默认即可。 70 | 3. 在选课网上,将待选课程手动添加到选课网的 `补退选` 页 `选课计划` 中。 71 | - 将待选课程在 `补退选` 页 `选课计划` 列表中的 **对应页数** 作为 `supply_cancel_page`的值 。 72 | - 该项目无法检查选课计划的合理性,请自行**确保填写的课程在有名额的时候可以被选上**,以免浪费时间。 73 | 4. 配置 `[course]` 定义待选课程,详细见注释。 74 | - `[course:]` 冒号后可定义唯一的课程标识符号。 75 | - 中间请间隔一个空白行。 76 | - `name` 为补退选页面中的`课程名称`。 77 | - `class` 为补退选页面中的`班号`。 78 | - `school` 为补退选页面中的`开课单位`。 79 | 5. 如果有需要使用高级功能,请务必仔细阅读详细的项目说明 [README_orgin.md](https://github.com/Chiyun-CHU/PKUAutoElective_updated/blob/master/README_origin.md)。 80 | 6. 进入项目根目录,运行 `python3 main.py`,即可开始自动选课。 81 | 82 | 83 | ## 一点额外陈述 84 | 85 | 计算中心对抗PKUAutoElective的方法相当简单粗暴,那就是每学期稍稍修改选课网的逻辑,使得程序的代码逻辑无法得到正确的请求响应而报错,破坏程序的正常工作。 86 | 87 | 除去2021年春季学期引入动态验证码的重大更新,选课网的这些小动作实际上并不涉及复杂的代码修改,基本可以在当时的补退选第一阶段开始,服务器宕机结束后的一小时到一天内完成更新。这意味着每学期选课网补退选阶段的更新,并不能支撑到选课网真正的压力峰值,即补退选第二阶段跨院系选课开始。 88 | 89 | 感谢PKUAutoElective的原作者在2021年春季几乎以一己之力标注了大量动态验证码,并完成了卷积神经网络[zhongxinghong/PKUElectiveCaptcha2021Spring](https://github.com/zhongxinghong/PKUElectiveCaptcha2021Spring)的训练,使得验证码的识别可以继续在本地进行,不再需要按照此前的解决方法调用商业性云的API,大大降低了高达几分钱的成本并节约了长达数分钟的配置时间。 90 | 91 | 此外,由于2021年春季学期更新同时引入了新的补退选第一阶段,即现在的候补选课阶段,这使得PKUAutoElective的维护者可以提前了解本学期的补退选页面逻辑,更早地对程序进行相应的更新。 92 | 93 | 然而,随着PKUAutoElective的原作者毕业离校,失去了访问选课网进行测试的权限,这一项目的维护必须由在校的更低年级学生接手,否则随着选课网的迭代,这些代码最终将变为一些无意义的文件。目前来看,至少2021年秋季学期的选课网更新,以笔者的代码水平,还能够通过对原作者代码上小修小补完成应对。 94 | 95 | **致计算中心** 基于前面的陈述,如果计算中心想要进一步制裁PKUAutoElective以降低补退选各个阶段开始时选课网服务器的压力,请将补退选三个阶段的操作逻辑进行分离,让可用版本的发布晚于选课网的崩溃。但有一点需要着重声明,无论是原作者2021年春季学期故意在跨院系选课开始数天后才放出更新的尝试,还是2021年秋季学期候补选课开始时选课网的崩溃,都毫无疑问地证明了以下事实:PKUAutoElective并没有对选课网每学期如约而至的崩溃做出决定性的贡献。 96 | 97 | **致反感PKUAutoElective的同学** 综上所述,即使您厌恶甚至痛恨这一项目,也请以某种意义上破坏了选课公平为理由进行讨伐,而不要再以导致选课网崩溃为帽子攻击PKUAutoElective的原作者和维护者。此外,考虑到信息科学与技术的迅速发展,无论您对于这一项目的态度如何,笔者也强烈建议您尝试一下完成PKUAutoElective的配置。即使您不愿意或者不屑于使用,配置一个开源项目也是相当有意义的一件事情:它至少可以带给您一套功能基本齐全的Python环境,并且还可以为您今后学习或工作中遇到的相似情景积累宝贵经验。 98 | 99 | > ## 原作者[Rabbit](https://github.com/zhongxinghong)的停更说明 100 | > 101 | > 感谢大家两年多来对这个项目的支持!我已经大四了,这学期结束后我将毕业,那之后我将不再更新这个项目。由于测试较为困难,这个项目一直以来基本都没有接受过 PR,如果有更改和扩充功能的需要,建议你 fork 一个自己的分支。停更后该项目不会 archive,有任何问题仍然可以在 Issue 里共同讨论,如果你想宣传自己的改版后的分支,也可以在 Issue 里分享 102 | > 103 | > 小白兔 写于 2021.03.12 104 | 105 | > qaz 修改于 2023.02.19 106 | -------------------------------------------------------------------------------- /config.sample.ini: -------------------------------------------------------------------------------- 1 | # filename: config.ini 2 | # coding: utf-8 3 | 4 | [user] 5 | 6 | ; student_id string 学号 7 | ; password string 密码 8 | ; dual_degree boolean 是否为双学位账号,可选 (true, false, True, False, 1, 0) 9 | ; 住:只要你的账号在登录时需要选择 "主修/辅双" 身份,此处就需要设为 true 10 | ; identity string 双学位账号登录身份,可选 ("bzx","bfx") 对应于 "主修/辅双" 11 | 12 | student_id = 1x000xxxxx 13 | password = xxxxxxxx 14 | dual_degree = false 15 | identity = bzx 16 | 17 | [client] 18 | 19 | ; supply_cancel_page int 待刷课程处在 "补退选" 选课计划的第几页 20 | ; refresh_interval float 每次循环后的暂停时间,单位 s 21 | ; random_deviation float 偏移量分数,如果设置为 <= 0 的值,则视为 0 22 | ; iaaa_client_timeout float IAAA 客户端最长请求超时 23 | ; elective_client_timeout float elective 客户端最长请求超时 24 | ; elective_client_pool_size int 最多同时保持几个 elective 的有效会话(同一 IP 下最多为 5) 25 | ; elective_client_max_life int elvetive 客户端的存活时间,单位 s(设置为 -1 则存活时间为无限长) 26 | ; login_loop_interval float IAAA 登录线程每回合结束后的等待时间 27 | ; print_mutex_rules boolean 是否在每次循环时打印完整的互斥规则列表 28 | ; debug_print_request boolean 是否打印请求细节 29 | ; debug_dump_request boolean 是否将重要接口的请求以日志的形式记录到本地(包括补退选页、提交选课等接口) 30 | ; 31 | ; 关于刷新间隔的配置示例: 32 | ; 33 | ; refresh_interval = 8 34 | ; random_deviation = 0.2 35 | ; 36 | ; 则每两个循环的间隔时间为 8 * (1.0 ± 0.2) s 37 | 38 | supply_cancel_page = 1 39 | refresh_interval = 8 40 | random_deviation = 0.2 41 | iaaa_client_timeout = 30 42 | elective_client_timeout = 60 43 | elective_client_pool_size = 2 44 | elective_client_max_life = 600 45 | login_loop_interval = 2 46 | print_mutex_rules = true 47 | debug_print_request = false 48 | debug_dump_request = false 49 | 50 | [monitor] 51 | 52 | ; host str 53 | ; port int 54 | 55 | host = 127.0.0.1 56 | port = 7074 57 | 58 | ;---------------- course ----------------; 59 | ; 60 | ; 课程结构定义: 61 | ; 62 | ; [course:${id}] ; 用户为该课程定义的 id 63 | ; 64 | ; name = ${name} ; elective 中的 `课程名` 65 | ; class = ${class} ; elective 中的 `班号` 66 | ; school = ${school} ; elective 中的 `开课单位` 67 | ; 68 | ; 69 | ; 例如: 70 | ; 71 | ; [course:math_3] 72 | ; 73 | ; name = 集合论与图论 74 | ; class = 3 75 | ; school = 信息科学技术学院 76 | ; 77 | ; 可以解析出: 78 | ; 79 | ; id = "math_3" 80 | ; name = "集合论与图论" 81 | ; class = 3 82 | ; school = "信息科学技术学院" 83 | ; 84 | ; 85 | ; 更多例子: 86 | ; 87 | ; [course:db] 88 | ; 89 | ; name = 数据库概论 90 | ; class = 1 91 | ; school = 信息科学技术学院 92 | ; 93 | ; [course:0] 94 | ; 95 | ; name = 概率统计 (A) 96 | ; class = 1 97 | ; school = 信息科学技术学院 98 | ; 99 | ; 100 | ; 注意: 101 | ; 102 | ; 1. [course:${id}] 中可以带空格,但是不推荐 103 | ; 例如 [course: 1], [course:math 1] [ course : hello world ] 104 | ; 可以解析出: "1", "math 1", "hello world" 105 | ; 2. [course:${id}] 中不要带有 ',' 否则会在后续规则定义中引入混乱! 不接受 '\,' 转义 106 | ; 例如 [course:Hai,Alice] 是非法的,在解析时会被忽略 107 | ; 3. [course:${id}] 中可以带有 ':',但是不推荐 108 | ; 4. 该文件中课程的优先级按照从上到下的顺序从高到低排序,如果在同一循环中同时出现多个有空名额的课,会从上到下依次提交选课请求, 109 | ; 高优先级的课会先被提交,例如上述案例中,数据库概率比概率统计(A)的优先级高,如果这两个课同时出现空名额,会先提交数据库 110 | ; 概率的选课请求 111 | ; 112 | ;----------------------------------------; 113 | 114 | ; [course:sample] 115 | ; 116 | ; name = class_name_here 117 | ; class = class_no_here 118 | ; school = class_school_here 119 | 120 | ;---------------- mutex ----------------; 121 | ; 122 | ; 互斥规则结构定义: 123 | ; 124 | ; [mutex:${id}] ; 用户为该互斥规则定义的 id 125 | ; 126 | ; courses = ${cid1},${cid2},... ; 用户定义的多个课程的 id,以 ',' 分隔 127 | ; 128 | ; 129 | ; 例如: 130 | ; 131 | ; [course:math_1] 132 | ; ... 133 | ; 134 | ; [course:math_2] 135 | ; ... 136 | ; 137 | ; [course:math_3] 138 | ; ... 139 | ; 140 | ; 141 | ; [mutex:0] 142 | ; 143 | ; courses = math_1,math_2,math_3 144 | ; 145 | ; 可以解析出 146 | ; 147 | ; id = "0" 148 | ; courses = ["math_1", "math_2", "math_3"] 149 | ; 150 | ; 151 | ; 解释: 152 | ; 153 | ; 同一个互斥规则内的课程一旦有一门课已经被选上,其他课程将会被自动忽略。 154 | ; 例如,对于上述例子,如果 math_1, math_2, math_3 有任何一门课已经被选上,其它两门课将会被自动忽略 155 | ; 例如,当 math_1 被选上时,math_2, math_3 会被自动忽略 156 | ; 157 | ; 158 | ; 注意: 159 | ; 160 | ; 1. [mutex:${id}] 的命名注意事项同 course 161 | ; 2. courses 中可以有空格,但是不推荐 162 | ; 例如 courses = math_1, math_2 , math_3 163 | ; 仍可以解析出 ["math_1", "math_2", "math_3"] 164 | ; 3. 如果互斥的几门课在同一回合内同时出现空位,优先级高的课会被首先提交,而优先级低的课会被忽略, 165 | ; 关于课程优先级的概念,参看 [course] 下的相关注释 166 | ; 167 | ;---------------------------------------; 168 | 169 | ; [mutex:sample] 170 | ; 171 | ; courses = course_id_1,course_id_2 172 | 173 | ;---------------- delay ----------------; 174 | ; 175 | ; 延迟规则结构定义: 176 | ; 177 | ; [delay:${id}] ; 用户为该延迟规则定义的 id 178 | ; 179 | ; course = ${course} ; 用户定义的课程的 id 180 | ; threshold = ${threshold} ; 触发选课的剩余名额的阈值,剩余名额小于等于该值的时候才会触发选课 181 | ; 182 | ; 183 | ; 例如: 184 | ; 185 | ; [course:math_1] 186 | ; ... 187 | ; 188 | ; [delay:0] 189 | ; 190 | ; course = math_1 191 | ; threshold = 10 192 | ; 193 | ; 可以解析出 194 | ; 195 | ; id = "0" 196 | ; course = "math_1" 197 | ; threshold = 10 198 | ; 199 | ; 200 | ; 解释: 201 | ; 202 | ; 被定义了延迟规则的课程,即使当回合它可以被选上时,也只有当该课程的剩余名额数小于等于设定的阈值时才会触发提交选课, 203 | ; 例如,对于上述例子,假设 math_1 的总名额是 240 人,如果当回合 math_1 的选课状况是 229/240,将不会 204 | ; 触发选课,因为剩余名额 = 240 - 229 = 11 > 10,而如果当回合 math_1 的选课状态是 230/240,将会触发选课, 205 | ; 因为剩余名额 = 240 - 230 = 10 <= 10,同理,诸如 235/240 这样的状态也会触发选课 206 | ; 207 | ; 208 | ; 注意: 209 | ; 1. [delay:${id}] 的命名注意事项同 course 210 | ; 2. threshold 必须是正整数,否则会报错 211 | ; 3. 使用前请务必查看 README.md 中与之相关的说明 212 | ; 213 | ;---------------------------------------; 214 | 215 | ; [delay:sample] 216 | ; 217 | ; course = course_id_1 218 | ; threshold = a_positive_int 219 | -------------------------------------------------------------------------------- /HISTORY_origin.md: -------------------------------------------------------------------------------- 1 | Release History 2 | =============== 3 | 4 | v6.0.0 (2021-03-12) 5 | ------------------- 6 | - 实现了对选课网新验证码的识别 7 | - 更新了对选课网部分 API 的请求方法 (get_supplement, get_Validate) 8 | - 修复了无法获取到位于补退选列表第一页之后课程的问题,详见 [Issue #54](https://github.com/zhongxinghong/PKUAutoElective/issues/54), [Issue #55](https://github.com/zhongxinghong/PKUAutoElective/issues/55) 9 | - 修复了无法正确捕获会话超时的错误信息导致一直报错 UnexceptedHTMLFormat 的问题,详见 [Issue #56](https://github.com/zhongxinghong/PKUAutoElective/issues/56) 10 | - 修复了无法正确识别补选课程成功时所返回的提示信息的问题 11 | 12 | 13 | v5.0.1 (2020-09-25) 14 | ------------------- 15 | - User-Agent 池的大小扩充至 6000+ 16 | - sso_login 时将使用随机生成的 dummy cookie 17 | - 删除所有 `Sec-Fetch-*` 请求头 18 | - 现在 elective 客户端可以设置存活时间,到期后 elective 会话将自动登出,以期解决 [Issue #47](https://github.com/zhongxinghong/PKUAutoElective/issues/47) 19 | - 每次重新登录 elective 时都将更换 User-Agent 20 | - 现在可以选择是否打印完整的互斥规则列表 21 | - 修正了配置文件和用户手册中关于 `supply_cancel_page` 选项的说明 22 | - 修改配置文件中 `elective_client_pool_size` 选项的默认值为 `2` 23 | - 程序启动时将首先打印重要的配置信息 24 | - 对 elective 可能返回的错误页面加以捕获,以期解决 [Issue #44](https://github.com/zhongxinghong/PKUAutoElective/issues/44) 25 | - 修改 sso_login 接口的参数名 `rand` 为 `_rand` 26 | - 更改识别 elective 响应结果的正则表达式,以确保包含空格的课程名也能被正确解析 27 | 28 | 29 | v4.0.1 (2020-05-30) 30 | ------------------- 31 | - 修复了 IAAA 登录报 500 状态码的问题,详见 [Issue #35](https://github.com/zhongxinghong/PKUAutoElective/issues/35) 32 | 33 | 34 | v3.0.9 (2020-02-20) 35 | ------------------- 36 | - 对相传可能出现的莫名其妙退课的情况做了防护,详见 [Issue #30](https://github.com/zhongxinghong/PKUAutoElective/issues/30) 37 | 38 | 39 | v3.0.8 (2020-02-20) 40 | ------------------- 41 | - 在 elective 两个刷新接口的 headers 中添加了 `Cache-Control: max-age=0` 42 | - 现在可以为课程定义延迟规则,详见 [Issue #28](https://github.com/zhongxinghong/PKUAutoElective/issues/28) 43 | - 修改了部分代码风格 44 | 45 | 46 | v3.0.7 (2020-02-18) 47 | ------------------- 48 | - 现在可以识别出因一学期选多门体育课而收到的来自选课网的错误提示 49 | - 同一回合中出现多门可选的课,并且低优先级待选的课与高优先级已选的课因为 mutex rules 冲突,那么低优先级的课将会被提前忽略,详见 [Issue #25](https://github.com/zhongxinghong/PKUAutoElective/issues/25) 50 | 51 | 52 | v3.0.6 (2020-02-18) 53 | ------------------- 54 | - 修正了 `config.ini` 注释中把 `班号` 写成 `课号` 的笔误 55 | - 选课网有的时候会突然显示某门课的已选人数为 0,而实际选课人数已满,此时会报一个 `Unknown tips` 的异常,现在程序可以对这种情况做出识别 56 | 57 | 58 | v3.0.5 (2020-02-17) 59 | ------------------- 60 | - 现在通过 `config.ini` 定义的课程列表可以像原来那样保持其在文件中的先后顺序,如果在同一循环中遇到同一列表中有多个课可选,将会按照从上往下的顺序依次提交 61 | - 现在会捕获 `Ctrl + C` 在 `main.py` 中引发的 `KeyboardInterrupt`,这样 `Ctrl + C` 将不会再打印 traceback 而是正常退出 62 | 63 | 64 | v3.0.4 (2020-02-17) 65 | ------------------- 66 | - 修改了 `TypeError: argmax() got an unexcepted keyword argument 'axis'` 的错误 67 | 68 | 69 | v3.0.3 (2020-02-17) 70 | ------------------- 71 | - 修改了 iaaa 和 elective 相关接口的请求细节,包括更换某些 url,修改 headers,修改替换 scheme 为 https 等 72 | - 修复了相同 Course 调用 `__eq__` 和 `__hash__` 得到不同值的 bug 73 | - 修复了 `assert self._status is not None` 引发的 `AssertionError` 74 | - 修复了 `mutexes` 在无规则时仍然 print 列表的 bug 75 | 76 | 77 | v3.0.2 beta (2020-02-17) 78 | ------------------- 79 | - 修复了 Windows 下 `Ctrl + C` 失效的问题 80 | 81 | 82 | v3.0.1 beta (2020-02-17) 83 | ------------------- 84 | - 改用 pytorch 训练的 CNN 模型进行验证码识别,提高了识别的准确率 85 | - 优化了验证码图像处理函数的执行效率 86 | - 将多进程架构重写为多线程架构,监控进程现在变为和主进程下的一个子线程 87 | - 允许自定义 User-Agent 列表 88 | - 配置文件中 `student_ID` 键名改成 `student_id` 89 | - 不再使用 `course.csv` 文件配置课程列表,而是统一归入 `config.ini` 中 90 | - 允许用户自定义互斥规则,详见 [Issue #8](https://github.com/zhongxinghong/PKUAutoElective/issues/8) 91 | - 重新设计了 monitor 的路由 92 | - 现在 monitor 不会在 iaaa_loop / elective_loop 正常退出的时候自动退出 93 | - 修改了多处代码风格和设计细节,删除了大量冗余设计 94 | 95 | 96 | v2.1.1 (2019-09-13) 97 | ------------------- 98 | - 修复了 `OperationFailedError` 使用错误的父类派生而导致不能正常初始化的问题 99 | 100 | 101 | v2.1.0 (2019-09-13) 102 | ------------------- 103 | - 修复了 Windows 下自定义参数不生效的问题 104 | 105 | 106 | v2.0.9 (2019-09-12) 107 | ------------------- 108 | - 对 v2.0.8 版本的完善,现在删除了与 `signal` 相关的逻辑,统一了两种运行模式下主进程退出的方式,确保了 `Ctrl + C` 的信号和子进程内部发出的终止信号均能使主进程正常退出 109 | 110 | 111 | v2.0.8 (2019-09-11) 112 | ------------------- 113 | - 对 v2.0.6 版本的完善,该版本在不带 `--with-monitor` 运行的情况下,也可以正确地接收到来自 `Ctrl + C` 的终止命令 114 | 115 | 116 | v2.0.7 (2019-09-11) 117 | ------------------- 118 | - 为 monitor 添加了与错误捕获记录相关的路由 119 | 120 | 121 | v2.0.6 (2019-09-11) 122 | ------------------- 123 | - 修复了在 Windows 下 `Ctrl + C` 无法退出程序的问题 124 | 125 | 126 | v2.0.5 (2019-09-11) 127 | ------------------- 128 | - 可以捕获 IAAA 登录时的密码错误和多次登录失败导致账号已被封禁的错误 129 | - 完善了对多进程/线程下进程死亡的处理,以确保主进程在遇到错误时可以完全退出 130 | - 现在 monitor 进程会在 loop 进程结束后自动退出 131 | 132 | 133 | v2.0.4 (2019-09-10) 134 | ------------------- 135 | - elective 客户端采用多会话机制 136 | 137 | 138 | v2.0.3 (2019-09-09) 139 | ------------------- 140 | - 可以捕获来自 IAAA 的错误 141 | - 丰富了部分错误的提示信息 142 | 143 | 144 | v2.0.2 (2019.09.09) 145 | ------------------- 146 | - 添加了对处于选课计划第一页之后的课程的支持 147 | 148 | 149 | v2.0.1 (2019.09.09) 150 | ------------------- 151 | - 代码重构,删减大量冗余设计 152 | - 新增监视器进程,开启后可以通过特定端口监听运行状态 153 | - 添加多账号支持,去除 cookies / token 本地共享的逻辑,并可以手动指定 config.ini / course.csv 文件的路径 154 | - 修复了在一些情况下会话无法保持的错误 155 | - 可以捕获几个新遇到的系统异常/错误提示 156 | - 美化了终端的输出格式 157 | 158 | 159 | v1.0.4 (2019.02.22) 160 | ------------------- 161 | - 修复了一处语法错误,位于 **main.py** 第 216-235 行的 `ignored.append` 处 162 | - 纠正了一些变量名的拼写错误 163 | - 可以捕获多选英语课引起的错误 164 | 165 | 166 | v1.0.3 (2019.02.20) 167 | ------------------- 168 | - 兼容了本科生辅双的登录界面,主修身份选课测试通过,辅双身份选课支持第一页 169 | - 可以捕获共享回话引起的系统异常 170 | - 可以捕获辅双登录无验证信息的系统异常 171 | 172 | 173 | v1.0.2 (2019.02.19) 174 | ------------------- 175 | - 研究生选课测试通过 176 | - 兼容了部分页面没有 `.//head/title` 标签的情况 177 | - 修改 `Course` 类的 `classNo` 属性为 int 类型,确保 `01` 与 `1` 为同班号 178 | - 主程序开始的第一个循环回合更改为首先主动登录一次,以免旧缓存导致无法切换账号 179 | - 重新登录时会率先删除客户端已有的 cookies ,修复了一次重新登录需要花费两回合的问题 180 | - 更改单一 `User-Agent` 为 `User-Agent` 池 181 | - 可以捕获课程互斥引起的错误提示 182 | 183 | 184 | v1.0.1 (2019.02.18) 185 | ------------------- 186 | - 上线版本,支持非辅双本科生选课 187 | 188 | -------------------------------------------------------------------------------- /autoelective/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: exceptions.py 4 | # modified: 2019-09-13 5 | 6 | __all__ = [ 7 | 8 | "AutoElectiveException", 9 | 10 | "UserInputException", 11 | 12 | "AutoElectiveClientException", 13 | 14 | "StatusCodeError", 15 | "ServerError", 16 | "OperationFailedError", 17 | "UnexceptedHTMLFormat", 18 | 19 | "IAAAException", 20 | "IAAANotSuccessError", 21 | "IAAAIncorrectPasswordError", 22 | "IAAAForbiddenError", 23 | 24 | "ElectiveException", 25 | 26 | "SystemException", 27 | "CaughtCheatingError", 28 | "InvalidTokenError", 29 | "SessionExpiredError", 30 | "NotInOperationTimeError", 31 | "CourseIndexError", 32 | "CaptchaError", 33 | "NoAuthInfoError", 34 | "SharedSessionError", 35 | "NotAgreedToSelectionAgreement", 36 | 37 | "TipsException", 38 | "ElectionSuccess", 39 | "ElectionRepeatedError", 40 | "TimeConflictError", 41 | "OperationTimeoutError", 42 | "ElectionPermissionError", 43 | "ElectionFailedError", 44 | "CreditsLimitedError", 45 | "MutexCourseError", 46 | "MultiEnglishCourseError", 47 | "ExamTimeConflictError", 48 | "QuotaLimitedError", 49 | "MultiPECourseError", 50 | 51 | ] 52 | 53 | 54 | class AutoElectiveException(Exception): 55 | """ Abstract Exception for AutoElective """ 56 | 57 | class UserInputException(AutoElectiveException, ValueError): 58 | """ 由于用户的输入数据不当而引发的错误 """ 59 | 60 | 61 | class AutoElectiveClientException(AutoElectiveException): 62 | 63 | code = -1 64 | desc = "AutoElectiveException" 65 | 66 | def __init__(self, *args, **kwargs): 67 | response = kwargs.pop("response", None) 68 | self.response = response 69 | msg = "[%d] %s" % ( 70 | self.__class__.code, 71 | kwargs.pop("msg", self.__class__.desc) 72 | ) 73 | super().__init__(msg, *args, **kwargs) 74 | 75 | 76 | class StatusCodeError(AutoElectiveClientException): 77 | code = 101 78 | desc = "response.status_code != 200" 79 | 80 | def __init__(self, *args, **kwargs): 81 | r = kwargs.get("response") 82 | if r is not None and "msg" not in kwargs: 83 | kwargs["msg"] = "%s. response status code: %s" % (self.__class__.code, r.status_code) 84 | super().__init__(*args, **kwargs) 85 | 86 | class ServerError(AutoElectiveClientException): 87 | code = 102 88 | desc = r"response.status_code in (500, 501, 502, 503)" 89 | 90 | def __init__(self, *args, **kwargs): 91 | r = kwargs.get("response") 92 | if r is not None and "msg" not in kwargs: 93 | kwargs["msg"] = "%s. response status_code: %s" % (self.__class__.code, r.status_code) 94 | super().__init__(*args, **kwargs) 95 | 96 | class OperationFailedError(AutoElectiveClientException): 97 | code = 103 98 | desc = r"some operations failed for unknown reasons" 99 | 100 | class UnexceptedHTMLFormat(AutoElectiveClientException): 101 | code = 104 102 | desc = r"unable to parse HTML content" 103 | 104 | 105 | class IAAAException(AutoElectiveClientException): 106 | code = 200 107 | desc = "IAAAException" 108 | 109 | 110 | class IAAANotSuccessError(IAAAException): 111 | code = 210 112 | desc = "response.json()['success'] == False" 113 | 114 | def __init__(self, *args, **kwargs): 115 | r = kwargs.get("response") 116 | if r is not None and "msg" not in kwargs: 117 | kwargs["msg"] = "%s. response JSON: %s" % (self.__class__.code, r.json()) 118 | super().__init__(*args, **kwargs) 119 | 120 | class IAAAIncorrectPasswordError(IAAANotSuccessError): 121 | code = 211 122 | desc = "User ID or Password is incorrect" 123 | 124 | class IAAAForbiddenError(IAAANotSuccessError): 125 | code = 212 126 | desc = "You are FORBIDDEN. Please sign in after a half hour" 127 | 128 | 129 | class ElectiveException(AutoElectiveClientException): 130 | code = 300 131 | desc = "ElectiveException" 132 | 133 | 134 | class SystemException(ElectiveException): 135 | code = 310 136 | desc = "系统异常" 137 | 138 | class CaughtCheatingError(SystemException): 139 | code = 311 140 | desc = "请不要用刷课机刷课,否则会受到学校严厉处分!" # 没有设 referer 141 | 142 | class InvalidTokenError(SystemException): 143 | code = 312 144 | desc = "Token无效" # sso_login 时出现,在上次登录前发生异地登录,缓存 token 失效 145 | 146 | class SessionExpiredError(SystemException): 147 | code = 313 148 | desc = "您尚未登录或者会话超时,请重新登录." # 相当于 token 失效 149 | 150 | class NotInOperationTimeError(SystemException): 151 | code = 314 152 | desc = "不在操作时段" 153 | 154 | class CourseIndexError(SystemException): 155 | code = 315 156 | desc = "索引错误。" 157 | 158 | class CaptchaError(SystemException): 159 | code = 316 160 | desc = "验证码不正确。" 161 | 162 | class NoAuthInfoError(SystemException): 163 | code = 317 164 | desc = "无验证信息。" # 仅辅双登录时会出现 165 | 166 | class SharedSessionError(SystemException): 167 | code = 318 168 | desc = "你与他人共享了回话,请退出浏览器重新登录。" 169 | 170 | class NotAgreedToSelectionAgreement(SystemException): 171 | code = 319 172 | desc = "只有同意选课协议才可以继续选课! " 173 | 174 | 175 | class TipsException(ElectiveException): 176 | code = 330 177 | desc = "TipsException" 178 | 179 | class ElectionSuccess(TipsException): 180 | code = 331 181 | desc = "补选课程成功,请查看已选上列表确认,并查看选课结果。" 182 | 183 | class ElectionRepeatedError(TipsException): 184 | code = 332 185 | desc = "您已经选过该课程了。" 186 | 187 | class TimeConflictError(TipsException): 188 | code = 333 189 | desc = "上课时间冲突" 190 | 191 | class OperationTimeoutError(TipsException): 192 | code = 334 193 | desc = "对不起,超时操作,请重新登录。" 194 | 195 | class ElectionPermissionError(TipsException): 196 | code = 335 197 | desc = "该课程在补退选阶段开始后的约一周开放选课" 198 | 199 | class ElectionFailedError(TipsException): 200 | code = 336 201 | desc = "选课操作失败,请稍后再试。" 202 | 203 | class CreditsLimitedError(TipsException): 204 | code = 327 205 | desc = "您本学期所选课程的总学分已经超过规定学分上限。" 206 | 207 | class MutexCourseError(TipsException): 208 | code = 328 209 | desc = "只能选其一门。" 210 | 211 | class MultiEnglishCourseError(TipsException): 212 | code = 329 213 | desc = "学校规定每学期只能修一门英语课,因此您不能选择该课。" 214 | 215 | class ExamTimeConflictError(TipsException): 216 | code = 330 217 | desc = "考试时间冲突" 218 | 219 | class QuotaLimitedError(TipsException): 220 | code = 331 221 | desc = "该课程选课人数已满。" 222 | 223 | class MultiPECourseError(TimeoutError): 224 | code = 332 225 | desc = "学校规定每学期只能修一门体育课。" 226 | -------------------------------------------------------------------------------- /autoelective/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: config.py 4 | # modified: 2019-09-10 5 | 6 | import os 7 | import re 8 | from configparser import RawConfigParser, DuplicateSectionError 9 | from collections import OrderedDict 10 | from .environ import Environ 11 | from .course import Course 12 | from .rule import Mutex, Delay 13 | from .utils import Singleton 14 | from .const import DEFAULT_CONFIG_INI 15 | from .exceptions import UserInputException 16 | 17 | _reNamespacedSection = re.compile(r'^\s*(?P[^:]+?)\s*:\s*(?P[^,]+?)\s*$') 18 | _reCommaSep = re.compile(r'\s*,\s*') 19 | 20 | environ = Environ() 21 | 22 | 23 | class BaseConfig(object): 24 | 25 | def __init__(self, config_file=None): 26 | if self.__class__ is __class__: 27 | raise NotImplementedError 28 | file = os.path.normpath(os.path.abspath(config_file)) 29 | if not os.path.exists(file): 30 | raise FileNotFoundError("Config file was not found: %s" % file) 31 | self._config = RawConfigParser() 32 | self._config.read(file, encoding="utf-8-sig") 33 | 34 | def get(self, section, key): 35 | return self._config.get(section, key) 36 | 37 | def getint(self, section, key): 38 | return self._config.getint(section, key) 39 | 40 | def getfloat(self, section, key): 41 | return self._config.getfloat(section, key) 42 | 43 | def getboolean(self, section, key): 44 | return self._config.getboolean(section, key) 45 | 46 | def getdict(self, section, options): 47 | assert isinstance(options, (list, tuple, set)) 48 | d = dict(self._config.items(section)) 49 | if not all( k in d for k in options ): 50 | raise UserInputException("Incomplete course in section %r, %s must all exist." % (section, options)) 51 | return d 52 | 53 | def getlist(self, section, option, *args, **kwargs): 54 | v = self.get(section, option, *args, **kwargs) 55 | return _reCommaSep.split(v) 56 | 57 | def ns_sections(self, ns): 58 | ns = ns.strip() 59 | ns_sects = OrderedDict() # { id: str(section) } 60 | for s in self._config.sections(): 61 | mat = _reNamespacedSection.match(s) 62 | if mat is None: 63 | continue 64 | if mat.group('ns') != ns: 65 | continue 66 | id_ = mat.group('id') 67 | if id_ in ns_sects: 68 | raise DuplicateSectionError("%s:%s" % (ns, id_)) 69 | ns_sects[id_] = s 70 | return [ (id_, s) for id_, s in ns_sects.items() ] # [ (id, str(section)) ] 71 | 72 | 73 | class AutoElectiveConfig(BaseConfig, metaclass=Singleton): 74 | 75 | def __init__(self): 76 | super().__init__(environ.config_ini or DEFAULT_CONFIG_INI) 77 | 78 | ## Constraints 79 | 80 | ALLOWED_IDENTIFY = ("bzx","bfx") 81 | 82 | ## Model 83 | 84 | # [user] 85 | 86 | @property 87 | def iaaa_id(self): 88 | return self.get("user", "student_id") 89 | 90 | @property 91 | def iaaa_password(self): 92 | return self.get("user", "password") 93 | 94 | @property 95 | def is_dual_degree(self): 96 | return self.getboolean("user", "dual_degree") 97 | 98 | @property 99 | def identity(self): 100 | return self.get("user", "identity").lower() 101 | 102 | # [client] 103 | 104 | @property 105 | def supply_cancel_page(self): 106 | return self.getint("client", "supply_cancel_page") 107 | 108 | @property 109 | def refresh_interval(self): 110 | return self.getfloat("client", "refresh_interval") 111 | 112 | @property 113 | def refresh_random_deviation(self): 114 | return self.getfloat("client", "random_deviation") 115 | 116 | @property 117 | def iaaa_client_timeout(self): 118 | return self.getfloat("client", "iaaa_client_timeout") 119 | 120 | @property 121 | def elective_client_timeout(self): 122 | return self.getfloat("client", "elective_client_timeout") 123 | 124 | @property 125 | def elective_client_pool_size(self): 126 | return self.getint("client", "elective_client_pool_size") 127 | 128 | @property 129 | def elective_client_max_life(self): 130 | return self.getint("client", "elective_client_max_life") 131 | 132 | @property 133 | def login_loop_interval(self): 134 | return self.getfloat("client", "login_loop_interval") 135 | 136 | @property 137 | def is_print_mutex_rules(self): 138 | return self.getboolean("client", "print_mutex_rules") 139 | 140 | @property 141 | def is_debug_print_request(self): 142 | return self.getboolean("client", "debug_print_request") 143 | 144 | @property 145 | def is_debug_dump_request(self): 146 | return self.getboolean("client", "debug_dump_request") 147 | 148 | # [monitor] 149 | 150 | @property 151 | def monitor_host(self): 152 | return self.get("monitor", "host") 153 | 154 | @property 155 | def monitor_port(self): 156 | return self.getint("monitor", "port") 157 | 158 | # [course] 159 | 160 | @property 161 | def courses(self): 162 | cs = OrderedDict() # { id: Course } 163 | rcs = {} 164 | for id_, s in self.ns_sections('course'): 165 | d = self.getdict(s, ('name','class','school')) 166 | d.update(class_no=d.pop('class')) 167 | c = Course(**d) 168 | cs[id_] = c 169 | rid = rcs.get(c) 170 | if rid is not None: 171 | raise UserInputException("Duplicated courses in sections 'course:%s' and 'course:%s'" % (rid, id_)) 172 | rcs[c] = id_ 173 | return cs 174 | 175 | # [mutex] 176 | 177 | @property 178 | def mutexes(self): 179 | ms = OrderedDict() # { id: Mutex } 180 | for id_, s in self.ns_sections('mutex'): 181 | lst = self.getlist(s, 'courses') 182 | ms[id_] = Mutex(lst) 183 | return ms 184 | 185 | # [delay] 186 | 187 | @property 188 | def delays(self): 189 | ds = OrderedDict() # { id: Delay } 190 | cid_id = {} # { cid: id } 191 | for id_, s in self.ns_sections('delay'): 192 | cid = self.get(s, 'course') 193 | threshold = self.getint(s, 'threshold') 194 | if not threshold > 0: 195 | raise UserInputException("Invalid threshold %d in 'delay:%s', threshold > 0 must be satisfied" % (threshold, id_)) 196 | id0 = cid_id.get(cid) 197 | if id0 is not None: 198 | raise UserInputException("Duplicated delays of 'course:%s' in 'delay:%s' and 'delay:%s'" % (cid, id0, id_)) 199 | cid_id[cid] = id_ 200 | ds[id_] = Delay(cid, threshold) 201 | return ds 202 | 203 | ## Method 204 | 205 | def check_identify(self, identity): 206 | limited = self.__class__.ALLOWED_IDENTIFY 207 | if identity not in limited: 208 | raise ValueError("unsupported identity %s for elective, identity must be in %s" % (identity, limited)) 209 | 210 | def check_supply_cancel_page(self, page): 211 | if page <= 0: 212 | raise ValueError("supply_cancel_page must be positive number, not %s" % page) 213 | 214 | def get_user_subpath(self): 215 | if self.is_dual_degree: 216 | identity = self.identity 217 | self.check_identify(identity) 218 | if identity == "bfx": 219 | return "%s_%s" % (self.iaaa_id, identity) 220 | return self.iaaa_id 221 | -------------------------------------------------------------------------------- /autoelective/hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: hook.py 4 | # modified: 2019-09-11 5 | 6 | import os 7 | import re 8 | import time 9 | from urllib.parse import quote, urlparse 10 | from .logger import ConsoleLogger 11 | from .config import AutoElectiveConfig 12 | from .parser import get_tree_from_response, get_title, get_errInfo, get_tips 13 | from .utils import pickle_gzip_dump 14 | from .const import REQUEST_LOG_DIR 15 | from .exceptions import * 16 | from ._internal import mkdir 17 | 18 | cout = ConsoleLogger("hook") 19 | config = AutoElectiveConfig() 20 | 21 | _USER_REQUEST_LOG_DIR = os.path.join(REQUEST_LOG_DIR, config.get_user_subpath()) 22 | mkdir(_USER_REQUEST_LOG_DIR) 23 | 24 | _regexErrorOperatingTime = re.compile(r'目前不是(.*?)时间,因此不能进行相应操作。') 25 | _regexElectionSuccess = re.compile(r'补选(或者候补)课程(.*)成功,请查看已选上列表确认,并查看选课结果。') 26 | _regexMutex = re.compile(r'(.+)与(.+)只能选其一门。') 27 | 28 | _DUMMY_HOOK = {"response": []} 29 | 30 | 31 | def get_hooks(*fn): 32 | return {"response": fn} 33 | 34 | def merge_hooks(*hooklike): 35 | funcs = [] 36 | for hook in hooklike: 37 | if isinstance(hook, dict): 38 | funcs.extend(hook["response"]) 39 | elif callable(hook): # function 40 | funcs.append(hook) 41 | else: 42 | raise TypeError(hook) 43 | return get_hooks(*funcs) 44 | 45 | def with_etree(r, **kwargs): 46 | r._tree = get_tree_from_response(r) 47 | 48 | def del_etree(r, **kwargs): 49 | del r._tree 50 | 51 | 52 | def check_status_code(r, **kwargs): 53 | if r.status_code != 200: 54 | if r.status_code in (301,302,304): 55 | pass 56 | elif r.status_code in (500,501,502,503): 57 | raise ServerError(response=r) 58 | else: 59 | raise StatusCodeError(response=r) 60 | 61 | 62 | def check_iaaa_success(r, **kwargs): 63 | respJson = r.json() 64 | 65 | if not respJson.get("success", False): 66 | try: 67 | errors = respJson["errors"] 68 | code = errors["code"] 69 | msg = errors["msg"] 70 | except Exception as e: 71 | cout.error(e) 72 | cout.info("Unable to get errcode/errmsg from response JSON") 73 | pass 74 | else: 75 | if code == "E01": 76 | raise IAAAIncorrectPasswordError(response=r, msg=msg) 77 | elif code == "E21": 78 | raise IAAAForbiddenError(response=r, msg=msg) 79 | 80 | raise IAAANotSuccessError(response=r) 81 | 82 | 83 | def check_elective_title(r, **kwargs): 84 | assert hasattr(r, "_tree") 85 | 86 | title = get_title(r._tree) 87 | if title is None: 88 | return 89 | 90 | try: 91 | if title in ("系统异常", "系统提示"): 92 | err = get_errInfo(r._tree) 93 | 94 | if err == "token无效": # sso_login 时出现 95 | raise InvalidTokenError(response=r) 96 | 97 | elif err == "您尚未登录或者会话超时,请重新登录.": 98 | raise SessionExpiredError(response=r) 99 | 100 | elif err == "请不要用刷课机刷课,否则会受到学校严厉处分!": 101 | raise CaughtCheatingError(response=r) 102 | 103 | elif err == "索引错误。": 104 | raise CourseIndexError(response=r) 105 | 106 | elif err == "验证码不正确。": 107 | raise CaptchaError(response=r) 108 | 109 | elif err == "无验证信息。": 110 | raise NoAuthInfoError(response=r) 111 | 112 | elif err == "你与他人共享了回话,请退出浏览器重新登录。": 113 | raise SharedSessionError(response=r) 114 | 115 | elif err == "只有同意选课协议才可以继续选课!": 116 | raise NotAgreedToSelectionAgreement(response=r) 117 | 118 | elif _regexErrorOperatingTime.search(err): 119 | raise NotInOperationTimeError(response=r, msg=err) 120 | 121 | else: 122 | raise SystemException(response=r, msg=err) 123 | 124 | except Exception as e: 125 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 126 | r.request._client.persist_cookies(r) 127 | raise e 128 | 129 | 130 | def check_elective_tips(r, **kwargs): 131 | assert hasattr(r, "_tree") 132 | tips = get_tips(r._tree) 133 | 134 | try: 135 | 136 | if tips is None: 137 | return 138 | 139 | elif tips == "您已经选过该课程了。": 140 | raise ElectionRepeatedError(response=r) 141 | 142 | elif tips == "对不起,超时操作,请重新登录。": 143 | raise OperationTimeoutError(response=r) 144 | 145 | elif tips == "选课操作失败,请稍后再试。": 146 | raise ElectionFailedError(response=r) 147 | 148 | elif tips == "您本学期所选课程的总学分已经超过规定学分上限。": 149 | raise CreditsLimitedError(response=r) 150 | 151 | elif tips == "学校规定每学期只能修一门英语课,因此您不能选择该课。": 152 | raise MultiEnglishCourseError(response=r) 153 | 154 | elif tips.startswith("上课时间冲突"): 155 | raise TimeConflictError(response=r, msg=tips) 156 | 157 | elif tips.startswith("考试时间冲突"): 158 | raise ExamTimeConflictError(response=r, msg=tips) 159 | 160 | elif tips.startswith("该课程在补退选阶段开始后的约一周开放选课"): # 这个可能需要根据当学期情况进行修改 161 | raise ElectionPermissionError(response=r, msg=tips) 162 | 163 | elif tips.startswith("该课程选课人数已满"): 164 | raise QuotaLimitedError(response=r, msg=tips) 165 | 166 | elif tips.startswith("学校规定每学期只能修一门体育课"): 167 | raise MultiPECourseError(response=r, msg=tips) 168 | 169 | elif _regexElectionSuccess.search(tips): 170 | raise ElectionSuccess(response=r, msg=tips) 171 | 172 | elif _regexMutex.search(tips): 173 | raise MutexCourseError(response=r, msg=tips) 174 | 175 | else: 176 | cout.warning("Unknown tips: %s" % tips) 177 | # raise TipsException(response=r, msg=tips) 178 | 179 | except Exception as e: 180 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 181 | r.request._client.persist_cookies(r) 182 | raise e 183 | 184 | 185 | def debug_print_request(r, **kwargs): 186 | if not config.is_debug_print_request: 187 | return 188 | cout.debug("> %s %s" % (r.request.method, r.url)) 189 | cout.debug("> Headers:") 190 | for k, v in r.request.headers.items(): 191 | cout.debug("%s: %s" % (k, v)) 192 | cout.debug("> Body:") 193 | cout.debug(r.request.body) 194 | cout.debug("> Response Headers:") 195 | for k, v in r.headers.items(): 196 | cout.debug("%s: %s" % (k, v)) 197 | cout.debug("") 198 | 199 | 200 | def _dump_request(r): 201 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 202 | client = r.request._client 203 | r.request._client = None # don't save client object 204 | 205 | hooks = r.request.hooks 206 | r.request.hooks = _DUMMY_HOOK # don't save hooks array 207 | 208 | timestamp = time.strftime("%Y-%m-%d_%H.%M.%S%z") 209 | basename = quote(urlparse(r.url).path, '') 210 | filename = "%s.%s.gz" % (timestamp, basename) # put timestamp first 211 | file = os.path.normpath(os.path.abspath(os.path.join(_USER_REQUEST_LOG_DIR, filename))) 212 | 213 | pickle_gzip_dump(r, file) 214 | 215 | # restore objects defined by autoelective package 216 | if "_client" in r.request.__dict__: 217 | r.request._client = client 218 | r.request.hooks = hooks 219 | 220 | return file 221 | 222 | 223 | def debug_dump_request(r, **kwargs): 224 | if not config.is_debug_dump_request: 225 | return 226 | file = _dump_request(r) 227 | cout.debug("Dump request %s to %s" % (r.url, file)) 228 | -------------------------------------------------------------------------------- /autoelective/elective.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: elective.py 4 | # modified: 2021-09-11 5 | 6 | import time 7 | import string 8 | import random 9 | from urllib.parse import quote 10 | from .client import BaseClient 11 | from .hook import get_hooks, debug_dump_request, debug_print_request, check_status_code, with_etree,\ 12 | check_elective_title, check_elective_tips 13 | from .const import ElectiveURL 14 | 15 | _hooks_check_status_code = get_hooks( 16 | # debug_dump_request, 17 | debug_print_request, 18 | check_status_code, 19 | ) 20 | 21 | _hooks_check_title = get_hooks( 22 | debug_dump_request, 23 | debug_print_request, 24 | check_status_code, 25 | with_etree, 26 | check_elective_title, 27 | ) 28 | 29 | _hooks_check_tips = get_hooks( 30 | debug_dump_request, 31 | debug_print_request, 32 | check_status_code, 33 | with_etree, 34 | check_elective_title, 35 | check_elective_tips, 36 | ) 37 | 38 | def _get_headers_with_referer(kwargs, referer=ElectiveURL.HelpController): 39 | headers = kwargs.pop("headers", {}) 40 | headers["Referer"] = referer 41 | return headers 42 | 43 | 44 | class ElectiveClient(BaseClient): 45 | 46 | default_headers = { 47 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 48 | "Accept-Encoding": "gzip, deflate, br", 49 | "Accept-Language": "en-US,en;q=0.9", 50 | "Host": ElectiveURL.Host, 51 | "Upgrade-Insecure-Requests": "1", 52 | "Connection": "keep-alive", 53 | } 54 | 55 | def __init__(self, id, **kwargs): 56 | super().__init__(**kwargs) 57 | self._id = id 58 | self._expired_time = -1 59 | 60 | @property 61 | def id(self): 62 | return self._id 63 | 64 | @property 65 | def expired_time(self): 66 | return self._expired_time 67 | 68 | @property 69 | def is_expired(self): 70 | if self._expired_time == -1: 71 | return False 72 | return int(time.time()) > self._expired_time 73 | 74 | @property 75 | def has_logined(self): 76 | return len(self._session.cookies) > 0 77 | 78 | def set_expired_time(self, expired_time): 79 | self._expired_time = expired_time 80 | 81 | def sso_login(self, token, **kwargs): 82 | dummy_cookie = "JSESSIONID=%s!%d" % ( 83 | ''.join(random.choice(string.digits + string.ascii_letters) for _ in range(52)), 84 | random.randint(184960435, 1984960435), 85 | ) 86 | headers = kwargs.pop("headers", {}) # no Referer 87 | headers["Cookie"] = dummy_cookie # 必须要指定一个 Cookie 否则报 101 status_code 88 | r = self._get( 89 | url=ElectiveURL.SSOLogin, 90 | params={ 91 | "_rand": str(random.random()), 92 | "token": token, 93 | }, 94 | headers=headers, 95 | hooks=_hooks_check_title, 96 | **kwargs, 97 | ) 98 | return r 99 | 100 | def sso_login_dual_degree(self, sida, sttp, referer, **kwargs): 101 | assert len(sida) == 32 102 | assert sttp in ("bzx", "bfx") 103 | headers = kwargs.pop("headers", {}) # no Referer 104 | r = self._get( 105 | url=ElectiveURL.SSOLogin, 106 | params={ 107 | "sida": sida, 108 | "sttp": sttp, 109 | }, 110 | headers=headers, 111 | hooks=_hooks_check_title, 112 | **kwargs, 113 | ) 114 | return r 115 | 116 | def logout(self, **kwargs): 117 | headers = _get_headers_with_referer(kwargs) 118 | r = self._get( 119 | url=ElectiveURL.Logout, 120 | headers=headers, 121 | hooks=_hooks_check_title, 122 | **kwargs, 123 | ) 124 | return r 125 | 126 | def get_HelpController(self, **kwargs): 127 | """ 帮助 """ 128 | r = self._get( 129 | url=ElectiveURL.HelpController, 130 | hooks=_hooks_check_title, 131 | **kwargs, 132 | ) # 无 Referer 133 | return r 134 | 135 | def get_ShowResults(self, **kwargs): 136 | """ 选课结果 """ 137 | headers = _get_headers_with_referer(kwargs) 138 | r = self._get( 139 | url=ElectiveURL.ShowResults, 140 | headers=headers, 141 | hooks=_hooks_check_title, 142 | **kwargs, 143 | ) 144 | return r 145 | 146 | def get_SupplyCancel(self, username, **kwargs): 147 | """ 补退选 """ 148 | headers = _get_headers_with_referer(kwargs) 149 | headers["Cache-Control"] = "max-age=0" 150 | r = self._get( 151 | url=ElectiveURL.SupplyCancel+"?xh="+str(username), 152 | headers=headers, 153 | hooks=_hooks_check_title, 154 | **kwargs, 155 | ) 156 | return r 157 | 158 | def get_supplement(self, username, page=1, **kwargs): 159 | """ 补退选(第二页及以后) """ 160 | assert page > 0 161 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel+"?xh="+str(username)) 162 | headers["Cache-Control"] = "max-age=0" 163 | r = self._get( 164 | url=ElectiveURL.Supplement, 165 | params={ 166 | "netui_pagesize": "electableListGrid;20", 167 | "netui_row": "electableListGrid;%s" % ( (page - 1) * 20 ), 168 | }, 169 | headers=headers, 170 | hooks=_hooks_check_title, 171 | **kwargs, 172 | ) 173 | return r 174 | 175 | def get_DrawServlet(self, **kwargs): 176 | """ 获得验证码 """ 177 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 178 | r = self._get( 179 | url=ElectiveURL.DrawServlet, 180 | params={ 181 | "Rand": str(random.random() * 10000), 182 | }, 183 | headers=headers, 184 | hooks=_hooks_check_status_code, 185 | **kwargs, 186 | ) 187 | return r 188 | 189 | def get_Validate(self, username, code, **kwargs): 190 | """ 验证用户输入的验证码 """ 191 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 192 | headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" 193 | headers["Accept-Encoding"] = "gzip, deflate, br" 194 | headers["Accept-Language"] = "zh-CN,zh;q=0.9" 195 | headers["X-Requested-With"] = "XMLHttpRequest" 196 | r = self._post( 197 | url=ElectiveURL.Validate, 198 | data={ 199 | "xh": username, 200 | "validCode": code, 201 | }, 202 | headers=headers, 203 | hooks=_hooks_check_status_code, 204 | **kwargs, 205 | ) 206 | return r 207 | 208 | def get_ElectSupplement(self, href, **kwargs): 209 | """ 补选一门课 """ 210 | 211 | if "/supplement/electSupplement.do" not in href: 212 | raise RuntimeError( 213 | "If %r is really a 'electSupplement' href, it would certainly contains '/supplement/electSupplement.do'. " 214 | "If you see this error, that means maybe something terrible will happpen ! Please raise an issue at " 215 | "https://github.com/zhongxinghong/PKUAutoElective/issues" % href 216 | ) 217 | 218 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 219 | r = self._get( 220 | url="%s://%s%s" % (ElectiveURL.Scheme, ElectiveURL.Host, href), 221 | headers=headers, 222 | hooks=_hooks_check_tips, 223 | **kwargs, 224 | ) 225 | return r 226 | -------------------------------------------------------------------------------- /README_origin.md: -------------------------------------------------------------------------------- 1 | # PKUAutoElective 2 | 3 | 北大选课网 **补退选** 阶段自动选课小工具 v6.0.0 (2021.03.12) 4 | 5 | 目前支持 `本科生(含辅双)` 和 `研究生` 选课 6 | 7 | ## 停更说明 8 | 9 | 感谢大家两年多来对这个项目的支持!我已经大四了,这学期结束后我将毕业,那之后我将不再更新这个项目。由于测试较为困难,这个项目一直以来基本都没有接受过 PR,如果有更改和扩充功能的需要,建议你 fork 一个自己的分支。停更后该项目不会 archive,有任何问题仍然可以在 Issue 里共同讨论,如果你想宣传自己的改版后的分支,也可以在 Issue 里分享 10 | 11 | 小白兔 写于 2021.03.12 12 | 13 | ## 注意事项 14 | 15 | 特地将一些重要的说明提前写在这里,希望能得到足够的重视 16 | 17 | 1. 不要使用过低的刷新间隔,以免对选课网服务器造成压力,建议时间间隔不小于 4 秒 18 | 2. 选课网存在 IP 级别的限流,访问过于频繁可能会导致 IP 被封禁 19 | 20 | ## 特点 21 | 22 | - 运行过程中不需要进行任何人为操作,且支持同时通过其他设备、IP 访问选课网 23 | - 利用专门训练的 CNN 模型自动识别验证码,识别准确率为 99.16%,详细见 [PKUElectiveCaptcha2021Spring](https://github.com/zhongxinghong/PKUElectiveCaptcha2021Spring) 24 | - 具有较为完善的错误捕获机制,程序鲁棒性好 25 | - 提供额外的监视器线程,开启后可以通过端口监听进程运行状况,为服务器上部署提供可能 26 | - 支持多进程下的多账号/多身份选课 27 | - 可以自定义额外的选课规则,目前支持互斥规则和延迟规则 28 | 29 | ## 安装 30 | 31 | ### Python 3 32 | 33 | 该项目至少需要 Python 3,可以从 [Python 官网](https://www.python.org/) 下载并安装(项目开发环境为 Python 3.6.8) 34 | 35 | 例如在 Linux 下运行: 36 | ```console 37 | $ apt-get install python3 38 | ``` 39 | 40 | ### Repo 41 | 42 | 下载这个 repo 至本地。点击右上角的 `Code -> Download ZIP` 即可下载 43 | 44 | 对于 git 命令行: 45 | ```console 46 | $ git clone https://github.com/zhongxinghong/PKUAutoElective.git 47 | ``` 48 | 49 | ### Packages 50 | 51 | 安装 PyTorch 外的依赖包(该示例中使用清华镜像源以加快下载速度) 52 | ```console 53 | $ pip3 install requests lxml Pillow opencv-python numpy flask -i https://pypi.tuna.tsinghua.edu.cn/simple 54 | ``` 55 | 56 | 安装 PyTorch,从 [PyTorch 官网](https://pytorch.org/) 中选择合适的条件获得下载命令,然后复制粘贴到命令行中运行即可下载安装 (CUDA 可以为 None),PyTorch 版本必须要大于 1.4.x,否则无法读取 CNN 模型 57 | 58 | 示例选项: 59 | 60 | - `PyTorch Build`: Stable (1.8.0) 61 | - `Your OS`: Windows 62 | - `Package`: Pip 63 | - `Language`: Python 64 | - `CUDA`: None 65 | 66 | 复制粘贴所得命令在命令行中运行: 67 | ```console 68 | pip3 install torch==1.8.0+cpu torchvision==0.9.0+cpu torchaudio===0.8.0 -f https://download.pytorch.org/whl/torch_stable.html 69 | ``` 70 | 71 | 该项目不依赖 torchvision 和 torchaudio,因此你可以只安装 torch 72 | ```console 73 | pip3 install torch==1.8.0+cpu -f https://download.pytorch.org/whl/torch_stable.html 74 | ``` 75 | 76 | PyTorch 安装时间可能比较长,需耐心等待 77 | 78 | ### 验证码识别模块测试 79 | 80 | 这个测试旨在检查与验证码识别模块相关的依赖包是否正确安装,尤其是 PyTorch, OpenCV 81 | ```console 82 | $ cd test/ 83 | $ python3 test_cnn.py 84 | 85 | Captcha('er47') True 86 | Captcha('rskh') True 87 | Captcha('uesg') True 88 | Captcha('skwc') True 89 | Captcha('mmfk') True 90 | ``` 91 | 92 | ## 基本用法 93 | 94 | 1. 复制 `config.sample.ini` 文件,所得的新文件重命名为 `config.ini` 95 | - 直接复制文件,不要新建一个文件叫 `config.ini`,然后复制粘贴内容,否则可能会遇到编码问题 96 | 2. 用文本编辑器打开 `config.ini` (建议用代码编辑器,当然记事本一类的系统工具也可以) 97 | 3. 配置 `[user]`,详细见注释 98 | - 如果是双学位账号,设置 `dual_degree` 为 `true`,同时需要设置登录身份 `identity`,非双学位账号两者均保持默认即可 99 | 4. 在选课网上,将待选课程手动添加到选课网的 `补退选` 页 `选课计划` 中,并确保它们处在 `补退选` 页 `选课计划` 列表的 **同一页** 100 | - 如果想刷的课处在不同页,可以参考 [多进程选课](#多进程选课) 101 | - 该项目无法事前检查选课计划的合理性,只会根据选课的提交结果来判断某门课是否能够被选上,所以请自行 **确保填写的课程在有名额的时候可以被选上**,以免浪费时间。选课失败引发的常见错误可参见 [异常处理](#异常处理) 102 | 5. 配置 `[course]` 定义待选课程,详细见注释 103 | 6. 如果有需要,可以配置 `[mutex]`,`[delay]`,详细见注释。如要使用,请务必仔细阅读 [自定义选课规则](#自定义选课规则) 104 | 7. 配置 `[client]`,详细见注释(如果不理解选项的含义,建议不要修改) 105 | - `supply_cancel_page` 指定实际刷新第几页,确保这个值等于 (4) 中待选课程所处的页数 106 | - `refresh_interval / random_deviation` 设置刷新的时间间隔(如果有需要),切记 **不要将刷新间隔改得过短**,以免对选课网服务器造成太大压力 107 | 8. 进入项目根目录,运行 `python3 main.py`,即可开始自动选课。 108 | 109 | 110 | ## 高级用法 111 | 112 | ### 命令行参数 113 | 114 | 输入 `python3 main.py -h` 查看帮助 115 | ```console 116 | $ python3 main.py -h 117 | 118 | Usage: main.py [options] 119 | 120 | PKU Auto-Elective Tool v6.0.0 (2021.03.12) 121 | 122 | Options: 123 | --version show program's version number and exit 124 | -h, --help show this help message and exit 125 | -c FILE, --config=FILE 126 | custom config file encoded with utf8 127 | -m, --with-monitor run the monitor thread simultaneously 128 | ``` 129 | 130 | ### 多进程选课 131 | 132 | 如果你有多个账号需要选课,那么可以为每一个账号单独配置一个 `config.ini` 然后以不同的配置文件运行多个进程,即可实现多账号同时刷课 133 | 134 | 例如你为 Alice 和 Bob 同学创建了这两个文件,假设它们处在 `config/` 文件夹中(手动创建) 135 | ```console 136 | $ ls 137 | 138 | config main.py 139 | 140 | $ ls config/ 141 | 142 | config.alice.ini config.bob.ini 143 | ``` 144 | 145 | 接下来在两个终端中分别运行下面两个命令,即可实现多账号刷课 146 | ```console 147 | $ python3 main.py -c ./config/config.alice.ini 148 | $ python3 main.py -c ./config/config.bob.ini 149 | ``` 150 | 151 | 由于选课网单 IP 下存在会话数上限,开启多进程时还需更改 `[client]` 中的 `elective_client_pool_size` 项,合理分配各个进程的会话数。同一 IP 下所有进程的会话总数不能超过 5。建议值: 单进程 3~4; 两进程 2+2; 三进程 1+1+2 ... (保留一个会话给浏览器正常访问选课网) 152 | 153 | 如果教学网的 `选课计划` 列表很长,想刷的课处在不同页,也可以通过类似的方法实现多页选课。例如:要同时刷第 1 页和第 2 页的课程,那么分别将两页的课配置成两个 `config.ini`,修改相应的 `supply_cancel_page`,然后按照上法运行即可。 154 | ```console 155 | $ ls config/ 156 | 157 | config.p1.ini config.p2.ini 158 | ``` 159 | 160 | ### 监视器 161 | 162 | 如果你拥有可以同时连上 `elective.pku.edu.cn` 和 `iaaa.pku.edu.cn` 的服务器,你可以在服务器上部署这个项目,并且开启监听线程,配置相应的路由,之后就可以通过外网访问服务器来查看当前运行状态。 163 | 164 | 示例: 165 | 166 | 1. 配置 `[monitor]`,修改需要绑定的 `host/port` 167 | 2. 在运行时指定额外 `-m` 参数,即 `python3 main.py -m` 168 | 3. 利用 `nginx` 进行反向代理。假设监视器线程监听 `http://127.0.0.1:7074`,相应的配置示例如下: 169 | ```nginx 170 | # filename: nginx.autoelective.conf 171 | # coding: utf-8 172 | 173 | server { 174 | 175 | listen 12345; 176 | server_name 10.123.124.125; 177 | charset UTF-8; 178 | 179 | location / { 180 | proxy_pass http://127.0.0.1:7074; 181 | } 182 | } 183 | ``` 184 | 在这个示例中,通过访问 `http://10.123.124.125:12345` 即可以查看运行状态 185 | 186 | 187 | 该项目为监视器注册了如下路由: 188 | ``` 189 | GET / 查看该路由规则 190 | GET /rules 同 / 191 | GET /stat 同 / 192 | GET /stat/course 查看与选课相关的状态 193 | GET /stat/error 查看与错误相关的状态 194 | GET /stat/loop 查看与 loop 线程相关的状态 195 | ``` 196 | 197 | 例如,请求 `http://10.123.124.125:12345/stat/course` 可以查看与选课相关的状态 198 | 199 | 200 | ### 自定义选课规则 201 | 202 | #### 互斥规则 203 | 204 | 假设你有多个备选方案,它们在选课规则上并不矛盾,可以同时被选上。例如你在考虑选 A 院的概率统计(B)或是 B 院开的概率统计(B),你希望在选上其中两者其一时就不再考虑选另一门,那么你可以定义这两门课为互斥的,之后在上述情境发生时,另一门课会被程序自动忽略,这样就不会发生两者同时被选上的问题。详细见 [Issue #8](https://github.com/zhongxinghong/PKUAutoElective/issues/8) 205 | 206 | 务必注意的是,如果互斥的几门课在同一回合内同时出现空位,优先级高的课会被首先提交,而低优先级的课会被忽略(关于课程优先级的概念,参看 `config.ini` 中 `[course]` 下的相关注释),虽然多门课同时出现空位的情况比较罕见,但是为了确保在这种情况发生的时候你能选到更加心仪的课,如果你使用了互斥规则,记得将你更希望选上的课定义成更高的优先级 207 | 208 | 另外,没有必要为本身就是互斥的课定义互斥规则,比如学校一学期只能选一门体育课,如果你现在有十几门体育课候选,你不必额外声明一个互斥规则来指定这些体育课互斥,因为一旦有一门体育课选上,选课网就会拒绝你提交另一门体育课的选课请求,然后其他的体育课在自然的选课失败后就会被自动忽略 209 | 210 | #### 延迟规则 211 | 212 | 假设你有一门课(不妨设为马原)有多个班可以选,但是每个班的受欢迎程度不一样,你希望选择某个比较火爆的班(比如林锋老师的班,不妨设为 A 班),但是它已经被选满了,而另外一个冷门的班(不妨设为 B 班)仍然有很多的名额,你只希望把它作为你的第二志愿,因此你想尝试抢 A 班。但是你发现当你选了 B 班后,出于某些原因(比如 A 班与 B 班时间冲突、不够学分),你将无法再选 A 班,因此你只好把 B 班先撂在一边,去抢 A 班,但是你并不知道 B 班什么时候会被选满,为此你还得不时地监控 B 班当前的选课情况。这个时候你就可以考虑同时添加 A 班和 B 班到选课计划中,并为 B 班定义一个延迟规则。延迟规则使得触发 B 班选课请求的提交必须要满足 B 班的当前空余人数小于等于某个特定的阈值,我们不妨假设这个阈值是 10,那么程序将会在 B 班剩余的空位小于等于 10 的时候,才会提交 B 班的选课请求,否则它将对此视而不见。详细见 [Issue #28](https://github.com/zhongxinghong/PKUAutoElective/issues/28) 213 | 214 | 如果你要使用这个规则,你必须要注意以下几件事情,以预知一些可能存在的风险: 215 | 216 | 1. 我已经对相关逻辑做了简单测试,但是考虑到这个规则出错的后果可能是十分严重的,如果你不放心,还是建议你先拿 B 班测试一下,以确保这个规则能够被程序正确实现 217 | 218 | 2. 情况并不一定总和你想象得那么美好: B 班人少的时候,程序肯定会帮我补选上,因此我一定会有课上的。你必须要考虑一些特殊情况下程序可能会对此无能为力,例如在某个选课时间节点的时候,选课网访问量骤增,程序可能会连不上服务器,然后一直放着不选的 B 班一瞬间就被选满,那么这学期你可能就没有这个课上了。举一个我假想的案例:假设你是一名信科大一的学生,这学期想选程设,同理可以假设有上述情境中的 A 班和 B 班,你试着抢 A 班并给 B 班设置了延迟规则,但是在跨院系选课名额开放的那个下午 5 点,很多想要选修程设的外院系同学涌进选课系统,一瞬间把 B 班的名额抢完了,而你的程序一直卡着没法登录,完美错过了 B 班的选课,于是你这学期可能就没有程设课可以上了。对于这种风险,你必须要权衡一下,是否有必要在跨院系选课名额开放前就率先抢下 B 班。如果你打算见好就收,是不是下午 4:50 这样我就把 B 班给选了呢?并不是!因为这个时候系统已经在维护了,你需要在跨院系选课名额开放的那天的中午 12 点前,趁着系统还没开始维护,就把 B 班给选了,否则你还是有可能会错过 B 班的选课 219 | 220 | 3. 不要以为空余人数的缩减速率一定是线性的。并不是说平时观察到 B 班每小时少 10 个名额,那么从 20 -> 10 和从 10 -> 0 都将花掉一小时。当延迟规则出现以后,阈值的设置有可能就会变成一次博弈。例如有 30 个程序都在抢 A 班,并均为 B 班设置了延迟规则,假设大家的阈值都是 10,那么当 B 班还剩 10 个名额的时候,它可能在接下来的一瞬间就没了,至少会有 20 个程序将抢不到 B 班。因此,爆发式抢课的情况仍然是有可能发生的 221 | 222 | 题外话:我个人觉得有课上就已经很好啦,所以最省心的事情是马上选下 B 班,然后当做 A 班不存在 :) 223 | 224 | ### 自定义 User-Agent 池 225 | 226 | 在 `user_agents.txt.gz` 中提供了默认的 User-Agent 池,可以通过 `gzip` 工具解压得到 `user_agents.txt` 加以查看 227 | 228 | 每次从 IAAA 登录时,都会从中随机选择一个 User-Agent 并设置到 IAAA 客户端和 Elective 客户端上,在下次重新登录前将不再更换 229 | 230 | 如果你需要自定义 User-Agent 池,可以在根目录下创建一个 `user_agents.user.txt`,在其中对其进行定义。格式为一行一条 User-Agent,具体格式可参考 `user_agents.txt`,需要确保文件为 `utf-8` 编码。程序会优先选择读入用户自定义的 User-Agent 池 231 | 232 | ### DEBUG 相关 233 | 234 | 在 `config.ini` 的 `[client]` 中: 235 | 236 | - `debug_print_request` 如果设置为 `true`,会将与请求相关的重要信息打印到终端 237 | - `debug_dump_request` 会用 `pickle+gzip` 保存请求的 `Response` 对象,如果发生未知错误,仍然可以重新导入当时的请求。关于未知错误,详见 [未知错误警告](#未知错误警告) 238 | 239 | ### 其他配置项 240 | 241 | 在 `config.ini` 的 `[client]` 中: 242 | 243 | - `iaaa_client_timeout` IAAA 客户端的最长请求超时 244 | - `elective_client_timeout` Elective 客户端的最长请求超时 245 | - `login_loop_interval` IAAA 登录循环每两回合的时间间隔 246 | - `elective_client_max_life` 设置 Elective 客户端的存活时间。超过存活时间的 Elective 客户端会主动登出并自动重登 247 | - `print_mutex_rules` 是否在每次循环时打印完整的互斥规则列表。如果你定义了很复杂的互斥规则,你可以将这个值设为 `False` 以避免每次循环都将整个列表重复打印一遍 248 | 249 | ## 异常处理 250 | 251 | ### 系统异常 `SystemException` 252 | 253 | 对应于 `elective.pku.edu.cn` 的各种系统异常页,目前可识别: 254 | 255 | - **请不要用刷课机刷课:** 请求头未设置 `Referer` 字段,或者未事先提交验证码校验请求,就提交选课请求(比如在 Chrome 的开发者工具中,直接找到 “补选” 按钮在 DOM 中对应的链接地址并单击访问) 256 | - **Token无效:** token 失效 257 | - **尚未登录或者会话超时:** cookies 中的 session 信息过期 258 | - **不在操作时段:** 例如,在预选阶段试图打开补退选页 259 | - **索引错误:** 貌似是因为在其他客户端操作导致课程列表中的索引值变化 260 | - **验证码不正确:** 在补退选页填写了错误验证码后刷新页面 261 | - **无验证信息:** 辅双登录时可能出现,原因不明 262 | - **你与他人共享了回话,请退出浏览器重新登录:** 同一浏览器内登录了第二个人的账号,则原账号选课页会报此错误(由于共用 cookies) 263 | - **只有同意选课协议才可以继续选课!** 第一次选课时需要先同意选课协议 264 | 265 | ### 提示框反馈 `TipsException` 266 | 267 | 对应于 `补退选页` 各种提交操作(补选、退选等)后的提示框反馈,目前可识别: 268 | 269 | - **补选课程成功:** 成功选课后的提示 270 | - **您已经选过该课程了:** 已经选了相同课号的课程(可能是别的院开的相同课,也可能是同一门课的不同班) 271 | - **上课时间冲突:** 上课时间冲突 272 | - **考试时间冲突** 考试时间冲突 273 | - **超时操作,请重新登录:** 貌似是在 cookies 失效时提交选课请求(比如在退出登录或清空 `session.cookies` 的情况下,直接提交选课请求) 274 | - **该课程在补退选阶段开始后的约一周开放选课:** 跨院系选课阶段未开放时,试图选其他院的专业课 275 | - **您本学期所选课程的总学分已经超过规定学分上限:** 选课超学分 276 | - **选课操作失败,请稍后再试:** 未知的操作失败,貌似是因为请求过快 277 | - **只能选其一门:** 已选过与待选课程性质互斥的课程(例如:高代与线代) 278 | - **学校规定每学期只能修一门英语课:** 一学期试图选修多门英语课 279 | - **学校规定每学期只能修一门体育课:** 一学期试图选修多门体育课 280 | - **该课程选课人数已满:** 试图选一门已经满人的课,这本是不允许的操作,但有的时候选课网会偶然出现某门课已选人数突然为 0 的情况,这时提交选课请求会遇到这个错误 281 | 282 | 283 | ## 补充说明 284 | 285 | 1. 一直遇到 `[310] 您尚未登录或者会话超时,请重新登录` 错误,可能是因为你是双学位账号,但是没有在 `config.ini` 中设置 `dual_degree = true` 286 | 2. 不要修改 `config.ini` 的编码,确保它能够以 `utf-8-sig` 编码被 Python 解析。如果遇到编码问题,请重新创建一个 `config.ini`,之后不要使用 `记事本 Notepad` 进行编辑,应改用更加专业的文本编辑工具或者代码编辑器,例如 `NotePad ++`, `Sublime Text`, `VSCode` 等,并以 `无 BOM 的 UTF-8` 编码保存文件 287 | 3. 该项目适用于:课在有空位的时候可以选,但是当前满人无法选上,需要长时间不断刷新页面。对于有名额但是网络拥堵的情况(比如到达某个特定的选课时段节点时),程序选课 **不一定比手选快**,因为该项目每次启动前都会先登录一次 IAAA,这个请求在网络阻塞时可能很难完成,如果你已经通过浏览器提前登入了选课网,那么手动选课可能是个更好的选择 288 | 4. 不要使用过低的刷新间隔,以免对选课网服务器造成压力,建议时间间隔不小于 4 秒 289 | 5. 选课网存在 IP 级别的限流,访问过于频繁可能会导致 IP 被封禁 290 | 291 | 292 | ## 未知错误警告 293 | 294 | 1. 在 2019.02.22 下午 5:00 跨院系选课名额开放的时刻,有人使用该项目试图抢 `程设3班`,终端日志表明,程序运行时发现 `程设3班` 存在空位,并成功选上,但人工登录选课网后发现,实际选上了 `程设4班(英文班)` 。使用者并未打算选修英文班,且并未将 `程设4班` 加入到 `course.csv` (从 v3.0.0 起已合并入 `config.ini`) 中,而仅仅将其添加到教学网 “选课计划” 中,在网页中与 `程设3班` 相隔一行。从本项目的代码逻辑上我可以断定,网页的解析部分是不会出错的,对应的提交选课链接一定是 `程设3班` 的链接。可惜没有用文件日志记录网页结构,当时的请求结果已无从考证。从这一极其奇怪的现象中我猜测,北大选课网的数据库或服务器有可能存在 **线程不安全** 的设计,也有可能在高并发时会偶发 **Race condition** 漏洞。因此,我在此 **强烈建议: (1) 不要把同班号、有空位,但是不想选的课放在选课计划内; (2) 不要在学校服务器遭遇突发流量的时候拥挤选课。** 否则很有可能遭遇 **未知错误!** 295 | 296 | ## 历史更新信息 297 | 298 | 详见 [Realease History](/HISTORY.md) 299 | 300 | ## 版本迁移指南 301 | 302 | 详见 [Migration Guide](/MIGRATION_GUIDE.md) 303 | 304 | ## 责任须知 305 | 306 | - 你可以修改和使用这个项目,但请自行承担由此造成的一切后果 307 | - 严禁在公共场合扩散这个项目,以免给你我都造成不必要的麻烦 308 | 309 | ## 证书 310 | 311 | - PKUElectiveCaptcha2021Spring [MIT LICENSE](https://github.com/zhongxinghong/PKUElectiveCaptcha2021Spring/blob/master/LICENSE) 312 | - PKUAutoElective [MIT LICENSE](https://github.com/zhongxinghong/PKUAutoElective/blob/master/LICENSE) 313 | -------------------------------------------------------------------------------- /autoelective/loop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: loop.py 4 | # modified: 2021-09-11 5 | 6 | import os 7 | import time 8 | import random 9 | from queue import Queue 10 | from collections import deque 11 | from itertools import combinations 12 | from requests.compat import json 13 | from requests.exceptions import RequestException 14 | import numpy as np 15 | from . import __version__, __date__ 16 | from .environ import Environ 17 | from .config import AutoElectiveConfig 18 | from .logger import ConsoleLogger, FileLogger 19 | from .course import Course 20 | from .parser import get_tables, get_courses, get_courses_with_detail, get_sida 21 | from .hook import _dump_request 22 | from .iaaa import IAAAClient 23 | from .elective import ElectiveClient 24 | from .const import CAPTCHA_CACHE_DIR, USER_AGENT_LIST, WEB_LOG_DIR, CNN_MODEL_FILE 25 | from .exceptions import * 26 | from ._internal import mkdir 27 | 28 | import ddddocr 29 | 30 | 31 | ocr = ddddocr.DdddOcr() 32 | 33 | environ = Environ() 34 | config = AutoElectiveConfig() 35 | cout = ConsoleLogger("loop") 36 | ferr = FileLogger("loop.error") # loop 的子日志,同步输出到 console 37 | 38 | username = config.iaaa_id 39 | password = config.iaaa_password 40 | is_dual_degree = config.is_dual_degree 41 | identity = config.identity 42 | refresh_interval = config.refresh_interval 43 | refresh_random_deviation = config.refresh_random_deviation 44 | supply_cancel_page = config.supply_cancel_page 45 | iaaa_client_timeout = config.iaaa_client_timeout 46 | elective_client_timeout = config.elective_client_timeout 47 | login_loop_interval = config.login_loop_interval 48 | elective_client_pool_size = config.elective_client_pool_size 49 | elective_client_max_life = config.elective_client_max_life 50 | is_print_mutex_rules = config.is_print_mutex_rules 51 | 52 | config.check_identify(identity) 53 | config.check_supply_cancel_page(supply_cancel_page) 54 | 55 | _USER_WEB_LOG_DIR = os.path.join(WEB_LOG_DIR, config.get_user_subpath()) 56 | mkdir(_USER_WEB_LOG_DIR) 57 | 58 | 59 | electivePool = Queue(maxsize=elective_client_pool_size) 60 | reloginPool = Queue(maxsize=elective_client_pool_size) 61 | 62 | goals = environ.goals # let N = len(goals); 63 | ignored = environ.ignored 64 | mutexes = np.zeros(0, dtype=np.uint8) # uint8 [N][N]; 65 | delays = np.zeros(0, dtype=np.int32) # int [N]; 66 | 67 | killedElective = ElectiveClient(-1) 68 | NO_DELAY = -1 69 | 70 | 71 | class _ElectiveNeedsLogin(Exception): 72 | pass 73 | 74 | class _ElectiveExpired(Exception): 75 | pass 76 | 77 | 78 | def _get_refresh_interval(): 79 | if refresh_random_deviation <= 0: 80 | return refresh_interval 81 | delta = (random.random() * 2 - 1) * refresh_random_deviation * refresh_interval 82 | return refresh_interval + delta 83 | 84 | def _ignore_course(course, reason): 85 | ignored[course.to_simplified()] = reason 86 | 87 | def _add_error(e): 88 | clz = e.__class__ 89 | name = clz.__name__ 90 | key = "[%s] %s" % (e.code, name) if hasattr(clz, "code") else name 91 | environ.errors[key] += 1 92 | 93 | def _format_timestamp(timestamp): 94 | if timestamp == -1: 95 | return str(timestamp) 96 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) 97 | 98 | def _dump_respose_content(content, filename): 99 | path = os.path.join(_USER_WEB_LOG_DIR, filename) 100 | with open(path, 'wb') as fp: 101 | fp.write(content) 102 | 103 | 104 | def run_iaaa_loop(): 105 | 106 | elective = None 107 | 108 | while True: 109 | 110 | if elective is None: 111 | elective = reloginPool.get() 112 | if elective is killedElective: 113 | cout.info("Quit IAAA loop") 114 | return 115 | 116 | environ.iaaa_loop += 1 117 | user_agent = random.choice(USER_AGENT_LIST) 118 | 119 | cout.info("Try to login IAAA (client: %s)" % elective.id) 120 | cout.info("User-Agent: %s" % user_agent) 121 | 122 | try: 123 | 124 | iaaa = IAAAClient(timeout=iaaa_client_timeout) # not reusable 125 | iaaa.set_user_agent(user_agent) 126 | 127 | # request elective's home page to get cookies 128 | r = iaaa.oauth_home() 129 | 130 | r = iaaa.oauth_login(username, password) 131 | 132 | try: 133 | token = r.json()["token"] 134 | except Exception as e: 135 | ferr.error(e) 136 | raise OperationFailedError(msg="Unable to parse IAAA token. response body: %s" % r.content) 137 | 138 | elective.clear_cookies() 139 | elective.set_user_agent(user_agent) 140 | 141 | r = elective.sso_login(token) 142 | 143 | if is_dual_degree: 144 | sida = get_sida(r) 145 | sttp = identity 146 | referer = r.url 147 | r = elective.sso_login_dual_degree(sida, sttp, referer) 148 | 149 | if elective_client_max_life == -1: 150 | elective.set_expired_time(-1) 151 | else: 152 | elective.set_expired_time(int(time.time()) + elective_client_max_life) 153 | 154 | cout.info("Login success (client: %s, expired_time: %s)" % ( 155 | elective.id, _format_timestamp(elective.expired_time))) 156 | cout.info("") 157 | 158 | electivePool.put_nowait(elective) 159 | elective = None 160 | 161 | except (ServerError, StatusCodeError) as e: 162 | ferr.error(e) 163 | cout.warning("ServerError/StatusCodeError encountered") 164 | _add_error(e) 165 | 166 | except OperationFailedError as e: 167 | ferr.error(e) 168 | cout.warning("OperationFailedError encountered") 169 | _add_error(e) 170 | 171 | except RequestException as e: 172 | ferr.error(e) 173 | cout.warning("RequestException encountered") 174 | _add_error(e) 175 | 176 | except IAAAIncorrectPasswordError as e: 177 | cout.error(e) 178 | _add_error(e) 179 | raise e 180 | 181 | except IAAAForbiddenError as e: 182 | ferr.error(e) 183 | _add_error(e) 184 | raise e 185 | 186 | except IAAAException as e: 187 | ferr.error(e) 188 | cout.warning("IAAAException encountered") 189 | _add_error(e) 190 | 191 | except CaughtCheatingError as e: 192 | ferr.critical(e) # 严重错误 193 | _add_error(e) 194 | raise e 195 | 196 | except ElectiveException as e: 197 | ferr.error(e) 198 | cout.warning("ElectiveException encountered") 199 | _add_error(e) 200 | 201 | except json.JSONDecodeError as e: 202 | ferr.error(e) 203 | cout.warning("JSONDecodeError encountered") 204 | _add_error(e) 205 | 206 | except KeyboardInterrupt as e: 207 | raise e 208 | 209 | except Exception as e: 210 | ferr.exception(e) 211 | _add_error(e) 212 | raise e 213 | 214 | finally: 215 | t = login_loop_interval 216 | cout.info("") 217 | cout.info("IAAA login loop sleep %s s" % t) 218 | cout.info("") 219 | time.sleep(t) 220 | 221 | 222 | def run_elective_loop(): 223 | 224 | elective = None 225 | noWait = False 226 | 227 | ## load courses 228 | 229 | cs = config.courses # OrderedDict 230 | N = len(cs) 231 | cid_cix = {} # { cid: cix } 232 | 233 | for ix, (cid, c) in enumerate(cs.items()): 234 | goals.append(c) 235 | cid_cix[cid] = ix 236 | 237 | ## load mutex 238 | 239 | ms = config.mutexes 240 | mutexes.resize((N, N), refcheck=False) 241 | 242 | for mid, m in ms.items(): 243 | ixs = [] 244 | for cid in m.cids: 245 | if cid not in cs: 246 | raise UserInputException("In 'mutex:%s', course %r is not defined" % (mid, cid)) 247 | ix = cid_cix[cid] 248 | ixs.append(ix) 249 | for ix1, ix2 in combinations(ixs, 2): 250 | mutexes[ix1, ix2] = mutexes[ix2, ix1] = 1 251 | 252 | ## load delay 253 | 254 | ds = config.delays 255 | delays.resize(N, refcheck=False) 256 | delays.fill(NO_DELAY) 257 | 258 | for did, d in ds.items(): 259 | cid = d.cid 260 | if cid not in cs: 261 | raise UserInputException("In 'delay:%s', course %r is not defined" % (did, cid)) 262 | ix = cid_cix[cid] 263 | delays[ix] = d.threshold 264 | 265 | ## setup elective pool 266 | 267 | for ix in range(1, elective_client_pool_size + 1): 268 | client = ElectiveClient(id=ix, timeout=elective_client_timeout) 269 | client.set_user_agent(random.choice(USER_AGENT_LIST)) 270 | electivePool.put_nowait(client) 271 | 272 | ## print header 273 | 274 | header = "# PKU Auto-Elective Tool v%s (%s) #" % (__version__, __date__) 275 | line = "#" + "-" * (len(header) - 2) + "#" 276 | 277 | cout.info(line) 278 | cout.info(header) 279 | cout.info(line) 280 | cout.info("") 281 | 282 | line = "-" * 30 283 | 284 | cout.info("> User Agent") 285 | cout.info(line) 286 | cout.info("pool_size: %d" % len(USER_AGENT_LIST)) 287 | cout.info(line) 288 | cout.info("") 289 | cout.info("> Config") 290 | cout.info(line) 291 | cout.info("is_dual_degree: %s" % is_dual_degree) 292 | cout.info("identity: %s" % identity) 293 | cout.info("refresh_interval: %s" % refresh_interval) 294 | cout.info("refresh_random_deviation: %s" % refresh_random_deviation) 295 | cout.info("supply_cancel_page: %s" % supply_cancel_page) 296 | cout.info("iaaa_client_timeout: %s" % iaaa_client_timeout) 297 | cout.info("elective_client_timeout: %s" % elective_client_timeout) 298 | cout.info("login_loop_interval: %s" % login_loop_interval) 299 | cout.info("elective_client_pool_size: %s" % elective_client_pool_size) 300 | cout.info("elective_client_max_life: %s" % elective_client_max_life) 301 | cout.info("is_print_mutex_rules: %s" % is_print_mutex_rules) 302 | cout.info(line) 303 | cout.info("") 304 | 305 | while True: 306 | 307 | noWait = False 308 | 309 | if elective is None: 310 | elective = electivePool.get() 311 | 312 | environ.elective_loop += 1 313 | 314 | cout.info("") 315 | cout.info("======== Loop %d ========" % environ.elective_loop) 316 | cout.info("") 317 | 318 | ## print current plans 319 | 320 | current = [ c for c in goals if c not in ignored ] 321 | if len(current) > 0: 322 | cout.info("> Current tasks") 323 | cout.info(line) 324 | for ix, course in enumerate(current): 325 | cout.info("%02d. %s" % (ix + 1, course)) 326 | cout.info(line) 327 | cout.info("") 328 | 329 | ## print ignored course 330 | 331 | if len(ignored) > 0: 332 | cout.info("> Ignored tasks") 333 | cout.info(line) 334 | for ix, (course, reason) in enumerate(ignored.items()): 335 | cout.info("%02d. %s %s" % (ix + 1, course, reason)) 336 | cout.info(line) 337 | cout.info("") 338 | 339 | ## print mutex rules 340 | 341 | if np.any(mutexes): 342 | cout.info("> Mutex rules") 343 | cout.info(line) 344 | ixs = [ (ix1, ix2) for ix1, ix2 in np.argwhere( mutexes == 1 ) if ix1 < ix2 ] 345 | if is_print_mutex_rules: 346 | for ix, (ix1, ix2) in enumerate(ixs): 347 | cout.info("%02d. %s --x-- %s" % (ix + 1, goals[ix1], goals[ix2])) 348 | else: 349 | cout.info("%d mutex rules" % len(ixs)) 350 | cout.info(line) 351 | cout.info("") 352 | 353 | ## print delay rules 354 | 355 | if np.any( delays != NO_DELAY ): 356 | cout.info("> Delay rules") 357 | cout.info(line) 358 | ds = [ (cix, threshold) for cix, threshold in enumerate(delays) if threshold != NO_DELAY ] 359 | for ix, (cix, threshold) in enumerate(ds): 360 | cout.info("%02d. %s --- %d" % (ix + 1, goals[cix], threshold)) 361 | cout.info(line) 362 | cout.info("") 363 | 364 | if len(current) == 0: 365 | cout.info("No tasks") 366 | cout.info("Quit elective loop") 367 | reloginPool.put_nowait(killedElective) # kill signal 368 | return 369 | 370 | ## print client info 371 | 372 | cout.info("> Current client: %s (qsize: %s)" % (elective.id, electivePool.qsize() + 1)) 373 | cout.info("> Client expired time: %s" % _format_timestamp(elective.expired_time)) 374 | cout.info("User-Agent: %s" % elective.user_agent) 375 | cout.info("") 376 | 377 | try: 378 | 379 | if not elective.has_logined: 380 | raise _ElectiveNeedsLogin # quit this loop 381 | 382 | if elective.is_expired: 383 | try: 384 | cout.info("Logout") 385 | r = elective.logout() 386 | except Exception as e: 387 | cout.warning("Logout error") 388 | cout.exception(e) 389 | raise _ElectiveExpired # quit this loop 390 | 391 | ## check supply/cancel page 392 | 393 | page_r = None 394 | 395 | if supply_cancel_page == 1: 396 | 397 | cout.info("Get SupplyCancel page %s" % supply_cancel_page) 398 | 399 | r = page_r = elective.get_SupplyCancel(username) 400 | tables = get_tables(r._tree) 401 | try: 402 | elected = get_courses(tables[1]) 403 | plans = get_courses_with_detail(tables[0]) 404 | except IndexError as e: 405 | filename = "elective.get_SupplyCancel_%d.html" % int(time.time() * 1000) 406 | _dump_respose_content(r.content, filename) 407 | cout.info("Page dump to %s" % filename) 408 | raise UnexceptedHTMLFormat 409 | 410 | else: 411 | # 412 | # 刷新非第一页的课程,第一次请求会遇到返回空页面的情况 413 | # 414 | # 模拟方法: 415 | # 1.先登录辅双,打开补退选第二页 416 | # 2.再在同一浏览器登录主修 417 | # 3.刷新辅双的补退选第二页可以看到 418 | # 419 | # ----------------------------------------------- 420 | # 421 | # 引入 retry 逻辑以防止以为某些特殊原因无限重试 422 | # 正常情况下一次就能成功,但是为了应对某些偶发错误,这里设为最多尝试 3 次 423 | # 424 | retry = 3 425 | while True: 426 | if retry == 0: 427 | raise OperationFailedError(msg="unable to get normal Supplement page %s" % supply_cancel_page) 428 | 429 | cout.info("Get Supplement page %s" % supply_cancel_page) 430 | r = page_r = elective.get_supplement(username, page=supply_cancel_page) # 双学位第二页 431 | tables = get_tables(r._tree) 432 | try: 433 | elected = get_courses(tables[1]) 434 | plans = get_courses_with_detail(tables[0]) 435 | except IndexError as e: 436 | cout.warning("IndexError encountered") 437 | cout.info("Get SupplyCancel first to prevent empty table returned") 438 | _ = elective.get_SupplyCancel(username) # 遇到空页面时请求一次补退选主页,之后就可以不断刷新 439 | else: 440 | break 441 | finally: 442 | retry -= 1 443 | 444 | ## check available courses 445 | 446 | cout.info("Get available courses") 447 | 448 | tasks = [] # [(ix, course)] 449 | for ix, c in enumerate(goals): 450 | if c in ignored: 451 | continue 452 | elif c in elected: 453 | cout.info("%s is elected, ignored" % c) 454 | _ignore_course(c, "Elected") 455 | for (mix, ) in np.argwhere( mutexes[ix,:] == 1 ): 456 | mc = goals[mix] 457 | if mc in ignored: 458 | continue 459 | cout.info("%s is simultaneously ignored by mutex rules" % mc) 460 | _ignore_course(mc, "Mutex rules") 461 | else: 462 | for c0 in plans: # c0 has detail 463 | if c0 == c: 464 | if c0.is_available(): 465 | delay = delays[ix] 466 | if delay != NO_DELAY and c0.remaining_quota > delay: 467 | cout.info("%s hasn't reached the delay threshold %d, skip" % (c0, delay)) 468 | else: 469 | tasks.append((ix, c0)) 470 | cout.info("%s is AVAILABLE now !" % c0) 471 | break 472 | else: 473 | raise UserInputException("%s is not in your course plan, please check your config." % c) 474 | 475 | tasks = deque([ (ix, c) for ix, c in tasks if c not in ignored ]) # filter again and change to deque 476 | 477 | ## elect available courses 478 | 479 | if len(tasks) == 0: 480 | cout.info("No course available") 481 | continue 482 | 483 | elected = [] # cache elected courses dynamically from `get_ElectSupplement` 484 | 485 | while len(tasks) > 0: 486 | 487 | ix, course = tasks.popleft() 488 | 489 | is_mutex = False 490 | 491 | # dynamically filter course by mutex rules 492 | for (mix, ) in np.argwhere( mutexes[ix,:] == 1 ): 493 | mc = goals[mix] 494 | if mc in elected: # ignore course in advanced 495 | is_mutex = True 496 | cout.info("%s --x-- %s" % (course, mc)) 497 | cout.info("%s is ignored by mutex rules in advance" % course) 498 | _ignore_course(course, "Mutex rules") 499 | break 500 | 501 | if is_mutex: 502 | continue 503 | 504 | cout.info("Try to elect %s" % course) 505 | 506 | ## validate captcha first 507 | 508 | while True: 509 | 510 | cout.info("Fetch a captcha") 511 | r = elective.get_DrawServlet() 512 | #captcha = recognizer.recognize(r.content) 513 | captcha = ocr.classification(r.content) 514 | 515 | cout.info("Recognition result: %s" % captcha) 516 | 517 | r = elective.get_Validate(username, captcha) 518 | try: 519 | res = r.json()["valid"] # 可能会返回一个错误网页 520 | except Exception as e: 521 | ferr.error(e) 522 | raise OperationFailedError(msg="Unable to validate captcha") 523 | 524 | if res == "2": 525 | cout.info("Validation passed") 526 | break 527 | # elif res == "0": 528 | # cout.info("Validation failed") 529 | # captcha.save(CAPTCHA_CACHE_DIR) 530 | # cout.info("Save %s to %s" % (captcha, CAPTCHA_CACHE_DIR)) 531 | # cout.info("Try again") 532 | else: 533 | cout.warning("Unknown validation result: %s" % res) 534 | 535 | ## try to elect 536 | 537 | try: 538 | 539 | r = elective.get_ElectSupplement(course.href) 540 | 541 | except ElectionRepeatedError as e: 542 | ferr.error(e) 543 | cout.warning("ElectionRepeatedError encountered") 544 | _ignore_course(course, "Repeated") 545 | _add_error(e) 546 | 547 | except TimeConflictError as e: 548 | ferr.error(e) 549 | cout.warning("TimeConflictError encountered") 550 | _ignore_course(course, "Time conflict") 551 | _add_error(e) 552 | 553 | except ExamTimeConflictError as e: 554 | ferr.error(e) 555 | cout.warning("ExamTimeConflictError encountered") 556 | _ignore_course(course, "Exam time conflict") 557 | _add_error(e) 558 | 559 | except ElectionPermissionError as e: 560 | ferr.error(e) 561 | cout.warning("ElectionPermissionError encountered") 562 | _ignore_course(course, "Permission required") 563 | _add_error(e) 564 | 565 | except CreditsLimitedError as e: 566 | ferr.error(e) 567 | cout.warning("CreditsLimitedError encountered") 568 | _ignore_course(course, "Credits limited") 569 | _add_error(e) 570 | 571 | except MutexCourseError as e: 572 | ferr.error(e) 573 | cout.warning("MutexCourseError encountered") 574 | _ignore_course(course, "Mutual exclusive") 575 | _add_error(e) 576 | 577 | except MultiEnglishCourseError as e: 578 | ferr.error(e) 579 | cout.warning("MultiEnglishCourseError encountered") 580 | _ignore_course(course, "Multi English course") 581 | _add_error(e) 582 | 583 | except MultiPECourseError as e: 584 | ferr.error(e) 585 | cout.warning("MultiPECourseError encountered") 586 | _ignore_course(course, "Multi PE course") 587 | _add_error(e) 588 | 589 | except ElectionFailedError as e: 590 | ferr.error(e) 591 | cout.warning("ElectionFailedError encountered") # 具体原因不明,且不能马上重试 592 | _add_error(e) 593 | 594 | except QuotaLimitedError as e: 595 | ferr.error(e) 596 | # 选课网可能会发回异常数据,本身名额 180/180 的课会发 180/0,这个时候选课会得到这个错误 597 | if course.used_quota == 0: 598 | cout.warning("Abnormal status of %s, a bug of 'elective.pku.edu.cn' found" % course) 599 | else: 600 | ferr.critical("Unexcepted behaviour") # 没有理由运行到这里 601 | _add_error(e) 602 | 603 | except ElectionSuccess as e: 604 | # 不从此处加入 ignored,而是在下回合根据教学网返回的实际选课结果来决定是否忽略 605 | cout.info("%s is ELECTED !" % course) 606 | 607 | # -------------------------------------------------------------------------- 608 | # Issue #25 609 | # -------------------------------------------------------------------------- 610 | # 但是动态地更新 elected,如果同一回合内有多门课可以被选,并且根据 mutex rules, 611 | # 低优先级的课和刚选上的高优先级课冲突,那么轮到低优先级的课提交选课请求的时候, 612 | # 根据这个动态更新的 elected 它将会被提前地忽略(而不是留到下一循环回合的开始时才被忽略) 613 | # -------------------------------------------------------------------------- 614 | r = e.response # get response from error ... a bit ugly 615 | tables = get_tables(r._tree) 616 | # use clear() + extend() instead of op `=` to ensure `id(elected)` doesn't change 617 | elected.clear() 618 | elected.extend(get_courses(tables[1])) 619 | 620 | except RuntimeError as e: 621 | ferr.critical(e) 622 | ferr.critical("RuntimeError with Course(name=%r, class_no=%d, school=%r, status=%s, href=%r)" % ( 623 | course.name, course.class_no, course.school, course.status, course.href)) 624 | # use this private function of 'hook.py' to dump the response from `get_SupplyCancel` or `get_supplement` 625 | file = _dump_request(page_r) 626 | ferr.critical("Dump response from 'get_SupplyCancel / get_supplement' to %s" % file) 627 | raise e 628 | 629 | except Exception as e: 630 | raise e # don't increase error count here 631 | 632 | except UserInputException as e: 633 | cout.error(e) 634 | _add_error(e) 635 | raise e 636 | 637 | except (ServerError, StatusCodeError) as e: 638 | ferr.error(e) 639 | cout.warning("ServerError/StatusCodeError encountered") 640 | _add_error(e) 641 | 642 | except OperationFailedError as e: 643 | ferr.error(e) 644 | cout.warning("OperationFailedError encountered") 645 | _add_error(e) 646 | 647 | except UnexceptedHTMLFormat as e: 648 | ferr.error(e) 649 | cout.warning("UnexceptedHTMLFormat encountered") 650 | _add_error(e) 651 | 652 | except RequestException as e: 653 | ferr.error(e) 654 | cout.warning("RequestException encountered") 655 | _add_error(e) 656 | 657 | except IAAAException as e: 658 | ferr.error(e) 659 | cout.warning("IAAAException encountered") 660 | _add_error(e) 661 | 662 | except _ElectiveNeedsLogin as e: 663 | cout.info("client: %s needs Login" % elective.id) 664 | reloginPool.put_nowait(elective) 665 | elective = None 666 | noWait = True 667 | 668 | except _ElectiveExpired as e: 669 | cout.info("client: %s expired" % elective.id) 670 | reloginPool.put_nowait(elective) 671 | elective = None 672 | noWait = True 673 | 674 | except (SessionExpiredError, InvalidTokenError, NoAuthInfoError, SharedSessionError) as e: 675 | ferr.error(e) 676 | _add_error(e) 677 | cout.info("client: %s needs relogin" % elective.id) 678 | reloginPool.put_nowait(elective) 679 | elective = None 680 | noWait = True 681 | 682 | except CaughtCheatingError as e: 683 | ferr.critical(e) # critical error ! 684 | _add_error(e) 685 | raise e 686 | 687 | except SystemException as e: 688 | ferr.error(e) 689 | cout.warning("SystemException encountered") 690 | _add_error(e) 691 | 692 | except TipsException as e: 693 | ferr.error(e) 694 | cout.warning("TipsException encountered") 695 | _add_error(e) 696 | 697 | except OperationTimeoutError as e: 698 | ferr.error(e) 699 | cout.warning("OperationTimeoutError encountered") 700 | _add_error(e) 701 | 702 | except json.JSONDecodeError as e: 703 | ferr.error(e) 704 | cout.warning("JSONDecodeError encountered") 705 | _add_error(e) 706 | 707 | except KeyboardInterrupt as e: 708 | raise e 709 | 710 | except Exception as e: 711 | ferr.exception(e) 712 | _add_error(e) 713 | raise e 714 | 715 | finally: 716 | 717 | if elective is not None: # change elective client 718 | electivePool.put_nowait(elective) 719 | elective = None 720 | 721 | if noWait: 722 | cout.info("") 723 | cout.info("======== END Loop %d ========" % environ.elective_loop) 724 | cout.info("") 725 | else: 726 | t = _get_refresh_interval() 727 | cout.info("") 728 | cout.info("======== END Loop %d ========" % environ.elective_loop) 729 | cout.info("Main loop sleep %s s" % t) 730 | cout.info("") 731 | time.sleep(t) 732 | --------------------------------------------------------------------------------