70 lines
9.2 KiB
TypeScript
70 lines
9.2 KiB
TypeScript
import {parseHTML} from 'linkedom';
|
||
import type {Check} from './model.ts';
|
||
|
||
export const RULES_VERSION='access-1';
|
||
export const CHECK_TTL=24*60*60*1000;
|
||
export function checkCurrent(check:Check|undefined,now=Date.now()){
|
||
return !!check&&check.rulesVersion===RULES_VERSION&&check.profile==='anonymous'&&now-Date.parse(check.checkedAt)<(['unknown','blocked','network','timeout'].includes(check.status)?600000:CHECK_TTL);
|
||
}
|
||
export function evidenceUrl(raw:string){const u=new URL(raw);for(const key of [...u.searchParams.keys()])if(!['pid','id','bcode'].includes(key))u.searchParams.delete(key);u.hash='';return u.href;}
|
||
export function result(url:string,status:Check['status'],reason:string,extra:Partial<Check>={}):Check{
|
||
return {url,status,reason,checkedAt:new Date().toISOString(),rulesVersion:RULES_VERSION,profile:'anonymous',...extra};
|
||
}
|
||
function evidence(url:string,text:string,rule:string){const normalized=text.replace(/\s+/g,' ').trim(),at=normalized.search(/публикация\s+изъята|для\s+(?:получения\s+)?(?:доступа|чтения|просмотра)|чтобы\s+получить\s+полный\s+доступ|sign in to|чтение\s+книги\s+недоступно|книга\s+недоступна/i);return {text:normalized.slice(Math.max(0,at),Math.max(0,at)+500),url:evidenceUrl(url),rule};}
|
||
function removeHidden(document:ReturnType<typeof parseHTML>['document']){
|
||
document.querySelectorAll('script,style,[hidden],[aria-hidden="true"],.hidden').forEach(n=>n.remove());
|
||
document.querySelectorAll('[style]').forEach(n=>{if(/(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important)?\s*(?:;|$)/i.test(n.getAttribute('style')??''))n.remove();});
|
||
}
|
||
export function accessMessage(url:string,text:string):Check|null{
|
||
const normalized=text.replace(/\s+/g,' ').trim();
|
||
const preview=/гостев[а-я]*\s+режим[а-я]*[^.]{0,180}?(\d{1,4})\s*страниц/i.exec(normalized)??/доступн[а-я]*\s+(?:только\s+|первые\s+)?(\d{1,4})\s+страниц/i.exec(normalized);
|
||
if(preview&&/гостев|ознакомительн|фрагмент/i.test(normalized))return result(url,'restricted',`Доступен только ознакомительный фрагмент — до ${preview[1]} страниц; полный текст недоступен.`,{evidence:evidence(url,normalized.slice(Math.max(0,preview.index-150),preview.index+200),'reader-preview-pages')});
|
||
if(/публикация\s+изъята\s+из\s+фонда/i.test(normalized))return result(url,'broken','Публикация изъята из фонда.',{evidence:evidence(url,normalized,'publication-withdrawn')});
|
||
if(/\/bookshelf\/\d+\/reading/.test(new URL(url).pathname)&&/для продолжения работы с сайтом.{0,60}войдите в систему/i.test(normalized))return result(url,'auth','Для чтения книги требуется авторизация.',{evidence:evidence(url,normalized,'ibooks-reader-login')});
|
||
const subscription=/(?:для\s+(?:полного\s+)?(?:доступа|чтения)|чтобы\s+(?:получить\s+)?полный\s+доступ|полный\s+текст)[^.]{0,250}(?:подписк|оплат|купить)|доступ\s+(?:к\s+полному\s+тексту\s+)?по\s+подписке|читать\s+по\s+подписке|sign in to (?:read|access)[^.]{0,100}subscri/i.test(normalized);
|
||
const login=/(?:для\s+(?:получения\s+)?(?:доступа\s+к\s+)?(?:чтения|просмотра|доступа)|чтобы\s+(?:получить\s+)?полный\s+доступ)[^.]{0,220}(?:авториз|войти|войдите|вход)|(?:авторизуйтесь|войдите)[^.]{0,100}(?:чтения|полного\s+доступа|чита)|sign in to (?:read|access|view)/i.test(normalized);
|
||
if(login||subscription)return result(url,subscription?'restricted':'auth',login&&subscription?'Для чтения книги требуются авторизация и подписка.':login?'Для чтения книги требуется авторизация.':'Для чтения книги требуется подписка или оплата.',{evidence:evidence(url,normalized,login&&subscription?'reader-login-subscription':login?'reader-login':'reader-subscription')});
|
||
if(/(?:доступен|доступно)\s+только\s+(?:ознакомительный\s+)?фрагмент|только\s+ознакомительный\s+фрагмент/i.test(normalized))return result(url,'restricted','Доступен только ознакомительный фрагмент; полный текст недоступен.',{evidence:evidence(url,normalized,'reader-preview')});
|
||
if(/книга\s+недоступна\s+для\s+чтения|чтение\s+книги\s+недоступно/i.test(normalized))return result(url,'restricted','Чтение книги недоступно. Сайт не указал подтверждённую причину.',{evidence:evidence(url,normalized,'reader-denied')});
|
||
return null;
|
||
}
|
||
export function purchaseOffer(url:string,html:string):Check|null{
|
||
const {document}=parseHTML(html),host=new URL(url).hostname;
|
||
removeHidden(document);
|
||
if(host==='znanium.ru'||host.endsWith('.znanium.ru')){
|
||
const id=new URL(url).searchParams.get('id');
|
||
for(const item of document.querySelectorAll('.book-offers__item')){
|
||
const text=item.textContent??'',control=item.querySelector('.book-offers__ctrl');
|
||
if(/доступ\s+онлайн/i.test(text)&&/[1-9]\d*(?:[.,]\d+)?\s*₽/.test(text)&&/в корзину/i.test(control?.textContent??'')&&(!id||control?.getAttribute('data-id')===id))return result(url,'restricted','Для чтения книги требуется покупка онлайн-доступа или подписка.',{evidence:evidence(url,text,'znanium-online-purchase')});
|
||
}
|
||
}
|
||
if(/(^|\.)(springer\.com|springernature\.com)$/.test(host)){
|
||
for(const access of document.querySelectorAll('.app-article-access')){
|
||
if(/this is an open access book|open access/i.test(access.textContent??''))continue;
|
||
for(const option of access.querySelectorAll('.buying-option')){
|
||
if(/ebook/i.test(option.textContent??'')&&/[1-9]/.test(option.querySelector('.price-amount')?.textContent??'')&&/buy\s+ebook/i.test(option.querySelector('button')?.textContent??''))return result(url,'restricted','Для чтения требуется покупка электронной книги или доступ по подписке организации.',{evidence:evidence(url,'Access this book. '+(option.textContent??'').replace(/\s+/g,' '),'springer-ebook-purchase')});
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
export function classifyResponse(url:string,r:{code:number;url:string;body:string;type:string}):Check{
|
||
const base={finalUrl:evidenceUrl(r.url),code:r.code};
|
||
const {document}=parseHTML(/html|text\//i.test(r.type)?r.body:'');
|
||
removeHidden(document);document.querySelectorAll('nav,footer,header').forEach(n=>n.remove());
|
||
const title=(document.querySelector('title')?.textContent??'').trim();
|
||
const text=(document.querySelector('main,[role="main"]')??document.querySelector('body')??document.documentElement)?.textContent??'';
|
||
const message=accessMessage(r.url,text);if(message?.status==='broken')return {...message,url,...base};
|
||
if(r.code===404||r.code===410)return result(url,'broken',r.code===404?'Страница материала не найдена.':'Материал удалён.',base);
|
||
if(r.code===401||/(?:\/|\.)(?:login|signin|sign-in|sso|auth)(?:[/.?]|$)/i.test(r.url))return result(url,'auth','Для чтения материала требуется авторизация.',base);
|
||
if(r.code===403&&message)return {...message,url,...base};
|
||
if(r.code===403||r.code===429||/captcha|verify you are human|checking your browser|just a moment|докажите, что вы|проверка браузера/i.test(title+' '+text.slice(0,2000)))return result(url,'blocked','Сайт ограничил автоматическую проверку. Доступ к полному тексту не определён.',base);
|
||
if(r.code>=400)return result(url,'network',`Сайт временно не предоставил материал (HTTP ${r.code}).`,base);
|
||
if(r.code<200||r.code>=300)return result(url,'unknown',`Ответ HTTP ${r.code} не подтвердил доступ к материалу.`,base);
|
||
if(message)return {...message,url,...base};
|
||
const purchase=purchaseOffer(r.url,r.body);if(purchase)return {...purchase,url,...base};
|
||
if(/^(404|страница не найдена|page not found|not found)/i.test(title))return result(url,'broken','Сайт показывает страницу «не найдено».',{...base,evidence:evidence(r.url,title,'page-not-found')});
|
||
if(/application\/pdf/i.test(r.type)&&r.body.startsWith('%PDF-'))return result(url,'unknown','PDF получен без авторизации, но не установлено, содержит ли он полный текст или только фрагмент.',base);
|
||
return result(url,'unknown','Страница открывается, но доступ к полному тексту не подтверждён.',base);
|
||
}
|