├── unit_test.py ├── .idea └── .gitignore ├── .gitignore ├── requirements.txt ├── config.ini ├── ui_helper.py ├── README.md ├── .github └── ISSUE_TEMPLATE │ └── 错误报告.md ├── crypto_helper.py ├── config.py ├── json_structs.py ├── account_manager.py ├── c.js ├── main.py ├── web_utils.py └── web_utils2.py /unit_test.py: -------------------------------------------------------------------------------- 1 | import crypto_helper 2 | import web_utils 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.xml 3 | *.pyc 4 | *.iml 5 | /accounts.json 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | future~=0.18.2 2 | Pillow~=10.4 3 | requests~=2.25.1 4 | urllib3~=1.26.4 5 | pycryptodome~=3.20.0 -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [Common] 2 | debug = yes 3 | ignore_finished_tasks = no 4 | max_task_num = 11451 5 | debug_print_max_len = 65535 6 | learn_timeout = 16 7 | 8 | [Network] 9 | jquery_ver = 3.4.1 10 | my_captcha = "ba95ef14-d7dd-4d7b-8ec5-189d33b3a6d8" 11 | captcha_crack_max_iter = 256 12 | 13 | [Cryptography] 14 | key_size = 128 15 | key = xie2gg -------------------------------------------------------------------------------- /ui_helper.py: -------------------------------------------------------------------------------- 1 | import tkinter 2 | from io import BytesIO 3 | 4 | from PIL import ImageTk, Image 5 | 6 | 7 | def display_img(image_bytes: bytes): 8 | im = Image.open(BytesIO(image_bytes)) 9 | wnd = tkinter.Tk() 10 | width = im.width 11 | height = im.height 12 | wnd.geometry(f"{width}x{height}") 13 | img = ImageTk.PhotoImage(im) 14 | label = tkinter.Label(wnd, image=img) 15 | label.place(x=0, y=0) 16 | label.config(width=width, height=height) 17 | label.pack() 18 | wnd.mainloop() 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 安全微伴课程一键学习 2 | 3 | > 4 | > Takedown Email: 参见本人主页。 5 | > 考虑到本人的反骚扰策略,不保证一定看得到邮件。 6 | > 7 | 8 | ## 简介 9 | 10 | 一键学习安全微伴所有课程,拯救你的假期! 11 | 12 | 如果你想要一键考试满分,请使用成熟的项目,参见:[安全微伴考试助手](https://github.com/kmoonn/Weiban-Tool)。 13 | 14 | ## 现有功能 15 | 16 | 登录方面,支持使用账号密码登录,也支持直接输入 token。 17 | 18 | 自动学习方面,目前已经支持单账号一键学习,异步实现,效率更高。 19 | 20 | ## 使用方法 21 | 22 | 1. clone 此项目 23 | 2. 在项目目录下执行 `pip install -r requirements.txt` 24 | 3. 修改 `config.ini` 中常量为合适值(一般只需要修改要进行的任务数) 25 | 4. 在项目目录下执行 `python main.py` 26 | 5. 选择模式,若选择了输入账号密码,则会弹出一个显示验证码的 PyTk 窗口,关闭窗口前不要输入内容 27 | 6. 关闭窗口,按照提示继续输入 28 | 7. 程序自动扫描进行中的学习任务,自动完成 29 | 30 | ## TODO 31 | - [x] 异步获取课程列表 32 | - [x] 不基于 Python 文件的配置文件 33 | - [x] 账号信息存储 34 | - [ ] 多账号支持 35 | - [ ] GUI 36 | 37 | ## 致谢 38 | - 结束学习 API jQuery 字符串格式基于 [pooneyy/weiban-tool](https://github.com/pooneyy/weiban-tool/blob/9922cd34b3b85af89490c65bad924a3c94e3aa7c/Utils.py#L198) 的相关代码 39 | - 结束学习延时时长数据一部分参考了 [pooneyy/weiban-tool](https://github.com/pooneyy/weiban-tool/) 40 | - 部分时间戳特殊要求与传递关系受 [Coaixy/weiban-tool](https://github.com/Coaixy/weiban-tool) 启发 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/错误报告.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 错误报告 3 | about: 报告一个 Bug 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **问题描述** 11 | 12 | 请在此清晰且简洁地描述您遇到的 Bug 或问题。请尽量用最少的文字概括问题的核心,方便开发人员快速理解问题的性质。 13 | 14 | **重现步骤** 15 | 16 | 请详细列出重现此 Bug 或问题的具体步骤。这些步骤应该足够清晰,使得开发人员能够完全按照您的操作在他们的环境中复现您所遇到的问题。 17 | 18 | 1. 前往 '...' (请填写您操作的具体页面或位置) 19 | 2. 点击 '....' (请填写您点击的具体按钮、链接或其他可交互元素) 20 | 3. 滚动到 '....' (如果需要滚动页面,请说明滚动到哪个具体位置或看到了什么内容) 21 | 4. 看到错误 (请描述您在此步骤中观察到的错误现象,例如错误提示信息、不正确的显示、功能异常等) 22 | 23 | **预期行为** 24 | 25 | 请在此清晰且简洁地描述您期望发生的正确行为。当您执行上述操作后,您认为系统或应用程序应该如何响应或运行。 26 | 27 | **截图** 28 | 29 | **必须添加程序运行时的屏幕截图以帮助解释您的问题。** 截图能够直观地展示您遇到的错误、异常界面或不符合预期的行为,对于开发人员理解问题非常有帮助。请确保截图清晰,并能指出问题所在。 30 | 31 | **桌面环境 (请填写以下信息):** 32 | 33 | * 操作系统: \[例如: Windows 10, macOS Monterey, Ubuntu 20.04] 34 | * 版本: \[例如: 96.0.4664.110] 35 | 36 | **移动设备 (请填写以下信息):** 37 | 38 | * 设备型号: \[例如: iPhone 13, Samsung Galaxy S21, iPad Air] 39 | * 操作系统: \[例如: iOS 15.2, Android 12] 40 | * 版本: \[例如: 96.0.4664.104] 41 | 42 | **补充说明** 43 | 44 | 请在此处添加任何其他与此问题相关的背景信息、您尝试过的解决方案、问题的严重程度、发生频率,或者任何您认为有助于开发人员理解和解决此问题的额外细节。例如: 45 | 46 | * 这个问题是第一次发生还是经常发生? 47 | * 是否有特定的操作或环境会导致这个问题更容易出现? 48 | * 您认为这个问题的优先级是高、中还是低? 49 | * 您是否尝试过任何临时的解决方案?如果有,请说明您尝试过的方法和结果。 50 | * 是否有相关的日志文件或错误报告可以提供? 51 | -------------------------------------------------------------------------------- /crypto_helper.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from Crypto.Cipher import AES 4 | from Crypto.Util.Padding import pad 5 | 6 | # Consider setting a breakpoint @ webpack:///src/store/index.js line 136 & 152 and run step by step. 7 | from config import * 8 | 9 | 10 | def encrypt(content: str) -> str: 11 | aes = AES.new(key=config_instance.KEY, mode=AES.MODE_ECB) 12 | pad_pkcs7 = pad(content.encode("utf-8"), AES.block_size, style='pkcs7') 13 | encrypted = aes.encrypt(pad_pkcs7) 14 | encrypted_text = str(base64.encodebytes(encrypted), encoding='utf-8') \ 15 | .strip() \ 16 | .replace("+", "-") \ 17 | .replace("/", "_") \ 18 | .replace("\n", "") # encrypted strings sometimes contain weird \n and I have no idea why. 19 | return encrypted_text 20 | 21 | 22 | def decrypt(content_b64: str) -> str: 23 | content = content_b64.strip() \ 24 | .replace("-", "+") \ 25 | .replace("_", "/") 26 | content_bytes = content.encode("utf-8") 27 | content_original: bytes = base64.b64decode(content_bytes) 28 | 29 | aes = AES.new(key=config_instance.KEY, mode=AES.MODE_ECB) 30 | pad_pkcs7 = pad(content_original, AES.block_size, style='pkcs7') 31 | decrypted = aes.decrypt(pad_pkcs7) 32 | # print(decrypted) 33 | return decrypted.decode("utf-8") 34 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from configparser import * 2 | 3 | 4 | class Conf: 5 | # Common 6 | DEBUG = True 7 | IGNORE_FINISHED_TASKS = False 8 | MAX_TASK_NUM = 8 9 | DEBUG_PRINT_MAX_LEN = 65535 10 | LEARN_TIMEOUT = 16 11 | 12 | # Network Service 13 | JQUERY_VER = "3.4.1" 14 | MY_CAPTCHA = "ba95ef14-d7dd-4d7b-8ec5-189d33b3a6d8" 15 | CAPTCHA_CRACK_MAX_ITER = 256 16 | 17 | # Cryptography 18 | KEY_SZ = 128 19 | KEY: bytes = "wbs512".encode("utf-8").ljust(KEY_SZ // 8, b"\x00") 20 | IV = b"" 21 | 22 | def parse_conf(self): 23 | conf: ConfigParser = ConfigParser() 24 | conf.read("./config.ini") 25 | 26 | if conf.has_section("Common"): 27 | self.DEBUG = conf.getboolean("Common", "debug") 28 | self.IGNORE_FINISHED_TASKS = conf.getboolean("Common", "ignore_finished_tasks") 29 | self.MAX_TASK_NUM = conf.getint("Common", "max_task_num") 30 | self.DEBUG_PRINT_MAX_LEN = conf.getint("Common", "debug_print_max_len") 31 | self.LEARN_TIMEOUT = conf.getfloat("Common", "learn_timeout") 32 | 33 | if conf.has_section("Network"): 34 | self.JQUERY_VER = conf.get("Network", "jquery_ver") 35 | self.MY_CAPTCHA = conf.get("Network", "my_captcha") 36 | self.CAPTCHA_CRACK_MAX_ITER = conf.get("Network", "captcha_crack_max_iter") 37 | 38 | return self 39 | 40 | 41 | config_instance: Conf = Conf().parse_conf() 42 | -------------------------------------------------------------------------------- /json_structs.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import quote 3 | 4 | 5 | class User: 6 | real_name: str 7 | tenant_code: str 8 | tenant_name: str 9 | token: str 10 | unique_value: str 11 | user_id: str 12 | user_name: str 13 | 14 | def __init__(self, real_name=None, tenant=None, tenant_name=None, token=None, 15 | unique_value=None, user_id=None, user_name=None): 16 | self.real_name = real_name 17 | self.tenant_code = str(tenant) 18 | self.tenant_name = tenant_name 19 | self.token = token 20 | self.unique_value = unique_value 21 | self.user_id = user_id 22 | self.user_name = user_name 23 | 24 | 25 | class Project: 26 | project_id: str 27 | project_name: str 28 | user_project_id: str 29 | 30 | def __init__(self, project_id, project_name, user_project_id): 31 | self.project_id = project_id 32 | self.project_name = project_name 33 | self.user_project_id = user_project_id 34 | 35 | 36 | class Category: 37 | category_name: str 38 | category_code: str 39 | finished: bool 40 | 41 | def __init__(self, name, code, finished_num, total_num): 42 | self.category_code = code 43 | self.category_name = name 44 | self.finished = finished_num == total_num 45 | 46 | 47 | class Course: 48 | resource_id: str 49 | resource_name: str 50 | user_course_id: str 51 | 52 | def __init__(self, resource_id, resource_name, user_course_id): 53 | self.resource_id = resource_id 54 | self.resource_name = resource_name 55 | self.user_course_id = user_course_id 56 | 57 | 58 | class Captcha: 59 | image_url: str 60 | num: int 61 | question_id: str 62 | 63 | def __init__(self, image_url, num, question_id): 64 | self.image_url = image_url 65 | self.num = num 66 | self.question_id = question_id 67 | 68 | 69 | class Position: 70 | x: int 71 | y: int 72 | 73 | def __init__(self, x, y): 74 | self.x = x 75 | self.y = y 76 | 77 | def to_dict(self) -> dict[str, int]: 78 | return { 79 | "x": self.x, 80 | "y": self.y 81 | } 82 | 83 | 84 | class CaptchaAnswer: 85 | p1: Position 86 | p2: Position 87 | p3: Position 88 | 89 | def __init__(self, p1: Position, p2: Position, p3: Position): 90 | self.p1 = p1 91 | self.p2 = p2 92 | self.p3 = p3 93 | 94 | def fetch(self) -> str: 95 | l: list = list() 96 | l.append(self.p1.to_dict()) 97 | l.append(self.p2.to_dict()) 98 | l.append(self.p3.to_dict()) 99 | 100 | return json.dumps(l).replace(" ", "") 101 | 102 | def fetch_url_encoded(self): 103 | return quote(self.fetch().encode(encoding="utf-8")) 104 | -------------------------------------------------------------------------------- /account_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os.path 3 | import functools 4 | 5 | 6 | class Account: 7 | uname: str 8 | pwd: str 9 | tenant: str 10 | 11 | def __init__(self, uname, pwd, tenant): 12 | self.uname = uname 13 | self.pwd = pwd 14 | self.tenant = tenant 15 | 16 | 17 | class AccountManager: 18 | __accounts: list[Account] 19 | 20 | @property 21 | def accounts(self) -> list[Account]: 22 | return self.__accounts 23 | 24 | def __init__(self): 25 | if not os.path.exists("./accounts.json"): 26 | with open("./accounts.json", "w", encoding="utf-8") as f: 27 | struct = { 28 | "accounts": [ 29 | { 30 | "uname": "placeholder", 31 | "pwd": "pwd", 32 | "tenant": "114514" 33 | } 34 | ] 35 | } 36 | 37 | t = json.dumps(struct) 38 | f.write(t) 39 | f.close() 40 | 41 | self.__accounts = list() 42 | self.refresh() 43 | 44 | def refresh(self): 45 | self.__accounts.clear() 46 | with open("./accounts.json", "r", encoding="utf-8") as f: 47 | data = json.loads(functools.reduce(lambda x, y: x + y, f.readlines())) 48 | for acc in data["accounts"]: 49 | self.__accounts.append( 50 | Account( 51 | acc["uname"], 52 | acc["pwd"], 53 | acc["tenant"] 54 | ) 55 | ) 56 | f.close() 57 | 58 | return self 59 | 60 | def fetch(self, uname) -> Account: 61 | for i in self.__accounts: 62 | if i.uname == uname: 63 | return i 64 | 65 | def append(self, uname, pwd, tenant): 66 | self.__accounts.append( 67 | Account( 68 | uname, 69 | pwd, 70 | tenant 71 | ) 72 | ) 73 | return self 74 | 75 | def contain(self, uname) -> bool: 76 | for i in self.__accounts: 77 | if i.uname == uname: 78 | return True 79 | return False 80 | 81 | def delete(self, uname): 82 | for i in self.__accounts: 83 | if i.uname == uname: 84 | self.__accounts.remove(i) 85 | return self 86 | 87 | def save(self): 88 | with open("./accounts.json", "w", encoding="utf-8") as f: 89 | struct = { 90 | "accounts": [] 91 | } 92 | 93 | for i in self.__accounts: 94 | struct["accounts"].append( 95 | { 96 | "uname": i.uname, 97 | "pwd": i.pwd, 98 | "tenant": i.tenant 99 | } 100 | ) 101 | 102 | j = json.dumps(struct) 103 | f.write(j) 104 | f.close() 105 | 106 | return self 107 | -------------------------------------------------------------------------------- /c.js: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js'; 2 | 3 | /** 4 | * 简化的去抖函数(debounce),具体功能参考underscore库对应函数_.debounce 5 | * @param {Function} fn - 待执行函数 6 | * @param {Number} ms - 多少区间内去抖(毫秒) 7 | * @returns {Function} - 包装后函数 8 | */ 9 | export const debounce = (fn, ms = 500) => { 10 | let timer; 11 | const clearTimer = () => { 12 | clearTimeout(timer); 13 | timer = null; 14 | }; 15 | 16 | return function (...params) { 17 | if (timer) { 18 | clearTimer(); 19 | } 20 | timer = setTimeout(() => { 21 | fn.apply(this, [...params]); 22 | clearTimer(); 23 | }, ms); 24 | }; 25 | }; 26 | 27 | export const formatDate = (fmt, timestamp) => { 28 | const date = new Date(timestamp); 29 | let ret; 30 | const opt = { 31 | 'y+': date.getFullYear().toString(), 32 | 'M+': (date.getMonth() + 1).toString(), 33 | 'd+': date.getDate().toString(), 34 | 'h+': date.getHours().toString(), 35 | 'm+': date.getMinutes().toString(), 36 | 's+': date.getSeconds().toString() 37 | }; 38 | for (const k in opt) { 39 | // if (Object.hasOwn(opt, k)) { 40 | // object.hasOwnProperty(property) 41 | if (Object.hasOwnProperty.call(opt, k)) { 42 | ret = new RegExp('(' + k + ')').exec(fmt); 43 | if (ret) { 44 | fmt = fmt.replace(ret[1], (ret[1].length === 1) ? (opt[k]) : (opt[k].padStart(ret[1].length, '0'))); 45 | } 46 | } 47 | } 48 | return fmt; 49 | }; 50 | 51 | export const setWeChatTitle = (title) => { 52 | document.title = title; 53 | 54 | const mobile = navigator.userAgent.toLowerCase(); 55 | if (/iphone|ipad|ipod/.test(mobile)) { 56 | const iframe = document.createElement('iframe'); 57 | iframe.style.display = 'none'; 58 | // 替换成站标favicon路径或者任意存在的较小的图片即可 59 | iframe.setAttribute('src', ''); 60 | const iframeCallback = function () { 61 | setTimeout(function () { 62 | iframe.removeEventListener('load', iframeCallback); 63 | document.body.removeChild(iframe); 64 | }, 0); 65 | }; 66 | 67 | iframe.addEventListener('load', iframeCallback); 68 | document.body.appendChild(iframe); 69 | } 70 | }; 71 | 72 | const initKey = 'xie2gg'; 73 | const keySize = 128; 74 | 75 | /** 76 | * 生成密钥字节数组, 原始密钥字符串不足128位, 补填0. 77 | * @param {string} key - 原始 key 值 78 | * @return Buffer 79 | */ 80 | const fillKey = (key) => { 81 | const filledKey = Buffer.alloc(keySize / 8); 82 | const keys = Buffer.from(key); 83 | if (keys.length < filledKey.length) { 84 | filledKey.forEach((b, i) => { filledKey[i] = keys[i]; }); 85 | } 86 | 87 | return filledKey; 88 | }; 89 | 90 | /** 91 | * 定义加密函数 92 | * @param {string} data - 需要加密的数据, 传过来前先进行 JSON.stringify(data); 93 | * @param {string} key - 加密使用的 key 94 | */ 95 | export const aesEncrypt = (data, key) => { 96 | /** 97 | * CipherOption, 加密的一些选项: 98 | * mode: 加密模式, 可取值(CBC, CFB, CTR, CTRGladman, OFB, ECB), 都在 CryptoJS.mode 对象下 99 | * padding: 填充方式, 可取值(Pkcs7, AnsiX923, Iso10126, Iso97971, ZeroPadding, NoPadding), 都在 CryptoJS.pad 对象下 100 | * iv: 偏移量, mode === ECB 时, 不需要 iv 101 | * 返回的是一个加密对象 102 | */ 103 | const cipher = CryptoJS.AES.encrypt(data, key, { 104 | mode: CryptoJS.mode.ECB, 105 | padding: CryptoJS.pad.Pkcs7, 106 | iv: '' 107 | }); 108 | 109 | // 将加密后的数据转换成 Base64 110 | const base64Cipher = cipher.ciphertext.toString(CryptoJS.enc.Base64); 111 | 112 | // 处理 Android 某些低版的BUG 113 | const resultCipher = base64Cipher.replace(/\+/g, '-').replace(/\//g, '_'); 114 | 115 | // 返回加密后的经过处理的 Base64 116 | return resultCipher; 117 | }; 118 | 119 | // 获取填充后的key 120 | // 注意,明文、秘钥和偏移向量一般先用诸如 CryptoJS.enc.Utf8.parse() 转成 WordArray 对象再传入,这样做得到结果与不转换直接传入是不一样的。 121 | export const key = CryptoJS.enc.Utf8.parse(fillKey(initKey)); 122 | 123 | 124 | 125 | // WEBPACK FOOTER // 126 | // ./src/utils/util-functions.js -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # Wei-ban Course Helper NWPU ver. 1.0 2 | # Do not use it for illegal purposes. 3 | # Do not distribute! 4 | 5 | import asyncio 6 | import functools 7 | import sys 8 | import time 9 | from datetime import datetime 10 | 11 | import json_structs 12 | import ui_helper 13 | import web_utils 14 | from account_manager import AccountManager 15 | from config import * 16 | 17 | 18 | def dbg_print(msg: str): 19 | if config_instance.DEBUG: 20 | now: datetime = datetime.now() 21 | print(f"[DEBUG] [{now}] {msg}") 22 | 23 | 24 | async def main(): 25 | print(config_instance.MAX_TASK_NUM) 26 | session = None 27 | account_manager: AccountManager = AccountManager().refresh() 28 | 29 | if (opt := input("Use normal login method or use cookie? \n" 30 | "Input 1 for cookie, 2 for saved account info, otherwise normal login. >>").strip()) == "1": 31 | token = input("Token here >>").strip() 32 | user_id = input("User ID here >>").strip() 33 | tenant_code = input("Tenant code here (press enter to use school name instead) >>").strip() 34 | if tenant_code == "": 35 | school_name = input("School name here >>").strip() 36 | tenant_code = web_utils.fetch_all_tenants()[school_name] 37 | session = json_structs.User( 38 | token=token, 39 | user_id=user_id, 40 | tenant=tenant_code 41 | ) 42 | elif opt == "2": 43 | uname = "" 44 | print("Saved accounts:") 45 | dbg_print(str(len(account_manager.accounts))) 46 | for i in account_manager.accounts: 47 | print(i.uname) 48 | while not account_manager.contain(uname): 49 | uname = input("User name here >>") 50 | print("User not found. Try again or use Ctrl-C to quit.") \ 51 | if not account_manager.contain(uname) else print("User found.") 52 | 53 | user = account_manager.fetch(uname) 54 | 55 | while session is None: 56 | captcha, ts_sec = web_utils.fetch_login_captcha() 57 | print("After viewing the CAPTCHA, close the window to continue.") 58 | print("Do not input any text now!") 59 | ui_helper.display_img(captcha.content) 60 | captcha_result = input("CAPTCHA here >>") 61 | 62 | session, msg = await web_utils.login(user.tenant, user.uname, user.pwd, captcha_result, ts_sec) 63 | if session is None: 64 | print("Error occurred. Please retry or use Ctrl-C to quit.", msg) 65 | 66 | else: 67 | tenants = web_utils.fetch_all_tenants() 68 | dbg_print(str(len(tenants))) 69 | 70 | while (inp := input("Your school name here >>").strip()) not in tenants.keys(): 71 | print("School not found:", inp, ". Please try again.") 72 | 73 | my_tenant = tenants[inp] 74 | dbg_print(my_tenant) 75 | 76 | uname_prompt, pwd_prompt = web_utils.fetch_tenant_conf(my_tenant) 77 | print("Customized username prompt:", uname_prompt) 78 | user_name = input("Username here >>").strip() 79 | 80 | pwd = "" 81 | if account_manager.contain(user_name): 82 | print("This account has been saved previously.") 83 | if input("Do you want to input the password automatically? ([Y]es / [N]o) >>")[0].lower() == "y": 84 | pwd = account_manager.fetch(user_name).pwd 85 | else: 86 | if input("Have you altered your account settings" 87 | "(e.g. password, school, etc.)? ([Y]es / [N]o) >>")[0].lower() == "y": 88 | new_pwd = input("New password here >>") 89 | account_manager.delete(user_name).append(user_name, new_pwd, my_tenant) 90 | print("Please restart the script.") 91 | sys.exit(0) 92 | 93 | if pwd == "": 94 | print("Customized pwd prompt:", pwd_prompt) 95 | pwd = input("Password here >>").strip() 96 | 97 | while session is None: 98 | captcha, ts_sec = web_utils.fetch_login_captcha() 99 | print("After viewing the CAPTCHA, close the window to continue.") 100 | print("Do not input any text now!") 101 | ui_helper.display_img(captcha.content) 102 | captcha_result = input("CAPTCHA here >>") 103 | 104 | session, msg = await web_utils.login(my_tenant, user_name, pwd, captcha_result, ts_sec) 105 | if session is None: 106 | print("Error occurred. Please retry or use Ctrl-C to quit.", msg) 107 | 108 | if session is not None and not account_manager.contain(user_name): 109 | if input("Do you want to save your account data? ([Y]es / [N]o) >>")[0].lower() == "y": 110 | account_manager\ 111 | .append(user_name, pwd, my_tenant)\ 112 | .save() 113 | else: 114 | print("User data not saved.") 115 | 116 | if session is None: 117 | print("FATAL: login failed with bad return value.") 118 | sys.exit(0) 119 | 120 | # IMPORTANT!!! 121 | web_utils.set_token(session.token) 122 | 123 | print("Start fetching all ongoing projects...") 124 | projects: list[json_structs.Project] = await web_utils.fetch_project_list(session.tenant_code, session.user_id) 125 | 126 | for p in projects: 127 | start = time.time() 128 | 129 | courses: list[json_structs.Course] = list() 130 | print("Ongoing project:", p.project_name, "ID:", p.project_id, "User Project ID:", p.user_project_id) 131 | categories: list[json_structs.Category] = \ 132 | await web_utils.fetch_category_list(session.tenant_code, session.user_id, p.user_project_id) 133 | 134 | task_courses: list = list() 135 | for c in categories: 136 | print("Ongoing category:", c.category_name, "ID:", c.category_code) 137 | if (not c.finished) or (not config_instance.IGNORE_FINISHED_TASKS): 138 | task_courses.append( 139 | asyncio.create_task( 140 | web_utils.fetch_course_list( 141 | session.tenant_code, session.user_id, 142 | p.user_project_id, c.category_code 143 | ) 144 | ) 145 | ) 146 | print("Starting to fetch ongoing courses asynchronously...") 147 | if not len(task_courses) == 0: 148 | courses = functools.reduce(lambda x, y: x + y, await asyncio.gather(*task_courses)) 149 | print("Detected", len(courses), "courses to be finished.") 150 | tasks: list = list() 151 | 152 | cnt = 0 153 | for c in courses: 154 | tasks.append( 155 | asyncio.create_task( 156 | web_utils.learn_course( 157 | session.tenant_code, session.user_id, 158 | p.user_project_id, c.user_course_id, c.resource_id, 159 | c.resource_name, session.user_name 160 | ) 161 | ) 162 | ) 163 | 164 | cnt += 1 165 | if cnt >= config_instance.MAX_TASK_NUM: 166 | break 167 | 168 | print("Gathered", total_cnt := len(tasks), "tasks in total. Starting coroutines...") 169 | result = await asyncio.gather(*tasks) 170 | 171 | end = time.time() 172 | 173 | success_cnt = 0 174 | for r in result: 175 | if r: 176 | success_cnt += 1 177 | 178 | time_elapsed = end - start 179 | print("Succeeded tasks:", success_cnt, "of", total_cnt, end=".\n") 180 | else: 181 | print("This project has been finished before.") 182 | time_elapsed = 0 183 | print("Ongoing task of Project", p.project_name, "successfully terminated in", time_elapsed, "secs.") 184 | 185 | print("All ongoing tasks successfully terminated.") 186 | 187 | 188 | if __name__ == "__main__": 189 | asyncio.run(main()) 190 | -------------------------------------------------------------------------------- /web_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | import time 5 | from copy import deepcopy 6 | from datetime import datetime 7 | from typing import Optional 8 | 9 | import requests 10 | import urllib3 11 | from urllib3 import exceptions 12 | 13 | import crypto_helper 14 | import json_structs 15 | from config import * 16 | 17 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 18 | 19 | headers = { 20 | "X-Token": "", 21 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 22 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 " 23 | "Safari/537.36 Edg/127.0.0.0 " 24 | } 25 | 26 | # To prevent potential errors raised by proxy configuration. 27 | proxies = { 28 | "http": None, 29 | "https": None 30 | } 31 | 32 | 33 | def dbg_print(msg: str): 34 | if config_instance.DEBUG: 35 | now: datetime = datetime.now() 36 | print(f"[DEBUG] [{now}] {msg}") 37 | 38 | 39 | class request_str_arg_builder: 40 | base_url: str 41 | first: bool 42 | 43 | def __init__(self, base_url: str): 44 | self.base_url = base_url 45 | self.first = True 46 | 47 | def format(self, *args): 48 | self.base_url = self.base_url.format(args) 49 | return self 50 | 51 | def replace(self, old, new): 52 | self.base_url = self.base_url.replace(old, new) 53 | return self 54 | 55 | def concat(self, arg: str, arg_v: object): 56 | if self.first: 57 | self.base_url += ("?" + arg + "=" + str(arg_v)) 58 | self.first = False 59 | else: 60 | self.base_url += ("&" + arg + "=" + str(arg_v)) 61 | return self 62 | 63 | def concat_ts(self, offset: float = 0.0): 64 | ts = round(time.time() * 1000) / 1000 + offset 65 | if self.first: 66 | self.base_url += ("?timestamp=" + str(ts)) 67 | self.first = False 68 | else: 69 | self.base_url += ("×tamp=" + str(ts)) 70 | return self 71 | 72 | def fetch(self): 73 | # dbg_print(self.base_url) 74 | return self.base_url 75 | 76 | 77 | class api: 78 | GET_LOGIN_CAPTCHA = 0x00 79 | GET_TENANT_LIST = 0x10 80 | GET_TENANT_CONF = 0x11 81 | LOGIN = 0x12 82 | FETCH_PROJECT_LIST = 0x20 83 | FETCH_CATEGORY_LIST = 0x21 84 | FETCH_COURSE_LIST = 0x22 85 | STUDY_START = 0x30, 86 | STUDY_GET_COURSE_URL = 0x31, 87 | STUDY_TERMINATE = 0x32 88 | STUDY_FETCH_CAPTCHA = 0x40 89 | STUDY_CHECK_CAPTCHA = 0x41 90 | 91 | 92 | api_mapping: dict = { 93 | api.GET_LOGIN_CAPTCHA: "https://weiban.mycourse.cn/pharos/login/randLetterImage.do", 94 | api.GET_TENANT_LIST: "https://weiban.mycourse.cn/pharos/login/getTenantList.do", 95 | api.GET_TENANT_CONF: "https://weiban.mycourse.cn/pharos/login/getTenantConfig.do", 96 | api.LOGIN: "https://weiban.mycourse.cn/pharos/login/login.do", 97 | api.FETCH_PROJECT_LIST: "https://weiban.mycourse.cn/pharos/index/listMyProject.do", 98 | api.FETCH_CATEGORY_LIST: "https://weiban.mycourse.cn/pharos/usercourse/listCategory.do", 99 | api.FETCH_COURSE_LIST: "https://weiban.mycourse.cn/pharos/usercourse/listCourse.do", 100 | 101 | api.STUDY_START: "https://weiban.mycourse.cn/pharos/usercourse/study.do", 102 | api.STUDY_GET_COURSE_URL: "https://weiban.mycourse.cn/pharos/usercourse/getCourseUrl.do", 103 | api.STUDY_FETCH_CAPTCHA: "https://weiban.mycourse.cn/pharos/usercourse/getCaptcha.do", 104 | api.STUDY_CHECK_CAPTCHA: "https://weiban.mycourse.cn/pharos/usercourse/checkCaptcha.do", 105 | api.STUDY_TERMINATE: "https://weiban.mycourse.cn/pharos/usercourse/v2/.do" 106 | } 107 | 108 | 109 | def set_token(token): 110 | """ 111 | Must be executed right after calling login() 112 | :param token: 113 | :return: 114 | """ 115 | headers["X-Token"] = token 116 | 117 | 118 | def post(url, data: dict, cookies: object = None) -> requests.Response: 119 | dbg_print("POST " + url) 120 | dbg_print("Parameters: " + json.dumps(data)) 121 | if cookies is None: 122 | cookies = {} 123 | resp: requests.Response = requests.post(url, data=data, headers=headers, cookies=cookies, 124 | timeout=5, verify=False, proxies=proxies) 125 | if len(t := resp.text) < config_instance.DEBUG_PRINT_MAX_LEN: 126 | dbg_print("Response: " + t) 127 | else: 128 | dbg_print("Response: " + t[0:65536]) 129 | return resp 130 | 131 | 132 | def get(url, cookies=None) -> requests.Response: 133 | dbg_print("GET " + url) 134 | if cookies is None: 135 | cookies = {} 136 | resp: requests.Response = requests.get(url, headers=headers, cookies=cookies, 137 | timeout=5, verify=False, proxies=proxies) 138 | if len(t := resp.text) < config_instance.DEBUG_PRINT_MAX_LEN: 139 | dbg_print("Response: " + t) 140 | else: 141 | dbg_print("Response: " + t[0:65536]) 142 | return resp 143 | 144 | 145 | def fetch_login_captcha() -> (requests.Response, int): 146 | current_time = time.time() 147 | return get( 148 | request_str_arg_builder(api_mapping[api.GET_LOGIN_CAPTCHA]) 149 | .concat("time", str(round(current_time * 1000))) 150 | .fetch(), 151 | ), current_time 152 | 153 | 154 | def fetch_all_tenants() -> dict[str, str]: 155 | resp = post( 156 | request_str_arg_builder(api_mapping[api.GET_TENANT_LIST]) 157 | .concat_ts() 158 | .fetch(), 159 | data={} 160 | ) 161 | 162 | ret = dict() 163 | 164 | data: list[dict] = json.loads(resp.text)["data"] 165 | for d in data: 166 | dbg_print(str({d["name"]: d["code"]})) 167 | ret.update({d["name"]: d["code"]}) 168 | 169 | return ret 170 | 171 | 172 | def fetch_tenant_conf(tenant_code: str): 173 | resp = post( 174 | request_str_arg_builder(api_mapping[api.GET_TENANT_CONF]) 175 | .concat_ts() 176 | .fetch(), 177 | data={ 178 | "tenantCode": tenant_code 179 | } 180 | ) 181 | 182 | data = json.loads(resp.text)["data"] 183 | pwd_prompt = data["passwordPrompt"] 184 | uname_prompt = data["userNamePrompt"] 185 | 186 | return uname_prompt, pwd_prompt 187 | 188 | 189 | async def login(tenant: str, uname: str, pwd: str, captcha: str, captcha_ts: float)\ 190 | -> (Optional[json_structs.User], str): 191 | captcha_ts = round(captcha_ts * 1000) 192 | dbg_print(json.dumps( 193 | { 194 | "keyNumber": uname, 195 | "password": pwd, 196 | "tenantCode": tenant, 197 | "time": captcha_ts, 198 | "verifyCode": captcha 199 | }) 200 | ) 201 | resp = post( 202 | request_str_arg_builder(api_mapping[api.LOGIN]) 203 | .concat_ts() 204 | .fetch(), 205 | data={ 206 | "data": crypto_helper.encrypt( 207 | json.dumps( 208 | { 209 | "keyNumber": uname, 210 | "password": pwd, 211 | "tenantCode": tenant, 212 | "time": captcha_ts, 213 | "verifyCode": captcha 214 | } 215 | ).replace(" ", "") 216 | ) 217 | } 218 | ) 219 | 220 | j = json.loads(resp.text) 221 | if j["code"] == "0": 222 | data = j["data"] 223 | return json_structs.User( 224 | data["realName"], 225 | data["tenantCode"], 226 | data["tenantName"], 227 | data["token"], 228 | data["uniqueValue"], 229 | data["userId"], 230 | data["userName"] 231 | ), resp.text 232 | else: 233 | return None, resp.text 234 | 235 | 236 | async def fetch_project_list(tenant: str, user_id: str, ended=2): 237 | """ 238 | Fetches designated project list according to argument ended. 239 | :param tenant: 240 | :param user_id: 241 | :param ended: when ended==2, function fetches ongoing projects; when ended==1, fetches finished projects instead. 242 | :return: 243 | """ 244 | resp = post( 245 | request_str_arg_builder(api_mapping[api.FETCH_PROJECT_LIST]) 246 | .concat_ts() 247 | .fetch(), 248 | data={ 249 | "tenantCode": tenant, 250 | "userId": user_id, 251 | "ended": str(ended) 252 | } 253 | ) 254 | 255 | data = json.loads(resp.text)["data"] 256 | ret: list = list() 257 | for d in data: 258 | ret.append( 259 | deepcopy( 260 | json_structs.Project( 261 | project_id=d["projectId"], 262 | project_name=d["projectName"], 263 | user_project_id=d["userProjectId"] 264 | ) 265 | ) 266 | ) 267 | return deepcopy(ret) 268 | 269 | 270 | async def fetch_category_list(tenant, user_id, user_project_id, choose_type=3): 271 | resp = post( 272 | request_str_arg_builder(api_mapping[api.FETCH_CATEGORY_LIST]) 273 | .concat_ts() 274 | .fetch(), 275 | data={ 276 | "tenantCode": tenant, 277 | "userId": user_id, 278 | "userProjectId": user_project_id, 279 | "chooseType": str(choose_type) 280 | } 281 | ) 282 | 283 | data = json.loads(resp.text)["data"] 284 | 285 | ret: list = list() 286 | for d in data: 287 | ret.append( 288 | deepcopy( 289 | json_structs.Category( 290 | d["categoryName"], 291 | d["categoryCode"], 292 | d["finishedNum"], 293 | d["totalNum"] 294 | ) 295 | ) 296 | ) 297 | return deepcopy(ret) 298 | 299 | 300 | async def fetch_course_list(tenant, user_id, user_project_id, category_code, choose_type=3): 301 | resp = post( 302 | request_str_arg_builder(api_mapping[api.FETCH_COURSE_LIST]) 303 | .concat_ts() 304 | .fetch(), 305 | data={ 306 | "tenantCode": tenant, 307 | "userId": user_id, 308 | "userProjectId": user_project_id, 309 | "chooseType": str(choose_type), 310 | "categoryCode": category_code 311 | } 312 | ) 313 | 314 | ret: list = list() 315 | data = json.loads(resp.text)["data"] 316 | for d in data: 317 | ret.append( 318 | deepcopy( 319 | json_structs.Course( 320 | resource_id=d["resourceId"], 321 | resource_name=d["resourceName"], 322 | user_course_id=d["userCourseId"] 323 | ) 324 | ) 325 | ) 326 | 327 | return deepcopy(ret) 328 | 329 | 330 | """ 331 | Flow: 332 | STUDY_START -> STUDY_GET_COURSE_URL -> 333 | STUDY_FETCH_CAPTCHA -> STUDY_CHECK_CAPTCHA -> 334 | STUDY_TERMINATE 335 | """ 336 | 337 | 338 | async def study_start(tenant, user_id, user_project_id, course_id) -> bool: 339 | """ 340 | 341 | :param tenant: 342 | :param user_id: 343 | :param user_project_id: 344 | :param course_id: resource_id of Course 345 | :return: 346 | """ 347 | resp = post( 348 | request_str_arg_builder(api_mapping[api.STUDY_START]) 349 | .concat_ts() 350 | .fetch(), 351 | data={ 352 | "tenantCode": tenant, 353 | "userId": user_id, 354 | "courseId": course_id, 355 | "userProjectId": user_project_id 356 | } 357 | ) 358 | 359 | if json.loads(resp.text)["code"] == "0": 360 | dbg_print("Starting " + course_id) 361 | return True 362 | else: 363 | dbg_print("Fail to start " + course_id) 364 | return False 365 | 366 | 367 | async def study_get_course_url(tenant, user_id, user_project_id, course_id) -> bool: 368 | """ 369 | 370 | :param tenant: 371 | :param user_id: 372 | :param user_project_id: 373 | :param course_id: resource_id of Course 374 | :return: 375 | """ 376 | resp = post( 377 | request_str_arg_builder(api_mapping[api.STUDY_GET_COURSE_URL]) 378 | .concat_ts() 379 | .fetch(), 380 | data={ 381 | "tenantCode": tenant, 382 | "userId": user_id, 383 | "courseId": course_id, 384 | "userProjectId": user_project_id 385 | } 386 | ) 387 | 388 | if json.loads(resp.text)["code"] == "0": 389 | dbg_print("Fetching " + course_id) 390 | return True 391 | else: 392 | dbg_print("Fail to fetch " + course_id) 393 | return False 394 | 395 | 396 | async def study_fetch_captcha(tenant, user_id, user_project_id, user_course_id) -> json_structs.Captcha: 397 | """ 398 | 399 | :param tenant: 400 | :param user_id: 401 | :param user_project_id: 402 | :param user_course_id: Note that this time must use user_course_id rather than resource_id!!! 403 | :return: 404 | """ 405 | resp = post( 406 | request_str_arg_builder(api_mapping[api.STUDY_FETCH_CAPTCHA]) 407 | .concat("userCourseId", user_course_id) 408 | .concat("userProjectId", user_project_id) 409 | .concat("userId", user_id, ) 410 | .concat("tenantCode", tenant) 411 | .fetch(), 412 | data={} 413 | ) 414 | 415 | captcha = json.loads(resp.text)["captcha"] 416 | return json_structs.Captcha( 417 | image_url=captcha["imageUrl"], 418 | num=captcha["num"], 419 | question_id=captcha["questionId"] 420 | ) 421 | 422 | 423 | async def study_verify_captcha(tenant, user_id, user_project_id, 424 | user_course_id, question_id, answer: json_structs.CaptchaAnswer) -> (bool, str): 425 | """ 426 | 427 | :param tenant: 428 | :param user_id: 429 | :param user_project_id: 430 | :param user_course_id: 431 | :param question_id: 432 | :param answer: 433 | :return: A bool, indicating whether the operation has succeeded; 434 | a string, which is the token for terminating study progress. 435 | """ 436 | resp = post( 437 | request_str_arg_builder(api_mapping[api.STUDY_CHECK_CAPTCHA]) 438 | .concat("userCourseId", user_course_id) 439 | .concat("userProjectId", user_project_id) 440 | .concat("userId", user_id) 441 | .concat("tenantCode", tenant) 442 | .concat("questionId", question_id) 443 | .fetch(), 444 | data={ 445 | "coordinateXYs": answer.fetch() 446 | } 447 | ) 448 | 449 | result = json.loads(resp.text) 450 | if error_code := result["code"] == "0": 451 | if (ck := result["data"]["checkResult"]) == 1: 452 | return True, result["data"]["methodToken"] 453 | else: 454 | dbg_print("Failed CAPTCHA with bad answer. CheckResult = " + str(ck)) 455 | return True, result["data"]["methodToken"] 456 | else: 457 | dbg_print("Failed CAPTCHA with error code = " + error_code) 458 | return False, None 459 | 460 | 461 | def ts_mill() -> int: 462 | return round(time.time() * 1000) 463 | 464 | 465 | def jquery_style_callback_parser(): 466 | """ 467 | Parses the given random number and timestamp to jQuery style string 468 | to be provided to study_terminate() func. 469 | 470 | For instance, it parses: 471 | Randint, 472 | Timestamp(ms) 473 | to: 474 | jQuery 341 02569642488978181 _ 1723205201708 475 | version 3.4.1 randint timestamp(ms) 476 | :return: 477 | """ 478 | ts = ts_mill() 479 | return ("jQuery" + config_instance.JQUERY_VER + str(random.random()) + "_" + str(ts)).replace(".", ""), ts 480 | 481 | 482 | async def study_terminate(user_course_id, tenant, captcha_token) -> bool: 483 | jq, ts = jquery_style_callback_parser() 484 | resp = post( 485 | request_str_arg_builder(api_mapping[api.STUDY_TERMINATE]) 486 | .replace("", captcha_token) 487 | .concat("callback", jq) 488 | .concat("userCourseId", user_course_id) 489 | .concat("tenantCode", tenant) 490 | .concat("_", ts + 1 / 1000) # MAGIC Number 491 | .fetch(), 492 | data={} 493 | ) 494 | 495 | if "ok" in resp.text: 496 | # screw it 操了,他真返回一个 jQuery callback and I don't want to make any comment 497 | # json serializer 直接干开线了 498 | dbg_print("Finished!") 499 | return True 500 | else: 501 | dbg_print("Failed!") 502 | return False 503 | 504 | 505 | async def captcha_crack(tenant, user_id, user_project_id, user_course_id, 506 | designated_captcha_id, answer: json_structs.CaptchaAnswer) -> str: 507 | """ 508 | 509 | :param tenant: 510 | :param user_id: 511 | :param user_project_id: 512 | :param user_course_id: 513 | :param designated_captcha_id: 514 | :param answer: 515 | :return: Token of CAPTCHA 516 | """ 517 | captcha_id = "" 518 | captcha: json_structs.Captcha 519 | count = 0 520 | while captcha_id != designated_captcha_id: 521 | captcha = await study_fetch_captcha(tenant, user_id, user_project_id, user_course_id) 522 | captcha_id = captcha.question_id 523 | count += 1 524 | if count > config_instance.CAPTCHA_CRACK_MAX_ITER: 525 | dbg_print("Maximum CAPTCHA crack iteration limit exceeded!") 526 | return "" 527 | success, captcha_token = await study_verify_captcha( 528 | tenant, user_id, user_project_id, 529 | user_course_id, captcha_id, answer 530 | ) 531 | return captcha_token 532 | 533 | 534 | async def learn_course(tenant, user_id, user_project_id, user_course_id, course_id, 535 | course_name, user_name) -> bool: 536 | await study_start(tenant, user_id, user_project_id, course_id) 537 | await study_get_course_url(tenant, user_id, user_project_id, course_id) 538 | 539 | print("Start learning course", course_name, "with id", course_id, 540 | ". Please stand by...") 541 | # PS: this timeout is necessary. Otherwise, the request would be rejected. 542 | # Do not remove it in further updates. 543 | # Try to minimize the waiting time by sending requests continuously until no request would be rejected. 544 | await asyncio.sleep(config_instance.LEARN_TIMEOUT) # what can I say 545 | print("Fetching CAPTCHA of course", course_name, "with id", course_id, ". Starting coroutine...") 546 | captcha: json_structs.Captcha = await study_fetch_captcha(tenant, user_id, user_project_id, user_course_id) 547 | print("Verifying CAPTCHA of course", course_name, "with id", course_id, ". Starting coroutine...") 548 | success, captcha_token = await study_verify_captcha( 549 | tenant, user_id, user_project_id, user_course_id, 550 | captcha.question_id, 551 | answer=json_structs.CaptchaAnswer( 552 | json_structs.Position(192, 420), 553 | json_structs.Position(61, 416), 554 | json_structs.Position(120, 425) 555 | ) 556 | ) 557 | # Interestingly there's no need to "crack" the captcha, 558 | # for that submitting any answer can bypass the CAPTCHA. 559 | 560 | success = await study_terminate(user_course_id, tenant, captcha_token) 561 | if success: 562 | print("Task", course_name, "of user", user_name, "with id", course_id, "terminated successfully.") 563 | else: 564 | print("Task", course_name, "of user", user_name, "with id", course_id, "terminated due to failure!") 565 | return success 566 | -------------------------------------------------------------------------------- /web_utils2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import json 4 | import time 5 | from copy import deepcopy 6 | from typing import Optional 7 | 8 | import requests 9 | import urllib3 10 | from urllib3 import exceptions 11 | 12 | import crypto_helper 13 | import json_structs 14 | from config import * 15 | from web_utils import api, api_mapping, dbg_print, request_str_arg_builder, jquery_style_callback_parser 16 | 17 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 18 | 19 | 20 | def main_print(*msg, end="\n"): 21 | print("[Main]", *msg, end=end) 22 | 23 | 24 | class AccountEntity: 25 | """ 26 | This is a variant of web_utils for multi-account purposes. 27 | """ 28 | headers = { 29 | "X-Token": "", 30 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 31 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " 32 | "Chrome/127.0.0.0 " 33 | "Safari/537.36 Edg/127.0.0.0 " 34 | } 35 | 36 | proxies = { 37 | "http": None, 38 | "https": None 39 | } 40 | 41 | user: json_structs.User 42 | id_: int 43 | 44 | def __init__(self, user: json_structs.User, id_: int): 45 | self.user = user 46 | self.set_token(user.token) 47 | self.id_ = id_ 48 | 49 | def entity_print(self, *args, end="\n"): 50 | print(f"[Coroutine Entity {0}]".format(self.id_), *args, "of user", self.user.user_name, end=end) 51 | 52 | def set_token(self, token): 53 | """ 54 | Must be executed right after calling login() 55 | :param token: 56 | :return: 57 | """ 58 | self.headers["X-Token"] = token 59 | 60 | def post(self, url, data: dict, cookies: object = None) -> requests.Response: 61 | dbg_print("POST " + url) 62 | dbg_print("Parameters: " + json.dumps(data)) 63 | if cookies is None: 64 | cookies = {} 65 | resp: requests.Response = requests.post(url, data=data, headers=self.headers, cookies=cookies, 66 | timeout=5, verify=False, proxies=self.proxies) 67 | if len(t := resp.text) < config_instance.DEBUG_PRINT_MAX_LEN: 68 | dbg_print("Response: " + t) 69 | else: 70 | dbg_print("Response: " + t[0:65536]) 71 | return resp 72 | 73 | def get(self, url, cookies=None) -> requests.Response: 74 | dbg_print("GET " + url) 75 | if cookies is None: 76 | cookies = {} 77 | resp: requests.Response = requests.get(url, headers=self.headers, cookies=cookies, 78 | timeout=5, verify=False, proxies=self.proxies) 79 | if len(t := resp.text) < config_instance.DEBUG_PRINT_MAX_LEN: 80 | dbg_print("Response: " + t) 81 | else: 82 | dbg_print("Response: " + t[0:65536]) 83 | return resp 84 | 85 | def fetch_login_captcha(self) -> (requests.Response, int): 86 | current_time = time.time() 87 | return self.get( 88 | request_str_arg_builder(api_mapping[api.GET_LOGIN_CAPTCHA]) 89 | .concat("time", str(round(current_time * 1000))) 90 | .fetch(), 91 | ), current_time 92 | 93 | def fetch_all_tenants(self) -> dict[str, str]: 94 | resp = self.post( 95 | request_str_arg_builder(api_mapping[api.GET_TENANT_LIST]) 96 | .concat_ts() 97 | .fetch(), 98 | data={} 99 | ) 100 | 101 | ret = dict() 102 | 103 | data: list[dict] = json.loads(resp.text)["data"] 104 | for d in data: 105 | dbg_print(str({d["name"]: d["code"]})) 106 | ret.update({d["name"]: d["code"]}) 107 | 108 | return ret 109 | 110 | def fetch_tenant_conf(self, tenant_code: str): 111 | resp = self.post( 112 | request_str_arg_builder(api_mapping[api.GET_TENANT_CONF]) 113 | .concat_ts() 114 | .fetch(), 115 | data={ 116 | "tenantCode": tenant_code 117 | } 118 | ) 119 | 120 | data = json.loads(resp.text)["data"] 121 | pwd_prompt = data["passwordPrompt"] 122 | uname_prompt = data["userNamePrompt"] 123 | 124 | return uname_prompt, pwd_prompt 125 | 126 | async def login(self, tenant: str, uname: str, pwd: str, captcha: str, captcha_ts: float)\ 127 | -> (Optional[json_structs.User], str): 128 | captcha_ts = round(captcha_ts * 1000) 129 | dbg_print(json.dumps( 130 | { 131 | "keyNumber": uname, 132 | "password": pwd, 133 | "tenantCode": tenant, 134 | "time": captcha_ts, 135 | "verifyCode": captcha 136 | }) 137 | ) 138 | resp = self.post( 139 | request_str_arg_builder(api_mapping[api.LOGIN]) 140 | .concat_ts() 141 | .fetch(), 142 | data={ 143 | "data": crypto_helper.encrypt( 144 | json.dumps( 145 | { 146 | "keyNumber": uname, 147 | "password": pwd, 148 | "tenantCode": tenant, 149 | "time": captcha_ts, 150 | "verifyCode": captcha 151 | } 152 | ).replace(" ", "") 153 | ) 154 | } 155 | ) 156 | 157 | j = json.loads(resp.text) 158 | if j["code"] == "0": 159 | data = j["data"] 160 | return json_structs.User( 161 | data["realName"], 162 | data["tenantCode"], 163 | data["tenantName"], 164 | data["token"], 165 | data["uniqueValue"], 166 | data["userId"], 167 | data["userName"] 168 | ), resp.text 169 | else: 170 | return None, resp.text 171 | 172 | async def fetch_project_list(self, tenant: str, user_id: str, ended=2): 173 | """ 174 | Fetches designated project list according to argument ended. 175 | :param tenant: 176 | :param user_id: 177 | :param ended: when ended==2, function fetches ongoing projects; 178 | when ended==1, fetches finished projects instead. 179 | :return: 180 | """ 181 | resp = self.post( 182 | request_str_arg_builder(api_mapping[api.FETCH_PROJECT_LIST]) 183 | .concat_ts() 184 | .fetch(), 185 | data={ 186 | "tenantCode": tenant, 187 | "userId": user_id, 188 | "ended": str(ended) 189 | } 190 | ) 191 | 192 | data = json.loads(resp.text)["data"] 193 | ret: list = list() 194 | for d in data: 195 | ret.append( 196 | deepcopy( 197 | json_structs.Project( 198 | project_id=d["projectId"], 199 | project_name=d["projectName"], 200 | user_project_id=d["userProjectId"] 201 | ) 202 | ) 203 | ) 204 | return deepcopy(ret) 205 | 206 | async def fetch_category_list(self, tenant, user_id, user_project_id, choose_type=3): 207 | resp = self.post( 208 | request_str_arg_builder(api_mapping[api.FETCH_CATEGORY_LIST]) 209 | .concat_ts() 210 | .fetch(), 211 | data={ 212 | "tenantCode": tenant, 213 | "userId": user_id, 214 | "userProjectId": user_project_id, 215 | "chooseType": str(choose_type) 216 | } 217 | ) 218 | 219 | data = json.loads(resp.text)["data"] 220 | 221 | ret: list = list() 222 | for d in data: 223 | ret.append( 224 | deepcopy( 225 | json_structs.Category( 226 | d["categoryName"], 227 | d["categoryCode"], 228 | d["finishedNum"], 229 | d["totalNum"] 230 | ) 231 | ) 232 | ) 233 | return deepcopy(ret) 234 | 235 | async def fetch_course_list(self, tenant, user_id, user_project_id, category_code, choose_type=3): 236 | resp = self.post( 237 | request_str_arg_builder(api_mapping[api.FETCH_COURSE_LIST]) 238 | .concat_ts() 239 | .fetch(), 240 | data={ 241 | "tenantCode": tenant, 242 | "userId": user_id, 243 | "userProjectId": user_project_id, 244 | "chooseType": str(choose_type), 245 | "categoryCode": category_code 246 | } 247 | ) 248 | 249 | ret: list = list() 250 | data = json.loads(resp.text)["data"] 251 | for d in data: 252 | ret.append( 253 | deepcopy( 254 | json_structs.Course( 255 | resource_id=d["resourceId"], 256 | resource_name=d["resourceName"], 257 | user_course_id=d["userCourseId"] 258 | ) 259 | ) 260 | ) 261 | 262 | return deepcopy(ret) 263 | 264 | """ 265 | Flow: 266 | STUDY_START -> STUDY_GET_COURSE_URL -> 267 | STUDY_FETCH_CAPTCHA -> STUDY_CHECK_CAPTCHA -> 268 | STUDY_TERMINATE 269 | """ 270 | 271 | async def study_start(self, tenant, user_id, user_project_id, course_id) -> bool: 272 | """ 273 | 274 | :param tenant: 275 | :param user_id: 276 | :param user_project_id: 277 | :param course_id: resource_id of Course 278 | :return: 279 | """ 280 | resp = self.post( 281 | request_str_arg_builder(api_mapping[api.STUDY_START]) 282 | .concat_ts() 283 | .fetch(), 284 | data={ 285 | "tenantCode": tenant, 286 | "userId": user_id, 287 | "courseId": course_id, 288 | "userProjectId": user_project_id 289 | } 290 | ) 291 | 292 | if json.loads(resp.text)["code"] == "0": 293 | dbg_print("Starting " + course_id) 294 | return True 295 | else: 296 | dbg_print("Fail to start " + course_id) 297 | return False 298 | 299 | async def study_get_course_url(self, tenant, user_id, user_project_id, course_id) -> bool: 300 | """ 301 | 302 | :param tenant: 303 | :param user_id: 304 | :param user_project_id: 305 | :param course_id: resource_id of Course 306 | :return: 307 | """ 308 | resp = self.post( 309 | request_str_arg_builder(api_mapping[api.STUDY_GET_COURSE_URL]) 310 | .concat_ts() 311 | .fetch(), 312 | data={ 313 | "tenantCode": tenant, 314 | "userId": user_id, 315 | "courseId": course_id, 316 | "userProjectId": user_project_id 317 | } 318 | ) 319 | 320 | if json.loads(resp.text)["code"] == "0": 321 | dbg_print("Fetching " + course_id) 322 | return True 323 | else: 324 | dbg_print("Fail to fetch " + course_id) 325 | return False 326 | 327 | async def study_fetch_captcha(self, tenant, user_id, user_project_id, user_course_id) -> json_structs.Captcha: 328 | """ 329 | 330 | :param tenant: 331 | :param user_id: 332 | :param user_project_id: 333 | :param user_course_id: Note that this time must use user_course_id rather than resource_id!!! 334 | :return: 335 | """ 336 | resp = self.post( 337 | request_str_arg_builder(api_mapping[api.STUDY_FETCH_CAPTCHA]) 338 | .concat("userCourseId", user_course_id) 339 | .concat("userProjectId", user_project_id) 340 | .concat("userId", user_id, ) 341 | .concat("tenantCode", tenant) 342 | .fetch(), 343 | data={} 344 | ) 345 | 346 | captcha = json.loads(resp.text)["captcha"] 347 | return json_structs.Captcha( 348 | image_url=captcha["imageUrl"], 349 | num=captcha["num"], 350 | question_id=captcha["questionId"] 351 | ) 352 | 353 | async def study_verify_captcha(self, tenant, user_id, user_project_id, 354 | user_course_id, question_id, answer: json_structs.CaptchaAnswer) -> (bool, str): 355 | """ 356 | 357 | :param tenant: 358 | :param user_id: 359 | :param user_project_id: 360 | :param user_course_id: 361 | :param question_id: 362 | :param answer: 363 | :return: A bool, indicating whether the operation has succeeded; 364 | a string, which is the token for terminating study progress. 365 | """ 366 | resp = self.post( 367 | request_str_arg_builder(api_mapping[api.STUDY_CHECK_CAPTCHA]) 368 | .concat("userCourseId", user_course_id) 369 | .concat("userProjectId", user_project_id) 370 | .concat("userId", user_id) 371 | .concat("tenantCode", tenant) 372 | .concat("questionId", question_id) 373 | .fetch(), 374 | data={ 375 | "coordinateXYs": answer.fetch() 376 | } 377 | ) 378 | 379 | result = json.loads(resp.text) 380 | if error_code := result["code"] == "0": 381 | if (ck := result["data"]["checkResult"]) == 1: 382 | return True, result["data"]["methodToken"] 383 | else: 384 | dbg_print("Failed CAPTCHA with bad answer. CheckResult = " + str(ck)) 385 | return True, result["data"]["methodToken"] 386 | else: 387 | dbg_print("Failed CAPTCHA with error code = " + error_code) 388 | return False, None 389 | 390 | async def study_terminate(self, user_course_id, tenant, captcha_token) -> bool: 391 | jq, ts = jquery_style_callback_parser() 392 | resp = self.post( 393 | request_str_arg_builder(api_mapping[api.STUDY_TERMINATE]) 394 | .replace("", captcha_token) 395 | .concat("callback", jq) 396 | .concat("userCourseId", user_course_id) 397 | .concat("tenantCode", tenant) 398 | .concat("_", ts + 1 / 1000) # MAGIC Number 399 | .fetch(), 400 | data={} 401 | ) 402 | 403 | if "ok" in resp.text: 404 | dbg_print("Finished!") 405 | return True 406 | else: 407 | dbg_print("Failed!") 408 | return False 409 | 410 | async def captcha_crack(self, tenant, user_id, user_project_id, user_course_id, 411 | designated_captcha_id, answer: json_structs.CaptchaAnswer) -> str: 412 | """ 413 | 414 | :param tenant: 415 | :param user_id: 416 | :param user_project_id: 417 | :param user_course_id: 418 | :param designated_captcha_id: 419 | :param answer: 420 | :return: Token of CAPTCHA 421 | """ 422 | captcha_id = "" 423 | captcha: json_structs.Captcha 424 | count = 0 425 | while captcha_id != designated_captcha_id: 426 | captcha = await self.study_fetch_captcha(tenant, user_id, user_project_id, user_course_id) 427 | captcha_id = captcha.question_id 428 | count += 1 429 | if count > config_instance.CAPTCHA_CRACK_MAX_ITER: 430 | dbg_print("Maximum CAPTCHA crack iteration limit exceeded!") 431 | return "" 432 | success, captcha_token = await self.study_verify_captcha( 433 | tenant, user_id, user_project_id, 434 | user_course_id, captcha_id, answer 435 | ) 436 | return captcha_token 437 | 438 | async def learn_course(self, tenant, user_id, user_project_id, user_course_id, course_id, 439 | course_name, user_name) -> bool: 440 | await self.study_start(tenant, user_id, user_project_id, course_id) 441 | await self.study_get_course_url(tenant, user_id, user_project_id, course_id) 442 | 443 | self.entity_print("Start learning course", course_name, "with id", course_id, 444 | ". Please stand by...") 445 | await asyncio.sleep(config_instance.LEARN_TIMEOUT) 446 | self.entity_print("Fetching CAPTCHA of course", course_name, "with id", course_id, ". Starting coroutine...") 447 | captcha: json_structs.Captcha = await self.study_fetch_captcha(tenant, user_id, user_project_id, user_course_id) 448 | self.entity_print("Verifying CAPTCHA of course", course_name, "with id", course_id, ". Starting coroutine...") 449 | success, captcha_token = await self.study_verify_captcha( 450 | tenant, user_id, user_project_id, user_course_id, 451 | captcha.question_id, 452 | answer=json_structs.CaptchaAnswer( 453 | json_structs.Position(192, 420), 454 | json_structs.Position(61, 416), 455 | json_structs.Position(120, 425) 456 | ) 457 | ) 458 | 459 | success = await self.study_terminate(user_course_id, tenant, captcha_token) 460 | if success: 461 | self.entity_print("Task", course_name, "of user", user_name, "with id", course_id, 462 | "terminated successfully.") 463 | else: 464 | self.entity_print("Task", course_name, "of user", user_name, "with id", course_id, 465 | "terminated due to failure!") 466 | return success 467 | 468 | async def main(self): 469 | self.entity_print("Start fetching all ongoing projects...") 470 | projects: list[json_structs.Project] = await self.fetch_project_list(self.user.tenant_code, self.user.user_id) 471 | 472 | for p in projects: 473 | start = time.time() 474 | 475 | courses: list[json_structs.Course] = list() 476 | self.entity_print("Ongoing project:", p.project_name, "ID:", p.project_id, "User Project ID:", 477 | p.user_project_id) 478 | categories: list[json_structs.Category] = \ 479 | await self.fetch_category_list(self.user.tenant_code, self.user.user_id, p.user_project_id) 480 | 481 | task_courses: list = list() 482 | for c in categories: 483 | self.entity_print("Ongoing category:", c.category_name, "ID:", c.category_code) 484 | if (not c.finished) or (not config_instance.IGNORE_FINISHED_TASKS): 485 | task_courses.append( 486 | asyncio.create_task( 487 | self.fetch_course_list( 488 | self.user.tenant_code, self.user.user_id, 489 | p.user_project_id, c.category_code 490 | ) 491 | ) 492 | ) 493 | self.entity_print("Starting to fetch ongoing courses asynchronously...") 494 | if not len(task_courses) == 0: 495 | courses = functools.reduce(lambda x, y: x + y, await asyncio.gather(*task_courses)) 496 | self.entity_print("Detected", len(courses), "courses to be finished.") 497 | tasks: list = list() 498 | 499 | cnt = 0 500 | for c in courses: 501 | tasks.append( 502 | asyncio.create_task( 503 | self.learn_course( 504 | self.user.tenant_code, self.user.user_id, 505 | p.user_project_id, c.user_course_id, c.resource_id, 506 | c.resource_name, self.user.user_name 507 | ) 508 | ) 509 | ) 510 | 511 | cnt += 1 512 | if cnt >= config_instance.MAX_TASK_NUM: 513 | break 514 | 515 | self.entity_print("Gathered", total_cnt := len(tasks), "tasks in total. Starting coroutines...") 516 | result = await asyncio.gather(*tasks) 517 | 518 | end = time.time() 519 | 520 | success_cnt = 0 521 | for r in result: 522 | if r: 523 | success_cnt += 1 524 | 525 | time_elapsed = end - start 526 | self.entity_print("Succeeded tasks:", success_cnt, "of", total_cnt, end=".\n") 527 | else: 528 | self.entity_print("This project has been finished before.") 529 | time_elapsed = 0 530 | self.entity_print("Ongoing task of Project", p.project_name, "successfully terminated in", time_elapsed, 531 | "secs.") 532 | 533 | self.entity_print("All ongoing tasks successfully terminated.") 534 | 535 | async def run(self) -> bool: 536 | try: 537 | await self.main() 538 | except: 539 | return False 540 | finally: 541 | return True 542 | 543 | 544 | class AccountEntityManager: 545 | account_entities: list[AccountEntity] 546 | entity_id: int 547 | 548 | def __init__(self): 549 | self.account_entities = list() 550 | self.entity_id = 0 551 | 552 | def add_entity(self, entity: AccountEntity): 553 | entity.id_ = self.entity_id 554 | self.account_entities.append(entity) 555 | self.entity_id += 1 556 | 557 | return self 558 | 559 | def remove_entity(self, entity_id: int): 560 | for i in self.account_entities: 561 | if i.id_ == entity_id: 562 | self.account_entities.remove(i) 563 | return self 564 | 565 | def fetch_entity(self, entity_id: int) -> AccountEntity: 566 | for i in self.account_entities: 567 | if i.id_ == entity_id: 568 | return i 569 | 570 | async def start_all_instances(self): 571 | tasks = list() 572 | cnt_all = len(self.account_entities) 573 | cnt = 1 574 | for entity in self.account_entities: 575 | main_print("Generating task", cnt, "of", cnt_all, "tasks...") 576 | tasks.append( 577 | asyncio.create_task( 578 | entity.run() 579 | ) 580 | ) 581 | 582 | main_print("Starting all instances...") 583 | 584 | success_cnt = 0 585 | result = await asyncio.gather(*tasks) 586 | for r in result: 587 | if r: 588 | success_cnt += 1 589 | 590 | main_print("All", cnt_all, "running instances have quited with", success_cnt, "succeeded instances.") 591 | 592 | 593 | class AccountEntityFactory: 594 | def make_entity(self, session: json_structs.User) -> AccountEntity: 595 | return AccountEntity(session, 0) 596 | 597 | def make_entity_from_sessions(self, sessions: list[json_structs.User]) -> list[AccountEntity]: 598 | ret = list() 599 | for sess in sessions: 600 | ret.append( 601 | AccountEntity( 602 | sess, 0 603 | ) 604 | ) 605 | return ret 606 | 607 | def make_entity_from_account_data(self, json_filename: str): 608 | # TODO 609 | pass 610 | 611 | 612 | class SingletonHolder: 613 | factory: AccountEntityFactory 614 | manager: AccountEntityManager 615 | 616 | def __init__(self): 617 | self.manager = AccountEntityManager() 618 | self.factory = AccountEntityFactory() 619 | 620 | 621 | web_utils2: SingletonHolder = SingletonHolder() 622 | --------------------------------------------------------------------------------