From 32352a2db9f2a6f600fd3a23f9535bc779d2edfc Mon Sep 17 00:00:00 2001 From: Vermilli0n Date: Sat, 14 May 2022 02:26:35 +0800 Subject: [PATCH] new login method --- README.md | 25 +++++---- fucker.py | 160 ++++++++++++++++++++++++++++-------------------------- main.py | 13 +++-- meta.json | 2 +- 4 files changed, 106 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index f21aaf0..6016d2e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ ## ZHS Fucker 食用指北 #### WTF? -这是一个 _Python3_ 的自动脚本, 用于自动刷智慧树课堂课程(翻转课和知到的共享学分课), 为您节约有限的生命. - _*起初仅为翻转课而写, 现在也支持知到的共享学分课_ +这是一个 _Python3_ 的自动脚本, 用于自动刷智慧树课堂课程, 为您节约有限的生命. + +**Features** +* 支持翻转课与知到共享学分课 +* 自动回答弹题 +* 设定时限 +* 射后不管, 无需交互 *** #### WHY? -自从智慧树的翻转课("hike"开头的域名)播放页面用了个窒息的 _JavaScript_ 混淆之后, 各种前端的脚本都没法用了. -因为它会检查 DevTools 是否打开, 如果开了就无法继续运行, 要分析的话由于混淆, 解读很麻烦. +自从智慧树的翻转课(hike)播放页面用了个窒息的 _JavaScript_ 混淆之后, 各种前端的脚本都没法用了. +因为它会检查 _DevTools_ 是否打开, 如果开了就无法继续运行, 要分析的话由于混淆, 解读很麻烦. 于是我打算直接抄家, 入它后端(*), 所以便有了该脚本. (虽然最后还是被逼着反混淆了前端代码...) *** #### 重要更新 -v1.X -> v2.0, 有以下重要改变 +-> v2.0.0: 1. 新增 知到 API(studyservice-api) 的支持, 参考了 [luoyily](https://github.com/luoyily/zhihuishu-tool) 与 [zhixiaobai](https://github.com/zhixiaobai/Python-zhihuishu) 的 repo -2. 不再需要 _selenium_ 来登入, 使用 _selenium_ 的版本将移入其他分支, 后续应该很少更新了 +2. 移除依赖 _selenium_ , 使用 _selenium_ 的版本将移入其他分支, 随缘更新 3. 新增依赖 _pycryptodome_ *** #### 准备工作 @@ -79,7 +84,7 @@ fucker.cookies = {} 登入之后就可以开干,例如您想要干 ID 为 `"114514"` 的课程,可以这样做: ```bash cd fuckZHS -python main.py -c 114514 +python main.py -c 114514 # -c 缺省将需要交互输入课程 ID # 又或者可以限制学习25分钟 python main.py -c 114514 -l 25 # 遇到问题想开 debug 模式, 顺带加个代理? @@ -88,7 +93,7 @@ python main.py -c 114514 -d --proxy http://127.0.0.1:2333 python main.py -c 114514 -v 4060 9891 ``` 什么?不知道课程 ID 或视频 ID? 进入课程界面就可以在网址里看到了. - _*课程 ID 为 `courseId` 或 `recruitAndCourseId` 参数, 如果为后者, 视频 ID 将被无视._ + _*课程 ID 为网址中的 `courseId`(翻转课) 或 `recruitAndCourseId`(共享学分课) 参数_ _**更多选项请使用 `-h` 查看._ _***优先级: 命令行 > 配置文件_ @@ -116,9 +121,9 @@ fucker.fuckVideo(courseId, fileId) # 只干一个视频 * 登入失败 * 检查您的账号密码是否有误 * 先用浏览器登入一次看看, 可能您的 IP 地址被认为是异地了, 需要短信验证 -* 请求响应: “需要滑块验证” (触发原因不明) +* 请求响应信息: “需要滑块验证” (触发原因不明) * 浏览器登入后手动过一次验证 -* 请求响应: “不明服务器故障” +* 请求响应代码: -1 * 某些请求被认为内容不合法了, 因为我测试例很少, 可能有些特例覆盖不全, 请把错误日志贴上, 开个 issue, 我尽力解决 * 语法错误 * 喂, 伙计, _Python_ 版本对了没? diff --git a/fucker.py b/fucker.py index b204ee2..63df981 100644 --- a/fucker.py +++ b/fucker.py @@ -3,7 +3,6 @@ from requests.adapters import HTTPAdapter, Retry from utils import progressBar, HMS from random import randint, random -from collections import deque from threading import Thread from base64 import b64encode from getpass import getpass @@ -18,6 +17,24 @@ import re import os +""" +⠄⠄⠄⢰⣧⣼⣯⠄⣸⣠⣶⣶⣦⣾⠄⠄⠄⠄⡀⠄⢀⣿⣿⠄⠄⠄⢸⡇⠄⠄ +⠄⠄⠄⣾⣿⠿⠿⠶⠿⢿⣿⣿⣿⣿⣦⣤⣄⢀⡅⢠⣾⣛⡉⠄⠄⠄⠸⢀⣿⠄ +⠄⠄⢀⡋⣡⣴⣶⣶⡀⠄⠄⠙⢿⣿⣿⣿⣿⣿⣴⣿⣿⣿⢃⣤⣄⣀⣥⣿⣿⠄ +⠄⠄⢸⣇⠻⣿⣿⣿⣧⣀⢀⣠⡌⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣿⣿⠄ +⠄⢀⢸⣿⣷⣤⣤⣤⣬⣙⣛⢿⣿⣿⣿⣿⣿⣿⡿⣿⣿⡍⠄⠄⢀⣤⣄⠉⠋⣰ +⠄⣼⣖⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⢇⣿⣿⡷⠶⠶⢿⣿⣿⠇⢀⣤ +⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣷⣶⣥⣴⣿⡗ +⢀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠄ +⢸⣿⣦⣌⣛⣻⣿⣿⣧⠙⠛⠛⡭⠅⠒⠦⠭⣭⡻⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠄ +⠘⣿⣿⣿⣿⣿⣿⣿⣿⡆⠄⠄⠄⠄⠄⠄⠄⠄⠹⠈⢋⣽⣿⣿⣿⣿⣵⣾⠃⠄ +⠄⠘⣿⣿⣿⣿⣿⣿⣿⣿⠄⣴⣿⣶⣄⠄⣴⣶⠄⢀⣾⣿⣿⣿⣿⣿⣿⠃⠄⠄ +⠄⠄⠈⠻⣿⣿⣿⣿⣿⣿⡄⢻⣿⣿⣿⠄⣿⣿⡀⣾⣿⣿⣿⣿⣛⠛⠁⠄⠄⠄ +⠄⠄⠄⠄⠈⠛⢿⣿⣿⣿⠁⠞⢿⣿⣿⡄⢿⣿⡇⣸⣿⣿⠿⠛⠁⠄⠄⠄⠄⠄ +⠄⠄⠄⠄⠄⠄⠄⠉⠻⣿⣿⣾⣦⡙⠻⣷⣾⣿⠃⠿⠋⠁⠄⠄⠄⠄⠄⢀⣠⣴ +⣿⣿⣿⣶⣶⣮⣥⣒⠲⢮⣝⡿⣿⣿⡆⣿⡿⠃⠄⠄⠄⠄⠄⠄⠄⣠⣴⣿⣿⣿ +""" + class TimeLimitExceeded(Exception): pass @@ -67,7 +84,7 @@ def __init__(self, cookies: dict = None, logger.debug(f'proxies: {self.proxies}') logger.debug(f'headers: {self.headers}') - self.limit = abs(limit*60) # time limit for fucking + self.limit = abs(limit) # time limit for fucking, in minutes self.total_studied_time = 0 # in seconds, fucking will stop when it reaches the limit self.speed = speed and max(speed, 0.1) # video play speed, Falsy values for default self.end_thre = min(end_thre or 0.91, 1.0) # video play end threshold, above this will be considered as finished @@ -81,13 +98,14 @@ def cookies(self): def cookies(self, cookies: dict|requests.cookies.RequestsCookieJar): self._cookies = cookies if isinstance(cookies, requests.cookies.RequestsCookieJar)\ else requests.utils.cookiejar_from_dict(cookies) + logger.debug(f'received cookies: {self.cookies}') if cookies: try: self.uuid = json.loads(unquote(cookies["CASLOGC"]))["uuid"] self._cookies[f"exitRecod_{self.uuid}"] = "2" except Exception: raise InvalidCookies() - logger.debug(f"cookies: {self._cookies}") + logger.debug(f"set cookies: {self._cookies}") def login(self, username: str=None, password: str=None): while not username or not password: @@ -97,43 +115,38 @@ def login(self, username: str=None, password: str=None): print(f"Username: {username}") if not password: password = getpass("Password: ") - - login_page = "https://passport.zhihuishu.com/login?service=https://onlineservice.zhihuishu.com/login/gologin" + # urls + login_page = "https://passport.zhihuishu.com/login?service=https://onlineservice-api.zhihuishu.com/login/gologin" + valid_url = "https://passport.zhihuishu.com/user/validateAccountAndPassword" + check_url = "https://appcomm-user.zhihuishu.com/app-commserv-user/userInfo/checkNeedAuth" self._sessionReady() # set cookies, headers, proxies self.session.headers.update({ "Origin": "https://passport.zhihuishu.com", "Referer": login_page }) try: - r = self.session.get(login_page, proxies=self.proxies, timeout=10) - tree = html.fromstring(r.text) - lt = tree.xpath("//input[@name=\"lt\"]")[0].attrib["value"] - data = { - "lt": lt, - "execution": "e1s1", - "_eventId": "submit", - "username": username, - "password": password, - "clCode": "", - "clPassword": "", - "tlCode": "", - "tlPassword": "", - "remember": "on" + self.session.get(login_page, proxies=self.proxies, timeout=10) + form = { + "account": username, + "password": password } - self.session.post(login_page,data=data, proxies=self.proxies, timeout=10) + user_info = self._apiQuery(valid_url, form) # get uuid and pwd + need_auth = self._apiQuery(check_url, {"uuid": user_info.uuid}).rt.needAuth + if need_auth: + raise Exception("account need auth, please login using browser to pass auth") + self.session.get(login_page, params={"pwd": user_info.pwd}, proxies=self.proxies, timeout=10) self.cookies = self.session.cookies.copy() if not self.cookies: raise Exception("No cookies found") - - logger.debug(f"session cookies: {self.session.cookies}\ncookies: {self.cookies}") + logger.info("Login successful") print("Login successful") except InvalidCookies as e: logger.error(f"Invalid cookies") - print("Invalid cookies, login failed") + raise Exception("Login failed: Invalid cookies") except Exception as e: logger.error(f"Login failed: {e}") - print(f"Login failed: {e}") + raise Exception("Login failed: {e}") def fuckCourse(self, course_id:str): if re.match(r".*[a-zA-Z].*", course_id): # determine if it's a course id or a recruitAndCourseId @@ -183,13 +196,13 @@ def fuckZhidaoCourse(self, RAC_id:str): self.session.get(login_url, params=params, proxies=self.proxies) # get course info, including schoolId, recruitId, course name, etc - course = self._apiQuery(course_url, data).data + course = self._zhidaoQuery(course_url, data).data school_id = course.schoolId # not used recruit_id = course.recruitId logger.info(f"course: {course.courseInfo.name}") # get videos list - chapters = self._apiQuery(videos_url, data).data + chapters = self._zhidaoQuery(videos_url, data).data chapters.default = [] # set default value for non exist attribute logger.debug(json.dumps(chapters, indent=4)) course_id = chapters.courseId @@ -208,7 +221,7 @@ def fuckZhidaoCourse(self, RAC_id:str): f"(total root chapters: {len(chapters.videoChapterDtos)})") # get read-before, maybe unneccessary. BUTT hey, it's a POST request - self._apiQuery(read_url, data={ + self._zhidaoQuery(read_url, data={ "courseId": course_id, "recruitId": recruit_id, }) @@ -219,13 +232,13 @@ def fuckZhidaoCourse(self, RAC_id:str): "lessonVideoIds": [video.id for video in videos], "recruitId": recruit_id } - r = self._apiQuery(state_url, data=data).data + r = self._zhidaoQuery(state_url, data=data).data r.default = {} # set default value for non exist attribute r.lesson.update(r.lv) # join lesson and video info states = r.lesson # get most recently viewed video id, probably unneccessary, again, it's a POST request - last_video = self._apiQuery(last_url, data={ + last_video = self._zhidaoQuery(last_url, data={ "recruitId": recruit_id }).data.lastViewVideoId @@ -268,8 +281,8 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): :param video: video info :param ctx: context info, including course id, chapter id, recruit id, """ - if self.limit and self.total_studied_time >= self.limit: - raise TimeLimitExceeded(f"{self.limit//60} minutes") + if self.limit and self.total_studied_time >= self.limit*60: + raise TimeLimitExceeded(f"{self.limit} minutes") # urls note_url = "https://studyservice-api.zhihuishu.com/gateway/t/v1/learning/prelearningNote" @@ -294,7 +307,7 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): "recruitId": ctx.recruit_id, "videoId": video.videoId } - token_id = self._apiQuery(note_url, data=data).data.studiedLessonDto.id + token_id = self._zhidaoQuery(note_url, data=data).data.studiedLessonDto.id token_id = b64encode(str(token_id).encode()).decode() # get questions @@ -304,15 +317,15 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): "recruitId": ctx.recruit_id, "courseId": ctx.course_id } - questions = self._apiQuery(event_url, data=data).data.questionPoint - questions = deque(sorted(questions, key=lambda x: x.timeSec)) if questions else None - while questions and questions[0].timeSec <= played_time: - questions.popleft() # remove questions that are already answered + questions = self._zhidaoQuery(event_url, data=data).data.questionPoint + questions = sorted(questions, key=lambda x: x.timeSec, reverse=True) if questions else None + while questions and questions[-1].timeSec <= played_time: + questions.pop() # remove questions that are already answered # compute end time and make sure to answer all questions end_time = video.videoSec * self.end_thre if questions: - end_time = max(questions[-1].timeSec, end_time) + end_time = max(questions[0].timeSec, end_time) # compare last question time with end_time # emulating video playing watch_thread = Thread(target=self._watchVideo, args=(video.videoId,)) @@ -339,10 +352,10 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): ### events ## get questions - if questions and played_time >= questions[0].timeSec: - question = questions.popleft() + if questions and played_time >= questions[-1].timeSec: + question = questions.pop() try: - question = self._apiQuery(getQ_url, data={ + question = self._zhidaoQuery(getQ_url, data={ "lessonId": video.lessonId, # this.lessonId "lessonVideoId": video.id, # this.smallLessonId "questionIds" : question.questionIds @@ -355,7 +368,7 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): if answer is not None: if answer == 0: answer = None # unset answer flag - self._apiQuery(subQ_url, data={ + self._zhidaoQuery(subQ_url, data={ "courseId": ctx.course_id, # this.courseId, "recruitId": ctx.recruit_id, # this.recruitId "testQuestionId": question.questionId, # this.pageList.testQuestion.questionId @@ -393,7 +406,7 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): "ev": getEv(raw_ev), "learningTokenId": token_id } - self._apiQuery(record_url, data=data) # now submit to database + self._zhidaoQuery(record_url, data=data) # now submit to database last_submit = played_time # update last pause time watch_point = "0,1" # reset watch point ## report to cache @@ -417,7 +430,7 @@ def _fuckZhidaoVideo(self, video:ObjDict, state:ObjDict, ctx:ObjDict): "ev": getEv(raw_ev), "learningTokenId": token_id } - self._apiQuery(cache_url, data=data) # now submit to cache + self._zhidaoQuery(cache_url, data=data) # now submit to cache last_submit = played_time # update last pause time watch_point = "0,1" # reset watch point ### end events @@ -433,35 +446,21 @@ def answerZhidao(self, q:dict): a = [str(opt.id) for opt in q.questionOptions if opt.result=='1'] # choose correct answers return ','.join(a) - def _apiQuery(self, url:str, data:dict, encrypt:bool=True, ok_code:int=0, + def _zhidaoQuery(self, url:str, data:dict, encrypt:bool=True, ok_code:int=0, setTimeStamp:bool=True, method:str="POST"): """set ok_code to None for no check""" - method = method.upper() cipher = Cipher() if setTimeStamp: data["dateFormate"] = int(time.time())*1000 # somehow their timestamps are ending with 000 - logger.debug(f"{method} url: {url}\ndata: {json.dumps(data, indent=4)}\n"+ - f"headers: {json.dumps(self.headers, indent=4)}\n"+ - f"cookies: {self.session.cookies}\n"+ - f"proxies: {json.dumps(self.session.proxies, indent=4)}") form ={"secretStr": cipher.encrypt(json.dumps(data)) if encrypt else json.dumps(data)} - match method: - case "POST": - r = self.session.post(url, data=form, proxies=self.proxies, timeout=10) - case "GET": - r = self.session.get(url, params=form, proxies=self.proxies, timeout=10) - case _: - e = ValueError(f"Unsupport method: {method}") - logger.error(e) - raise e - ret = ObjDict(r.json()) + ret = self._apiQuery(url, data=form, method=method) if ok_code is not None and ret.code != ok_code: e = Exception(ret.message) logger.error(e) raise e return ret - +# end of zhidao methods ############################################# # following are methods for hike API def fuckFile(self, course_id, file_id): @@ -478,9 +477,9 @@ def fuckHikeVideo(self, course_id, file_id, prev_time=0): if not self._cookies: logger.warning("No cookies found, please login first") return - if self.limit and self.total_studied_time >= self.limit: + if self.limit and self.total_studied_time >= self.limit*60: logger.info(f"Studied time limit reached, video {file_id} skipped") - raise TimeLimitExceeded(f"{self.limit//60} minutes") + raise TimeLimitExceeded(f"{self.limit} minutes") logger.info(f"Fucking Hike video {file_id} of course {course_id}") begin_time = time.time() @@ -597,27 +596,13 @@ def _traverse(self,course_id, node: ObjDict, depth=0): def _hikeQuery(self, url:str, data:dict,sig:bool=False, ok_code:int=200, setTimeStamp:bool=True, method:str="GET"): """set ok_code to None for no check""" - method = method.upper() if setTimeStamp: data["_"] = int(time.time()*1000) # miliseconds if sig: for k,v in data.items(): data[k] = str(v) data["signature"] = sign(data) - logger.debug(f"{method} url: {url}\ndata: {json.dumps(data, indent=4)}\n"+ - f"headers: {json.dumps(self.headers, indent=4)}\n"+ - f"cookies: {self.session.cookies}\n"+ - f"proxies: {json.dumps(self.session.proxies, indent=4)}") - match method: - case "POST": - r = self.session.post(url, data=data, proxies=self.proxies, timeout=10) - case "GET": - r = self.session.get(url, params=data, proxies=self.proxies, timeout=10) - case _: - e = ValueError(f"Unsupport method: {method}") - logger.error(e) - raise e - ret = ObjDict(r.json()) + ret = self._apiQuery(url, data, method=method) if ok_code is not None and int(ret.status) != ok_code: e = Exception(f"{ret.status} {ret.msg}") logger.error(e) @@ -642,8 +627,27 @@ def _watchVideo(self, video_id): # it's probably unnecessary but let's keep it t url = ObjDict(json.loads(r)).result.lines[0].lineUrl requests.get(url, headers=headers, cookies=cookies, proxies=self.proxies) + def _apiQuery(self, url:str, data:dict, method:str="POST"): + method = method.upper() + logger.debug(f"{method} url: {url}\ndata: {json.dumps(data, indent=4)}\n"+ + f"headers: {json.dumps(self.headers, indent=4)}\n"+ + f"cookies: {self.session.cookies}\n"+ + f"proxies: {json.dumps(self.session.proxies, indent=4)}") + match method: + case "POST": + r = self.session.post(url, data=data, proxies=self.proxies, timeout=10) + case "GET": + r = self.session.get(url, params=data, proxies=self.proxies, timeout=10) + case _: + e = ValueError(f"Unsupport method: {method}") + logger.error(e) + raise e + ret = ObjDict(r.json()) + logger.debug(json.dumps(ret, indent=4)) + return ret + def _sessionReady(self): - self.session.cookies = self._cookies.copy() + self.session.cookies = self.cookies.copy() self.session.headers = self.headers.copy() self.session.proxies = self.proxies.copy() diff --git a/main.py b/main.py index 547f445..b6a593d 100644 --- a/main.py +++ b/main.py @@ -25,10 +25,10 @@ # parse auguments parser = argparse.ArgumentParser(prog="ZHS Fucker") -parser.add_argument("-c", "--course", type=str, required=True, help="CourseId or recruitAndCourseId, can be found in URL") +parser.add_argument("-c", "--course", type=str, help="CourseId or recruitAndCourseId, can be found in URL") parser.add_argument("-v", "--videos", type=str, nargs="+", help="Video IDs(fileId), can be found in URL, won't work if -c is recruitAndCourseId") parser.add_argument("-u", "--username", type=str, help="if not set anywhere, will be prompted") -parser.add_argument("-p", "--password", type=str, help="If not set anywhere, will be prompted. Be careful, it will be stored history") +parser.add_argument("-p", "--password", type=str, help="If not set anywhere, will be prompted. Be careful, it will be stored in history") parser.add_argument("-s", "--speed", type=float, help="Video Play Speed, default value is maximum speed when watching in browser") parser.add_argument("-t", "--threshold", type=float, help="Video End Threshold, above this will be considered finished, overloaded when there are questions left unanswered") parser.add_argument("-l", "--limit", type=int, help="Time Limit (in minutes, 0 for no limit), default is 0") @@ -36,7 +36,9 @@ parser.add_argument("--proxy", type=str, help="HTTP Proxy Server, e.g: http://127.0.0.1:8080") args = parser.parse_args() - +course = args.course +while not course: + course = input("Requires courseId or recruitAndCourseId: ") logger.setLevel("DEBUG" if args.debug else config.logLevel) username = args.username or config.username password = args.password or config.password @@ -88,9 +90,10 @@ if args.videos: for v in args.videos: print(f"fucking {v}") - fucker.fuckVideo(course_id=args.course, file_id=v) + fucker.fuckVideo(course_id=course, file_id=v) else: - fucker.fuckCourse(course_id=args.course) + fucker.fuckCourse(course_id=course) + # use fuckCourse method to fuck the entire course # fucker.fuckCourse(course_id="") diff --git a/meta.json b/meta.json index 0a24bf9..3a49594 100644 --- a/meta.json +++ b/meta.json @@ -1,5 +1,5 @@ { - "version": "2.0.2", + "version": "2.0.3", "branch": "master", "author": "VermiIIi0n" } \ No newline at end of file