From b1f892ca7de96356c4a5a4cd7153796a55f08ac5 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:38:09 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=20-=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5=D0=B9=20=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=B8=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=20=20=D0=9A=D1=80=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BD=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=B1=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B0=D0=BC=D1=8F?= =?UTF-8?q?=D1=82=D0=B8=20=D1=81=201.2=20=D0=93=D0=91:=20=20-=20=D0=9A?= =?UTF-8?q?=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D0=B8:=20?= =?UTF-8?q?=20=20*=20=D0=9A=D1=8D=D1=88=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=83=D1=89=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20(=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B0=20wk)=20=20=20*=20=D0=97=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=8B=20=D1=81=20=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=80=D0=B5=D1=82=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20(wk=20=D1=83=D0=BA=D0=B0=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?)=20=D0=BD=D0=B5=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D1=83=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=20=20*=20=D0=9A=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=81=20`${group}=5F${wk}`=20=D0=BD=D0=B0=20`group`?= =?UTF-8?q?=20=20=20*=20=D0=A3=D0=BC=D0=B5=D0=BD=D1=8C=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=20maxCacheSize=20=D1=81=20100=20=D0=B4=D0=BE=2050=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20=20-=20=D0=A3=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F=D0=BC:=20=20=20*=20=D0=9F=D0=B0=D1=80=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=20weekNavigationEnabled=20=3D=3D=3D=20true=20=20?= =?UTF-8?q?=20*=20=D0=95=D1=81=D0=BB=D0=B8=20=D0=BD=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B0,=20parseWeekNavigation=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=20=20*=20=D0=AD=D0=BA=D0=BE=D0=BD=D0=BE=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D0=B8=20CPU=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=20=20*=20=D0=9F=D0=B0=D1=80=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D1=80=20shouldParseWeekNavigation=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20getSchedule=20->=20parsePage=20=20-=20=D0=A0?= =?UTF-8?q?=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82:=20=20=20*=20?= =?UTF-8?q?=D0=97=D0=BD=D0=B0=D1=87=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=81=D0=BD=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8=20=20=20*=20?= =?UTF-8?q?=D0=9A=D1=8D=D1=88=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8?= =?UTF-8?q?=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20(=D1=82=D0=B5=D0=BA=D1=83=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D0=B8)=20=20=20*?= =?UTF-8?q?=20=D0=9F=D0=B0=D1=80=D1=81=D0=B8=D0=BD=D0=B3=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=D1=8B=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=20=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B:=20-=20src/pages/?= =?UTF-8?q?[group].tsx=20-=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BA?= =?UTF-8?q?=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D0=B8=20-?= =?UTF-8?q?=20src/app/agregator/schedule.ts=20-=20=D0=BF=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80=20=D0=B4=D0=BB=D1=8F=20=D1=83=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D0=B3=D0=B0=20-=20src/app/parser/schedule.ts?= =?UTF-8?q?=20-=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=20parseWeekNavigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Критические оптимизации для снижения потребления памяти - Кэширование только текущей недели: * Кэш хранит только текущие недели (без параметра 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 --- README.md | 9 ++++++- src/app/agregator/schedule.ts | 18 ++++++++++--- src/app/parser/schedule.ts | 36 ++++++++++--------------- src/pages/[group].tsx | 49 +++++++++++++++++++++++++++++++---- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 400b5fd..a13af66 100644 --- a/README.md +++ b/README.md @@ -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. +## Known issues + +- Previous week cannot be accessed if you enter from main "/" +Workaround: Locate to next week, then enter previous twice. + ## Development ### Prerequisites @@ -106,7 +111,9 @@ Install the application directly on a Linux system as a systemd service: # Clone the repository git clone cd kspguti-schedule - +# Copy example and edit .env +cp .env.production.example .env +nano .env # Run the installation script sudo ./scripts/install.sh ``` diff --git a/src/app/agregator/schedule.ts b/src/app/agregator/schedule.ts index 6791127..6be8a00 100644 --- a/src/app/agregator/schedule.ts +++ b/src/app/agregator/schedule.ts @@ -13,22 +13,32 @@ export type ScheduleResult = { } // ПС-7: 146 -export async function getSchedule(groupID: number, groupName: string, wk?: number): Promise { +export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise { const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}` const page = await fetch(url) // const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } } const content = await page.text() const contentType = page.headers.get('content-type') if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') { + let dom: JSDOM | null = null try { - const root = new JSDOM(content, { url }).window.document - const result = parsePage(root, groupName, url) - return { + dom = new JSDOM(content, { url }) + const root = dom.window.document + const result = parsePage(root, groupName, url, parseWeekNavigation) + const scheduleResult = { days: result.days, currentWk: result.currentWk || wk, availableWeeks: result.availableWeeks } + // Явно очищаем JSDOM для освобождения памяти + dom.window.close() + dom = null + return scheduleResult } catch(e) { + // Очищаем JSDOM даже в случае ошибки + if (dom) { + dom.window.close() + } console.error(`Error while parsing ${PROXY_URL}`) reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName) throw e diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index c0badff..a236c48 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -29,34 +29,24 @@ function parseWeekNavigation(document: Document, currentWeekNumber: number, curr const wkToWeekNumber = new Map() // Ищем все ссылки, которые содержат параметр 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 forms = Array.from(document.querySelectorAll('form[action*="wk="], form input[name="wk"]')) - // Ищем во всех элементах, которые могут содержать URL с wk - const allElements = Array.from(document.querySelectorAll('*')) - const elementsWithWk: Element[] = [] + // Ищем в элементах с data-атрибутами (только те, которые могут содержать ссылки) + const elementsWithDataHref = Array.from(document.querySelectorAll('[data-href*="wk="]')) - for (const el of allElements) { - const href = el.getAttribute('href') - const onclick = el.getAttribute('onclick') - const action = el.getAttribute('action') - const dataHref = el.getAttribute('data-href') - - 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] + // Объединяем все найденные элементы (убираем дубликаты) + const allLinkElementsSet = new Set() + links.forEach(el => allLinkElementsSet.add(el)) + linksWithOnclick.forEach(el => allLinkElementsSet.add(el)) + elementsWithDataHref.forEach(el => allLinkElementsSet.add(el)) + const allLinkElements = Array.from(allLinkElementsSet) for (const link of allLinkElements) { // Пробуем извлечь 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 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) @@ -382,11 +372,11 @@ export function parsePage(document: Document, groupName: string, url?: string): } } - // Парсим навигацию по неделям + // Парсим навигацию по неделям только если включена навигация let availableWeeks: WeekInfo[] | undefined let finalCurrentWk = currentWk - if (currentWeekNumber) { + if (shouldParseWeekNavigation && currentWeekNumber) { availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk) // Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю diff --git a/src/pages/[group].tsx b/src/pages/[group].tsx index 6787ff5..ab5ddc9 100644 --- a/src/pages/[group].tsx +++ b/src/pages/[group].tsx @@ -82,6 +82,33 @@ export default function HomePage(props: NextSerialized) { const cachedSchedules = new Map() 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>> { const groups = loadGroups() const settings = loadSettings() @@ -93,9 +120,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr let scheduleResult: ScheduleResult let parsedAt - // Ключ кэша включает группу и неделю - const cacheKey = wk ? `${group}_${wk}` : group - const cachedSchedule = cachedSchedules.get(cacheKey) + // Очищаем старые записи из кэша перед использованием + cleanupCache() + + // Кэшируем только текущую неделю (без параметра wk) + // Если запрашивается конкретная неделя (wk указан), не используем кэш + const useCache = !wk + const cacheKey = group // Ключ кэша - только группа (текущая неделя) + const cachedSchedule = useCache ? cachedSchedules.get(cacheKey) : undefined if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) { scheduleResult = cachedSchedule.results @@ -103,9 +135,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr } else { try { 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() - cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult }) + + // Кэшируем только текущую неделю + if (useCache) { + cachedSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult }) + // Очищаем кэш после добавления новой записи, если он стал слишком большим + cleanupCache() + } } catch(e) { if (cachedSchedule?.lastFetched) { scheduleResult = cachedSchedule.results