Added last update, cache strategy, telegram fail notifications, teachers photos

This commit is contained in:
VityaSchel
2023-10-02 18:54:26 +04:00
parent f6daee6201
commit 755654cf9d
19 changed files with 579 additions and 124 deletions

View File

@@ -1 +1,2 @@
PROXY_HOST= PROXY_HOST=
PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN=

View File

@@ -1,5 +1,10 @@
# Schedule for колледж связи пгути # Schedule for колледж связи пгути
- [Schedule for колледж связи пгути](#schedule-for-колледж-связи-пгути)
- [Tech stack](#tech-stack)
- [Hire me!](#hire-me)
Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally ugly. Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally ugly.
![TODO: screenshot](TODO: screenshot) ![TODO: screenshot](TODO: screenshot)
@@ -9,12 +14,14 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support and is generally
## Tech stack ## Tech stack
- React with Next.js v13.5 (pages router) - React with Next.js v13.5 (pages router)
- Tailwind CSS - Tailwind CSS. This is my first project using it, after using SCSS Modules for many years
- @shadcn/ui components (built with Radix UI) - @shadcn/ui components (built with Radix UI)
- node-html-parser for scraping, rehydration strategy for cache - node-html-parser for scraping, rehydration strategy for cache
- TypeScript with types for each package - TypeScript 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)
Built in 1 day. Tools used: pnpm, eslint, react-icons. Built under 1 day. Tools used: pnpm, eslint, react-icons.
## Hire me! ## Hire me!

View File

@@ -20,21 +20,25 @@
"classnames": "^2.3.2", "classnames": "^2.3.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"date-fns": "^2.30.0",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"next": "latest", "next": "latest",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"node-html-parser": "^6.1.10", "node-html-parser": "^6.1.10",
"node-telegram-bot-api": "^0.63.0",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/jsdom": "^21.1.3", "@types/jsdom": "^21.1.3",
"@types/node": "latest", "@types/node": "latest",
"@types/node-telegram-bot-api": "^0.61.8",
"@types/react": "latest", "@types/react": "latest",
"@types/react-dom": "latest", "@types/react-dom": "latest",
"@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/eslint-plugin": "^6.7.3",

452
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -4,6 +4,7 @@ import contentTypeParser from 'content-type'
// import { parse } from 'node-html-parser' // import { parse } from 'node-html-parser'
import { JSDOM } from 'jsdom' import { JSDOM } from 'jsdom'
import { content as mockContent } from './mock' import { content as mockContent } from './mock'
import { reportParserError } from '@/app/logger'
// ПС-7: 146 // ПС-7: 146
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> { export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
@@ -17,11 +18,13 @@ export async function getSchedule(groupID: number, groupName: string): Promise<D
return parsePage(root, groupName) return parsePage(root, groupName)
} catch(e) { } catch(e) {
console.error('Error while parsing lk.ks.psuti.ru') console.error('Error while parsing lk.ks.psuti.ru')
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e throw e
} }
} else { } else {
console.error(page.status, contentType) console.error(page.status, contentType)
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content) console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error('Error while fetching lk.ks.psuti.ru') throw new Error('Error while fetching lk.ks.psuti.ru')
} }
} }

18
src/app/logger.ts Normal file
View File

@@ -0,0 +1,18 @@
import TelegramBot from 'node-telegram-bot-api'
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
let bot: TelegramBot
if (!token || !ownerID) {
console.warn('Telegram Token is not specified. This means you won\'t get any notifications about parsing failures.')
} else {
bot = new TelegramBot(token, { polling: false })
}
export async function reportParserError(...text: string[]) {
if (!token || !ownerID) return
await bot.sendMessage(ownerID, text.join(' '))
}

View File

