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