feat(schedule): auto-parsing groups from target site
This commit is contained in:
15
README.md
15
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
|
- Dark theme with automatic switching based on system settings
|
||||||
- Admin panel for managing groups and settings
|
- Admin panel for managing groups and settings
|
||||||
- Optimized code structure with reusable utilities and components
|
- Optimized code structure with reusable utilities and components
|
||||||
|
- Optional auto-sync of groups from `lk.ks.psuti.ru` controlled by env
|
||||||
|
|
||||||
## Architecture & Code Organization
|
## 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.
|
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
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ let groups: { [group: string]: [number, string] } = {}
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
// Серверная сторона
|
// Серверная сторона
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// Исторически здесь использовался загрузчик групп.
|
||||||
const groupsLoader = require('./groups-loader')
|
// В актуальной версии приложения данные о группах берутся из новой подсистемы,
|
||||||
groups = groupsLoader.loadGroups()
|
// поэтому для старого кода оставляем пустой объект.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading groups:', error)
|
console.error('Error loading groups:', error)
|
||||||
groups = {}
|
groups = {}
|
||||||
|
|||||||
103
src/app/agregator/groups.ts
Normal file
103
src/app/agregator/groups.ts
Normal 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
112
src/app/parser/groups.ts
Normal 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 колонок (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<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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ function cleanupCache() {
|
|||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||||
// Используем кеш (обновляется каждую минуту автоматически)
|
// Используем кеш (обновляется каждую минуту автоматически)
|
||||||
const groups = loadGroups()
|
const groups = await loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const group = context.params?.group
|
const group = context.params?.group
|
||||||
const wkParam = context.query.wk
|
const wkParam = context.query.wk
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/shadcn/ui/accordion'
|
} from '@/shadcn/ui/accordion'
|
||||||
|
import { SCHED_MODE } from '@/shared/constants/urls'
|
||||||
|
|
||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
settings: AppSettings
|
settings: AppSettings
|
||||||
isDefaultPassword: boolean
|
isDefaultPassword: boolean
|
||||||
|
isKspsutiMode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Компонент Toggle Switch
|
// Компонент 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 [authenticated, setAuthenticated] = React.useState<boolean | null>(null)
|
||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
@@ -590,9 +592,11 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
<CardTitle>Группы</CardTitle>
|
<CardTitle>Группы</CardTitle>
|
||||||
<CardDescription>Управление группами для расписания</CardDescription>
|
<CardDescription>Управление группами для расписания</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
{!isKspsutiMode && (
|
||||||
<Button onClick={() => setShowAddDialog(true)}>
|
<Button onClick={() => setShowAddDialog(true)}>
|
||||||
Добавить группу
|
Добавить группу
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -610,7 +614,13 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
ID: {id} | Parse ID: {group.parseId} | Курс: {group.course}
|
ID: {id} | Parse ID: {group.parseId} | Курс: {group.course}
|
||||||
</div>
|
</div>
|
||||||
|
{isKspsutiMode && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
Группа получена автоматически с lk.ks.psuti.ru. Редактирование отключено.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isKspsutiMode && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -627,6 +637,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1124,7 +1135,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () => {
|
||||||
const groups = loadGroups()
|
const groups = await loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
|
|
||||||
// Проверяем, используется ли дефолтный пароль
|
// Проверяем, используется ли дефолтный пароль
|
||||||
@@ -1135,7 +1146,8 @@ export const getServerSideProps: GetServerSideProps<AdminPageProps> = async () =
|
|||||||
props: {
|
props: {
|
||||||
groups,
|
groups,
|
||||||
settings,
|
settings,
|
||||||
isDefaultPassword: isDefault
|
isDefaultPassword: isDefault,
|
||||||
|
isKspsutiMode: SCHED_MODE === 'kspsuti',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||||
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||||
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
|
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
|
||||||
|
import { SCHED_MODE } from '@/shared/constants/urls'
|
||||||
|
|
||||||
type ResponseData = ApiResponse<{
|
type ResponseData = ApiResponse<{
|
||||||
groups?: GroupsData
|
groups?: GroupsData
|
||||||
@@ -11,10 +12,15 @@ async function handler(
|
|||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ResponseData>
|
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') {
|
if (req.method === 'GET') {
|
||||||
// Получение списка групп (всегда свежие данные для админ-панели)
|
// Получение списка групп (всегда свежие данные для админ-панели)
|
||||||
clearGroupsCache()
|
clearGroupsCache()
|
||||||
const groups = loadGroups(true)
|
const groups = await loadGroups(true)
|
||||||
res.status(200).json({ groups })
|
res.status(200).json({ groups })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -50,7 +56,7 @@ async function handler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = loadGroups()
|
const groups = await loadGroups()
|
||||||
|
|
||||||
// Проверка на дубликат
|
// Проверка на дубликат
|
||||||
if (groups[id]) {
|
if (groups[id]) {
|
||||||
@@ -68,7 +74,7 @@ async function handler(
|
|||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
clearGroupsCache()
|
clearGroupsCache()
|
||||||
const updatedGroups = loadGroups(true)
|
const updatedGroups = await loadGroups(true)
|
||||||
res.status(200).json({ success: true, groups: updatedGroups })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
|
||||||
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
|
||||||
import { validateCourse } from '@/shared/utils/validation'
|
import { validateCourse } from '@/shared/utils/validation'
|
||||||
|
import { SCHED_MODE } from '@/shared/constants/urls'
|
||||||
|
|
||||||
type ResponseData = ApiResponse<{
|
type ResponseData = ApiResponse<{
|
||||||
groups?: GroupsData
|
groups?: GroupsData
|
||||||
@@ -18,8 +19,13 @@ async function handler(
|
|||||||
return
|
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') {
|
if (req.method === 'PUT') {
|
||||||
// Редактирование группы
|
// Редактирование группы
|
||||||
@@ -56,7 +62,7 @@ async function handler(
|
|||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
clearGroupsCache()
|
clearGroupsCache()
|
||||||
const updatedGroups = loadGroups(true)
|
const updatedGroups = await loadGroups(true)
|
||||||
res.status(200).json({ success: true, groups: updatedGroups })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,7 @@ async function handler(
|
|||||||
saveGroups(groups)
|
saveGroups(groups)
|
||||||
// Сбрасываем кеш и загружаем свежие данные из БД
|
// Сбрасываем кеш и загружаем свежие данные из БД
|
||||||
clearGroupsCache()
|
clearGroupsCache()
|
||||||
const updatedGroups = loadGroups(true)
|
const updatedGroups = await loadGroups(true)
|
||||||
res.status(200).json({ success: true, groups: updatedGroups })
|
res.status(200).json({ success: true, groups: updatedGroups })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,17 +118,6 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
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) => {
|
const toggleCourse = (course: number) => {
|
||||||
setOpenCourses(prev => {
|
setOpenCourses(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -158,7 +147,6 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
{[1, 2, 3, 4, 5].map((course, courseIndex) => {
|
{[1, 2, 3, 4, 5].map((course, courseIndex) => {
|
||||||
const courseGroups = groupsByCourse[course] || []
|
const courseGroups = groupsByCourse[course] || []
|
||||||
const isOpen = openCourses.has(course)
|
const isOpen = openCourses.has(course)
|
||||||
const courseOffset = courseOffsets.offsets[course]
|
|
||||||
|
|
||||||
if (courseGroups.length === 0) {
|
if (courseGroups.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -193,19 +181,10 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{courseGroups.map(({ id, name }, groupIndex) => {
|
{courseGroups.map(({ id, name }, groupIndex) => {
|
||||||
// Последовательная анимация: каждый следующий элемент с задержкой
|
|
||||||
// courseOffset - это количество групп во всех предыдущих курсах
|
|
||||||
// groupIndex - это индекс в текущем курсе
|
|
||||||
// Итого: последовательный счетчик для всех групп подряд
|
|
||||||
const globalIndex = courseOffset + groupIndex
|
|
||||||
const delay = 0.15 + globalIndex * 0.04
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className="stagger-card"
|
className="stagger-card"
|
||||||
style={{
|
|
||||||
animationDelay: `${delay}s`,
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
>
|
||||||
<Link href={`/${id}`}>
|
<Link href={`/${id}`}>
|
||||||
<Button
|
<Button
|
||||||
@@ -239,7 +218,6 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
{showTeachersButton && (
|
{showTeachersButton && (
|
||||||
<div
|
<div
|
||||||
className="stagger-card mt-6"
|
className="stagger-card mt-6"
|
||||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
|
||||||
>
|
>
|
||||||
<Link href="/teachers" className="block">
|
<Link href="/teachers" className="block">
|
||||||
<Button variant="default" className="w-full h-auto py-4 text-base font-semibold">
|
<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 && (
|
{showAddGroupButton && (
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
className="stagger-card"
|
||||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -267,13 +244,11 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
className="stagger-card"
|
||||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
|
|
||||||
>
|
>
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="stagger-card"
|
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">
|
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<Button variant="outline" className="gap-2">
|
<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 }> } = {}
|
const groupsByCourse: { [course: number]: Array<{ id: string; name: string }> } = {}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SITEMAP_SITE_URL } from '@/shared/constants/urls'
|
|||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
// Используем кеш (обновляется каждую минуту автоматически)
|
// Используем кеш (обновляется каждую минуту автоматически)
|
||||||
const groups = loadGroups()
|
const groups = await loadGroups()
|
||||||
const fields = Object.keys(groups).map<ISitemapField>(group => (
|
const fields = Object.keys(groups).map<ISitemapField>(group => (
|
||||||
{
|
{
|
||||||
loc: `${SITEMAP_SITE_URL}/${group}`,
|
loc: `${SITEMAP_SITE_URL}/${group}`,
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ function cleanupCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
|
||||||
const groups = loadGroups()
|
const groups = await loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const teacherParam = context.params?.teacher
|
const teacherParam = context.params?.teacher
|
||||||
const wkParam = context.query.wk
|
const wkParam = context.query.wk
|
||||||
|
|||||||
@@ -17,3 +17,10 @@ export const TELEGRAM_CONTACT_URL = 'https://t.me/ilyakm'
|
|||||||
// Teacher photos base URL
|
// Teacher photos base URL
|
||||||
export const TEACHER_PHOTOS_BASE_URL = `${KS_PSUTI_IMAGES_BASE_URL}/stories`
|
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'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getAllGroups as getAllGroupsFromDB, createGroup, updateGroup, deleteGroup, getGroup } from './database'
|
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 = {
|
export type GroupInfo = {
|
||||||
parseId: number
|
parseId: number
|
||||||
@@ -11,12 +13,14 @@ export type GroupsData = { [group: string]: GroupInfo }
|
|||||||
let cachedGroups: GroupsData | null = null
|
let cachedGroups: GroupsData | null = null
|
||||||
let cacheTimestamp: number = 0
|
let cacheTimestamp: number = 0
|
||||||
const CACHE_TTL_MS = 1000 * 60 // 1 минута
|
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 now = Date.now()
|
||||||
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
const isCacheValid = cachedGroups !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
|
||||||
|
|
||||||
@@ -24,6 +28,15 @@ export function loadGroups(forceRefresh: boolean = false): GroupsData {
|
|||||||
return cachedGroups
|
return cachedGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В авто‑режиме сначала пробуем синхронизировать группы с lk.ks.psuti.ru.
|
||||||
|
if (SCHED_MODE === 'kspsuti') {
|
||||||
|
const synced = await syncGroupsFromKspsutiIfNeeded(KSPSUTI_SYNC_TTL_MS)
|
||||||
|
if (synced) {
|
||||||
|
saveGroups(synced)
|
||||||
|
clearGroupsCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cachedGroups = getAllGroupsFromDB()
|
cachedGroups = getAllGroupsFromDB()
|
||||||
cacheTimestamp = now
|
cacheTimestamp = now
|
||||||
|
|||||||
Reference in New Issue
Block a user