refactor: optimize project structure, migrate to SQLite, and add new features

fix:
  - Fix TypeScript type errors in api-wrapper.ts (ApiResponse type)
  - Fix backward compatibility in database.ts getSettings() for missing fields
  - Fix default value for weekNavigationEnabled (changed from true to false)
  - Fix API routes error handling with unified wrapper
  - Fix duplicate toggle switch code in admin.tsx (6 instances)
  - Fix inconsistent authentication check in API routes (unified with withAuth)
  - Fix error message text in loading-context.tsx (improved user experience)

add:
  - Add database.ts: SQLite database layer with better-sqlite3 for persistent storage
    * Groups management (CRUD operations)
    * Settings management with caching
    * Admin password hashing with bcrypt
    * Automatic database initialization and migration
  - Add api-wrapper.ts utility for unified API route handling
    * withAuth wrapper for protected routes
    * withMethods wrapper for public routes
    * Consistent error handling and method validation
  - Add validation.ts utility with centralized validation functions
    * validateCourse - course validation (1-5)
    * validateGroupId - group ID format validation
    * validatePassword - password strength validation
  - Add showAddGroupButton setting to control visibility of 'Add Group' button on homepage
  - Add toggle switch component in admin.tsx for reusable UI (replaces 6 duplicate instances)
  - Add CourseSelect component in admin.tsx for reusable course selection
  - Add DialogFooterButtons component in admin.tsx for reusable dialog footer
  - Add unified loadData function in admin.tsx to reduce code duplication
  - Add change-password.ts API endpoint for admin password management
  - Add logs.ts API endpoint for viewing error logs in admin panel
  - Add logErrorToFile function in logger.ts for persistent error logging
  - Add comprehensive error logging in schedule.ts (parsing, fetch, timeout, network errors)
  - Add comprehensive project structure documentation in README.md
  - Add architecture and code organization section in README.md
  - Add database information section in README.md
  - Add SQLite and bcrypt to tech stack documentation
  - Add better-sqlite3 and bcrypt dependencies to package.json
  - Add .gitignore rules for error.log and database files (data/, *.db, *.db-shm, *.db-wal)

refactor:
  - Refactor admin.tsx: extract reusable components (toggle, select, dialog footer)
  - Refactor API routes to use withAuth wrapper for consistent authentication
  - Refactor API routes to use validation utilities instead of inline validation
  - Refactor groups.ts and groups.json: move to old/data/ directory (deprecated, now using SQLite)
  - Refactor settings-loader.ts: migrate from JSON to SQLite database
  - Refactor groups-loader.ts: migrate from JSON to SQLite database
  - Refactor database.ts: improve backward compatibility for settings migration
  - Refactor admin.tsx: unify data loading functions (loadGroupsList, loadSettingsList)
  - Refactor index.tsx: add showAddGroupButton prop and conditional rendering
  - Refactor API routes: consistent error handling and method validation
  - Refactor README.md: update tech stack, project structure, and admin panel documentation
  - Refactor auth.ts: improve session management and cookie handling
  - Refactor schedule.ts: improve error handling with detailed logging and error types
  - Refactor logger.ts: add file-based error logging functionality
  - Refactor loading-context.tsx: improve error message clarity

remove:
  - Remove hello.ts test API endpoint
  - Remove groups.ts and groups.json (moved to old/data/, replaced by SQLite)

update:
  - Update .gitignore to exclude old data files, database files, and error logs
  - Update package.json: add better-sqlite3, bcrypt and their type definitions
  - Update README.md with new features, architecture, and database information
  - Update all API routes to use new wrapper system
  - Update admin panel with new settings and improved UI
  - Update sitemap.xml with cache usage comment
This commit is contained in:
kilyabin
2025-12-03 21:44:07 +04:00
parent 0907581cc0
commit e46a2419c3
27 changed files with 1937 additions and 627 deletions

View File

