Files
thptqg/docs/system-architecture.md
T
tiennm99 00a08d5fab refactor(parser): remove the Rust crate now that Go is at parity
The Go parser has matched the Rust one field-by-field across all four datasets,
so the Rust crate is retired and CI builds the Go binary instead.

crawl-baotintuc.js moves to go-parser/scripts/ — it is the only mechanism for
refreshing data/2017 and has a documented runbook. check-duplicates.js and
diff-datasets.js are dropped: both had been broken since before the repo was
unified, and neither had a caller. db-stats.js and verify-parity.js are dropped
as superseded by differential-parity.mjs, which compares more and cannot
silently skip a dataset.

The reader-fidelity oracle is kept and marked frozen. It was produced by the
Rust reader so it can no longer be regenerated, but it still fails if any single
cell of any of the 299 input files reads differently.

Source comments cite the original Rust by file and line; those paths resolve at
tag pre-go-parser-removal, recorded in go-parser/README.md.
2026-08-13 20:38:25 +07:00

7.0 KiB
Raw Blame History

System Architecture

Static site, no backend. The browser downloads a compressed SQLite file at boot and every query runs locally via sql.js (SQLite compiled to WebAssembly).

One frontend, one parser, one schema, four datasets.

Data flow

data/<id>/*.xls(x)
        │
        ▼  parser/  (Rust, one binary, one config per dataset)
   .build/public/db/<id>.db
        │
        ▼  gzip -9   (no -k: the raw file does not survive)
   .build/public/db/<id>.db.gz
        │
        ▼  vite build   (publicDir = .build/public)
   dist/
        │
        ▼  scripts/assemble-site.js
   _site/   →  GitHub Pages
        │
        ▼  browser
   sql.js (WASM) opens the .db → queries run client-side

The dataset id

One identifier ties the whole pipeline together:

data/2017-old/  →  go-parser/configs/2017-old.yml  →  db/2017-old.db.gz  →  /thptqg/2017-old/

src/datasets.js declares the four ids once. The frontend, the database build (go-parser/scripts/build-db.js) and the site assembly all import that list, so adding a dataset means adding one entry and one config file.

id Exam Rows Source
2016 2016 877,461 Bộ GD&ĐT
2017 2017 861,068 baotintuc.vn
2017-old 2017 847,348 pre-refresh archive
2017-old2 2017 679,764 corrected re-export

Canonical schema

Defined once in go-parser/internal/schema/schema.go — DDL, INSERT, column order and the 16 subject regexes. The four TOML configs carry no SQL at all, only per-dataset parse rules. Config parsing uses deny_unknown_fields, so a leftover [schema] block fails loudly instead of looking effective while schema.rs drives the build.

CREATE TABLE student (
  so_bao_danh   TEXT PRIMARY KEY,   -- 2017: 8 digits; 2016: 9 digits or a
                                    -- 2-4 letter cluster code then digits
  ho_ten        TEXT NOT NULL,
  ho_ten_ascii  TEXT NOT NULL,      -- NFD-stripped lowercase, for accent-insensitive search
  ngay_sinh     TEXT,               -- dd/mm/yyyy
  ten_cum_thi   TEXT,               -- 2016 only
  gioi_tinh     TEXT,               -- 2016 only
  toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
  lich_su, dia_ly, gdcd, khxh,
  tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung   REAL
);
CREATE INDEX idx_ho_ten       ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
CREATE INDEX idx_ten_cum_thi  ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;

Every dataset gets all 22 columns; ones it has no data for are NULL, costing about a byte per row. khtn, khxh and gdcd are empty on 2016; ten_cum_thi and gioi_tinh are empty on the 2017 datasets.

idx_ten_cum_thi is partial, so it holds zero entries where the column is always NULL.

Routing

URLs are flat, one segment per dataset, and the segment is the id:

/thptqg/            hub
/thptqg/2016/
/thptqg/2017/
/thptqg/2017-old/
/thptqg/2017-old2/

src/router.js is an exact match on that segment. The nested form used before (/thptqg/2017/old/) would have needed longest-prefix matching, since it also starts with /thptqg/2017/. Those two legacy URLs still resolve: the router rewrites them to the flat equivalent with history.replaceState, preserving the query string so ?q= deep links survive.

A single Vite build emits one index.html. Because base is absolute (/thptqg/), that file references /thptqg/assets/... regardless of the directory it is served from, so the assemble step copies it to every route and each URL is a real static file. No SPA 404-fallback redirect is used — the usual hack rewrites URLs and would interfere with the deep links.

Serving both exam years without branching

No component contains a per-dataset conditional. Two mechanisms do the work:

  • All-NULL columns are hidden. score-table.jsx drops any column where every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức / Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga.
  • Incomplete admission blocks are skipped. computeBlocks() only returns a block when the student has all three subjects, so one block list covers both years: GDCD blocks self-exclude on 2016, German and Japanese blocks self-exclude wherever those languages were not sat.

Anything genuinely per-dataset — title, source, database size, search examples, SQL presets — lives in src/datasets.js.

Exam ID formats

src/lib/query-mode.js decides whether a query is an exam ID or a name, and is shared by App.jsx and search-form.jsx (they previously held separate copies and had drifted apart on exactly this rule).

Form Example Where
8 digits 49008235 2017 — first two digits are the province
9 digits with leading zero 017006021 2016
2-4 letters then digits BAL000001 2016 — exam cluster code

The letter-prefixed form is the majority case for 2016: 616,593 of 877,461 candidates (70.3%). Letter prefixes are upper-cased before lookup, so bal000001 resolves.

Score tiers

Six-level ladder in scoreTier() (src/lib/admission-blocks.js), paired with a symbol so meaning is never colour-only.

Tier Range Vietnamese
common ≤ 1 Điểm liệt
uncommon < 5 Chưa đạt
rare 5–6.5 Trung bình
epic 6.5–8 Khá
legendary 8–9 Giỏi
prismatic 9–10 Xuất sắc

Admission blocks

Vietnamese universities admit on three-subject combinations (khối thi). src/lib/admission-blocks.js lists the blocks computable from this schema (A00–A11, B00–B08, C00–C20, D01–D15, plus D05/D06 for German and Japanese). computeBlocks(student) returns those where all three scores exist, sorted by total descending.

Design decisions

Concern Choice Rationale
Storage Static SQLite file No backend; the datasets are frozen
Compression gzip in CI, DecompressionStream in the browser Native API, no extra library
WASM hosting sql.js.org CDN Smaller self-hosted artifact
Diacritics search Pre-computed ho_ten_ascii LOWER(REPLACE(...)) at query time defeats the index
SQL safety Leading-keyword allowlist sql.js is in-memory so writes cannot persist; the allowlist prevents confusion
Row caps 100 (lookup), 1000 (SQL) Keeps DOM render sizes reasonable
Routing Hand-rolled, ~20 lines Five static routes do not justify a router dependency

Risks and limitations

  • Database size. 38–48 MB gzipped per dataset; slow links wait, mitigated by a progress bar.
  • Browser memory. The full database lives in RAM; older mobile devices may run out.
  • sql.js.org dependency. If that CDN is unreachable, the WASM fails to load. Self-hosting sql-wasm.wasm and updating SQL_WASM_URL in use-sqlite.js is the fix.
  • Excel format drift. A new source file with an unseen header layout needs a new branch in format_detect_2016.rs or a new config.