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
|
||||
- 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
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>>> {
|
||||
// Используем кеш (обновляется каждую минуту автоматически)
|
||||
const groups = loadGroups()
|
||||
const groups = await loadGroups()
|
||||
const settings = loadSettings()
|
||||
const group = context.params?.group
|
||||
const wkParam = context.query.wk
|
||||
|
||||
@@ -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>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
{!isKspsutiMode && (
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -610,23 +614,30 @@ 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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(id)}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
{!isKspsutiMode && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(id)}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(id)}
|
||||
>
|
||||
Удалить
|
||||
</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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }> } = {}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user