From 5a562176aec701407782a459b610401f84fb8503 Mon Sep 17 00:00:00 2001 From: spearzificial <342299391+spearzificial@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:09:21 +0530 Subject: [PATCH] Diagram pipeline: import 1,856 image MCQs with local figures, sanitized img allowlist, WebP shrink --- .gitignore | 3 + scripts/import_with_images.py | 207 ++++++++++++++++++++++++++++++++++ scripts/shrink_qimg.py | 61 ++++++++++ src/views.rs | 77 ++++++++++--- static/style.css | 49 ++++++++ 5 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 scripts/import_with_images.py create mode 100644 scripts/shrink_qimg.py diff --git a/.gitignore b/.gitignore index 9512e44..5d7ea3d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ whaleprep.db* *.pid *.log + +# Question figures: 200MB of WebP/SVG, deployed by rsync not git +/static/qimg/ diff --git a/scripts/import_with_images.py b/scripts/import_with_images.py new file mode 100644 index 0000000..3869eb4 --- /dev/null +++ b/scripts/import_with_images.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Import the image-bearing MCQs that scripts/import_examgoal.py skipped. + +Downloads each question figure from the examgoal CDN into static/qimg/ +and rewrites to the local path, so the bank stays self-contained. + +Usage: python3 scripts/import_with_images.py DB_PATH [--pkl /tmp/db.pkl] +Safe to re-run: dedupes on exact stem match, skips already-downloaded files. +""" +import argparse, hashlib, html, pickle, re, sqlite3, sys, urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "scripts" / "examgoal_model")) +from question import Question +from chapter import Chapter +from fix_math import fix_field + +ALLOWED = {"br", "p", "sup", "sub", "b", "strong", "i", "em", "u", + "ol", "ul", "li", "table", "tbody", "thead", "tr", "th", "td", + "span", "h3"} +SUBJECT_FIX = {"mathematics": "Maths", "physics": "Physics", "chemistry": "Chemistry"} +QIMG = ROOT / "static" / "qimg" + +class FixUnpickler(pickle.Unpickler): + def find_class(self, module, name): + if name == "Question": + return Question + if name == "Chapter": + return Chapter + return super().find_class(module, name) + +def title_case(slug: str) -> str: + return " ".join(w.capitalize() for w in slug.replace("-", " ").split()) + +def shift_of(paper_title, exam_date) -> str: + t = str(paper_title or "") + low = t.lower() + if "evening" in low: return "S2" + if "morning" in low: return "S1" + return "S1" + +def url_hash(u: str) -> str: + return hashlib.sha1(u.encode()).hexdigest()[:16] + +def fetch(url: str, dest: Path, tries=3) -> bool: + if dest.exists() and dest.stat().st_size > 0: + return True + for _ in range(tries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=20) as r: + b = r.read() + if b: + dest.write_bytes(b) + return True + except Exception: + pass + return False + +def rewrite_images(qid: str, raw: str): + """Download each , rewrite to /static/qimg/.png.""" + ok_all = True + def repl(m): + nonlocal ok_all + url = html.unescape(m.group(1)) + name = f"{qid[:24]}-{url_hash(url)}.png" + if fetch(url, QIMG / name): + return f'' + ok_all = False + return "" + out = re.sub(r']*src="([^"]+)"[^>]*>', repl, raw) + return out, ok_all + +def sanitize_img(qid: str, raw: str): + """sanitize() + surviving img tags with local src only.""" + if not raw: + return "", True + text = html.unescape(str(raw)) + # unwrap : keep the fallback inside, drop variants + def unwrap_pic(m): + inner = m.group(0) + imgs = re.findall(r"]*>", inner) + return imgs[0] if imgs else "" + text = re.sub(r"", unwrap_pic, text, flags=re.S | re.I) + text = re.sub(r"", "", text, flags=re.S | re.I) + text = re.sub(r"", "", text, flags=re.S | re.I) + text = re.sub(r"]*>", "", text, flags=re.I) + text, ok = rewrite_images(qid, text) + def tag(m): + whole, closing, name = m.group(0), m.group(1), m.group(2).lower() + if name == "img": + # keep ONLY if src is already a local rewritten path + if 'src="/static/qimg/' in whole: + return whole + return "" + if name in ALLOWED: + return f"<{closing}{name}>" + return "" + text = re.sub(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^>]*)?)>", tag, text) + text = re.sub(r"[ \t\xa0]+", " ", text) + text = re.sub(r"\n\s*\n+", "\n", text) + text = text.strip() + return fix_field(text), ok + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("db") + ap.add_argument("--pkl", default="/tmp/db.pkl") + args = ap.parse_args() + + print(f"loading {args.pkl} ...", flush=True) + with open(args.pkl, "rb") as f: + data = FixUnpickler(f).load() + QIMG.mkdir(parents=True, exist_ok=True) + + con = sqlite3.connect(args.db) + cur = con.cursor() + existing = {r[0] for r in cur.execute("SELECT stem FROM questions")} + print(f"existing stems: {len(existing)}", flush=True) + + def ensure_ids(subject, chapter, subtopic): + cur.execute("SELECT id FROM chapters WHERE subject=? AND name=?", (subject, chapter)) + row = cur.fetchone() + if row: + ch_id = row[0] + else: + cur.execute("INSERT INTO chapters(subject,name) VALUES(?,?)", (subject, chapter)) + ch_id = cur.lastrowid + cur.execute("SELECT id FROM subtopics WHERE chapter_id=? AND name=?", (ch_id, subtopic)) + row = cur.fetchone() + if row: + return ch_id, row[0] + cur.execute("INSERT INTO subtopics(chapter_id,name) VALUES(?,?)", (ch_id, subtopic)) + return ch_id, cur.lastrowid + + imported = skipped = dl_fail = opt_img = 0 + diff_map = {"easy": "Easy", "medium": "Medium", "hard": "Hard"} + for _key, chap in data.items(): + for q in chap.question_dict.values(): + d = q.__dict__ + if d.get("type") != "mcq": + continue + co = d.get("correct_options") or [] + if len(co) != 1 or co[0] not in ("A", "B", "C", "D"): + continue + stem_raw = d.get("question") or "" + opts_raw = [o for o in (d.get("options") or []) if isinstance(o, dict)] + opt_txt = " ".join((o.get("content") or "") for o in opts_raw) + if " 900: + im = im.resize((900, int(h * 900 / w))) + im.save(webp, "WEBP", quality=80) + png.unlink() + converted += 1 + except Exception as e: + print(f"skip {png.name}: {e}") + continue + print(f"converted {converted} pngs to webp") + # rewrite DB references + n = 0 + for (qid, stem, a, b, c, d, sol) in cur.execute( + "SELECT id, stem, opt_a, opt_b, opt_c, opt_d, solution FROM questions"): + vals = [stem, a, b, c, d, sol] + changed = False + for i, v in enumerate(vals): + if v and "/static/qimg/" in v and ".png" in v: + nv = re.sub(r"(/static/qimg/[A-Za-z0-9_-]+)\.png", r"\1.webp", v) + if nv != v: + vals[i] = nv + changed = True + if changed: + cur.execute("UPDATE questions SET stem=?, opt_a=?, opt_b=?, opt_c=?, opt_d=?, solution=? WHERE id=?", + (vals[0], vals[1], vals[2], vals[3], vals[4], vals[5], qid)) + n += 1 + db.commit() + print(f"updated {n} question rows") + db.close() + +if __name__ == "__main__": + main() diff --git a/src/views.rs b/src/views.rs index af68ebe..4617ae9 100644 --- a/src/views.rs +++ b/src/views.rs @@ -25,8 +25,7 @@ pub fn rich(s: &str) -> String { const ALLOWED: &[&str] = &[ "br", "p", "sup", "sub", "b", "strong", "i", "em", "u", "ol", "ul", "li", "table", "tbody", "thead", "tr", "th", "td", "span", - ]; - // A `<` only starts a tag when followed by a letter, `/`, `!` or `?` + ]; // A `<` only starts a tag when followed by a letter, `/`, `!` or `?` // (i.e. `

`, `

`, `