@@ -0,0 +1,14 @@
import { formatDistanceStrict } from 'date-fns'
import { ru as dateFnsRuLocale } from 'date-fns/locale'
export function LastUpdateAt({ date }: {
date: Date
}) {
return (
<div className='flex md:justify-end px-4 md:h-0'>
<span className='text-sm text-border md:whitespace-pre-wrap md:text-right'>
Последнее обновление:{'\n'}{formatDistanceStrict(date, Date.now(), { locale: dateFnsRuLocale, addSuffix: true })}
</span>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { Button } from '@/shadcn/ui/button'
import { MdAdd } from 'react-icons/md'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shadcn/ui/dialog'
import Link from 'next/link'
import coolEmoji from './1925.png'
import Image from 'next/image'
import { BsTelegram } from 'react-icons/bs'
import { SlSocialVkontakte } from 'react-icons/sl'
export function AddGroupButton() {
const [popupVisible, setPopupVisible] = React.useState(false)
const handleOpenPopup = () => {
setPopupVisible(true)
}
return (
<>
<Button variant='secondary' onClick={handleOpenPopup}><MdAdd /></Button>
<Popup open={popupVisible} onClose={() => setPopupVisible(false)} />
</>
)
}
function Popup({ open, onClose }: {
open: boolean
onClose: () => any
}) {
return (
<Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Добавить группу</DialogTitle>
</DialogHeader>
<DialogDescription>
Если вы хотите добавить свою группу на сайт, скиньтесь всей группой и задонатьте мне 500
</DialogDescription>
<DialogDescription>
На сайте не только появится ваше расписание, но оно будет обновляться каждую неделю. А если во время парсинга произойдет ошибка я сразу получу об этом уведомление в телеграм, потому что у меня настроен бот.
</DialogDescription>
<DialogDescription>
Для меня добавить вашу группу это даже не одна строка кода, а одно нажатие клавиши, но я хочу чтобы этот сайт был доступен только самым лучшим и избранным, достойных престижа <Image src={coolEmoji} width={14} height={14} alt='' className='inline align-text-bottom' />
</DialogDescription>
<DialogFooter className='!justify-start !flex-row mt-3 gap-3'>
<Link href='https://t.me/hloth'>
<Button tabIndex={-1} className='gap-3'><BsTelegram /> Мой телеграм</Button>
</Link>
<Link href='https://vk.com/hloth'>
<Button tabIndex={-1} className='gap-3'><SlSocialVkontakte /> Мой ВКонтакте</Button>
</Link>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,22 +4,27 @@ import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
import { getSchedule } from '@/app/agregator/schedule' import { getSchedule } from '@/app/agregator/schedule'
import { NextSerialized, nextDeserializer, nextSerialized } from '@/app/utils/date-serializer' import { NextSerialized, nextDeserializer, nextSerialized } from '@/app/utils/date-serializer'
import { NavBar } from '@/widgets/navbar' import { NavBar } from '@/widgets/navbar'
import { LastUpdateAt } from '@/entities/last-update-at'
type PageProps = NextSerialized<{ type PageProps = NextSerialized<{
schedule: Day[] schedule: Day[]
parsedAt: Date
}> }>
export default function HomePage(props: PageProps) { export default function HomePage(props: PageProps) {
const { schedule } = nextDeserializer(props) const { schedule, parsedAt } = nextDeserializer(props)
return ( return (
<> <>
<NavBar /> <NavBar />
<LastUpdateAt date={parsedAt} />
<Schedule days={schedule} /> <Schedule days={schedule} />
</> </>
) )
} }
const cachedSchedules = new Map<string, { lastFetched: Date, results: Day[] }>()
const maxCacheDurationInMS = 1000 * 60 * 60
export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<PageProps>> { export async function getServerSideProps(context: GetServerSidePropsContext<{ group: string }>): Promise<GetServerSidePropsResult<PageProps>> {
const groups: { [group: string]: [number, string] } = { const groups: { [group: string]: [number, string] } = {
ps7: [146, 'ПС-7'], ps7: [146, 'ПС-7'],
@@ -27,11 +32,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
} }
const group = context.params?.group const group = context.params?.group
if (group && Object.hasOwn(groups, group) && group in groups) { if (group && Object.hasOwn(groups, group) && group in groups) {
const schedule = await getSchedule(...groups[group]) let schedule
let parsedAt
const cachedSchedule = cachedSchedules.get(group)
if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
schedule = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
} else {
schedule = await getSchedule(...groups[group])
parsedAt = new Date()
cachedSchedules.set(group, { lastFetched: new Date(), results: schedule })
}
return { return {
props: { props: nextSerialized({
schedule: nextSerialized(schedule) schedule: schedule,
} parsedAt: parsedAt
})
} }
} else { } else {
return { return {

View File

@@ -14,7 +14,7 @@ export const teachers = [
}, },
{ {
name: 'Амукова Светлана Николаевна', name: 'Амукова Светлана Николаевна',
picture: 'https://ks.psuti.ru/images/stories/emp/', picture: 'https://ks.psuti.ru/images/stories/emp/амукова.jpg',
pronouns: 'she' pronouns: 'she'
}, },
{ {
@@ -36,7 +36,7 @@ export const teachers = [
}, },
{ {
name: 'Арефьев Андрей Андреевич', name: 'Арефьев Андрей Андреевич',
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/%20class=', picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/арефьев.jpg',
pronouns: 'he' pronouns: 'he'
}, },
{ {
@@ -107,7 +107,7 @@ export const teachers = [
}, },
{ {
name: 'Ларионова Софья Николаевна', name: 'Ларионова Софья Николаевна',
picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/.jpg', picture: 'https://ks.psuti.ru/images/stories/ks-news/2016/prepodavateli-foto/larionova.jpg',
pronouns: 'she' pronouns: 'she'
}, },
{ {

View File

@@ -1,19 +1,20 @@
import { AddGroupButton } from '@/features/add-group'
import { ThemeSwitcher } from '@/features/theme-switch' import { ThemeSwitcher } from '@/features/theme-switch'
import { Button } from '@/shadcn/ui/button' import { Button } from '@/shadcn/ui/button'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import cx from 'classnames'
export function NavBar() { export function NavBar() {
const { resolvedTheme } = useTheme() const { resolvedTheme, theme } = useTheme()
return ( return (
<header className="w-full p-2"> <header className="w-full p-2">
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': resolvedTheme === 'light', 'bg-slate-900': resolvedTheme === 'dark' })}> <nav className={`rounded-lg p-2 w-full flex justify-between ${(resolvedTheme || theme) === 'light' ? 'bg-slate-200' : ''} ${(resolvedTheme || theme) === 'dark' ? 'bg-slate-900' : ''}`}>
<ul className="flex gap-2"> <ul className="flex gap-2">
<NavBarItem url="/ps7">ПС-7</NavBarItem> <NavBarItem url="/ps7">ПС-7</NavBarItem>
<NavBarItem url="/pks35k">ПКС-35к</NavBarItem> <NavBarItem url="/pks35k">ПКС-35к</NavBarItem>
<AddGroupButton />
</ul> </ul>
<ThemeSwitcher /> <ThemeSwitcher />
</nav> </nav>
@@ -30,7 +31,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
return ( return (
<li> <li>
<Link href={url}> <Link href={url}>
<Button tabIndex={-1} variant={isActive ? 'default' : 'outline'}>{children}</Button> <Button tabIndex={-1} variant={isActive ? 'default' : 'secondary'}>{children}</Button>
</Link> </Link>
</li> </li>
) )

View File

@@ -18,17 +18,18 @@ export function Day({ day }: {
.some(lesson => 'subject' in lesson && lesson.subject.length > 20) .some(lesson => 'subject' in lesson && lesson.subject.length > 20)
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-3 md:gap-5">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl"> <h1 className="scroll-m-20 text-2xl md:text-4xl font-extrabold tracking-tight lg:text-5xl">
{dayOfWeek} <span className='text-muted ml-3'>{Intl.DateTimeFormat('ru-RU', { {dayOfWeek} <span className='text-border ml-3'>{Intl.DateTimeFormat('ru-RU', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
// year: 'numeric' // year: 'numeric'
}).format(day.date)}</span> }).format(day.date)}</span>
</h1> </h1>
<div className='overflow-hidden'> <div>
<div className='overflow-auto'> <div className='overflow-auto md:snap-x md:snap-proximity md:-translate-x-16 md:w-[calc(100%+8rem)] scrollbar-hide'>
<div className="flex flex-row gap-4 w-max"> <div className="flex flex-col md:flex-row gap-4 w-full md:w-max">
<div className='snap-start hidden md:block' style={{ flex: '0 0 3rem' }} />
{day.lessons.map((lesson, i) => ( {day.lessons.map((lesson, i) => (
<Lesson <Lesson
width={longNames ? 450 : 350} width={longNames ? 450 : 350}
@@ -36,6 +37,7 @@ export function Day({ day }: {
key={i} key={i}
/> />
))} ))}
<div className='snap-start hidden md:block' style={{ flex: `0 0 calc(100vw - 4rem - ${longNames ? 450 : 350}px - 1rem)` }} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,16 @@
import type { Day as DayType } from '@/shared/model/day' import type { Day as DayType } from '@/shared/model/day'
import { Day } from '@/widgets/schedule/day' import { Day } from '@/widgets/schedule/day'
import { useRouter } from 'next/router'
export function Schedule({ days }: { export function Schedule({ days }: {
days: DayType[] days: DayType[]
}) { }) {
const group = useRouter().query['group']
return ( return (
<div className="flex flex-col p-16 gap-14"> <div className="flex flex-col p-8 md:p-16 gap-12 md:gap-14">
{days.map((day, i) => ( {days.map((day, i) => (
<Day day={day} key={i} /> <Day day={day} key={`${group}_day${i}`} />
))} ))}
</div> </div>
) )

View File

@@ -60,8 +60,8 @@ export function Lesson({ lesson, width = 350 }: {
} }
return ( return (
<Card className={`w-[${width}px] min-w-[${width}px] max-w-[${width}px] flex flex-col relative overflow-hidden`} style={{ minWidth: width, maxWidth: width }}> <Card className={`w-full ${width === 450 ? `md:w-[450px] md:min-w-[450px] md:max-w-[450px]` : `md:w-[350px] md:min-w-[350px] md:max-w-[350px]`} flex flex-col relative overflow-hidden snap-start scroll-ml-16 shrink-0`}>
{lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026]'></div>} {lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026] pointer-events-none'></div>}
<CardHeader> <CardHeader>
<div className='flex gap-4'> <div className='flex gap-4'>
{hasTeacher ? ( {hasTeacher ? (
@@ -97,7 +97,7 @@ export function Lesson({ lesson, width = 350 }: {
{lesson.topic ? ( {lesson.topic ? (
<span className='leading-relaxed hyphens-auto'>{lesson.topic}</span> <span className='leading-relaxed hyphens-auto'>{lesson.topic}</span>
) : ( ) : (
!isFallbackDiscipline && <span className='text-muted font-semibold'>Нет описания пары</span> !isFallbackDiscipline && <span className='text-border font-semibold'>Нет описания пары</span>
)} )}
</CardContent> </CardContent>
{(Boolean(lesson.resources.length) || hasPlace) && ( {(Boolean(lesson.resources.length) || hasPlace) && (

View File

@@ -1,76 +1,76 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ["class"], darkMode: ['class'],
content: [ content: [
'./pages/**/*.{ts,tsx}', './pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}', './components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}', './app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}',
], ],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: '2rem',
screens: { screens: {
"2xl": "1400px", '2xl': '1400px',
}, },
}, },
extend: { extend: {
colors: { colors: {
border: "hsl(var(--border))", border: 'hsl(var(--border))',
input: "hsl(var(--input))", input: 'hsl(var(--input))',
ring: "hsl(var(--ring))", ring: 'hsl(var(--ring))',
background: "hsl(var(--background))", background: 'hsl(var(--background))',
foreground: "hsl(var(--foreground))", foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: 'hsl(var(--primary))',
foreground: "hsl(var(--primary-foreground))", foreground: 'hsl(var(--primary-foreground))',
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: 'hsl(var(--secondary))',
foreground: "hsl(var(--secondary-foreground))", foreground: 'hsl(var(--secondary-foreground))',
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive))", DEFAULT: 'hsl(var(--destructive))',
foreground: "hsl(var(--destructive-foreground))", foreground: 'hsl(var(--destructive-foreground))',
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: 'hsl(var(--muted))',
foreground: "hsl(var(--muted-foreground))", foreground: 'hsl(var(--muted-foreground))',
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent))", DEFAULT: 'hsl(var(--accent))',
foreground: "hsl(var(--accent-foreground))", foreground: 'hsl(var(--accent-foreground))',
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover))", DEFAULT: 'hsl(var(--popover))',
foreground: "hsl(var(--popover-foreground))", foreground: 'hsl(var(--popover-foreground))',
}, },
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: 'hsl(var(--card))',
foreground: "hsl(var(--card-foreground))", foreground: 'hsl(var(--card-foreground))',
}, },
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: 'var(--radius)',
md: "calc(var(--radius) - 2px)", md: 'calc(var(--radius) - 2px)',
sm: "calc(var(--radius) - 4px)", sm: 'calc(var(--radius) - 4px)',
}, },
keyframes: { keyframes: {
"accordion-down": { 'accordion-down': {
from: { height: 0 }, from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" }, to: { height: 'var(--radix-accordion-content-height)' },
}, },
"accordion-up": { 'accordion-up': {
from: { height: "var(--radix-accordion-content-height)" }, from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }, to: { height: 0 },
}, },
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", 'accordion-down': 'accordion-down 0.2s ease-out',
"accordion-up": "accordion-up 0.2s ease-out", 'accordion-up': 'accordion-up 0.2s ease-out',
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require('tailwindcss-animate'), require('tailwind-scrollbar-hide')],
} }