fix: preserve anonymous sessions and ignore hidden access templates

This commit is contained in:
MIEM DEV committed 2026-10-30 18:26:10 +03:00
1 parent 3abe7e26ec
commit 71bf5fd926
4 files changed
+19 -9

No files matched your search

+6 -2
View File
@@ -11,6 +11,10 @@ export function result(url:string,status:Check['status'],reason:string,extra:Par
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+(?:только\s+|первые\s+)?(\d{1,4})\s+страниц/i.exec(normalized);
@@ -26,7 +30,7 @@ export function accessMessage(url:string,text:string):Check|null{
}
export function purchaseOffer(url:string,html:string):Check|null{
const {document}=parseHTML(html),host=new URL(url).hostname;
document.querySelectorAll('script,style,[hidden],[aria-hidden="true"],.hidden,[style*="display: none"],[style*="display:none"]').forEach(n=>n.remove());
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')){
@@ -47,7 +51,7 @@ export function purchaseOffer(url:string,html:string):Check|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:'');
document.querySelectorAll('script,style,nav,footer,header,[hidden],[aria-hidden="true"],.hidden').forEach(n=>n.remove());
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};
+6 -2
View File
@@ -29,7 +29,7 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
if(controller.signal.aborted||limit||!browserRequestAllowed(target,root,request.method(),request.resourceType())){await route.abort();return;}
if(++requests>100||totalBytes>32000000||active.size>=10)throw new Error('Превышен лимит ресурсов страницы');
if(navigation){if(++navigations>8)throw new Error('Слишком много переходов');await waitForHost(new URL(target).hostname,controller.signal);}
const headers:Record<string,string>={};for(const key of ['accept','accept-language','origin','referer','cookie','content-type']){const value=request.headers()[key];if(value)headers[key]=value;}
const allHeaders=await request.allHeaders(),headers:Record<string,string>={};for(const key of ['accept','accept-language','origin','referer','cookie','content-type']){const value=allHeaders[key];if(value)headers[key]=value;}
const response=await requestPublic(target,{signal:controller.signal,maxBytes:8000000,method:request.method() as 'GET'|'HEAD'|'OPTIONS',headers});
totalBytes+=response.body.length;if(totalBytes>32000000)throw new Error('Превышен лимит ресурсов страницы');
if([429,503].includes(response.code)){
@@ -54,7 +54,11 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
const capture=async()=>{
await page.locator('body').waitFor({state:'attached'});
// Only rendered text is evidence; hidden login templates and site chrome cannot decide book access.
const text=await page.locator('body').innerText();
const text=await page.locator('body').evaluate(body=>{
const walker=document.createTreeWalker(body,NodeFilter.SHOW_TEXT),parts:string[]=[];let node:Node|null,size=0;
while((node=walker.nextNode())&&size<100000){const el=node.parentElement;if(!el||el.closest('header,footer,nav,script,style,[role="navigation"],[role="banner"],[role="contentinfo"]'))continue;const style=getComputedStyle(el);if(style.visibility==='hidden'||style.display==='none'||!el.getClientRects().length)continue;const text=node.textContent??'';parts.push(text);size+=text.length;}
return parts.join(' ');
});
const offers=await page.locator('.book-offers__item,.app-article-access').evaluateAll(nodes=>nodes.filter(node=>{const style=getComputedStyle(node);return style.display!=='none'&&style.visibility!=='hidden'&&node.getClientRects().length>0;}).map(node=>node.outerHTML).join(''));
return {text,offers,url:page.url(),title:await page.title()};
};
+1 -1
View File
@@ -34,7 +34,7 @@ test('Springer requires an eBook purchase offer, not the paper edition price',()
assert.equal(purchaseOffer(url,block.replace('Access this book','This is an open access book')),null);
});
test('hidden templates, general login, annotations and successful HTTP do not confirm access',()=>{
for(const body of ['<h1>Book</h1>Аннотация','<header><input type="password"/>Для чтения авторизуйтесь</header><p>Book card</p>','<div hidden>Для чтения необходимо авторизоваться</div>','<div class="hidden">Для чтения необходимо авторизоваться</div>'])assert.equal(classify(body).status,'unknown');
for(const body of ['<h1>Book</h1>Аннотация','<header><input type="password"/>Для чтения авторизуйтесь</header><p>Book card</p>','<div hidden>Для чтения необходимо авторизоваться</div>','<div class="hidden">Для чтения необходимо авторизоваться</div>','<div style="display:none">Для чтения необходимо авторизоваться</div>','<div style="visibility: hidden !important">Для чтения необходимо авторизоваться</div>'])assert.equal(classify(body).status,'unknown');
});
test('old rule versions and expired checks are invalidated independently of course content',()=>{
const now=Date.now(),fresh=result(lan,'auth','Login');assert.equal(checkCurrent(fresh,now),true);
+6 -4
View File
@@ -5,12 +5,13 @@ import {createSocket} from 'node:dgram';
import {useTestDatabase} from './test-database.ts';
useTestDatabase('browser');
const original=await import('../lib/http.ts');
let fixture='',proxied:string[]=[];
mock.module(new URL('../lib/http.ts',import.meta.url).href,{namedExports:{...original,requestPublic:async(url:string)=>{
let fixture='',proxied:string[]=[],cookieSeen=false;
mock.module(new URL('../lib/http.ts',import.meta.url).href,{namedExports:{...original,requestPublic:async(url:string,options:{headers?:Record<string,string>})=>{
proxied.push(url);
if(url.endsWith('/session')){cookieSeen=options.headers?.cookie?.includes('anonymous_session=fixture')??false;return {code:200,headers:{'content-type':'text/plain'},body:Buffer.from('ok'),type:'text/plain'};}
if(url.endsWith('/redirect'))return {code:302,headers:{location:target},body:Buffer.alloc(0),type:'text/html'};
if(!url.startsWith('https://e.lanbook.com/'))throw new Error('Unexpected transport destination');
return {code:200,headers:{'content-type':'text/html; charset=utf-8'},body:Buffer.from(fixture),type:'text/html'};
return {code:200,headers:{'content-type':'text/html; charset=utf-8','set-cookie':['anonymous_session=fixture; Path=/; Secure; SameSite=Lax']},body:Buffer.from(fixture),type:'text/html'};
}}});
const {checkBrowser}=await import('../lib/browser.ts');
let target='';
@@ -22,6 +23,7 @@ test('sandboxed Chromium blocks private HTTP, redirects, frames, popups, workers
const udpAddress=udp.address();
fixture=`<!doctype html><html><body><h1>Book</h1><div id="message">Loading</div><script>
fetch(${JSON.stringify(target)}).catch(()=>{});
fetch('https://e.lanbook.com/session').catch(()=>{});
fetch('https://e.lanbook.com/redirect').catch(()=>{});
const iframe=document.createElement('iframe');iframe.src=${JSON.stringify(target)};document.body.appendChild(iframe);
window.open(${JSON.stringify(target)});
@@ -32,7 +34,7 @@ test('sandboxed Chromium blocks private HTTP, redirects, frames, popups, workers
</script></body></html>`;
try{
const check=await checkBrowser('https://e.lanbook.com/book/111431');
assert.equal(check.status,'auth',check.reason);assert.equal(httpHits,0);assert.equal(udpHits,0);
assert.equal(check.status,'auth',check.reason);assert.equal(httpHits,0);assert.equal(udpHits,0);assert.equal(cookieSeen,true,'The fresh anonymous cookie must survive the pinned transport');
assert.ok(proxied.every(url=>url.startsWith('https://e.lanbook.com/')));assert.ok(proxied.includes('https://e.lanbook.com/redirect'));
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()));udp.close();}
});