feat: schedule of teachers (but one)

i think its many poop code and schedule currently now working properly
This commit is contained in:
kilyabin
2026-01-28 14:29:19 +04:00
parent 56a48b4552
commit a930dcfa4e
13 changed files with 1494 additions and 68 deletions

View File

@@ -0,0 +1,96 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadTeachers, saveTeachers, clearTeachersCache, TeachersData } from '@/shared/data/teachers-loader'
import { parseTeachersList } from '@/app/parser/teachers-list'
import { JSDOM } from 'jsdom'
import { PROXY_URL } from '@/shared/constants/urls'
import contentTypeParser from 'content-type'
type ResponseData = ApiResponse<{
teachers?: TeachersData
parsed?: number
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение списка преподавателей (всегда свежие данные для админ-панели)
clearTeachersCache()
const teachers = loadTeachers(true)
res.status(200).json({ teachers })
return
}
if (req.method === 'POST') {
// Парсинг и обновление списка преподавателей
try {
const url = `${PROXY_URL}/?mn=3`
// Добавляем таймаут 10 секунд для fetch запроса
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
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') {
res.status(500).json({ error: `Failed to fetch teachers list: status ${page.status}` })
return
}
const dom = new JSDOM(content, { url })
const document = dom.window.document
const teachersList = parseTeachersList(document)
// Закрываем JSDOM для освобождения памяти
dom.window.close()
if (teachersList.length === 0) {
res.status(500).json({ error: 'No teachers found on the page' })
return
}
// Преобразуем список в формат TeachersData
// Используем parseId как id (строковое представление)
const teachersData: TeachersData = {}
for (const teacher of teachersList) {
const id = String(teacher.parseId)
teachersData[id] = {
parseId: teacher.parseId,
name: teacher.name
}
}
// Сохраняем в БД
saveTeachers(teachersData)
// Сохраняем timestamp последнего обновления
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
setTeachersLastUpdateTime(Date.now())
// Сбрасываем кеш и загружаем свежие данные из БД
clearTeachersCache()
const updatedTeachers = loadTeachers(true)
res.status(200).json({
success: true,
teachers: updatedTeachers,
parsed: teachersList.length
})
return
} catch (error) {
console.error('Error parsing teachers list:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: `Failed to parse teachers list: ${errorMessage}` })
return
}
}
}
export default withAuth(handler, ['GET', 'POST'])

View File

@@ -235,10 +235,20 @@ export default function HomePage(props: HomePageProps) {
)}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
>
<Link href="/teachers">
<Button variant="secondary" className="gap-2">
Преподаватели
</Button>
</Link>
</div>
{showAddGroupButton && (
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
>
<Button
variant="secondary"
@@ -252,13 +262,13 @@ export default function HomePage(props: HomePageProps) {
)}
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.08 : 0.05)}s` } as React.CSSProperties}
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.11 : 0.08)}s` } as React.CSSProperties}
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">

View File

