-
Notifications
You must be signed in to change notification settings - Fork 430
Expand file tree
/
Copy pathbase.py
More file actions
1223 lines (1044 loc) · 48.6 KB
/
Copy pathbase.py
File metadata and controls
1223 lines (1044 loc) · 48.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
import functools
import random
import re
import threading
import time
from difflib import SequenceMatcher
from enum import Enum, IntEnum
from hashlib import md5
from typing import Self, Optional, Literal
import requests
from loguru import logger
from requests import RequestException
from requests.adapters import HTTPAdapter
from tqdm import tqdm
from api.answer import *
from api.answer_check import cut
from api.cipher import AESCipher
from api.config import GlobalConst as gc
from api.cookies import save_cookies, use_cookies
from api.decode import (
decode_course_list,
decode_course_point,
decode_course_card,
decode_course_folder,
decode_questions_info,
)
from api.exceptions import MaxRetryExceeded
def get_timestamp():
return str(int(time.time() * 1000))
class SessionManager:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
self._session = requests.Session()
self._session.mount("https://", HTTPAdapter(max_retries=10))
self._session.mount("http://", HTTPAdapter(max_retries=10))
self._session.request = functools.partial(self._session.request, timeout=5)
# For debug purposes
# self._session.verify=False
self._session.headers.clear()
self._session.headers.update(gc.HEADERS)
self._session.cookies.update(use_cookies())
@classmethod
def get_instance(cls) -> Self:
return cls()
@classmethod
def get_session(cls) -> requests.Session:
instance = cls.get_instance()
return instance._session
@classmethod
def update_cookies(cls):
cls.get_instance()._session.cookies.update(use_cookies())
class Account:
username = None
password = None
last_login = None
isSuccess = None
def __init__(self, _username, _password):
self.username = _username
self.password = _password
class RateLimiter:
def __init__(self, call_interval):
self.last_call = time.time()
self.lock = threading.Lock()
self.call_interval = call_interval
def limit_rate(self, random_time=False, random_min=0.0, random_max=1.0):
with self.lock:
now = time.time()
base_wait = max(self.last_call + self.call_interval - now, 0)
extra_wait = random.uniform(random_min, random_max) if random_time else 0
call_wait = base_wait + extra_wait
self.last_call = now + call_wait
time.sleep(call_wait)
class StudyResult(Enum):
SUCCESS = 0
FORBIDDEN = 1 # 403
ERROR = 2
TIMEOUT = 3
def is_success(self):
return self == StudyResult.SUCCESS
def is_failure(self):
return self != StudyResult.SUCCESS
class SignType(IntEnum):
NORMAL = 0
GESTURE = 3
LOCATION = 4
class ActivityStatus(IntEnum):
ACTIVE = 1
INACTIVE = 2
class ActivityType(IntEnum):
SIGNIN = 2
class Chaoxing:
def __init__(self, account: Account = None, tiku: Tiku = None, **kwargs):
self.account = account
self.cipher = AESCipher()
self.tiku = tiku
self.kwargs = kwargs
self.rollback_times = 0
self.rate_limiter = RateLimiter(0.5) # 其他接口速率限制比较松
self.video_log_limiter = RateLimiter(2) # 上报进度极其容易卡验证码,限制2s一次
def login(self, login_with_cookies=False):
if login_with_cookies:
logger.info("Logging in with cookies")
SessionManager.update_cookies()
logger.debug(f"Logged in with cookies: {SessionManager.get_instance()._session.cookies}")
if not self._validate_cookie_session():
logger.warning("Cookie 登录校验失败,尝试使用账号密码重新登录")
if self.account and self.account.username and self.account.password:
return self.login(login_with_cookies=False)
return {"status": False, "msg": "cookies 已失效,请更新 cookies 或提供账号密码"}
logger.info("登录成功...")
return {"status": True, "msg": "登录成功"}
_session = requests.Session()
_url = "https://passport2.chaoxing.com/fanyalogin"
_data = {
"fid": "-1",
"uname": self.cipher.encrypt(self.account.username),
"password": self.cipher.encrypt(self.account.password),
"refer": "https%3A%2F%2Fi.chaoxing.com",
"t": True,
"forbidotherlogin": 0,
"validate": "",
"doubleFactorLogin": 0,
"independentId": 0,
}
logger.trace("正在尝试登录...")
resp = _session.post(_url, headers=gc.HEADERS, data=_data)
if resp and resp.json()["status"] == True:
save_cookies(_session)
SessionManager.update_cookies()
logger.info("登录成功...")
return {"status": True, "msg": "登录成功"}
else:
return {"status": False, "msg": str(resp.json()["msg2"])}
def _validate_cookie_session(self) -> bool:
session = SessionManager.get_instance()._session
if not session.cookies.get("_uid"):
return False
test_session = requests.Session()
test_session.headers.update(gc.HEADERS)
test_session.cookies.update(session.cookies.get_dict())
try:
resp = test_session.post(
"https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata",
data={"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0},
timeout=8,
)
except RequestException as exc:
logger.debug("Cookie validation request failed: {}", exc)
return False
if resp.status_code != 200:
return False
if "passport2.chaoxing.com" in resp.text or "login" in resp.text.lower():
return False
return True
def get_fid(self):
_session = SessionManager.get_session()
return _session.cookies.get("fid", 1024)
def get_uid(self):
s = SessionManager.get_session()
if "_uid" in s.cookies:
return s.cookies["_uid"]
if "UID" in s.cookies:
return s.cookies["UID"]
raise ValueError("Cannot get uid !")
def get_course_list(self):
_session = SessionManager.get_session()
_url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata"
_data = {"courseType": 1, "courseFolderId": 0, "query": "", "superstarClass": 0}
logger.trace("正在读取所有的课程列表...")
# 接口突然抽风, 增加headers
# 有可能只是referer的问题
_headers = {
"Referer": "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction?moocDomain=https://mooc1-1.chaoxing.com/mooc-ans",
}
_resp = _session.post(_url, headers=_headers, data=_data)
# logger.trace(f"原始课程列表内容:\n{_resp.text}")
logger.info("课程列表读取完毕...")
course_list = decode_course_list(_resp.text)
_interaction_url = "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction"
_interaction_resp = _session.get(_interaction_url)
course_folder = decode_course_folder(_interaction_resp.text)
for folder in course_folder:
_data = {
"courseType": 1,
"courseFolderId": folder["id"],
"query": "",
"superstarClass": 0,
}
_resp = _session.post(_url, data=_data)
course_list += decode_course_list(_resp.text)
return course_list
def get_activity_list(self, course: dict) -> list[dict]:
s = SessionManager.get_session()
url = "https://mobilelearn.chaoxing.com/v2/apis/active/student/activelist"
params = {
"fid": self.get_fid(),
"courseId": course["courseId"],
"classId": course["clazzId"],
"showNotStartedActive": 0,
"_": get_timestamp()
}
resp = s.get(url, params=params, allow_redirects=False)
if resp.status_code != 200:
logger.error("Failed to get activity list, return code: " + str(resp.status_code))
logger.debug("Request url: " + resp.url)
return []
data = resp.json()
if data["result"] != 1:
logger.error("Unknown status: {} {}", data["result"], data["errorMsg"])
logger.debug("Request url: " + resp.url)
return []
return data["data"]["activeList"]
def pre_sign(self, course: dict, activity_id):
s = SessionManager.get_session()
params = {
"general": 1,
"sys": 1,
"ls": 1,
"appType": 15,
"tid": '',
"ut": 's',
"uid": self.get_uid(),
"activePrimaryId": activity_id,
"courseId": course["courseId"],
"classId": course["clazzId"],
}
resp = s.get('https://mobilelearn.chaoxing.com/newsign/preSign', params=params)
resp_txt = resp.text
logger.debug("Request url" + resp.url)
if resp.status_code != 200:
logger.error("Failed to get sign in, return code: " + str(resp.status_code) + "message: " + resp_txt)
return resp_txt
def sign_in_normal(self, course: dict, activity_id, name="", obj_id="aaa", lat=-1, lon=-1, type_=SignType.NORMAL):
s = SessionManager.get_session()
params = {
"activeId": activity_id,
"uid": self.get_uid(),
"fid": self.get_fid(),
"courseId": course["courseId"],
"classId": course["clazzId"],
"clientip": "",
"objectId": obj_id,
"name": name,
"useragent": "",
"latitude": lat,
"longitude": lon,
"appType": "15",
}
resp = s.get("https://mobilelearn.chaoxing.com/pptSign/stuSignajax", params=params)
resp_txt = resp.text
if resp.status_code != 200:
logger.error("Failed to get sign in, return code: " + str(resp.status_code) + "message: " + resp_txt)
if type_ != SignType.LOCATION:
return resp_txt
pattern = r"[^0-9\.]*(.+)米[^0-9\.]*"
msg = re.match(pattern, resp_txt)
logger.warning(f"距离签到位置 {msg}m")
# TOD0: Implement triangulation for location signs
return resp_txt
def get_course_point(self, _courseid, _clazzid, _cpi):
_session = SessionManager.get_session()
_url = f"https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse?courseid={_courseid}&clazzid={_clazzid}&cpi={_cpi}&ut=s"
logger.trace("URL: " + _url)
logger.trace("开始读取课程所有章节...")
_resp = _session.get(_url)
logger.trace(f"原始章节列表内容:\n{_resp.text}")
logger.info("课程章节读取成功...")
return decode_course_point(_resp.text)
def get_job_list(self, course: dict, point: dict) -> tuple[list[dict], dict]:
_session = SessionManager.get_session()
self.rate_limiter.limit_rate()
job_list = []
job_info = {}
cards_params = {
"clazzid": course["clazzId"],
"courseid": course["courseId"],
"knowledgeid": point["id"],
"ut": "s",
"cpi": course["cpi"],
"v": "2025-0424-1038-3",
"mooc2": 1
}
# 学习界面任务卡片数, 很少有3个的, 但是对于章节解锁任务点少一个都不行, 可以从API /mooc-ans/mycourse/studentstudyAjax获取值, 或者干脆直接加, 但二者都会造成额外的请求
for _possible_num in "0123456":
logger.trace("开始读取章节所有任务点...")
cards_params.update({"num": _possible_num})
_resp = _session.get("https://mooc1.chaoxing.com/mooc-ans/knowledge/cards", params=cards_params)
if _resp.status_code != 200:
logger.error(f"未知错误: {_resp.status_code} 正在跳过")
logger.error(_resp.text)
return [], {}
_job_list, _job_info = decode_course_card(_resp.text)
if _job_info.get("notOpen", False):
# 直接返回, 节省一次请求
logger.info("该章节未开放")
return [], _job_info
job_list += _job_list
job_info.update(_job_info)
if not job_list:
self.study_emptypage(course, point)
logger.trace(f"原始任务点列表内容:\n{_resp.text}")
logger.info("章节任务点读取成功...")
return job_list, job_info
def get_enc(self, clazzId, jobid, objectId, playingTime, duration, userid):
return md5(
f"[{clazzId}][{userid}][{jobid}][{objectId}][{playingTime * 1000}][d_yHJ!$pdA~5][{duration * 1000}][0_{duration}]"
.encode()).hexdigest()
def video_progress_log(
self,
_session,
_course,
_job,
_job_info,
_dtoken,
_duration,
_playingTime,
_type: str = "Video",
_isdrag: int = 3,
headers: Optional[dict] = None,
) -> tuple[bool, int]:
if headers is None:
logger.warning("null headers")
headers = gc.VIDEO_HEADERS
self.video_log_limiter.limit_rate(random_time=True, random_max=2)
if "courseId" in _job["otherinfo"]:
logger.error(_job["otherinfo"])
raise RuntimeError("this is not possible")
enc = self.get_enc(_course["clazzId"], _job["jobid"], _job["objectid"], _playingTime, _duration, self.get_uid())
params = {
"clazzId": _course["clazzId"],
"playingTime": _playingTime,
"duration": _duration,
"clipTime": f"0_{_duration}",
"objectId": _job["objectid"],
"otherInfo": _job["otherinfo"],
"courseId": _course["courseId"],
"jobid": _job["jobid"],
"userid": self.get_uid(),
"isdrag": _isdrag,
"view": "pc",
"enc": enc,
"dtype": _type
}
_url = (
f"https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/"
f"{_course['cpi']}/"
f"{_dtoken}"
)
face_capture_enc = _job["videoFaceCaptureEnc"]
att_duration = _job["attDuration"]
att_duration_enc = _job["attDurationEnc"]
if face_capture_enc:
params["videoFaceCaptureEnc"] = face_capture_enc
if att_duration:
params["attDuration"] = att_duration
if att_duration_enc:
params["attDurationEnc"] = att_duration_enc
rt = _job['rt']
if not rt:
rt_search = re.search(r"-rt_([1d])", _job['otherinfo'])
if rt_search:
rt_char = rt_search.group(1)
rt = "0.9" if rt_char == "d" else "1"
logger.trace(f"Got rt from otherinfo: {rt}")
if rt:
logger.trace(f"Got rt: {rt}")
_job['rt'] = rt
params.update({"rt": rt,
"_t": get_timestamp()})
resp = _session.get(_url, params=params, headers=headers)
else:
logger.warning("Failed to get rt")
for rt in [0.9, 1]:
params.update({"rt": rt,
"_t": get_timestamp()})
resp = _session.get(_url, params=params, headers=headers)
if resp.status_code == 200:
logger.trace(resp.text)
return resp.json()["isPassed"], 200
# elif resp.ok:
# # TODO: 处理验证码
# pass
elif resp.status_code == 403:
logger.warning("出现403报错, 正常尝试切换rt")
else:
logger.warning("未知错误 jobid={}, status_code={}, 摘要:\n{}",
_job.get("jobid"),
resp.status_code,
resp.text[:200])
break
if resp.status_code == 200:
logger.trace(resp.text)
return resp.json()["isPassed"], 200
elif resp.status_code == 403:
logger.debug(
"视频进度上报返回403, jobid={}, 摘要={}",
_job.get("jobid"),
resp.text[:200],
)
# 若出现两个rt参数都返回403的情况, 则跳过当前任务
logger.error("出现403报错, 尝试修复无效, 正在跳过当前任务点...")
logger.error("请求url: {}", resp.url)
logger.error("请求头: {}", dict(_session.headers) | headers)
return False, 403
logger.error(f"未知错误: {resp.status_code}")
logger.error("请求url:", resp.url)
logger.error("请求头:", dict(_session.headers) | headers)
return False, resp.status_code
def _refresh_video_status(self, session: requests.Session, job: dict, _type: Literal["Video", "Audio"]) \
-> Optional[dict]:
self.rate_limiter.limit_rate(random_time=True, random_max=0.2)
headers = gc.VIDEO_HEADERS if _type == "Video" else gc.AUDIO_HEADERS
info_url = (
f"https://mooc1.chaoxing.com/ananas/status/{job['objectid']}?"
f"k={self.get_fid()}&flag=normal"
)
try:
resp = session.get(info_url, timeout=8, headers=headers)
except RequestException as exc:
logger.debug("刷新视频状态失败: {}", exc)
return None
if resp.status_code != 200:
logger.debug("刷新视频状态返回码异常: {}" % resp.status_code)
logger.debug(resp.text)
return None
try:
data = resp.json()
except ValueError as exc:
logger.debug("解析视频状态响应失败: {}", exc)
return None
if data.get("status") == "success":
return data
return None
def _recover_after_forbidden(self, session: requests.Session, job: dict, _type: Literal["Video", "Audio"]):
SessionManager.update_cookies()
refreshed = self._refresh_video_status(session, job, _type)
if refreshed:
return refreshed
# FIXME: Temporarily disabled for multithreading support
if False and self.account and self.account.username and self.account.password:
login_result = self.login(login_with_cookies=False)
if login_result.get("status"):
SessionManager.update_cookies()
return self._refresh_video_status(session, job, _type)
logger.warning("账号密码登录失败: {}", login_result.get("msg"))
return None
def study_video(self, _course, _job, _job_info, _speed: float = 1.0,
_type: Literal["Video", "Audio"] = "Video") -> StudyResult:
_session = SessionManager.get_session()
headers = gc.VIDEO_HEADERS if _type == "Video" else gc.AUDIO_HEADERS
_info_url = f"https://mooc1.chaoxing.com/ananas/status/{_job['objectid']}?k={self.get_fid()}&flag=normal"
_video_info = _session.get(_info_url, headers=headers).json()
if _video_info["status"] != "success":
logger.error(f"Unknown status: {_video_info['status']}")
return StudyResult.ERROR
_dtoken = _video_info["dtoken"]
_crc = _video_info["crc"]
_key = _video_info["key"]
# Time in the real world: last_iter, gc.THRESHOLD
# Time in the video (can be scaled with the speed factor): duration, play_time, last_log_time, wait_time
duration = int(_video_info["duration"])
play_time = int(_job["playTime"]) // 1000
last_log_time = 0
last_iter = time.time()
wait_time = int(random.uniform(30, 90))
logger.info(f"开始任务: {_job['name']}, 总时长: {duration}s, 已进行: {play_time}s")
pbar = tqdm(total=duration, initial=play_time, desc=_job["name"],
unit_scale=True, bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}')
forbidden_retry = 0
max_forbidden_retry = 2
passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration, duration,
_type, headers=headers, _isdrag=4)
if passed:
logger.info("任务瞬间完成: {}", _job['name'])
return StudyResult.SUCCESS
while not passed:
# Sometimes the last request needs to be sent several times to complete the task
if play_time - last_log_time >= wait_time or play_time == duration:
passed, state = self.video_progress_log(_session, _course, _job, _job_info, _dtoken, duration,
int(play_time), _type, headers=headers)
if state == 403:
if forbidden_retry >= max_forbidden_retry:
logger.warning("403重试失败, 跳过当前任务")
return StudyResult.FORBIDDEN
forbidden_retry += 1
logger.warning(
"出现403报错, 正在尝试刷新会话状态 (第{}次)",
forbidden_retry,
)
time.sleep(random.uniform(2, 4))
refreshed_meta = self._recover_after_forbidden(_session, _job, _type)
if refreshed_meta:
# FIXME: if those keys aren't present, it should be considered an error rather than falling back
_dtoken = refreshed_meta.get("dtoken", _dtoken)
_duration = refreshed_meta.get("duration", duration)
play_time = refreshed_meta.get("playTime", play_time)
logger.debug("Refreshed token: {}, duration: {}, play time: {}", _dtoken, _duration, play_time)
continue
elif not passed and state != 200:
return StudyResult.ERROR
wait_time = int(random.uniform(30, 90))
last_log_time = play_time
logger.trace("Progress logged")
# Uploading the progress takes time, we assume that the video is still playing in the background, this manually calculates the time elapsed
dt = (time.time() - last_iter) * _speed
last_iter = time.time()
play_time = min(duration, play_time + dt)
pbar.n = int(play_time)
pbar.refresh()
time.sleep(gc.THRESHOLD)
logger.info("任务完成: {}", _job['name'])
return StudyResult.SUCCESS
def study_document(self, _course, _job) -> StudyResult:
"""
Study a document in Chaoxing platform.
This method makes a GET request to fetch document information for a given course and job.
Args:
_course (dict): Dictionary containing course information with keys:
- courseId: ID of the course
- clazzId: ID of the class
_job (dict): Dictionary containing job information with keys:
- jobid: ID of the job
- otherinfo: String containing node information
- jtoken: Authentication token for the job
Returns:
requests.Response: Response object from the GET request
Note:
This method requires the following helper functions:
- init_session(): To initialize a new session
- get_timestamp(): To get current timestamp
- re module for regular expression matching
"""
_session = SessionManager.get_session()
_url = f"https://mooc1.chaoxing.com/ananas/job/document?jobid={_job['jobid']}&knowledgeid={re.findall(r'nodeId_(.*?)-', _job['otherinfo'])[0]}&courseid={_course['courseId']}&clazzid={_course['clazzId']}&jtoken={_job['jtoken']}&_dc={get_timestamp()}"
_resp = _session.get(_url)
if _resp.status_code != 200:
return StudyResult.ERROR
else:
return StudyResult.SUCCESS
def study_work(self, _course, _job, _job_info) -> StudyResult:
# FIXME: 这一块可以单独搞一个类出来了,方法里面又套方法,每一次调用都会创建新的方法,十分浪费
if self.tiku.DISABLE or not self.tiku:
return StudyResult.SUCCESS
_ORIGIN_HTML_CONTENT = "" # 用于配合输出网页源码, 帮助修复#391错误
def random_answer(options: str) -> str:
answer = ""
if not options:
return answer
if q["type"] == "multiple":
logger.debug(f"当前选项列表[cut前] -> {options}")
_op_list = multi_cut(options)
logger.debug(f"当前选项列表[cut后] -> {_op_list}")
if not _op_list:
logger.error(
"选项为空, 未能正确提取题目选项信息! 请反馈并提供以上信息"
)
return answer
available_options = len(_op_list)
select_count = 0
# 根据可用选项数量调整可能选择的选项数
if available_options <= 1:
select_count = available_options
else:
max_possible = min(4, available_options)
min_possible = min(2, available_options)
weights_map = {
2: [1.0],
3: [0.3, 0.7],
4: [0.1, 0.5, 0.4],
5: [0.1, 0.4, 0.3, 0.2],
}
weights = weights_map.get(max_possible, [0.3, 0.4, 0.3])
possible_counts = list(range(min_possible, max_possible + 1))
weights = weights[:len(possible_counts)]
weights_sum = sum(weights)
if weights_sum > 0:
weights = [w / weights_sum for w in weights]
select_count = random.choices(possible_counts, weights=weights, k=1)[0]
selected_options = random.sample(_op_list, select_count) if select_count > 0 else []
for option in selected_options:
answer += option[:1] # 取首字为答案,例如A或B
answer = "".join(sorted(answer))
elif q["type"] == "single":
answer = random.choice(options.split("\n"))[:1] # 取首字为答案, 例如A或B
# 判断题处理
elif q["type"] == "judgement":
# answer = self.tiku.jugement_select(_answer)
answer = "true" if random.choice([True, False]) else "false"
logger.info(f"随机选择 -> {answer}")
return answer
def multi_cut(answer: str):
"""
将多选题答案字符串按特定字符进行切割, 并返回切割后的答案列表
参数:
answer(str): 多选题答案字符串.
返回:
list[str]: 切割后的答案列表,如果无法切割, 则返回默认的选项列表None
注意:
如果无法从网页中提取题目信息,将记录警告日志并返回None
"""
# cut_char = [',',',','|','\n','\r','\t','#','*','-','_','+','@','~','/','\\','.','&',' '] # 多选答案切割符
# ',' 在常规被正确划分的, 选项中出现, 导致 multi_cut 无法正确划分选项 #391
# IndexError: Cannot choose from an empty sequence #391
# 同时为了避免没有考虑到的 case, 应该先按照 '\n' 匹配, 匹配不到再按照其他字符匹配
cut_char = [
"\n",
",",
",",
"|",
"\r",
"\t",
"#",
"*",
"-",
"_",
"+",
"@",
"~",
"/",
"\\",
".",
"&",
" ",
"、",
] # 多选答案切割符
res = cut(answer)
if res is None:
logger.warning(
f"未能从网页中提取题目信息, 以下为相关信息:\n\t{answer}\n\n{_ORIGIN_HTML_CONTENT}\n"
) # 尝试输出网页内容和选项信息
logger.warning("未能正确提取题目选项信息! 请反馈并提供以上信息")
return None
else:
return res
def clean_res(res):
cleaned_res = []
if isinstance(res, str):
res = [res]
for c in res:
# 仅在字符串长度大于1时才尝试去除开头的字母编号,防止误删单个字母答案
cleaned = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*|[.,!?;:,。!?;:]', '', c) if len(c) > 1 else c
cleaned_res.append(cleaned.strip())
return cleaned_res
def normalize_text(text: str) -> str:
if not isinstance(text, str):
text = str(text)
# 统一常见异体字符,降低“风/⻛”类差异导致的匹配失败。
char_map = str.maketrans({
'⻛': '风',
'⻔': '门',
'⻋': '车',
'⻢': '马',
})
normalized = text.translate(char_map)
normalized = re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', normalized)
normalized = re.sub(r'\s+', '', normalized)
normalized = re.sub(r'[,。!?;:,.!?;:()()\[\]【】"“”‘’\-_/\\|]', '', normalized)
return normalized.lower()
def get_option_text(option: str) -> str:
return re.sub(r'^[A-Za-z]\s*[.、::)?)]?\s*', '', option).strip()
def best_option_by_similarity(target: str, options: list, threshold: float = 0.8) -> str:
if not target or not options:
return ""
target_norm = normalize_text(target)
if not target_norm:
return ""
best_letter = ""
best_score = 0.0
for option in options:
option_text = get_option_text(option)
option_norm = normalize_text(option_text)
if not option_norm:
continue
score = SequenceMatcher(None, target_norm, option_norm).ratio()
if score > best_score:
best_score = score
best_letter = option[:1]
if best_score >= threshold:
logger.info(f"相似度兜底匹配成功: {best_letter} (score={best_score:.2f}, threshold={threshold:.2f})")
return best_letter
return ""
def is_subsequence(a, o):
iter_o = iter(o)
return all(c in iter_o for c in a)
# FIXME: Use tenacity for retrying
def with_retry(max_retries=3, delay=1):
def decorator(func):
def wrapper(*args, **kwargs):
retries = 0
while retries < max_retries:
try:
_resp = func(*args, **kwargs)
# 未创建完成该测验则不进行答题,目前遇到的情况是未创建完成等同于没题目
if '教师未创建完成该测验' in _resp.text:
raise PermissionError("教师未创建完成该测验")
questions = decode_questions_info(_resp.text)
if _resp.status_code == 200 and questions.get("questions"):
return (_resp, questions)
logger.warning(
f"无效响应 (Code: {getattr(_resp, 'status_code', 'Unknown')}), 重试中... ({retries + 1}/{max_retries})")
except requests.exceptions.RequestException as e:
logger.warning(f"请求失败: {str(e)[:50]}, 重试中... ({retries + 1}/{max_retries})")
retries += 1
time.sleep(delay * (2 ** retries))
raise MaxRetryExceeded(f"超过最大重试次数 ({max_retries})")
return wrapper
return decorator
# 学习通这里根据参数差异能重定向至两个不同接口, 需要定向至https://mooc1.chaoxing.com/mooc-ans/workHandle/handle
_session = SessionManager.get_session()
_url = "https://mooc1.chaoxing.com/mooc-ans/api/work"
@with_retry(max_retries=3, delay=1)
def fetch_response():
return _session.get(
_url,
params={
"api": "1",
"workId": _job["jobid"].replace("work-", ""),
"jobid": _job["jobid"],
"originJobId": _job["jobid"],
"needRedirect": "true",
"skipHeader": "true",
"knowledgeid": str(_job_info["knowledgeid"]),
"ktoken": _job_info["ktoken"],
"cpi": _job_info["cpi"],
"ut": "s",
"clazzId": _course["clazzId"],
"type": "",
"enc": _job["enc"],
"mooc2": "1",
"courseid": _course["courseId"],
}
)
final_resp = {}
questions = {}
try:
final_resp, questions = fetch_response()
except Exception as e:
logger.error(f"请求失败: {e}")
return StudyResult.ERROR
_ORIGIN_HTML_CONTENT = final_resp.text # 用于配合输出网页源码, 帮助修复#391错误
# 搜题
total_questions = len(questions["questions"])
found_answers = 0
for q in questions["questions"]:
logger.debug(f"当前题目信息 -> {q}")
# 添加搜题延迟 #428 - 默认0s延迟
query_delay = self.kwargs.get("query_delay", 0)
time.sleep(query_delay)
res = self.tiku.query(q)
answer = ""
if not res:
# 随机答题
answer = random_answer(q["options"])
q[f'answerSource{q["id"]}'] = "random"
else:
# 根据响应结果选择答案
if q["type"] == "multiple":
# 多选处理
options_list = multi_cut(q["options"])
res_list = multi_cut(res)
if res_list is not None and options_list is not None:
for _a in clean_res(res_list):
matched = False
for o in options_list:
if (
is_subsequence(_a, o) # 去掉各种符号和前面ABCD的答案应当是选项的子序列
):
answer += o[:1]
matched = True
break # 找到匹配项后立即停止,防止重复添加
if not matched:
best_letter = best_option_by_similarity(_a, options_list, threshold=0.8)
if best_letter:
answer += best_letter
# 对答案进行排序, 否则会提交失败
answer = "".join(sorted(set(answer)))
# else 如果分割失败那么就直接到下面去随机选
elif q["type"] == "single":
# 单选也进行切割,主要是防止返回的答案有异常字符
options_list = multi_cut(q["options"])
if options_list is not None:
t_res = clean_res(res)
for o in options_list:
if is_subsequence(t_res[0], o):
answer = o[:1]
break
if not answer and t_res:
answer = best_option_by_similarity(t_res[0], options_list, threshold=0.8)
elif q["type"] == "judgement":
answer = "true" if self.tiku.judgement_select(res) else "false"
elif q["type"] == "completion":
if isinstance(res, list):
answer = "".join(res)
elif isinstance(res, str):
answer = res
else:
# 其他类型直接使用答案 (目前仅知有简答题,待补充处理)
answer = res
if not answer: # 检查 answer 是否为空
logger.warning(f"找到答案但答案未能匹配 -> {res}\t随机选择答案")
answer = random_answer(q["options"]) # 如果为空,则随机选择答案
q[f'answerSource{q["id"]}'] = "random"
else:
logger.info(f"成功获取到答案:{answer}")
q[f'answerSource{q["id"]}'] = "cover"
found_answers += 1
# 填充答案
q["answerField"][f'answer{q["id"]}'] = answer
logger.info(f'{q["title"]} 填写答案为 {answer}')
cover_rate = (found_answers / total_questions) * 100
logger.info(f"章节检测题库覆盖率: {cover_rate:.0f}%")
# 提交模式 现在与题库绑定,留空直接提交, 1保存但不提交
if self.tiku.get_submit_params() == "1":
questions["pyFlag"] = "1"
elif cover_rate >= self.tiku.COVER_RATE * 100 or self.rollback_times >= 1:
questions["pyFlag"] = ""
else:
questions["pyFlag"] = "1"
logger.info(f"章节检测题库覆盖率低于{self.tiku.COVER_RATE * 100:.0f}%,不予提交")
# 组建提交表单
if questions["pyFlag"] == "1":
for q in questions["questions"]:
questions.update(
{
f'answer{q["id"]}':
q["answerField"][f'answer{q["id"]}'] if q[f'answerSource{q["id"]}'] == "cover" else '',
f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'],
}
)
else:
for q in questions["questions"]:
questions.update(
{
f'answer{q["id"]}': q["answerField"][f'answer{q["id"]}'],
f'answertype{q["id"]}': q["answerField"][f'answertype{q["id"]}'],
}