Fotos: Gesichtssuche FH-weit (Studierende + Lektoren) als Job mit Live-Progress, Person-Info-Seiten, Scope-Auswahl
This commit is contained in:
+99
-14
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user