From 82c22c54d357416991c90f854733265ce6e7c804 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Tue, 18 Nov 2025 03:33:08 +0400 Subject: [PATCH] modernize project with Docker support and dependency updates - Pin all dependencies to stable versions (remove 'latest') - Update lucide-react to 0.554.0 for React 19 compatibility - Add Docker support with Dockerfile and docker-compose.yml - Update TypeScript target to ES2022 - Add .nvmrc and netlify.toml for deployment configuration - Update README with Docker deployment instructions --- .dockerignore | 65 +++++++++++++++++++++++++++++++ .nvmrc | 2 + Dockerfile | 61 +++++++++++++++++++++++++++++ README.md | 80 ++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 24 ++++++++++++ netlify.toml | 11 ++++++ next.config.js | 1 + package.json | 28 +++++++------ src/app/parser/schedule.ts | 59 +++++++++++++++++++++------- tsconfig.json | 4 +- 10 files changed, 303 insertions(+), 32 deletions(-) create mode 100644 .dockerignore create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 netlify.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b5f68c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-debug.log* + +# Testing +coverage +*.log + +# Next.js +.next/ +out/ +build +dist + +# Production +*.tsbuildinfo +next-env.d.ts + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env*.local +.env + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore +docker-compose.yml + +# CI/CD +.github + +# Documentation +README.md +*.md + diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..35f4978 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +20 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d307de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# Используем официальный образ Node.js LTS +FROM node:20-alpine AS base + +# Устанавливаем зависимости только при необходимости +FROM base AS deps +# Проверяем https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Копируем файлы для установки зависимостей +COPY package.json package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm install --frozen-lockfile; \ + elif [ -f package-lock.json ]; then \ + npm ci; \ + else \ + echo "Lockfile not found." && exit 1; \ + fi + +# Пересобираем исходный код только при необходимости +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Переменные окружения для сборки +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f pnpm-lock.yaml ]; then \ + corepack enable pnpm && pnpm run build; \ + else \ + npm run build; \ + fi + +# Production образ, копируем все файлы и запускаем next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Автоматически используем output: 'standalone' в next.config.js +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] + diff --git a/README.md b/README.md index d02bfb5..e5c3620 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,88 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support. ## Tech stack & features -- React with Next.js v13.5 (pages router) -- Tailwind CSS. +- React 19.2.0 with Next.js 16.0.3 (pages router) +- Tailwind CSS - @shadcn/ui components (built with Radix UI) - JSDOM for parsing scraped pages, rehydration strategy for cache -- TypeScript with types for each package +- TypeScript 5.6.0 with types for each package - Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications - Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c) - Accessability & tab navigation support - Dark theme with automatic switching based on system settings Tools used: pnpm, eslint, react-icons. Deployed with Netlify and supported by Cloudflare. + +## Development + +### Prerequisites + +- Node.js 20+ (see `.nvmrc`) +- npm 10+ or pnpm + +### Local development + +```bash +# Install dependencies +npm install +# or +pnpm install + +# Run development server +npm run dev +# or +pnpm dev +``` + +### Docker deployment + +#### Build and run with Docker + +```bash +# Build the image +docker build -t kspguti-schedule . + +# Run the container +docker run -p 3000:3000 kspguti-schedule +``` + +#### Using Docker Compose + +```bash +# Build and start +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop +docker-compose down +``` + +**Environment variables:** Edit `docker-compose.yml` to add your environment variables: +- `PROXY_URL` - URL for schedule parsing +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN` - Telegram bot token +- `PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID` - Telegram chat ID + +### Production deployment + +#### Netlify + +The project includes `netlify.toml` for automatic deployment configuration. + +#### Docker + +The Dockerfile uses Next.js standalone output for optimized production builds. The image includes: +- Multi-stage build for smaller image size +- Non-root user for security +- Health checks +- Production optimizations + +#### Other platforms + +The project can be deployed to any platform supporting Node.js 20+: +- Vercel +- Railway +- DigitalOcean App Platform +- AWS App Runner +- Any Docker-compatible platform diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..800c97a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - NEXT_TELEMETRY_DISABLED=1 + # Добавьте ваши переменные окружения здесь + # - PROXY_URL=${PROXY_URL} + # - PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN=${PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN} + # - PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID=${PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID} + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..3f93c04 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,11 @@ +[build] + command = "npm run build" + publish = ".next" + +[[plugins]] + package = "@netlify/plugin-nextjs" + +[build.environment] + NODE_VERSION = "20" + NPM_VERSION = "10" + diff --git a/next.config.js b/next.config.js index 60707f6..2045259 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + output: 'standalone', redirects: async () => [{ permanent: false, destination: '/ps7', diff --git a/package.json b/package.json index cbe7c7b..12f5108 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "kspguti-schedule", "version": "0.1.0", "private": true, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -22,14 +26,14 @@ "content-type": "^1.0.5", "date-fns": "^2.30.0", "jsdom": "^22.1.0", - "lucide-react": "^0.279.0", - "next": "latest", + "lucide-react": "^0.554.0", + "next": "16.0.3", "next-sitemap": "^4.2.3", "next-themes": "^0.2.1", "node-html-parser": "^6.1.10", "node-telegram-bot-api": "^0.63.0", - "react": "latest", - "react-dom": "latest", + "react": "19.2.0", + "react-dom": "19.2.0", "react-icons": "^4.11.0", "sass": "^1.69.3", "sharp": "^0.32.6", @@ -39,16 +43,16 @@ }, "devDependencies": { "@types/jsdom": "^21.1.3", - "@types/node": "latest", + "@types/node": "22.0.0", "@types/node-telegram-bot-api": "^0.61.8", - "@types/react": "latest", - "@types/react-dom": "latest", + "@types/react": "19.0.0", + "@types/react-dom": "19.0.0", "@typescript-eslint/eslint-plugin": "^6.7.3", - "autoprefixer": "latest", - "eslint": "latest", - "eslint-config-next": "latest", - "postcss": "latest", + "autoprefixer": "10.4.20", + "eslint": "9.0.0", + "eslint-config-next": "16.0.3", + "postcss": "8.4.47", "tailwindcss": "^3.4.18", - "typescript": "latest" + "typescript": "5.6.0" } } diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index 56c45d1..ab49086 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -16,21 +16,31 @@ const parseLesson = (row: Element): Lesson | null => { try { const cells = Array.from(row.querySelectorAll(':scope > td')) - if (cells[3].textContent!.trim() === 'Свободное время') return null + if (!cells[3] || cells[3].textContent?.trim() === 'Свободное время') return null lesson.isChange = cells.every(td => td.getAttribute('bgcolor') === 'ffffbb') + if (!cells[1] || !cells[1].childNodes[0]) { + return null + } const timeCell = cells[1].childNodes - const [startTime, endTime] = timeCell[0].textContent!.trim().split(' – ') + const timeText = timeCell[0].textContent?.trim() + if (!timeText) { + return null + } + const [startTime, endTime] = timeText.split(' – ') lesson.time = { start: startTime ?? '', end: endTime ?? '' } if (timeCell[2]) { - lesson.time.hint = timeCell[2].textContent!.trim() + lesson.time.hint = timeCell[2].textContent?.trim() } try { + if (!cells[3].childNodes[0]) { + throw new Error('Subject node not found') + } lesson.subject = cells[3].childNodes[0].textContent!.trim() const teacherCell = cells[3].childNodes[2] @@ -40,10 +50,21 @@ const parseLesson = (row: Element): Lesson | null => { const placeCell = cells[3].childNodes[3] - if (placeCell) { - lesson.place = { - address: placeCell.childNodes[1].textContent!.trim(), - classroom: placeCell.childNodes[3].textContent!.trim().match(/^Кабинет: ([^ ]+)(-2)?$/)![1] + if (placeCell && placeCell.childNodes.length > 0) { + const addressNode = placeCell.childNodes[1] + const classroomNode = placeCell.childNodes[3] + + if (addressNode && classroomNode) { + const address = addressNode.textContent?.trim() + const classroomText = classroomNode.textContent?.trim() + const classroomMatch = classroomText?.match(/^Кабинет: ([^ ]+)(-2)?$/) + + if (address && classroomMatch) { + lesson.place = { + address, + classroom: classroomMatch[1] + } + } } } } catch(e) { @@ -51,17 +72,25 @@ const parseLesson = (row: Element): Lesson | null => { lesson.fallbackDiscipline = cells[3].textContent?.trim() } - lesson.topic = cells[4].textContent!.trim() + if (cells[4]) { + lesson.topic = cells[4].textContent?.trim() || '' + } lesson.resources = [] - Array.from(cells[5].querySelectorAll('a')) - .forEach(a => { - lesson.resources.push({ - type: 'link', - title: a.textContent!.trim(), - url: a.getAttribute('href')! + if (cells[5]) { + Array.from(cells[5].querySelectorAll('a')) + .forEach(a => { + const title = a.textContent?.trim() + const url = a.getAttribute('href') + if (title && url) { + lesson.resources.push({ + type: 'link', + title, + url + }) + } }) - }) + } return lesson } catch(e) { diff --git a/tsconfig.json b/tsconfig.json index 2f53d7b..283e9ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2022", "lib": [ "dom", "dom.iterable", - "esnext" + "ES2022" ], "allowJs": true, "skipLibCheck": true,