feat(schedule): auto-parsing groups from target site

This commit is contained in:
kilyabin
2026-03-02 14:12:01 +04:00
parent 9bca838fbc
commit da7b4fe812
13 changed files with 313 additions and 64 deletions

View File

@@ -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 readonly 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
```

View File

@@ -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 = {}

103
src/app/agregator/groups.ts Normal file
View File

@@ -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<Document> {
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<GroupsData> {
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<GroupsData> {
const groups = await loadGroupsFromKspsuti()
lastSyncAt = Date.now()
lastResult = groups
return groups
}
export async function syncGroupsFromKspsutiIfNeeded(ttlMs: number): Promise<GroupsData | null> {
const now = Date.now()
if (lastSyncAt && lastResult && now - lastSyncAt < ttlMs) {
return lastResult
}
try {
return await syncGroupsFromKspsuti()
} catch {
// В случае ошибки не ломаем основной поток — просто возвращаем null.
return null
}
}

112
src/app/parser/groups.ts Normal file
View File

@@ -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 {
// На странице групп "колонки курсов" лежат во внешнем <tr>,
// а сами ссылки групп — во вложенных таблицах внутри этих колонок.
// Поэтому нужно подняться до такого <tr>, у которого:
// - прямые дети: несколько <td>, среди них есть разделители width="1"
// - если убрать разделители, останется ровно 5 колонок (15 курс)
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 {
// Генерируем стабильный ASCIIid, совместимый с 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<HTMLAnchorElement>('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
}

View File

@@ -154,7 +154,7 @@ function cleanupCache() {
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
// Используем кеш (обновляется каждую минуту автоматически)
const groups = loadGroups()
const groups = await loadGroups()
const settings = loadSettings()
const group = context.params?.group
const wkParam = context.query.wk

View File

@@ -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<boolean | null>(null)
const [password, setPassword] = React.useState('')
const [loading, setLoading] = React.useState(false)
@@ -590,9 +592,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
<CardTitle>Группы</CardTitle>
<CardDescription>Управление группами для расписания</CardDescription>
</div>
{!isKspsutiMode && (
<Button onClick={() => setShowAddDialog(true)}>
Добавить группу
</Button>
)}
</div>
</CardHeader>
<CardContent>
@@ -610,7 +614,13 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
<div className="text-sm text-muted-foreground">
ID: {id} | Parse ID: {group.parseId} | Курс: {group.course}
</div>
{isKspsutiMode && (
<div className="text-xs text-muted-foreground mt-1">
Группа получена автоматически с lk.ks.psuti.ru. Редактирование отключено.
</div>
)}
</div>
{!isKspsutiMode && (
<div className="flex gap-2">
<Button
variant="outline"
@@ -627,6 +637,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
Удалить
</Button>
</div>
)}
</div>
))}
</div>
@@ -1124,7 +1135,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
}
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
const groups = loadGroups()
const groups = await loadGroups()
const settings = loadSettings()
// Проверяем, используется ли дефолтный пароль
@@ -1135,7 +1146,8 @@ export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () =
props: {
groups,
settings,
isDefaultPassword: isDefault
isDefaultPassword: isDefault,
isKspsutiMode: SCHED_MODE === 'kspsuti',
}
}
}

View File

@@ -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<ResponseData>
) {
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
}

View File

@@ -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
}

View File

@@ -118,17 +118,6 @@ export default function HomePage(props: HomePageProps) {
const [openCourses, setOpenCourses] = React.useState<Set<number>>(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) {
<CardContent>
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{courseGroups.map(({ id, name }, groupIndex) => {
// Последовательная анимация: каждый следующий элемент с задержкой
// courseOffset - это количество групп во всех предыдущих курсах
// groupIndex - это индекс в текущем курсе
// Итого: последовательный счетчик для всех групп подряд
const globalIndex = courseOffset + groupIndex
const delay = 0.15 + globalIndex * 0.04
return (
<div
key={id}
className="stagger-card"
style={{
animationDelay: `${delay}s`,
} as React.CSSProperties}
>
<Link href={`/${id}`}>
<Button
@@ -239,7 +218,6 @@ export default function HomePage(props: HomePageProps) {
{showTeachersButton && (
<div
className="stagger-card mt-6"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
>
<Link href="/teachers" className="block">
<Button variant="default" className="w-full h-auto py-4 text-base font-semibold">
@@ -253,7 +231,6 @@ export default function HomePage(props: HomePageProps) {
{showAddGroupButton && (
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
>
<Button
variant="secondary"
@@ -267,13 +244,11 @@ export default function HomePage(props: HomePageProps) {
)}
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
>
<ThemeSwitcher />
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.14 : 0.11)}s` } as React.CSSProperties}
>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">
@@ -329,7 +304,7 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
}
// Если режим каникул выключен, загружаем группы и обрабатываем их
const groups = loadGroups()
const groups = await loadGroups()
// Группируем группы по курсам
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}

