feat: add course literature audit with scheduled checks
This commit is contained in:
1 parent
484095c084
commit
55f9d43ea2
45 files changed
+2404
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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-заглушек, ограничений доступа и устаревших результатов.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Визуальная система
|
||||
Режим Operate. Академический реестр: тёплая бумага, глубокий зелёный, киноварь для проблем. Боковая навигация по программам, заголовок с засечками, плотный список результатов. Состояния подписаны словами. Без иллюстративных статистик и декоративных карточек. Адаптивность, клавиатура, заметный фокус, reduced motion.
|
||||
+24
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
# Сверка
|
||||
Общий реестр доступности литературы пяти программ НИУ ВШЭ. Без входа. Только реальные результаты; начальное состояние пустое. Node.js24 и PostgreSQL хранят результаты, контрольные суммы курсов и прогресс в постоянном volume. Проверка порциями, пока открыта вкладка; продолжение вручную. Неизменившиеся курсы используют кэш; доступна полная проверка. Успешный HTTP-ответ не доказывает доступ к полному тексту. Целевой путь — https://miem.space/courses.
|
||||
|
||||
Расписание: одно общее, ежедневно или еженедельно по Москве, выключено до сохранения пользователем. Запланированный аудит работает фоновым процессом при закрытом браузере и продолжается после перезапуска. Совпавший слот пропускается, если предыдущая ручная или автоматическая проверка ещё running. Изменение/отключение влияет на будущие запуски.
|
||||
|
||||
Для VPS используется отдельная база/роль на существующем PostgreSQL. Пароль только в окружении, не в исходниках.
|
||||
@@ -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 не используются загрузчиком.
|
||||
@@ -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});}}
|
||||
@@ -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});}}
|
||||
@@ -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>;}
|
||||
@@ -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%}}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import Audit from './dashboard';
|
||||
export default function Home(){return <Audit/>;}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()};}}
|
||||
@@ -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};
|
||||
@@ -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;}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
basePath: '/courses',
|
||||
serverExternalPackages: ['linkedom','pg'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+1291
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
workerd: true
|
||||
@@ -0,0 +1 @@
|
||||
export default {plugins:{}};
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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.
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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');});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user