diff --git a/README.md b/README.md
index faec98b..593d482 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-## iSmart 课程自动姬 v1.0.0
+## iSmart 课程自动姬 v1.0.2
>
「不止于自动化,追求极致效率」
>
@@ -39,14 +39,38 @@
### 使用方法
- 修改 `configs.yml` 中的账号和密码,保证与 iSmart 客户端中登录的账号一致,然后修改 iSmart 的启动快捷方式,增加参数 `--remote-debugging-port=9222`:
+ 修改 iSmart 的启动快捷方式,增加参数 `--remote-debugging-port=9222`:
![](images/edit-lnk.png)
- 此时运行 main.py,启动 iSmart 客户端,进入某本书籍的教材学习页(如下图),脚本会自动提交成绩。
+ 修改 `configs.yml` 中的账号和密码,保证与 iSmart 客户端中登录的账号一致,然后根据需要调整下方参数。在终端中执行 `py main.py -h` 可以查看更多帮助信息,这里列举几个常用命令
+
+* 列出所有课程和书籍的详细信息
+
+```shell
+py main.py list -d
+```
+
+* 根据书籍 id 执行刷课
+
+```shell
+py main.py flash -i 51627#7B6911511DB6B33638F6C58531D8FBD3
+```
+
+* 根据当前打开的页面执行刷课
+
+```shell
+py main.py flash -c
+```
+
+ 注意如果打开的是「教材学习」页(如下图),只会刷打开的这一本书籍的任务
![](images/booklearn.png)
-### 写在最后
+ 而如果是在课程详情页面,则会对该课程下的所有书籍执行刷课:
+
+![](images/current_course.png)
+
+### 过滤器语法
- 该项目尚处于起步阶段,项目结构还没有完全确定下来,所以后续可能会经历多次重构。目前很多功能虽然存在于源码中,但还不完善或者未经测试,可能造成意料之外的结果,所以在使用前还请三思
+* 待完善
\ No newline at end of file
diff --git a/automaton/__init__.py b/automaton/__init__.py
index 64948a8..e69de29 100644
--- a/automaton/__init__.py
+++ b/automaton/__init__.py
@@ -1,2 +0,0 @@
-from .spider import Spider
-from .devtools import Browser
diff --git a/automaton/devtools.py b/automaton/devtools.py
index b8ae184..86af9c6 100644
--- a/automaton/devtools.py
+++ b/automaton/devtools.py
@@ -1,40 +1,29 @@
import asyncio
-import ctypes
import json
import re
+import urllib.parse as parser
import httpx
import websockets
from loguru import logger
from configs import configs
-from .utils import ainput
-
-_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)
+ async def connect(cls):
+ browser = cls(configs['browser']['port'])
+ if configs['browser']['verify'] and not await browser._verify():
+ return None
+ return browser
def __init__(self, dev_port):
self.port = dev_port
- async def verify(self): # 校验客户端和配置文件中的用户是否相同
+ async def _verify(self): # 校验客户端和配置文件中的用户是否相同
logger.info('正在校验账号...')
- page = await self._any_http_page()
+ page = await self.wait_for_page(r'https?://.*')
user_info = json.loads((await page.eval('''
(function () {
var xhr = new XMLHttpRequest()
@@ -47,47 +36,35 @@ class Browser(object):
spider_user = configs['user']['username']
if spider_user != user_info['mobile'] and spider_user != user_info['username']:
logger.warning('检测到 iSmart 客户端中登录的账号与配置文件中账号不符!')
- choice = await ainput('继续使用可能会出现意料之外的问题,是否继续?[y/N]')
+ choice = input('继续使用可能会出现意料之外的问题,是否继续?[y/N]')
if choice.lower() != 'y':
return False
else:
logger.info('校验通过!')
return True
- 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']) and \
- 'webSocketDebuggerUrl' in page:
- return Page(page['url'], page['webSocketDebuggerUrl'])
- except httpx.ConnectError:
- pass
- await asyncio.sleep(2)
-
- async def _any_http_page(self):
+ async def wait_for_page(self, regexp): # 等待符合条件的页面出现
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'https?://.*', page['url']) and 'webSocketDebuggerUrl' in page:
+ if re.fullmatch(regexp, page['url']) and 'webSocketDebuggerUrl' in page:
return Page(page['url'], page['webSocketDebuggerUrl'])
except httpx.ConnectError:
pass
await asyncio.sleep(2)
- async def submit(self, book_id, chapter_id, task_id, score, seconds, percent, user_id): # 提交任务点的得分
- page = await self._any_http_page()
- 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 page.eval(
- model % (book_id, chapter_id, task_id, score, seconds, percent, result)
- )
+ # noinspection PyTypeChecker
+ async def get_current(self):
+ async with httpx.AsyncClient() as client:
+ pages = (await client.get(f'http://127.0.0.1:{self.port}/json')).json()
+ for page in pages:
+ params = dict(parser.parse_qsl(parser.urlsplit(page['url']).query))
+ if 'bookId' in params:
+ return params['courseId'], params['bookId']
+ return params['courseId'], None
class Page(object):
@@ -113,3 +90,10 @@ class Page(object):
}
)
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/spider/spider.py b/automaton/spider/spider.py
index 464511f..49ae6d9 100644
--- a/automaton/spider/spider.py
+++ b/automaton/spider/spider.py
@@ -5,14 +5,30 @@ import httpx
from loguru import logger
from .captcha import recognize
-from ..utils import Tree
+
+
+class Tree: # 任务树
+ def __init__(self, task):
+ self.task = task
+ self.child = []
+
+ def sort(self):
+ self.child.sort(
+ key=lambda node: node.task['displayOrder']
+ )
+ for ch in self.child:
+ ch.sort()
class Spider(httpx.AsyncClient):
def __init__(self):
super().__init__()
+ self.is_login = False
async def login(self, username, password):
+ if self.is_login:
+ return {}
+
self.cookies.clear() # 重置 cookies
logger.info('正在获取验证码...')
result = await self.get(f'http://sso.ismartlearning.cn/captcha.html?{random()}')
@@ -34,6 +50,7 @@ class Spider(httpx.AsyncClient):
logger.debug(info['result'])
assert info['result']['code'] == -26 # 断言登录结果
+ self.is_login = True
return info['result']
async def get_courses(self): # 获取课程列表
@@ -49,7 +66,13 @@ class Spider(httpx.AsyncClient):
async def get_books(self, course_id): # 获取某课程的书籍列表
logger.info('正在获取书籍列表...')
- await self.get_courses() # 必须有这个请求,否则后面会报错
+ await self.post( # 必须有这个请求,否则后面会报错
+ 'http://school.ismartlearning.cn/client/course/list-of-student?status=1',
+ data={
+ 'pager.currentPage': 1,
+ 'pager.pageSize': 32767
+ }
+ )
books = (await self.post(
'http://school.ismartlearning.cn/client/course/textbook/list-of-student',
data={
@@ -60,6 +83,7 @@ class Spider(httpx.AsyncClient):
async def get_tasks(self, book_id, book_type, course_id): # 获取某书籍的任务树
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={
@@ -69,9 +93,11 @@ class Spider(httpx.AsyncClient):
}
)).json()['data']
id_record = {task['id']: Tree(task) for task in tasks}
+ book_name = (await self.book_info(book_id))['bookName']
root = Tree({
'book_id': tasks[0]['book_id'],
- 'unitStudyPercent': 0
+ 'unitStudyPercent': 0,
+ 'name': book_name
})
for task_id in id_record:
node = id_record[task_id]
@@ -80,9 +106,10 @@ class Spider(httpx.AsyncClient):
if (parent_id := node.task['parent_id']) in id_record:
id_record[parent_id].child.append(node)
else:
- logger.warning(f'任务已忽略(父节点不存在):{node_name}')
+ logger.warning(f'父节点不存在:{node_name}')
else:
root.child.append(node)
+ root.sort()
return root
async def get_paper(self, paper_id): # 获取任务点信息(包括题目和答案)
@@ -92,6 +119,7 @@ class Spider(httpx.AsyncClient):
'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={
@@ -114,3 +142,30 @@ class Spider(httpx.AsyncClient):
return (await self.post(
'https://school.ismartlearning.cn/client/user/student-info')
).json()
+
+ async def book_info(self, book_id):
+ logger.info('正在获取书籍信息...')
+ ticket = (await self.post(
+ 'http://sso.ismartlearning.cn/v1/serviceTicket',
+ data={
+ 'service': 'http://book-api.ismartlearning.cn/client/v2/book/info'
+ }
+ )).json()['data']['serverTicket']
+ logger.debug(f'Ticket: {ticket}')
+ 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/automaton/utils.py b/automaton/utils.py
index f002a54..dab53c6 100644
--- a/automaton/utils.py
+++ b/automaton/utils.py
@@ -1,33 +1,123 @@
-import asyncio
-from concurrent.futures import ThreadPoolExecutor
+import json
import urllib.parse as parser
+from random import uniform, randint
+
+from bs4 import BeautifulSoup
+from loguru import logger
from configs import configs
from .devtools import Browser
from .spider import Spider
+PLACEHOLDER = '. '
+_paper_config = configs['paper']
+
+
+async def list_books(detail):
+ async with Spider() as spider:
+ await spider.login(**configs['user'])
+ courses = await spider.get_courses()
+
+ for cr in courses:
+ if detail:
+ hint = f'{cr["courseName"]} ({cr["teacherName"]})'
+ else:
+ hint = cr['courseName']
+ print(hint)
+
+ books = await spider.get_books(cr["courseId"])
+ for book in books:
+ if detail:
+ hint = f'> [{cr["courseId"]}#{book["bookId"]}] {book["bookName"]} ({book["percent"]}%)'
+ else:
+ hint = f'> {book["bookName"]}'
+ print(PLACEHOLDER + hint)
+
+
+async def _random(spider, paper_id): # 随机的分数和学习时长
+ paper = BeautifulSoup((await spider.get_paper(paper_id))['paperData'], 'lxml-xml')
+ questions = paper.select('element[knowledge]:has(> question_type)')
+ if not questions:
+ return 100, 5
+
+ total = 0
+ score, time = 0, 0
+ for q in questions:
+ q_type = int(q.select_one('question_type').text)
+ q_score = float(q.select_one('question_score').text)
+ total += q_score
+
+ ranges = _paper_config['random-score']
+ if q_type <= len(ranges) and (r := ranges[q_type-1]) is not None:
+ if not isinstance(ranges[q_type-1], list):
+ r = [r, r]
+ score += q_score * uniform(*r)
+ else:
+ q_no = q.select_one('question_no').text
+ logger.warning(f'T{q_no}:未知的题型!')
+ if (r := _paper_config['defaults']) == 'pause':
+ score += float(input('请手动输入该题得分 [0-1]:'))
+ else: # defaults
+ score += q_score * uniform(*r)
-class Tree: # 任务树
- def __init__(self, task):
- self.task = task
- self.child = []
+ time += randint(*_paper_config['random-time'])
+ return int(100 * score / total), time
-async def ainput(prompt: str = ''):
- with ThreadPoolExecutor(1, 'ainput') as executor:
- return (
- await asyncio.get_event_loop().run_in_executor(executor, input, prompt)
- ).rstrip()
+
+async def _flash(course_id, book_id, spider):
+ async def dfs(node, depth=0):
+ task = node.task
+ print(PLACEHOLDER * depth + task['name'])
+ if _paper_config['skip-finished'] and task['unitStudyPercent'] == 100:
+ print(PLACEHOLDER * depth + '# Skipped')
+ return
+ if 'paperId' in task: # 如果有任务则提交
+ chapter_id = task['chapterId']
+ task_id = task['id']
+ score, time = await _random(spider, task['paperId'])
+ result = await page.submit(book_id, chapter_id, task_id, score, time, 100, user_id)
+ if result['wasThrown'] or not result['result']['value']:
+ logger.warning(f'任务 {task["name"]} 可能提交失败,请留意最终结果!')
+ for ch in node.child:
+ await dfs(ch, depth + 1)
+
+ browser = await Browser.connect()
+ page = await browser.wait_for_page(r'https?://pc\.ismartin\.com.*')
+
+ # With Spider
+ await spider.login(**configs['user'])
+ user_id = (await spider.user_info())['data']['uid']
+
+ book_type = (await spider.book_info(book_id))['bookType']
+ root = await spider.get_tasks(book_id, book_type, course_id)
+ await dfs(root)
+
+
+async def flash_by_id(identity):
+ async with Spider() as spider:
+ await _flash(*identity.split('#'), spider)
+
+
+async def flash_current(): # 对当前课程或书籍执行刷课
+ browser = await Browser.connect()
+ course_id, book_id = await browser.get_current()
+ async with Spider() as spider:
+ if book_id:
+ await _flash(course_id, book_id, spider)
+ else:
+ await spider.login(**configs['user'])
+ books = await spider.get_books(course_id)
+ for book in books:
+ await _flash(course_id, book['bookId'], spider)
-async def flash_recent(): # 对当前书籍执行刷课
- if configs['browser']['mode'] == 'launch':
- browser = Browser.launch()
- else:
- 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 def flash_all():
async with Spider() as spider:
await spider.login(**configs['user'])
+ courses = await spider.get_courses()
+ for course in courses:
+ course_id = course['courseId']
+ books = await spider.get_books(course_id)
+ for book in books:
+ await _flash(course_id, book['bookId'], spider)
diff --git a/configs.py b/configs.py
index 6db0278..eb9d085 100644
--- a/configs.py
+++ b/configs.py
@@ -1,10 +1,13 @@
import yaml
-
with open('configs.yml', 'r', encoding='utf-8') as _fp:
configs = yaml.safe_load(_fp)
+def update(conf):
+ configs.update(conf)
+
+
if __name__ == '__main__':
import json
print(json.dumps(configs, indent=4))
diff --git a/configs.yml b/configs.yml
index 455604b..55280c8 100644
--- a/configs.yml
+++ b/configs.yml
@@ -1,17 +1,7 @@
-# Todo: 每次 commit 之前务必清除账号密码
-
-# 刷课配置
-project:
- skip-finished: true # 跳过已完成任务(暂不支持)
-
-# iSmart 客户端配置
browser:
- mode: connect # 连接模式( connect / launch )
- # 下面为 mode = launch 时的配置
- executable: Z:\iSmart\client\iSmart.exe # 客户端可执行文件的路径
- args: # 启动 iSmart 客户端时额外提供的参数
- - --disable-web-security
- port: 9222 # devTools 调试端口
+ port: 9222
+ verify: true
+
# 用户配置(务必保持账号密码与 iSmart 中已登录的相同)
user:
@@ -20,8 +10,10 @@ user:
# 答题配置
paper:
+ skip-finished: true # 跳过已完成任务
+
# 不同题目类型的随机得分
- ramdom-score: # Todo: 判断列表长度
+ random-score:
- 1 # 1.单选
- 1 # 2.多选
- 1 # 3.判断
@@ -33,6 +25,8 @@ paper:
- [ 0.7, 1 ] # 9.口语跟读
- [ 0.7, 1 ] # 10.短文改错
- 1 # 11.选词填空
- defaults: pause # 未知题型的处理方式
- random-time: [ 90, 240 ] # 每道题的随机用时(秒)
+ -
+ defaults: pause # 未知题型的处理方式(暂停或使用默认得分)
+# defaults: [ 1, 1 ]
+ random-time: [ 90, 180 ] # 每道题的随机用时(秒)
diff --git a/images/current_course.png b/images/current_course.png
new file mode 100644
index 0000000..60b72c6
Binary files /dev/null and b/images/current_course.png differ
diff --git a/main.py b/main.py
index 56ed878..a55aca8 100644
--- a/main.py
+++ b/main.py
@@ -1,23 +1,42 @@
import asyncio
+import sys
from argparse import ArgumentParser
+from loguru import logger
+
+from automaton import utils
+
async def main():
parser = ArgumentParser('main.py')
- parser.add_argument('-v', dest='LEVEL', default='warning', help='日志过滤等级,默认为 warning')
- subparsers = parser.add_subparsers(help='模式选择')
+
+ parser.add_argument('-v', dest='level', action='count', help='日志过滤等级,依次为 warning, info, debug')
+ subparsers = parser.add_subparsers(dest='method', help='模式选择')
method_list = subparsers.add_parser('list', help='列出所有课程和书籍')
+ method_list.add_argument('-d', '--detail', action='store_true', help='显示详细信息')
method_flash = subparsers.add_parser('flash', help='对选定的一个或几个课程执行刷课')
target = method_flash.add_mutually_exclusive_group()
- target.add_argument('-b', '--book', action='store_true', help='对当前打开的书籍执行刷课')
- target.add_argument('-c', '--course', action='store_true', help='对当前打开的课程执行刷课')
- target.add_argument('-a', '--all', action='store_true', help='对所有课程和书籍执行刷课')
- method_flash.add_argument('-f', '--filter', help='')
- method_flash.add_argument('-i', '--invert', help='过滤器反向')
+ target.add_argument('-i', '--id', help='直接指定书籍 id')
+ target.add_argument('-c', '--current', action='store_true', help='限定当前课程或书籍')
+ target.add_argument('-a', '--all', action='store_true', help='选择全部')
+ method_flash.add_argument('-f', '--filter', help='任务过滤器,设置后只刷匹配的任务(尚未实现)') # Todo: 实现这个
+
+ args = parser.parse_args()
+
+ logger.remove()
+ logger.add(sys.stdout, level=['WARNING', 'INFO', 'DEBUG'][args.level or 0])
- parser.parse_args()
+ if args.method == 'list':
+ await utils.list_books(detail=args.detail)
+ elif args.method == 'flash':
+ if args.id:
+ await utils.flash_by_id(args.id)
+ elif args.current:
+ await utils.flash_current()
+ elif args.all:
+ await utils.flash_all()
if __name__ == '__main__':
loop = asyncio.new_event_loop()