Files

70 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}