View File

@@ -5,7 +5,7 @@ import { SITEMAP_SITE_URL } from '@/shared/constants/urls'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
// Используем кеш (обновляется каждую минуту автоматически)
const groups = loadGroups()
const groups = await loadGroups()
const fields = Object.keys(groups).map<ISitemapField>(group => (
{
loc: `${SITEMAP_SITE_URL}/${group}`,

View File

@@ -166,7 +166,7 @@ function cleanupCache() {
}
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
const groups = loadGroups()
const groups = await loadGroups()
const settings = loadSettings()
const teacherParam = context.params?.teacher
const wkParam = context.query.wk

View File

@@ -17,3 +17,10 @@ export const TELEGRAM_CONTACT_URL = 'https://t.me/ilyakm'
// Teacher photos base URL
export const TEACHER_PHOTOS_BASE_URL = `${KS_PSUTI_IMAGES_BASE_URL}/stories`
// Schedule mode: controls how groups and schedules are obtained
export type SchedMode = 'hobby' | 'kspsuti'
const rawSchedMode = process.env.SCHED_MODE?.toLowerCase()
export const SCHED_MODE: SchedMode = rawSchedMode === 'kspsuti' ? 'kspsuti' : 'hobby'

View File

@@ -1,4 +1,6 @@
import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database'
import { SCHED_MODE } from '@/shared/constants/urls'
import { syncGroupsFromKspsutiIfNeeded } from '@/app/agregator/groups'
export type GroupInfo = {
parseId: number
@@ -11,12 +13,14 @@ export type GroupsData = { [group: string]: GroupInfo }
let cachedGroups: GroupsData | null = null
let cacheTimestamp: number = 0
const CACHE_TTL_MS = 1000 * 60 // 1 минута
const KSPSUTI_SYNC_TTL_MS = 1000 * 60 * 60 // 1 час
/**
* Загружает группы из базы данных
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
* Загружает группы из базы данных.
* В режиме SCHED_MODE=kspsuti перед загрузкой пытается синхронизировать список групп с сайтом колледжа.
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости.
*/
export function loadGroups(forceRefresh: boolean = false): GroupsData {
export async function loadGroups(forceRefresh: boolean = false): Promise<GroupsData> {
const now = Date.now()
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
@@ -24,6 +28,15 @@ export function loadGroups(forceRefresh: boolean = false): GroupsData {
return cachedGroups
}
// В авто‑режиме сначала пробуем синхронизировать группы с lk.ks.psuti.ru.
if (SCHED_MODE === 'kspsuti') {
const synced = await syncGroupsFromKspsutiIfNeeded(KSPSUTI_SYNC_TTL_MS)
if (synced) {
saveGroups(synced)
clearGroupsCache()
}
}
try {
cachedGroups = getAllGroupsFromDB()
cacheTimestamp = now