From 8ccbc0e9e03ee88ad5dcb330d8c48f498ccf421d Mon Sep 17 00:00:00 2001 From: Triton Date: Mon, 28 Sep 2026 05:53:16 +0000 Subject: [PATCH] Initial import: schedule-web (Flask, triron.ru) --- .gitignore | 8 + PythonApplication1.py | 1573 +++++++++++++++++++++++++++++ PythonApplication1.py.bak_timeago | 1424 ++++++++++++++++++++++++++ README.md | 29 + auto-refresh.sh | 2 + requirements.txt | 11 + 6 files changed, 3047 insertions(+) create mode 100644 .gitignore create mode 100644 PythonApplication1.py create mode 100644 PythonApplication1.py.bak_timeago create mode 100644 README.md create mode 100755 auto-refresh.sh create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..862608f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.log +.journal_credentials +session.json +session_test.json +schedule.json diff --git a/PythonApplication1.py b/PythonApplication1.py new file mode 100644 index 0000000..c3ca5db --- /dev/null +++ b/PythonApplication1.py @@ -0,0 +1,1573 @@ +from playwright.sync_api import sync_playwright +from pathlib import Path +from datetime import datetime, timedelta +from logging.handlers import RotatingFileHandler +import json +import threading +import signal +import sys +import os +import logging +import urllib.request +import urllib.parse +import ssl +from flask import Flask, jsonify, Response, request + +os.environ.setdefault("DISPLAY", ":99") + +BASE_DIR = Path(__file__).resolve().parent +SESSION_FILE = BASE_DIR / "session.json" +SCHEDULE_FILE = BASE_DIR / "schedule.json" +LOG_FILE = BASE_DIR / "app.log" +CREDENTIALS_FILE = BASE_DIR / ".journal_credentials" + +LOGIN_URL = "https://journal.top-academy.ru/ru/auth/login/index" +SCHEDULE_URL = "https://journal.top-academy.ru/ru/main/schedule/page" +API_BASE = "https://journal.top-academy.ru/api/v2/schedule/operations/get-month" + +LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(message)s" +LOG_DATE = "%Y-%m-%d %H:%M:%S" + +log = logging.getLogger("schedule") +log.setLevel(logging.DEBUG) + +_console = logging.StreamHandler(sys.stdout) +_console.setLevel(logging.INFO) +_console.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE)) +log.addHandler(_console) + +_file = RotatingFileHandler( + str(LOG_FILE), maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", +) +_file.setLevel(logging.DEBUG) +_file.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE)) +log.addHandler(_file) + +app = Flask(__name__) + +fetch_status = { + "running": False, + "message": "", + "last_error": "", + "last_update": "", +} +status_lock = threading.Lock() +browser_lock = threading.Lock() +shutdown_event = threading.Event() + +activity_log = [] +activity_lock = threading.Lock() + + +def log_event(level, text): + """Append a human-readable event shown on the site (кириллица).""" + try: + with activity_lock: + stamp = datetime.now().strftime("%d.%m %H:%M:%S") + activity_log.append({"time": stamp, "level": level, "text": text}) + del activity_log[:-60] + except Exception as e: + log.error("Failed to log event: %s", e) + + +def set_status(**kwargs): + try: + with status_lock: + fetch_status.update(kwargs) + log.debug("Status updated: %s", kwargs) + except Exception as e: + log.error("Failed to set status: %s", e) + + +def safe_json_load(path): + try: + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + log.debug("Loaded %d items from %s", len(data), path.name) + return data + except json.JSONDecodeError as e: + log.error("Corrupt JSON in %s: %s", path.name, e) + backup = path.with_suffix(".json.bak") + try: + path.rename(backup) + except Exception: + pass + return [] + except Exception as e: + log.error("Failed to read %s: %s", path.name, e) + return [] + + +def safe_json_save(path, data): + try: + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + tmp.replace(path) + log.debug("Saved %d items to %s", len(data), path.name) + except Exception as e: + log.error("Failed to save %s: %s", path.name, e) + raise + + +def get_schedule(): + log.info("Starting schedule fetch...") + if not browser_lock.acquire(timeout=5): + raise RuntimeError("Another update is already running.") + try: + return _get_schedule_inner() + finally: + browser_lock.release() + log.info("Lock released.") + + +def _merge_with_previous(new_items): + """Merge fresh lessons with the last known cache. + + Fresh items win for the same (date, lesson); older items that are absent + from the fresh fetch are kept as a fallback so already-published days are + not lost when the journal temporarily stops returning them. + """ + old_items = safe_json_load(SCHEDULE_FILE) or [] + old_keys = {(x.get("date"), x.get("lesson")) for x in old_items} + new_keys = {(x.get("date"), x.get("lesson")) for x in new_items} + dropped = sorted({k for k in old_keys - new_keys}) + added = sorted({k for k in new_keys - old_keys}) + kept = [x for x in old_items if (x.get("date"), x.get("lesson")) not in new_keys] + merged = new_items + kept + if dropped: + dates_dropped = sorted({d for d, _ in dropped}) + log_event("warning", f"Журнал вернул меньше: убраны даты {', '.join(dates_dropped)}, оставлены из кэша ({len(dropped)} ур.)") + if added: + dates_added = sorted({d for d, _ in added}) + log_event("ok", f"Обнаружены новые занятия: {', '.join(dates_added)}") + return sorted(merged, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _load_cookies(): + if not SESSION_FILE.exists(): + return {} + try: + with open(SESSION_FILE, "r", encoding="utf-8") as f: + state = json.load(f) + cookies = {} + for c in state.get("cookies", []): + if "top-academy.ru" in c.get("domain", ""): + cookies[c["name"]] = c["value"] + return cookies + except Exception as e: + log.error("Failed to load cookies: %s", e) + return {} + + +def _fetch_schedule_direct(): + cookies = _load_cookies() + if not cookies: + return None + + cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items()) + now = datetime.now() + months = [] + for offset in range(-1, 2): + d = now + timedelta(days=offset * 30) + months.append(d.strftime("%Y-%m")) + + all_data = [] + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + for month in sorted(set(months)): + url = f"{API_BASE}?month={month}" + try: + req = urllib.request.Request(url) + req.add_header("Cookie", cookie_header) + req.add_header("User-Agent", "Mozilla/5.0") + req.add_header("Accept", "application/json") + req.add_header("Referer", SCHEDULE_URL) + resp = urllib.request.urlopen(req, timeout=15, context=ctx) + data = json.loads(resp.read().decode("utf-8")) + if isinstance(data, list) and len(data) > 0: + all_data.extend(data) + log.info("API month %s: %d lessons.", month, len(data)) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + log.warning("Auth failed for month %s (HTTP %d).", month, e.code) + return None + except Exception as e: + log.warning("Request error for month %s: %s", month, e) + + if not all_data: + return None + return sorted(all_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _fetch_schedule_browser(): + log.info("Falling back to browser...") + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"]) + try: + if SESSION_FILE.exists(): + context = browser.new_context( + storage_state=str(SESSION_FILE), + user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + set_status(message="Updating via browser...") + else: + set_status(message="No session available.") + browser.close() + return None + + schedule_data = [] + seen = set() + page = context.pages[0] if context.pages else context.new_page() + + def handle_response(response): + try: + if "/api/v2/schedule/operations/get-month" not in response.url: + return + if response.status != 200: + return + data = response.json() + if isinstance(data, list): + for item in data: + key = (item.get("date"), item.get("lesson")) + if key not in seen: + seen.add(key) + schedule_data.append(item) + except Exception: + pass + + page.on("response", handle_response) + page.goto(SCHEDULE_URL) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(5000) + finally: + browser.close() + + if not schedule_data: + return None + return sorted(schedule_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _web_relogin(): + """Renew the journal session using protected perms-600 credentials file.""" + if not CREDENTIALS_FILE.exists(): + log.warning("No credentials file; relogin skipped.") + return False + try: + with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + if len(lines) < 2: + log.warning("Credentials file malformed.") + return False + username, password = lines[0].strip(), lines[1].strip() + if not username or not password: + return False + except Exception as e: + log.error("Failed to read credentials: %s", e) + return False + + try: + log.info("Relogin: opening login page...") + with sync_playwright() as p: + browser = p.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-blink-features=AutomationControlled"], + ) + context = browser.new_context() + page = context.new_page() + page.goto(LOGIN_URL) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(2000) + + try: + page.fill('input[name="username"]', username) + page.fill('input[name="password"]', password) + page.wait_for_timeout(500) + page.click('button[type="submit"], input[type="submit"]') + page.wait_for_load_state("networkidle") + page.wait_for_timeout(4000) + except Exception as e: + log.warning("Relogin form step error: %s", e) + + if "login" not in page.url: + context.storage_state(path=str(SESSION_FILE)) + log.info("Relogin successful. Session renewed and saved.") + return True + else: + log.warning("Relogin failed: still on login page.") + return False + except Exception as e: + log.error("Relogin error: %s", e) + return False + + +def _retry_after_relogin(): + log_event("warning", "Сессия журнала устарела — пытаюсь войти заново (автоматически)...") + if not _web_relogin(): + log_event("error", "Не удалось заново войти в журнал. Проверь аккаунт.") + return None + log_event("info", "Вход в журнал выполнен заново. Повторяю загрузку расписания...") + result = _fetch_schedule_direct() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (переподключение).", + ) + log.info("Fetch complete (relogin+http): %d lessons.", len(merged)) + log_event("ok", f"Расписание обновлено после повторного входа: {len(merged)} занятий (данные журнала: {len(result)}).") + return merged + result = _fetch_schedule_browser() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (браузер).", + ) + log.info("Fetch complete (relogin+browser): %d lessons.", len(merged)) + log_event("ok", f"Расписание обновлено после повторного входа (браузер): {len(merged)} занятий (данные журнала: {len(result)}).") + return merged + log_event("error", "После повторного входа расписание так и не получилось загрузить.") + return None + + +def _get_schedule_inner(): + set_status(message="Получаю расписание из журнала...") + log_event("info", "Начинаю обновление расписания...") + result = _fetch_schedule_direct() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий.", + ) + log.info("Fetch complete (HTTP): %d lessons.", len(merged)) + log_event("ok", f"Журнал ответил (напрямую): {len(result)} занятий. Показано: {len(merged)}.") + return merged + + log.info("Direct fetch failed, trying browser...") + log_event("warning", "Прямое соединение с журналом не сработало — открываю журнал в браузере...") + if not SESSION_FILE.exists(): + log.info("No session file; going straight to relogin...") + return _retry_after_relogin() + + result = _fetch_schedule_browser() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (браузер).", + ) + log.info("Fetch complete (browser): %d lessons.", len(merged)) + log_event("ok", f"Журнал открылся в браузере: {len(result)} занятий. Показано: {len(merged)}.") + return merged + + log_event("error", "Браузер тоже не смог получить расписание из журнала.") + log.warning("Session likely expired; attempting relogin...") + return _retry_after_relogin() + + +def run_update(): + log.info("Update thread started.") + try: + set_status(running=True, last_error="") + for attempt in range(1, 4): + try: + log.info("Attempt %d/3", attempt) + get_schedule() + break + except Exception as e: + log.warning("Attempt %d failed: %s", attempt, e, exc_info=True) + log_event("error", f"Попытка {attempt} не удалась: {e}") + if attempt < 3: + set_status(message=f"Попытка {attempt} не удалась, повтор...") + except Exception as e: + set_status(message=f"Ошибка: {e}", last_error=str(e)) + log.error("All update attempts failed.", exc_info=True) + log_event("error", f"Обновление окончательно не удалось: {e}") + finally: + set_status(running=False) + log.info("Update thread finished.") + + +@app.route("/") +def index(): + try: + return Response(INDEX_HTML, mimetype="text/html") + except Exception as e: + log.error("Failed to serve index: %s", e) + return "Internal Server Error", 500 + + +@app.route("/api/schedule") +def api_schedule(): + try: + data = safe_json_load(SCHEDULE_FILE) + return jsonify(sorted(data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))) + except Exception as e: + log.error("API /schedule error: %s", e) + return jsonify([]) + + +@app.route("/api/update", methods=["POST"]) +def api_update(): + try: + with status_lock: + if fetch_status["running"]: + return jsonify({"ok": False, "message": "Already updating"}) + thread = threading.Thread(target=run_update, daemon=True) + thread.start() + log.info("Update triggered by user.") + return jsonify({"ok": True, "message": "Update started"}) + except Exception as e: + log.error("API /update error: %s", e) + return jsonify({"ok": False, "message": str(e)}) + + +@app.route("/api/status") +def api_status(): + try: + with status_lock: + return jsonify(dict(fetch_status)) + except Exception as e: + return jsonify({"running": False, "message": "", "last_error": ""}) + + +@app.route("/api/logs") +def api_logs(): + try: + with activity_lock: + return jsonify(list(activity_log[-30:])) + except Exception: + return jsonify([]) + + +@app.errorhandler(404) +def not_found(e): + return jsonify({"error": "Not found"}), 404 + + +@app.errorhandler(500) +def server_error(e): + log.error("500 error: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +INDEX_HTML = r""" + + + + +TRIRON — Расписание + + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ +
+TRIRON · источник TOP Academy +
/ поиск · R обновить · T сегодня · Esc закрыть
+
+ +
+ +
Загружаю...
+
+
+
+ +
+ + + + + +""" + + +def handle_signal(signum, frame): + log.info("Signal %s received. Shutting down...", signum) + try: + os._exit(0) + except Exception: + pass + + +signal.signal(signal.SIGINT, handle_signal) +signal.signal(signal.SIGTERM, handle_signal) + + +if __name__ == "__main__": + log.info("=" * 50) + log.info("Schedule Web Server starting...") + log.info("Session file: %s (exists: %s)", SESSION_FILE, SESSION_FILE.exists()) + log.info("Schedule file: %s (exists: %s)", SCHEDULE_FILE, SCHEDULE_FILE.exists()) + log.info("Log file: %s", LOG_FILE) + log.info("Server: http://127.0.0.1:5000") + log.info("=" * 50) + log_event("info", "Сервис запущен. Обновление расписания выполняется автоматически.") + + try: + app.run(host="0.0.0.0", port=5000, debug=False) + except Exception as e: + log.critical("Server crashed: %s", e, exc_info=True) + finally: + log.info("Server stopped.") + logging.shutdown() diff --git a/PythonApplication1.py.bak_timeago b/PythonApplication1.py.bak_timeago new file mode 100644 index 0000000..7762018 --- /dev/null +++ b/PythonApplication1.py.bak_timeago @@ -0,0 +1,1424 @@ +from playwright.sync_api import sync_playwright +from pathlib import Path +from datetime import datetime, timedelta +from logging.handlers import RotatingFileHandler +import json +import threading +import signal +import sys +import os +import logging +import urllib.request +import urllib.parse +import ssl +from flask import Flask, jsonify, Response, request + +os.environ.setdefault("DISPLAY", ":99") + +BASE_DIR = Path(__file__).resolve().parent +SESSION_FILE = BASE_DIR / "session.json" +SCHEDULE_FILE = BASE_DIR / "schedule.json" +LOG_FILE = BASE_DIR / "app.log" +CREDENTIALS_FILE = BASE_DIR / ".journal_credentials" + +LOGIN_URL = "https://journal.top-academy.ru/ru/auth/login/index" +SCHEDULE_URL = "https://journal.top-academy.ru/ru/main/schedule/page" +API_BASE = "https://journal.top-academy.ru/api/v2/schedule/operations/get-month" + +LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(message)s" +LOG_DATE = "%Y-%m-%d %H:%M:%S" + +log = logging.getLogger("schedule") +log.setLevel(logging.DEBUG) + +_console = logging.StreamHandler(sys.stdout) +_console.setLevel(logging.INFO) +_console.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE)) +log.addHandler(_console) + +_file = RotatingFileHandler( + str(LOG_FILE), maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", +) +_file.setLevel(logging.DEBUG) +_file.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE)) +log.addHandler(_file) + +app = Flask(__name__) + +fetch_status = { + "running": False, + "message": "", + "last_error": "", + "last_update": "", +} +status_lock = threading.Lock() +browser_lock = threading.Lock() +shutdown_event = threading.Event() + +activity_log = [] +activity_lock = threading.Lock() + + +def log_event(level, text): + """Append a human-readable event shown on the site (кириллица).""" + try: + with activity_lock: + stamp = datetime.now().strftime("%d.%m %H:%M:%S") + activity_log.append({"time": stamp, "level": level, "text": text}) + del activity_log[:-60] + except Exception as e: + log.error("Failed to log event: %s", e) + + +def set_status(**kwargs): + try: + with status_lock: + fetch_status.update(kwargs) + log.debug("Status updated: %s", kwargs) + except Exception as e: + log.error("Failed to set status: %s", e) + + +def safe_json_load(path): + try: + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + log.debug("Loaded %d items from %s", len(data), path.name) + return data + except json.JSONDecodeError as e: + log.error("Corrupt JSON in %s: %s", path.name, e) + backup = path.with_suffix(".json.bak") + try: + path.rename(backup) + except Exception: + pass + return [] + except Exception as e: + log.error("Failed to read %s: %s", path.name, e) + return [] + + +def safe_json_save(path, data): + try: + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + tmp.replace(path) + log.debug("Saved %d items to %s", len(data), path.name) + except Exception as e: + log.error("Failed to save %s: %s", path.name, e) + raise + + +def get_schedule(): + log.info("Starting schedule fetch...") + if not browser_lock.acquire(timeout=5): + raise RuntimeError("Another update is already running.") + try: + return _get_schedule_inner() + finally: + browser_lock.release() + log.info("Lock released.") + + +def _merge_with_previous(new_items): + """Merge fresh lessons with the last known cache. + + Fresh items win for the same (date, lesson); older items that are absent + from the fresh fetch are kept as a fallback so already-published days are + not lost when the journal temporarily stops returning them. + """ + old_items = safe_json_load(SCHEDULE_FILE) or [] + old_keys = {(x.get("date"), x.get("lesson")) for x in old_items} + new_keys = {(x.get("date"), x.get("lesson")) for x in new_items} + dropped = sorted({k for k in old_keys - new_keys}) + added = sorted({k for k in new_keys - old_keys}) + kept = [x for x in old_items if (x.get("date"), x.get("lesson")) not in new_keys] + merged = new_items + kept + if dropped: + dates_dropped = sorted({d for d, _ in dropped}) + log_event("warning", f"Журнал вернул меньше: убраны даты {', '.join(dates_dropped)}, оставлены из кэша ({len(dropped)} ур.)") + if added: + dates_added = sorted({d for d, _ in added}) + log_event("ok", f"Обнаружены новые занятия: {', '.join(dates_added)}") + return sorted(merged, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _load_cookies(): + if not SESSION_FILE.exists(): + return {} + try: + with open(SESSION_FILE, "r", encoding="utf-8") as f: + state = json.load(f) + cookies = {} + for c in state.get("cookies", []): + if "top-academy.ru" in c.get("domain", ""): + cookies[c["name"]] = c["value"] + return cookies + except Exception as e: + log.error("Failed to load cookies: %s", e) + return {} + + +def _fetch_schedule_direct(): + cookies = _load_cookies() + if not cookies: + return None + + cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items()) + now = datetime.now() + months = [] + for offset in range(-1, 2): + d = now + timedelta(days=offset * 30) + months.append(d.strftime("%Y-%m")) + + all_data = [] + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + for month in sorted(set(months)): + url = f"{API_BASE}?month={month}" + try: + req = urllib.request.Request(url) + req.add_header("Cookie", cookie_header) + req.add_header("User-Agent", "Mozilla/5.0") + req.add_header("Accept", "application/json") + req.add_header("Referer", SCHEDULE_URL) + resp = urllib.request.urlopen(req, timeout=15, context=ctx) + data = json.loads(resp.read().decode("utf-8")) + if isinstance(data, list) and len(data) > 0: + all_data.extend(data) + log.info("API month %s: %d lessons.", month, len(data)) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + log.warning("Auth failed for month %s (HTTP %d).", month, e.code) + return None + except Exception as e: + log.warning("Request error for month %s: %s", month, e) + + if not all_data: + return None + return sorted(all_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _fetch_schedule_browser(): + log.info("Falling back to browser...") + with sync_playwright() as p: + browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"]) + try: + if SESSION_FILE.exists(): + context = browser.new_context( + storage_state=str(SESSION_FILE), + user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + set_status(message="Updating via browser...") + else: + set_status(message="No session available.") + browser.close() + return None + + schedule_data = [] + seen = set() + page = context.pages[0] if context.pages else context.new_page() + + def handle_response(response): + try: + if "/api/v2/schedule/operations/get-month" not in response.url: + return + if response.status != 200: + return + data = response.json() + if isinstance(data, list): + for item in data: + key = (item.get("date"), item.get("lesson")) + if key not in seen: + seen.add(key) + schedule_data.append(item) + except Exception: + pass + + page.on("response", handle_response) + page.goto(SCHEDULE_URL) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(5000) + finally: + browser.close() + + if not schedule_data: + return None + return sorted(schedule_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))) + + +def _web_relogin(): + """Renew the journal session using protected perms-600 credentials file.""" + if not CREDENTIALS_FILE.exists(): + log.warning("No credentials file; relogin skipped.") + return False + try: + with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + if len(lines) < 2: + log.warning("Credentials file malformed.") + return False + username, password = lines[0].strip(), lines[1].strip() + if not username or not password: + return False + except Exception as e: + log.error("Failed to read credentials: %s", e) + return False + + try: + log.info("Relogin: opening login page...") + with sync_playwright() as p: + browser = p.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-blink-features=AutomationControlled"], + ) + context = browser.new_context() + page = context.new_page() + page.goto(LOGIN_URL) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(2000) + + try: + page.fill('input[name="username"]', username) + page.fill('input[name="password"]', password) + page.wait_for_timeout(500) + page.click('button[type="submit"], input[type="submit"]') + page.wait_for_load_state("networkidle") + page.wait_for_timeout(4000) + except Exception as e: + log.warning("Relogin form step error: %s", e) + + if "login" not in page.url: + context.storage_state(path=str(SESSION_FILE)) + log.info("Relogin successful. Session renewed and saved.") + return True + else: + log.warning("Relogin failed: still on login page.") + return False + except Exception as e: + log.error("Relogin error: %s", e) + return False + + +def _retry_after_relogin(): + log_event("warning", "Сессия журнала устарела — пытаюсь войти заново (автоматически)...") + if not _web_relogin(): + log_event("error", "Не удалось заново войти в журнал. Проверь аккаунт.") + return None + log_event("info", "Вход в журнал выполнен заново. Повторяю загрузку расписания...") + result = _fetch_schedule_direct() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (переподключение).", + ) + log.info("Fetch complete (relogin+http): %d lessons.", len(merged)) + log_event("ok", f"Расписание обновлено после повторного входа: {len(merged)} занятий (данные журнала: {len(result)}).") + return merged + result = _fetch_schedule_browser() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (браузер).", + ) + log.info("Fetch complete (relogin+browser): %d lessons.", len(merged)) + log_event("ok", f"Расписание обновлено после повторного входа (браузер): {len(merged)} занятий (данные журнала: {len(result)}).") + return merged + log_event("error", "После повторного входа расписание так и не получилось загрузить.") + return None + + +def _get_schedule_inner(): + set_status(message="Получаю расписание из журнала...") + log_event("info", "Начинаю обновление расписания...") + result = _fetch_schedule_direct() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий.", + ) + log.info("Fetch complete (HTTP): %d lessons.", len(merged)) + log_event("ok", f"Журнал ответил (напрямую): {len(result)} занятий. Показано: {len(merged)}.") + return merged + + log.info("Direct fetch failed, trying browser...") + log_event("warning", "Прямое соединение с журналом не сработало — открываю журнал в браузере...") + if not SESSION_FILE.exists(): + log.info("No session file; going straight to relogin...") + return _retry_after_relogin() + + result = _fetch_schedule_browser() + if result: + merged = _merge_with_previous(result) + safe_json_save(SCHEDULE_FILE, merged) + set_status( + last_update=datetime.now().strftime("%H:%M:%S"), + message=f"Готово: {len(merged)} занятий (браузер).", + ) + log.info("Fetch complete (browser): %d lessons.", len(merged)) + log_event("ok", f"Журнал открылся в браузере: {len(result)} занятий. Показано: {len(merged)}.") + return merged + + log_event("error", "Браузер тоже не смог получить расписание из журнала.") + log.warning("Session likely expired; attempting relogin...") + return _retry_after_relogin() + + +def run_update(): + log.info("Update thread started.") + try: + set_status(running=True, last_error="") + for attempt in range(1, 4): + try: + log.info("Attempt %d/3", attempt) + get_schedule() + break + except Exception as e: + log.warning("Attempt %d failed: %s", attempt, e, exc_info=True) + log_event("error", f"Попытка {attempt} не удалась: {e}") + if attempt < 3: + set_status(message=f"Попытка {attempt} не удалась, повтор...") + except Exception as e: + set_status(message=f"Ошибка: {e}", last_error=str(e)) + log.error("All update attempts failed.", exc_info=True) + log_event("error", f"Обновление окончательно не удалось: {e}") + finally: + set_status(running=False) + log.info("Update thread finished.") + + +@app.route("/") +def index(): + try: + return Response(INDEX_HTML, mimetype="text/html") + except Exception as e: + log.error("Failed to serve index: %s", e) + return "Internal Server Error", 500 + + +@app.route("/api/schedule") +def api_schedule(): + try: + data = safe_json_load(SCHEDULE_FILE) + return jsonify(sorted(data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))) + except Exception as e: + log.error("API /schedule error: %s", e) + return jsonify([]) + + +@app.route("/api/update", methods=["POST"]) +def api_update(): + try: + with status_lock: + if fetch_status["running"]: + return jsonify({"ok": False, "message": "Already updating"}) + thread = threading.Thread(target=run_update, daemon=True) + thread.start() + log.info("Update triggered by user.") + return jsonify({"ok": True, "message": "Update started"}) + except Exception as e: + log.error("API /update error: %s", e) + return jsonify({"ok": False, "message": str(e)}) + + +@app.route("/api/status") +def api_status(): + try: + with status_lock: + return jsonify(dict(fetch_status)) + except Exception as e: + return jsonify({"running": False, "message": "", "last_error": ""}) + + +@app.route("/api/logs") +def api_logs(): + try: + with activity_lock: + return jsonify(list(activity_log[-30:])) + except Exception: + return jsonify([]) + + +@app.errorhandler(404) +def not_found(e): + return jsonify({"error": "Not found"}), 404 + + +@app.errorhandler(500) +def server_error(e): + log.error("500 error: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +INDEX_HTML = r""" + + + + +TRIRON — Расписание + + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ +
+TRIRON · источник TOP Academy +
/ поиск · R обновить · T сегодня · Esc закрыть
+
+ +
+ +
Загружаю...
+
+
+
+ +
+ + + + + +""" + + +def handle_signal(signum, frame): + log.info("Signal %s received. Shutting down...", signum) + try: + os._exit(0) + except Exception: + pass + + +signal.signal(signal.SIGINT, handle_signal) +signal.signal(signal.SIGTERM, handle_signal) + + +if __name__ == "__main__": + log.info("=" * 50) + log.info("Schedule Web Server starting...") + log.info("Session file: %s (exists: %s)", SESSION_FILE, SESSION_FILE.exists()) + log.info("Schedule file: %s (exists: %s)", SCHEDULE_FILE, SCHEDULE_FILE.exists()) + log.info("Log file: %s", LOG_FILE) + log.info("Server: http://127.0.0.1:5000") + log.info("=" * 50) + log_event("info", "Сервис запущен. Обновление расписания выполняется автоматически.") + + try: + app.run(host="0.0.0.0", port=5000, debug=False) + except Exception as e: + log.critical("Server crashed: %s", e, exc_info=True) + finally: + log.info("Server stopped.") + logging.shutdown() diff --git a/README.md b/README.md new file mode 100644 index 0000000..956210c --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# schedule-web + +Flask-сервис, который тянет расписание и ведёт журнал. Отдаётся через nginx +на домен `triron.ru` (проксирование на `127.0.0.1:5000`). + +## Состав + +- `PythonApplication1.py` — основной код приложения +- `auto-refresh.sh` — периодический POST на `/api/update` +- `requirements.txt` — зависимости из production-окружения +- `PythonApplication1.py.bak_timeago` — бэкап кода от 13.09 + +## Запуск + +``` +python3 -m venv venv +./venv/bin/pip install -r requirements.txt +./venv/bin/playwright install chromium +./venv/bin/python PythonApplication1.py +``` + +Приложение ожидает `127.0.0.1:5000` и файл с учётными данными +`.journal_credentials` (права 600) в корне проекта — две строки: логин, пароль. +Файл в репозиторий намеренно не включён. + +## Что не коммитится + +`venv/`, логи, `session*.json`, `schedule.json` и `.journal_credentials` — +см. `.gitignore`. diff --git a/auto-refresh.sh b/auto-refresh.sh new file mode 100755 index 0000000..5be521c --- /dev/null +++ b/auto-refresh.sh @@ -0,0 +1,2 @@ +#!/bin/bash +curl -s -X POST http://127.0.0.1:5000/api/update > /dev/null 2>&1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e2c154 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.9.0 +click==8.5.0 +Flask==3.1.3 +greenlet==3.5.5 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +playwright==1.62.0 +pyee==13.0.1 +typing_extensions==4.16.0 +Werkzeug==3.1.8