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"?(?:col|colgroup)[^>]*>", "", 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. ``, `
`, `