Skip to content

Add demand allocation scraper#4428

Draft
jloh02 wants to merge 5 commits into
masterfrom
codex/coursereg-history-scraper
Draft

Add demand allocation scraper#4428
jloh02 wants to merge 5 commits into
masterfrom
codex/coursereg-history-scraper

Conversation

@jloh02

@jloh02 jloh02 commented Jun 20, 2026

Copy link
Copy Markdown
Member

Summary

  • add a standalone scrapers/demand-allocation-scraper package for CourseReg vacancy and demand allocation PDFs
  • pin the Python PDF conversion dependencies in scrapers/demand-allocation-scraper/requirements.txt
  • support manual per-round PDF upload with incremental merge into courseRegHistory.json
  • match CourseRekt merge semantics for vacancy-only rows: zero registered demand and allocated slots equal to the vacancy PDF value
  • expose the static API contract at /v2/{acadYear}/semesters/{semester}/courseRegHistory.json in Swagger and NUS v2 types
  • add a website API URL helper for CourseReg history
  • document the maintenance flow and source PDF archive layout

Refs #4424

CourseRekt parity

  • generated all local PDF-derived outputs for AY21/22 through AY25/26, semesters 1 and 2
  • compared all 20 CourseRekt rendered matrices against generated courseRegHistory.json: 0 missing rows, 0 extra rows, 0 cell mismatches
  • verified 72 live CourseRekt UG/GD PDF links match the local archive by SHA-256
  • verified 36 CourseRekt repo vacancy PDFs match the local archive by SHA-256

Testing

  • pnpm --filter nusmods-demand-allocation-scraper check
  • /private/tmp/courserekt-venv/bin/python -m pip install --dry-run -r scrapers/demand-allocation-scraper/requirements.txt
  • /private/tmp/courserekt-venv/bin/python -m pip check
  • pnpm --filter nusmods test -- src/apis/nusmods.test.js (ran full website suite; unrelated existing date/weather assertions failed under local Node v24 run)
  • pnpm --filter nusmods exec vitest run src/apis/nusmods.test.js --coverage=false
  • pnpm --filter nusmods typecheck
  • pnpm --filter nusmods lint:code
  • pnpm --filter nus-v2 lint
  • pnpm --filter nus-v2 typecheck
  • pnpm --filter nus-v2 build
  • python3 -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('scrapers/nus-v2/swagger.yaml').read_text()); print('swagger yaml ok')"
  • PYTHONPYCACHEPREFIX=/private/tmp/nusmods-pycache python3 -m py_compile scrapers/demand-allocation-scraper/scripts/coursereg_pdf_to_csv.py scrapers/demand-allocation-scraper/scripts/import_courserekt_pdfs.py

@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
nusmods-export Ignored Ignored Preview Jun 20, 2026 9:46pm
nusmods-website Ignored Ignored Preview Jun 20, 2026 9:46pm

Request Review

@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 56.40%. Comparing base (988c6fd) to head (31a9723).
⚠️ Report is 262 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4428      +/-   ##
==========================================
+ Coverage   54.52%   56.40%   +1.87%     
==========================================
  Files         274      317      +43     
  Lines        6076     6975     +899     
  Branches     1455     1683     +228     
==========================================
+ Hits         3313     3934     +621     
- Misses       2763     3041     +278     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jloh02
jloh02 marked this pull request as ready for review June 20, 2026 21:38
@jloh02

jloh02 commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a standalone scrapers/demand-allocation-scraper package that ingests CourseReg vacancy and demand allocation PDFs, converts them to CSV via a Python/tabula-py pipeline, merges all rounds incrementally into a courseRegHistory.json, and exposes the result on the existing NUSMods v2 API surface. It also adds the Swagger schema, TypeScript types, a website URL helper, and a maintenance runbook entry.

  • New scraper package (scrapers/demand-allocation-scraper): TypeScript CLI + Python PDF-to-CSV conversion, with a full incremental-merge algorithm (mergeCourseRegHistories) that preserves previously-scraped rounds and backfills notAvailable for classes absent from a new round.
  • API contract additions: Swagger path /{acadYear}/semesters/{semester}/courseRegHistory.json, new CourseReg* schema components in swagger.yaml, matching types in scrapers/nus-v2/src/types/course-reg.ts, and a courseRegHistoryUrl static helper on NUSModsApi.
  • Utility scripts: import_courserekt_pdfs.py to seed the local archive from an existing CourseRekt checkout, and coursereg_pdf_to_csv.py wrapping tabula's batch conversion.

Confidence Score: 5/5

Safe to merge — all changes are additive and well-isolated to the new scraper package and its API surface.

The parsing and merge algorithms are thoroughly tested against known CourseRekt data. The two findings are minor: a silently-ignored option in run() that is not exercised by any current caller, and a loop-variable shadow that is semantically safe.