@@ -0,0 +1,376 @@
import { Schedule } from '@/widgets/schedule'
import { Day } from '@/shared/model/day'
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
import { getTeacherSchedule, ScheduleResult, ScheduleTimeoutError } from '@/app/agregator/schedule'
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
import { NavBar } from '@/widgets/navbar'
import { LastUpdateAt } from '@/entities/last-update-at'
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
import { getTeacherByParseId } from '@/shared/data/database'
import { SITE_URL } from '@/shared/constants/urls'
import crypto from 'crypto'
import React from 'react'
import { getDayOfWeek } from '@/shared/utils'
import Head from 'next/head'
import { WeekInfo } from '@/app/parser/schedule'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card'
import { AlertCircle } from 'lucide-react'
type PageProps = {
schedule?: Day[]
teacher: {
id: string
name: string
}
parsedAt?: Date
cacheAvailableFor: string[]
groups: GroupsData
currentWk?: number | null
availableWeeks?: WeekInfo[] | null
settings: AppSettings
error?: {
message: string
isTimeout: boolean
}
isFromCache?: boolean
cacheAge?: number // возраст кэша в минутах
cacheInfo?: {
size: number
entries: number
}
}
export default function TeacherPage(props: NextSerialized<PageProps>) {
const { schedule, teacher, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error, isFromCache, cacheAge, cacheInfo } = nextDeserialized<PageProps>(props)
React.useEffect(() => {
if (typeof window === 'undefined' || error) return
// Используем 'auto' для нормальной работы обновления страницы
if ('scrollRestoration' in history) {
history.scrollRestoration = 'auto'
}
let attempts = 0
const MAX_ATTEMPTS = 50 // Максимум 5 секунд (50 * 100ms)
const interval = setInterval(() => {
attempts++
const today = getDayOfWeek(new Date())
const todayBlock = document.getElementById(today)
if (todayBlock) {
const GAP = 48
const HEADER_HEIGHT = 64
window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT, behavior: 'smooth' })
clearInterval(interval)
} else if (attempts >= MAX_ATTEMPTS) {
// Прекращаем попытки после максимального количества
clearInterval(interval)
}
}, 100)
// Cleanup функция для очистки интервала при размонтировании
return () => {
clearInterval(interval)
}
}, [schedule, error])
return (
<>
<Head>
<title>{error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`}</title>
<link rel="canonical" href={`${SITE_URL}/teacher/${teacher.id}`} />
<meta name="description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
<meta property="og:title" content={error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`} />
<meta property="og:description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
</Head>
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} isTeacherPage={true} />
{error ? (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<Card className="stagger-card">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-destructive" />
<CardTitle>Не удалось загрузить расписание</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
{error.isTimeout
? 'Превышено время ожидания ответа от сервера. Пожалуйста, попробуйте обновить страницу через несколько минут.'
: 'Произошла ошибка при загрузке расписания. Пожалуйста, попробуйте обновить страницу позже.'}
</CardDescription>
</CardContent>
</Card>
</div>
) : (
<>
{parsedAt && <LastUpdateAt date={parsedAt} />}
{schedule && (
<Schedule
days={schedule}
currentWk={currentWk ?? null}
availableWeeks={availableWeeks ?? null}
weekNavigationEnabled={settings.weekNavigationEnabled}
isFromCache={isFromCache}
cacheAge={cacheAge}
cacheInfo={cacheInfo}
hideTeacher={true}
/>
)}
</>
)}
</>
)
}
const cachedTeacherSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
const maxCacheDurationInMS = 1000 * 60 * 15 // 15 минут для нормального использования кэша
const fallbackCacheDurationInMS = 1000 * 60 * 60 * 24 // 24 часа для fallback кэша при ошибках парсинга
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
// Очистка старых записей из кэша
function cleanupCache() {
const now = Date.now()
const entriesToDelete: string[] = []
// Находим устаревшие записи (используем fallback TTL для сохранения кэша при ошибках)
for (const [key, value] of cachedTeacherSchedules.entries()) {
if (now - value.lastFetched.getTime() >= fallbackCacheDurationInMS) {
entriesToDelete.push(key)
}
}
// Удаляем устаревшие записи
entriesToDelete.forEach(key => cachedTeacherSchedules.delete(key))
// Если кэш все еще слишком большой, удаляем самые старые записи
if (cachedTeacherSchedules.size > maxCacheSize) {
const sortedEntries = Array.from(cachedTeacherSchedules.entries())
.sort((a, b) => a[1].lastFetched.getTime() - b[1].lastFetched.getTime())
const toRemove = sortedEntries.slice(0, cachedTeacherSchedules.size - maxCacheSize)
toRemove.forEach(([key]) => cachedTeacherSchedules.delete(key))
}
}
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
const groups = loadGroups()
const settings = loadSettings()
const teacherParam = context.params?.teacher
const wkParam = context.query.wk
// Валидация wk параметра: проверка на валидное число (не NaN, не Infinity)
const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0
? Number(wkParam)
: undefined
// Валидация teacher параметра: должен быть числом (parseId)
const teacherParseId = teacherParam && !isNaN(Number(teacherParam)) && isFinite(Number(teacherParam)) && Number.isInteger(Number(teacherParam)) && Number(teacherParam) > 0
? Number(teacherParam)
: null
if (!teacherParseId) {
return {
notFound: true
}
}
const teacherInfo = getTeacherByParseId(teacherParseId)
if (!teacherInfo) {
return {
notFound: true
}
}
// Проверяем debug опции
const debug = settings.debug || {}
// Debug: принудительно показать ошибку
if (debug.forceError) {
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0])
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: 'Debug: принудительная ошибка',
isTimeout: false
}
}) as NextSerialized<PageProps>
}
}
// Debug: принудительно симулировать таймаут
if (debug.forceTimeout) {
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0])
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: 'Debug: принудительный таймаут',
isTimeout: true
}
}) as NextSerialized<PageProps>
}
}
let scheduleResult: ScheduleResult
let parsedAt
let isFromCache = false
let cacheAge: number | undefined
// Очищаем старые записи из кэша перед использованием
cleanupCache()
// Кэшируем только текущую неделю (без параметра wk)
// Если запрашивается конкретная неделя (wk указан), не используем кэш
const useCache = !wk
const cacheKey = `teacher_${teacherParseId}` // Ключ кэша для преподавателя
const cachedSchedule = useCache ? cachedTeacherSchedules.get(cacheKey) : undefined
// Debug: принудительно использовать кэш
if (debug.forceCache && cachedSchedule) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
isFromCache = true
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
} else if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
} else {
try {
// Передаем настройки в getTeacherSchedule для условного парсинга навигации
scheduleResult = await getTeacherSchedule(teacherParseId, teacherInfo.name, wk, settings.weekNavigationEnabled)
parsedAt = new Date()
// Кэшируем только текущую неделю
if (useCache) {
cachedTeacherSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
// Очищаем кэш после добавления новой записи, если он стал слишком большим
cleanupCache()
}
} catch(e) {
// При таймауте или любой другой ошибке используем кэш, если он доступен (fallback кэш)
// Используем кэш независимо от возраста при ошибке парсинга
if (cachedSchedule) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
isFromCache = true
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
// Логируем использование fallback кэша с указанием возраста
if (e instanceof ScheduleTimeoutError) {
console.warn(`Schedule fetch timeout for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
} else {
console.warn(`Schedule fetch error for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
}
} else {
// Если кэша нет, возвращаем страницу с ошибкой вместо throw
const isTimeout = e instanceof ScheduleTimeoutError
const errorMessage = isTimeout
? 'Превышено время ожидания ответа от сервера'
: 'Произошла ошибка при загрузке расписания'
console.error(`Schedule fetch failed for teacher ${teacherInfo.name}, no cache available:`, e)
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: errorMessage,
isTimeout
}
}) as NextSerialized<PageProps>
}
}
}
}
// Debug: принудительно показать пустое расписание
if (debug.forceEmpty) {
scheduleResult = {
days: [],
currentWk: scheduleResult.currentWk,
availableWeeks: scheduleResult.availableWeeks
}
}
const schedule = scheduleResult.days
const getSha256Hash = (input: string) => {
const hash = crypto.createHash('sha256')
hash.update(input)
return hash.digest('hex')
}
const etag = getSha256Hash(JSON.stringify(nextSerialized(schedule)))
const ifNoneMatch = context.req.headers['if-none-match']
if (ifNoneMatch === etag) {
context.res.writeHead(304, { ETag: `"${etag}"` })
context.res.end()
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Content has not changed
return { props: {} }
}
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
// Debug: информация о кэше
const cacheInfo = debug.showCacheInfo ? {
size: cachedTeacherSchedules.size,
entries: cachedTeacherSchedules.size
} : undefined
context.res.setHeader('ETag', `"${etag}"`)
return {
props: nextSerialized({
schedule: schedule,
parsedAt: parsedAt,
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
currentWk: scheduleResult.currentWk ?? null,
availableWeeks: scheduleResult.availableWeeks ?? null,
settings,
isFromCache: isFromCache ?? false,
cacheAge: cacheAge ?? null,
cacheInfo: cacheInfo ?? null
}) as NextSerialized<PageProps>
}
}

