- HTML 99.7%
- Python 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| app | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| asgi.py | ||
| conftest.py | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
lecture_chooser
Interaktywny kreator planu zajęć: kalendarz tygodniowy zbudowany z rozkładu zajęć
połączony z planem studiów, który dostarcza
podziału na kategorie/serie i ograniczeń wyboru (settings.json).
Dane pochodzą wyłącznie ze scrapingu portalu e-KUL (scripts/scrape.py):
plan studiów, rozkład zajęć, terminarze przedmiotów (konkretne daty spotkań)
oraz kalendarium akademickie (dni wolne, daty semestru).
Stack
- backend: Quart + BeautifulSoup (parsowanie tabel S4A) + itsdangerous (podpisane ciasteczko wyboru)
- frontend: vanilla JS + CSS (bez frameworków), responsywny, jasny/ciemny motyw
Obsługa telefonu (mobile)
Frontend jest przystosowany do ekranów dotykowych (testowany na Firefox Android):
- Topbar: zwija się do 2 linii — tytuł, przyciski akcji, a pod nimi pasek statusu na pełną szerokość.
- Kalendarz: poziomy scroll z przypiętą kolumną godzin (
position: sticky), nazwa dnia z datą w trybie „Tydzień obecny”, kafelki z zawijaną/ucinaną treścią. Poziomy scroll kalendarza nie przerzuca przewijania na stronę (overscroll-behavior-x: contain), a pozycja scrolla jest zapamiętywana wsessionStorage— po obrocie telefonu widok wraca na to samo miejsce. - Picker: pod kalendarzem; elementy listy mają powiększone pola dotyku (~44 px), a pasek statusu planu (sticky w topbarze) jest widoczny także podczas scrollowania listy.
- Pop-up kalendarium: na wąskich ekranach pełnoekranowy; menu „Pobierz” otwiera się jako bottom-sheet przyklejony do dołu ekranu (nie wypada poza viewport).
- Dotyk: opcja „Drukuj (1 strona)” jest ukryta na urządzeniach dotykowych (druk A4 ma sens
tylko na desktopie); przyciski reagują stanem
:activezamiast „przyklejonego”:hover;touch-action: manipulationeliminuje opóźnienie/zoom podwójnego tapnięcia. - Wysokości liczone w
dvh(a nievh) i odświeżane zvisualViewport— zwijanie paska adresu w Firefox/Chrome Android nie przycina treści. - „Udostępnij”: na telefonie otwiera systemowe okno udostępniania; gdy przeglądarka je ogranicza (np. bez https) — link spada do schowka z potwierdzeniem toastem.
- Pasek systemowy Android ma kolor motywu (
theme-colordla schematu jasnego i ciemnego).
Uruchomienie lokalne
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
quart run # tryb deweloperski
# lub produkcyjnie:
hypercorn --bind 0.0.0.0:8000 app:app
Dane planu i rozkładu pochodzą wyłącznie ze scrapingu e-KUL —
python scripts/scrape.py course --wid 5368 --kid 6089 --save zapisuje tabele
do app/data/scraped/{kid}/{etap}/ (plan.html, week.html, meta.json),
a stan zbierania do course.json + catalog.json. Pełny zbiór wszystkich
kierunków zbiera python scripts/scrape.py bootstrap (kilka godzin, ~1000
żądań; pauzy i przerwy co 50 żądań z settings["scraping"]["bootstrap"]).
Przerwany bieg (Ctrl-C) wznawia się od miejsca stopu — kierunki kompletne
na dysku są pomijane bez żądań; --wid/--kid ograniczają zbiór.
Kierunki ignorujące etap=0 (np. studia podyplomowe) mają fallback:
plany pobierane per semestr, po 1 żądaniu.
Zmienne środowiskowe
Aplikacja czyta konfigurację z pliku .env w katalogu projektu (format
KLUCZ=WARTOŚĆ, komentarze #, cudzysłowy wokół wartości zjadane) oraz
ze zmiennych środowiskowych (te mają pierwszeństwo). Dostępne zmienne:
| zmienna | wymagana | opis |
|---|---|---|
SECRET_KEY |
nie | sekret do podpisywania ciasteczka wyboru (domyślnie dev-only-insecure-secret — zmień w produkcji) |
EKUL_LOGIN |
nie | login e-KUL; włącza usługę odświeżania (bez niej worker nie startuje) |
EKUL_PASSWORD |
nie | hasło e-KUL (paruje z EKUL_LOGIN) |
Przykładowy .env:
SECRET_KEY=zmień-mnie-na-losowy-ciąg
EKUL_LOGIN=jan.kowalski@kul.lublin.pl
EKUL_PASSWORD=moje-hasło
Ustawienia scrapingu (settings["scraping"])
Sekcja scraping w app/data/settings.json kontroluje wszystkie tempa,
limity i częstotliwości odświeżania (bez dotykania kodu):
| klucz | domyślnie | opis |
|---|---|---|
base_url |
https://e.kul.pl |
bazowy URL portalu e-KUL |
request_delay |
[0.1, 0.5] |
zakres [min, max] sekund pauzy między żądaniami (losowy jitter) |
request_retries |
3 |
dodatkowe próby żądania HTTP przy błędach transportu (zerwane połączenie, timeout); pauza między próbami z request_delay |
task_retries |
2 |
ile razy nieudane odświeżenie kierunku wraca do kolejki (po task_retries nieudanych ponowieniach zadanie odpada) |
daily_requests |
100 |
globalny limit odświeżeń kierunków dziennie (ręczne odświeżanie) |
per_course_daily |
10 |
limit odświeżeń per kierunek dziennie |
per_course_cooldown_minutes |
5 |
cooldown między odświeżeniami tego samego kierunku (minuty) |
weekly_refresh |
{"weekday":"sat","hour":4} |
dzień i godzina cyklu tygodniowego |
login_counts_towards_limit |
true |
czy logowanie e-KUL liczy się do limitu dobowego |
bootstrap.request_delay |
[0.1, 0.5] |
tempo bootstrapu i cyklu tygodniowego (krótsze pauzy) |
bootstrap.batch_size |
50 |
przerwa co N żądań dla bootstrapu i cyklu tygodniowego |
bootstrap.batch_pause |
0 |
długość przerwy w bootstrapie i cyklu tygodniowym (sekundy) |
Terminarze przedmiotów (sales/{zid}.html) zbierane są w normalnym przepływie
— po planie i rozkładzie etapu, po jednym żądaniu fetch_sale per zid,
z pominięciem zidów z kompletem na dysku (wznawialność). Nie wymaga osobnego
przełącznika ani ustawienia; limity ręczne liczą odświeżenia kierunków
(nie żądania HTTP), więc terminarze nie zwiększają zużycia limitu —
jedno odświeżenie kierunku = 1 do limitu, niezależnie od liczby żądań.
Cykl tygodniowy (scheduler) używa tempa z sekcji bootstrap — krótsze pauzy
i przerwa co 50 żądań, ale bez limitów dobowych/per-kierunek/cooldown
(jak bootstrap, nie jak odświeżanie na żądanie).
Usługa odświeżania (worker)
Aplikacja może sama odświeżać dane e-KUL w tle — kolejka FIFO per
kierunek z limitami z settings["scraping"] (dobowy limit żądań,
limit odświeżeń per kierunek, cooldown, deduplikacja zadań).
Worker startuje tylko przy zmiennych EKUL_LOGIN/EKUL_PASSWORD
w środowisku; pusta kolejka nie wysyła żadnych żądań, więc samo
uruchomienie aplikacji z danymi logowania jest bezpieczne. Stan
(liczniki dobowe, timestampy ostatniego odświeżenia, zawartość
kolejki) trwa w app/data/scraped/state.json (zapis atomowy;
reset liczników przy zmianie dnia). Zadania, na które zabrakło
limitu, zostają w kolejce na kolejny dzień.
Wybór kierunku odbywa się w UI — pasek pod nagłówkiem (wydział → kierunek →
semestr) pobiera /api/dataset?kid=&etap= i zapamiętuje kierunek w ciasteczku
(wybór zajęć i tydzień użytkownika przetrwają; po powrocie na stary kierunek
wybór wraca). Semestry bez danych na dysku są wyszarzone („· brak danych”).
Przycisk „Odśwież” kolejkuje pobranie całego kierunku z e-KUL (limity jak
wyżej), a po jego wykonaniu strona sama przeładowuje nowy dataset.
Domyślnie startuje informatyka II st., semestr 1 (kid=6089, etap=1).
Ustawienia (settings.json) leżą w katalogu app/data/.
Ograniczenia wyboru (notki w tabeli planu)
Jedynym źródłem ograniczeń są notki w wierszach pod nagłówkami sekcji w tabeli
planu studiów rozpoznawane przez parse_note (app/core/parsers.py):
- „do wyboru 2 przedmioty” / „(należy wybrać 2 przedmioty)” — dokładnie 2 przedmioty,
- „(należy wybrać 120 godz., 12 pkt. ECTS)” — dokładnie 120 godzin i 12 pkt. ECTS,
- „(należy kontynuować wybrane seminarium)” — dokładnie 1 przedmiot,
- „do wyboru 1 specjalność” — notka serii: wybierz 1 specjalność,
- sekcja bez notki — bez ograniczeń liczby („brak limitu”).
settings.json przechowuje ustawienia scrapingu (sekcja scraping).
Zakres semestru (data_first/data_last) jest wyliczany z danych
(app/core/dataset.py): rozpoczęcie zajęć dydaktycznych z kalendarium
(app/data/calendary.html) zaokrąglone W GÓRĘ do najbliższego poniedziałku
(pierwszy pełny tydzień — zajęcia w „tygodniu zerowym" przed oficjalnym
startem nie przesuwają początku semestru), w razie braku kalendarium
min/max daty spotkań z terminarzy przedmiotów (sales/{zid}.html),
ostatecznie stała DEFAULT_SEMESTER_START w kodzie. Liczba tygodni
wyliczana z data_last; w ostatecznym fallbacku stała
DEFAULT_SEMESTER_WEEKS (w ics.py).
API
| metoda | ścieżka | opis |
|---|---|---|
| GET | / |
strona główna (kalendarz + picker) |
| GET | /api/health |
status aplikacji + usługi odświeżania (refresh_service, queue_length, requests_today, last_weekly); healthcheck Dockera |
| GET | /api/catalog |
drzewo wydziały → kierunki (etapy, available_etaps z danymi na dysku, last_refreshed) |
| GET | /api/dataset |
plan + rozkład + ograniczenia (JSON); ?kid=&etap= przełącza kierunek (zapamiętywany w ciasteczku — bez parametrów: ciasteczko → kierunek domyślny) |
| POST | /api/courses/<kid>/refresh |
kolejkuje odświeżenie kierunku: 202 dodano/już w kolejce, 429 cooldown/limit per-kierunek (retry_after_minutes), 503 dzienny limit lub usługa wyłączona (bez EKUL_LOGIN/EKUL_PASSWORD), 404 nieznany kierunek |
| GET | /api/selection |
aktualny wybór z ciasteczka + status walidacji |
| PUT | /api/selection |
zapis wyboru {selected: [zid], week: 1..4, kid?, etap?} (ustawia ciasteczko); 409 przy wyborze naruszającym limity |
| DELETE | /api/selection |
wyczyszczenie wyboru i ciasteczka |
| GET | /api/calendary |
kalendarium akademickie: {events: [...], semester: {...}} (wydarzenia z free: bool, daty rozpoczęcia semestru) |
| GET | /api/selection.ics |
kalendarz iCalendar (parametr z — wybór z linku, bez cookies) |
| GET | /api/selection.pdf |
wektorowy PDF planu (z, view=sum|A|B|w1..w4); 503, gdy serwer nie ma Playwright/Chromium — wtedy strona sama generuje PDF w przeglądarce |
Wybór jest pamiętany w podpisanym ciasteczku (httponly) — odświeżenie strony przywraca ostatni stan. Kolizje godzinowe są raportowane jako ostrzeżenia.
Przycisk „Udostępnij” kopiuje link do planu (/?z=...&view=...; na telefonie
otwiera natywne okno udostępniania). Plan otwarty z takiego linku nie nadpisuje
własnego wyboru odbiorcy — ten pozostaje w jego ciasteczku.
Źródła danych
Dane pochodzą wyłącznie ze scrapingu portalu e-KUL (scripts/scrape.py):
plan.html— plan studiów (kategorie, serie, ograniczenia wyboru, notki)week.html— rozkład zajęć (godziny, sale, nauczyciele, cykle T/A/B/C/D)sales/{zid}.html— terminarze przedmiotów (v3): tabeladatatabz konkretnymi datami spotkań, salami i godzinami; pominięte są dni wolne/święta już w źródle. Parsowane przezparse_sale_table(app/core/parsers.py) na listęMeeting. Gdy przedmiot nie ma opublikowanego terminarza — fallback na cykle zweek.html.calendary.html— kalendarium akademickie (na sztywno wapp/data/): wydarzenia z datami i flagą dnia wolnego (heurystyka: frazy „dzień wolny”, „dni wolne", „ferie"); daty rozpoczęcia zajęć dydaktycznych semestru. Udostępniane przez/api/calendary; wyświetlane w modalu kalendarium.
Tryby kalendarza
Kalendarz ma dwa tryby (przełącznik obok przełącznika widoków):
- „Tydzień ogólny" — widok cykliczny (sum/A/B/w1–w4), bez zmian względem v2.
Renderuje wpisy z
week.htmlna podstawie cykli (T/A/B/C/D). - „Tydzień obecny" — widok z konkretnymi datami z terminarzy (
sales/{zid}.html). Tydzień wyliczany z dzisiejszej daty na podstawie zakresu danych kierunku; nawigacja tydzień w przód do końca danych (bez przewijania poza dane), wstecz tylko do bieżącego tygodnia. Wpisy bez terminarza — fallback na cykle.
Nakładka dni wolnych (tryb „Tydzień obecny")
Dni z free: true z kalendarium (/api/calendary) dostają w kalendarzu
przekreślenie kolumny dnia + badge z etykietą (np. „Ferie — Boże Narodzenie");
tooltip z pełną treścią wpisu. Dni z wydarzeniem bez wolnego (uroczystości) —
subtelniejszy znacznik informacyjny.
Testy
pip install -r requirements-dev.txt
pytest
Docker
docker-compose.yml używa image: lecture-chooser:latest — obraz znajduje się
w moim dockerhub (https://hub.docker.com/repository/docker/qbsoon/lecture-chooser/general)
można też zbudować własnoręcznie (docker build -t lecture-chooser .). Wolumen
./app/data/scraped zapewnia trwałość danych między restartami. Healthcheck
odpytuje /api/health co 30 s. Zmienne EKUL_LOGIN/EKUL_PASSWORD odkomentuj
w sekcji environment docker-compose.yml, aby włączyć usługę odświeżania.
Dockerfile instaluje Playwright + Chromium (serwerowy PDF) oraz fonts-liberation
(polskie znaki w system-ui). Bez tego /api/selection.pdf zwraca 503,
a „PDF (plik)” spada na wersję generowaną w przeglądarce (kalendarz jako
obraz osadzony w PDF).
docker build -t lecture-chooser .
docker compose up -d