No files require special attention; the minor inconsistencies are confined to scrapers/demand-allocation-scraper/src/scraper.ts.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    actor Operator
    participant CLI as index.ts (CLI)
    participant Scraper as DemandAllocationScraper
    participant NUS as NUS CourseReg Website
    participant Python as coursereg_pdf_to_csv.py (tabula)
    participant FS as Filesystem (CSV / JSON)

    Operator->>CLI: pnpm dev semester acadYear --round N --pdfDir ...
    CLI->>Scraper: new DemandAllocationScraper(semester, academicYear, opts)
    CLI->>Scraper: run()

    alt --download flag
        Scraper->>NUS: fetch vacancy/UG/GD PDFs for each round
        NUS-->>Scraper: PDF bytes (or HTML bot-block, warn + skip)
        Scraper->>FS: write PDFs to pdfDir
    end

    alt pdfDir or download set
        Scraper->>Python: spawn coursereg_pdf_to_csv.py --pdf-dir --csv-dir
        Python->>FS: read PDFs, write CSVs (one per round x type)
    end

    loop for each round in rounds
        Scraper->>FS: read vacancy/round_N.csv
        Scraper->>FS: read ug/round_N.csv, gd/round_N.csv
        Scraper->>Scraper: parseDemandAllocationCsv + parseVacancyCsv
        Scraper->>Scraper: mergeCourseRegRound (full outer join on moduleCode:classNo)
    end

    Scraper->>Scraper: groupCourseRegHistory (group by module, studentType, classNo)

    alt --round specified (incremental mode)
        Scraper->>FS: readJsonIfExists courseRegHistory.json
        Scraper->>Scraper: mergeCourseRegHistories (replace selected rounds, backfill notAvailable)
    end

    Scraper->>FS: write courseRegHistory.json to nus-v2/data/acadYear/semesters/sem/
    Scraper-->>CLI: return histories[]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    actor Operator
    participant CLI as index.ts (CLI)
    participant Scraper as DemandAllocationScraper
    participant NUS as NUS CourseReg Website
    participant Python as coursereg_pdf_to_csv.py (tabula)
    participant FS as Filesystem (CSV / JSON)

    Operator->>CLI: pnpm dev semester acadYear --round N --pdfDir ...
    CLI->>Scraper: new DemandAllocationScraper(semester, academicYear, opts)
    CLI->>Scraper: run()

    alt --download flag
        Scraper->>NUS: fetch vacancy/UG/GD PDFs for each round
        NUS-->>Scraper: PDF bytes (or HTML bot-block, warn + skip)
        Scraper->>FS: write PDFs to pdfDir
    end

    alt pdfDir or download set
        Scraper->>Python: spawn coursereg_pdf_to_csv.py --pdf-dir --csv-dir
        Python->>FS: read PDFs, write CSVs (one per round x type)
    end

    loop for each round in rounds
        Scraper->>FS: read vacancy/round_N.csv
        Scraper->>FS: read ug/round_N.csv, gd/round_N.csv
        Scraper->>Scraper: parseDemandAllocationCsv + parseVacancyCsv
        Scraper->>Scraper: mergeCourseRegRound (full outer join on moduleCode:classNo)
    end

    Scraper->>Scraper: groupCourseRegHistory (group by module, studentType, classNo)

    alt --round specified (incremental mode)
        Scraper->>FS: readJsonIfExists courseRegHistory.json
        Scraper->>Scraper: mergeCourseRegHistories (replace selected rounds, backfill notAvailable)
    end

    Scraper->>FS: write courseRegHistory.json to nus-v2/data/acadYear/semesters/sem/
    Scraper-->>CLI: return histories[]
Loading

Reviews (2): Last reviewed commit: "Pin demand allocation Python dependencie..." | Re-trigger Greptile

Comment on lines +1 to +27
import type { ModuleCode, Semester } from './modules';

export type CourseRegStudentType = 'UG' | 'GD';

export type CourseRegRound = 0 | 1 | 2 | 3;

export type CourseRegSlotValue = number | 'notAvailable' | 'unlimited';

export type CourseRegRoundHistory = {
allocatedSlots: CourseRegSlotValue;
forecastedSlots: CourseRegSlotValue;
registered: number | null;
round: CourseRegRound;
};

export type CourseRegClassHistory = {
classNo: string;
rounds: Array<CourseRegRoundHistory>;
studentType: CourseRegStudentType;
};

export type CourseRegModuleSemesterHistory = {
acadYear: string;
classes: Array<CourseRegClassHistory>;
moduleCode: ModuleCode;
semester: Semester;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate type definitions across packages

CourseRegModuleSemesterHistory, CourseRegClassHistory, CourseRegRoundHistory, CourseRegSlotValue, CourseRegRound, and CourseRegStudentType are defined identically in both scrapers/demand-allocation-scraper/src/types.ts and this file. These two copies will drift independently as the format evolves. Consider exporting the types from one canonical location (e.g., the scraper package) and importing them in nus-v2, or pulling the shared contract into a shared workspace package.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scrapers/nus-v2/src/types/course-reg.ts
Line: 1-27

Comment:
**Duplicate type definitions across packages**

`CourseRegModuleSemesterHistory`, `CourseRegClassHistory`, `CourseRegRoundHistory`, `CourseRegSlotValue`, `CourseRegRound`, and `CourseRegStudentType` are defined identically in both `scrapers/demand-allocation-scraper/src/types.ts` and this file. These two copies will drift independently as the format evolves. Consider exporting the types from one canonical location (e.g., the scraper package) and importing them in `nus-v2`, or pulling the shared contract into a shared workspace package.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@jloh02
jloh02 marked this pull request as draft June 23, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant