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.
Files changed (24) hide show
  1. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/PKG-INFO +1 -1
  2. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/client.py +9 -3
  3. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/ctx.py +30 -2
  4. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/jsonapi.py +18 -3
  5. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/quizapi.py +16 -14
  6. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/vision.py +15 -4
  7. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/PKG-INFO +1 -1
  8. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/pyproject.toml +1 -1
  9. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/LICENSE +0 -0
  10. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/README.md +0 -0
  11. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/__init__.py +0 -0
  12. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/aipreview.py +0 -0
  13. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/airunner.py +0 -0
  14. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/cdn.py +0 -0
  15. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/errors.py +0 -0
  16. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/mcp_server.py +0 -0
  17. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/parse.py +0 -0
  18. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver/schema/quiz_protocol.json +0 -0
  19. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/SOURCES.txt +0 -0
  20. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/dependency_links.txt +0 -0
  21. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/entry_points.txt +0 -0
  22. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/requires.txt +0 -0
  23. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/moodlesolver.egg-info/top_level.txt +0 -0
  24. {moodlesolver-2.1.0 → moodlesolver-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moodlesolver
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Moodle quiz automation library and MCP server with local OCR
5
5
  Author: i-execute
6
6
  License: MIT
@@ -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(10):
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(f'{self.dumps_dir}/q{quiz_id}_n{q['number']}.jpg')
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(f'q{quiz_id}_n{q['number']}.json', rec)
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'res_{r['cmid']}.bin'
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(f'error:{r['cmid']}:{str(e)[:40]}')
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.m.pick_tab_nowait('attempt.php') if hasattr(self.q.m, 'pick_tab_nowait') else None
178
- fin = await self.q.m.tab.evaluate("(async () => { const n = document.querySelector('input[name=finishattempt], input[name=next]'); if (n) { n.click(); return n.name; } return 'nobtn'; })()")
179
- await asyncio.sleep(5)
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
- 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')
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
- 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]) + "; 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 && !e.checked) { e.click(); n++; } } return JSON.stringify({clicked: n}); })()")
82
- if wants or index is None:
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
- r2 = await t.click_answer(index)
85
- return {'clicked': 1 if r2 == 'ok' else 0}
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("var b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё')); if (b) { b.click(); return 'sub'; } return 'nobtn'")
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("var b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё и завершить тест')); if (b) { b.click(); return 'done'; } return 'noconfirm'")
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
- for root in ('/tmp',):
47
- c = os.path.join(root, path)
48
- if os.path.exists(c):
49
- return c
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moodlesolver
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Moodle quiz automation library and MCP server with local OCR
5
5
  Author: i-execute
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moodlesolver"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Moodle quiz automation library and MCP server with local OCR"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes