No description
  • HTML 99.7%
  • Python 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-06 01:17:20 +02:00
app caching 2026-10-06 01:17:20 +02:00
scripts
tests hybrid fix 2026-10-05 00:01:01 +02:00
.dockerignore
.env.example
.gitignore
asgi.py
conftest.py
docker-compose.yml
docker-entrypoint.sh
Dockerfile retries 2026-10-05 23:13:37 +02:00
README.md mobile 2026-10-06 01:08:21 +02:00
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 w sessionStorage — 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 :active zamiast „przyklejonego” :hover; touch-action: manipulation eliminuje opóźnienie/zoom podwójnego tapnięcia.
  • Wysokości liczone w dvh (a nie vh) i odświeżane z visualViewport — 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-color dla 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): tabela datatab z konkretnymi datami spotkań, salami i godzinami; pominięte są dni wolne/święta już w źródle. Parsowane przez parse_sale_table (app/core/parsers.py) na listę Meeting. Gdy przedmiot nie ma opublikowanego terminarza — fallback na cykle z week.html.
  • calendary.html — kalendarium akademickie (na sztywno w app/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.html na 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