moodlesolver 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moodlesolver-1.0.0/PKG-INFO +106 -0
- moodlesolver-1.0.0/README.md +93 -0
- moodlesolver-1.0.0/moodlesolver/__init__.py +6 -0
- moodlesolver-1.0.0/moodlesolver/client.py +406 -0
- moodlesolver-1.0.0/moodlesolver/parse.py +42 -0
- moodlesolver-1.0.0/moodlesolver.egg-info/PKG-INFO +106 -0
- moodlesolver-1.0.0/moodlesolver.egg-info/SOURCES.txt +10 -0
- moodlesolver-1.0.0/moodlesolver.egg-info/dependency_links.txt +1 -0
- moodlesolver-1.0.0/moodlesolver.egg-info/requires.txt +1 -0
- moodlesolver-1.0.0/moodlesolver.egg-info/top_level.txt +1 -0
- moodlesolver-1.0.0/pyproject.toml +21 -0
- moodlesolver-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moodlesolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async Moodle quiz automation client with a Telethon-style session API
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: moodle,async,automation,quiz
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Framework :: AsyncIO
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.9
|
|
13
|
+
|
|
14
|
+
# MoodleSolver
|
|
15
|
+
|
|
16
|
+
Async Moodle quiz automation client with a Telethon-style session API.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Telethon-style session**: `start()`, `connect()`, `disconnect()`, `is_connected`
|
|
21
|
+
- **Fully async** on top of `aiohttp` and a Camofox browser API
|
|
22
|
+
- **Multi-client**: run several accounts in parallel, each with its own browser profile; `disconnect()` only touches that client's tabs
|
|
23
|
+
- **All question types**: single choice, multiple choice, text answers, matching, image questions
|
|
24
|
+
- **Question dumps**: every question saved as JSON (+ optional JPG image) for offline analysis
|
|
25
|
+
- **Review parser**: fetch attempt reviews after submission
|
|
26
|
+
- **Result tracking**: read the final grade table
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.10+
|
|
31
|
+
- A running Camofox browser API (default `http://localhost:9377`)
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install moodlesolver
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
from moodlesolver import AsyncMoodle
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
async with AsyncMoodle(
|
|
47
|
+
site="https://moodle.example.edu",
|
|
48
|
+
username="user@example.edu",
|
|
49
|
+
password="secret",
|
|
50
|
+
) as m:
|
|
51
|
+
print("logged in as", await m.whoami())
|
|
52
|
+
|
|
53
|
+
await m.resume_attempt(quiz_id=123)
|
|
54
|
+
q = await m.current_page()
|
|
55
|
+
print(q["number"], q["text"], [o["label"] for o in q["options"]])
|
|
56
|
+
|
|
57
|
+
r, nxt = await m.answer_step(["correct answer label"])
|
|
58
|
+
print("answered", r, "timer left", nxt["timer"])
|
|
59
|
+
|
|
60
|
+
await m.finish_attempt()
|
|
61
|
+
print(await m.quiz_result(quiz_id=123))
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Session lifecycle
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
m = AsyncMoodle(site=..., username=..., password=...)
|
|
70
|
+
await m.connect() # open HTTP session to the browser API
|
|
71
|
+
await m.start() # login (or reuse an existing session)
|
|
72
|
+
...
|
|
73
|
+
await m.disconnect() # close this client's tabs and session
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`AsyncMoodle` also works as an async context manager (`async with`).
|
|
77
|
+
|
|
78
|
+
## API overview
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `start() / connect() / disconnect()` | Session lifecycle |
|
|
83
|
+
| `login(u, p) / whoami()` | Authentication |
|
|
84
|
+
| `new_tab(url) / pick(substr) / tabs() / close_stale()` | Tab management |
|
|
85
|
+
| `current_page()` | Parse the current question (type, options, timer) |
|
|
86
|
+
| `answer_step(labels)` | Answer by label text, then submit ("next") in one step |
|
|
87
|
+
| `answer_text(text)` | Fill a text-answer input |
|
|
88
|
+
| `answer_match(pairs)` | Fill matching selects |
|
|
89
|
+
| `download_image(path) / download_all_images(prefix)` | Export question images |
|
|
90
|
+
| `dump_question(q, quiz_id) / step_quiz(quiz_id, bank)` | Dump questions and drive a full quiz from an answer bank |
|
|
91
|
+
| `resume_attempt(quiz_id) / finish_attempt() / quiz_result(quiz_id) / review(attempt, cmid)` | Quiz lifecycle |
|
|
92
|
+
|
|
93
|
+
## Question bank format
|
|
94
|
+
|
|
95
|
+
`step_quiz` accepts a dict mapping question text (case-insensitive substring) to the answer:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
bank = {
|
|
99
|
+
"Some question text": "single answer label",
|
|
100
|
+
"Pick all that apply": ["first", "second"],
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# MoodleSolver
|
|
2
|
+
|
|
3
|
+
Async Moodle quiz automation client with a Telethon-style session API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Telethon-style session**: `start()`, `connect()`, `disconnect()`, `is_connected`
|
|
8
|
+
- **Fully async** on top of `aiohttp` and a Camofox browser API
|
|
9
|
+
- **Multi-client**: run several accounts in parallel, each with its own browser profile; `disconnect()` only touches that client's tabs
|
|
10
|
+
- **All question types**: single choice, multiple choice, text answers, matching, image questions
|
|
11
|
+
- **Question dumps**: every question saved as JSON (+ optional JPG image) for offline analysis
|
|
12
|
+
- **Review parser**: fetch attempt reviews after submission
|
|
13
|
+
- **Result tracking**: read the final grade table
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Python 3.10+
|
|
18
|
+
- A running Camofox browser API (default `http://localhost:9377`)
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install moodlesolver
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from moodlesolver import AsyncMoodle
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
async with AsyncMoodle(
|
|
34
|
+
site="https://moodle.example.edu",
|
|
35
|
+
username="user@example.edu",
|
|
36
|
+
password="secret",
|
|
37
|
+
) as m:
|
|
38
|
+
print("logged in as", await m.whoami())
|
|
39
|
+
|
|
40
|
+
await m.resume_attempt(quiz_id=123)
|
|
41
|
+
q = await m.current_page()
|
|
42
|
+
print(q["number"], q["text"], [o["label"] for o in q["options"]])
|
|
43
|
+
|
|
44
|
+
r, nxt = await m.answer_step(["correct answer label"])
|
|
45
|
+
print("answered", r, "timer left", nxt["timer"])
|
|
46
|
+
|
|
47
|
+
await m.finish_attempt()
|
|
48
|
+
print(await m.quiz_result(quiz_id=123))
|
|
49
|
+
|
|
50
|
+
asyncio.run(main())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Session lifecycle
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
m = AsyncMoodle(site=..., username=..., password=...)
|
|
57
|
+
await m.connect() # open HTTP session to the browser API
|
|
58
|
+
await m.start() # login (or reuse an existing session)
|
|
59
|
+
...
|
|
60
|
+
await m.disconnect() # close this client's tabs and session
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`AsyncMoodle` also works as an async context manager (`async with`).
|
|
64
|
+
|
|
65
|
+
## API overview
|
|
66
|
+
|
|
67
|
+
| Method | Description |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `start() / connect() / disconnect()` | Session lifecycle |
|
|
70
|
+
| `login(u, p) / whoami()` | Authentication |
|
|
71
|
+
| `new_tab(url) / pick(substr) / tabs() / close_stale()` | Tab management |
|
|
72
|
+
| `current_page()` | Parse the current question (type, options, timer) |
|
|
73
|
+
| `answer_step(labels)` | Answer by label text, then submit ("next") in one step |
|
|
74
|
+
| `answer_text(text)` | Fill a text-answer input |
|
|
75
|
+
| `answer_match(pairs)` | Fill matching selects |
|
|
76
|
+
| `download_image(path) / download_all_images(prefix)` | Export question images |
|
|
77
|
+
| `dump_question(q, quiz_id) / step_quiz(quiz_id, bank)` | Dump questions and drive a full quiz from an answer bank |
|
|
78
|
+
| `resume_attempt(quiz_id) / finish_attempt() / quiz_result(quiz_id) / review(attempt, cmid)` | Quiz lifecycle |
|
|
79
|
+
|
|
80
|
+
## Question bank format
|
|
81
|
+
|
|
82
|
+
`step_quiz` accepts a dict mapping question text (case-insensitive substring) to the answer:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
bank = {
|
|
86
|
+
"Some question text": "single answer label",
|
|
87
|
+
"Pick all that apply": ["first", "second"],
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CamofoxError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncTab:
|
|
16
|
+
def __init__(self, session, user, tab_id):
|
|
17
|
+
self.s = session
|
|
18
|
+
self.user = user
|
|
19
|
+
self.id = tab_id
|
|
20
|
+
|
|
21
|
+
async def _post(self, path, body):
|
|
22
|
+
async with self.s.post(path, json=body,
|
|
23
|
+
timeout=aiohttp.ClientTimeout(total=90)) as r:
|
|
24
|
+
d = await r.json()
|
|
25
|
+
if r.status >= 400:
|
|
26
|
+
raise CamofoxError(f"HTTP {r.status}: {str(d)[:200]}")
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
async def _get(self, path):
|
|
30
|
+
async with self.s.get(path,
|
|
31
|
+
timeout=aiohttp.ClientTimeout(total=90)) as r:
|
|
32
|
+
return await r.json()
|
|
33
|
+
|
|
34
|
+
async def snapshot_raw(self):
|
|
35
|
+
return await self._get(f"/tabs/{self.id}/snapshot?userId={self.user}")
|
|
36
|
+
|
|
37
|
+
async def snapshot(self, tries=5, delay=3):
|
|
38
|
+
for _ in range(tries):
|
|
39
|
+
d = await self.snapshot_raw()
|
|
40
|
+
s = d.get("snapshot") or ""
|
|
41
|
+
if s:
|
|
42
|
+
return s
|
|
43
|
+
await asyncio.sleep(delay)
|
|
44
|
+
raise CamofoxError(f"empty snapshot {self.id}")
|
|
45
|
+
|
|
46
|
+
async def main_content(self):
|
|
47
|
+
s = await self.snapshot()
|
|
48
|
+
a, b = s.find("Содержимое"), s.find("Блоки")
|
|
49
|
+
if a >= 0 < b:
|
|
50
|
+
return s[a + 9:b]
|
|
51
|
+
a, b = s.find("Main content"), s.find("Blocks")
|
|
52
|
+
if a >= 0 < b:
|
|
53
|
+
return s[a + 13:b]
|
|
54
|
+
return s
|
|
55
|
+
|
|
56
|
+
async def click(self, ref):
|
|
57
|
+
return await self._post(f"/tabs/{self.id}/click",
|
|
58
|
+
{"userId": self.user, "ref": ref})
|
|
59
|
+
|
|
60
|
+
async def type(self, ref, text):
|
|
61
|
+
return await self._post(f"/tabs/{self.id}/type",
|
|
62
|
+
{"userId": self.user, "ref": ref, "text": text})
|
|
63
|
+
|
|
64
|
+
async def select(self, ref, option):
|
|
65
|
+
return await self._post(f"/tabs/{self.id}/select",
|
|
66
|
+
{"userId": self.user, "ref": ref, "option": option})
|
|
67
|
+
|
|
68
|
+
async def navigate(self, url, settle=2):
|
|
69
|
+
r = await self._post(f"/tabs/{self.id}/navigate",
|
|
70
|
+
{"userId": self.user, "url": url})
|
|
71
|
+
await asyncio.sleep(settle)
|
|
72
|
+
return r
|
|
73
|
+
|
|
74
|
+
async def evaluate(self, expression):
|
|
75
|
+
return await self._post(f"/tabs/{self.id}/evaluate",
|
|
76
|
+
{"userId": self.user, "expression": expression})
|
|
77
|
+
|
|
78
|
+
async def url(self):
|
|
79
|
+
d = await self.snapshot_raw()
|
|
80
|
+
return d.get("url", "")
|
|
81
|
+
|
|
82
|
+
async def close(self):
|
|
83
|
+
async with self.s.delete(f"/tabs/{self.id}",
|
|
84
|
+
params={"userId": self.user}) as r:
|
|
85
|
+
return await r.json()
|
|
86
|
+
|
|
87
|
+
async def js_click_label(self, labels):
|
|
88
|
+
labs = json.dumps(labels if isinstance(labels, list) else [labels],
|
|
89
|
+
ensure_ascii=False)
|
|
90
|
+
r = await self.evaluate(f"""(() => {{
|
|
91
|
+
const want = {labs};
|
|
92
|
+
let n = 0;
|
|
93
|
+
for (const w of want) {{
|
|
94
|
+
const el = [...document.querySelectorAll('input[type=checkbox],input[type=radio]')]
|
|
95
|
+
.find(x => (x.parentElement.textContent || '').trim().toLowerCase()
|
|
96
|
+
.includes(w.toLowerCase()));
|
|
97
|
+
if (el) {{ el.click(); n++; }}
|
|
98
|
+
}}
|
|
99
|
+
return String(n);
|
|
100
|
+
}})()""")
|
|
101
|
+
return r.get("result")
|
|
102
|
+
|
|
103
|
+
async def js_click_next(self):
|
|
104
|
+
await self.evaluate(
|
|
105
|
+
"document.querySelector('input[name=next]').click(); 'ok'")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AsyncMoodle:
|
|
109
|
+
def __init__(self, site, username=None, password=None,
|
|
110
|
+
user=None, api_url="http://localhost:9377",
|
|
111
|
+
dumps_dir="moodlesolver_dumps"):
|
|
112
|
+
self.username = username
|
|
113
|
+
self.password = password
|
|
114
|
+
self.site = site.rstrip("/")
|
|
115
|
+
self.api_url = api_url.rstrip("/")
|
|
116
|
+
self.user = user or ("moodle_" + hashlib.sha1(
|
|
117
|
+
(username or "anon").encode()).hexdigest()[:8])
|
|
118
|
+
self.dumps_dir = dumps_dir
|
|
119
|
+
self.tab = None
|
|
120
|
+
self.s = None
|
|
121
|
+
self._connected = False
|
|
122
|
+
|
|
123
|
+
async def __aenter__(self):
|
|
124
|
+
await self.connect()
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, *exc):
|
|
128
|
+
await self.disconnect()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_connected(self):
|
|
132
|
+
return self._connected
|
|
133
|
+
|
|
134
|
+
async def connect(self):
|
|
135
|
+
if not self._connected:
|
|
136
|
+
self.s = aiohttp.ClientSession(base_url=self.api_url)
|
|
137
|
+
self._connected = True
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
async def start(self):
|
|
141
|
+
if not self._connected:
|
|
142
|
+
await self.connect()
|
|
143
|
+
if self.username and self.password:
|
|
144
|
+
return await self.login(self.username, self.password)
|
|
145
|
+
return await self.whoami()
|
|
146
|
+
|
|
147
|
+
async def disconnect(self):
|
|
148
|
+
if self.s:
|
|
149
|
+
for t in await self.tabs():
|
|
150
|
+
await AsyncTab(self.s, self.user, t["tabId"]).close()
|
|
151
|
+
await self.s.close()
|
|
152
|
+
self.s = None
|
|
153
|
+
self._connected = False
|
|
154
|
+
|
|
155
|
+
async def login(self, username, password):
|
|
156
|
+
t = await self.pick("login/index.php") or \
|
|
157
|
+
await self.new_tab(f"{self.site}/login/index.php")
|
|
158
|
+
s = await t.main_content()
|
|
159
|
+
if "Вы уже вошли" in s or "Вы зашли" in s or "Logged in" in s:
|
|
160
|
+
return await self.whoami()
|
|
161
|
+
m_u = re.search(r'(?:textbox|field) "([^"]*(?:Username|Логин|Пользователь)[^"]*)"\s*\[e(\d+)\]', s)
|
|
162
|
+
m_p = re.search(r'(?:textbox|field) "([^"]*(?:Password|Пароль)[^"]*)"\s*\[e(\d+)\]', s)
|
|
163
|
+
m_b = re.search(r'button "([^"]*(?:Log in|Вход|Войти)[^"]*)"\s*\[e(\d+)\]', s)
|
|
164
|
+
if not (m_u and m_p and m_b):
|
|
165
|
+
raise CamofoxError(f"login form not parsed: {s[:300]}")
|
|
166
|
+
await t.type(f"e{m_u.group(2)}", username)
|
|
167
|
+
await t.type(f"e{m_p.group(2)}", password)
|
|
168
|
+
await t.click(f"e{m_b.group(2)}")
|
|
169
|
+
await asyncio.sleep(4)
|
|
170
|
+
return await self.whoami()
|
|
171
|
+
|
|
172
|
+
async def whoami(self):
|
|
173
|
+
t = await self.pick("my/") or await self.new_tab(f"{self.site}/my/")
|
|
174
|
+
r = await t.evaluate(
|
|
175
|
+
"JSON.stringify(document.querySelector('.usertext')?.textContent || null)")
|
|
176
|
+
u = r.get("result")
|
|
177
|
+
if u and u != "null":
|
|
178
|
+
return u.strip('"')
|
|
179
|
+
s = await t.main_content()
|
|
180
|
+
m = re.search(r'Вы зашли под именем\s*\n?\s*- link "([^"]+)"', s)
|
|
181
|
+
if m:
|
|
182
|
+
return m.group(1)
|
|
183
|
+
m = re.search(r'You are logged in as\s*\n?\s*- link "([^"]+)"', s)
|
|
184
|
+
return m.group(1) if m else None
|
|
185
|
+
|
|
186
|
+
async def tabs(self):
|
|
187
|
+
async with self.s.get("/tabs", params={"userId": self.user}) as r:
|
|
188
|
+
return (await r.json()).get("tabs", [])
|
|
189
|
+
|
|
190
|
+
async def new_tab(self, url, settle=3):
|
|
191
|
+
async with self.s.post("/tabs", json={
|
|
192
|
+
"userId": self.user, "sessionKey": "s", "url": url}) as r:
|
|
193
|
+
d = await r.json()
|
|
194
|
+
t = AsyncTab(self.s, self.user, d["tabId"])
|
|
195
|
+
await asyncio.sleep(settle)
|
|
196
|
+
self.tab = t
|
|
197
|
+
return t
|
|
198
|
+
|
|
199
|
+
async def pick(self, url_substr, latest=True):
|
|
200
|
+
ts = [t for t in await self.tabs() if url_substr in t.get("url", "")]
|
|
201
|
+
if not ts:
|
|
202
|
+
return None
|
|
203
|
+
t = AsyncTab(self.s, self.user,
|
|
204
|
+
ts[-1]["tabId"] if latest else ts[0]["tabId"])
|
|
205
|
+
self.tab = t
|
|
206
|
+
return t
|
|
207
|
+
|
|
208
|
+
async def close_stale(self, keep=""):
|
|
209
|
+
for t in await self.tabs():
|
|
210
|
+
if keep and keep in t.get("url", ""):
|
|
211
|
+
continue
|
|
212
|
+
await AsyncTab(self.s, self.user, t["tabId"]).close()
|
|
213
|
+
|
|
214
|
+
async def current_page(self, tab=None):
|
|
215
|
+
from .parse import parse_page
|
|
216
|
+
return parse_page(await (tab or self.tab).main_content())
|
|
217
|
+
|
|
218
|
+
async def download_image(self, path="/tmp/moodle_img.jpg", tab=None):
|
|
219
|
+
t = tab or self.tab
|
|
220
|
+
d = (await t.evaluate("""(async () => {
|
|
221
|
+
const img = document.querySelector('.qtext img, .content img');
|
|
222
|
+
if (!img) return 'noimg';
|
|
223
|
+
await new Promise(r => img.complete ? r() : img.onload = r);
|
|
224
|
+
const c = document.createElement('canvas');
|
|
225
|
+
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
|
226
|
+
c.getContext('2d').drawImage(img, 0, 0);
|
|
227
|
+
return c.toDataURL('image/jpeg', 0.9);
|
|
228
|
+
})()""")).get("result")
|
|
229
|
+
if not isinstance(d, str) or "," not in d:
|
|
230
|
+
return None
|
|
231
|
+
open(path, "wb").write(base64.b64decode(d.split(",")[1]))
|
|
232
|
+
return path
|
|
233
|
+
|
|
234
|
+
async def download_all_images(self, prefix="/tmp/moodle_img", tab=None):
|
|
235
|
+
t = tab or self.tab
|
|
236
|
+
d = (await t.evaluate("""(async () => {
|
|
237
|
+
const imgs = [...document.querySelectorAll('.qtext img, .content img')];
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const img of imgs) {
|
|
240
|
+
await new Promise(r => img.complete ? r() : img.onload = r);
|
|
241
|
+
const c = document.createElement('canvas');
|
|
242
|
+
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
|
243
|
+
c.getContext('2d').drawImage(img, 0, 0);
|
|
244
|
+
out.push(c.toDataURL('image/jpeg', 0.9));
|
|
245
|
+
}
|
|
246
|
+
return JSON.stringify(out);
|
|
247
|
+
})()""")).get("result")
|
|
248
|
+
try:
|
|
249
|
+
arr = json.loads(d or "[]")
|
|
250
|
+
except Exception:
|
|
251
|
+
return []
|
|
252
|
+
paths = []
|
|
253
|
+
for i, data in enumerate(arr):
|
|
254
|
+
if isinstance(data, str) and "," in data:
|
|
255
|
+
p = f"{prefix}_{i}.jpg"
|
|
256
|
+
open(p, "wb").write(base64.b64decode(data.split(",")[1]))
|
|
257
|
+
paths.append(p)
|
|
258
|
+
return paths
|
|
259
|
+
|
|
260
|
+
async def answer_text(self, text, tab=None):
|
|
261
|
+
t = tab or self.tab
|
|
262
|
+
r = await t.evaluate(f"""(() => {{
|
|
263
|
+
const el = [...document.querySelectorAll('.qtext ~ * input[type=text], input[type=text]')]
|
|
264
|
+
.filter(x => !x.name.includes('sesskey'))[0];
|
|
265
|
+
if (!el) return 'notext';
|
|
266
|
+
el.focus();
|
|
267
|
+
el.value = {json.dumps(text, ensure_ascii=False)};
|
|
268
|
+
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
269
|
+
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
270
|
+
return el.value;
|
|
271
|
+
}})()""")
|
|
272
|
+
return r.get("result")
|
|
273
|
+
|
|
274
|
+
async def answer_match(self, pairs, tab=None):
|
|
275
|
+
t = tab or self.tab
|
|
276
|
+
r = await t.evaluate(f"""(() => {{
|
|
277
|
+
const pairs = {json.dumps(pairs, ensure_ascii=False)};
|
|
278
|
+
let n = 0;
|
|
279
|
+
for (const p of pairs) {{
|
|
280
|
+
const row = [...document.querySelectorAll('tr, .answer li')]
|
|
281
|
+
.find(x => (x.textContent || '').includes(p[0]));
|
|
282
|
+
if (!row) continue;
|
|
283
|
+
const sel = row.querySelector('select');
|
|
284
|
+
if (!sel) continue;
|
|
285
|
+
const opt = [...sel.options].find(o => o.textContent.trim() === p[1]);
|
|
286
|
+
if (opt) {{ sel.value = opt.value;
|
|
287
|
+
sel.dispatchEvent(new Event('change', {{bubbles: true}})); n++; }}
|
|
288
|
+
}}
|
|
289
|
+
return String(n);
|
|
290
|
+
}})()""")
|
|
291
|
+
return r.get("result")
|
|
292
|
+
|
|
293
|
+
def dump(self, name, content):
|
|
294
|
+
os.makedirs(self.dumps_dir, exist_ok=True)
|
|
295
|
+
p = f"{self.dumps_dir}/{name}"
|
|
296
|
+
open(p, "w").write(content if isinstance(content, str)
|
|
297
|
+
else json.dumps(content, ensure_ascii=False, indent=1))
|
|
298
|
+
return p
|
|
299
|
+
|
|
300
|
+
async def dump_question(self, q, quiz_id):
|
|
301
|
+
rec = {"n": q["number"], "text": q["text"],
|
|
302
|
+
"options": [o["label"] for o in q["options"]],
|
|
303
|
+
"multi": q["multi"], "timer": q["timer"]}
|
|
304
|
+
p = await self.download_image(
|
|
305
|
+
f"{self.dumps_dir}/q{quiz_id}_n{q['number']}.jpg")
|
|
306
|
+
if p:
|
|
307
|
+
rec["image"] = p
|
|
308
|
+
self.dump(f"q{quiz_id}_n{q['number']}.json", rec)
|
|
309
|
+
return rec
|
|
310
|
+
|
|
311
|
+
async def answer_step(self, labels, tab=None):
|
|
312
|
+
t = tab or self.tab
|
|
313
|
+
r = await t.js_click_label(labels)
|
|
314
|
+
await asyncio.sleep(0.7)
|
|
315
|
+
await t.js_click_next()
|
|
316
|
+
await asyncio.sleep(2.4)
|
|
317
|
+
return r, await self.current_page()
|
|
318
|
+
|
|
319
|
+
async def step_quiz(self, quiz_id, bank, noans=None, on_question=None):
|
|
320
|
+
noans = noans if noans is not None else []
|
|
321
|
+
prev = None
|
|
322
|
+
while True:
|
|
323
|
+
q = await self.current_page()
|
|
324
|
+
if q.get("number") == prev:
|
|
325
|
+
await asyncio.sleep(2)
|
|
326
|
+
continue
|
|
327
|
+
prev = q.get("number")
|
|
328
|
+
if not q["text"] and not q["options"] and not q.get("selects"):
|
|
329
|
+
return noans
|
|
330
|
+
rec = await self.dump_question(q, quiz_id)
|
|
331
|
+
ans = None
|
|
332
|
+
for k, v in bank.items():
|
|
333
|
+
if k.lower() in (q["text"] or "").lower():
|
|
334
|
+
ans = v
|
|
335
|
+
break
|
|
336
|
+
if on_question:
|
|
337
|
+
ans = await on_question(q, ans, rec)
|
|
338
|
+
if ans:
|
|
339
|
+
labs = ans if isinstance(ans, list) else [ans]
|
|
340
|
+
r, nxt = await self.answer_step(labs)
|
|
341
|
+
if not nxt["next_ref"] and nxt["text"] is None:
|
|
342
|
+
return noans
|
|
343
|
+
else:
|
|
344
|
+
noans.append(rec)
|
|
345
|
+
if not q["next_ref"]:
|
|
346
|
+
return noans
|
|
347
|
+
await self.tab.js_click_next()
|
|
348
|
+
await asyncio.sleep(2.4)
|
|
349
|
+
|
|
350
|
+
async def resume_attempt(self, quiz_id):
|
|
351
|
+
t = await self.pick(f"view.php?id={quiz_id}") or \
|
|
352
|
+
await self.new_tab(f"{self.site}/mod/quiz/view.php?id={quiz_id}")
|
|
353
|
+
self.tab = t
|
|
354
|
+
s = await t.main_content()
|
|
355
|
+
m = re.search(r'button "(Продолжить текущую попытку|Continue attempt)"[^\[]*\[e(\d+)\]', s)
|
|
356
|
+
if m:
|
|
357
|
+
await t.click(f"e{m.group(1)}")
|
|
358
|
+
await asyncio.sleep(3)
|
|
359
|
+
at = await self.pick("attempt.php")
|
|
360
|
+
if at:
|
|
361
|
+
self.tab = at
|
|
362
|
+
return at.id
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
async def finish_attempt(self):
|
|
366
|
+
q = await self.current_page()
|
|
367
|
+
if q.get("next_label") and ("Закончить" in q["next_label"]
|
|
368
|
+
or "Finish" in q["next_label"]):
|
|
369
|
+
await self.tab.js_click_next()
|
|
370
|
+
await asyncio.sleep(3)
|
|
371
|
+
t = await self.pick("summary.php")
|
|
372
|
+
if t:
|
|
373
|
+
self.tab = t
|
|
374
|
+
s = await self.tab.main_content()
|
|
375
|
+
m = re.search(r'button "Отправить всё и завершить[^"]*"[^\[]*\[e(\d+)\]', s)
|
|
376
|
+
if m:
|
|
377
|
+
await self.tab.click(f"e{m.group(1)}")
|
|
378
|
+
await asyncio.sleep(3)
|
|
379
|
+
s = await self.tab.main_content()
|
|
380
|
+
m = re.search(r'button "Отправить всё и завершить тест"[^\[]*\[e(\d+)\]', s)
|
|
381
|
+
if m:
|
|
382
|
+
await self.tab.click(f"e{m.group(1)}")
|
|
383
|
+
await asyncio.sleep(6)
|
|
384
|
+
|
|
385
|
+
async def quiz_result(self, quiz_id):
|
|
386
|
+
v = await self.pick(f"view.php?id={quiz_id}") or \
|
|
387
|
+
await self.new_tab(f"{self.site}/mod/quiz/view.php?id={quiz_id}")
|
|
388
|
+
await asyncio.sleep(2)
|
|
389
|
+
s = await v.main_content()
|
|
390
|
+
self.dump(f"result_{quiz_id}.txt", s)
|
|
391
|
+
sc = re.search(r'итоговая оценка за этот тест:\s*([\d,]+)/([\d,]+)', s)
|
|
392
|
+
if sc:
|
|
393
|
+
return sc.group(1) + "/" + sc.group(2)
|
|
394
|
+
return re.findall(r'- row "([^"]+Отправлено[^"]*)"', s)
|
|
395
|
+
|
|
396
|
+
async def review(self, attempt_id, cmid, showall=True):
|
|
397
|
+
u = f"{self.site}/mod/quiz/review.php?attempt={attempt_id}&cmid={cmid}"
|
|
398
|
+
if showall:
|
|
399
|
+
u += "&showall=1"
|
|
400
|
+
t = self.tab or await self.new_tab(u)
|
|
401
|
+
await t.navigate(u)
|
|
402
|
+
await asyncio.sleep(3)
|
|
403
|
+
s = await t.snapshot(tries=2)
|
|
404
|
+
self.dump(f"review_{attempt_id}.txt", s)
|
|
405
|
+
a = s.find("Содержимое")
|
|
406
|
+
return s[a:s.find("Блоки")] if a >= 0 else s
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
ANSWER_TYPES = ("radio", "checkbox", "combobox", "textbox")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_page(snapshot):
|
|
7
|
+
q = {"type": "unknown"}
|
|
8
|
+
m = re.search(r'heading "Вопрос (\d+)"', snapshot)
|
|
9
|
+
if not m:
|
|
10
|
+
m = re.search(r'heading "Question (\d+)"', snapshot)
|
|
11
|
+
q["number"] = int(m.group(1)) if m else None
|
|
12
|
+
m = re.search(r'Балл:\s*([\d,]+)', snapshot)
|
|
13
|
+
if not m:
|
|
14
|
+
m = re.search(r'Mark[s]?\s*([\d,]+)', snapshot)
|
|
15
|
+
q["points"] = m.group(1) if m else None
|
|
16
|
+
m = re.search(r'heading "Текст вопроса".*?\n.*?paragraph: (.+)', snapshot)
|
|
17
|
+
if not m:
|
|
18
|
+
m = re.search(r'heading "Question text".*?\n.*?paragraph: (.+)', snapshot)
|
|
19
|
+
q["text"] = m.group(1).strip() if m else None
|
|
20
|
+
opts = []
|
|
21
|
+
for m in re.finditer(r'(radio|checkbox) "([^"]+)" \[e(\d+)\]([^\n]*)', snapshot):
|
|
22
|
+
opts.append({"ref": "e" + m.group(3), "label": m.group(2),
|
|
23
|
+
"type": m.group(1), "checked": "checked" in m.group(4)})
|
|
24
|
+
q["options"] = opts
|
|
25
|
+
q["selects"] = [{"ref": "e" + r, "current": l}
|
|
26
|
+
for l, r in re.findall(r'combobox[^"\n]*"([^"]+)" \[e(\d+)\]', snapshot)]
|
|
27
|
+
q["textboxes"] = [{"ref": "e" + r, "label": l}
|
|
28
|
+
for l, r in re.findall(r'textbox "([^"]+)" \[e(\d+)\]', snapshot)]
|
|
29
|
+
m = re.search(r'button "(Следующая страница|Завершить попытку[^"]*|'
|
|
30
|
+
r'Next page|Finish attempt[^"]*)"[^\[]*\[e(\d+)\]', snapshot)
|
|
31
|
+
q["next_ref"] = "e" + m.group(2) if m else None
|
|
32
|
+
q["next_label"] = m.group(1) if m else None
|
|
33
|
+
m = re.search(r'Оставшееся время (\d+):(\d+):(\d+)', snapshot)
|
|
34
|
+
if not m:
|
|
35
|
+
m = re.search(r'Time remaining (\d+):(\d+):(\d+)', snapshot)
|
|
36
|
+
q["timer"] = (int(m.group(1)) * 3600 + int(m.group(2)) * 60
|
|
37
|
+
+ int(m.group(3))) if m else None
|
|
38
|
+
q["raw"] = snapshot
|
|
39
|
+
q["multi"] = any(o["type"] == "checkbox" for o in opts)
|
|
40
|
+
q["has_image"] = bool(q["text"] and ("picture" in q["text"].lower()
|
|
41
|
+
or "картинк" in q["text"].lower()))
|
|
42
|
+
return q
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moodlesolver
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async Moodle quiz automation client with a Telethon-style session API
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: moodle,async,automation,quiz
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Framework :: AsyncIO
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.9
|
|
13
|
+
|
|
14
|
+
# MoodleSolver
|
|
15
|
+
|
|
16
|
+
Async Moodle quiz automation client with a Telethon-style session API.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Telethon-style session**: `start()`, `connect()`, `disconnect()`, `is_connected`
|
|
21
|
+
- **Fully async** on top of `aiohttp` and a Camofox browser API
|
|
22
|
+
- **Multi-client**: run several accounts in parallel, each with its own browser profile; `disconnect()` only touches that client's tabs
|
|
23
|
+
- **All question types**: single choice, multiple choice, text answers, matching, image questions
|
|
24
|
+
- **Question dumps**: every question saved as JSON (+ optional JPG image) for offline analysis
|
|
25
|
+
- **Review parser**: fetch attempt reviews after submission
|
|
26
|
+
- **Result tracking**: read the final grade table
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.10+
|
|
31
|
+
- A running Camofox browser API (default `http://localhost:9377`)
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install moodlesolver
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
from moodlesolver import AsyncMoodle
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
async with AsyncMoodle(
|
|
47
|
+
site="https://moodle.example.edu",
|
|
48
|
+
username="user@example.edu",
|
|
49
|
+
password="secret",
|
|
50
|
+
) as m:
|
|
51
|
+
print("logged in as", await m.whoami())
|
|
52
|
+
|
|
53
|
+
await m.resume_attempt(quiz_id=123)
|
|
54
|
+
q = await m.current_page()
|
|
55
|
+
print(q["number"], q["text"], [o["label"] for o in q["options"]])
|
|
56
|
+
|
|
57
|
+
r, nxt = await m.answer_step(["correct answer label"])
|
|
58
|
+
print("answered", r, "timer left", nxt["timer"])
|
|
59
|
+
|
|
60
|
+
await m.finish_attempt()
|
|
61
|
+
print(await m.quiz_result(quiz_id=123))
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Session lifecycle
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
m = AsyncMoodle(site=..., username=..., password=...)
|
|
70
|
+
await m.connect() # open HTTP session to the browser API
|
|
71
|
+
await m.start() # login (or reuse an existing session)
|
|
72
|
+
...
|
|
73
|
+
await m.disconnect() # close this client's tabs and session
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`AsyncMoodle` also works as an async context manager (`async with`).
|
|
77
|
+
|
|
78
|
+
## API overview
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `start() / connect() / disconnect()` | Session lifecycle |
|
|
83
|
+
| `login(u, p) / whoami()` | Authentication |
|
|
84
|
+
| `new_tab(url) / pick(substr) / tabs() / close_stale()` | Tab management |
|
|
85
|
+
| `current_page()` | Parse the current question (type, options, timer) |
|
|
86
|
+
| `answer_step(labels)` | Answer by label text, then submit ("next") in one step |
|
|
87
|
+
| `answer_text(text)` | Fill a text-answer input |
|
|
88
|
+
| `answer_match(pairs)` | Fill matching selects |
|
|
89
|
+
| `download_image(path) / download_all_images(prefix)` | Export question images |
|
|
90
|
+
| `dump_question(q, quiz_id) / step_quiz(quiz_id, bank)` | Dump questions and drive a full quiz from an answer bank |
|
|
91
|
+
| `resume_attempt(quiz_id) / finish_attempt() / quiz_result(quiz_id) / review(attempt, cmid)` | Quiz lifecycle |
|
|
92
|
+
|
|
93
|
+
## Question bank format
|
|
94
|
+
|
|
95
|
+
`step_quiz` accepts a dict mapping question text (case-insensitive substring) to the answer:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
bank = {
|
|
99
|
+
"Some question text": "single answer label",
|
|
100
|
+
"Pick all that apply": ["first", "second"],
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
moodlesolver/__init__.py
|
|
4
|
+
moodlesolver/client.py
|
|
5
|
+
moodlesolver/parse.py
|
|
6
|
+
moodlesolver.egg-info/PKG-INFO
|
|
7
|
+
moodlesolver.egg-info/SOURCES.txt
|
|
8
|
+
moodlesolver.egg-info/dependency_links.txt
|
|
9
|
+
moodlesolver.egg-info/requires.txt
|
|
10
|
+
moodlesolver.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moodlesolver
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moodlesolver"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Async Moodle quiz automation client with a Telethon-style session API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
keywords = ["moodle", "async", "automation", "quiz"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["aiohttp>=3.9"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["moodlesolver*"]
|