Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl

This commit is contained in:
Nicolas Gruber
2026-10-19 14:43:09 +02:00
parent 567bcb40ee
commit 6763faf9c1
7 changed files with 405 additions and 72 deletions
+99 -14
View File
@@ -1,6 +1,7 @@
"""CIS API client for FH-Burgenland (basic auth, HTML/iCal scraping)."""
import json
import re
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
@@ -14,6 +15,7 @@ from bs4 import BeautifulSoup
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
PERSON_ID_CACHE = DATA_DIR / "person_ids.json"
PHOTO_DIR = DATA_DIR / "photos"
_FILE_LOCK = threading.Lock()
TZ = timezone(timedelta(hours=1)) # Europe/Vienna winter; events carry their own tz, we just parse naive
@@ -114,6 +116,9 @@ class CISClient:
self.s.headers["User-Agent"] = "Mozilla/5.0 (cis-enhanced)"
self._meta_cache: Optional[dict] = None
self._meta_ts = 0.0
self._benutzer: Optional[list[dict]] = None
self._benutzer_ts = 0.0
self._profile_cache: dict = {}
# ---------- metadata (stg list, semesters, rooms) ----------
def meta(self, max_age: int = 3600) -> dict:
@@ -218,14 +223,15 @@ class CISClient:
uid = (uid or "").strip()
if not uid:
return ""
cache: dict = {}
if PERSON_ID_CACHE.exists():
try:
cache = json.loads(PERSON_ID_CACHE.read_text())
except Exception:
cache = {}
if uid in cache:
return cache[uid]
with _FILE_LOCK:
cache: dict = {}
if PERSON_ID_CACHE.exists():
try:
cache = json.loads(PERSON_ID_CACHE.read_text())
except Exception:
cache = {}
if uid in cache:
return cache[uid]
try:
html = self._get(f"{self.base}/private/profile/index.php?uid={uid}")
except requests.HTTPError:
@@ -234,12 +240,18 @@ class CISClient:
if not m:
return ""
pid = m.group(1)
cache[uid] = pid
try:
PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True)
PERSON_ID_CACHE.write_text(json.dumps(cache, indent=0))
except OSError:
pass
with _FILE_LOCK:
if PERSON_ID_CACHE.exists():
try:
cache = json.loads(PERSON_ID_CACHE.read_text())
except Exception:
cache = {}
cache[uid] = pid
try:
PERSON_ID_CACHE.parent.mkdir(parents=True, exist_ok=True)
PERSON_ID_CACHE.write_text(json.dumps(cache, indent=0))
except OSError:
pass
return pid
def photo_bytes(self, person_id: str, max_age: int = 7 * 86400) -> Optional[tuple[bytes, str]]:
@@ -310,6 +322,79 @@ class CISClient:
except Exception:
return []
# ---------- Benutzer (alle Studierenden + Lektoren) ----------
def benutzer_all(self, max_age: int = 6 * 3600) -> list[dict]:
"""Alle aktiven Benutzer (Studierende + Lektoren) via CIS-Autocomplete.
Substring-Suche über Vokale liefert die Vollmenge."""
if self._benutzer and time.time() - self._benutzer_ts < max_age:
return self._benutzer
merged: dict[str, dict] = {}
for ch in "aeiou":
try:
r = self.s.get(f"{self.base}/private/lvplan/lvplan_autocomplete.php",
params={"autocomplete": "benutzer", "term": ch}, timeout=60)
t = r.text.strip()
if not t:
continue
for d in r.json():
uid = str(d.get("uid") or "").strip()
if not uid:
continue
merged[uid] = {"uid": uid,
"vorname": (d.get("vorname") or "").strip(),
"nachname": (d.get("nachname") or "").strip(),
"mitarbeiter_uid": (d.get("mitarbeiter_uid") or "").strip()}
except Exception:
continue
self._benutzer = list(merged.values())
self._benutzer_ts = time.time()
return self._benutzer
# ---------- Profilseite ----------
def profile(self, uid: str, max_age: int = 3600) -> Optional[dict]:
"""Profilseite parsen (StudentIn oder MitarbeiterIn)."""
uid = (uid or "").strip()
if not uid:
return None
hit = self._profile_cache.get(uid)
if hit and time.time() - hit[0] < max_age:
return hit[1]
try:
html = self._get(f"{self.base}/private/profile/index.php?uid={uid}")
except requests.HTTPError:
return None
t = re.sub(r"<[^>]+>", " ", html)
t = re.sub(r"\s+", " ", t)
def _pick(pattern: str, group: int = 1) -> str:
m = re.search(pattern, t)
return m.group(group).strip() if m else ""
role = ""
for cand in ("StudentIn", "MitarbeiterIn Extern", "MitarbeiterIn Intern", "MitarbeiterIn"):
if re.search(rf"\b{cand}\b", t):
role = cand
break
m = re.search(r"bild\.php\?src=person&person_id=(\d+)", html)
prof = {
"uid": uid,
"role": role,
"person_id": m.group(1) if m else "",
"username": _pick(r"Username:\s*(\S+)"),
"titel": _pick(r"Titel:\s*(.*?)\s*Vorname:").strip(),
"vorname": _pick(r"Vorname:\s*(.*?)\s*Nachname:"),
"nachname": _pick(r"Nachname:\s*(.*?)\s*Postnomen:"),
"postnomen": _pick(r"Postnomen:\s*(.*?)\s*(?:Geburtsdatum:|Kurzzeichen:|Studiengang:|eMail)"),
"kurzzeichen": _pick(r"Kurzzeichen:\s*(\S+)"),
"mail": _pick(r"Intern:\s*([^\s]+@[^\s]+)"),
"mail_alias": _pick(r"Alias:\s*([^\s]+@[^\s]+)"),
"stg": _pick(r"Studiengang:\s*(.*?)\s*Semester:"),
"semester": _pick(r"Semester:\s*(\d+)"),
"verband": _pick(r"Verband:\s*(\S+)"),
}
if not prof["nachname"] and not prof["vorname"]:
prof = None
self._profile_cache[uid] = (time.time(), prof)
return prof
# ---------- Verbände pro StG/Semester ----------
def verbands_for(self, kz: str, sem: str) -> list[str]:
fallback = ["V", "B", "G", "A", "C", "D", "E", "F"]