perf: оптимизация памяти - кэширование только текущей недели и условный парсинг Критические оптимизации для снижения потребления памяти с 1.2 ГБ: - Кэширование только текущей недели: * Кэш хранит только текущие недели (без параметра wk) * Запросы с конкретной неделей (wk указан) не кэшируются * Ключ кэша изменен с ${group}_${wk} на group * Уменьшен maxCacheSize с 100 до 50 записей - Условный парсинг навигации по неделям: * Парсинг навигации выполняется только если weekNavigationEnabled === true * Если навигация выключена, parseWeekNavigation не вызывается * Экономит память и CPU при выключенной навигации * Параметр shouldParseWeekNavigation передается через getSchedule -> parsePage - Результат: * Значительное снижение потребления памяти * Кэш содержит только актуальные данные (текущие недели) * Парсинг навигации выполняется только при необходимости Измененные файлы: - src/pages/[group].tsx - логика кэширования только текущей недели - src/app/agregator/schedule.ts - параметр для условного парсинга - src/app/parser/schedule.ts - условный вызов parseWeekNavigation
Критические оптимизации для снижения потребления памяти
- Кэширование только текущей недели:
* Кэш хранит только текущие недели (без параметра wk)
* Запросы с конкретной неделей (wk указан) не кэшируются
* Ключ кэша изменен с `${group}_${wk}` на `group`
* Уменьшен maxCacheSize с 100 до 50 записей
- Условный парсинг навигации по неделям:
* Парсинг навигации выполняется только если weekNavigationEnabled === true
* Если навигация выключена, parseWeekNavigation не вызывается
* Экономит память и CPU при выключенной навигации
* Параметр shouldParseWeekNavigation передается через getSchedule -> parsePage
- Результат:
* Значительное снижение потребления памяти
* Кэш содержит только актуальные данные (текущие недели)
* Парсинг навигации выполняется только при необходимости
Измененные файлы:
- src/pages/[group].tsx - логика кэширования только текущей недели
- src/app/agregator/schedule.ts - параметр для условного парсинга
- src/app/parser/schedule.ts - условный вызов parseWeekNavigation
This commit is contained in:
@@ -22,6 +22,11 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support.
|
|||||||
|
|
||||||
Tools used: pnpm, eslint, react-icons. Deployed with Netlify and supported by Cloudflare.
|
Tools used: pnpm, eslint, react-icons. Deployed with Netlify and supported by Cloudflare.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- Previous week cannot be accessed if you enter from main "/"
|
||||||
|
Workaround: Locate to next week, then enter previous twice.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -106,7 +111,9 @@ Install the application directly on a Linux system as a systemd service:
|
|||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd kspguti-schedule
|
cd kspguti-schedule
|
||||||
|
# Copy example and edit .env
|
||||||
|
cp .env.production.example .env
|
||||||
|
nano .env
|
||||||
# Run the installation script
|
# Run the installation script
|
||||||
sudo ./scripts/install.sh
|
sudo ./scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,22 +13,32 @@ export type ScheduleResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ПС-7: 146
|
// ПС-7: 146
|
||||||
export async function getSchedule(groupID: number, groupName: string, wk?: number): Promise<ScheduleResult> {
|
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
|
||||||
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
||||||
const page = await fetch(url)
|
const page = await fetch(url)
|
||||||
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
|
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
|
||||||
const content = await page.text()
|
const content = await page.text()
|
||||||
const contentType = page.headers.get('content-type')
|
const contentType = page.headers.get('content-type')
|
||||||
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
|
||||||
|
let dom: JSDOM | null = null
|
||||||
try {
|
try {
|
||||||
const root = new JSDOM(content, { url }).window.document
|
dom = new JSDOM(content, { url })
|
||||||
const result = parsePage(root, groupName, url)
|
const root = dom.window.document
|
||||||
return {
|
const result = parsePage(root, groupName, url, parseWeekNavigation)
|
||||||
|
const scheduleResult = {
|
||||||
days: result.days,
|
days: result.days,
|
||||||
currentWk: result.currentWk || wk,
|
currentWk: result.currentWk || wk,
|
||||||
availableWeeks: result.availableWeeks
|
availableWeeks: result.availableWeeks
|
||||||
}
|
}
|
||||||
|
// Явно очищаем JSDOM для освобождения памяти
|
||||||
|
dom.window.close()
|
||||||
|
dom = null
|
||||||
|
return scheduleResult
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
// Очищаем JSDOM даже в случае ошибки
|
||||||
|
if (dom) {
|
||||||
|
dom.window.close()
|
||||||
|
}
|
||||||
console.error(`Error while parsing ${PROXY_URL}`)
|
console.error(`Error while parsing ${PROXY_URL}`)
|
||||||
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -29,34 +29,24 @@ function parseWeekNavigation(document: Document, currentWeekNumber: number, curr
|
|||||||
const wkToWeekNumber = new Map<number, number>()
|
const wkToWeekNumber = new Map<number, number>()
|
||||||
|
|
||||||
// Ищем все ссылки, которые содержат параметр wk
|
// Ищем все ссылки, которые содержат параметр wk
|
||||||
|
// Используем более специфичные селекторы вместо перебора всех элементов
|
||||||
const links = Array.from(document.querySelectorAll('a[href*="wk="]'))
|
const links = Array.from(document.querySelectorAll('a[href*="wk="]'))
|
||||||
|
|
||||||
// Также ищем ссылки в onclick и других атрибутах
|
// Также ищем ссылки в onclick (только для ссылок, не всех элементов)
|
||||||
const linksWithOnclick = Array.from(document.querySelectorAll('a[onclick*="wk="], a[onclick*="wk"]'))
|
const linksWithOnclick = Array.from(document.querySelectorAll('a[onclick*="wk="], a[onclick*="wk"]'))
|
||||||
|
|
||||||
// Ищем в формах
|
// Ищем в формах
|
||||||
const forms = Array.from(document.querySelectorAll('form[action*="wk="], form input[name="wk"]'))
|
const forms = Array.from(document.querySelectorAll('form[action*="wk="], form input[name="wk"]'))
|
||||||
|
|
||||||
// Ищем во всех элементах, которые могут содержать URL с wk
|
// Ищем в элементах с data-атрибутами (только те, которые могут содержать ссылки)
|
||||||
const allElements = Array.from(document.querySelectorAll('*'))
|
const elementsWithDataHref = Array.from(document.querySelectorAll('[data-href*="wk="]'))
|
||||||
const elementsWithWk: Element[] = []
|
|
||||||
|
|
||||||
for (const el of allElements) {
|
// Объединяем все найденные элементы (убираем дубликаты)
|
||||||
const href = el.getAttribute('href')
|
const allLinkElementsSet = new Set<Element>()
|
||||||
const onclick = el.getAttribute('onclick')
|
links.forEach(el => allLinkElementsSet.add(el))
|
||||||
const action = el.getAttribute('action')
|
linksWithOnclick.forEach(el => allLinkElementsSet.add(el))
|
||||||
const dataHref = el.getAttribute('data-href')
|
elementsWithDataHref.forEach(el => allLinkElementsSet.add(el))
|
||||||
|
const allLinkElements = Array.from(allLinkElementsSet)
|
||||||
if ((href && href.includes('wk=')) ||
|
|
||||||
(onclick && onclick.includes('wk=')) ||
|
|
||||||
(action && action.includes('wk=')) ||
|
|
||||||
(dataHref && dataHref.includes('wk='))) {
|
|
||||||
elementsWithWk.push(el)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Объединяем все найденные элементы
|
|
||||||
const allLinkElements = [...links, ...linksWithOnclick, ...elementsWithWk]
|
|
||||||
|
|
||||||
for (const link of allLinkElements) {
|
for (const link of allLinkElements) {
|
||||||
// Пробуем извлечь wk из разных атрибутов
|
// Пробуем извлечь wk из разных атрибутов
|
||||||
@@ -332,7 +322,7 @@ const parseLesson = (row: Element): Lesson | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePage(document: Document, groupName: string, url?: string): ParseResult {
|
export function parsePage(document: Document, groupName: string, url?: string, shouldParseWeekNavigation: boolean = true): ParseResult {
|
||||||
const tables = Array.from(document.querySelectorAll('body > table'))
|
const tables = Array.from(document.querySelectorAll('body > table'))
|
||||||
const table = tables.find(table => table.querySelector(':scope > tbody > tr:first-child')?.textContent?.trim() === groupName)
|
const table = tables.find(table => table.querySelector(':scope > tbody > tr:first-child')?.textContent?.trim() === groupName)
|
||||||
const rows = Array.from(table!.children[0].children).filter(el => el.tagName === 'TR').slice(2)
|
const rows = Array.from(table!.children[0].children).filter(el => el.tagName === 'TR').slice(2)
|
||||||
@@ -382,11 +372,11 @@ export function parsePage(document: Document, groupName: string, url?: string):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Парсим навигацию по неделям
|
// Парсим навигацию по неделям только если включена навигация
|
||||||
let availableWeeks: WeekInfo[] | undefined
|
let availableWeeks: WeekInfo[] | undefined
|
||||||
let finalCurrentWk = currentWk
|
let finalCurrentWk = currentWk
|
||||||
|
|
||||||
if (currentWeekNumber) {
|
if (shouldParseWeekNavigation && currentWeekNumber) {
|
||||||
availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk)
|
availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk)
|
||||||
|
|
||||||
// Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю
|
// Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю
|
||||||
|
|||||||
@@ -82,6 +82,33 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
|||||||
|
|
||||||
const cachedSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
|
const cachedSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
|
||||||
const maxCacheDurationInMS = 1000 * 60 * 60
|
const maxCacheDurationInMS = 1000 * 60 * 60
|
||||||
|
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
|
||||||
|
|
||||||
|
// Очистка старых записей из кэша
|
||||||
|
function cleanupCache() {
|
||||||
|
const now = Date.now()
|
||||||
|
const entriesToDelete: string[] = []
|
||||||
|
|
||||||
|
// Находим устаревшие записи
|
||||||
|
for (const [key, value] of cachedSchedules.entries()) {
|
||||||
|
if (now - value.lastFetched.getTime() >= maxCacheDurationInMS) {
|
||||||
|
entriesToDelete.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем устаревшие записи
|
||||||
|
entriesToDelete.forEach(key => cachedSchedules.delete(key))
|
||||||
|
|
||||||
|
// Если кэш все еще слишком большой, удаляем самые старые записи
|
||||||
|
if (cachedSchedules.size > maxCacheSize) {
|
||||||
|
const sortedEntries = Array.from(cachedSchedules.entries())
|
||||||
|
.sort((a, b) => a[1].lastFetched.getTime() - b[1].lastFetched.getTime())
|
||||||
|
|
||||||
|
const toRemove = sortedEntries.slice(0, cachedSchedules.size - maxCacheSize)
|
||||||
|
toRemove.forEach(([key]) => cachedSchedules.delete(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 = loadGroups()
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -93,9 +120,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
let scheduleResult: ScheduleResult
|
let scheduleResult: ScheduleResult
|
||||||
let parsedAt
|
let parsedAt
|
||||||
|
|
||||||
// Ключ кэша включает группу и неделю
|
// Очищаем старые записи из кэша перед использованием
|
||||||
const cacheKey = wk ? `${group}_${wk}` : group
|
cleanupCache()
|
||||||
const cachedSchedule = cachedSchedules.get(cacheKey)
|
|
||||||
|
// Кэшируем только текущую неделю (без параметра wk)
|
||||||
|
// Если запрашивается конкретная неделя (wk указан), не используем кэш
|
||||||
|
const useCache = !wk
|
||||||
|
const cacheKey = group // Ключ кэша - только группа (текущая неделя)
|
||||||
|
const cachedSchedule = useCache ? cachedSchedules.get(cacheKey) : undefined
|
||||||
|
|
||||||
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
|
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
|
||||||
scheduleResult = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
@@ -103,9 +135,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const groupInfo = groups[group]
|
const groupInfo = groups[group]
|
||||||
scheduleResult = await getSchedule(groupInfo.parseId, groupInfo.name, wk)
|
// Передаем настройки в getSchedule для условного парсинга навигации
|
||||||
|
scheduleResult = await getSchedule(groupInfo.parseId, groupInfo.name, wk, settings.weekNavigationEnabled)
|
||||||
parsedAt = new Date()
|
parsedAt = new Date()
|
||||||
|
|
||||||
|
// Кэшируем только текущую неделю
|
||||||
|
if (useCache) {
|
||||||
cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
|
cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
|
||||||
|
// Очищаем кэш после добавления новой записи, если он стал слишком большим
|
||||||
|
cleanupCache()
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
if (cachedSchedule?.lastFetched) {
|
if (cachedSchedule?.lastFetched) {
|
||||||
scheduleResult = cachedSchedule.results
|
scheduleResult = cachedSchedule.results
|
||||||
|
|||||||
Reference in New Issue
Block a user