moodlesolver 2.1.0__tar.gz → 2.2.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-2.1.0 → moodlesolver-2.2.0}/PKG-INFO +1 -1
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/client.py +9 -3
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/ctx.py +30 -2
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/jsonapi.py +18 -3
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/quizapi.py +16 -14
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/vision.py +15 -4
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/PKG-INFO +1 -1
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/pyproject.toml +1 -1
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/LICENSE +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/README.md +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/__init__.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/aipreview.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/airunner.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/cdn.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/errors.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/mcp_server.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/parse.py +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/schema/quiz_protocol.json +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/SOURCES.txt +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/dependency_links.txt +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/entry_points.txt +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/requires.txt +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/top_level.txt +0 -0
- {moodlesolver-2.1.0 → moodlesolver-2.2.0}/setup.cfg +0 -0
|
@@ -101,12 +101,18 @@ class AsyncTab:
|
|
|
101
101
|
|
|
102
102
|
async def next_page(self):
|
|
103
103
|
u0 = await self.url()
|
|
104
|
+
h0 = await self.evaluate("(() => { const q = document.querySelector('.qtext'); return q ? q.innerText.trim().slice(0, 80) : ''; })()")
|
|
105
|
+
h0 = (h0.get('result') or '')
|
|
104
106
|
await self.evaluate("(() => { const n = document.querySelector('input[name=next]') || document.querySelector('input[name=finishattempt]'); if (n) n.click(); return n ? 'ok' : 'nonext'; })()")
|
|
105
|
-
for _ in range(
|
|
107
|
+
for _ in range(12):
|
|
106
108
|
await asyncio.sleep(1)
|
|
107
109
|
u = await self.url()
|
|
110
|
+
h = await self.evaluate("(() => { const q = document.querySelector('.qtext'); return q ? q.innerText.trim().slice(0, 80) : ''; })()")
|
|
111
|
+
h = (h.get('result') or '')
|
|
108
112
|
if u != u0 and '[object' not in u:
|
|
109
113
|
return u
|
|
114
|
+
if h and h != h0 and u == u0 and '[object' not in u:
|
|
115
|
+
return u
|
|
110
116
|
return u0
|
|
111
117
|
|
|
112
118
|
async def qtext(self):
|
|
@@ -297,10 +303,10 @@ class AsyncMoodle:
|
|
|
297
303
|
|
|
298
304
|
async def dump_question(self, q, quiz_id):
|
|
299
305
|
rec = {'n': q['number'], 'text': q['text'], 'options': [o['label'] for o in q['options']], 'multi': q['multi'], 'timer': q['timer']}
|
|
300
|
-
p = await self.download_image(
|
|
306
|
+
p = await self.download_image('%s/q%s_n%s.jpg' % (self.dumps_dir, quiz_id, q['number']))
|
|
301
307
|
if p:
|
|
302
308
|
rec['image'] = p
|
|
303
|
-
self.dump(
|
|
309
|
+
self.dump("q%s_n%s.json" % (quiz_id, q['number']), rec)
|
|
304
310
|
return rec
|
|
305
311
|
|
|
306
312
|
async def answer_step(self, labels, tab=None):
|
|
@@ -95,13 +95,13 @@ class CtxAPI:
|
|
|
95
95
|
d['files'] = []
|
|
96
96
|
if with_resources:
|
|
97
97
|
for r in d['resources']:
|
|
98
|
-
p = f
|
|
98
|
+
p = f"res_{r['cmid']}.bin"
|
|
99
99
|
try:
|
|
100
100
|
got = await self.download_resource(r['cmid'], p)
|
|
101
101
|
if got.get('file'):
|
|
102
102
|
d['files'].append(got['file'])
|
|
103
103
|
except Exception as e:
|
|
104
|
-
d['files'].append(
|
|
104
|
+
d['files'].append("error:%s:%s" % (r['cmid'], str(e)[:40]))
|
|
105
105
|
p = path or f'course_{course_id}.json'
|
|
106
106
|
with open(p, 'w') as f:
|
|
107
107
|
json.dump(d, f, ensure_ascii=False, indent=1)
|
|
@@ -124,3 +124,31 @@ class CtxAPI:
|
|
|
124
124
|
|
|
125
125
|
async def search_courses(self, query):
|
|
126
126
|
return [c for c in await self.get_courses() if query.lower() in c['name'].lower()]
|
|
127
|
+
|
|
128
|
+
async def get_guide(self, quiz_cmid, path=None):
|
|
129
|
+
return await self._get_guide(quiz_cmid, path)
|
|
130
|
+
|
|
131
|
+
async def _get_guide(self, quiz_cmid, path=None):
|
|
132
|
+
for course in await self.get_courses():
|
|
133
|
+
secs = await self.get_course_sections(course['id'])
|
|
134
|
+
for s in secs:
|
|
135
|
+
names = [i for i in s.get('items', [])]
|
|
136
|
+
quiz = next((i for i in names if i.get('cmid') == str(quiz_cmid)), None)
|
|
137
|
+
if not quiz:
|
|
138
|
+
continue
|
|
139
|
+
import re as _re
|
|
140
|
+
m = _re.search(r'(\d+)', quiz.get('name') or '')
|
|
141
|
+
num = m.group(1) if m else ''
|
|
142
|
+
guide = next((i for i in names
|
|
143
|
+
if 'етодичка' in (i.get('name') or '')
|
|
144
|
+
and num and num in (i.get('name') or '')), None)
|
|
145
|
+
if guide:
|
|
146
|
+
gm = _re.search(r'id=(\d+)', guide.get('href') or '')
|
|
147
|
+
if gm:
|
|
148
|
+
p = path or f'guide_{num}.pdf'
|
|
149
|
+
r = await self.download_resource(int(gm.group(1)), p)
|
|
150
|
+
return {'ok': True, 'quiz': quiz.get('name'),
|
|
151
|
+
'guide': guide.get('name'),
|
|
152
|
+
'cmid': int(gm.group(1)), 'file': p,
|
|
153
|
+
'bytes': r.get('bytes')}
|
|
154
|
+
return {'error': 'guide not found'}
|
|
@@ -174,9 +174,24 @@ class JsonAPI:
|
|
|
174
174
|
st = await self.q.get_state() or {}
|
|
175
175
|
if not (st.get('checked') or st.get('text_inputs') or st.get('selects')):
|
|
176
176
|
return err('E_NOTHING_CHECKED', page=self._page(st), question=st.get('question'))
|
|
177
|
-
t = self.q.
|
|
178
|
-
|
|
179
|
-
await
|
|
177
|
+
t = await self.q._tab()
|
|
178
|
+
u0 = st.get('url') or ''
|
|
179
|
+
fin = await t.evaluate("(async () => { const n = document.querySelector('input[name=finishattempt], input[name=next]'); if (n) { n.click(); return n.name; } return 'nobtn'; })()")
|
|
180
|
+
for _ in range(15):
|
|
181
|
+
await asyncio.sleep(1)
|
|
182
|
+
u = await t.url()
|
|
183
|
+
if u != u0 and '[object' not in u and 'summary.php' in (u or ''):
|
|
184
|
+
break
|
|
185
|
+
if u != u0 and '[object' not in u:
|
|
186
|
+
break
|
|
187
|
+
u = await t.url()
|
|
188
|
+
if u == u0 or '[object' in (u or ''):
|
|
189
|
+
from .ctx import CtxAPI
|
|
190
|
+
mm = re.search('cmid=(\d+)', u0)
|
|
191
|
+
if mm:
|
|
192
|
+
r = await CtxAPI(self.q.m).resume_attempt(int(mm.group(1)))
|
|
193
|
+
if r.get('ok'):
|
|
194
|
+
t = self.q.m.tab
|
|
180
195
|
st2 = await self.q.get_state() or {}
|
|
181
196
|
if not st2.get('question'):
|
|
182
197
|
st2 = await self._resume() or {}
|
|
@@ -65,24 +65,26 @@ class QuizAPI:
|
|
|
65
65
|
self.m.dump('q_image.b64', arr[0])
|
|
66
66
|
st['question_image_file'] = 'q_image.b64'
|
|
67
67
|
st['question_image_b64'] = arr[0]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
arr2 = await t.opts_b64()
|
|
69
|
+
for i, b in enumerate(arr2):
|
|
70
|
+
self.m.dump(f'opt_image_{i}.b64', b)
|
|
71
|
+
st.setdefault('option_image_files', []).append(f'opt_image_{i}.b64')
|
|
72
|
+
if arr2:
|
|
72
73
|
st['option_images_b64'] = arr2
|
|
73
74
|
return st
|
|
74
75
|
|
|
75
|
-
async def set_answer(self, label=None, index=None, labels=None):
|
|
76
|
+
async def set_answer(self, label=None, index=None, labels=None, checked=None):
|
|
76
77
|
t = await self._tab()
|
|
77
|
-
if index is not None:
|
|
78
|
-
r2 = await t.click_answer(index)
|
|
79
|
-
return {'clicked': 1 if r2 == 'ok' else 0}
|
|
80
78
|
wants = labels if labels else [label] if label else []
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
if wants:
|
|
80
|
+
set_mark = 'true' if checked is not False else 'false'
|
|
81
|
+
r = await t.evaluate("(async () => { const ins = [...document.querySelectorAll('.answer input[type=radio], .answer input[type=checkbox]')]; const wants = " + json.dumps([w.lower() for w in wants]) + "; const setTo = " + set_mark + "; let n = 0; const norm = s => (s || '').replace(/[\\s\\xa0\\u200b\\u200c]+/g, ' ') .trim().toLowerCase(); for (const w of wants) { const wn = norm(w); let e = ins.find(x => norm(x.parentElement.textContent) === wn); if (!e) e = ins.find(x => norm(x.parentElement.textContent) .startsWith(wn.slice(0, 25))); if (e) { if (e.type === 'checkbox' && e.checked !== setTo) { e.click(); } else if (e.type === 'radio' && !e.checked) { e.click(); } if (e.type === 'radio' || e.checked === setTo) n++; } } return JSON.stringify({clicked: n}); })()")
|
|
83
82
|
return json.loads(r.get('result') or '{}')
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
if index is not None:
|
|
84
|
+
set_mark = 'true' if checked is not False else 'false'
|
|
85
|
+
r2 = await t.evaluate(f"(() => {{ const ins = document.querySelectorAll('.answer input[type=radio], .answer input[type=checkbox]'); if (!ins[{index}]) return 'nobtn'; const e = ins[{index}]; if (e.type === 'checkbox') {{ const want = {set_mark}; if (e.checked !== want) e.click(); return e.checked === want ? 'ok' : 'fail'; }} if (e.checked) return 'ok'; e.click(); return e.checked ? 'ok' : 'fail'; }})()")
|
|
86
|
+
return {'clicked': 1 if r2 == 'ok' else 0}
|
|
87
|
+
return {'clicked': 0}
|
|
86
88
|
|
|
87
89
|
async def set_text(self, text, index=0):
|
|
88
90
|
t = await self._tab()
|
|
@@ -115,9 +117,9 @@ class QuizAPI:
|
|
|
115
117
|
break
|
|
116
118
|
if not t:
|
|
117
119
|
return {'error': 'no summary'}
|
|
118
|
-
r = await t.evaluate("
|
|
120
|
+
r = await t.evaluate("(() => { const b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё')); if (b) { b.click(); return 'sub'; } return 'nobtn'; })()")
|
|
119
121
|
await asyncio.sleep(4)
|
|
120
|
-
r2 = await t.evaluate("
|
|
122
|
+
r2 = await t.evaluate("(() => { const b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё и завершить тест')); if (b) { b.click(); return 'done'; } return 'noconfirm'; })()")
|
|
121
123
|
return r2.get('result')
|
|
122
124
|
|
|
123
125
|
async def get_result(self, quiz_id):
|
|
@@ -43,10 +43,21 @@ def _resolve(path, dumps_dir=None):
|
|
|
43
43
|
if not os.path.exists(p) and dumps_dir:
|
|
44
44
|
p = os.path.join(dumps_dir, path)
|
|
45
45
|
if not os.path.exists(p):
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
c = os.path.join('/tmp', path)
|
|
47
|
+
if os.path.exists(c):
|
|
48
|
+
p = c
|
|
49
|
+
if os.path.exists(p) and p.endswith('.b64'):
|
|
50
|
+
import base64
|
|
51
|
+
try:
|
|
52
|
+
raw = open(p, 'rb').read()
|
|
53
|
+
data = base64.b64decode(raw)
|
|
54
|
+
if data[:4] == b'\x89PNG' or data[:2] == b'\xff\xd8':
|
|
55
|
+
png = '/tmp/ocr_' + os.path.basename(p)[:-4] + '.png'
|
|
56
|
+
with open(png, 'wb') as f:
|
|
57
|
+
f.write(data)
|
|
58
|
+
return png
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
50
61
|
return p
|
|
51
62
|
|
|
52
63
|
def ocr_file(path, prompt=None, dumps_dir=None):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|