Files

51 lines
6.5 KiB
TypeScript

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';
import {checkCurrent} from './access.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:')){const check:Check=JSON.parse(row.value);checks[row.key.slice(5)]=checkCurrent(check)?check:{...check,status:'unknown',reason:'Результат устарел или получен по прежним правилам. Запустите проверку доступа к полному тексту.',evidence:undefined};}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='Раздел литературы не найден. Проверьте программу курса вручную.';
}
const keys=course.refs.map(ref=>'link:'+ref.url),{rows}=await db.query<{key:string;value:string}>('SELECT key,value FROM records WHERE key=ANY($1::text[])',[keys]);
const checks=new Map(rows.map(row=>[row.key,JSON.parse(row.value) as Check]));
for(const ref of course.refs)if((run.force||!checkCurrent(checks.get('link:'+ref.url)))&&!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 url=run.links.shift();run.message='Проверяем доступность материалов';if(url){const result=await checkLink(url);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;}