├── .gitignore ├── LICENSE ├── README.md ├── apikey.sample.json ├── autoelective ├── __init__.py ├── _internal.py ├── captcha │ ├── __init__.py │ ├── captcha.py │ └── online.py ├── cli.py ├── client.py ├── config.py ├── const.py ├── course.py ├── elective.py ├── environ.py ├── exceptions.py ├── hook.py ├── iaaa.py ├── logger.py ├── loop.py ├── monitor.py ├── parser.py ├── rule.py └── utils.py ├── config.sample.ini ├── main.py └── user_agents.txt.gz /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | /test 4 | /log 5 | /cache 6 | /config 7 | /bin 8 | *.bac 9 | config.ini 10 | apikey.json 11 | user_agents.user.txt 12 | .DS_Store -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PKUAutoElective 2021 Spring Version 2 | 3 | *Final Update: 本项目作者并不是专业开发者,上传本项目的初衷仅是在 2021 年选课网站发生改动,而新的验证码识别模型开源之前,给大家提供一个 AutoElective 的过渡选项(现在它的使命也完成了)。2021 的春季学期将会是作者在 PKU 的最后一个学期(如果顺利毕业的话 233),因此今后这个项目将不会在再更新,希望大家理解。* 4 | 5 | *** 6 | 本项目基于 [PKUAutoElective](https://github.com/zhongxinghong/PKUAutoElective),对 2021 春季学期的选课网站 API 改动进行了调整。并针对验证码系统的改动,将识别系统转为在线商用平台 [TT识图](http://www.ttshitu.com)(**打钱!打钱!**),目前识别准确度仍然略微堪忧。 7 | 8 | ## 安装 9 | 10 | 请参考 [PKUAutoElective](https://github.com/zhongxinghong/PKUAutoElective) 项目提供的安装指南进行安装,但本项目**不**依赖于 `pytorch`,因此可以**省略**其中的以下部分: 11 | 12 | > 安装 PyTorch,从 PyTorch 官网 中选择合适的条件获得下载命令,然后复制粘贴到命令行中运行即可下载安装。(注:本项目不需要 cuda,当然你可以安装带 gpu 优化的版本) 13 | > 14 | > ...... 15 | > 16 | > PyTorch 安装时间可能比较长,需耐心等待。 17 | > 如果实在无法安装,可以考虑用其他方式安装 PyTorch,详见附页 PyTorch 安装 18 | 19 | ## 配置文件 20 | 21 | ### config.ini 22 | 23 | 参考 [PKUAutoElective](https://github.com/zhongxinghong/PKUAutoElective) 项目中的 `config.ini` 配置说明。 24 | 25 | **WARNING:建议不要将刷新间隔 `refresh_interval` 调到过小,否则您的 ip 有可能被选课网短时间内封禁** 26 | 27 | ### apikey.json 28 | 29 | **请首先将 apikey.sample.json 复制一份并改名为 apikey.json,并按照以下说明进行配置。** 30 | 31 | 该文件为 [TT识图](http://www.ttshitu.com) 平台的 API 密钥,在平台注册后,填入用户名与密码即可。由于该 API 需要收费,须在平台充值后方可使用(1 RMB 足够用到天荒地老了)。 32 | 33 | ```json 34 | { 35 | "username": "xiaoming", 36 | "password": "xiaominghaoshuai" 37 | } 38 | ``` 39 | 40 | ## 使用说明 41 | 42 | ### 基本用法 43 | 44 | 将项目 clone 至本地后,切换至项目根目录下并运行 `main.py` 即可。 45 | 46 | ``` 47 | cd PKUElective2021Spring 48 | python3 main.py 49 | ``` 50 | 51 | 使用 `Ctrl + C` 输送 `KeyboardInterrupt`,可以终止程序运行。 52 | 53 | ### 命令行参数 54 | 55 | 关于支持的命令行参数,参见 [PKUAutoElective](https://github.com/zhongxinghong/PKUAutoElective) 的使用说明。 56 | 57 | ### TT识图:无感学习模式 58 | 59 | *本条目基于 [XiaoTian](https://github.com/xiaotianxt) 用户提出的 PR。* 60 | 61 | 关于无感学习的详细信息,可参见 [无感学习介绍页面](http://www.ttshitu.com/news/fcda89be991e4af8927c32527fb45b1e.html)。简而言之,无感模式可以达到更高的识别准确率(并且识别准确度会随着使用次数的增加而进一步提高),但使用费率也更高,且使用前期识别速率较低。 62 | 63 | 可以通过向 `apikey.json` 中传入额外参数 `enhanced_mode` 来控制无感学习模式是否开启(该参数缺省时默认不开启): 64 | 65 | ```json 66 | { 67 | "username": "xiaoming", 68 | "password": "xiaominghaoshuai", 69 | "enhanced_mode": true 70 | } 71 | ``` 72 | 73 | **WARNING: 根据TT识图后台统计明细,无感学习模式前期单次识别耗时通常 > 3000ms,而普通模式下单次识别耗时通常 < 100ms。因此若您认为其他选课同学的手速足够快,请不要开启无感学习模式。** 74 | 75 | ### TT识图平台测试 76 | 77 | 配置好 `apikey.json` 后,在命令行运行以下指令以测试在线识图是否正常工作 78 | (由于无感学习模式下识别结果因用户异,请在关闭无感学习模式的条件下进行测试) 79 | 80 | ``` 81 | python -c "import base64; from autoelective.captcha import TTShituRecognizer; 82 | c = TTShituRecognizer().recognize(base64.b64decode( 83 | 'iVBORw0KGgoAAAANSUhEUgAAAIIAAAA0CAMAAABxThCnAAADAFBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAz' 84 | 'AABmAACZAADMAAD/AAAAMwAzMwBmMwCZMwDMMwD/MwAAZgAzZgBmZgCZZgDMZgD/ZgAAmQAzmQBmmQCZmQDMmQD/mQAAzAAzzABm' 85 | 'zACZzADMzAD/zAAA/wAz/wBm/wCZ/wDM/wD//wAAADMzADNmADOZADPMADP/ADMAMzMzMzNmMzOZMzPMMzP/MzMAZjMzZjNmZjOZ' 86 | 'ZjPMZjP/ZjMAmTMzmTNmmTOZmTPMmTP/mTMAzDMzzDNmzDOZzDPMzDP/zDMA/zMz/zNm/zOZ/zPM/zP//zMAAGYzAGZmAGaZAGbM' 87 | 'AGb/AGYAM2YzM2ZmM2aZM2bMM2b/M2YAZmYzZmZmZmaZZmbMZmb/ZmYAmWYzmWZmmWaZmWbMmWb/mWYAzGYzzGZmzGaZzGbMzGb/' 88 | 'zGYA/2Yz/2Zm/2aZ/2bM/2b//2YAAJkzAJlmAJmZAJnMAJn/AJkAM5kzM5lmM5mZM5nMM5n/M5kAZpkzZplmZpmZZpnMZpn/ZpkA' 89 | 'mZkzmZlmmZmZmZnMmZn/mZkAzJkzzJlmzJmZzJnMzJn/zJkA/5kz/5lm/5mZ/5nM/5n//5kAAMwzAMxmAMyZAMzMAMz/AMwAM8wz' 90 | 'M8xmM8yZM8zMM8z/M8wAZswzZsxmZsyZZszMZsz/ZswAmcwzmcxmmcyZmczMmcz/mcwAzMwzzMxmzMyZzMzMzMz/zMwA/8wz/8xm' 91 | '/8yZ/8zM/8z//8wAAP8zAP9mAP+ZAP/MAP//AP8AM/8zM/9mM/+ZM//MM///M/8AZv8zZv9mZv+ZZv/MZv//Zv8Amf8zmf9mmf+Z' 92 | 'mf/Mmf//mf8AzP8zzP9mzP+ZzP/MzP//zP8A//8z//9m//+Z///M//////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 93 | 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACP6ykAAAOH0lEQVR4nJWZ' 94 | 'PXbkOBKE6bejvsiM0+w2VnORojGAo7oI6DDp6BZrDcpp0CFvsRbgoG6yXySq36yx7+1bSS3VD4tIREZGRqKnetX/76eYvkKM//lT' 95 | 'am+lt3q22g8eZ7O0pe3R21lbL63Xo1c9LmVcUDqPxjvT/1zUwlqqdR7nyOMY17iGuH6s6Z7WLUXjb0zb8UYAVpez9p3VX1+hGq8Q' 96 | 'NTEU4wIbMRAqz7htiXViFZ6fvtx/+R1T7aXP1kq9WClugiDyza1qKWwsmCORy5/F+I5FAbBmiwYOFvtqbHmvbCI/aiOeRkglv1t9' 97 | 'L21b+kQAXNFKtp1VaiFE/+3PY+pHt731oyy9xo/Ya+rEnvqTvfQdcN9aiFsMtm7NWm9PY++Fq+71PQqH/qPvv7W+W1kCEZ7cPf7k' 98 | 'gdLy6NrDBCKvL/LH+8233/p4bY/hR90IKVgDETJ8/XbVuaR0ld9JZzqtkxxbuanyro0vRnRrByWerLUCo25Y5qXUcluEv924hGX4' 99 | 'VW3SCyyey0NLcuXIAlilEVaqB/esjY3G7PwAN1jEn/zktqCQLPC549bOlSz0couLgwkIWzuXgxUTW32SCkVanq0TzpG3aiA+wTcB' 100 | 'eMFSLdlEDJYAkVbnTn5Ia0n1XDo5BxwQhOy1HKE4K1K4B/uI4uF1I4DG0hcBJTIiFLRdj+e9WglsupQOBftePAu8QUU4/mDiyw1y' 101 | 'roFHZ1t4g9dSLHNkxfhRoz0hdwFvigJWJgqERCR7B2qAT1coc60LF4yy2GqFoL0F1d+er9gfBkR9AfdKDC11KkLcKCrcYqsRM8Aq' 102 | 'O63f2cwOMsu8CDgWm6FIjapMFv+gEoCAv+m+pA12J6uZ5YooKULceOU7mBrXB+2FhZYnK3NjgQK+RRXBRvMhuA7tOC+xKzYLpc7W' 103 | 'H9rKZvk9PytLXalcFu9h/YgSA9eJtAq7fmxa+CIA4W+XcTc+WuZMpPAq6ouaBVyKg7/ozE5KFIILB+XKymQixqrLuCuQF6ekldUe' 104 | 'RW+FkslWQJmStGBzvpYzlz3HTJWctzahUim/p6qAtgfbioYksMQCvHw/pA88JYZQ974EJeIJ6JKDc13NeSEQkNYyr6oIe9TO37on' 105 | 'Abkn0r+RB731IKhnjk3YRxUgpCYFrWcBYBRm3yMqciAkVFm/DSRuvTySlbkZ9bvMLk1GsW2RBRRC575bvm7iw28GPZyTgMQWvvTe' 106 | 'PgSBlef2MLaOaD+kvJRJyrYUL8pVqfCPVVCF93tcE+k9HQhr0lZKlCpr6WyTxNlyYAe3p/jY3qGL3UTrLanucqpzS7V8UgHdDth4' 107 | 'pxiSgCgsT9qsKWXbcqU3BLEAFASHXOTUIrUXvZNFIeyU+Fk32lXqKEAFL6RJojcqlFhQImjNWhvoNYEQ6RCdYC1uK3hSD5ICJYMN' 108 | 'bVlMMgkZIGSBQDAQv9xUcSBKsY4IIBCaOccc0w7XJbdSob5PVCz9AE10NM0gc90/W1v6PEBYDm6mJK1xqwf32ecVUpl4HlsUY+Ao' 109 | '6xYxUs2TCgsqOFgN6vyioBVELIvYGbf0kEaSoazCmBDFkGwPqlYVR1Yvq/2s85UEQu5dNaHLwKMSOdxabJYsBdolvbe0T0Coz+Ie' 110 | 'gcCL6ob7fTd1SykXSQwbtfFVGhuRA8VHwp8IBHQkbWYz+BfXwtJVIbIXm0qyqZOmfBUvb3pCn3jprSWlIupe+zeiokCX4BaBWEsi' 111 | 'hlVP1Cwjiyt6apuNUL6x/AYGxVAr5OacvFUHPtZnsrbS86SWsPpbIa1/Waz+2Vrva/ra6e9SirR6jacqBlFVWhlWigif6pdt4GEu' 112 | 'CuKumv7BM/CX2ahfZRlaPgi8T13dcVuzQnE+5j3XpWBSPk2JkNZSs8ecgn1DxBPlwPIfMIObnQkn04+TXnBVbwzu0aKWzzyOYipX' 113 | 'JlkgSywSIoUKqyNS80asGXVc6LddtEZy2jCGT9gSb4ogd6Wn8/ikqmhm0VtCGp7RAkpXr3le03rengQANa39SZ9MNAhsyoosiDVJ' 114 | 'RkR3OEJ0jkZZLpLRY6Eoz1l9ehFIgMWy9FvKL2Zv3tgrdTAyEzZ7q0XyTGF+6GlWu6eTVzUoykaGaSGPPJSHQjGkC1UVeeN+McNk' 115 | 'NbmtPlgtf7qshgkpiosLcF+26hZqFhfSpQiwHcDJzmRYTO02uD5TvATaoKbcADy0M82e/ko3FgoXWvoNBNQzgj7A547wQ52GRrjN' 116 | 'M/sHDeR1kgdkJQCc0QZ1ECiGOZNsr2pWX/qXQPB2T6ozCVRgO82GwaW3iCCbalqNaUddenEL/9hQvc1jSN4ckNjiAlEThBB9oToh' 117 | 'qPomCh1kzsXZv/T8pQ9HqTUWFf6pGrAnpQuQ4e4Ot32t4bu6j5xCgtZaq0sp2p9u2ahzk3jkdSPEcLvEC1CoF6VR77MEZe82SRXg' 118 | 'u7Rg1iBAO3z2G11K5dDqezK3+ajsKtctHw+/S/9ep+zKVqQDOAk+rHULBqocCJJ8D6UGajL1ZUEoiM/CAh4Yex9jECfsa5d94k0S' 119 | 'SNxfKUtSsld7qD5nPcvX7yYhuWfzuSUA91zsbP5RvxfppjPLq7UUZvRKiaAKpKDo5rt3pyDHYpqHSEJRvhCordwm9XLmBJZuCzt/' 120 | 'CuOlbDeB8DglYiUjFTsfpD3jVldlF8OIX3yc+K3S3mCfLOfF1jc5cMkZG6eVbxEM8qtR8bWNLqH2634RgYKO4IYmIZYzrbFPpinP' 121 | '3bSIKaYQ8lMJSGwOIDZaHsLziQYsYCXVLTQGn9EE+VKXtxuP4MQfUmjpcIQw0tOIa9B0EGnveMjlnCm1qdr5u+zcY/glFi8IgbrD' 122 | 'usiyL58g0xfqGf9Rijzb3eSncbaSC+k5sMt4lW1246zvVT7MVBC9veP+ZavkNGVwUQj8PBiEpvtMAlD0ZodxUdX0G3f9KVGgXNup' 123 | 'JCs6FhayQgOckXqK5iHnTkOUjGvAYJqRcfCRkm10Tb3AgmfBt2QNjVHTEHO5OIYpJ98SaOKjbWsWc3sGohobAZh6wPxUMqXOw2iR' 124 | 'L1GCgoiFOaidN2PqEhl6BgYUaZawd3syeYri39UmZVQBjXyVJjL25IAQjrqHvT3UecnTjXEH22lvy6zZrbhXaTwOmu20iN1XqXV+' 125 | 'MVtDXguBhg+cJkEHG6wsygTPG2veundL8jfJKESGuiUE9QdCAIZn8dmcukAd77Jt2ngKtTO1qKsLBK08EcVo51qY+ZgPA6byT3lA' 126 | 'f4CQtmuQku0654cG4jCHOA4Y1qixS1aT+odZCp97YYEY+drGLct0LOMsA/LczvZjkEjl8GA0K1qeLPYfJILSlM8FW4Jc8iazuzMd' 127 | 'IfctXT6+hXmRoRtHLP79EW/PkLy/UozxHufXbBVZ3JZGKU4y+Hg7N4666fha5UXh1InL7DiCqk7j4+OHaZKun1FzEz2XqIpYsI5l' 128 | 'R0X4IY+qj0KVTVMd9WA9oO5BY5AUjx6qwlhwPIhkJID+65Qh+wCzSryaKI9NP2ZNABhnUXzVaIhaM3wxfNt7mWfEypL9Wj9tOWuW' 129 | 'kPw8JYjkK45mSbKTD6Q7dgEWYeUPpqkT0/ag8sC4TnSY5vVAZnr5R1Fhih2nrXefoQOq8BM+k0xa3sm8/1BLnek1TdK0bvZPLc9s' 130 | 'xWcfubpDaT9N9ZmlO6Nr6kjD3JdPPtjXMzLSUFadEvwDupsKk5uiFHWKyx8yEsGNEh/P6E67Q2MdPwWdWhQ/QhNuY6rXXP46MTAZ' 131 | 'DT9i08GDfD2tUtvy6VED96TruBXUbrKbJfgkmw9SSGumIDBVmoOZVPDFWCn6pNmtTI+k5p/2J3j4gd8xTvjGtyz4oT6m1ahRJ3x1' 132 | 'KiYJJ67Ix72ebLpUfNPsvthk/sa4TGM2l01uj7W4mLNFC+oqz181gdM5ub77i4fGFvImDZOa+Tg3RPd0tss86UxGKciO2NMTp2lo' 133 | 'm4Q0Rkqc6X7WMM4v1b1aX6Rc5w0NEWNI6TwvXCNTuI3TsSJ0S/OjnMMPqtRpTr0+zheLj7N+WCOAq6LyQ7WuAx/0K049XU+GMh1I' 134 | 'otOHnKMwXGTlCpLcyo10AMpCfQaf+4jc5ft0/LXs6UeqQr3KS/aX1PiZKxvywDQ/jgRpkla1ob0KUv6TRH9j01Gy9JcOJLgM4cGp' 135 | 'IkA645RFvNVFKYNP6xuDrd/6HJBWgTDOK3lFKW+vkE49PMchsLefNojr+aH16VedaM3kyk++cGeFYUp4JjkhPvfkH1MPeDAAcNvP' 136 | 'Rl1Lib2NjANMgSAZfR3jen+t3r0coBGpu9xfAehH+NBqx4kbwT+in7zqwIIuN+sIzDlmbozffVDdyT7mxNNJM9LH/WxEy7euRf1o' 137 | '+ap9rPLKi5DwkjwHLL+CIwct33m9TJ61buOkk0CizlOsv3uO7fjG+yBde3Kb5eIiuG0kmLm09F5/mQ4Ze/brDH3lZXDV+6/Pek6U' 138 | 'wVEkWTZWBz2jemGtR3flrgObpnjm/ExFelLsknvXQaav9MgqesVQnPPj0ND54NWpzY+86IVBGv+sMHPC6h2dJsSgM+injqVeU1NZ' 139 | '+neljFXF285MXGeR2dIz6GCwjpP8odz1yHv/xbz20qJXfbbz139EHL/Y6vh4uXCLVsclMm42+928iP6oOgZt7zNTR29v7T4myiSF' 140 | 'P0aJOdUoSYfZHw2CuQg96/n3/y2Mk32Ffb60aiiFV+hL0LiWRHRm+Xrd0InD/vWpE0YmKKZ2zJ8wZSSNzE+q6bEV/58ZP10XTPYi' 141 | 'vLbbRnd1ZsgKvzY/mDJI50R1fD0SBTLxmfugrY6KNQHpaFAneaqD4vYjSir8IMh/uPmqw23XMPMc+DaPWv6uBueHA+2LOlXLKMnj' 142 | 'lSfnQz3/Dc7xKmEJtRLLAAAAAElFTkSuQmCC')); print(c, c.code == 'vfg8')" 143 | ``` 144 | 145 | 如正常运行,将输出 146 | 147 | ``` 148 | Captcha('vfg8') True 149 | ``` 150 | 151 | 152 | ## 注意事项 153 | 154 | * 作者可能无视 Issue 和 PR,如果您有更好的改进想法,请最好 clone 一份后自行改动! 155 | * 请不要在公开场合(以及某匿名平台)传播此项目,以免造成不必要的麻烦! 156 | * 刷课有风险 USE AT YOUR OWN RISK! -------------------------------------------------------------------------------- /apikey.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "xiaoming", 3 | "password": "xiaominghaoshuai" 4 | } -------------------------------------------------------------------------------- /autoelective/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: __init__.py 4 | # modified: 2019-09-13 5 | 6 | __version__ = "5.0.1" 7 | __date__ = "2020.09.25" 8 | __author__ = "Rabbit" 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /autoelective/captcha/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: __init__.py 4 | # modified: 2019-09-08 5 | 6 | from .captcha import Captcha 7 | from .online import TTShituRecognizer 8 | -------------------------------------------------------------------------------- /autoelective/captcha/captcha.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ..utils import xMD5 3 | 4 | class Captcha(object): 5 | 6 | __slots__ = ['_code','_original','_denoised','_segments','_spans'] 7 | 8 | def __init__(self, code, original, denoised, segments, spans): 9 | self._code = code 10 | self._original = original 11 | self._denoised = denoised 12 | self._segments = segments 13 | self._spans = spans 14 | 15 | @property 16 | def code(self): 17 | return self._code 18 | 19 | @property 20 | def original(self): 21 | return self._original 22 | 23 | @property 24 | def denoised(self): 25 | return self._denoised 26 | 27 | @property 28 | def segments(self): 29 | return self._segments 30 | 31 | @property 32 | def spans(self): 33 | return self._spans 34 | 35 | def __repr__(self): 36 | return '%s(%r)' % ( 37 | self.__class__.__name__, 38 | self._code, 39 | ) 40 | 41 | def save(self, folder): 42 | code = self._code 43 | oim = self._original 44 | dim = self._denoised 45 | segs = self._segments 46 | spans = self._spans 47 | md5 = xMD5(oim.tobytes()) 48 | 49 | oim.save(os.path.join(folder, "%s_original_%s.jpg" % (code, md5))) 50 | dim.save(os.path.join(folder, "%s_denoised_%s.jpg" % (code, md5))) 51 | for im, (st, ed), c in zip(segs, spans, code): 52 | im.save(os.path.join(folder, "%s_%s_(%d,%d)_%s.jpg" % (code, c, st, ed, md5))) -------------------------------------------------------------------------------- /autoelective/captcha/online.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO 3 | import json 4 | import requests 5 | from PIL import Image 6 | 7 | from .captcha import Captcha 8 | from ..config import BaseConfig 9 | from .._internal import absp 10 | from ..exceptions import OperationFailedError, OperationTimeoutError, RecognizerError 11 | 12 | class APIConfig(object): 13 | 14 | _DEFAULT_CONFIG_PATH = '../apikey.json' 15 | 16 | def __init__(self, path=_DEFAULT_CONFIG_PATH): 17 | with open(absp(path), 'r') as handle: 18 | self._apikey = json.load(handle) 19 | assert 'username' in self._apikey.keys() and 'password' in self._apikey.keys() 20 | 21 | @property 22 | def uname(self): 23 | return self._apikey['username'] 24 | 25 | @property 26 | def pwd(self): 27 | return self._apikey['password'] 28 | 29 | def get(self, *args, **kwargs): # wrapper of _apikey.get 30 | return self._apikey.get(*args, **kwargs) 31 | 32 | 33 | class TTShituRecognizer(object): 34 | 35 | _RECOGNIZER_URL = "http://api.ttshitu.com/base64" 36 | _ERROR_REPORT_URL = "http://api.ttshitu.com/reporterror.json" 37 | 38 | def __init__(self, **kwargs): 39 | self._config = APIConfig() 40 | self._cache = None # previous result 41 | 42 | def recognize(self, raw): 43 | encode = TTShituRecognizer._to_b64(raw) 44 | data = { 45 | "username": self._config.uname, 46 | "password": self._config.pwd, 47 | "image": encode 48 | } 49 | if self._config.get('enhanced_mode', False): # “无感学习” 模式 50 | data["typeid"] = 7 51 | data["typename"] = "elective" 52 | try: 53 | result = json.loads(requests.post(TTShituRecognizer._RECOGNIZER_URL, json=data, timeout=20).text) 54 | except requests.Timeout: 55 | raise OperationTimeoutError(msg="Recognizer connection time out") 56 | except requests.ConnectionError: 57 | raise OperationFailedError(msg="Unable to coonnect to the recognizer") 58 | 59 | if result["success"]: 60 | self._cache = result 61 | return Captcha(result["data"]["result"].lower(), None, None, None, None) 62 | else: # fail 63 | raise RecognizerError(msg="Recognizer ERROR: %s" % result["message"]) 64 | 65 | def report_last_error(self): 66 | assert self._cache is not None 67 | try: 68 | requests.post(TTShituRecognizer._ERROR_REPORT_URL, json={"id": self._cache["data"]["id"]}, timeout=5) 69 | except requests.Timeout: 70 | pass 71 | 72 | def _to_b64(raw): 73 | im = Image.open(BytesIO(raw)) 74 | try: 75 | if im.is_animated: 76 | oim = im 77 | oim.seek(oim.n_frames-1) 78 | im = Image.new('RGB', oim.size) 79 | im.paste(oim) 80 | except AttributeError: 81 | pass 82 | buffer = BytesIO() 83 | im.convert('RGB').save(buffer, format='JPEG') 84 | b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') 85 | return b64 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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("./captcha/model/cnn.pt.gz") 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 | SSOLoginDualDegree sso登录(双学位) 51 | Logout 登出 52 | HelpController 选课帮助页 53 | ShowResults 选课结果页 54 | SupplyCancel 补退选页 55 | Supplement 补退选页第一页之后 56 | DrawServlet 获取一张验证码 57 | validate 补退选验证码校验接口 58 | """ 59 | Scheme = "https" 60 | Host = "elective.pku.edu.cn" 61 | HomePage = "https://elective.pku.edu.cn/elective2008/" 62 | SSOLoginRedirect = "http://elective.pku.edu.cn:80/elective2008/ssoLogin.do" 63 | SSOLogin = "https://elective.pku.edu.cn/elective2008/ssoLogin.do" 64 | Logout = "https://elective.pku.edu.cn/elective2008/logout.do" 65 | HelpController = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/help/HelpController.jpf" 66 | ShowResults = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/electiveWork/showResults.do" 67 | SupplyCancel = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/SupplyCancel.do" 68 | Supplement = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/supplement.jsp" 69 | DrawServlet = "https://elective.pku.edu.cn/elective2008/DrawServlet" 70 | Validate = "https://elective.pku.edu.cn/elective2008/edu/pku/stu/elective/controller/supplement/validate.do" 71 | -------------------------------------------------------------------------------- /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) OR (maxi, used, waitlist) 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 | if len(self.status) == 2: # 2nd phase 57 | maxi, used = self._status 58 | return maxi > used 59 | else: # 1st phase: always available 60 | return True 61 | 62 | def to_simplified(self): 63 | return Course(self._name, self._class_no, self._school) 64 | 65 | def __eq__(self, other): 66 | if not isinstance(other, self.__class__): 67 | return False 68 | return self._ident == other._ident 69 | 70 | def __hash__(self): 71 | return hash(self._ident) 72 | 73 | def __repr__(self): 74 | if self._status is not None: 75 | return "%s(%s, %s, %s, %s)" % ( 76 | self.__class__.__name__, 77 | self._name, self._class_no, self._school, 78 | '/'.join(map(str, self._status)) 79 | ) 80 | else: 81 | return "%s(%s, %s, %s)" % ( 82 | self.__class__.__name__, 83 | self._name, self._class_no, self._school, 84 | ) 85 | -------------------------------------------------------------------------------- /autoelective/elective.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: elective.py 4 | # modified: 2019-09-10 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, 152 | params={ 153 | "xh": username 154 | }, 155 | headers=headers, 156 | hooks=_hooks_check_title, 157 | **kwargs, 158 | ) 159 | return r 160 | 161 | def get_supplement(self, username, page=1, **kwargs): 162 | """ 补退选(第二页及以后) """ 163 | assert page > 0 164 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 165 | headers["Cache-Control"] = "max-age=0" 166 | r = self._get( 167 | url=ElectiveURL.Supplement, 168 | params={ 169 | "netui_pagesize": "electableListGrid;20", 170 | "netui_row": "electableListGrid;%s" % ( (page - 1) * 20 ), 171 | "xh": username 172 | }, 173 | headers=headers, 174 | hooks=_hooks_check_title, 175 | **kwargs, 176 | ) 177 | return r 178 | 179 | def get_DrawServlet(self, **kwargs): 180 | """ 获得验证码 """ 181 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 182 | r = self._get( 183 | url=ElectiveURL.DrawServlet, 184 | params={ 185 | "Rand": str(random.random() * 10000), 186 | }, 187 | headers=headers, 188 | hooks=_hooks_check_status_code, 189 | **kwargs, 190 | ) 191 | return r 192 | 193 | def get_Validate(self, captcha, stuid, **kwargs): 194 | """ 验证用户输入的验证码 """ 195 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 196 | headers["Accept"] = "application/json, text/javascript, */*; q=0.01" 197 | headers["Accept-Encoding"] = "gzip, deflate, br" 198 | headers["Accept-Language"] = "en-US,en;q=0.9" 199 | headers["X-Requested-With"] = "XMLHttpRequest" 200 | r = self._post( 201 | url=ElectiveURL.Validate, 202 | data={ 203 | "xh": stuid, 204 | "validCode": captcha, 205 | }, 206 | headers=headers, 207 | hooks=_hooks_check_status_code, 208 | **kwargs, 209 | ) 210 | return r 211 | 212 | def get_ElectSupplement(self, href, **kwargs): 213 | """ 补选一门课 """ 214 | 215 | if "/supplement/electSupplement.do" not in href: 216 | raise RuntimeError( 217 | "If %r is really a 'electSupplement' href, it would certainly contains '/supplement/electSupplement.do'. " 218 | "If you see this error, that means maybe something terrible will happpen ! Please raise an issue at " 219 | "https://github.com/zhongxinghong/PKUAutoElective/issues" % href 220 | ) 221 | 222 | headers = _get_headers_with_referer(kwargs, ElectiveURL.SupplyCancel) 223 | r = self._get( 224 | url="%s://%s%s" % (ElectiveURL.Scheme, ElectiveURL.Host, href), 225 | headers=headers, 226 | hooks=_hooks_check_tips, 227 | **kwargs, 228 | ) 229 | return r 230 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "RecognizerError", 34 | "NoAuthInfoError", 35 | "SharedSessionError", 36 | "NotAgreedToSelectionAgreement", 37 | 38 | "TipsException", 39 | "ElectionSuccess", 40 | "ElectionRepeatedError", 41 | "TimeConflictError", 42 | "OperationTimeoutError", 43 | "ElectionPermissionError", 44 | "ElectionFailedError", 45 | "CreditsLimitedError", 46 | "MutexCourseError", 47 | "MultiEnglishCourseError", 48 | "ExamTimeConflictError", 49 | "QuotaLimitedError", 50 | "MultiPECourseError", 51 | 52 | ] 53 | 54 | 55 | class AutoElectiveException(Exception): 56 | """ Abstract Exception for AutoElective """ 57 | 58 | class UserInputException(AutoElectiveException, ValueError): 59 | """ 由于用户的输入数据不当而引发的错误 """ 60 | 61 | 62 | class AutoElectiveClientException(AutoElectiveException): 63 | 64 | code = -1 65 | desc = "AutoElectiveException" 66 | 67 | def __init__(self, *args, **kwargs): 68 | response = kwargs.pop("response", None) 69 | self.response = response 70 | msg = "[%d] %s" % ( 71 | self.__class__.code, 72 | kwargs.pop("msg", self.__class__.desc) 73 | ) 74 | super().__init__(msg, *args, **kwargs) 75 | 76 | 77 | class StatusCodeError(AutoElectiveClientException): 78 | code = 101 79 | desc = "response.status_code != 200" 80 | 81 | def __init__(self, *args, **kwargs): 82 | r = kwargs.get("response") 83 | if r is not None and "msg" not in kwargs: 84 | kwargs["msg"] = "%s. response status code: %s" % (self.__class__.code, r.status_code) 85 | super().__init__(*args, **kwargs) 86 | 87 | class ServerError(AutoElectiveClientException): 88 | code = 102 89 | desc = r"response.status_code in (500, 501, 502, 503)" 90 | 91 | def __init__(self, *args, **kwargs): 92 | r = kwargs.get("response") 93 | if r is not None and "msg" not in kwargs: 94 | kwargs["msg"] = "%s. response status_code: %s" % (self.__class__.code, r.status_code) 95 | super().__init__(*args, **kwargs) 96 | 97 | class OperationFailedError(AutoElectiveClientException): 98 | code = 103 99 | desc = r"some operations failed for unknown reasons" 100 | 101 | class UnexceptedHTMLFormat(AutoElectiveClientException): 102 | code = 104 103 | desc = r"unable to parse HTML content" 104 | 105 | 106 | class IAAAException(AutoElectiveClientException): 107 | code = 200 108 | desc = "IAAAException" 109 | 110 | 111 | class IAAANotSuccessError(IAAAException): 112 | code = 210 113 | desc = "response.json()['success'] == False" 114 | 115 | def __init__(self, *args, **kwargs): 116 | r = kwargs.get("response") 117 | if r is not None and "msg" not in kwargs: 118 | kwargs["msg"] = "%s. response JSON: %s" % (self.__class__.code, r.json()) 119 | super().__init__(*args, **kwargs) 120 | 121 | class IAAAIncorrectPasswordError(IAAANotSuccessError): 122 | code = 211 123 | desc = "User ID or Password is incorrect" 124 | 125 | class IAAAForbiddenError(IAAANotSuccessError): 126 | code = 212 127 | desc = "You are FORBIDDEN. Please sign in after a half hour" 128 | 129 | 130 | class ElectiveException(AutoElectiveClientException): 131 | code = 300 132 | desc = "ElectiveException" 133 | 134 | 135 | class SystemException(ElectiveException): 136 | code = 310 137 | desc = "系统异常" 138 | 139 | class CaughtCheatingError(SystemException): 140 | code = 311 141 | desc = "请不要用刷课机刷课,否则会受到学校严厉处分!" # 没有设 referer 142 | 143 | class InvalidTokenError(SystemException): 144 | code = 312 145 | desc = "Token无效" # sso_login 时出现,在上次登录前发生异地登录,缓存 token 失效 146 | 147 | class SessionExpiredError(SystemException): 148 | code = 313 149 | desc = "您尚未登录或者会话超时,请重新登录." # 相当于 token 失效 150 | 151 | class NotInOperationTimeError(SystemException): 152 | code = 314 153 | desc = "不在操作时段" 154 | 155 | class CourseIndexError(SystemException): 156 | code = 315 157 | desc = "索引错误。" 158 | 159 | class CaptchaError(SystemException): 160 | code = 316 161 | desc = "验证码不正确。" 162 | 163 | class RecognizerError(SystemException): 164 | pass 165 | 166 | class NoAuthInfoError(SystemException): 167 | code = 317 168 | desc = "无验证信息。" # 仅辅双登录时会出现 169 | 170 | class SharedSessionError(SystemException): 171 | code = 318 172 | desc = "你与他人共享了回话,请退出浏览器重新登录。" 173 | 174 | class NotAgreedToSelectionAgreement(SystemException): 175 | code = 319 176 | desc = "只有同意选课协议才可以继续选课! " 177 | 178 | 179 | class TipsException(ElectiveException): 180 | code = 330 181 | desc = "TipsException" 182 | 183 | class ElectionSuccess(TipsException): 184 | code = 331 185 | desc = "补选课程成功,请查看已选上列表确认,并查看选课结果。" 186 | 187 | class ElectionRepeatedError(TipsException): 188 | code = 332 189 | desc = "您已经选过该课程了。" 190 | 191 | class TimeConflictError(TipsException): 192 | code = 333 193 | desc = "上课时间冲突" 194 | 195 | class OperationTimeoutError(TipsException): 196 | code = 334 197 | desc = "对不起,超时操作,请重新登录。" 198 | 199 | class ElectionPermissionError(TipsException): 200 | code = 335 201 | desc = "该课程在补退选阶段开始后的约一周开放选课" 202 | 203 | class ElectionFailedError(TipsException): 204 | code = 336 205 | desc = "选课操作失败,请稍后再试。" 206 | 207 | class CreditsLimitedError(TipsException): 208 | code = 327 209 | desc = "您本学期所选课程的总学分已经超过规定学分上限。" 210 | 211 | class MutexCourseError(TipsException): 212 | code = 328 213 | desc = "只能选其一门。" 214 | 215 | class MultiEnglishCourseError(TipsException): 216 | code = 329 217 | desc = "学校规定每学期只能修一门英语课,因此您不能选择该课。" 218 | 219 | class ExamTimeConflictError(TipsException): 220 | code = 330 221 | desc = "考试时间冲突" 222 | 223 | class QuotaLimitedError(TipsException): 224 | code = 331 225 | desc = "该课程选课人数已满。" 226 | 227 | class MultiPECourseError(TimeoutError): 228 | code = 332 229 | desc = "学校规定每学期只能修一门体育课。" 230 | -------------------------------------------------------------------------------- /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 | # __regex_errInfo = re.compile(r"出错提示:(\S+?)
", re.S) 25 | _regexErrorOperatingTime = re.compile(r'目前不是(.*?)时间,因此不能进行相应操作。') 26 | _regexElectionSuccess = re.compile(r'补选(.+)成功,请查看已选上列表确认,并查看选课结果。') 27 | _regexMutex = re.compile(r'(.+)与(.+)只能选其一门。') 28 | 29 | _DUMMY_HOOK = {"response": []} 30 | 31 | 32 | def get_hooks(*fn): 33 | return {"response": fn} 34 | 35 | def merge_hooks(*hooklike): 36 | funcs = [] 37 | for hook in hooklike: 38 | if isinstance(hook, dict): 39 | funcs.extend(hook["response"]) 40 | elif callable(hook): # function 41 | funcs.append(hook) 42 | else: 43 | raise TypeError(hook) 44 | return get_hooks(*funcs) 45 | 46 | def with_etree(r, **kwargs): 47 | r._tree = get_tree_from_response(r) 48 | 49 | def del_etree(r, **kwargs): 50 | del r._tree 51 | 52 | 53 | def check_status_code(r, **kwargs): 54 | if r.status_code != 200: 55 | if r.status_code in (301,302,304): 56 | pass 57 | elif r.status_code in (500,501,502,503): 58 | raise ServerError(response=r) 59 | else: 60 | raise StatusCodeError(response=r) 61 | 62 | 63 | def check_iaaa_success(r, **kwargs): 64 | respJson = r.json() 65 | 66 | if not respJson.get("success", False): 67 | try: 68 | errors = respJson["errors"] 69 | code = errors["code"] 70 | msg = errors["msg"] 71 | except Exception as e: 72 | cout.error(e) 73 | cout.info("Unable to get errcode/errmsg from response JSON") 74 | pass 75 | else: 76 | if code == "E01": 77 | raise IAAAIncorrectPasswordError(response=r, msg=msg) 78 | elif code == "E21": 79 | raise IAAAForbiddenError(response=r, msg=msg) 80 | 81 | raise IAAANotSuccessError(response=r) 82 | 83 | 84 | def check_elective_title(r, **kwargs): 85 | assert hasattr(r, "_tree") 86 | 87 | title = get_title(r._tree) 88 | if title is None: 89 | return 90 | 91 | try: 92 | if title in ("系统异常", "系统提示"): 93 | # err = __regex_errInfo.search(r.text).group(1) 94 | err = get_errInfo(r._tree) 95 | 96 | if err == "token无效": # sso_login 时出现 97 | raise InvalidTokenError(response=r) 98 | 99 | elif err == "您尚未登录或者会话超时,请重新登录.": 100 | raise SessionExpiredError(response=r) 101 | 102 | elif err == "请不要用刷课机刷课,否则会受到学校严厉处分!": 103 | raise CaughtCheatingError(response=r) 104 | 105 | elif err == "索引错误。": 106 | raise CourseIndexError(response=r) 107 | 108 | elif err == "验证码不正确。": 109 | raise CaptchaError(response=r) 110 | 111 | elif err == "无验证信息。": 112 | raise NoAuthInfoError(response=r) 113 | 114 | elif err == "你与他人共享了回话,请退出浏览器重新登录。": 115 | raise SharedSessionError(response=r) 116 | 117 | elif err == "只有同意选课协议才可以继续选课!": 118 | raise NotAgreedToSelectionAgreement(response=r) 119 | 120 | elif _regexErrorOperatingTime.search(err): 121 | raise NotInOperationTimeError(response=r, msg=err) 122 | 123 | else: 124 | raise SystemException(response=r, msg=err) 125 | 126 | except Exception as e: 127 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 128 | r.request._client.persist_cookies(r) 129 | raise e 130 | 131 | 132 | def check_elective_tips(r, **kwargs): 133 | assert hasattr(r, "_tree") 134 | tips = get_tips(r._tree) 135 | 136 | try: 137 | 138 | if tips is None: 139 | return 140 | 141 | elif tips == "您已经选过该课程了。": 142 | raise ElectionRepeatedError(response=r) 143 | 144 | elif tips == "对不起,超时操作,请重新登录。": 145 | raise OperationTimeoutError(response=r) 146 | 147 | elif tips == "选课操作失败,请稍后再试。": 148 | raise ElectionFailedError(response=r) 149 | 150 | elif tips == "您本学期所选课程的总学分已经超过规定学分上限。": 151 | raise CreditsLimitedError(response=r) 152 | 153 | elif tips == "学校规定每学期只能修一门英语课,因此您不能选择该课。": 154 | raise MultiEnglishCourseError(response=r) 155 | 156 | elif tips.startswith("上课时间冲突"): 157 | raise TimeConflictError(response=r, msg=tips) 158 | 159 | elif tips.startswith("考试时间冲突"): 160 | raise ExamTimeConflictError(response=r, msg=tips) 161 | 162 | elif tips.startswith("该课程在补退选阶段开始后的约一周开放选课"): # 这个可能需要根据当学期情况进行修改 163 | raise ElectionPermissionError(response=r, msg=tips) 164 | 165 | elif tips.startswith("该课程选课人数已满"): 166 | raise QuotaLimitedError(response=r, msg=tips) 167 | 168 | elif tips.startswith("学校规定每学期只能修一门体育课"): 169 | raise MultiPECourseError(response=r, msg=tips) 170 | 171 | elif _regexElectionSuccess.search(tips): 172 | raise ElectionSuccess(response=r, msg=tips) 173 | 174 | elif _regexMutex.search(tips): 175 | raise MutexCourseError(response=r, msg=tips) 176 | 177 | else: 178 | cout.warning("Unknown tips: %s" % tips) 179 | # raise TipsException(response=r, msg=tips) 180 | 181 | except Exception as e: 182 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 183 | r.request._client.persist_cookies(r) 184 | raise e 185 | 186 | 187 | def debug_print_request(r, **kwargs): 188 | if not config.is_debug_print_request: 189 | return 190 | cout.debug("> %s %s" % (r.request.method, r.url)) 191 | cout.debug("> Headers:") 192 | for k, v in r.request.headers.items(): 193 | cout.debug("%s: %s" % (k, v)) 194 | cout.debug("> Body:") 195 | cout.debug(r.request.body) 196 | cout.debug("> Response Headers:") 197 | for k, v in r.headers.items(): 198 | cout.debug("%s: %s" % (k, v)) 199 | cout.debug("") 200 | 201 | 202 | def _dump_request(r): 203 | if "_client" in r.request.__dict__: # _client will be set by BaseClient 204 | client = r.request._client 205 | r.request._client = None # don't save client object 206 | 207 | hooks = r.request.hooks 208 | r.request.hooks = _DUMMY_HOOK # don't save hooks array 209 | 210 | timestamp = time.strftime("%Y-%m-%d_%H.%M.%S%z") 211 | basename = quote(urlparse(r.url).path, '') 212 | filename = "%s.%s.gz" % (timestamp, basename) # put timestamp first 213 | file = os.path.normpath(os.path.abspath(os.path.join(_USER_REQUEST_LOG_DIR, filename))) 214 | 215 | pickle_gzip_dump(r, file) 216 | 217 | # restore objects defined by autoelective package 218 | if "_client" in r.request.__dict__: 219 | r.request._client = client 220 | r.request.hooks = hooks 221 | 222 | return file 223 | 224 | 225 | def debug_dump_request(r, **kwargs): 226 | if not config.is_debug_dump_request: 227 | return 228 | file = _dump_request(r) 229 | cout.debug("Dump request %s to %s" % (r.url, file)) 230 | -------------------------------------------------------------------------------- /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/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/loop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # filename: loop.py 4 | # modified: 2019-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 .captcha import TTShituRecognizer, Captcha 21 | from .parser import get_tables, get_courses, get_courses_with_detail, get_sida 22 | from .hook import _dump_request 23 | from .iaaa import IAAAClient 24 | from .elective import ElectiveClient 25 | from .const import CAPTCHA_CACHE_DIR, USER_AGENT_LIST, WEB_LOG_DIR 26 | from .exceptions import * 27 | from ._internal import mkdir 28 | 29 | environ = Environ() 30 | config = AutoElectiveConfig() 31 | cout = ConsoleLogger("loop") 32 | ferr = FileLogger("loop.error") # loop 的子日志,同步输出到 console 33 | 34 | username = config.iaaa_id 35 | password = config.iaaa_password 36 | is_dual_degree = config.is_dual_degree 37 | identity = config.identity 38 | refresh_interval = config.refresh_interval 39 | refresh_random_deviation = config.refresh_random_deviation 40 | supply_cancel_page = config.supply_cancel_page 41 | iaaa_client_timeout = config.iaaa_client_timeout 42 | elective_client_timeout = config.elective_client_timeout 43 | login_loop_interval = config.login_loop_interval 44 | elective_client_pool_size = config.elective_client_pool_size 45 | elective_client_max_life = config.elective_client_max_life 46 | is_print_mutex_rules = config.is_print_mutex_rules 47 | 48 | config.check_identify(identity) 49 | config.check_supply_cancel_page(supply_cancel_page) 50 | 51 | _USER_WEB_LOG_DIR = os.path.join(WEB_LOG_DIR, config.get_user_subpath()) 52 | mkdir(_USER_WEB_LOG_DIR) 53 | 54 | # recognizer = CaptchaRecognizer() 55 | recognizer = TTShituRecognizer() 56 | RECOGNIZER_MAX_ATTEMPT = 15 57 | 58 | electivePool = Queue(maxsize=elective_client_pool_size) 59 | reloginPool = Queue(maxsize=elective_client_pool_size) 60 | 61 | goals = environ.goals # let N = len(goals); 62 | ignored = environ.ignored 63 | mutexes = np.zeros(0, dtype=np.uint8) # uint8 [N][N]; 64 | delays = np.zeros(0, dtype=np.int) # int [N]; 65 | 66 | killedElective = ElectiveClient(-1) 67 | NO_DELAY = -1 68 | 69 | 70 | class _ElectiveNeedsLogin(Exception): 71 | pass 72 | 73 | class _ElectiveExpired(Exception): 74 | pass 75 | 76 | class _ElectiveCorrupted(Exception): 77 | pass 78 | 79 | 80 | def _get_refresh_interval(eps=0.1): 81 | if refresh_random_deviation <= 0: 82 | return refresh_interval 83 | delta = (random.random() * 2 - 1) * refresh_random_deviation * refresh_interval 84 | ret = refresh_interval + delta 85 | return ret if (ret > eps) else eps 86 | 87 | def _ignore_course(course, reason): 88 | ignored[course.to_simplified()] = reason 89 | 90 | def _add_error(e): 91 | clz = e.__class__ 92 | name = clz.__name__ 93 | key = "[%s] %s" % (e.code, name) if hasattr(clz, "code") else name 94 | environ.errors[key] += 1 95 | 96 | def _format_timestamp(timestamp): 97 | if timestamp == -1: 98 | return str(timestamp) 99 | return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) 100 | 101 | def _dump_respose_content(content, filename): 102 | path = os.path.join(_USER_WEB_LOG_DIR, filename) 103 | with open(path, 'wb') as fp: 104 | fp.write(content) 105 | 106 | 107 | def run_iaaa_loop(): 108 | 109 | elective = None 110 | 111 | while True: 112 | 113 | if elective is None: 114 | elective = reloginPool.get() 115 | if elective is killedElective: 116 | cout.info("Quit IAAA loop") 117 | return 118 | 119 | environ.iaaa_loop += 1 120 | user_agent = random.choice(USER_AGENT_LIST) 121 | 122 | cout.info("Try to login IAAA (client: %s)" % elective.id) 123 | cout.info("User-Agent: %s" % user_agent) 124 | 125 | try: 126 | 127 | iaaa = IAAAClient(timeout=iaaa_client_timeout) # not reusable 128 | iaaa.set_user_agent(user_agent) 129 | 130 | # request elective's home page to get cookies 131 | r = iaaa.oauth_home() 132 | 133 | r = iaaa.oauth_login(username, password) 134 | 135 | try: 136 | token = r.json()["token"] 137 | except Exception as e: 138 | ferr.error(e) 139 | raise OperationFailedError(msg="Unable to parse IAAA token. response body: %s" % r.content) 140 | 141 | elective.clear_cookies() 142 | elective.set_user_agent(user_agent) 143 | 144 | r = elective.sso_login(token) 145 | 146 | if is_dual_degree: 147 | sida = get_sida(r) 148 | sttp = identity 149 | referer = r.url 150 | r = elective.sso_login_dual_degree(sida, sttp, referer) 151 | 152 | if elective_client_max_life == -1: 153 | elective.set_expired_time(-1) 154 | else: 155 | elective.set_expired_time(int(time.time()) + elective_client_max_life) 156 | 157 | cout.info("Login success (client: %s, expired_time: %s)" % ( 158 | elective.id, _format_timestamp(elective.expired_time))) 159 | cout.info("") 160 | 161 | electivePool.put_nowait(elective) 162 | elective = None 163 | 164 | except (ServerError, StatusCodeError) as e: 165 | ferr.error(e) 166 | cout.warning("ServerError/StatusCodeError encountered") 167 | _add_error(e) 168 | 169 | except OperationFailedError as e: 170 | ferr.error(e) 171 | cout.warning("OperationFailedError encountered") 172 | _add_error(e) 173 | 174 | except RequestException as e: 175 | ferr.error(e) 176 | cout.warning("RequestException encountered") 177 | _add_error(e) 178 | 179 | except IAAAIncorrectPasswordError as e: 180 | cout.error(e) 181 | _add_error(e) 182 | raise e 183 | 184 | except IAAAForbiddenError as e: 185 | ferr.error(e) 186 | _add_error(e) 187 | raise e 188 | 189 | except IAAAException as e: 190 | ferr.error(e) 191 | cout.warning("IAAAException encountered") 192 | _add_error(e) 193 | 194 | except CaughtCheatingError as e: 195 | ferr.critical(e) # 严重错误 196 | _add_error(e) 197 | raise e 198 | 199 | except ElectiveException as e: 200 | ferr.error(e) 201 | cout.warning("ElectiveException encountered") 202 | _add_error(e) 203 | 204 | except json.JSONDecodeError as e: 205 | ferr.error(e) 206 | cout.warning("JSONDecodeError encountered") 207 | _add_error(e) 208 | 209 | except KeyboardInterrupt as e: 210 | raise e 211 | 212 | except Exception as e: 213 | ferr.exception(e) 214 | _add_error(e) 215 | raise e 216 | 217 | finally: 218 | t = login_loop_interval 219 | cout.info("") 220 | cout.info("IAAA login loop sleep %s s" % t) 221 | cout.info("") 222 | time.sleep(t) 223 | 224 | 225 | def run_elective_loop(): 226 | 227 | elective = None 228 | noWait = False 229 | 230 | ## load courses 231 | 232 | cs = config.courses # OrderedDict 233 | N = len(cs) 234 | cid_cix = {} # { cid: cix } 235 | 236 | for ix, (cid, c) in enumerate(cs.items()): 237 | goals.append(c) 238 | cid_cix[cid] = ix 239 | 240 | ## load mutex 241 | 242 | ms = config.mutexes 243 | mutexes.resize((N, N), refcheck=False) 244 | 245 | for mid, m in ms.items(): 246 | ixs = [] 247 | for cid in m.cids: 248 | if cid not in cs: 249 | raise UserInputException("In 'mutex:%s', course %r is not defined" % (mid, cid)) 250 | ix = cid_cix[cid] 251 | ixs.append(ix) 252 | for ix1, ix2 in combinations(ixs, 2): 253 | mutexes[ix1, ix2] = mutexes[ix2, ix1] = 1 254 | 255 | ## load delay 256 | 257 | ds = config.delays 258 | delays.resize(N, refcheck=False) 259 | delays.fill(NO_DELAY) 260 | 261 | for did, d in ds.items(): 262 | cid = d.cid 263 | if cid not in cs: 264 | raise UserInputException("In 'delay:%s', course %r is not defined" % (did, cid)) 265 | ix = cid_cix[cid] 266 | delays[ix] = d.threshold 267 | 268 | ## setup elective pool 269 | 270 | for ix in range(1, elective_client_pool_size + 1): 271 | client = ElectiveClient(id=ix, timeout=elective_client_timeout) 272 | client.set_user_agent(random.choice(USER_AGENT_LIST)) 273 | electivePool.put_nowait(client) 274 | 275 | ## print header 276 | 277 | header = "# PKU Auto-Elective Tool v%s (%s) #" % (__version__, __date__) 278 | line = "#" + "-" * (len(header) - 2) + "#" 279 | 280 | cout.info(line) 281 | cout.info(header) 282 | cout.info(line) 283 | cout.info("") 284 | 285 | line = "-" * 30 286 | 287 | cout.info("> User Agent") 288 | cout.info(line) 289 | cout.info("pool_size: %d" % len(USER_AGENT_LIST)) 290 | cout.info(line) 291 | cout.info("") 292 | cout.info("> Config") 293 | cout.info(line) 294 | cout.info("is_dual_degree: %s" % is_dual_degree) 295 | cout.info("identity: %s" % identity) 296 | cout.info("refresh_interval: %s" % refresh_interval) 297 | cout.info("refresh_random_deviation: %s" % refresh_random_deviation) 298 | cout.info("supply_cancel_page: %s" % supply_cancel_page) 299 | cout.info("iaaa_client_timeout: %s" % iaaa_client_timeout) 300 | cout.info("elective_client_timeout: %s" % elective_client_timeout) 301 | cout.info("login_loop_interval: %s" % login_loop_interval) 302 | cout.info("elective_client_pool_size: %s" % elective_client_pool_size) 303 | cout.info("elective_client_max_life: %s" % elective_client_max_life) 304 | cout.info("is_print_mutex_rules: %s" % is_print_mutex_rules) 305 | cout.info(line) 306 | cout.info("") 307 | 308 | while True: 309 | 310 | noWait = False 311 | 312 | if elective is None: 313 | elective = electivePool.get() 314 | 315 | environ.elective_loop += 1 316 | 317 | cout.info("") 318 | cout.info("======== Loop %d ========" % environ.elective_loop) 319 | cout.info("") 320 | 321 | ## print current plans 322 | 323 | current = [ c for c in goals if c not in ignored ] 324 | if len(current) > 0: 325 | cout.info("> Current tasks") 326 | cout.info(line) 327 | for ix, course in enumerate(current): 328 | cout.info("%02d. %s" % (ix + 1, course)) 329 | cout.info(line) 330 | cout.info("") 331 | 332 | ## print ignored course 333 | 334 | if len(ignored) > 0: 335 | cout.info("> Ignored tasks") 336 | cout.info(line) 337 | for ix, (course, reason) in enumerate(ignored.items()): 338 | cout.info("%02d. %s %s" % (ix + 1, course, reason)) 339 | cout.info(line) 340 | cout.info("") 341 | 342 | ## print mutex rules 343 | 344 | if np.any(mutexes): 345 | cout.info("> Mutex rules") 346 | cout.info(line) 347 | ixs = [ (ix1, ix2) for ix1, ix2 in np.argwhere( mutexes == 1 ) if ix1 < ix2 ] 348 | if is_print_mutex_rules: 349 | for ix, (ix1, ix2) in enumerate(ixs): 350 | cout.info("%02d. %s --x-- %s" % (ix + 1, goals[ix1], goals[ix2])) 351 | else: 352 | cout.info("%d mutex rules" % len(ixs)) 353 | cout.info(line) 354 | cout.info("") 355 | 356 | ## print delay rules 357 | 358 | if np.any( delays != NO_DELAY ): 359 | cout.info("> Delay rules") 360 | cout.info(line) 361 | ds = [ (cix, threshold) for cix, threshold in enumerate(delays) if threshold != NO_DELAY ] 362 | for ix, (cix, threshold) in enumerate(ds): 363 | cout.info("%02d. %s --- %d" % (ix + 1, goals[cix], threshold)) 364 | cout.info(line) 365 | cout.info("") 366 | 367 | if len(current) == 0: 368 | cout.info("No tasks") 369 | cout.info("Quit elective loop") 370 | reloginPool.put_nowait(killedElective) # kill signal 371 | return 372 | 373 | ## print client info 374 | 375 | cout.info("> Current client: %s (qsize: %s)" % (elective.id, electivePool.qsize() + 1)) 376 | cout.info("> Client expired time: %s" % _format_timestamp(elective.expired_time)) 377 | cout.info("User-Agent: %s" % elective.user_agent) 378 | cout.info("") 379 | 380 | try: 381 | 382 | if not elective.has_logined: 383 | raise _ElectiveNeedsLogin # quit this loop 384 | 385 | if elective.is_expired: 386 | try: 387 | cout.info("Logout") 388 | r = elective.logout() 389 | except Exception as e: 390 | cout.warning("Logout error") 391 | cout.exception(e) 392 | raise _ElectiveExpired # quit this loop 393 | ## check supply/cancel page 394 | 395 | page_r = None 396 | 397 | if supply_cancel_page == 1: 398 | 399 | cout.info("Get SupplyCancel page %s" % supply_cancel_page) 400 | 401 | r = page_r = elective.get_SupplyCancel(username) 402 | tables = get_tables(r._tree) 403 | try: 404 | elected = get_courses(tables[1]) 405 | plans = get_courses_with_detail(tables[0]) 406 | except IndexError as e: 407 | filename = "elective.get_SupplyCancel_%d.html" % int(time.time() * 1000) 408 | _dump_respose_content(r.content, filename) 409 | cout.info("Page dump to %s" % filename) 410 | raise UnexceptedHTMLFormat 411 | 412 | else: 413 | # 414 | # 刷新非第一页的课程,第一次请求会遇到返回空页面的情况 415 | # 416 | # 模拟方法: 417 | # 1.先登录辅双,打开补退选第二页 418 | # 2.再在同一浏览器登录主修 419 | # 3.刷新辅双的补退选第二页可以看到 420 | # 421 | # ----------------------------------------------- 422 | # 423 | # 引入 retry 逻辑以防止以为某些特殊原因无限重试 424 | # 正常情况下一次就能成功,但是为了应对某些偶发错误,这里设为最多尝试 3 次 425 | # 426 | # 特殊情况下 retry 达到最大次数后仍然失败,并且这个 client 以后也会一直失败 427 | # 解决方法:抛出 _ElectiveCorrupted 使该 client 重新登录 428 | # 429 | retry = 3 430 | while True: 431 | if retry == 0: # maximum retry reached 432 | cout.error("unable to get normal Supplement page %s" % supply_cancel_page) 433 | raise _ElectiveCorrupted 434 | 435 | cout.info("Get Supplement page %s" % supply_cancel_page) 436 | r = page_r = elective.get_supplement(username, page=supply_cancel_page) # 双学位第二页 437 | tables = get_tables(r._tree) 438 | try: 439 | elected = get_courses(tables[1]) 440 | plans = get_courses_with_detail(tables[0]) 441 | except IndexError as e: 442 | cout.warning("IndexError encountered") 443 | cout.info("Get SupplyCancel first to prevent empty table returned") 444 | _ = elective.get_SupplyCancel(username) # 遇到空页面时请求一次补退选主页,之后就可以不断刷新 445 | else: 446 | break 447 | finally: 448 | retry -= 1 449 | 450 | ## check available courses 451 | 452 | cout.info("Get available courses") 453 | 454 | tasks = [] # [(ix, course)] 455 | for ix, c in enumerate(goals): 456 | if c in ignored: 457 | continue 458 | elif c in elected: 459 | cout.info("%s is elected, ignored" % c) 460 | _ignore_course(c, "Elected") 461 | for (mix, ) in np.argwhere( mutexes[ix,:] == 1 ): 462 | mc = goals[mix] 463 | if mc in ignored: 464 | continue 465 | cout.info("%s is simultaneously ignored by mutex rules" % mc) 466 | _ignore_course(mc, "Mutex rules") 467 | else: 468 | for c0 in plans: # c0 has detail 469 | if c0 == c: 470 | if c0.is_available(): 471 | delay = delays[ix] 472 | if delay != NO_DELAY and c0.remaining_quota > delay: 473 | cout.info("%s hasn't reached the delay threshold %d, skip" % (c0, delay)) 474 | else: 475 | tasks.append((ix, c0)) 476 | cout.info("%s is AVAILABLE now !" % c0) 477 | break 478 | else: 479 | raise UserInputException("%s is not in your course plan, please check your config." % c) 480 | 481 | tasks = deque([ (ix, c) for ix, c in tasks if c not in ignored ]) # filter again and change to deque 482 | 483 | ## elect available courses 484 | 485 | if len(tasks) == 0: 486 | cout.info("No course available") 487 | continue 488 | 489 | elected = [] # cache elected courses dynamically from `get_ElectSupplement` 490 | 491 | while len(tasks) > 0: 492 | 493 | ix, course = tasks.popleft() 494 | 495 | is_mutex = False 496 | 497 | # dynamically filter course by mutex rules 498 | for (mix, ) in np.argwhere( mutexes[ix,:] == 1 ): 499 | mc = goals[mix] 500 | if mc in elected: # ignore course in advanced 501 | is_mutex = True 502 | cout.info("%s --x-- %s" % (course, mc)) 503 | cout.info("%s is ignored by mutex rules in advance" % course) 504 | _ignore_course(course, "Mutex rules") 505 | break 506 | 507 | if is_mutex: 508 | continue 509 | 510 | cout.info("Try to elect %s" % course) 511 | 512 | ## validate captcha first 513 | recognizer_attemp = 0 514 | while True: 515 | 516 | cout.info("Fetch a captcha") 517 | r = elective.get_DrawServlet() 518 | 519 | captcha = recognizer.recognize(r.content) 520 | cout.info("Recognition result: %s" % captcha.code) 521 | 522 | r = elective.get_Validate(captcha.code, config.iaaa_id) 523 | try: 524 | res = r.json()["valid"] # 可能会返回一个错误网页 ... 525 | except Exception as e: 526 | ferr.error(e) 527 | raise OperationFailedError(msg="Unable to validate captcha") 528 | 529 | if res == "2": 530 | cout.info("Validation passed") 531 | break 532 | elif res == "0": 533 | cout.info("Validation failed") 534 | recognizer.report_last_error() 535 | cout.info("Try again") 536 | recognizer_attemp += 1 537 | else: 538 | cout.warning("Unknown validation result: %s" % res) 539 | 540 | if recognizer_attemp >= RECOGNIZER_MAX_ATTEMPT: 541 | raise RecognizerError(msg="Recognizer: max attempts %d reached" % RECOGNIZER_MAX_ATTEMPT) 542 | 543 | ## try to elect 544 | 545 | try: 546 | 547 | r = elective.get_ElectSupplement(course.href) 548 | 549 | except ElectionRepeatedError as e: 550 | ferr.error(e) 551 | cout.warning("ElectionRepeatedError encountered") 552 | _ignore_course(course, "Repeated") 553 | _add_error(e) 554 | 555 | except TimeConflictError as e: 556 | ferr.error(e) 557 | cout.warning("TimeConflictError encountered") 558 | _ignore_course(course, "Time conflict") 559 | _add_error(e) 560 | 561 | except ExamTimeConflictError as e: 562 | ferr.error(e) 563 | cout.warning("ExamTimeConflictError encountered") 564 | _ignore_course(course, "Exam time conflict") 565 | _add_error(e) 566 | 567 | except ElectionPermissionError as e: 568 | ferr.error(e) 569 | cout.warning("ElectionPermissionError encountered") 570 | _ignore_course(course, "Permission required") 571 | _add_error(e) 572 | 573 | except CreditsLimitedError as e: 574 | ferr.error(e) 575 | cout.warning("CreditsLimitedError encountered") 576 | _ignore_course(course, "Credits limited") 577 | _add_error(e) 578 | 579 | except MutexCourseError as e: 580 | ferr.error(e) 581 | cout.warning("MutexCourseError encountered") 582 | _ignore_course(course, "Mutual exclusive") 583 | _add_error(e) 584 | 585 | except MultiEnglishCourseError as e: 586 | ferr.error(e) 587 | cout.warning("MultiEnglishCourseError encountered") 588 | _ignore_course(course, "Multi English course") 589 | _add_error(e) 590 | 591 | except MultiPECourseError as e: 592 | ferr.error(e) 593 | cout.warning("MultiPECourseError encountered") 594 | _ignore_course(course, "Multi PE course") 595 | _add_error(e) 596 | 597 | except ElectionFailedError as e: 598 | ferr.error(e) 599 | cout.warning("ElectionFailedError encountered") # 具体原因不明,且不能马上重试 600 | _add_error(e) 601 | 602 | except QuotaLimitedError as e: 603 | ferr.error(e) 604 | # 选课网可能会发回异常数据,本身名额 180/180 的课会发 180/0,这个时候选课会得到这个错误 605 | if course.used_quota == 0: 606 | cout.warning("Abnormal status of %s, a bug of 'elective.pku.edu.cn' found" % course) 607 | else: 608 | ferr.critical("Unexcepted behaviour") # 没有理由运行到这里 609 | _add_error(e) 610 | 611 | except ElectionSuccess as e: 612 | # 不从此处加入 ignored,而是在下回合根据教学网返回的实际选课结果来决定是否忽略 613 | cout.info("%s is ELECTED (OR WAITLISTED)!" % course) 614 | 615 | # -------------------------------------------------------------------------- 616 | # Issue #25 617 | # -------------------------------------------------------------------------- 618 | # 但是动态地更新 elected,如果同一回合内有多门课可以被选,并且根据 mutex rules, 619 | # 低优先级的课和刚选上的高优先级课冲突,那么轮到低优先级的课提交选课请求的时候, 620 | # 根据这个动态更新的 elected 它将会被提前地忽略(而不是留到下一循环回合的开始时才被忽略) 621 | # -------------------------------------------------------------------------- 622 | r = e.response # get response from error ... a bit ugly 623 | tables = get_tables(r._tree) 624 | # use clear() + extend() instead of op `=` to ensure `id(elected)` doesn't change 625 | elected.clear() 626 | elected.extend(get_courses(tables[1])) 627 | 628 | except RuntimeError as e: 629 | ferr.critical(e) 630 | ferr.critical("RuntimeError with Course(name=%r, class_no=%d, school=%r, status=%s, href=%r)" % ( 631 | course.name, course.class_no, course.school, course.status, course.href)) 632 | # use this private function of 'hook.py' to dump the response from `get_SupplyCancel` or `get_supplement` 633 | file = _dump_request(page_r) 634 | ferr.critical("Dump response from 'get_SupplyCancel / get_supplement' to %s" % file) 635 | raise e 636 | 637 | except Exception as e: 638 | raise e # don't increase error count here 639 | 640 | except UserInputException as e: 641 | cout.error(e) 642 | _add_error(e) 643 | raise e 644 | 645 | except (ServerError, StatusCodeError) as e: 646 | ferr.error(e) 647 | cout.warning("ServerError/StatusCodeError encountered") 648 | _add_error(e) 649 | 650 | except OperationFailedError as e: 651 | ferr.error(e) 652 | cout.warning("OperationFailedError encountered") 653 | _add_error(e) 654 | 655 | except UnexceptedHTMLFormat as e: 656 | ferr.error(e) 657 | cout.warning("UnexceptedHTMLFormat encountered") 658 | _add_error(e) 659 | 660 | except RequestException as e: 661 | ferr.error(e) 662 | cout.warning("RequestException encountered") 663 | _add_error(e) 664 | 665 | except IAAAException as e: 666 | ferr.error(e) 667 | cout.warning("IAAAException encountered") 668 | _add_error(e) 669 | 670 | except _ElectiveNeedsLogin as e: 671 | cout.info("client: %s needs Login" % elective.id) 672 | reloginPool.put_nowait(elective) 673 | elective = None 674 | noWait = True 675 | 676 | except _ElectiveExpired as e: 677 | cout.info("client: %s expired" % elective.id) 678 | reloginPool.put_nowait(elective) 679 | elective = None 680 | noWait = True 681 | 682 | except _ElectiveCorrupted as e: 683 | cout.info("client: %s is probably corrupted, try to relogin" % elective.id) 684 | reloginPool.put_nowait(elective) 685 | elective = None 686 | noWait = True 687 | 688 | except (SessionExpiredError, InvalidTokenError, NoAuthInfoError, SharedSessionError) as e: 689 | ferr.error(e) 690 | _add_error(e) 691 | cout.info("client: %s needs relogin" % elective.id) 692 | reloginPool.put_nowait(elective) 693 | elective = None 694 | noWait = True 695 | 696 | except CaughtCheatingError as e: 697 | ferr.critical(e) # critical error ! 698 | _add_error(e) 699 | raise e 700 | 701 | except RecognizerError as e: 702 | ferr.critical(e) 703 | _add_error(e) 704 | raise e 705 | 706 | except SystemException as e: 707 | ferr.error(e) 708 | cout.warning("SystemException encountered") 709 | _add_error(e) 710 | 711 | except TipsException as e: 712 | ferr.error(e) 713 | cout.warning("TipsException encountered") 714 | _add_error(e) 715 | 716 | except OperationTimeoutError as e: 717 | ferr.error(e) 718 | cout.warning("OperationTimeoutError encountered") 719 | _add_error(e) 720 | 721 | except json.JSONDecodeError as e: 722 | ferr.error(e) 723 | cout.warning("JSONDecodeError encountered") 724 | _add_error(e) 725 | 726 | except KeyboardInterrupt as e: 727 | raise e 728 | 729 | except Exception as e: 730 | ferr.exception(e) 731 | _add_error(e) 732 | raise e 733 | 734 | finally: 735 | 736 | if elective is not None: # change elective client 737 | electivePool.put_nowait(elective) 738 | elective = None 739 | 740 | if noWait: 741 | cout.info("") 742 | cout.info("======== END Loop %d ========" % environ.elective_loop) 743 | cout.info("") 744 | else: 745 | t = _get_refresh_interval() 746 | cout.info("") 747 | cout.info("======== END Loop %d ========" % environ.elective_loop) 748 | cout.info("Main loop sleep %s s" % t) 749 | cout.info("") 750 | time.sleep(t) 751 | -------------------------------------------------------------------------------- /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/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 | try: 68 | ixs = tuple(map(header.index, ["课程名","班号","开课单位","限数/已选","补选"])) 69 | except ValueError: 70 | ixs = tuple(map(header.index, ["课程名","班号","开课单位","限数/已选/候补","补选"])) 71 | cs = [] 72 | for tr in trs: 73 | t = tr.xpath('./th | ./td') 74 | name, class_no, school, status, _ = map(lambda ix: t[ix].xpath('.//text()')[0], ixs) 75 | status = tuple(map(int, status.split("/"))) 76 | href = t[ixs[-1]].xpath('./a/@href')[0] 77 | c = Course(name, class_no, school, status, href) 78 | cs.append(c) 79 | return cs 80 | 81 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /user_agents.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mzhhh/PKUElective2021Spring/dee95e430bac489d14bf740518f4ec1397b70baf/user_agents.txt.gz --------------------------------------------------------------------------------