From da7b4fe8124427c9a3b45e070e9af6905b9f71af Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:12:01 +0400 Subject: [PATCH] feat(schedule): auto-parsing groups from target site --- README.md | 15 ++++ old/data/groups.ts | 6 +- src/app/agregator/groups.ts | 103 ++++++++++++++++++++++++++ src/app/parser/groups.ts | 112 +++++++++++++++++++++++++++++ src/pages/[group].tsx | 2 +- src/pages/admin.tsx | 56 +++++++++------ src/pages/api/admin/groups.ts | 12 +++- src/pages/api/admin/groups/[id].ts | 12 +++- src/pages/index.tsx | 27 +------ src/pages/sitemap.xml/index.tsx | 2 +- src/pages/teacher/[teacher].tsx | 2 +- src/shared/constants/urls.ts | 7 ++ src/shared/data/groups-loader.ts | 21 ++++-- 13 files changed, 313 insertions(+), 64 deletions(-) create mode 100644 src/app/agregator/groups.ts create mode 100644 src/app/parser/groups.ts diff --git a/README.md b/README.md index 03de1f9..9a9f445 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support. - Dark theme with automatic switching based on system settings - Admin panel for managing groups and settings - Optimized code structure with reusable utilities and components + - Optional auto-sync of groups from `lk.ks.psuti.ru` controlled by env ## Architecture & Code Organization @@ -59,6 +60,20 @@ The project follows a feature-sliced design pattern with clear separation of con Workaround: Locate to next week, then enter previous twice. +## Schedule modes + +The behaviour of group management and data source is controlled by the `SCHED_MODE` environment variable: + +- `hobby` (default): + - Groups are managed manually via the admin panel (add/edit/delete). + - The app uses whatever is stored in the local SQLite `groups` table. +- `kspsuti`: + - On the server, the app periodically fetches `https://lk.ks.psuti.ru/?mn=2`, parses the group list (day + distance), and synchronises it into the local database. + - The admin panel shows the current groups but disables manual editing/removal — groups are treated as read‑only and must be changed on the college site. + - All pages that call `loadGroups()` automatically work with the synced list. + +Set `SCHED_MODE=kspsuti` during deployment to enable automatic group syncing; omit it or set `SCHED_MODE=hobby` to keep the previous manual workflow. + ## Project structure ``` diff --git a/old/data/groups.ts b/old/data/groups.ts index 94f9c3e..d540299 100644 --- a/old/data/groups.ts +++ b/old/data/groups.ts @@ -6,9 +6,9 @@ let groups: { [group: string]: [number, string] } = {} if (typeof window === 'undefined') { // Серверная сторона try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const groupsLoader = require('./groups-loader') - groups = groupsLoader.loadGroups() + // Исторически здесь использовался загрузчик групп. + // В актуальной версии приложения данные о группах берутся из новой подсистемы, + // поэтому для старого кода оставляем пустой объект. } catch (error) { console.error('Error loading groups:', error) groups = {} diff --git a/src/app/agregator/groups.ts b/src/app/agregator/groups.ts new file mode 100644 index 0000000..0e12143 --- /dev/null +++ b/src/app/agregator/groups.ts @@ -0,0 +1,103 @@ +import contentTypeParser from 'content-type' +import { JSDOM } from 'jsdom' +import { PROXY_URL } from '@/shared/constants/urls' +import { logErrorToFile, logInfo } from '@/app/logger' +import { parseGroupsList, GroupListParseError } from '@/app/parser/groups' +import type { GroupsData } from '@/shared/data/groups-loader' + +export class GroupsTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'GroupsTimeoutError' + } +} + +let lastSyncAt: number | null = null +let lastResult: GroupsData | null = null + +async function fetchGroupsPage(): Promise { + const url = `${PROXY_URL}/?mn=2` + logInfo('Groups fetch start', { url }) + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 8000) + + try { + const page = await fetch(url, { signal: controller.signal }) + clearTimeout(timeoutId) + + const content = await page.text() + const contentType = page.headers.get('content-type') + + if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') { + const dom = new JSDOM(content, { url }) + return dom.window.document + } + + const error = new Error(`Failed to fetch groups page: status ${page.status}`) + logErrorToFile(error, { + type: 'groups_fetch_error', + url, + status: page.status, + contentType, + }) + throw error + } catch (error) { + clearTimeout(timeoutId) + if (error instanceof Error && error.name === 'AbortError') { + const timeoutError = new GroupsTimeoutError(`Request timeout while fetching groups page ${url}`) + logErrorToFile(timeoutError, { + type: 'groups_timeout_error', + url, + }) + throw timeoutError + } + + const errorObj = error instanceof Error ? error : new Error(String(error)) + logErrorToFile(errorObj, { + type: 'groups_unknown_error', + url, + }) + throw errorObj + } +} + +export async function loadGroupsFromKspsuti(): Promise { + const document = await fetchGroupsPage() + + try { + const groups = parseGroupsList(document, document.location?.href) + logInfo('Groups parsed successfully', { groupsCount: Object.keys(groups).length }) + return groups + } catch (error) { + const errorObj = error instanceof Error ? error : new Error(String(error)) + logErrorToFile(errorObj, { + type: errorObj instanceof GroupListParseError ? 'groups_parse_error' : 'groups_unknown_parse_error', + url: document.location?.href, + }) + throw errorObj + } +} + +export async function syncGroupsFromKspsuti(): Promise { + const groups = await loadGroupsFromKspsuti() + lastSyncAt = Date.now() + lastResult = groups + return groups +} + +export async function syncGroupsFromKspsutiIfNeeded(ttlMs: number): Promise { + const now = Date.now() + + if (lastSyncAt && lastResult && now - lastSyncAt < ttlMs) { + return lastResult + } + + try { + return await syncGroupsFromKspsuti() + } catch { + // В случае ошибки не ломаем основной поток — просто возвращаем null. + return null + } +} + diff --git a/src/app/parser/groups.ts b/src/app/parser/groups.ts new file mode 100644 index 0000000..ca70a29 --- /dev/null +++ b/src/app/parser/groups.ts @@ -0,0 +1,112 @@ +import { logDebug } from '@/app/logger' +import type { GroupsData } from '@/shared/data/groups-loader' + +export class GroupListParseError extends Error { + constructor(message: string) { + super(message) + this.name = 'GroupListParseError' + } +} + +function extractParseIdFromHref(href: string | null): number | null { + if (!href) return null + const match = href.match(/[?&]obj=(\d+)/) + if (!match) return null + const id = Number(match[1]) + return Number.isInteger(id) && id > 0 ? id : null +} + +function detectCourseFromAnchor(anchor: HTMLAnchorElement): number { + // На странице групп "колонки курсов" лежат во внешнем , + // а сами ссылки групп — во вложенных таблицах внутри этих колонок. + // Поэтому нужно подняться до такого , у которого: + // - прямые дети: несколько , среди них есть разделители width="1" + // - если убрать разделители, останется ровно 5 колонок (1–5 курс) + + const isSeparatorTd = (td: HTMLTableCellElement) => td.getAttribute('width')?.trim() === '1' + + let current: Element | null = anchor + while (current) { + if (current.tagName === 'TR') { + const directTds = Array.from(current.children).filter((el) => el.tagName === 'TD') as HTMLTableCellElement[] + if (directTds.length >= 5) { + const contentTds = directTds.filter((td) => !isSeparatorTd(td)) + if (contentTds.length === 5) { + const idx = contentTds.findIndex((td) => td.contains(anchor)) + if (idx >= 0) { + return idx + 1 + } + } + } + } + current = current.parentElement + } + + logDebug('detectCourseFromAnchor: failed to detect course, falling back to 1', { + anchorText: (anchor.textContent || '').trim(), + }) + return 1 +} + +function buildGroupId(parseId: number, isDistance: boolean): string { + // Генерируем стабильный ASCII‑id, совместимый с validateGroupId (a-z0-9_-). + // Для заочного отделения добавляем префикс za_. + const prefix = isDistance ? 'za_' : '' + return `${prefix}g${parseId}` +} + +export function parseGroupsList(document: Document, url?: string): GroupsData { + const anchors = Array.from(document.querySelectorAll('a[href*="?mn=2&obj="]')) + + if (anchors.length === 0) { + throw new GroupListParseError('Не найдены ссылки на группы (?mn=2&obj=...)') + } + + const groups: GroupsData = {} + + for (const anchor of anchors) { + const href = anchor.getAttribute('href') + const parseId = extractParseIdFromHref(href) + if (!parseId) { + continue + } + + const rawName = (anchor.textContent || '').trim() + if (!rawName) { + logDebug('parseGroupsList: anchor without text', { href }) + continue + } + + const isDistance = /\(з\/о\)/i.test(rawName) + const course = detectCourseFromAnchor(anchor) + const id = buildGroupId(parseId, isDistance) + + if (groups[id]) { + // Если вдруг id уже есть, логируем и пропускаем дубликат. + logDebug('parseGroupsList: duplicate group id, skipping', { + id, + existingParseId: groups[id].parseId, + newParseId: parseId, + }) + continue + } + + groups[id] = { + parseId, + name: rawName, + course, + } + } + + if (Object.keys(groups).length === 0) { + throw new GroupListParseError('Удалось найти ссылки на группы, но после парсинга список пуст') + } + + logDebug('parseGroupsList: parsed groups summary', { + groupsCount: Object.keys(groups).length, + url: url || document.location?.href || '', + }) + + return groups +} + diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 1e17297..0638b31 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -154,7 +154,7 @@ function cleanupCache() { export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise>> { // Используем кеш (обновляется каждую минуту автоматически) - const groups = loadGroups() + const groups = await loadGroups() const settings = loadSettings() const group = context.params?.group const wkParam = context.query.wk diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index ada071c..8798a38 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -23,11 +23,13 @@ import { AccordionItem, AccordionTrigger, } from '@/shadcn/ui/accordion' +import { SCHED_MODE } from '@/shared/constants/urls' type AdminPageProps = { groups: GroupsData settings: AppSettings isDefaultPassword: boolean + isKspsutiMode: boolean } // Компонент Toggle Switch @@ -94,7 +96,7 @@ function DialogFooterButtons({ onCancel, onSubmit, submitLabel, loading, submitV ) } -export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword }: AdminPageProps) { +export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword, isKspsutiMode }: AdminPageProps) { const [authenticated, setAuthenticated] = React.useState(null) const [password, setPassword] = React.useState('') const [loading, setLoading] = React.useState(false) @@ -590,9 +592,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett Группы Управление группами для расписания - + {!isKspsutiMode && ( + + )} @@ -610,23 +614,30 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
ID: {id} | Parse ID: {group.parseId} | Курс: {group.course}
+ {isKspsutiMode && ( +
+ Группа получена автоматически с lk.ks.psuti.ru. Редактирование отключено. +
+ )} -
- - -
+ {!isKspsutiMode && ( +
+ + +
+ )} ))} @@ -1124,7 +1135,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett } export const getServerSideProps: GetServerSideProps = async () => { - const groups = loadGroups() + const groups = await loadGroups() const settings = loadSettings() // Проверяем, используется ли дефолтный пароль @@ -1135,7 +1146,8 @@ export const getServerSideProps: GetServerSideProps = async () = props: { groups, settings, - isDefaultPassword: isDefault + isDefaultPassword: isDefault, + isKspsutiMode: SCHED_MODE === 'kspsuti', } } } diff --git a/src/pages/api/admin/groups.ts b/src/pages/api/admin/groups.ts index d8297b9..911524f 100644 --- a/src/pages/api/admin/groups.ts +++ b/src/pages/api/admin/groups.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper' import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader' import { validateGroupId, validateCourse } from '@/shared/utils/validation' +import { SCHED_MODE } from '@/shared/constants/urls' type ResponseData = ApiResponse<{ groups?: GroupsData @@ -11,10 +12,15 @@ async function handler( req: NextApiRequest, res: NextApiResponse ) { + if (SCHED_MODE === 'kspsuti' && req.method !== 'GET') { + res.status(403).json({ error: 'Groups are managed automatically from lk.ks.psuti.ru in this mode' }) + return + } + if (req.method === 'GET') { // Получение списка групп (всегда свежие данные для админ-панели) clearGroupsCache() - const groups = loadGroups(true) + const groups = await loadGroups(true) res.status(200).json({ groups }) return } @@ -50,7 +56,7 @@ async function handler( return } - const groups = loadGroups() + const groups = await loadGroups() // Проверка на дубликат if (groups[id]) { @@ -68,7 +74,7 @@ async function handler( saveGroups(groups) // Сбрасываем кеш и загружаем свежие данные из БД clearGroupsCache() - const updatedGroups = loadGroups(true) + const updatedGroups = await loadGroups(true) res.status(200).json({ success: true, groups: updatedGroups }) return } diff --git a/src/pages/api/admin/groups/[id].ts b/src/pages/api/admin/groups/[id].ts index bef53a3..cc377b9 100644 --- a/src/pages/api/admin/groups/[id].ts +++ b/src/pages/api/admin/groups/[id].ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper' import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader' import { validateCourse } from '@/shared/utils/validation' +import { SCHED_MODE } from '@/shared/constants/urls' type ResponseData = ApiResponse<{ groups?: GroupsData @@ -18,8 +19,13 @@ async function handler( return } + if (SCHED_MODE === 'kspsuti') { + res.status(403).json({ error: 'Groups are managed automatically from lk.ks.psuti.ru in this mode' }) + return + } + // Загружаем группы с проверкой кеша - let groups = loadGroups() + let groups = await loadGroups() if (req.method === 'PUT') { // Редактирование группы @@ -56,7 +62,7 @@ async function handler( saveGroups(groups) // Сбрасываем кеш и загружаем свежие данные из БД clearGroupsCache() - const updatedGroups = loadGroups(true) + const updatedGroups = await loadGroups(true) res.status(200).json({ success: true, groups: updatedGroups }) return } @@ -73,7 +79,7 @@ async function handler( saveGroups(groups) // Сбрасываем кеш и загружаем свежие данные из БД clearGroupsCache() - const updatedGroups = loadGroups(true) + const updatedGroups = await loadGroups(true) res.status(200).json({ success: true, groups: updatedGroups }) return } diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 9c096d9..347db77 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -118,17 +118,6 @@ export default function HomePage(props: HomePageProps) { const [openCourses, setOpenCourses] = React.useState>(new Set()) const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false) - // Подсчитываем смещения для каждого курса для последовательной анимации - const courseOffsets = React.useMemo(() => { - const offsets: { [course: number]: number } = {} - let totalGroups = 0 - for (const course of [1, 2, 3, 4, 5]) { - offsets[course] = totalGroups - totalGroups += (groupsByCourse[course] || []).length - } - return { offsets, totalGroups } - }, [groupsByCourse]) - const toggleCourse = (course: number) => { setOpenCourses(prev => { const next = new Set(prev) @@ -158,7 +147,6 @@ export default function HomePage(props: HomePageProps) { {[1, 2, 3, 4, 5].map((course, courseIndex) => { const courseGroups = groupsByCourse[course] || [] const isOpen = openCourses.has(course) - const courseOffset = courseOffsets.offsets[course] if (courseGroups.length === 0) { return null @@ -193,19 +181,10 @@ export default function HomePage(props: HomePageProps) {
{courseGroups.map(({ id, name }, groupIndex) => { - // Последовательная анимация: каждый следующий элемент с задержкой - // courseOffset - это количество групп во всех предыдущих курсах - // groupIndex - это индекс в текущем курсе - // Итого: последовательный счетчик для всех групп подряд - const globalIndex = courseOffset + groupIndex - const delay = 0.15 + globalIndex * 0.04 return (