182
src/pages/teachers.tsx Normal file
View File

@@ -0,0 +1,182 @@
import React from 'react'
import { GetServerSideProps } from 'next'
import { loadTeachers, TeachersData } from '@/shared/data/teachers-loader'
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
import { Button } from '@/shadcn/ui/button'
import { ThemeSwitcher } from '@/features/theme-switch'
import Link from 'next/link'
import Head from 'next/head'
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
import { FaGithub } from 'react-icons/fa'
import { ArrowLeft } from 'lucide-react'
type TeachersPageProps = {
teachers: TeachersData
}
export default function TeachersPage({ teachers }: TeachersPageProps) {
// Преобразуем объект преподавателей в массив и сортируем по имени
const teachersList = Object.entries(teachers)
.map(([id, teacher]) => ({ id, parseId: teacher.parseId, name: teacher.name }))
.sort((a, b) => a.name.localeCompare(b.name))
return (
<>
<Head>
<title>Преподаватели Расписание занятий</title>
<meta name="description" content="Список преподавателей Колледжа Связи ПГУТИ" />
</Head>
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-4">
<div className="text-center space-y-2 mb-8 stagger-card" style={{ animationDelay: '0.05s' } as React.CSSProperties}>
<h1 className="text-3xl md:text-4xl font-bold">Преподаватели</h1>
<p className="text-muted-foreground">Выберите преподавателя для просмотра расписания</p>
</div>
{teachersList.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Преподаватели не найдены
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-2">
{teachersList.map((teacher, index) => {
const delay = 0.1 + index * 0.02
return (
<div
key={teacher.id}
className="stagger-card"
style={{
animationDelay: `${delay}s`,
} as React.CSSProperties}
>
<Link href={`/teacher/${teacher.parseId}`}>
<Button
variant="outline"
className="w-full justify-center h-auto py-3 px-4 text-sm sm:text-base"
>
{teacher.name}
</Button>
</Link>
</div>
)
})}
</div>
)}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.05}s` } as React.CSSProperties}
>
<Link href="/">
<Button variant="secondary" className="gap-2">
<ArrowLeft className="h-4 w-4" />
На главную
</Button>
</Link>
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.08}s` } as React.CSSProperties}
>
<ThemeSwitcher />
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.11}s` } as React.CSSProperties}
>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">
<FaGithub className="h-4 w-4" />
GitHub
</Button>
</Link>
</div>
</div>
</div>
</div>
</>
)
}
async function parseAndSaveTeachers(): Promise<boolean> {
try {
const { parseTeachersList } = await import('@/app/parser/teachers-list')
const { JSDOM } = await import('jsdom')
const { PROXY_URL } = await import('@/shared/constants/urls')
const contentTypeParser = await import('content-type')
const teachersUrl = `${PROXY_URL}/?mn=3`
const page = await fetch(teachersUrl)
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.default.parse(contentType).type === 'text/html') {
const dom = new JSDOM(content, { url: teachersUrl })
const document = dom.window.document
const teachersList = parseTeachersList(document)
dom.window.close()
if (teachersList.length > 0) {
// Преобразуем в формат TeachersData
const teachersData: TeachersData = {}
for (const teacher of teachersList) {
const id = String(teacher.parseId)
teachersData[id] = {
parseId: teacher.parseId,
name: teacher.name
}
}
// Сохраняем в БД
const { saveTeachers } = await import('@/shared/data/teachers-loader')
saveTeachers(teachersData)
// Сохраняем timestamp последнего обновления
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
setTeachersLastUpdateTime(Date.now())
return true
}
}
return false
} catch (error) {
console.error('Error parsing teachers:', error)
return false
}
}
export const getServerSideProps: GetServerSideProps<TeachersPageProps> = async () => {
let teachers = loadTeachers()
// Проверяем, нужно ли обновить список преподавателей
const { getTeachersLastUpdateTime } = await import('@/shared/data/database')
const lastUpdate = getTeachersLastUpdateTime()
const now = Date.now()
const ONE_DAY_MS = 1000 * 60 * 60 * 24 // 24 часа в миллисекундах
const shouldUpdate = !lastUpdate || (now - lastUpdate) >= ONE_DAY_MS
const isEmpty = Object.keys(teachers).length === 0
// Если список пуст или прошло 24 часа с последнего обновления, обновляем
if (isEmpty || shouldUpdate) {
// Парсим и сохраняем преподавателей напрямую (без вызова API)
const success = await parseAndSaveTeachers()
if (success) {
// Перезагружаем данные из БД
teachers = loadTeachers(true)
} else if (isEmpty) {
// Если не удалось загрузить и список был пуст, логируем ошибку
console.error('Failed to load teachers list')
}
}
return {
props: {
teachers
}
}
}