Tracks the status of end-term school exams across Iranian cities (cancelled / online / in-school) by aggregating multiple news sources.
Two folders, run independently:
frontend/— Svelte + Vite single-page appbackend/— FastAPI + JSON-file storage
- Node.js 18+
- Python 3.10+
- VS Code (the workspace ships with recommended extensions and launch configs)
# Backend
cd backend
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
cd ..
# Frontend
cd frontend
npm install
cd ..The easiest way: open the folder in VS Code, then pick Run and Debug → "Run both (backend + frontend)" (or hit F5 on either individual config).
Or from two terminals:
# terminal 1
cd backend && source .venv/bin/activate && uvicorn app.main:app --reload
# terminal 2
cd frontend && npm run devThen open http://localhost:5173. Vite proxies /api/* to the backend on port 8000.
| Method | Path | Description |
|---|---|---|
| GET | /api/cities |
List status for all tracked cities |
| GET | /api/cities/{id} |
Status for one city |
| POST | /api/refresh |
Re-run the scrapers and update all cities |
Swagger UI at http://127.0.0.1:8000/docs. There's also api.http at the repo root if you have the REST Client extension.
exams/
├── frontend/
│ ├── src/
│ │ ├── App.svelte
│ │ ├── main.js
│ │ ├── app.css
│ │ ├── lib/api.js
│ │ └── components/
│ │ ├── CityCard.svelte
│ │ └── StatusFilter.svelte
│ ├── index.html
│ ├── vite.config.js # proxies /api -> :8000
│ └── package.json
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app + CORS + lifespan
│ │ ├── routes/exams.py # /api endpoints
│ │ ├── services/
│ │ │ ├── storage.py # JSON-file storage
│ │ │ └── news_scraper.py # source stubs + status inference
│ │ └── models/exam_status.py
│ ├── data/ # exam_status.json lives here at runtime
│ ├── requirements.txt
│ └── .env.example
├── .vscode/ # extensions, settings, launch, tasks
├── api.http
├── .editorconfig
└── README.md
Open backend/app/services/news_scraper.py and either fill in one of the stubs (IrnaStub, IsnaStub, MehrStub) or add a new NewsSource subclass:
class MyNewSource(NewsSource):
name = "My Source"
async def fetch_for_city(self, client, city):
r = await client.get("https://example.com/search", params={"q": f"امتحان {city.name}"})
# parse r.text with BeautifulSoup, return [Article(...)]
return []Then add an instance to the SOURCES list at the bottom of the same file. The aggregator handles parallel fetching, failures, and status inference automatically.
For better classification than keyword matching, replace infer_status with a smarter classifier (e.g. an LLM call or per-source regex).
- The JSON file is seeded with 20 major cities on first run; edit
DEFAULT_SEEDinstorage.pyto change the list. - To swap JSON for SQLite/Postgres later, only
app/services/storage.pyneeds to change — routes don't import the file directly. - CORS origins are set via
CORS_ORIGINSinbackend/.env.