commit d78668ce3a10da9558bb806767b05e272fe0054e Author: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Mon Mar 2 14:21:02 2026 +0400 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5abf640 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# kspsuti-teacher-schedule + Teacher's schedule of KS PSUTI diff --git a/app.py b/app.py new file mode 100644 index 0000000..1960d71 --- /dev/null +++ b/app.py @@ -0,0 +1,274 @@ +from flask import Flask, render_template, jsonify, request +import requests +from bs4 import BeautifulSoup +import re +import yaml +import os + +app = Flask(__name__) + +# ── Загрузка конфига ───────────────────────────────────────────────────────── +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.yaml") + +def load_config(): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + +config = load_config() +BASE_URL = config["base_url"] +DEFAULT_TEACHER = str(config.get("default_teacher", "")) +TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])} + + +# ── Парсер ─────────────────────────────────────────────────────────────────── +def fetch_schedule(obj=None, wk=None): + if obj is None: + obj = DEFAULT_TEACHER + + params = {"mn": "3", "obj": obj} + if wk: + params["wk"] = wk + + try: + resp = requests.get(BASE_URL, params=params, timeout=10) + resp.encoding = "utf-8" + html = resp.text + except Exception as e: + return {"error": str(e), "days": [], "week_range": "", "prev_wk": None, "next_wk": None} + + return parse_schedule(html) + + +def parse_schedule(html): + soup = BeautifulSoup(html, "lxml") + result = { + "days": [], + "week_range": "", + "prev_wk": None, + "next_wk": None, + "error": None + } + + # Диапазон недели + for td in soup.find_all("td"): + text = td.get_text(strip=True) + if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text): + result["week_range"] = text + break + + # Ссылки на соседние недели + seen_wk = set() + for a in soup.find_all("a", href=True): + href = a["href"] + if "wk=" not in href: + continue + wk_val = re.search(r"wk=(\d+)", href) + if not wk_val: + continue + wk_num = int(wk_val.group(1)) + if wk_num in seen_wk: + continue + seen_wk.add(wk_num) + text = a.get_text(strip=True).lower() + if "предыдущая" in text and result["prev_wk"] is None: + result["prev_wk"] = wk_num + elif "следующая" in text and result["next_wk"] is None: + result["next_wk"] = wk_num + + # Дни недели + day_anchors = soup.find_all("a", class_="t_wth") + + for anchor in day_anchors: + day_text = anchor.get_text(strip=True) + m = re.match(r"(\w+)\s*(\d{2}\.\d{2}\.\d{4})/(\d+)\s+неделя", day_text) + if not m: + continue + + day_name, day_date, week_num = m.group(1), m.group(2), m.group(3) + + # Находим родительскую таблицу с парами + parent_table = anchor + for _ in range(10): + parent_table = parent_table.parent + if parent_table and parent_table.name == "table" and parent_table.get("cellpadding") == "1": + break + + lessons = [] + if parent_table: + for row in parent_table.find_all("tr"): + tds = row.find_all("td", recursive=False) + if len(tds) != 4: + continue + + num_td = tds[0].get_text(strip=True) + time_td = tds[1].get_text(strip=True) + subj_td = tds[2] + room_td = tds[3].get_text(strip=True) + + if not re.match(r"^\d+$", num_td): + continue + + info = {"subject": "", "group": "", "group_short": "", "lesson_type": "", "location": ""} + + bold = subj_td.find("b") + if bold: + info["subject"] = bold.get_text(strip=True) + + font_green = subj_td.find("font", class_="t_green_10") + if font_green: + info["location"] = font_green.get_text(strip=True) + + raw = "" + if bold: + for node in bold.next_siblings: + if hasattr(node, "name"): + if node.name == "font": + break + if node.name == "br": + continue + raw += node.get_text(strip=True) + else: + raw += str(node).strip() + raw = raw.strip() + + if raw: + info["group"] = raw + m_grp = re.search(r'\(([^)]+)\)', raw) + if m_grp: + info["group_short"] = m_grp.group(1) + + after = raw[raw.find(")")+1:].strip() if ")" in raw else "" + if after: + unwrapped = re.sub(r'^\((.+)\)$', r'\1', after.strip()) + inner = re.search(r'\(([^()]+)\)\s*$', unwrapped) + info["lesson_type"] = inner.group(1) if inner else unwrapped + + lessons.append({ + "num": num_td, + "time": time_td, + "subject": info["subject"], + "group": info["group"], + "group_short": info["group_short"], + "lesson_type": info["lesson_type"], + "location": info["location"], + "room": room_td, + "has_class": bool(info["subject"]) + }) + + result["days"].append({ + "name": day_name, + "date": day_date, + "week_num": week_num, + "lessons": lessons, + "has_classes": any(l["has_class"] for l in lessons) + }) + + return result + + + +# ── Кэш списка преподавателей ──────────────────────────────────────────────── +_teachers_cache = {"data": None, "ts": 0} +CACHE_TTL = 3600 # обновлять раз в час + +def fetch_all_teachers(): + import time + now = time.time() + if _teachers_cache["data"] and now - _teachers_cache["ts"] < CACHE_TTL: + return _teachers_cache["data"] + try: + resp = requests.get(BASE_URL, params={"mn": "3"}, timeout=10) + resp.encoding = "utf-8" + soup = BeautifulSoup(resp.text, "lxml") + teachers = [] + for a in soup.find_all("a", href=lambda h: h and "obj=" in h): + m = re.search(r"obj=(\d+)", a["href"]) + bold = a.find("b") + if m and bold: + teachers.append({ + "id": m.group(1), + "name": bold.get_text(strip=True) + }) + _teachers_cache["data"] = teachers + _teachers_cache["ts"] = now + return teachers + except Exception as e: + return [] + +@app.route("/api/all-teachers") +def api_all_teachers(): + """Полный список преподавателей с сайта (с кэшем на 1 час).""" + teachers = fetch_all_teachers() + return jsonify({"teachers": teachers, "count": len(teachers)}) + +# ── Маршруты ───────────────────────────────────────────────────────────────── +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/api/teachers") +def api_teachers(): + teachers_list = [ + {"id": str(t["id"]), "name": t["name"], "short": t.get("short", t["name"])} + for t in config.get("teachers", []) + ] + return jsonify({"teachers": teachers_list, "default": DEFAULT_TEACHER}) + +@app.route("/api/schedule") +def api_schedule(): + obj = request.args.get("obj", DEFAULT_TEACHER) + wk = request.args.get("wk", None) + # Имя: сначала из конфига, потом из полного списка + teacher_info = TEACHERS.get(str(obj), {}) + name = teacher_info.get("name", "") + if not name: + all_t = fetch_all_teachers() + match = next((t for t in all_t if t["id"] == str(obj)), None) + if match: + name = match["name"] + data = fetch_schedule(obj=obj, wk=wk) + data["teacher_name"] = name + return jsonify(data) + +@app.route("/api/reload-config") +def api_reload(): + global config, BASE_URL, DEFAULT_TEACHER, TEACHERS + config = load_config() + BASE_URL = config["base_url"] + DEFAULT_TEACHER = str(config.get("default_teacher", "")) + TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])} + return jsonify({"ok": True, "teachers_count": len(TEACHERS)}) + + +#if __name__ == "__main__": +# app.run(debug=False, host="0.0.0.0", port=5609) + +@app.route("/api/debug") +def api_debug(): + """Отладка: показывает что нашёл парсер.""" + obj = request.args.get("obj", DEFAULT_TEACHER) + params = {"mn": "3", "obj": obj} + try: + resp = requests.get(BASE_URL, params=params, timeout=10) + resp.encoding = "utf-8" + html = resp.text + except Exception as e: + return jsonify({"error": str(e)}) + + soup = BeautifulSoup(html, "lxml") + day_anchors = soup.find_all("a", class_="t_wth") + week_range = "" + for td in soup.find_all("td"): + text = td.get_text(strip=True) + if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text): + week_range = text + break + + return jsonify({ + "html_length": len(html), + "html_snippet": html[:300], + "week_range_found": week_range, + "day_anchors_count": len(day_anchors), + "day_anchors": [a.get_text(strip=True) for a in day_anchors], + "full_parse": parse_schedule(html), + }) diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..360f9bd --- /dev/null +++ b/config.yaml @@ -0,0 +1,37 @@ +# Настройки приложения расписания +# ------------------------------------ +# base_url: адрес сайта с расписанием (без слеша в конце) +# default_teacher: id преподавателя, который открывается по умолчанию +# teachers: список преподавателей для быстрого переключения +# - id: число из параметра obj= в адресной строке +# например: https://lk.ks.psuti.ru/?mn=3&obj=244 → id: 244 +# - name: полное имя (отображается в шапке) +# - short: короткое имя для кнопки-таба + +base_url: "https://lk.ks.psuti.ru/?mn=3" + +default_teacher: 244 + +teachers: + - id: 244 + name: "Комаров Илья Сергеевич" + short: "Комаров И.С." + + - id: 229 + name: "Шахматов Егор Денисович" + short: "Шахматов Е.Д." + + - id: 13 + name: "Гончарова Анастасия Александровна" + short: "Гончарова А.А." + + # Добавь других преподавателей по аналогии — id берётся из ссылки: + # https://lk.ks.psuti.ru/?mn=3&obj=65 → id: 65 + # + # - id: 65 + # name: "Андреевская Наталья Владимировна" + # short: "Андреевская Н.В." + # + # - id: 3 + # name: "Абалымова Людмила Павловна" + # short: "Абалымова Л.П." diff --git a/install-service.sh b/install-service.sh new file mode 100644 index 0000000..6c1a508 --- /dev/null +++ b/install-service.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Запускать с sudo: sudo bash install-service.sh +# Должен быть запущен из папки с проектом + +set -e +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVICE_USER="${SUDO_USER:-$(whoami)}" + +echo "==> Устанавливаем systemd сервис..." +echo " Папка проекта : $PROJECT_DIR" +echo " Пользователь : $SERVICE_USER" + +sed \ + -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ + -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ + "$PROJECT_DIR/schedule.service.template" \ + > /etc/systemd/system/schedule.service + +systemctl daemon-reload +systemctl enable schedule +systemctl restart schedule + +echo "" +echo "✓ Сервис запущен!" +systemctl status schedule --no-pager -l diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fceb8aa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask>=2.3.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +lxml>=4.9.0 +pyyaml +gunicorn diff --git a/schedule.service.template b/schedule.service.template new file mode 100644 index 0000000..bff5a87 --- /dev/null +++ b/schedule.service.template @@ -0,0 +1,18 @@ +[Unit] +Description=Schedule Parser — расписание преподавателей +After=network.target + +[Service] +Type=simple +User=__SERVICE_USER__ +WorkingDirectory=__PROJECT_DIR__ +ExecStart=__PROJECT_DIR__/venv/bin/python app.py +Restart=on-failure +RestartSec=5 + +# Логи доступны через: journalctl -u schedule -f +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..398a788 --- /dev/null +++ b/setup.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Запускать из папки с проектом: bash setup.sh + +set -e +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==> Создаём venv..." +python3 -m venv "$PROJECT_DIR/venv" + +echo "==> Устанавливаем зависимости..." +"$PROJECT_DIR/venv/bin/pip" install --upgrade pip -q +"$PROJECT_DIR/venv/bin/pip" install -r "$PROJECT_DIR/requirements.txt" -q + +echo "" +echo "✓ venv готов: $PROJECT_DIR/venv" +echo " Python: $($PROJECT_DIR/venv/bin/python --version)" +echo "" +echo "Следующий шаг:" +echo " sudo bash install-service.sh" diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..46c7ea8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,729 @@ + + +
+ + +Загрузка...
+Загружаем расписание...