diff --git a/catalog.py b/catalog.py
new file mode 100644
index 00000000..c1daf107
--- /dev/null
+++ b/catalog.py
@@ -0,0 +1,18 @@
+TEACHERS = [
+ {"id": "432837452", "fullname": "Р.Р. Юзькив"},
+ {"id": "335824546", "fullname": "А.И. Максимов"},
+ {"id": "664017039", "fullname": "А.Н. Борисов"},
+ {"id": "364272302", "fullname": "А.А. Агафонов"},
+ {"id": "147619112", "fullname": "А.В. Кузнецов"},
+ {"id": "333991624", "fullname": "А.В. Веричев"},
+ {"id": "544973937", "fullname": "Д.А. Шапиро"},
+ {"id": "62061001", "fullname": "В.В. Мясников"},
+ {"id": "114869468", "fullname": "А.В. Сергеев"},
+ {"id": "594502705", "fullname": "П.В. Чернышев"},
+]
+
+GROUPS = [
+ {"id": "1282690301", "title": "6411-100503D - Информационная безопасность"},
+ {"id": "1282690279", "title": "6412-100503D - Информационная безопасность"},
+ {"id": "1213641978", "title": "6413-100503D - Информационная безопасность"},
+]
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..b6f36461
--- /dev/null
+++ b/index.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ Университетское расписание
+
+
+
+
+
+
+
+
+
+
+
Расписание учебных занятий
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Учебная неделя
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Обработка данных...
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/parser.py b/parser.py
new file mode 100644
index 00000000..e8e3dd38
--- /dev/null
+++ b/parser.py
@@ -0,0 +1,94 @@
+from bs4 import BeautifulSoup
+
+def parse_schedule(html):
+ soup = BeautifulSoup(html, 'html.parser')
+
+ day_map = {
+ 'понедельник': 'monday', 'вторник': 'tuesday',
+ 'среда': 'wednesday', 'четверг': 'thursday',
+ 'пятница': 'friday', 'суббота': 'saturday'
+ }
+
+ days = [day_map.get(d.text.strip().lower(), d.text.strip().lower())
+ for d in soup.select('.schedule__head-weekday')]
+
+ if not days:
+ days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
+
+ days = days[5:] + days[:5]
+
+ time_slots = [
+ "08:00-09:35", "09:45-11:20", "11:30-13:05",
+ "13:30-15:05", "15:15-16:50", "17:00-18:35"
+ ]
+
+ items = soup.select('.schedule__items .schedule__item')
+
+ lesson_cells = []
+ for item in items:
+ if item.find(class_='schedule__head') or item.find(class_='schedule__time-item'):
+ continue
+ lesson_cells.append(item)
+
+ rows = [lesson_cells[i:i + 6] for i in range(0, len(lesson_cells), 6)]
+
+ for idx, row in enumerate(rows):
+ if any(cell.find(class_='schedule__lesson') for cell in row):
+ rows = rows[idx:]
+ break
+
+ rows = [row for row in rows if any(cell.find(class_='schedule__lesson') for cell in row)]
+
+ schedule = []
+
+ for row_idx, row in enumerate(rows):
+ if row_idx >= len(time_slots):
+ break
+
+ day_data = {}
+
+ for col_idx, cell in enumerate(row):
+ if col_idx >= len(days):
+ continue
+
+ day = days[col_idx]
+ lessons = []
+
+ for lesson in cell.select('.schedule__lesson'):
+ title = lesson.select_one('.schedule__discipline')
+ teacher = lesson.select_one('.schedule__teacher')
+ place = lesson.select_one('.schedule__place')
+ type_chip = lesson.select_one('.schedule__lesson-type-chip')
+
+ title_text = title.text.strip() if title else ""
+
+ teacher_text = ""
+ if teacher:
+ a = teacher.find('a')
+ teacher_text = a.text.strip() if a else teacher.text.strip()
+
+ place_text = place.text.strip() if place else ""
+ type_text = type_chip.text.strip() if type_chip else ""
+
+ type_class = 'lesson'
+ if 'лекц' in type_text.lower():
+ type_class = 'lecture'
+ elif 'практ' in type_text.lower():
+ type_class = 'practice'
+ elif 'лаб' in type_text.lower():
+ type_class = 'lab'
+
+ if title_text:
+ lessons.append({
+ "title": title_text,
+ "teacher": teacher_text,
+ "room": place_text,
+ "type": type_text if type_text else "Занятие",
+ "typeClass": type_class
+ })
+
+ day_data[day] = lessons
+
+ schedule.append({"time": time_slots[row_idx], "days": day_data})
+
+ return schedule
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..ef3a2a31
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+fastapi
+uvicorn
+requests
+beautifulsoup4
\ No newline at end of file
diff --git a/script.js b/script.js
new file mode 100644
index 00000000..9c1cb885
--- /dev/null
+++ b/script.js
@@ -0,0 +1,209 @@
+console.log("App loaded");
+
+let currentMode = 'group';
+let currentItems = [];
+
+function getCurrentWeek() {
+ const start = new Date(2025, 8, 1);
+ const now = new Date();
+ const diff = Math.floor((now - start) / (1000 * 60 * 60 * 24));
+ if (diff < 0) return 1;
+ return (Math.floor(diff / 7) % 52) + 1;
+}
+
+function loadGroups() {
+ return $.get('/api/groups');
+}
+
+function loadTeachers() {
+ return $.get('/api/teachers');
+}
+
+function loadItems() {
+ const label = currentMode === 'group' ? 'Группа' : 'Преподаватель';
+ $('#selectLabel').text(label);
+ $('#entitySelect').prop('disabled', true).html('');
+
+ const loader = currentMode === 'group' ? loadGroups() : loadTeachers();
+
+ loader.done(function(items) {
+ currentItems = items;
+ renderSelect(items);
+ $('#entitySelect').prop('disabled', false);
+ }).fail(function() {
+ $('#entitySelect').prop('disabled', false).html('');
+ });
+}
+
+function renderSelect(items) {
+ const modeText = currentMode === 'group' ? 'группу' : 'преподавателя';
+ let options = ``;
+
+ items.forEach(function(item) {
+ options += ``;
+ });
+
+ $('#entitySelect').html(options);
+}
+
+function getLessonClass(typeText) {
+ const t = (typeText || '').toLowerCase();
+ if (t.includes('лекц')) return 'lesson-lecture';
+ if (t.includes('практ')) return 'lesson-practice';
+ if (t.includes('лаб')) return 'lesson-lab';
+ if (t.includes('экзамен')) return 'lesson-exam';
+ if (t.includes('зачёт')) return 'lesson-credit';
+ return 'lesson-default';
+}
+
+function buildLessonHTML(lesson) {
+ const lessonClass = getLessonClass(lesson.type);
+ let html = ``;
+ html += `${escapeHtml(lesson.title)}`;
+ if (lesson.teacher) {
+ html += `
${escapeHtml(lesson.teacher)}`;
+ }
+ if (lesson.room) {
+ html += `
${escapeHtml(lesson.room)}`;
+ }
+ html += `
`;
+ return html;
+}
+
+function buildDayCellHTML(lessons) {
+ if (!lessons || lessons.length === 0) {
+ return '—';
+ }
+
+ let html = '';
+ lessons.forEach(function(lesson) {
+ html += buildLessonHTML(lesson);
+ });
+ return html;
+}
+
+function buildRowHTML(row) {
+ const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
+ let html = '';
+ html += `| ${escapeHtml(row.time)} | `;
+
+ days.forEach(function(day) {
+ const lessons = row.days[day] || [];
+ html += `${buildDayCellHTML(lessons)} | `;
+ });
+
+ html += '
';
+ return html;
+}
+
+function loadSchedule() {
+ const entityId = $('#entitySelect').val();
+ const week = $('#weekInput').val();
+
+ if (!entityId) {
+ showEmptyState('Выберите ' + (currentMode === 'group' ? 'группу' : 'преподавателя'));
+ return;
+ }
+
+ showLoading(true);
+
+ const url = currentMode === 'group'
+ ? `/api/schedule/${entityId}?week=${week}`
+ : `/api/schedule/teacher/${entityId}?week=${week}`;
+
+ $.get(url)
+ .done(function(data) {
+ renderSchedule(data);
+ showLoading(false);
+ })
+ .fail(function() {
+ showLoading(false);
+ showErrorState('Не удалось загрузить расписание');
+ });
+}
+
+function renderSchedule(data) {
+ if (!data || data.length === 0) {
+ showEmptyState('Нет данных');
+ return;
+ }
+
+ let html = '';
+ data.forEach(function(row) {
+ html += buildRowHTML(row);
+ });
+
+ $('#scheduleBody').html(html);
+}
+
+function showLoading(show) {
+ if (show) {
+ $('#loadingIndicator').show();
+ $('#scheduleBody').html('| Загрузка... | ');
+ } else {
+ $('#loadingIndicator').hide();
+ }
+}
+
+function showEmptyState(message) {
+ $('#scheduleBody').html(`
| ${escapeHtml(message)} | `);
+}
+
+function showErrorState(message) {
+ $('#scheduleBody').html(`
${escapeHtml(message)}
| `);
+}
+
+function escapeHtml(str) {
+ if (!str) return '';
+ return String(str).replace(/[&<>]/g, function(m) {
+ if (m === '&') return '&';
+ if (m === '<') return '<';
+ if (m === '>') return '>';
+ return m;
+ });
+}
+
+function prevWeek() {
+ let week = parseInt($('#weekInput').val());
+ if (week > 1) {
+ $('#weekInput').val(week - 1);
+ loadSchedule();
+ }
+}
+
+function nextWeek() {
+ let week = parseInt($('#weekInput').val());
+ if (week < 52) {
+ $('#weekInput').val(week + 1);
+ loadSchedule();
+ }
+}
+
+function setMode(mode) {
+ currentMode = mode;
+
+ if (mode === 'group') {
+ $('#btnGroups').addClass('active');
+ $('#btnTeachers').removeClass('active');
+ } else {
+ $('#btnTeachers').addClass('active');
+ $('#btnGroups').removeClass('active');
+ }
+
+ loadItems();
+ showEmptyState('Выберите ' + (mode === 'group' ? 'группу' : 'преподавателя'));
+}
+
+$(document).ready(function() {
+ $('#weekInput').val(getCurrentWeek());
+ $('#currentWeekBadge').html(getCurrentWeek() + '-я неделя');
+
+ setMode('group');
+
+ $('#btnGroups').on('click', function() { setMode('group'); });
+ $('#btnTeachers').on('click', function() { setMode('teacher'); });
+ $('#entitySelect').on('change', loadSchedule);
+ $('#weekInput').on('change', loadSchedule);
+ $('#weekPrev').on('click', prevWeek);
+ $('#weekNext').on('click', nextWeek);
+});
\ No newline at end of file
diff --git a/server.py b/server.py
new file mode 100644
index 00000000..3a7f8141
--- /dev/null
+++ b/server.py
@@ -0,0 +1,95 @@
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.middleware.cors import CORSMiddleware
+import datetime
+import os
+import requests
+import uvicorn
+from parser import parse_schedule
+from catalog import GROUPS, TEACHERS
+
+app = FastAPI()
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+os.makedirs("static", exist_ok=True)
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+cache = {}
+
+HEADERS = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
+}
+
+def get_current_week():
+ start = datetime.date(2025, 9, 1)
+ today = datetime.date.today()
+ diff = (today - start).days
+ if diff < 0:
+ return 1
+ return (diff // 7) % 52 + 1
+
+@app.get("/")
+@app.get("/index.html")
+async def root():
+ with open("static/index.html", "r", encoding="utf-8") as f:
+ return HTMLResponse(content=f.read())
+
+@app.get("/api/groups")
+async def get_groups():
+ return GROUPS
+
+@app.get("/api/teachers")
+async def get_teachers():
+ return TEACHERS
+
+@app.get("/api/schedule/{group_id}")
+async def get_schedule(group_id: str, week: int = None):
+ if not week:
+ week = get_current_week()
+
+ cache_key = f"group_{group_id}_{week}"
+ if cache_key in cache:
+ return cache[cache_key]
+
+ try:
+ url = f"https://ssau.ru/rasp?groupId={group_id}&selectedWeek={week}"
+ response = requests.get(url, headers=HEADERS, timeout=10)
+ response.raise_for_status()
+
+ data = parse_schedule(response.text)
+ if data:
+ cache[cache_key] = data
+ return data
+ except Exception:
+ return []
+
+@app.get("/api/schedule/teacher/{teacher_id}")
+async def get_teacher_schedule(teacher_id: str, week: int = None):
+ if not week:
+ week = get_current_week()
+
+ cache_key = f"teacher_{teacher_id}_{week}"
+ if cache_key in cache:
+ return cache[cache_key]
+
+ try:
+ url = f"https://ssau.ru/rasp?staffId={teacher_id}&selectedWeek={week}"
+ response = requests.get(url, headers=HEADERS, timeout=10)
+ response.raise_for_status()
+
+ data = parse_schedule(response.text)
+ if data:
+ cache[cache_key] = data
+ return data
+ except Exception:
+ return []
+
+if __name__ == "__main__":
+ uvicorn.run(app, host="127.0.0.1", port=8000)
\ No newline at end of file
diff --git a/styles.css b/styles.css
new file mode 100644
index 00000000..b3e2995f
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,410 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #eef2f5;
+ min-height: 100vh;
+}
+
+.app-wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.main-container {
+ max-width: 1360px;
+ margin: 0 auto;
+ padding: 0 24px;
+}
+
+.top-panel {
+ background: #1e3a5f;
+ color: #ffffff;
+ padding: 14px 0;
+ position: sticky;
+ top: 0;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.12);
+}
+
+.panel-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.university-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.week-status {
+ background: rgba(255,255,255,0.18);
+ padding: 6px 16px;
+ border-radius: 28px;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.content-area {
+ flex: 1;
+ padding: 28px 0;
+}
+
+.mode-selector {
+ display: flex;
+ gap: 10px;
+ background: #ffffff;
+ padding: 6px;
+ border-radius: 50px;
+ margin-bottom: 24px;
+ box-shadow: 0 1px 6px rgba(0,0,0,0.06);
+}
+
+.mode-btn {
+ flex: 1;
+ padding: 9px 22px;
+ border: none;
+ background: transparent;
+ border-radius: 44px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ color: #5a6e8a;
+ transition: 0.2s;
+}
+
+.mode-btn.active {
+ background: #1e3a5f;
+ color: #ffffff;
+}
+
+.settings-block {
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 18px 22px;
+ margin-bottom: 24px;
+ display: flex;
+ gap: 24px;
+ flex-wrap: wrap;
+ box-shadow: 0 1px 6px rgba(0,0,0,0.05);
+}
+
+.setting-item {
+ flex: 1;
+ min-width: 190px;
+}
+
+.setting-caption {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: #6c7e9e;
+ margin-bottom: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.custom-select {
+ width: 100%;
+ padding: 10px 32px 10px 12px;
+ font-size: 0.85rem;
+ border: 1.5px solid #e2e8f0;
+ border-radius: 12px;
+ background: #ffffff;
+ cursor: pointer;
+ color: #1a2a3f;
+}
+
+.custom-select:focus {
+ outline: none;
+ border-color: #1e3a5f;
+}
+
+.custom-select option {
+ color: #1a2a3f;
+ background: #ffffff;
+}
+
+.week-navigation {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.nav-btn {
+ width: 38px;
+ height: 38px;
+ border: 1.5px solid #e2e8f0;
+ background: #ffffff;
+ border-radius: 12px;
+ cursor: pointer;
+ font-size: 1.1rem;
+ font-weight: 600;
+ transition: 0.2s;
+}
+
+.nav-btn:hover {
+ background: #1e3a5f;
+ color: #ffffff;
+ border-color: #1e3a5f;
+}
+
+.week-field {
+ width: 75px;
+ height: 38px;
+ text-align: center;
+ font-size: 0.9rem;
+ font-weight: 600;
+ border: 1.5px solid #e2e8f0;
+ border-radius: 12px;
+}
+
+.type-legend {
+ background: #ffffff;
+ border-radius: 14px;
+ padding: 12px 18px;
+ margin-bottom: 24px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 20px;
+ box-shadow: 0 1px 6px rgba(0,0,0,0.05);
+}
+
+.legend-label {
+ font-size: 0.7rem;
+ font-weight: 700;
+ color: #6c7e9e;
+}
+
+.legend-items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+.legend-entry {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.68rem;
+ color: #4a5a72;
+}
+
+.legend-marker {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ border-left-width: 3px;
+ border-left-style: solid;
+}
+
+.mark-lecture { border-left-color: #1e3a5f; background: #e6edf6; }
+.mark-practical { border-left-color: #2b6e3c; background: #e8f3ea; }
+.mark-labwork { border-left-color: #d97706; background: #fef3e4; }
+.mark-exam { border-left-color: #c0392b; background: #fcebe9; }
+.mark-credit { border-left-color: #7c3a9e; background: #f3eaf9; }
+.mark-other { border-left-color: #6b7280; background: #f2f2f4; }
+
+.loading-panel {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ padding: 42px;
+ background: #ffffff;
+ border-radius: 18px;
+ margin-bottom: 24px;
+}
+
+.loading-spinner {
+ width: 30px;
+ height: 30px;
+ border: 3px solid #e2e8f0;
+ border-top-color: #1e3a5f;
+ border-radius: 50%;
+ animation: rotateSpin 0.7s linear infinite;
+}
+
+@keyframes rotateSpin {
+ to { transform: rotate(360deg); }
+}
+
+.timetable-block {
+ background: #ffffff;
+ border-radius: 18px;
+ overflow-x: auto;
+ box-shadow: 0 1px 6px rgba(0,0,0,0.05);
+}
+
+.timetable-scroll {
+ overflow-x: auto;
+}
+
+.timetable-grid {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.timetable-grid thead tr {
+ background: #1e3a5f;
+ color: #ffffff;
+}
+
+.timetable-grid th {
+ padding: 12px 8px;
+ font-weight: 600;
+}
+
+.timetable-grid th:first-child {
+ width: 110px;
+ background: #15324f;
+}
+
+.timetable-grid td {
+ border-bottom: 1px solid #edf2f7;
+ vertical-align: top;
+ padding: 10px 8px;
+}
+
+.timetable-grid td:first-child {
+ background: #fafcff;
+ font-weight: 600;
+ text-align: center;
+ width: 110px;
+}
+
+.info-placeholder {
+ text-align: center;
+ padding: 55px !important;
+ color: #94a3b8;
+}
+
+.bottom-footer {
+ background: #0f2a3d;
+ color: #9aaebf;
+ padding: 16px 0;
+ text-align: center;
+ font-size: 0.68rem;
+ margin-top: 40px;
+}
+
+.lesson-card {
+ border-radius: 8px;
+ padding: 7px;
+ margin-bottom: 5px;
+}
+
+.lesson-lecture {
+ border-left: 3px solid #1e3a5f;
+ background: #e6edf6;
+}
+
+.lesson-practice {
+ border-left: 3px solid #2b6e3c;
+ background: #e8f3ea;
+}
+
+.lesson-lab {
+ border-left: 3px solid #d97706;
+ background: #fef3e4;
+}
+
+.lesson-exam {
+ border-left: 3px solid #c0392b;
+ background: #fcebe9;
+}
+
+.lesson-credit {
+ border-left: 3px solid #7c3a9e;
+ background: #f3eaf9;
+}
+
+.lesson-default {
+ border-left: 3px solid #6b7280;
+ background: #f2f2f4;
+}
+
+.lesson-teacher, .lesson-room {
+ font-size: 10px;
+ color: #5a6e8a;
+}
+
+.lesson-empty {
+ color: #cbd5e1;
+ text-align: center;
+ padding: 6px;
+ display: block;
+}
+
+@media (max-width: 768px) {
+ .main-container {
+ padding: 0 14px;
+ }
+
+ .settings-block {
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .mode-btn {
+ padding: 7px 14px;
+ font-size: 0.75rem;
+ }
+
+ .legend-items {
+ gap: 10px;
+ }
+
+ .legend-entry {
+ font-size: 0.6rem;
+ }
+
+ .timetable-grid {
+ font-size: 0.68rem;
+ }
+
+ .timetable-grid th:first-child {
+ width: 85px;
+ }
+
+ .timetable-grid td:first-child {
+ width: 85px;
+ }
+
+ .lesson-card {
+ padding: 4px;
+ }
+
+ .lesson-teacher, .lesson-room {
+ font-size: 8px;
+ }
+}
+
+/* Принудительные стили для селекта */
+#entitySelect {
+ color: #1a2a3f !important;
+ background-color: #ffffff !important;
+}
+
+#entitySelect option {
+ color: #1a2a3f !important;
+ background-color: #ffffff !important;
+}
+.control-select option {
+ color: #333;
+ background: white;
+}
\ No newline at end of file