feat: add course literature audit with scheduled checks

This commit is contained in:
MIEM DEV committed 2026-10-29 17:22:39 +03:00
1 parent 484095c084
commit 55f9d43ea2
45 files changed
+2404

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
.git
node_modules
.next
.wrangler
.sites-runtime
dist
work
outputs
data
.env*
*.sqlite*
*.tsbuildinfo
components
hooks
examples
db
drizzle
scripts
worker
vendor
.openai
app/chatgpt-auth.ts
+41
View File
@@ -0,0 +1,41 @@
# Next.js / Node
node_modules/
.next/
next-env.d.ts
*.tsbuildinfo
# Persistent runtime data and local state
data/
*.sqlite*
.env*
.wrangler/
.sites-runtime/
.vinext/
dist/
work/
outputs/
# Retained original Sites scaffold (not part of Node deployment)
components/
hooks/
examples/
db/
drizzle/
scripts/
worker/
vendor/
.openai/
vite.config.ts
drizzle.config.ts
cloudflare-env.d.ts
components.json
eslint.config.mjs
lib/utils.ts
lib/connectors.ts
lib/connector-preview.d.ts
lib/connector-errors.mts
lib/connector-contract.mts
lib/connector-context.ts
app/chatgpt-auth.ts
# OS
.DS_Store
AGENTS.md
CLAUDE.md
+3
View File
@@ -0,0 +1,3 @@
audit=false
fund=false
update-notifier=false
+30
View File
@@ -0,0 +1,30 @@
# Changelog
## 1.2.0 — 2026-10-29
- Хранилище перенесено на PostgreSQL для использования существующего сервера VPS; приложению предназначены отдельная база и роль.
- Добавлено общее редактируемое расписание: ежедневная или еженедельная проверка по московскому времени, выбор режима кэша, включение и отключение.
- Проверки по расписанию выполняются на сервере при закрытом браузере и возобновляются после перезапуска.
- Следующий запуск, пропуск совпавшего запуска и ошибки фоновой проверки отображаются в интерфейсе.
- Расписание и запуск сохраняются атомарно; совпавшие запуски не дублируются, после простоя выполняется один пропущенный запуск.
- При старте приложения одновременно запускаются веб-сервер и worker с общим завершением при сбое одного процесса.
## 1.1.1 — 2026-10-29
- Ответы 302/304 без материала, включая бинарный Content-Type, отмечаются как требующие ручной проверки.
## 1.1.0 — 2026-10-29
- Перенесён сервер с Cloudflare Workers/D1 на Node.js24 и постоянную SQLite для VPS.
- Добавлен путь `/courses` для интерфейса, API и шрифтов; production Origin настроен для miem.space.
- DNS-адрес закрепляется в сетевом соединении, включая повторную проверку перенаправлений.
- Добавлены standalone-сборка, непривилегированный Docker runtime, постоянный volume, healthcheck и Nginx-сниппет.
- Сохранение порций и очереди атомарно; добавлены проверки восстановления после ошибки записи, блокировки одновременных порций и Origin за reverse proxy.
## 1.0.1 — 2026-10-29
- Добавлен общий реестр доступности литературы пяти программ НИУ ВШЭ без регистрации.
- Ручной запуск с продолжением, обход пагинации, чтение основной и дополнительной литературы, включая текстовые URL.
- Сохранение результатов и контрольных сумм курсов; повторное использование неизменившихся курсов и полная проверка без кэша.
- Отдельные причины недоступности, переходы к материалам и исходным курсам, поиск, фильтры и экспорт CSV.
- Явные отметки неполного охвата, JavaScript-заглушек, ограничений доступа и устаревших результатов.
+2
View File
@@ -0,0 +1,2 @@
# Визуальная система
Режим Operate. Академический реестр: тёплая бумага, глубокий зелёный, киноварь для проблем. Боковая навигация по программам, заголовок с засечками, плотный список результатов. Состояния подписаны словами. Без иллюстративных статистик и декоративных карточек. Адаптивность, клавиатура, заметный фокус, reduced motion.
+24
View File
@@ -0,0 +1,24 @@
FROM node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm install --global pnpm@11.25.0
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY app ./app
COPY lib/model.ts lib/checker.ts lib/scanner.ts lib/store.ts lib/scheduler.ts lib/schedule-worker.ts ./lib/
COPY public ./public
COPY next.config.ts tsconfig.json postcss.config.mjs ./
RUN pnpm build
FROM node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c AS runtime
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/lib ./lib
COPY server ./server
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/courses/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node","server/runtime.mjs","--standalone"]
+6
View File
@@ -0,0 +1,6 @@
# Сверка
Общий реестр доступности литературы пяти программ НИУ ВШЭ. Без входа. Только реальные результаты; начальное состояние пустое. Node.js24 и PostgreSQL хранят результаты, контрольные суммы курсов и прогресс в постоянном volume. Проверка порциями, пока открыта вкладка; продолжение вручную. Неизменившиеся курсы используют кэш; доступна полная проверка. Успешный HTTP-ответ не доказывает доступ к полному тексту. Целевой путь — https://miem.space/courses.
Расписание: одно общее, ежедневно или еженедельно по Москве, выключено до сохранения пользователем. Запланированный аудит работает фоновым процессом при закрытом браузере и продолжается после перезапуска. Совпавший слот пропускается, если предыдущая ручная или автоматическая проверка ещё running. Изменение/отключение влияет на будущие запуски.
Для VPS используется отдельная база/роль на существующем PostgreSQL. Пароль только в окружении, не в исходниках.
+53
View File
@@ -0,0 +1,53 @@
# Сверка
Проверка доступности литературы пяти образовательных программ НИУ ВШЭ. Целевой адрес: `https://miem.space/courses`. Авторизация не требуется.
## Запуск
Требуется Node.js24 и pnpm11.25.0.
```sh
pnpm install --frozen-lockfile
TEST_DATABASE_URL=<изолированная-локальная-БД-sverka_test> pnpm test
pnpm typecheck
pnpm dev
```
Открыть `http://127.0.0.1:5173/courses`. Для сервера требуется `DATABASE_URL` с подключением к отдельной базе PostgreSQL. Там хранятся результаты, очередь и расписание; новое пустое хранилище инициализируется при первом подключении. Пароль передаётся только через окружение или защищённый файл окружения вне Git. `COURSES_ORIGIN` задаёт допустимый Origin для POST за reverse proxy, в production — `https://miem.space`.
Ручная проверка выполняется порциями, пока вкладка открыта. После её закрытия или перезапуска сервера кнопка «Продолжить» использует сохранённую очередь. Глобальная аренда не допускает одновременную обработку одной порции. Каждая порция сохраняет результаты и очередь в одной транзакции PostgreSQL; при ошибке записи задания остаются в очереди.
Страницы курсов загружаются заново, но литература неизменившихся курсов берётся из кэша вместе с исходной датой проверки. Полная проверка без кэша нужна для проверки изменений на внешних сайтах. HTTP200 не доказывает доступ к полному тексту; JavaScript-заглушки, CAPTCHA и признаки подписки отмечаются отдельно. Литература только во вложении отмечается как неполный охват.
## Расписание
В интерфейсе откройте «Проверка по расписанию»: включите автоматический запуск, выберите ежедневное или еженедельное повторение, время по Москве, при необходимости день недели и режим кэша. Сохранение и отключение доступны там же. По умолчанию расписание выключено; оно общее для всех посетителей и хранится в той же базе PostgreSQL.
`pnpm dev`, `pnpm start` и Docker запускают веб-сервер и отдельный фоновый процесс одним управляемым runtime. Проверка по расписанию выполняется без открытой вкладки, при перезапуске продолжается по сохранённой очереди. Расписание проверяется раз в пять секунд. Ручные проверки сохраняют прежний режим с открытой вкладкой.
Если любая предыдущая проверка ещё имеет статус running, совпавший слот пропускается с отметкой в интерфейсе: параллельных запусков нет. После простоя сервера выполняется один просроченный запуск; пропущенные дни не образуют очередь. Изменение или отключение расписания не прерывает уже начатую автоматическую проверку. Если один из двух процессов runtime завершится, второй останавливается; restart policy Docker поднимает их вместе.
## Production
```sh
docker compose build
docker compose up -d
```
Эти команды изменяют production: выполнять только через разрешённый deployment-процесс после независимого review. Сервис слушает только `127.0.0.1:3011`, работает от UID1000 и использует существующий сервер PostgreSQL через внешнюю Docker-сеть `miem_workers_default`. Отдельный контейнер БД или SQLite volume не создаётся. Healthcheck: `GET /courses/api/health`. Порт3011 — предлагаемый; перед запуском проверить, что он свободен. Код не зависит от Cloudflare Workers, D1 или Sites hosting.
Добавить `deploy/nginx-courses.conf` в существующий TLS-server `miem.space`, проверить `nginx -t`, затем выполнить разрешённый reload. Конфиг передаёт исходный URI без удаления `/courses`; `/courses-other` не захватывается. Фактический путь Nginx-конфига должен быть подтверждён read-only preflight, а не угадан.
## Порядок первого deployment
1. Подтвердить актуальные branch/head, независимое review, тесты, сборку, Docker daemon/диск, свободный порт и отсутствие существующей локации `/courses`.
2. Сохранить текущую конфигурацию Nginx. Использовать отдельную базу `miem_courses_links` и отдельную LOGIN-роль без SUPERUSER/CREATEDB/CREATEROLE. Не использовать учётную запись и таблицы существующих приложений. Перед изменением уже заполненной базы сделать pg_dump и проверить возможность восстановления; на первом размещении новая база пустая.
3. Собрать exact commit, запустить только новый сервис. Проверить localhost healthcheck и `/courses`, статические файлы и шрифты.
4. Включить Nginx location, выполнить syntax check/reload через разрешённый канал. Проверить HTTPS страницу, healthcheck и ручной шаг проверки, затем перезапуск сервиса с сохранением данных.
5. При неудаче вернуть прежний Nginx-конфиг, проверить и reload; остановить только новый сервис. Базу и роль не удалять. При обновлении вернуть предыдущий совместимый image tag, сохранив PostgreSQL. Не применять `compose down -v` или общий prune.
Обновление Nginx обычно не требует остановки существующих сервисов; фактический риск и разрешённый способ reload определяются инфраструктурным runbook. DATABASE_URL передаётся Docker Compose из окружения либо через защищённый deployment env-file; его содержимое не публикуется и не коммитится. В этом checkout production deployment не выполнен.
## Проверки
`TEST_DATABASE_URL` должен вести только на локальный `sverka_test`; DB-тесты создают и удаляют собственные случайные схемы и отказываются работать с другой базой. `pnpm test` проверяет извлечение ссылок, классификацию, URL со скобками, запрет непубличных IP, атомарный rollback/replay очереди, единственный общий запуск, lease, создание/изменение/отключение расписания, московское время и переходы календаря, однократный claim, skip пересекающихся запусков и выполнение worker без браузера. Node HTTP-загрузчик закрепляет проверенный DNS-адрес в сокете и проверяет каждый redirect; TLS проверяет исходное имя хоста. Прокси из environment не используются загрузчиком.
+15
View File
@@ -0,0 +1,15 @@
import {dashboard,start,step} from '../../../lib/scanner.ts';
import {saveSchedule} from '../../../lib/scheduler.ts';
export const dynamic='force-dynamic';
export const runtime='nodejs';
export async function GET(){try{return Response.json(await dashboard(),{headers:{'Cache-Control':'no-store'}});}catch{return Response.json({error:'Не удалось прочитать результаты. Попробуйте обновить страницу.'},{status:503});}}
export async function POST(request:Request){
const origin=request.headers.get('origin');
// Production uses a fixed public origin. Next's internal URL may use localhost
// during development even when the browser connected to 127.0.0.1.
const host=request.headers.get('host');
const expectedOrigin=process.env.COURSES_ORIGIN??(process.env.NODE_ENV!=='production'&&host?new URL(request.url).protocol+'//'+host:null);
if(!origin||!expectedOrigin||origin!==expectedOrigin)return Response.json({error:'Запрос с другого сайта запрещён'},{status:403});
try{const body=await request.json() as {action?:string;force?:boolean;schedule?:unknown};if(body.action==='schedule'){
try{return Response.json(await saveSchedule(body.schedule));}catch(error){if(error instanceof RangeError)return Response.json({error:error.message},{status:400});throw error;}
}if(body.action==='start')return Response.json(await start(body.force===true));if(body.action==='step')return Response.json(await step());return Response.json({error:'Неизвестное действие'},{status:400});}catch{return Response.json({error:'Не удалось выполнить шаг. Сохранённый прогресс можно продолжить.'},{status:503});}}
+5
View File
@@ -0,0 +1,5 @@
import {database} from '../../../lib/store';
import {VERSION} from '../../../lib/model';
export const dynamic='force-dynamic';
export const runtime='nodejs';
export async function GET(){try{await (await database()).query('SELECT 1');return Response.json({status:'ok',version:VERSION},{headers:{'Cache-Control':'no-store'}});}catch{return Response.json({status:'error'},{status:503});}}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import {useEffect,useRef,useState} from 'react';
import {ArrowUpRight,Download,BookOpen,Check,ChevronDown,ExternalLink,FileSearch,Link2,LoaderCircle,Pause,Play,Search,ShieldCheck,SlidersHorizontal,X,RefreshCw} from 'lucide-react';
import SchedulePanel from './schedule-panel';
import {PROGRAMS,LABELS,VERSION,DEFAULT_SCHEDULE,type Dashboard,type Run} from '../lib/model';
const date=(v?:string)=>v?new Date(v).toLocaleString('ru-RU',{timeZone:'Europe/Moscow',day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}):'Ещё не проверено';
export default function Audit(){
const [data,setData]=useState<Dashboard>({run:null,courses:[],checks:{},programs:PROGRAMS,schedule:{...DEFAULT_SCHEDULE}}),[loaded,setLoaded]=useState(false);
const [loading,setLoading]=useState(true),[active,setActive]=useState(false),[error,setError]=useState(''),[program,setProgram]=useState('all'),[query,setQuery]=useState(''),[status,setStatus]=useState('issues'),[view,setView]=useState('links'),[force,setForce]=useState(false),[expanded,setExpanded]=useState(''),[limit,setLimit]=useState(40);
const running=useRef(false),mounted=useRef(true);
async function refresh(){const r=await fetch('/courses/api/audit',{cache:'no-store'}),d=await r.json() as Dashboard & {error?:string};if(!r.ok)throw new Error(d.error);if(mounted.current){setData(d);setLoaded(true);}}
useEffect(()=>{mounted.current=true;refresh().catch(e=>setError(e.message)).finally(()=>setLoading(false));return()=>{mounted.current=false;running.current=false;};},[]);
async function post(action:string){const r=await fetch('/courses/api/audit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,force})}),d=await r.json() as Run & {error?:string};if(!r.ok)throw new Error(d.error);return d as Run;}
async function launch(){if(running.current)return;running.current=true;setActive(true);setError('');try{let run=data.run?.status==='running'?data.run:await post('start');setData(d=>({...d,run}));while(running.current&&run?.status==='running'){run=await post('step');if(!mounted.current)break;setData(d=>({...d,run}));await refresh();await new Promise(r=>setTimeout(r,350));}}catch(e){setError((e as Error).message);}finally{running.current=false;if(mounted.current){setActive(false);refresh().catch(()=>{});}}}
function pause(){running.current=false;}
const serverActive=data.run?.source==='schedule'&&data.run.status==='running';
useEffect(()=>{if(active||(!data.schedule.enabled&&!serverActive))return;const timer=setInterval(()=>{refresh().catch(e=>setError(e.message));},3000);return()=>clearInterval(timer);},[active,data.schedule.enabled,serverActive]);
const run=data.run,courses=data.courses.filter(c=>program==='all'||c.program===program);
const rows=courses.flatMap(course=>course.refs.map(ref=>({course,ref,result:data.checks[ref.url]}))),problems=rows.filter(r=>r.result&&r.result.status!=='ok');
const visible=rows.filter(r=>(status==='all'||status==='issues'&&r.result&&r.result.status!=='ok'||status==='pending'&&!r.result||r.result?.status===status)&&(!query||(r.ref.title+' '+r.ref.url+' '+r.course.name).toLowerCase().includes(query.toLowerCase())));
const shownCourses=courses.filter(c=>!query||(c.name+' '+c.url).toLowerCase().includes(query.toLowerCase()));
useEffect(()=>setLimit(40),[query,status,program,view]);
function exportCsv(){const rows=[['Программа','Курс','Ссылка на курс','Литература','URL','Статус','Причина','Проверено'],...visible.map(r=>[PROGRAMS.find(p=>p.id===r.course.program)?.name??'',r.course.name,r.course.url,r.ref.title,r.ref.url,r.result?LABELS[r.result.status]:'Не проверено',r.result?.reason??'',r.result?.checkedAt??''])];const csv='\uFEFF'+rows.map(row=>row.map(v=>'"'+String(v).replace(/^[=+@-]/,"'$&").replaceAll('"','""')+'"').join(';')).join('\r\n');const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));a.download='sverka-links.csv';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);}
const selected=PROGRAMS.find(p=>p.id===program),percent=run?.phase==='links'?Math.round(run.checked/Math.max(1,run.totalLinks)*100):run?.phase==='courses'?Math.round(run.processed/Math.max(1,run.totalCourses)*100):0;
return <div className="shell"><a className="skip" href="#main">К результатам</a><aside className="sidebar"><a href="/courses" className="brand"><span className="brand__mark"><Link2 size={25}/></span>сверка<span className="brand__dot">.</span></a><div className="sidebar__description">Доступность знаний<br/>начинается со ссылки.</div><nav aria-label="Разделы" className="primary-nav"><button className={view==='links'?'selected':''} onClick={()=>setView('links')}><FileSearch size={18}/>Результаты проверки<span>{problems.length||'—'}</span></button><button className={view==='courses'?'selected':''} onClick={()=>setView('courses')}><BookOpen size={18}/>Учебные курсы</button></nav><div className="sidebar__label">Образовательные программы</div><nav className="program-nav" aria-label="Программы"><button className={program==='all'?'selected':''} onClick={()=>setProgram('all')}><span>Все программы</span><b>5</b></button>{PROGRAMS.map(p=><button key={p.id} className={program===p.id?'selected':''} onClick={()=>{setProgram(p.id);if(view==='method')setView('links');}}><span>{p.name}<small>{p.level}</small></span><ArrowUpRight size={15}/></button>)}</nav><div className="sidebar__bottom"><ShieldCheck size={20}/><p>Без регистрации.<br/><span>Общий реестр результатов.</span></p></div></aside><div className="workspace"><header className="topbar"><span>НИУ ВШЭ <span className="topbar__divider">/</span> Учебная литература</span><button onClick={()=>setView(view==='method'?'links':'method')}>Как это работает <ArrowUpRight size={14}/></button></header><main id="main"><div className="page-title"><div><h1>{view==='method'?'Как устроена сверка':view==='courses'?'Учебные курсы':'Ссылки под наблюдением'}</h1><p>{view==='method'?'Что проверяется и как читать результаты.':'Проверяйте литературу. Находите недоступные материалы.'}</p></div><span className="edition">5 программ<span>единый реестр</span></span></div>
{error&&<div role="alert" className="error-banner"><span>{error}</span><button onClick={()=>{setError('');refresh().catch(e=>setError(e.message));}}>Повторить</button></div>}
{view==='method'?<section className="method"><h2>От программы — к материалу</h2><p>Сверка читает текущий учебный год на пяти страницах ВШЭ, обходит пагинацию и открывает каждый курс. Из разделов основной и дополнительной литературы извлекаются кликабельные ссылки и адреса, записанные обычным текстом.</p><h2>Вы управляете проверкой</h2><p>Нажмите «Запустить проверку» и оставьте вкладку открытой. Можно приостановить проверку, закрыть вкладку и позже продолжить сохранённый запуск. Одновременно выполняется один общий запуск. Приостановка в этой вкладке не останавливает проверку у другого посетителя.</p><h2>Проверка по расписанию</h2><p>Настройте ежедневный или еженедельный запуск и время по Москве. Расписание общее для всех посетителей и сохраняется на сервере. Автоматическая проверка работает без браузера. Изменение или отключение расписания влияет на будущие запуски; уже начатая проверка продолжается. Если другая проверка ещё идёт, совпавший запуск пропускается. После простоя сервера выполняется один просроченный запуск, без очереди пропущенных повторений.</p><h2>Изменения вместо повторений</h2><p>Страницы курсов загружаются и сравниваются по контрольной сумме. Неизменившиеся курсы сохраняют результаты и исходные даты проверки ссылок. Одинаковые адреса в изменившихся курсах проверяются один раз за запуск. Полная проверка игнорирует кэш: используйте её, чтобы обнаружить изменения на внешних сайтах при неизменной программе курса.</p><h2>Успешный ответ — ещё не полный текст</h2><p>Различаются 404/410, вход, ограничения доступа, CAPTCHA, ошибки сервера и тайм-ауты. «Доступна» означает успешный ответ сервера. Карточка книги может открываться, а текст — требовать подписку. Ограничения определяются по признакам на странице и требуют ручного подтверждения. Сверка не обходит вход и защиту сайта.</p><h2>Границы проверки</h2><p>Проверяется литература в HTML-программах. Если раздел отсутствует, доступен только во вложении или не читается, курс получает отдельную отметку. Архивные годы и навигационные ссылки не проверяются. Старые результаты сохраняются с датами; непрочитанные и исчезнувшие курсы отмечаются как не обновлённые.</p></section>:<>
<section className="control" aria-label="Управление проверкой"><div className="control__icon"><Link2 size={25}/></div><div className="control__copy"><h2>{serverActive?'Выполняется проверка по расписанию':active?'Сверяем источники':run?.status==='running'?'Проверка ожидает продолжения':run?'Результаты сохранены':'Начните с первой проверки'}</h2><p aria-live="polite">{active||serverActive?run?.message:run?`Последний запуск: ${date(run.startedAt)} · МСК`:'Все курсы пяти программ. Только реальные результаты.'}</p></div><div className="control__actions">{serverActive?<button disabled className="button button--primary"><LoaderCircle size={16} className="spin"/>Выполняется на сервере</button>:active?<button className="button button--primary" onClick={pause}><Pause size={16}/>Приостановить</button>:<button disabled={loading} className="button button--primary" onClick={launch}>{loading?<LoaderCircle size={16} className="spin"/>:<Play size={16}/>} {run?.status==='running'?'Продолжить проверку':'Запустить проверку'}</button>}<label className="force"><input type="checkbox" checked={force} disabled={active||run?.status==='running'} onChange={e=>setForce(e.target.checked)}/>Полная проверка без кэша</label></div>{run?.status==='running'&&<div className="scan-progress"><div><span>{run.phase==='catalogs'?`Каталоги: ${run.visited.length}`:run.phase==='courses'?`Курсы: ${run.processed} / ${run.totalCourses}`:`Адреса: ${run.checked} / ${run.totalLinks}`}</span><span>{serverActive?'Выполняется без открытой вкладки':'Оставьте вкладку открытой'}</span></div><progress aria-label="Ход проверки" max={100} value={run.phase==='catalogs'?undefined:percent}/></div>}</section>
{loaded?<SchedulePanel schedule={data.schedule} onSaved={schedule=>setData(d=>({...d,schedule}))}/>:<p className="schedule__hint" role="status">{loading?'Загружаем настройки расписания…':'Расписание недоступно. Повторите загрузку через сообщение об ошибке выше.'}</p>}<div className="summary" aria-label="Сводка"><div><span className="summary__number summary__number--problem">{problems.length||'—'}</span><span>ссылок требуют внимания</span></div><div><span className="summary__number">{courses.length||'—'}</span><span>курсов в реестре</span></div><div><span className="summary__number">{rows.filter(r=>r.result?.status==='ok').length||'—'}</span><span>ссылок доступны</span></div><div><span className="summary__number">{run?.reused??'—'}</span><span>курсов взято из кэша</span></div></div>
{run&&run.errors.length>0&&<details className="source-errors"><summary>Не удалось прочитать {run.errors.length} источников — охват неполный</summary>{run.errors.map((e,i)=><p key={i}><a href={e.url.startsWith('https://')?e.url:undefined} target="_blank" rel="noreferrer">{e.url}</a><br/>{e.reason}</p>)}</details>}
<section className="results"><div className="results__heading"><h2>{view==='courses'?'Каталог курсов':'Результаты проверки'}<span>{view==='courses'?shownCourses.length:visible.length}</span></h2><button className="text-button" onClick={exportCsv} disabled={!visible.length}><Download size={16}/>Экспорт CSV</button></div><div className="filters"><label className="search"><Search size={18}/><input aria-label="Поиск по курсу, материалу или адресу" placeholder="Найти курс, материал или ссылку" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button aria-label="Очистить поиск" onClick={()=>setQuery('')}><X size={16}/></button>}</label>{view==='links'&&<label className="status-select"><SlidersHorizontal size={16}/><select aria-label="Причина недоступности" value={status} onChange={e=>setStatus(e.target.value)}><option value="issues">Требуют внимания</option><option value="all">Все ссылки</option>{Object.entries(LABELS).map(([k,v])=><option value={k} key={k}>{v}</option>)}<option value="pending">Не проверены</option></select><ChevronDown size={14}/></label>}</div>{selected&&<div className="scope">{selected.name}<button aria-label="Сбросить программу" onClick={()=>setProgram('all')}><X size={14}/></button><a href={selected.url} target="_blank" rel="noreferrer">Каталог ВШЭ <ExternalLink size={13}/></a></div>}
{loading?<div className="empty" role="status"><LoaderCircle className="spin" size={30}/><h3>Загружаем реестр</h3></div>:view==='courses'?shownCourses.length?<div className="course-list">{shownCourses.slice(0,limit).map(c=><article key={c.url}><div><a className="course-name" href={c.url+'#library'} target="_blank" rel="noreferrer">{c.name}<ArrowUpRight size={16}/></a><p>{PROGRAMS.find(p=>p.id===c.program)?.name}</p>{c.issue&&<p className="course-issue">{c.issue}</p>}<small>{date(c.checkedAt)} · {c.refs.length} адресов {c.reused?'· из кэша':''}{run&&c.seen!==run.id?' · не обновлён в текущем запуске':''}</small></div><span className={'badge '+(c.issue?'badge--blocked':'badge--ok')}>{c.issue?'Нужна проверка':c.refs.length?'Литература найдена':'Нет ссылок'}</span></article>)}</div>:<Empty hasRun={!!run} filtered={!!query||program!=='all'} launch={launch}/>:visible.length?<><div className="table-head"><span>Материал и источник</span><span>Результат</span><span>Проверено, МСК</span></div><div className="link-list">{visible.slice(0,limit).map(r=>{const key=r.course.url+r.ref.url;return <article key={key} className="link-row"><div className="link-row__main"><span className={'status-dot status-dot--'+(r.result?.status??'pending')}/><div><a className="material-name" href={r.ref.url} target="_blank" rel="noreferrer">{r.ref.title||r.ref.url}<ArrowUpRight size={15}/></a><div className="material-url">{r.ref.url}</div><a className="source-course" href={r.course.url+'#library'} target="_blank" rel="noreferrer"><BookOpen size={13}/>{r.course.name}<ArrowUpRight size={12}/></a><small className="program-name">{PROGRAMS.find(p=>p.id===r.course.program)?.name}{run&&r.course.seen!==run.id?' · прежний результат':''}</small></div></div><div className="link-row__result"><span className={'badge badge--'+(r.result?.status??'pending')}>{r.result?LABELS[r.result.status]:'Не проверено'}</span><button className="reason-toggle" aria-expanded={expanded===key} onClick={()=>setExpanded(expanded===key?'':key)}>Причина <ChevronDown size={12}/></button></div><div className="link-row__date">{date(r.result?.checkedAt)}{r.result?.code&&<small>HTTP {r.result.code}</small>}</div>{expanded===key&&<div className="reason"><p>{r.result?.reason??'Адрес ещё не проверен. Запустите или продолжите сверку.'}</p>{r.result?.finalUrl&&r.result.finalUrl!==r.ref.url&&<a href={r.result.finalUrl} target="_blank" rel="noreferrer">После перенаправления: {r.result.finalUrl}</a>}{r.course.reused&&<small>Содержание курса не изменилось. Использован сохранённый результат.</small>}</div>}</article>;})}</div></>:<Empty hasRun={!!run} filtered={!!query||program!=='all'||status!=='issues'} launch={launch}/>}
{(view==='courses'?shownCourses.length:visible.length)>limit&&<button className="load-more" onClick={()=>setLimit(n=>n+40)}>Показать ещё 40</button>}<div className="results__note"><ShieldCheck size={15}/><span>Блокировка проверки не означает, что материал удалён. Причину можно уточнить по исходной ссылке.</span></div></section><div className="bottom-note"><span>Проверяем изменения, сохраняем результаты.</span><span><RefreshCw size={13}/>Неизменившиеся курсы — из кэша</span></div></>}
<footer><span>сверка. <span>Независимый инструмент · источники: hse.ru</span></span><span>Интерфейс {VERSION} · Сервер {VERSION}</span></footer></main></div></div>;}
function Empty({hasRun,filtered,launch}:{hasRun:boolean;filtered:boolean;launch:()=>void}){return <div className="empty"><div className="empty__drawing"><FileSearch size={43} strokeWidth={1.15}/><span><Check size={15}/></span></div><h3>{filtered?'По этим условиям ничего нет':hasRun?'Проблемных ссылок пока нет':'Пока ни одна ссылка не проверена'}</h3><p>{filtered?'Измените запрос, причину или выбранную программу.':hasRun?'Смотрите охват и список курсов. Непрочитанные источники отмечаются отдельно.':'Запустите сверку, чтобы найти недоступную литературу и увидеть, в каких курсах она используется.'}</p>{!hasRun&&!filtered&&<button className="text-button" onClick={launch}>Проверить пять программ<ArrowUpRight size={16}/></button>}<div className="empty__legend"><span><i className="status-dot status-dot--broken"/>404 / удалена</span><span><i className="status-dot status-dot--auth"/>Требуется вход</span><span><i className="status-dot status-dot--blocked"/>Ограничен доступ</span></div></div>;}
+8
View File
@@ -0,0 +1,8 @@
@import url('/courses/fonts/fonts.css');
:root{--paper:#f7f7f2;--white:#fff;--ink:#243a32;--muted:#69756d;--forest:#203e32;--line:#dfe3da;--red:#a54735;--accent:#d9ebbb;--sans:'Golos Text',sans-serif;--serif:'Lora',Georgia,serif}
*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer;color:inherit}button:disabled{opacity:.45;cursor:default}a{color:inherit;text-decoration:none;text-underline-offset:4px}a:hover{text-decoration:underline}button{border:0;background:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #85a457;outline-offset:4px}::selection{background:#d9ebbb;color:#203e32}input{caret-color:var(--forest)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:#b7c2b5;border-radius:8px}svg{flex-shrink:0;vertical-align:middle}.shell{min-height:100vh;display:grid;grid-template-columns:275px minmax(0,1fr)}.sidebar{background:var(--forest);color:#f2f5ed;padding:38px 21px 24px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;font-size:32px;font-weight:600;letter-spacing:-1.3px;padding:0 10px;line-height:1}.brand:hover{text-decoration:none}.brand__mark{border:1px solid #b9ccad;width:38px;height:38px;display:grid;place-items:center;border-radius:50%;margin-right:11px;color:var(--accent)}.brand__dot{color:var(--accent)}.sidebar__description{color:#c1cfbf;font-size:12px;line-height:1.7;margin:24px 10px 39px}.primary-nav{display:grid;gap:6px}.primary-nav button{display:flex;align-items:center;text-align:left;gap:11px;padding:13px 11px;font-size:12px;border-radius:5px;color:#d6dfd1}.primary-nav button.selected{background:#3d5743;color:#f6f9f0}.primary-nav button:hover,.program-nav button:hover{background:#35513e}.primary-nav button span{margin-left:auto;background:#50664c;color:#e0edce;border-radius:3px;padding:0 6px;font-size:11px}.sidebar__label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#b6c6af;margin:37px 10px 14px}.program-nav{display:grid;gap:3px}.program-nav button{text-align:left;color:#d9e3d3;display:flex;align-items:flex-start;justify-content:space-between;gap:9px;font-size:12px;line-height:1.55;padding:12px 11px;border-radius:5px}.program-nav button.selected{background:#d9ebbb;color:#223c2e}.program-nav small{display:block;font-size:10px;opacity:.75;margin-top:4px}.program-nav svg{margin-top:2px}.program-nav b{font-weight:500}.sidebar__bottom{border-top:1px solid #4b604a;display:flex;gap:12px;align-items:center;margin: auto 10px 0;padding-top:20px;color:#c5d5b9}.sidebar__bottom p{font-size:11px;line-height:1.7;margin:0}.sidebar__bottom span{color:#b2c1a9}.workspace{min-width:0}.topbar{height:76px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 44px;font-size:11px}.topbar>span{font-weight:500}.topbar__divider{color:#a7b3a2;margin:0 14px}.topbar button{display:flex;gap:10px;align-items:center;color:var(--muted);font-size:11px}main{max-width:1530px;margin:0 auto;padding:44px 44px 0}.page-title{display:flex;justify-content:space-between;gap:20px;align-items:center;margin:0 0 32px}h1{font-family:var(--serif);font-weight:400;font-size:clamp(28px,3vw,43px);line-height:1.2;letter-spacing:-1.3px;margin:0 0 12px}.page-title p{margin:0;color:var(--muted);font-size:12px}.edition{font-size:12px;white-space:nowrap;text-align:right;padding-left:22px;border-left:1px solid var(--line)}.edition span{display:block;color:var(--muted);font-size:10px;margin-top:4px}.control{border:1px solid #ced8c2;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:17px;background:#eef2e7;padding:26px 25px;border-radius:7px}.control__icon{width:47px;height:47px;display:grid;place-items:center;background:#e0e8d5;border-radius:50%;color:#4b6340}.control h2{font-size:15px;margin:0 0 6px;font-weight:550}.control p{font-size:11px;color:#5f7058;margin:0;overflow-wrap:anywhere}.control__actions{display:grid;gap:10px;justify-items:center}.button{display:inline-flex;align-items:center;justify-content:center;gap:9px;border-radius:5px;padding:12px 16px;font-size:12px;font-weight:500;white-space:nowrap;transition:background .15s}.button--primary{background:var(--forest);color:#f4f7ef}.button--primary:hover{background:#345b42}.force{display:flex;align-items:center;font-size:10px;color:#596950;gap:6px;cursor:pointer}.force input{accent-color:#34553a;width:12px;height:12px;margin:0}.scan-progress{grid-column:1/-1;border-top:1px solid #d4dfcb;padding-top:14px;font-size:11px;color:#536a48}.scan-progress>div{display:flex;justify-content:space-between;gap:10px}progress{width:100%;height:5px;accent-color:var(--forest);margin-top:10px;display:block}.summary{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr;padding:30px 0 28px;border-bottom:1px solid var(--line);gap:18px;margin-bottom:34px}.summary>div{display:flex;flex-direction:column;padding-left:24px;border-left:1px solid var(--line)}.summary>div:first-child{padding-left:0;borderLine truncated
@media(min-width:1500px){.shell{grid-template-columns:290px minmax(0,1fr)}main{padding:55px 64px 0}.topbar{padding:0 64px}.sidebar{padding-top:42px}.control{padding:30px}.summary{padding:35px 0}.empty{min-height:340px}}
@media(max-width:1150px){.shell{grid-template-columns:235px minmax(0,1fr)}.sidebar{padding:28px 13px 20px}.sidebar__description{margin-bottom:28px}.sidebar__label{font-size:9px}.program-nav button{font-size:11px;padding:10px}.primary-nav button{font-size:11px}.topbar{padding:0 27px}main{padding:35px 27px 0}.control{grid-template-columns:auto minmax(0,1fr);padding:20px}.control__actions{grid-column:2;justify-items:start}.edition{display:none}.summary{gap:10px}.summary>div{padding-left:14px}.table-head,.link-row{grid-template-columns:minmax(0,1fr) 130px}.link-row__date{grid-column:2}.table-head>span:last-child{display:none}.filters{flex-wrap:wrap}.search{flex-basis:260px}footer{flex-wrap:wrap;gap:8px}.sidebar__bottom{margin-top:25px}.sidebar{overflow-y:auto}.bottom-note{flex-wrap:wrap}}
@media(max-width:760px){.shell{display:block}.sidebar{height:auto;position:relative;padding:22px 20px 14px;overflow:visible}.brand{font-size:28px;padding:0}.brand__mark{width:32px;height:32px}.sidebar__description,.sidebar__label,.sidebar__bottom{display:none}.primary-nav{display:flex;margin-top:23px;gap:10px}.primary-nav button{font-size:11px;padding:10px;flex:1;justify-content:center}.program-nav{display:flex;overflow-x:auto;gap:7px;margin-top:15px;padding-bottom:5px}.program-nav button{white-space:nowrap;flex-shrink:0;padding:7px 11px;max-width:235px;overflow:hidden;align-items:center;background:#2d4c38;font-size:10px}.program-nav button>span{overflow:hidden;text-overflow:ellipsis}.program-nav small,.program-nav svg{display:none}.program-nav b{margin-left:4px}.topbar{height:54px;padding:0 20px;font-size:9px}.topbar__divider{margin:0 8px}.topbar button{font-size:9px;gap:5px}main{padding:28px 20px 0}.page-title{margin-bottom:23px}h1{font-size:31px;letter-spacing:-.8px}.page-title p{font-size:11px;max-width:290px;line-height:1.8}.control{gap:12px;padding:19px 16px;grid-template-columns:auto minmax(0,1fr)}.control__icon{width:38px;height:38px}.control h2{font-size:13px}.control p{font-size:10px}.control__actions{grid-column:1/-1;justify-items:stretch;margin-top:6px}.force{justify-content:center}.summary{grid-template-columns:1fr 1fr;gap:23px 10px;margin-bottom:25px;padding:25px 0}.summary>div{padding-left:17px}.summary>div:nth-child(3){padding-left:0;border:0}.summary__number{font-size:27px}.summary>div>span:last-child{font-size:10px}.results__heading{padding:18px 14px 15px;gap:6px}.results__heading h2{font-size:14px;gap:7px}.text-button{font-size:10px;gap:5px}.filters{padding:0 14px 16px;gap:8px}.search{padding:10px;font-size:10px}.status-select{width:100%}.status-select select{flex:1;max-width:none}.table-head{display:none}.link-row{padding:18px 14px;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px}.link-row__main{grid-column:1/-1}.link-row__result{grid-column:1;padding-left:19px;display:flex;gap:10px;align-items:center}.link-row__date{grid-column:2;font-size:9px}.link-row__date small{display:none}.material-name{font-size:12px}.program-name{font-size:9px}.empty{padding:29px 16px 24px;min-height:320px}.empty h3{font-size:15px}.empty p{font-size:11px}.empty__legend{gap:13px;font-size:9px}.results__note{font-size:9px;padding:12px 14px;align-items:start}.results__note svg{margin-top:2px}.bottom-note{font-size:9px}.course-list article{padding:17px 14px;flex-direction:column;gap:9px}.course-list .badge{align-self:flex-start}.scope{padding:0 14px 15px;flex-wrap:wrap;font-size:10px}.scope a{margin-left:0}footer{margin-top:35px;padding:18px 0;font-size:9px;display:grid}footer>span>span{margin-left:6px;font-size:8px}.scan-progress>div{flex-direction:column;gap:4px}.error-banner{font-size:11px}.method h2{font-size:23px}.method p{font-size:13px}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
.schedule{margin-top:16px;border-bottom:1px solid var(--line)}.schedule summary{display:flex;align-items:center;gap:12px;cursor:pointer;list-style:none;padding:17px 2px;color:var(--forest)}.schedule summary::-webkit-details-marker{display:none}.schedule__title{font-size:13px;font-weight:500}.schedule__title small{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px}.schedule__state{margin-left:auto;font-size:10px;color:var(--muted);white-space:nowrap}.schedule__state--on{color:#42602f}.schedule__chevron{transition:transform .15s}.schedule details[open] .schedule__chevron{transform:rotate(180deg)}.schedule__form{padding:8px 0 24px}.schedule__toggle{display:flex;align-items:center;gap:9px;font-size:12px;font-weight:500;cursor:pointer}.schedule input[type=checkbox]{accent-color:var(--forest);width:15px;height:15px;margin:0}.schedule__fields{display:flex;flex-wrap:wrap;gap:16px;margin:20px 0 14px}.schedule__fields label{display:flex;flex-direction:column;gap:7px;font-size:10px;color:var(--muted);min-width:140px;flex:1}.schedule__fields select,.schedule__fields input{border:1px solid #c9d2c3;border-radius:4px;padding:11px 10px;background:var(--white);color:var(--ink);font-size:12px;min-width:0;width:100%;height:42px}.schedule__hint{font-size:11px;line-height:1.8;color:var(--muted);max-width:80ch;margin:10px 0}.schedule__history{font-size:11px;margin:12px 0;color:#52654b}.schedule__error{font-size:11px;line-height:1.7;color:var(--red);margin:12px 0;overflow-wrap:anywhere}.schedule__actions{display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-top:19px}.schedule__actions>span{display:flex;align-items:center;gap:7px;color:#52654b;font-size:11px}@media(max-width:760px){.schedule summary{gap:9px}.schedule__title{font-size:12px}.schedule__title small{font-size:10px;max-width:230px}.schedule__state{font-size:9px}.schedule__fields{gap:12px}.schedule__fields label{flex-basis:calc(50% - 12px);min-width:120px}.schedule__actions{gap:12px}.schedule__actions .button{width:100%}}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Сверка — доступность учебной литературы",
description: "Проверка ссылок на литературу в учебных курсах пяти программ НИУ ВШЭ.",
icons: {
icon: "/courses/favicon.svg",
shortcut: "/courses/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru">
<body className="antialiased">{children}</body>
</html>
);
}
+2
View File
@@ -0,0 +1,2 @@
import Audit from './dashboard';
export default function Home(){return <Audit/>;}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import {useEffect,useState} from 'react';
import {CalendarClock,ChevronDown,LoaderCircle,Check} from 'lucide-react';
import {type Schedule,type ScheduleSettings} from '../lib/model';
const DAYS=['Воскресенье','Понедельник','Вторник','Среда','Четверг','Пятница','Суббота'];
const date=(value:string|null)=>value?new Date(value).toLocaleString('ru-RU',{timeZone:'Europe/Moscow',day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'}):'—';
export default function SchedulePanel({schedule,onSaved}:{schedule:Schedule;onSaved:(schedule:Schedule)=>void}){
const [form,setForm]=useState<ScheduleSettings>(schedule),[dirty,setDirty]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[saved,setSaved]=useState(false);
useEffect(()=>{if(!dirty&&!busy)setForm(schedule);},[schedule,dirty,busy]);
function update(value:Partial<ScheduleSettings>){setForm(f=>({...f,...value}));setDirty(true);setSaved(false);setError('');}
async function save(event:React.FormEvent){event.preventDefault();setBusy(true);setError('');setSaved(false);
try{const response=await fetch('/courses/api/audit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'schedule',schedule:{enabled:form.enabled,frequency:form.frequency,time:form.time,weekday:form.weekday,force:form.force}})});
const result=await response.json() as Schedule & {error?:string};if(!response.ok)throw new Error(result.error??'Не удалось сохранить расписание.');setForm(result);setDirty(false);setSaved(true);onSaved(result);
}catch(e){setError(e instanceof Error?e.message:'Не удалось сохранить расписание.');}finally{setBusy(false);}
}
return <section className="schedule" aria-label="Расписание проверки"><details><summary><CalendarClock size={19}/><span className="schedule__title">Проверка по расписанию<small>{schedule.enabled?`Следующий запуск: ${date(schedule.nextRunAt)} · МСК`:'Автоматическая проверка выключена'}</small></span><span className={'schedule__state '+(schedule.enabled?'schedule__state--on':'')}>{schedule.enabled?'Включено':'Настроить'}</span><ChevronDown className="schedule__chevron" size={15}/></summary>
<form className="schedule__form" onSubmit={save}>
<label className="schedule__toggle"><input type="checkbox" checked={form.enabled} onChange={e=>update({enabled:e.target.checked})} disabled={busy}/>Запускать проверку автоматически</label>
<div className="schedule__fields"><label>Повторять<select value={form.frequency} onChange={e=>update({frequency:e.target.value as ScheduleSettings['frequency']})} disabled={busy}><option value="daily">Каждый день</option><option value="weekly">Каждую неделю</option></select></label>
{form.frequency==='weekly'&&<label>День недели<select value={form.weekday} onChange={e=>update({weekday:Number(e.target.value)})} disabled={busy}>{[1,2,3,4,5,6,0].map(day=><option value={day} key={day}>{DAYS[day]}</option>)}</select></label>}
<label>Время по Москве<input type="time" required value={form.time} onChange={e=>update({time:e.target.value})} disabled={busy}/></label>
<label>Режим проверки<select value={String(form.force)} onChange={e=>update({force:e.target.value==='true'})} disabled={busy}><option value="false">Только изменившиеся курсы</option><option value="true">Полная, без кэша</option></select></label></div>
<p className="schedule__hint">Запланированная проверка выполняется на сервере — вкладку можно закрыть. Новые настройки не прерывают уже начатую проверку. Если другая проверка ещё идёт, совпавший запуск пропускается.</p>
{schedule.lastAttemptAt&&<p className="schedule__history">{schedule.lastOutcome==='skipped'?'Последний запуск пропущен: предыдущая проверка ещё не завершена.':'Последний автоматический запуск:'} {date(schedule.lastAttemptAt)} · МСК</p>}
{schedule.lastError&&<p className="schedule__error" role="status">Ошибка фоновой проверки: {schedule.lastError}</p>}
{error&&<p className="schedule__error" role="alert">{error}</p>}
<div className="schedule__actions"><button type="submit" className="button button--primary" disabled={busy||!dirty}>{busy&&<LoaderCircle size={15} className="spin"/>}Сохранить расписание</button><span role="status">{saved?<><Check size={15}/>Расписание сохранено</>:dirty?'Есть несохранённые изменения':''}</span></div>
</form></details></section>;
}
+30
View File
@@ -0,0 +1,30 @@
name: miem-courses
services:
courses:
build: .
image: miem-courses:1.2.0
restart: unless-stopped
init: true
user: "1000:1000"
ports:
- "127.0.0.1:${COURSES_PORT:-3011}:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
COURSES_ORIGIN: https://miem.space
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL for the dedicated courses PostgreSQL database}
networks:
- database
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 512m
cpus: 1.0
networks:
database:
external: true
name: ${COURSES_DATABASE_NETWORK:-miem_workers_default}
+17
View File
@@ -0,0 +1,17 @@
# Include inside the existing TLS server for miem.space; preserve the /courses prefix.
location = /courses {
proxy_pass http://127.0.0.1:3011;
include /etc/nginx/proxy_params;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 35s;
}
location ^~ /courses/ {
proxy_pass http://127.0.0.1:3011;
include /etc/nginx/proxy_params;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 35s;
}
+99
View File
@@ -0,0 +1,99 @@
import {parseHTML} from 'linkedom';
import {lookup} from 'node:dns/promises';
import {request as httpRequest} from 'node:http';
import {request as httpsRequest} from 'node:https';
import {createUnzip,createBrotliDecompress} from 'node:zlib';
import type {Check,Reference} from './model';
export function safeUrl(value:string,base?:string):string|null{
try{const u=new URL(value,base),h=u.hostname.toLowerCase();
if(!['https:','http:'].includes(u.protocol)||u.username||u.password||(u.port&&!['80','443'].includes(u.port)))return null;
if(!h.includes('.')||h.includes(':')||/^\d+[.\d]*$/.test(h)||h.endsWith('.')||/(^|\.)(localhost|local|internal|test|invalid|example|onion|arpa)$/.test(h))return null;
u.hash='';return u.href;}catch{return null;}}
export function clean(text:string){return text.replace(/\s+/g,' ').trim();}
export function trimUrl(value:string){let v=value.replace(/[.,;:!?]+$/g,'');for(const [open,close] of [['(',')'],['[',']'],['{','}']])while(v.endsWith(close)&&v.split(close).length>v.split(open).length)v=v.slice(0,-1);return v;}
export function parseCatalog(html:string,url:string,root:string){
const {document}=parseHTML(html),courses=new Map<string,{url:string;name:string}>(),pages=new Set<string>();
for(const a of document.querySelectorAll('a[href]')){const href=safeUrl(a.getAttribute('href')!,url);if(!href)continue;
const u=new URL(href),r=new URL(root);if(u.origin!==r.origin)continue;
if(new RegExp('^'+r.pathname+'/[0-9]+\\.html$').test(u.pathname))courses.set(u.origin+u.pathname,{url:u.origin+u.pathname,name:clean(a.textContent??'')});
if(new RegExp('^'+r.pathname+'/page[0-9]+\\.html$').test(u.pathname)&&(url===root||u.search===new URL(url).search))pages.add(href);}
return {courses:[...courses.values()],pages:[...pages]};}
export function parseCourse(html:string,url:string){
const {document}=parseHTML(html),refs=new Map<string,Reference>();
const sections=[...document.querySelectorAll('.pud__section')].filter(s=>/список литературы|основная литература|дополнительная литература|reading list|bibliography/i.test(s.querySelector('h4,h3,h2')?.textContent??''));
let found=sections.length>0;
if(!found){const h=[...document.querySelectorAll('h2,h3,h4')].find(h=>/^(список литературы|литература|reading list|bibliography)$/i.test(clean(h.textContent??'')));if(h){found=true;let n=h.nextElementSibling;while(n&&!/^H[1-4]$/.test(n.tagName)){sections.push(n);n=n.nextElementSibling;}}}
for(const s of sections){const items=[...s.querySelectorAll('li')];for(const item of items.length?items:[s]){
const title=clean(item.textContent??''),urls=[...item.querySelectorAll('a[href]')].map(a=>a.getAttribute('href')!);
urls.push(...(title.match(/https?:\/\/[^\s<>"«»]+/gi)??[]).map(trimUrl));
for(const raw of urls){const normalized=safeUrl(raw,url);if(normalized&&normalized!==url)refs.set(normalized,{url:normalized,title:title.slice(0,2000)});}}}
const content=[...document.querySelectorAll('.pud__section')].map(s=>clean(s.textContent??'')).join('\n')||clean(document.querySelector('main')?.textContent??document.querySelector('h1')?.textContent??'');
return {name:clean(document.querySelector('h1')?.textContent??''),refs:[...refs.values()],literature:found,content};}
export async function fingerprint(v:string){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(v)))].map(n=>n.toString(16).padStart(2,'0')).join('');}
export function publicIp(ip:string){
if(ip.includes(':')){const fields=ip.toLowerCase().split(':');return /^[23][0-9a-f]{3}:/i.test(ip)&&fields[0]!=='2002'&&fields[0]!=='3fff'&&!(fields[0]==='2001'&&(parseInt(fields[1]||'0',16)<=0x1ff||parseInt(fields[1],16)===0xdb8));}
const p=ip.split('.').map(Number);if(p.length!==4||p.some(n=>!Number.isInteger(n)||n<0||n>255))return false;
const [a,b,c]=p;return !(a===0||a===10||a===127||a>=224||a===169&&b===254||a===172&&b>=16&&b<=31||a===192&&(b===168||b===0||b===88&&c===99)||a===100&&b>=64&&b<=127||a===198&&(b===18||b===19||b===51&&c===100)||a===203&&b===0&&c===113);
}
export async function resolvePublicHost(host:string){
const addresses=await lookup(host,{all:true,verbatim:true});
if(!addresses.length)throw new Error('DNS: адрес сервера не найден');
if(addresses.some(a=>!publicIp(a.address)))throw new Error('DNS указывает на непубличный адрес; запрос запрещён');
return addresses.find(a=>a.family===4)??addresses[0];
}
export async function retrieve(raw:string,source=false){
let url=safeUrl(raw);if(!url)throw new Error('Адрес не разрешён политикой безопасности');
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),12000);
const aborted=new Promise<never>((_,reject)=>controller.signal.addEventListener('abort',()=>reject(new DOMException('Истекло время ожидания','AbortError')),{once:true}));
try{
for(let hop=0;hop<5;hop++){
const target=new URL(url),ip=await Promise.race([resolvePublicHost(target.hostname),aborted]);
// Pin the validated address into this socket. Redirects create a new socket and repeat DNS validation.
const result=await new Promise<{code:number;location?:string;body:string;type:string}>((resolve,reject)=>{
const request=(target.protocol==='https:'?httpsRequest:httpRequest)(target,{
agent:false,family:ip.family,signal:controller.signal,
lookup:(_hostname,_options,callback)=>callback(null,ip.address,ip.family),
headers:{'User-Agent':'Sverka/1.1 (academic bibliography check)','Accept':'text/html,application/pdf;q=0.9,*/*;q=0.8','Accept-Encoding':'identity'},
},response=>{
const code=response.statusCode??0,type=String(response.headers['content-type']??''),location=response.headers.location;
if(location&&code>=300&&code<400){response.destroy();resolve({code,location,body:'',type});return;}
if(!source&&!/html|text\//i.test(type)){response.destroy();resolve({code,body:'',type});return;}
void(async()=>{
const encoding=String(response.headers['content-encoding']??'');
const stream=/gzip|deflate/i.test(encoding)?response.pipe(createUnzip()):/br/i.test(encoding)?response.pipe(createBrotliDecompress()):response;
// pipe() does not forward source errors to a decompressor by itself.
response.on('error',error=>stream.destroy(error));
response.on('aborted',()=>stream.destroy(new Error('Соединение прервано')));
request.once('error',error=>{response.destroy();stream.destroy(error);});
const chunks:Buffer[]=[];let size=0;
try{for await(const chunk of stream){const bytes=Buffer.from(chunk);size+=bytes.length;if(size>(source?3000000:1500000))throw new Error('Превышен допустимый размер загрузки');chunks.push(bytes);}
const charset=/charset=([\w-]+)/i.exec(type)?.[1]??'utf-8';let body:string;try{body=new TextDecoder(charset).decode(Buffer.concat(chunks));}catch{body=Buffer.concat(chunks).toString('utf8');}resolve({code,body,type});
}catch(error){response.destroy();stream.destroy();reject(error);}
})();
});request.on('error',reject);request.end();
});
if(result.location&&result.code>=300&&result.code<400){const next=safeUrl(result.location,url);if(!next)throw new Error('Небезопасное перенаправление');url=next;continue;}
return {...result,url};
}
throw new Error('Слишком много перенаправлений');
}finally{clearTimeout(timer);}
}
export function classify(url:string,r:{code:number;url:string;body:string;type:string}):Check{
const out:Check={url,finalUrl:r.url,code:r.code,checkedAt:new Date().toISOString(),status:'ok',reason:'Сервер вернул материал. Доступ к полному тексту может потребовать ручной проверки.'};
if(r.code===404||r.code===410)return {...out,status:'broken',reason:`HTTP ${r.code}: ${r.code===404?'страница не найдена':'материал удалён'}.`};
if(r.code===401||/(?:\/|\.)(?:login|signin|sign-in|sso|auth)(?:[/.?]|$)/i.test(r.url))return {...out,status:'auth',reason:'Требуется авторизация или выполнено перенаправление на вход.'};
if(r.code===403||r.code===429)return {...out,status:'blocked',reason:`HTTP ${r.code}: сервер ограничил автоматический доступ. Проверьте вручную.`};
if(r.code>=400)return {...out,status:'network',reason:`HTTP ${r.code}: сервер не смог предоставить материал.`};
if(r.code<200||r.code>=300)return {...out,status:'unknown',reason:`Необычный ответ HTTP ${r.code}; нужна ручная проверка.`};
if(r.code===204||r.code===205)return {...out,status:'unknown',reason:`HTTP ${r.code}: сервер вернул ответ без содержимого. Доступ к материалу не подтверждён.`};
if(!/html|text\//i.test(r.type))return out;
if(!r.body.trim())return {...out,status:'unknown',reason:'Сервер вернул пустую страницу. Доступ к материалу не подтверждён.'};
const {document}=parseHTML(r.body);const scripted=!!document.querySelector('script');document.querySelectorAll('script,style,nav,footer,header').forEach(n=>n.remove());
const title=clean(document.querySelector('title,h1')?.textContent??''),text=clean(document.querySelector('body')?.textContent??document.documentElement?.textContent??r.body);
if(/captcha|verify you are human|checking your browser|just a moment|докажите, что вы|проверка браузера|доступ ограничен/i.test(title+' '+text.slice(0,2500)))return {...out,status:'blocked',reason:'CAPTCHA или антибот-защита: автоматически подтвердить доступность нельзя.'};
if(/^(404|страница не найдена|page not found|not found)/i.test(title))return {...out,status:'broken',reason:'Сайт показывает «не найдено» при успешном HTTP-ответе.'};
if(document.querySelector('input[type="password"]')&&text.length<10000)return {...out,status:'auth',reason:'Обнаружена форма входа с паролем; доступ к материалу не подтверждён.'};
if(/(для (?:чтения|доступа|просмотра).{0,80}(?:авториз|войд|подписк)|(?:авторизуйтесь|войдите).{0,80}(?:чита|доступ|просмотр)|sign in to (?:read|access|view)|purchase (?:this|access)|institutional access|доступ (?:к полному тексту|по подписке)|читать по подписке)/i.test(text))return {...out,status:'restricted',reason:'Для полного текста может потребоваться вход, подписка или доступ организации. Проверьте вручную.'};
if(/html/i.test(r.type)&&!text)return {...out,status:'unknown',reason:'Получена пустая HTML-страница. Доступ к материалу не подтверждён.'};
if(/html/i.test(r.type)&&((scripted&&text.length<200)||/^(загрузка|loading)[.\s…]*$/i.test(text)))return {...out,status:'unknown',reason:'Получена оболочка страницы с загрузкой через JavaScript. Доступ к материалу не подтверждён; откройте ссылку вручную.'};return out;}
export async function checkLink(url:string):Promise<Check>{try{return classify(url,await retrieve(url));}catch(e){const error=e as Error;return {url,status:error.name==='AbortError'?'timeout':'network',reason:error.name==='AbortError'?'Сервер не ответил за 12 секунд.':`Не удалось загрузить: ${error.message.slice(0,180)}`,checkedAt:new Date().toISOString()};}}
+17
View File
@@ -0,0 +1,17 @@
export const VERSION='1.2.0';
export const PROGRAMS=[
{id:'it',name:'Цифровые технологии и телекоммуникации',level:'Бакалавриат',url:'https://www.hse.ru/ba/it/courses'},
{id:'is',name:'Информационная безопасность',level:'Бакалавриат',url:'https://www.hse.ru/ba/is/courses'},
{id:'cosec',name:'Компьютерная безопасность',level:'Специалитет',url:'https://www.hse.ru/ba/cosec/courses'},
{id:'internet',name:'Интернет вещей и киберфизические системы',level:'Магистратура',url:'https://www.hse.ru/ma/internet/courses'},
{id:'cyber',name:'Информационная безопасность и технологии искусственного интеллекта',level:'Магистратура',url:'https://www.hse.ru/ma/cyber/courses'}];
export type Status='ok'|'broken'|'auth'|'blocked'|'timeout'|'network'|'restricted'|'unknown';
export const LABELS:Record<string,string>={ok:'Доступна',broken:'Не найдена',auth:'Требуется вход',blocked:'Доступ запрещён',timeout:'Нет ответа',network:'Ошибка соединения',restricted:'Ограничен доступ',unknown:'Нужна проверка'};
export type Check={url:string;finalUrl?:string;status:Status;reason:string;code?:number;checkedAt:string};
export type Reference={url:string;title:string};
export type Course={url:string;name:string;program:string;hash?:string;refs:Reference[];issue?:string;checkedAt:string;seen:string;reused:boolean;literature:boolean};
export type Run={id:string;source?:'manual'|'schedule';status:'running'|'complete';startedAt:string;finishedAt?:string;force:boolean;phase:'catalogs'|'courses'|'links'|'done';catalogs:{url:string;program:string}[];visited:string[];courses:{url:string;name:string;program:string}[];links:string[];seen:string[];errors:{url:string;reason:string}[];processed:number;reused:number;checked:number;totalCourses:number;totalLinks:number;message:string};
export type ScheduleSettings={enabled:boolean;frequency:'daily'|'weekly';time:string;weekday:number;force:boolean};
export type Schedule=ScheduleSettings & {nextRunAt:string|null;updatedAt:string|null;lastRunAt:string|null;lastAttemptAt:string|null;lastOutcome:'started'|'skipped'|null;lastError:string|null};
export const DEFAULT_SCHEDULE:Schedule={enabled:false,frequency:'daily',time:'09:00',weekday:1,force:false,nextRunAt:null,updatedAt:null,lastRunAt:null,lastAttemptAt:null,lastOutcome:null,lastError:null};
export type Dashboard={run:Run|null;courses:Course[];checks:Record<string,Check>;programs:typeof PROGRAMS;schedule:Schedule};
+46
View File
@@ -0,0 +1,46 @@
import {PROGRAMS,DEFAULT_SCHEDULE,type Schedule,type Run,type Course,type Check} from './model.ts';
import {checkLink,retrieve,parseCatalog,parseCourse,fingerprint} from './checker.ts';
import {database,transaction,type Connection} from './store.ts';
async function read<T>(key:string):Promise<T|null>{const {rows}=await (await database()).query<{value:string}>('SELECT value FROM records WHERE key=$1',[key]);return rows[0]?JSON.parse(rows[0].value):null;}
export async function getRun(connection?:Connection):Promise<Run|null>{const {rows}=await (connection??await database()).query<{data:string}>('SELECT data FROM scan WHERE id=1');return rows[0]?JSON.parse(rows[0].data):null;}
export async function dashboard(){const run=await getRun(),{rows}=await (await database()).query<{key:string;value:string}>('SELECT key,value FROM records'),courses:Course[]=[],checks:Record<string,Check>={};
let schedule:Schedule={...DEFAULT_SCHEDULE};for(const row of rows){if(row.key.startsWith('course:'))courses.push(JSON.parse(row.value));else if(row.key.startsWith('link:'))checks[row.key.slice(5)]=JSON.parse(row.value);else if(row.key==='schedule')schedule=JSON.parse(row.value);}return {run,courses,checks,programs:PROGRAMS,schedule};}
export async function start(force:boolean,source:'manual'|'schedule'='manual',connection?:Connection){const run:Run={id:crypto.randomUUID(),source,status:'running',phase:'catalogs',startedAt:new Date().toISOString(),force,catalogs:PROGRAMS.map(p=>({url:p.url,program:p.id})),visited:[],courses:[],links:[],seen:[],errors:[],processed:0,reused:0,checked:0,totalCourses:0,totalLinks:0,message:'Читаем каталоги пяти программ'};
const db=connection??await database();await db.query("INSERT INTO scan(id,data,lease,token) VALUES(1,$1,0,'') ON CONFLICT(id) DO UPDATE SET data=excluded.data,lease=0,token='' WHERE scan.data::jsonb->>'status'!='running'",[JSON.stringify(run)]);return getRun(db);}
export async function step(){const token=crypto.randomUUID(),now=Date.now(),db=await database();
const {rows}=await db.query<{data:string}>('UPDATE scan SET lease=$1,token=$2 WHERE id=1 AND lease<$3 RETURNING data',[now+90000,token,now]);if(!rows[0])return getRun();const run:Run=JSON.parse(rows[0].data);const pending:{key:string;value:unknown}[]=[];let succeeded=false;
try{if(run.status!=='running')return run;
if(run.phase==='catalogs'){
const task=run.catalogs.shift();if(task&&!run.visited.includes(task.url)){run.visited.push(task.url);run.message=`Читаем каталог: ${PROGRAMS.find(p=>p.id===task.program)?.name}`;
try{const page=await retrieve(task.url,true);if(page.code!==200)throw new Error(`Каталог вернул HTTP ${page.code}`);
const root=PROGRAMS.find(p=>p.id===task.program)!.url,parsed=parseCatalog(page.body,task.url,root);if(!parsed.courses.length)throw new Error('Курсы не найдены: каталог недоступен или его структура изменилась');
for(const c of parsed.courses)if(!run.seen.includes(c.url)){run.seen.push(c.url);run.courses.push({...c,program:task.program});}
for(const url of parsed.pages)if(!run.visited.includes(url)&&!run.catalogs.some(p=>p.url===url))run.catalogs.push({url,program:task.program});run.totalCourses=run.seen.length;
}catch(e){run.errors.push({url:task.url,reason:(e as Error).message});}}
if(!run.catalogs.length)run.phase='courses';
}else if(run.phase==='courses'){
const task=run.courses.shift();if(task){run.message=`Читаем курс: ${task.name}`;const old=await read<Course>('course:'+task.url);
let course:Course={...task,refs:old?.refs??[],checkedAt:old?.checkedAt??'',seen:run.id,reused:false,literature:false};
try{const page=await retrieve(task.url,true);if(page.code!==200)throw new Error(`Страница курса вернула HTTP ${page.code}`);
const parsed=parseCourse(page.body,task.url);if(!parsed.name)throw new Error('Не удалось прочитать программу курса');const hash=await fingerprint(parsed.name+parsed.content+JSON.stringify(parsed.refs));
if(!run.force&&old?.hash===hash&&!old.issue){course={...old,seen:run.id,reused:true};run.reused++;}
else{course={...course,name:parsed.name,hash,refs:parsed.refs,literature:parsed.literature,checkedAt:new Date().toISOString()};if(!parsed.literature)course.issue='Раздел литературы не найден. Проверьте программу курса вручную.';
for(const ref of course.refs)if(!run.links.includes(ref.url))run.links.push(ref.url);}
}catch(e){course.issue=(e as Error).message;}
pending.push({key:'course:'+task.url,value:course});run.processed++;}
if(!run.courses.length){run.phase='links';run.totalLinks=run.links.length;}
}else if(run.phase==='links'){
const urls=run.links.splice(0,3);run.message='Проверяем доступность материалов';const results=await Promise.all(urls.map(checkLink));for(const result of results){pending.push({key:'link:'+result.url,value:result});run.checked++;}
if(!run.links.length){run.status='complete';run.phase='done';run.finishedAt=new Date().toISOString();run.message=run.errors.length?'Проверка завершена с ошибками чтения каталогов':'Проверка завершена';}
}
succeeded=true;
}finally{
if(succeeded){
try{await transaction(async client=>{
const {rows}=await client.query<{token:string}>('SELECT token FROM scan WHERE id=1 FOR UPDATE');
if(rows[0]?.token!==token)throw new Error('Порция проверки уже выполняется другим процессом');
for(const record of pending)await client.query('INSERT INTO records(key,value) VALUES($1,$2) ON CONFLICT(key) DO UPDATE SET value=excluded.value',[record.key,JSON.stringify(record.value)]);
await client.query("UPDATE scan SET data=$1,lease=0,token='' WHERE id=1 AND token=$2",[JSON.stringify(run),token]);
});}catch(error){await db.query("UPDATE scan SET lease=0,token='' WHERE id=1 AND token=$1",[token]);throw error;}
}else await db.query("UPDATE scan SET lease=0,token='' WHERE id=1 AND token=$1",[token]);
}return run;}
+10
View File
@@ -0,0 +1,10 @@
import {setTimeout as sleep} from 'node:timers/promises';
import {schedulerStep,recordSchedulerError} from './scheduler.ts';
// The worker shares SQLite leases with the API; restarting the container resumes scheduled runs.
for(;;){
let busy=false;
try{busy=await schedulerStep();}
catch(error){console.error('Scheduled audit failed; retrying in five seconds');try{await recordSchedulerError(error);}catch(storageError){console.error('Cannot save scheduler error');}}
await sleep(busy?350:5000);
}
+66
View File
@@ -0,0 +1,66 @@
import {DEFAULT_SCHEDULE,type Schedule,type ScheduleSettings} from './model.ts';
import {database,transaction,type Connection} from './store.ts';
import {getRun,start,step} from './scanner.ts';
export function validateSchedule(value:unknown):ScheduleSettings{
if(!value||typeof value!=='object')throw new RangeError('Укажите настройки расписания.');
const s=value as Record<string,unknown>;
if(typeof s.enabled!=='boolean'||typeof s.force!=='boolean')throw new RangeError('Укажите состояние расписания и режим кэша.');
if(s.frequency!=='daily'&&s.frequency!=='weekly')throw new RangeError('Выберите ежедневную или еженедельную проверку.');
if(typeof s.time!=='string'||!/^([01]\d|2[0-3]):[0-5]\d$/.test(s.time))throw new RangeError('Укажите время в формате ЧЧ:ММ.');
if(!Number.isInteger(s.weekday)||Number(s.weekday)<0||Number(s.weekday)>6)throw new RangeError('Выберите день недели.');
return {enabled:s.enabled,frequency:s.frequency,time:s.time,weekday:s.weekday as number,force:s.force};
}
const clock=new Intl.DateTimeFormat('en-CA',{timeZone:'Europe/Moscow',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
function moscowWallTime(instant:number){const parts=Object.fromEntries(clock.formatToParts(instant).map(p=>[p.type,p.value]));return Date.UTC(+parts.year,+parts.month-1,+parts.day,+parts.hour,+parts.minute,+parts.second);}
export function nextOccurrence(settings:ScheduleSettings,now=Date.now()):string{
const local=new Date(moscowWallTime(now)),[hour,minute]=settings.time.split(':').map(Number);
for(let days=0;days<=7;days++){
const wall=Date.UTC(local.getUTCFullYear(),local.getUTCMonth(),local.getUTCDate()+days,hour,minute);
if(settings.frequency==='weekly'&&new Date(wall).getUTCDay()!==settings.weekday)continue;
let instant=wall;for(let i=0;i<3;i++)instant+=wall-moscowWallTime(instant);
if(instant>now)return new Date(instant).toISOString();
}
throw new Error('Не удалось рассчитать следующий запуск.');
}
export async function getSchedule(connection?:Connection):Promise<Schedule>{
const {rows}=await (connection??await database()).query<{value:string}>("SELECT value FROM records WHERE key='schedule'");
return rows[0]?JSON.parse(rows[0].value):{...DEFAULT_SCHEDULE};
}
async function persist(schedule:Schedule,client:Connection){await client.query("INSERT INTO records(key,value) VALUES('schedule',$1) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[JSON.stringify(schedule)]);}
export async function saveSchedule(value:unknown,now=Date.now()):Promise<Schedule>{
const settings=validateSchedule(value);
return transaction(async client=>{
await client.query('SELECT pg_advisory_xact_lock(72847322)');
const schedule:Schedule={...await getSchedule(client),...settings,updatedAt:new Date(now).toISOString(),nextRunAt:settings.enabled?nextOccurrence(settings,now):null};
await persist(schedule,client);return schedule;
});
}
export async function claimScheduledRun(now=Date.now()){
await transaction(async client=>{
await client.query('SELECT pg_advisory_xact_lock(72847322)');
const schedule=await getSchedule(client);
if(schedule.enabled&&schedule.nextRunAt&&Date.parse(schedule.nextRunAt)<=now){
const run=await getRun(client);schedule.lastAttemptAt=new Date(now).toISOString();schedule.nextRunAt=nextOccurrence(schedule,now);
if(run?.status==='running')schedule.lastOutcome='skipped';
else{
const started=await start(schedule.force,'schedule',client);
if(started?.source==='schedule'){schedule.lastRunAt=new Date(now).toISOString();schedule.lastOutcome='started';schedule.lastError=null;}
else schedule.lastOutcome='skipped';
}
await persist(schedule,client);
}
});
}
export async function schedulerStep(now=Date.now()){
await claimScheduledRun(now);
const run=await getRun();
if(run?.source==='schedule'&&run.status==='running'){await step();return true;}
return false;
}
export async function recordSchedulerError(error:unknown){
await transaction(async client=>{
await client.query('SELECT pg_advisory_xact_lock(72847322)');
const schedule=await getSchedule(client);schedule.lastError='Не удалось обработать очередь. Сервер повторит попытку через пять секунд.';await persist(schedule,client);
});
}
+26
View File
@@ -0,0 +1,26 @@
import {Pool,type PoolClient} from 'pg';
export type Connection=Pool|PoolClient;
const runtime=globalThis as typeof globalThis & {coursesPool?:Pool;coursesReady?:Promise<void>};
async function inTransaction<T>(pool:Pool,work:(client:PoolClient)=>Promise<T>):Promise<T>{
const client=await pool.connect();
try{await client.query('BEGIN');const result=await work(client);await client.query('COMMIT');return result;}
catch(error){await client.query('ROLLBACK').catch(()=>{});throw error;}
finally{client.release();}
}
export async function database():Promise<Pool>{
if(!runtime.coursesPool){
if(!process.env.DATABASE_URL)throw new Error('DATABASE_URL is required');
runtime.coursesPool=new Pool({connectionString:process.env.DATABASE_URL,max:4,connectionTimeoutMillis:5000,idleTimeoutMillis:30000,statement_timeout:15000});
runtime.coursesPool.on('error',()=>console.error('PostgreSQL connection unavailable'));
}
const pool=runtime.coursesPool;
runtime.coursesReady??=inTransaction(pool,async client=>{
await client.query('SELECT pg_advisory_xact_lock(72847321)');
await client.query('CREATE TABLE IF NOT EXISTS records(key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL)');
await client.query("CREATE TABLE IF NOT EXISTS scan(id INTEGER PRIMARY KEY,data TEXT NOT NULL,lease BIGINT NOT NULL DEFAULT 0,token TEXT NOT NULL DEFAULT '')");
});
try{await runtime.coursesReady;}catch(error){runtime.coursesReady=undefined;throw error;}
return pool;
}
export async function transaction<T>(work:(client:PoolClient)=>Promise<T>):Promise<T>{return inTransaction(await database(),work);}
export async function closeDatabase(){const pool=runtime.coursesPool;runtime.coursesPool=undefined;runtime.coursesReady=undefined;if(pool)await pool.end();}
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
basePath: '/courses',
serverExternalPackages: ['linkedom','pg'],
};
export default nextConfig;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "sverka",
"version": "1.2.0",
"private": true,
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "node server/runtime.mjs --dev",
"build": "next build --webpack",
"start": "node server/runtime.mjs",
"test": "node --experimental-strip-types --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.4",
"react": "19.2.6",
"react-dom": "19.2.6",
"linkedom": "0.18.14",
"lucide-react": "1.63.0",
"pg": "8.16.3"
},
"devDependencies": {
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3",
"@types/pg": "8.15.5"
},
"type": "module",
"packageManager": "pnpm@11.25.0"
}
+1291
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
sharp: true
unrs-resolver: true
workerd: true
+1
View File
@@ -0,0 +1 @@
export default {plugins:{}};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#203e32"/><g fill="none" stroke="#d9ebbb" stroke-width="2.5" stroke-linecap="round"><path d="M13 11H11a5 5 0 0 0 0 10h2M19 11h2a5 5 0 0 1 0 10h-2M12 16h8"/></g></svg>

After

Width:  |  Height:  |  Size: 270 B

+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
@font-face {
font-family: 'Golos Text';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/courses/fonts/font-0.woff2) format('truetype');
}
@font-face {
font-family: 'Golos Text';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/courses/fonts/font-1.woff2) format('truetype');
}
@font-face {
font-family: 'Golos Text';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/courses/fonts/font-2.woff2) format('truetype');
}
@font-face {
font-family: 'Golos Text';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/courses/fonts/font-3.woff2) format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/courses/fonts/font-4.woff2) format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/courses/fonts/font-5.woff2) format('truetype');
}
+14
View File
@@ -0,0 +1,14 @@
import {spawn} from 'node:child_process';
import {fileURLToPath} from 'node:url';
import {join,resolve} from 'node:path';
import {cpSync} from 'node:fs';
const root=resolve(fileURLToPath(new URL('..',import.meta.url)));
const dev=process.argv.includes('--dev'),standalone=process.argv.includes('--standalone');
if(!dev&&!standalone){cpSync(join(root,'public'),join(root,'.next/standalone/public'),{recursive:true});cpSync(join(root,'.next/static'),join(root,'.next/standalone/.next/static'),{recursive:true});}
const webArgs=standalone?['server.js']:dev?[join(root,'node_modules/next/dist/bin/next'),'dev','--webpack','-H','127.0.0.1','-p',process.env.PORT??'5173']:[join(root,'.next/standalone/server.js')];
const children=[spawn(process.execPath,webArgs,{cwd:root,stdio:'inherit'}),spawn(process.execPath,['--experimental-strip-types',join(root,'lib/schedule-worker.ts')],{cwd:root,stdio:'inherit'})];
let stopping=false;
function stop(code){if(stopping)return;stopping=true;process.exitCode=code;for(const child of children)if(child.exitCode===null)child.kill('SIGTERM');const timer=setTimeout(()=>{for(const child of children)if(child.exitCode===null)child.kill('SIGKILL');},5000);timer.unref();}
for(const child of children){child.on('error',error=>{console.error(error);stop(1);});child.on('exit',(code,signal)=>{if(!stopping)stop(code||1);});}
process.on('SIGINT',()=>stop(0));process.on('SIGTERM',()=>stop(0));
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
import {parseCatalog,parseCourse,classify,safeUrl,fingerprint} from '../lib/checker.ts';
const url='https://www.hse.ru/ba/is/courses/1.html';
const result=(body='',code=200,finalUrl='https://public.org/book')=>({body,code,url:finalUrl,type:'text/html'});
const html='<html><head><title>Book</title></head><body><h1>Algorithms</h1><div class="pud__section"><h4>Список литературы</h4><ul><li>Книга https://books.org/a. <a href="https://books.org/a">читать</a></li><li>Другая https://books.org/b (дата).</li></ul></div><footer><a href="https://ignore.org/">footer</a></footer></body></html>';
test('extracts linked and plain-text bibliography, normalizes and excludes footer',()=>{const p=parseCourse(html,url);assert.equal(p.name,'Algorithms');assert.deepEqual(p.refs.map(r=>r.url),['https://books.org/a','https://books.org/b']);assert.equal(p.literature,true);});
test('missing bibliography is explicit',()=>assert.equal(parseCourse('<h1>Course</h1>',url).literature,false));
test('pagination follows current program and excludes archived years',()=>{const root='https://www.hse.ru/ba/is/courses',p=parseCatalog('<a href="/ba/is/courses/1.html">A</a><a href="/ba/is/courses/page2.html?year=2026">2</a><a href="/ba/is/courses?year=2023">archive</a><a href="/ba/it/courses/4.html">other</a>',root,root);assert.equal(p.courses.length,1);assert.deepEqual(p.pages,[root+'/page2.html?year=2026']);});
test('classifies errors, login, protection and subscription separately',()=>{assert.equal(classify(url,result('',404)).status,'broken');assert.equal(classify(url,result('',403)).status,'blocked');assert.equal(classify(url,result('',401)).status,'auth');assert.equal(classify(url,result('',200,'https://public.org/login')).status,'auth');assert.equal(classify(url,result('<html><title>Just a moment</title><body>Checking your browser</body></html>')).status,'blocked');assert.equal(classify(url,result('<html><body>Sign in to read this book</body></html>')).status,'restricted');assert.equal(classify(url,result('<html><body><input type="password"/></body></html>')).status,'auth');assert.equal(classify(url,result('<html><body>Open book</body></html>')).status,'ok');});
test('rejects dangerous and non-http URLs',()=>{for(const v of ['http://127.0.0.1','http://[::1]','http://localhost/a','https://foo.internal','file:///a','https://a:b@public.org','https://public.org:8080'])assert.equal(safeUrl(v),null);assert.equal(safeUrl('https://public.org/book#x'),'https://public.org/book');});
test('hash detects content changes and remains stable',async()=>{assert.equal(await fingerprint(html),await fingerprint(html));assert.notEqual(await fingerprint(html),await fingerprint(html+'x'));});
import {publicIp,trimUrl} from '../lib/checker.ts';
test('preserves balanced parentheses in URLs',()=>{assert.equal(trimUrl('https://public.org/Foo_(bar).'),'https://public.org/Foo_(bar)');assert.equal(trimUrl('https://public.org/book).'),'https://public.org/book');});
test('rejects private, reserved and loopback DNS answers',()=>{for(const ip of ['127.0.0.1','10.1.2.3','169.254.169.254','100.64.0.1','172.16.2.2','192.168.1.1','192.0.2.1','198.18.1.1','203.0.113.1','::1','fc00::1','fe80::1','2001:db8::1'])assert.equal(publicIp(ip),false,ip);assert.equal(publicIp('1.1.1.1'),true);assert.equal(publicIp('2606:4700:4700::1111'),true);});
test('does not mark a JavaScript loading shell as accessible',()=>{assert.equal(classify(url,result('<html><body><div>Загрузка...</div><script src="app.js"></script></body></html>')).status,'unknown');});
test('empty HTML and No Content responses do not prove material access',()=>{for(const code of [200,204,205])assert.equal(classify(url,result('',code)).status,'unknown');});
test('non-successful binary responses do not count as available material',()=>{for(const code of [302,304])assert.equal(classify(url,{...result('',code),type:'application/pdf'}).status,'unknown');assert.equal(classify(url,{...result(),type:'application/pdf'}).status,'ok');});
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {POST} from '../app/api/audit/route.ts';
const request=(origin:string,host:string)=>new Request('http://localhost:5173/courses/api/audit',{method:'POST',headers:{Origin:origin,Host:host,'Content-Type':'application/json'},body:JSON.stringify({action:'invalid'})});
test('development accepts the actual browser Host, not Next internal localhost',async()=>{
delete process.env.COURSES_ORIGIN;
assert.equal((await POST(request('http://127.0.0.1:5173','127.0.0.1:5173'))).status,400);
assert.equal((await POST(request('https://other.example','127.0.0.1:5173'))).status,403);
});
test('canonical production origin works behind a proxy and rejects another site',async()=>{
process.env.COURSES_ORIGIN='https://miem.space';
assert.equal((await POST(request('https://miem.space','internal:3000'))).status,400);
assert.equal((await POST(request('https://other.example','internal:3000'))).status,403);
delete process.env.COURSES_ORIGIN;
});
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {database} from '../lib/store.ts';
import {dashboard,getRun,start,step} from '../lib/scanner.ts';
import type {Run} from '../lib/model.ts';
import {useTestDatabase} from './test-database.ts';
useTestDatabase('scanner');
test('only one shared manual run can be started',async()=>{
const [first,second]=await Promise.all([start(false),start(false)]);
assert.equal(first?.id,second?.id);assert.equal(second?.force,false);
});
test('a failed record transaction preserves its queue and can resume',async()=>{
const run=(await getRun())!;run.phase='links';run.links=['http://127.0.0.1/'];run.totalLinks=1;run.checked=0;
const db=await database();await db.query("UPDATE scan SET data=$1,lease=0,token='' WHERE id=1",[JSON.stringify(run)]);
await db.query("CREATE FUNCTION reject_record() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'simulated disk failure'; END $$");
await db.query('CREATE TRIGGER fail_record BEFORE INSERT ON records FOR EACH ROW EXECUTE FUNCTION reject_record()');
try{await assert.rejects(step(),/simulated disk failure/);const afterFailure=(await getRun())!;assert.deepEqual(afterFailure.links,run.links);assert.equal(afterFailure.checked,0);assert.equal((await dashboard()).checks['http://127.0.0.1/'],undefined);}
finally{await db.query('DROP TRIGGER fail_record ON records');await db.query('DROP FUNCTION reject_record()');}
const resumed=(await step())!;assert.equal(resumed.status,'complete');assert.equal(resumed.checked,1);assert.equal((await dashboard()).checks['http://127.0.0.1/'].status,'network');
});
test('an active lease prevents two workers from processing the same queue',async()=>{
const run=(await start(false))!;run.phase='links';run.links=['http://127.0.0.1/'];
await (await database()).query('UPDATE scan SET data=$1,lease=$2,token=$3 WHERE id=1',[JSON.stringify(run),Date.now()+60000,'another-worker']);
const unchanged=(await step()) as Run;assert.deepEqual(unchanged.links,run.links);assert.equal(unchanged.checked,0);
});
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {fileURLToPath} from 'node:url';
import {spawn} from 'node:child_process';
import {setTimeout as sleep} from 'node:timers/promises';
import {database} from '../lib/store.ts';
import {DEFAULT_SCHEDULE} from '../lib/model.ts';
import {getRun,start,dashboard} from '../lib/scanner.ts';
import {nextOccurrence,validateSchedule,saveSchedule,getSchedule,claimScheduledRun} from '../lib/scheduler.ts';
import {POST} from '../app/api/audit/route.ts';
import {useTestDatabase} from './test-database.ts';
useTestDatabase('scheduler');
const settings={...DEFAULT_SCHEDULE,enabled:true};
const now=Date.parse('2026-10-29T05:00:00Z');
async function reset(){const db=await database();await db.query('DELETE FROM records');await db.query('DELETE FROM scan');}
test('daily and weekly times use Moscow including midnight, month rollover and exact boundary',()=>{
assert.equal(nextOccurrence(settings,now),'2026-10-29T06:00:00.000Z');
assert.equal(nextOccurrence(settings,Date.parse('2026-10-29T06:00:00Z')),'2026-10-30T06:00:00.000Z');
assert.equal(nextOccurrence({...settings,time:'00:15'},Date.parse('2026-10-31T21:00:00Z')),'2026-10-31T21:15:00.000Z');
assert.equal(nextOccurrence({...settings,frequency:'weekly',weekday:1},now),'2026-11-02T06:00:00.000Z');
});
test('strict validation rejects impossible times and malformed configuration',()=>{
for(const bad of [null,{}, {...settings,time:'25:00'},{...settings,time:'9:00'},{...settings,time:'12:90'},{...settings,enabled:'true'},{...settings,force:1},{...settings,weekday:7},{...settings,weekday:1.5},{...settings,frequency:'hourly'}])assert.throws(()=>validateSchedule(bad),RangeError);
});
test('schedule can be created, edited and disabled without dropping history',async()=>{
await reset();assert.equal((await getSchedule()).enabled,false);
const first=await saveSchedule(settings,now);assert.equal(first.nextRunAt,'2026-10-29T06:00:00.000Z');
const changed=await saveSchedule({...settings,time:'11:30',frequency:'weekly',weekday:5,force:true},now);
assert.equal(changed.nextRunAt,'2026-10-30T08:30:00.000Z');assert.equal((await dashboard()).schedule.force,true);
const disabled=await saveSchedule({...changed,enabled:false},now);assert.equal(disabled.nextRunAt,null);assert.equal((await getSchedule()).time,'11:30');
});
test('a due slot is claimed once and persists a scheduled source and cache choice',async()=>{
await reset();await saveSchedule({...settings,force:true},now);
const due=Date.parse('2026-10-29T06:00:00Z');await Promise.all([claimScheduledRun(due),claimScheduledRun(due)]);
const run=await getRun();assert.equal(run?.source,'schedule');assert.equal(run?.force,true);
const id=run?.id;await claimScheduledRun(due);assert.equal((await getRun())?.id,id);
assert.equal((await getSchedule()).lastOutcome,'started');assert.equal((await getSchedule()).nextRunAt,'2026-10-30T06:00:00.000Z');
});
test('an existing manual run is preserved and the conflicting slot is skipped',async()=>{
await reset();await saveSchedule(settings,now);const manual=await start(false);
await claimScheduledRun(Date.parse('2026-10-29T06:00:00Z'));
assert.equal((await getRun())?.id,manual?.id);assert.equal((await getRun())?.source,'manual');assert.equal((await getSchedule()).lastOutcome,'skipped');
});
test('downtime catches up once rather than replaying every missed day',async()=>{
await reset();await saveSchedule(settings,now);const later=Date.parse('2026-11-05T08:00:00Z');
await claimScheduledRun(later);const id=(await getRun())?.id;await claimScheduledRun(later);
assert.equal((await getRun())?.id,id);assert.equal((await getSchedule()).nextRunAt,'2026-11-06T06:00:00.000Z');
});
test('failed schedule claim rolls back both the new run and the consumed slot',async()=>{
await reset();await saveSchedule(settings,now);const original=(await getSchedule());
const db=await database();await db.query("CREATE FUNCTION reject_schedule() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'schedule disk failure'; END $$");
await db.query("CREATE TRIGGER fail_schedule BEFORE UPDATE ON records FOR EACH ROW WHEN (NEW.key='schedule') EXECUTE FUNCTION reject_schedule()");
try{await assert.rejects(claimScheduledRun(Date.parse('2026-10-29T06:00:00Z')),/schedule disk failure/);assert.equal(await getRun(),null);assert.deepEqual((await getSchedule()),original);}
finally{await db.query('DROP TRIGGER fail_schedule ON records');await db.query('DROP FUNCTION reject_schedule()');}
});
test('schedule API rejects a foreign Origin and invalid input, then persists valid settings',async()=>{
await reset();process.env.COURSES_ORIGIN='https://miem.space';
const request=(origin:string,schedule:unknown)=>new Request('http://internal:3000/courses/api/audit',{method:'POST',headers:{Origin:origin,'Content-Type':'application/json'},body:JSON.stringify({action:'schedule',schedule})});
try{assert.equal((await POST(request('https://other.example',settings))).status,403);assert.equal((await POST(request('https://miem.space',{...settings,time:'99:99'}))).status,400);assert.equal((await POST(request('https://miem.space',settings))).status,200);assert.equal((await getSchedule()).enabled,true);}
finally{delete process.env.COURSES_ORIGIN;}
});
test('the standalone worker resumes a saved scheduled run without any browser or API client',async()=>{
await reset();const run=(await start(false,'schedule'))!;run.phase='links';run.links=['http://127.0.0.1/'];run.totalLinks=1;
await (await database()).query('UPDATE scan SET data=$1 WHERE id=1',[JSON.stringify(run)]);
const worker=spawn(process.execPath,['--experimental-strip-types',fileURLToPath(new URL('../lib/schedule-worker.ts',import.meta.url))],{env:{...process.env},stdio:'ignore'});
const exited=new Promise<void>(resolve=>worker.once('exit',()=>resolve()));
try{const deadline=Date.now()+8000;while((await getRun())?.status!=='complete'&&Date.now()<deadline)await sleep(50);
assert.equal((await getRun())?.status,'complete');assert.equal((await getRun())?.id,run.id);assert.equal((await dashboard()).checks['http://127.0.0.1/'].status,'network');
}finally{worker.kill('SIGTERM');await exited;}
});
+23
View File
@@ -0,0 +1,23 @@
import {before,after} from 'node:test';
import {randomUUID} from 'node:crypto';
import {Pool} from 'pg';
import {closeDatabase} from '../lib/store.ts';
export function useTestDatabase(label:string){
let url:string,schema:string;
before(async()=>{
if(!process.env.TEST_DATABASE_URL)throw new Error('Tests require a dedicated local TEST_DATABASE_URL');
const target=new URL(process.env.TEST_DATABASE_URL);
if(!['127.0.0.1','localhost'].includes(target.hostname)||target.pathname!=='/sverka_test')throw new Error('Tests must use the isolated local sverka_test database');
schema='sverka_test_'+label+'_'+randomUUID().replaceAll('-','');url=target.href;
const admin=new Pool({connectionString:url});
try{await admin.query(`CREATE SCHEMA "${schema}"`);}finally{await admin.end();}
target.searchParams.set('options','-c search_path='+schema);process.env.DATABASE_URL=target.href;
});
after(async()=>{
await closeDatabase();
if(!url||!schema)return;
const admin=new Pool({connectionString:url});
try{await admin.query(`DROP SCHEMA "${schema}" CASCADE`);}finally{await admin.end();}
});
}
+59
View File
@@ -0,0 +1,59 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"types": [
"node"
],
"allowImportingTsExtensions": true
},
"include": [
"next-env.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"lib/model.ts",
"lib/checker.ts",
"lib/scanner.ts",
"lib/store.ts",
"tests/**/*.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"lib/scheduler.ts",
"lib/schedule-worker.ts"
],
"exclude": [
"node_modules",
"examples",
"dist",
".vinext",
".wrangler",
".sites-runtime",
"outputs",
"work"
]
}