commit b85fe66bcb17ac68c2a0c01d11eb83ad9b5f89ad
Author: Mufanc <47652878+Mufanc@users.noreply.github.com>
Date: Sun Sep 26 01:14:37 2021 +0800
Initial Commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5fcb02f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/.cache/*
+/venv/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..73f69e0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/iSmartAuto2.iml b/.idea/iSmartAuto2.iml
new file mode 100644
index 0000000..f61c21b
--- /dev/null
+++ b/.idea/iSmartAuto2.iml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..a52339a
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..c76173c
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..9c51603
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/automaton/__init__.py b/automaton/__init__.py
new file mode 100644
index 0000000..4211d10
--- /dev/null
+++ b/automaton/__init__.py
@@ -0,0 +1,2 @@
+from .ismart import finish
+from .ismart import export
diff --git a/automaton/captcha/__init__.py b/automaton/captcha/__init__.py
new file mode 100644
index 0000000..cc96be8
--- /dev/null
+++ b/automaton/captcha/__init__.py
@@ -0,0 +1 @@
+from .captcha import recognize
diff --git a/automaton/captcha/captcha.py b/automaton/captcha/captcha.py
new file mode 100644
index 0000000..65c65a1
--- /dev/null
+++ b/automaton/captcha/captcha.py
@@ -0,0 +1,38 @@
+from os import path
+
+import cv2
+import numpy as np
+from loguru import logger
+from numpy import average, dot, linalg
+
+base_path = path.join(path.split(__file__)[0], 'models')
+
+
+def similarity(img_1, img_2):
+ images = [img_1, img_2]
+ vectors = []
+ norms = []
+ for image in images:
+ vector = [average(pixels) for pixels in image]
+ vectors.append(vector)
+ norms.append(linalg.norm(vector, 2))
+ a, b = vectors
+ a_norm, b_norm = norms
+ return dot(a / a_norm, b / b_norm)
+
+
+def recognize(img_content: bytes):
+ img = cv2.imdecode(np.asarray(bytearray(img_content), dtype=np.uint8), cv2.IMREAD_COLOR)
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+ img = cv2.threshold(img, 200, 255, cv2.THRESH_BINARY)[1]
+ models = [cv2.imread(path.join(base_path, f'{i}.png')) for i in range(10)]
+ code = ''
+ for i in range(4):
+ code += sorted(
+ [(f'{j}', similarity(img[4:24, 9 + i * 15:24 + i * 15], std)) for j, std in enumerate(models)],
+ key=lambda x: x[1]
+ )[-1][0]
+ logger.info(f'识别结果:{code}')
+ if len(code) != 4:
+ logger.warning('验证码长度不是 4 位')
+ return code
diff --git a/automaton/captcha/models/0.png b/automaton/captcha/models/0.png
new file mode 100644
index 0000000..bd3709b
Binary files /dev/null and b/automaton/captcha/models/0.png differ
diff --git a/automaton/captcha/models/1.png b/automaton/captcha/models/1.png
new file mode 100644
index 0000000..f6cfa53
Binary files /dev/null and b/automaton/captcha/models/1.png differ
diff --git a/automaton/captcha/models/2.png b/automaton/captcha/models/2.png
new file mode 100644
index 0000000..8a995f8
Binary files /dev/null and b/automaton/captcha/models/2.png differ
diff --git a/automaton/captcha/models/3.png b/automaton/captcha/models/3.png
new file mode 100644
index 0000000..e89be4d
Binary files /dev/null and b/automaton/captcha/models/3.png differ
diff --git a/automaton/captcha/models/4.png b/automaton/captcha/models/4.png
new file mode 100644
index 0000000..f5da1e9
Binary files /dev/null and b/automaton/captcha/models/4.png differ
diff --git a/automaton/captcha/models/5.png b/automaton/captcha/models/5.png
new file mode 100644
index 0000000..4fa56ba
Binary files /dev/null and b/automaton/captcha/models/5.png differ
diff --git a/automaton/captcha/models/6.png b/automaton/captcha/models/6.png
new file mode 100644
index 0000000..2ee50f8
Binary files /dev/null and b/automaton/captcha/models/6.png differ
diff --git a/automaton/captcha/models/7.png b/automaton/captcha/models/7.png
new file mode 100644
index 0000000..10234ab
Binary files /dev/null and b/automaton/captcha/models/7.png differ
diff --git a/automaton/captcha/models/8.png b/automaton/captcha/models/8.png
new file mode 100644
index 0000000..30e73e5
Binary files /dev/null and b/automaton/captcha/models/8.png differ
diff --git a/automaton/captcha/models/9.png b/automaton/captcha/models/9.png
new file mode 100644
index 0000000..0c54866
Binary files /dev/null and b/automaton/captcha/models/9.png differ
diff --git a/automaton/devtools.py b/automaton/devtools.py
new file mode 100644
index 0000000..22c9f7a
--- /dev/null
+++ b/automaton/devtools.py
@@ -0,0 +1,77 @@
+import asyncio
+import ctypes
+import json
+import re
+
+import httpx
+import websockets
+from loguru import logger
+
+from configs import configs
+
+_default_port = configs['browser']['port']
+_executable = configs['browser']['executable']
+_args = configs['browser']['args']
+
+
+class Browser(object):
+ @classmethod
+ def connect(cls):
+ return cls(_default_port)
+
+ @classmethod
+ def launch(cls):
+ ctypes.windll.shell32.ShellExecuteW(
+ None, 'runas', _executable,
+ ' '.join([f'--remote-debugging-port={_default_port}', *_args]),
+ None, 1
+ )
+ return cls(_default_port)
+
+ def __init__(self, dev_port):
+ self.port = dev_port
+
+ async def wait_for_book(self): # 等待「教材学习」页面
+ async with httpx.AsyncClient() as client:
+ while True:
+ logger.info('等待「教材学习」页面...')
+ try:
+ pages = (await client.get(f'http://127.0.0.1:{self.port}/json')).json()
+ for page in pages:
+ if re.match(r'.*me.ismartlearning.cn/center/student/course/bookLearn\.html.*', page['url']):
+ return Page(page['url'], page['webSocketDebuggerUrl'])
+ await asyncio.sleep(2) # 这样写跟套 finally 有区别
+ except httpx.ConnectError:
+ await asyncio.sleep(2)
+
+
+class Page(object):
+ def __init__(self, url, dev_url):
+ self.id = 0
+ self.url, self.dev_url = url, dev_url
+
+ async def send(self, command, params):
+ async with websockets.connect(self.dev_url) as devtools:
+ await devtools.send(json.dumps({
+ 'id': self.id,
+ 'method': command,
+ 'params': params
+ }))
+ self.id += 1
+ return json.loads(await devtools.recv())
+
+ async def eval(self, script):
+ result = await self.send(
+ 'Runtime.evaluate', {
+ 'expression': script,
+ 'awaitPromise': True
+ }
+ )
+ return result['result']
+
+ async def submit(self, book_id, chapter_id, task_id, score, seconds, percent, user_id):
+ model = 'NetBrowser.submitTask("%s", "%s", "%s", 0, "%d", %d, %d, "%s");'
+ result = f'%7B%22studentid%22:{user_id},%22testInfo%22:%7B%22answerdata%22:%22%22,%22markdatao%22:%22%22%7D%7D'
+ return await self.eval(
+ model % (book_id, chapter_id, task_id, score, seconds, percent, result)
+ )
diff --git a/automaton/ismart.py b/automaton/ismart.py
new file mode 100644
index 0000000..2b3d9f9
--- /dev/null
+++ b/automaton/ismart.py
@@ -0,0 +1,122 @@
+import json
+import os
+import pickle
+import urllib.parse as parser
+
+from bs4 import BeautifulSoup
+from loguru import logger
+from random import random, randint
+
+from configs import configs
+from .devtools import Browser
+from .markdown import generate_md
+from .spider import Spider
+
+random_args = { # 不同题型对应的随机时长和分数范围
+ '1': { # 单选题
+ 'time': (20, 60), # 完成时长 / 秒
+ 'score': 1 # 得分 (归一化, 向上至满分)
+ },
+ '2': { # 多选题
+ 'time': (40, 120),
+ 'score': 0.9
+ },
+ '3': { # 判断题
+ 'time': (20, 50),
+ 'score': 1
+ },
+ '4': { # 填空题
+ 'time': (60, 180),
+ 'score': 1
+ },
+ '6': { # 连线题
+ 'time': (60, 180),
+ 'score': 0.8
+ },
+ '8': { # 匹配题
+ 'time': (30, 90),
+ 'score': 1
+ },
+ '9': { # 口语跟读
+ 'time': (15, 30),
+ 'score': 0.8
+ },
+ '10': { # 短文改错
+ 'time': (120, 180),
+ 'score': 0.7
+ },
+ '11': { # 选词填空
+ 'time': (30, 90),
+ 'score': 0.9
+ },
+}
+
+
+def _random_progress(paper):
+ paper = BeautifulSoup(paper, 'lxml-xml')
+ questions = paper.select('element[knowledge]:has(> question_type)')
+ if questions:
+ total_score = 0
+ my_score, my_time = 0, 0
+ for que in questions:
+ qt_type = que.select_one('question_type').text
+ qt_score = int(que.select_one('question_score').text)
+ total_score += qt_score
+
+ rate = 1 - (1 - random_args[qt_type]['score']) * random()
+ my_score += qt_score * rate
+ my_time += randint(*random_args[qt_type]['time'])
+ return int(100 * my_score / total_score), my_time
+ return 100, 5
+
+
+async def export(): # 导出某书籍的答案
+ browser = Browser.connect()
+ page = await browser.wait_for_book()
+ params = dict(parser.parse_qsl(parser.urlsplit(page.url).query))
+ # noinspection PyTypeChecker
+ book_id, course_id = params['bookId'], params['courseId']
+ if not os.path.exists(f'.cache/books/{book_id}'):
+ async with Spider() as spider:
+ await spider.login(**configs['user'])
+ book = await spider.book_info(book_id)
+ book['courseId'] = course_id
+ tasks = await spider.get_tasks(book, tree=True)
+ await spider.download_tree(tasks)
+ with open(f'.cache/books/{book_id}/Tree.pck', 'rb') as fp:
+ generate_md(pickle.load(fp))
+
+
+async def finish(): # 直接完成某书籍的任务
+ browser = Browser.connect()
+ page = await browser.wait_for_book()
+ params = dict(parser.parse_qsl(parser.urlsplit(page.url).query))
+ # noinspection PyTypeChecker
+ book_id, course_id = params['bookId'], params['courseId']
+ async with Spider() as spider:
+ await spider.login(**configs['user'])
+ if not os.path.exists(f'.cache/books/{book_id}'):
+ book = await spider.book_info(book_id)
+ book['courseId'] = course_id
+ tasks = await spider.get_tasks(book, tree=True)
+ await spider.download_tree(tasks)
+ user_id = (await spider.get_user())['data']['uid']
+ logger.info('正在提交任务...')
+ for file in os.listdir(f'.cache/books/{book_id}'):
+ paper_id, ext = os.path.splitext(file)
+ if ext != '.json':
+ continue
+
+ with open(f'.cache/books/{book_id}/{file}') as fp:
+ data = json.load(fp)
+ task = data['task']
+ paper = data['paperData']
+ score, time = _random_progress(paper)
+ result = await page.submit(book_id, task['chapterId'], task['id'], score, time, 100, user_id)
+ if result['wasThrown'] or not result['result']['value']:
+ logger.warning(f'任务 {task["name"]} [paperId: {paper_id}] 可能提交失败,请留意最终结果!')
+ logger.info('全部提交完成!')
+
+
+async def finish_all(): # Todo: 全刷了?
+ pass
diff --git a/automaton/markdown/__init__.py b/automaton/markdown/__init__.py
new file mode 100644
index 0000000..5d77c46
--- /dev/null
+++ b/automaton/markdown/__init__.py
@@ -0,0 +1 @@
+from .md import generate_md
diff --git a/automaton/markdown/formatter.py b/automaton/markdown/formatter.py
new file mode 100644
index 0000000..e052814
--- /dev/null
+++ b/automaton/markdown/formatter.py
@@ -0,0 +1,34 @@
+import re
+
+
+class Formatter:
+ @staticmethod
+ def fix_img(text): # 处理 标签
+ return re.sub('', '「暂不支持图片显示澳」', text)
+
+ @staticmethod
+ def rm_lgt(text): # 处理括号对
+ return re.sub('<.+?>', '', text)
+
+ @staticmethod
+ def fix_uline(text): # 处理下划线
+ return re.sub('_{3,}', lambda mch: '\\_' * len(mch.group()), text)
+
+ @staticmethod
+ def rm_head(text): # 处理数字标号
+ return re.sub(r'^(?:\d+(?:\.| +\b))+\d+ ', '', text)
+
+ @staticmethod
+ def fix_lf(text): # 处理换行
+ text = re.sub('
', '\n\n', text)
+ return re.sub('(.+?)
', lambda mch: mch.group(1) + '\n\n', text)
+
+ @staticmethod
+ def fix_space(text):
+ return re.sub('(?: )+', ' ', text)
+
+
+def fix(text, func_ptrs):
+ for func in func_ptrs:
+ text = getattr(Formatter, func)(text)
+ return text
diff --git a/automaton/markdown/generator.py b/automaton/markdown/generator.py
new file mode 100644
index 0000000..68815e5
--- /dev/null
+++ b/automaton/markdown/generator.py
@@ -0,0 +1,133 @@
+"""
+不同 question type 对应的解析方法
+传入两个参数 ( question, answer, output ), 将输出行依次 append 到 output 队列中
+"""
+
+import re
+
+from .formatter import fix
+
+
+class Generators:
+ @staticmethod
+ def type_1(que, ans, output): # 单选题
+ # 提取题目内容
+ question = que.select_one("question_text").text
+ question = fix(question, ('rm_lgt', 'fix_uline', 'fix_space'))
+ output.append(f'* **{question}**\n')
+ # 提取答案
+ ans_id = que.attrs['id']
+ corrects = set(ans.select_one(f'[id="{ans_id}"] > answers').text)
+ # 生成对应 Markdown
+ options = que.select('options > *')
+ for opt in options:
+ opt_id = opt.attrs['id']
+ answer_text = fix(opt.text, ('rm_lgt', 'fix_space'))
+ if opt_id in corrects: # 高亮正确答案
+ output.append(f' {opt_id}. {answer_text}
\n')
+ else:
+ output.append(f' {opt_id}. {answer_text}\n')
+
+ @staticmethod
+ def type_2(*args): # 多选题
+ return Generators.type_1(*args)
+
+ @staticmethod
+ def type_3(que, ans, output): # 判断题
+ question = que.select_one("question_text").text
+ question = fix(question, ('rm_lgt', 'fix_uline', 'fix_space'))
+ output.append(f'* **{question}**\n')
+ # 提取答案
+ ans_id = que.attrs['id']
+ correct = ans.select_one(f'[id="{ans_id}"] > answers').text
+ # 生成对应 Markdown
+ output.append(f'* 答案:「**{correct}**」\n')
+
+ @staticmethod
+ def type_4(que, ans, output): # 填空题
+ # 提取题目内容
+ question = que.select_one('question_text').text
+ question = re.sub('
', '\n', question)
+ question = fix(question, ('rm_lgt', 'fix_uline', 'fix_space'))
+ # 提取答案
+ ans_id = que.attrs['id']
+ corrects = ans.select(f'[id="{ans_id}"] answers > answer')
+ # 执行替换
+ for ans in corrects:
+ question = question.replace(
+ '{{' + ans.attrs['id'] + '}}',
+ f' [{ans.text}] '
+ )
+ output.append(question + '\n')
+
+ @staticmethod
+ def type_6(que, ans, output): # 连线题
+ # 提取题目内容
+ question = que.select_one('question_text').text
+ question = fix(question, ('rm_lgt', 'fix_uline', 'fix_space'))
+ output.append(f'* **{question}**\n')
+ # 提取答案
+ options = que.select('options > *')
+ pairs = {}
+ for opt in options:
+ opt_id = opt.attrs['id']
+ if opt_id not in pairs:
+ pairs[opt_id] = [0, 0]
+ flag = int(opt.attrs['flag'])
+ pairs[opt_id][flag - 1] = opt.text
+ output.append('| Part-A | Part-B |')
+ output.append('| :- | :- |')
+ for gp_id in pairs:
+ left = fix(pairs[gp_id][0], ('fix_img', 'rm_lgt', 'fix_uline', 'fix_space')).replace('|', '\\|')
+ right = fix(pairs[gp_id][1], ('fix_img', 'rm_lgt', 'fix_uline', 'fix_space')).replace('|', '\\|')
+ output.append(f'| {left} | {right} |')
+ output.append('')
+
+ @staticmethod
+ def type_8(que, ans, output): # 匹配题
+ # 提取题目内容
+ question = que.select_one('question_text').text
+ question = fix(question, ('rm_lgt', 'fix_uline'))
+ # 提取答案
+ ans_id = que.attrs['id']
+ corrects = ans.select(f'[id="{ans_id}"] answers > answer')
+ # 执行替换
+ question = fix(question, ('fix_lf', 'rm_lgt', 'fix_space'))
+ for ans in corrects:
+ question = question.replace(
+ '{{' + ans.attrs['id'] + '}}',
+ f' {ans.text} '
+ )
+ output.append(question + '\n')
+
+ @staticmethod
+ def type_9(que, ans, output): # 口语跟读
+ output.append('「口语跟读」\n')
+
+ @staticmethod
+ def type_10(que, ans, output): # 短文改错
+ output.append('* **短文改错**')
+ ans_id = que.attrs['id']
+ corrects = ans.select(f'[id="{ans_id}"] answers > answer')
+ for i, ans in enumerate(corrects):
+ desc = re.sub('(?<=[A-Za-z0-9])(?=[\u4e00-\u9fa5])', ' ', ans.attrs['desc'])
+ desc = re.sub('(?<=[\u4e00-\u9fa5])(?=[A-Za-z0-9])', ' ', desc)
+ output.append(f'{i + 1}. {desc}\n')
+ output.append('')
+
+ @staticmethod
+ def type_11(que, ans, output): # 选词填空
+ # 提取题目内容
+ question = que.select_one('question_text').text
+ question = fix(question, ('fix_uline', 'fix_lf', 'rm_lgt', 'fix_space'))
+ options = {opt.attrs['id']: opt.text for opt in que.select('options > option[flag="2"]')}
+ # 提取答案
+ ans_id = que.attrs['id']
+ corrects = ans.select(f'[id="{ans_id}"] answers > answer')
+ # 执行替换
+ for ans in corrects:
+ question = question.replace(
+ '{{' + ans.attrs['id'] + '}}',
+ f' {options[ans.text]} '
+ )
+ output.append(question + '\n')
diff --git a/automaton/markdown/md.py b/automaton/markdown/md.py
new file mode 100644
index 0000000..3d7c9de
--- /dev/null
+++ b/automaton/markdown/md.py
@@ -0,0 +1,59 @@
+import json
+from collections import deque
+
+from bs4 import BeautifulSoup
+from loguru import logger
+
+from .formatter import fix
+from .generator import Generators
+
+_output = deque()
+
+
+# 解码题目与答案 xml
+def decode(que, ans, qt_type):
+ getattr(Generators, f'type_{qt_type}')(que, ans, _output)
+
+
+# 生成每个 paper 的答案
+def unescape(node, book_id):
+ paper_id = node.task['paperId']
+ with open(f'.cache/books/{book_id}/{paper_id}.json', 'r') as fp:
+ task = json.load(fp)
+ paper = BeautifulSoup(task['paperData'], 'lxml-xml')
+ answer = BeautifulSoup(task['answerData'], 'lxml-xml')
+ questions = paper.select('element[knowledge]:has(> question_type)')
+ if questions:
+ for que in questions:
+ qt_type = int(que.select_one('question_type').text)
+ decode(que, answer, qt_type)
+ return True
+ return False
+
+
+# 深搜创建目录树
+def dfs(node, book_id, depth=2):
+ if title := node.task['name']:
+ logger.info(f'{". " * (depth - 1)}{title}')
+ title = fix(title, ('rm_head',))
+ _output.append(f'{"#" * depth} {title}\n')
+ flag = False
+ if 'paperId' in node.task:
+ flag = unescape(node, book_id)
+ for ch in node.children:
+ if dfs(ch, book_id, depth + 1):
+ flag = True
+ if not flag:
+ _output.pop()
+ return flag
+
+
+def generate_md(root): # 生成答案
+ book_id = root.task['book_id']
+ for ch in root.children:
+ dfs(ch, book_id)
+ with open('.cache/answer.md', 'w', encoding='utf-8') as file:
+ while len(_output):
+ line = _output.popleft()
+ file.write(line + '\n')
+ logger.info('Done.')
diff --git a/automaton/spider.py b/automaton/spider.py
new file mode 100644
index 0000000..46a24ed
--- /dev/null
+++ b/automaton/spider.py
@@ -0,0 +1,184 @@
+import asyncio
+import json
+import os
+import pickle
+from hashlib import md5
+from random import random
+
+import httpx
+from loguru import logger
+
+from .captcha import recognize
+
+
+class Tree:
+ def __init__(self, task):
+ self.task = task
+ self.children = []
+
+
+class Spider(httpx.AsyncClient):
+ def __init__(self):
+ super().__init__()
+
+ async def login(self, username, password): # 账号密码登录
+ logger.info('正在获取验证码...')
+ result = await self.get(f'http://sso.ismartlearning.cn/captcha.html?{random()}')
+ code = recognize(result.content)
+ token = md5(password.encode()).hexdigest()
+ info = (await self.post(
+ 'http://sso.ismartlearning.cn/v2/tickets-v2',
+ data={
+ 'username': username,
+ 'password': md5(token.encode() + b'fa&s*l%$k!fq$k!ld@fjlk').hexdigest(),
+ 'captcha': code
+ },
+ headers={
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Origin': 'http://me.ismartlearning.cn',
+ 'Referer': 'http://me.ismartlearning.cn/'
+ }
+ )).json()
+ logger.debug(info['result'])
+
+ if info['result']['code'] != -26:
+ raise AssertionError(f'[!] 登录失败: {info["result"]["msg"]}')
+ return info['result']
+
+ async def get_courses(self): # 获取用户课程列表
+ logger.info('正在获取课程列表...')
+ courses = (await self.post(
+ 'https://school.ismartlearning.cn/client/course/list-of-student?status=1',
+ data={
+ 'pager.currentPage': 1,
+ 'pager.pageSize': 32767
+ }
+ )).json()['data']
+ return courses['list']
+
+ async def get_books(self, course): # 获取某课程的书籍列表
+ logger.info('正在获取书籍列表...')
+ await self.get_courses() # 必须有这个请求,否则后面会报错
+ books = (await self.post(
+ 'http://school.ismartlearning.cn/client/course/textbook/list-of-student',
+ data={
+ 'courseId': course['courseId']
+ }
+ )).json()['data']
+ return books
+
+ @staticmethod
+ def _merge_tasks(tasks): # 将任务列表重组成树形结构
+ id_record = {task['id']: Tree(task) for task in tasks}
+ root = Tree({
+ 'book_id': tasks[0]['book_id'],
+ 'unitStudyPercent': 0
+ })
+
+ for task_id in id_record:
+ node = id_record[task_id]
+ node_name = (f'{node.task["name"]} ' if 'name' in node.task else '') + f'[id:{node.task["id"]}]'
+ if 'parent_id' in node.task:
+ if (parent_id := node.task['parent_id']) in id_record:
+ id_record[parent_id].children.append(node)
+ else:
+ logger.warning(f'任务已忽略(父节点不存在):{node_name}')
+ else:
+ root.children.append(node)
+
+ return root
+
+ async def get_tasks(self, book, tree=False): # 获取某书籍的任务列表
+ logger.info('正在获取任务列表...')
+ await self.post('http://school.ismartlearning.cn/client/course/textbook/chapters')
+ tasks = (await self.post(
+ 'http://school.ismartlearning.cn/client/course/textbook/chapters',
+ data={key: book[key] for key in ('bookId', 'bookType', 'courseId')}
+ )).json()['data']
+ if tree:
+ return self._merge_tasks(tasks)
+ else:
+ return tasks
+
+ async def get_paper(self, paper_id): # 获取任务点信息(包括题目和答案)
+ ticket = (await self.post(
+ 'http://sso.ismartlearning.cn/v1/serviceTicket',
+ data={
+ 'service': 'http://xot-api.ismartlearning.cn/client/textbook/paperinfo'
+ }
+ )).json()['data']['serverTicket']
+ logger.debug(f'Ticket: {ticket}')
+ paper_info = (await self.post(
+ 'http://xot-api.ismartlearning.cn/client/textbook/paperinfo',
+ data={
+ 'paperId': paper_id
+ },
+ headers={
+ 'Origin': 'http://me.ismartlearning.cn',
+ 'Referer': 'http://me.ismartlearning.cn/',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Accept-Encoding': 'gzip, deflate'
+ },
+ params={
+ 'ticket': ticket
+ }
+ )).json()['data']
+ return paper_info
+
+ async def download_tree(self, root):
+ async def download(task):
+ paper_id = task['paperId']
+ filepath = f'.cache/books/{root.task["book_id"]}/{paper_id}.json'
+ if os.path.exists(filepath):
+ return
+ async with limit: # 防止并发过高
+ result = await self.get_paper(paper_id)
+ result['task'] = task # 继续存入 Task
+ with open(filepath, 'w') as file:
+ json.dump(result, file)
+
+ def dfs(src):
+ if 'paperId' in (task := src.task):
+ logger.info(f'添加任务:{task["name"]}')
+ tasks.append(download(task))
+ for child in src.children:
+ dfs(child)
+
+ logger.info('开始下载试题及答案...')
+ os.makedirs(f'.cache/books/{root.task["book_id"]}', exist_ok=True)
+ with open(f'.cache/books/{root.task["book_id"]}/Tree.pck', 'wb') as fp:
+ pickle.dump(root, fp)
+ tasks, limit = [], asyncio.Semaphore(4)
+ dfs(root)
+ await asyncio.gather(*tasks)
+ logger.info('下载完成.')
+
+ async def get_user(self):
+ return (await self.post(
+ 'https://school.ismartlearning.cn/client/user/student-info')
+ ).json()
+
+ async def book_info(self, book_id):
+ ticket = (await self.post(
+ 'http://sso.ismartlearning.cn/v1/serviceTicket',
+ data={
+ 'service': 'http://book-api.ismartlearning.cn/client/v2/book/info'
+ }
+ )).json()['data']['serverTicket']
+ book_info = (await self.post(
+ 'http://book-api.ismartlearning.cn/client/v2/book/info',
+ headers={
+ 'Origin': 'http://me.ismartlearning.cn',
+ 'Referer': 'http://me.ismartlearning.cn/',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Accept-Encoding': 'gzip, deflate'
+ },
+ params={
+ 'ticket': ticket
+ },
+ data={
+ 'bookId': book_id,
+ 'bookType': 0
+ }
+ )).json()
+ return book_info['data']
diff --git a/configs.py b/configs.py
new file mode 100644
index 0000000..cdbd37f
--- /dev/null
+++ b/configs.py
@@ -0,0 +1,9 @@
+import yaml
+
+
+with open('configs.yml', 'r', encoding='utf-8') as _fp:
+ configs = yaml.safe_load(_fp)
+
+if __name__ == '__main__':
+ import json
+ print(json.dumps(configs, indent=4))
diff --git a/configs.yml b/configs.yml
new file mode 100644
index 0000000..dc95ad5
--- /dev/null
+++ b/configs.yml
@@ -0,0 +1,13 @@
+# Todo: 每次 commit 之前务必清除账号密码
+
+# iSmart 客户端相关配置
+browser:
+ executable: Z:\iSmart\client\iSmart.exe # 客户端可执行文件的路径
+ args: # 启动 iSmart 客户端时额外提供的参数
+ - --disable-web-security
+ port: 9222 # devTools 调试端口
+
+# 用户相关配置(务必保持账号密码与 iSmart 中已登录的相同)
+user:
+ username: <用户名> # 手机号
+ password: <密码> # 密码
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..cf7e695
--- /dev/null
+++ b/main.py
@@ -0,0 +1,14 @@
+import asyncio
+from automaton import finish
+
+
+async def main():
+ await finish()
+
+
+if __name__ == '__main__':
+ loop = asyncio.new_event_loop()
+ try:
+ loop.run_until_complete(main())
+ finally:
+ loop.close()