@@ -2,7 +2,7 @@ import { Day } from '@/shared/model/day'
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
import contentTypeParser from 'content-type'
import { JSDOM } from 'jsdom'
import { reportParserError } from '@/app/logger'
import { reportParserError, logErrorToFile } from '@/app/logger'
import { PROXY_URL } from '@/shared/constants/urls'
export type ScheduleResult = {
@@ -60,19 +60,78 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
dom.window.close()
}
console.error(`Error while parsing ${PROXY_URL}`)
const error = e instanceof Error ? e : new Error(String(e))
logErrorToFile(error, {
type: 'parsing_error',
groupName,
url,
groupID
})
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
}
} else {
// Логируем только метаданные, без содержимого ответа
console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`)
const error = new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
logErrorToFile(error, {
type: 'fetch_error',
groupName,
url,
groupID,
status: page.status,
contentType
})
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
throw error
}
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
throw new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
const timeoutError = new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
logErrorToFile(timeoutError, {
type: 'timeout_error',
groupName,
url,
groupID
})
throw timeoutError
}
// Улучшенная обработка сетевых ошибок для диагностики
const errorObj = error instanceof Error ? error : new Error(String(error))
if (errorObj && 'cause' in errorObj && errorObj.cause instanceof Error) {
const networkError = errorObj.cause as Error & { code?: string }
if (networkError.code === 'ECONNRESET' || networkError.code === 'ECONNREFUSED' || networkError.code === 'ETIMEDOUT') {
console.error(`Network error while fetching ${PROXY_URL}:`, {
code: networkError.code,
message: networkError.message,
url
})
logErrorToFile(errorObj, {
type: 'network_error',
groupName,
url,
groupID,
networkErrorCode: networkError.code,
networkErrorMessage: networkError.message
})
} else {
// Логируем другие ошибки тоже
logErrorToFile(errorObj, {
type: 'unknown_error',
groupName,
url,
groupID
})
}
} else {
// Логируем ошибки без cause
logErrorToFile(errorObj, {
type: 'unknown_error',
groupName,
url,
groupID
})
}
throw error
}

View File

@@ -1,4 +1,6 @@
import TelegramBot from 'node-telegram-bot-api'
import fs from 'fs'
import path from 'path'
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
@@ -10,6 +12,46 @@ if (!token || !ownerID) {
bot = new TelegramBot(token, { polling: false })
}
// Путь к файлу логов (в корне проекта)
const getErrorLogPath = () => {
// В production (standalone) используем текущую рабочую директорию
// В development используем корень проекта (process.cwd())
return path.join(process.cwd(), 'error.log')
}
/**
* Логирует ошибку в файл error.log
* @param error - Объект ошибки или строка с описанием ошибки
* @param context - Дополнительный контекст (опционально)
*/
export function logErrorToFile(error: Error | string, context?: Record<string, unknown>): void {
try {
const logPath = getErrorLogPath()
const timestamp = new Date().toISOString()
const errorMessage = error instanceof Error ? error.message : error
const errorStack = error instanceof Error ? error.stack : undefined
const errorName = error instanceof Error ? error.name : 'Error'
let logEntry = `[${timestamp}] ${errorName}: ${errorMessage}\n`
if (errorStack) {
logEntry += `Stack: ${errorStack}\n`
}
if (context && Object.keys(context).length > 0) {
logEntry += `Context: ${JSON.stringify(context, null, 2)}\n`
}
logEntry += '---\n'
// Используем appendFileSync для надежности (не блокирует надолго)
fs.appendFileSync(logPath, logEntry, 'utf8')
} catch (logError) {
// Если не удалось записать в файл, выводим в консоль
console.error('Failed to write to error.log:', logError)
}
}
export async function reportParserError(...text: string[]) {
if (!token || !ownerID) return

View File

@@ -275,10 +275,101 @@ const parseLesson = (row: Element): Lesson | null => {
const isFreeTimeReplacement = lesson.isChange &&
(cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:'))
// Проверяем, является ли это заменой предмета на предмет
const isSubjectReplacement = lesson.isChange &&
!isFreeTimeReplacement &&
cellText.includes('Замена') &&
cellText.includes('на:')
if (isFreeTimeReplacement) {
// Для замены "свободное время" на пару нужно парсить данные после "на:"
// Структура: "Замена Свободное время на:</a><br> название <br> преподаватель <font> адрес <br> кабинет </font>
// Используем HTML парсинг для извлечения данных после "на:"
const afterOnIndex = cellHTML.indexOf('на:')
if (afterOnIndex !== -1) {
const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:"
// Пропускаем первый <br> (он идет сразу после "на:")
const firstBrIndex = afterOn.indexOf('<br')
if (firstBrIndex !== -1) {
// Находим конец первого <br> тега
const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1
const afterFirstBr = afterOn.substring(firstBrEnd)
// Извлекаем название предмета (текст до следующего <br>)
const secondBrIndex = afterFirstBr.indexOf('<br')
if (secondBrIndex !== -1) {
const subjectHTML = afterFirstBr.substring(0, secondBrIndex)
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
// Извлекаем преподавателя (текст между вторым <br> и <font> или следующим <br>)
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
}
}
} else {
// Если нет второго <br>, название предмета может быть до <font> или до конца
const fontIndex = afterFirstBr.indexOf('<font')
if (fontIndex !== -1) {
const subjectHTML = afterFirstBr.substring(0, fontIndex)
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim()
}
}
}
// Ищем адрес и кабинет внутри <font>
const fontMatch = afterOn.match(/<font[^>]*>([\s\S]*?)<\/font>/i)
if (fontMatch) {
const fontContent = fontMatch[1]
// Ищем паттерн: <br> адрес <br> Кабинет: номер
// Сначала убираем все теги и разбиваем по <br>
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter(p => p.trim())
// Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:")
for (let i = 0; i < cleanContent.length; i++) {
const part = cleanContent[i].trim()
if (part && !part.includes('Кабинет:')) {
const nextPart = cleanContent[i + 1]?.trim() || ''
const classroomMatch = nextPart.match(/Кабинет:\s*([^\s]+)/i)
if (classroomMatch) {
lesson.place = {
address: part,
classroom: classroomMatch[1]
}
break
}
}
}
} else {
// Если нет <font>, ищем адрес и кабинет напрямую в тексте после "на:"
const addressMatch = afterOn.match(/([^<]+?)(?:<br[^>]*>|\s+)Кабинет:\s*([^<\s]+)/i)
if (addressMatch) {
lesson.place = {
address: addressMatch[1].replace(/<[^>]+>/g, '').trim(),
classroom: addressMatch[2].trim()
}
}
}
}
} else if (isSubjectReplacement) {
// Для замены предмета на предмет нужно парсить данные после "на:"
// Структура: "Замена [старый предмет] на:</a><br> [новый предмет] <br> [преподаватель] <font> [адрес] <br> Кабинет: [номер] </font>
// Используем HTML парсинг для извлечения данных после "на:"
const afterOnIndex = cellHTML.indexOf('на:')
if (afterOnIndex !== -1) {