#!/usr/bin/env python3 """Audit saved VJudge MHTML files without copying problem statements. The script reports the metadata needed by the course and verifies that each snapshot contains an embedded statement, rather than only the VJudge shell. It intentionally stores no statement text. """ from __future__ import annotations import argparse import html import json import re import sys from email import policy from email.parser import BytesParser from html.parser import HTMLParser from pathlib import Path class TextExtractor(HTMLParser): def __init__(self) -> None: super().__init__() self.parts: list[str] = [] def handle_data(self, data: str) -> None: if data.strip(): self.parts.append(data.strip()) def text(self) -> str: return " ".join(self.parts) def plain_text(fragment: str) -> str: parser = TextExtractor() parser.feed(fragment) return html.unescape(parser.text()) def decode_part(part) -> str: payload = part.get_payload(decode=True) or b"" charset = part.get_content_charset() or "utf-8" return payload.decode(charset, errors="replace") def parse_snapshot(path: Path) -> dict[str, object]: message = BytesParser(policy=policy.default).parsebytes(path.read_bytes()) snapshot_url = str(message.get("Snapshot-Content-Location", "")) subject = str(message.get("Subject", path.stem)) html_parts = [ part for part in message.walk() if not part.is_multipart() and part.get_content_type() == "text/html" ] main_part = next( ( part for part in html_parts if str(part.get("Content-Location", "")) == snapshot_url ), None, ) if main_part is None: raise ValueError("missing VJudge main HTML part") main_html = decode_part(main_part) match = re.search( r']*name=["\']dataJson["\'][^>]*>(.*?)', main_html, flags=re.DOTALL, ) if match is None: raise ValueError("missing dataJson metadata") metadata = json.loads(html.unescape(match.group(1))) official_keys = { str(item["key"]) for item in metadata.get("descBriefs", []) if item.get("mainOfficial") or (item.get("official") and item.get("type") == "main") } statement_candidates: list[tuple[bool, str]] = [] for part in html_parts: location = str(part.get("Content-Location", "")) if "/problem/description/" not in location: continue body = decode_part(part) key_match = re.search(r"/problem/description/(\d+)", location) is_official = bool(key_match and key_match.group(1) in official_keys) statement_candidates.append((is_official, body)) if not statement_candidates: raise ValueError("missing embedded statement HTML") statement_candidates.sort(key=lambda item: (item[0], len(item[1])), reverse=True) statement_html = statement_candidates[0][1] statement_text = plain_text(statement_html) properties = { str(item.get("title", "")): plain_text(str(item.get("content", ""))) for item in metadata.get("properties", []) } title = re.sub(r"\s+-\s+Virtual Judge$", "", subject) title = re.sub(r"\s+-\s+[^-]+\s+[^-]+$", "", title) has_input = bool(re.search(r"\bInput\b|输入", statement_text, re.IGNORECASE)) has_output = bool(re.search(r"\bOutput\b|输出", statement_text, re.IGNORECASE)) sample_count = len(re.findall(r'class=["\']vjudge_sample["\']', statement_html)) return { "file": path.as_posix(), "query": f"{metadata.get('oj', '?')}-{metadata.get('prob', '?')}", "title": title, "source": properties.get("source", ""), "difficulty": properties.get("difficulty", ""), "time_limit": properties.get("time_limit", ""), "memory_limit": properties.get("mem_limit", ""), "statement_chars": len(statement_text), "samples": sample_count, "has_input": has_input, "has_output": has_output, "official_statement": statement_candidates[0][0], "url": snapshot_url, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("root", nargs="?", default="problems") parser.add_argument("--json", action="store_true", dest="as_json") args = parser.parse_args() paths = sorted(Path(args.root).rglob("*.mhtml")) if not paths: print(f"no .mhtml files found under {args.root}", file=sys.stderr) return 2 rows: list[dict[str, object]] = [] errors: list[str] = [] for path in paths: try: rows.append(parse_snapshot(path)) except Exception as exc: # keep auditing the remaining snapshots errors.append(f"{path}: {exc}") if args.as_json: print(json.dumps(rows, ensure_ascii=False, indent=2)) else: print("query\ttitle\tdifficulty\tsource\tstatement\tsamples\tofficial") for row in rows: statement = "ok" if row["has_input"] and row["has_output"] else "incomplete" official = "yes" if row["official_statement"] else "fallback" print( f"{row['query']}\t{row['title']}\t{row['difficulty'] or '-'}\t" f"{row['source'] or '-'}\t{statement}\t{row['samples']}\t{official}" ) for error in errors: print(error, file=sys.stderr) incomplete = [ row for row in rows if not row["has_input"] or not row["has_output"] or row["statement_chars"] < 200 ] return 1 if errors or incomplete else 0 if __name__ == "__main__": raise SystemExit(main())