fix: allow redirect pauses and honor long server cooldowns

This commit is contained in:
MIEM DEV committed 2026-10-29 18:23:43 +03:00
1 parent 8534fd2775
commit 3cceb1a453
4 files changed
+7 -5

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ pnpm dev
## Частота запросов
Материалы проверяются последовательно. Между запросами к одному хосту выдерживается 3–4 секунды, включая каталоги, курсы и перенаправления. Ограничение общее для API и worker и сохраняется в PostgreSQL. После HTTP429/503 действует пауза из `Retry-After` (не менее минуты, не более суток), после HTTP403 — пять минут. Повторных попыток в цикле нет. Ссылки, пропущенные во время паузы, отмечаются как ограниченные с датой окончания паузы; это не подтверждает, что материал удалён. Их можно обновить последующей полной проверкой. Паузы снижают нагрузку, но не гарантируют отсутствие блокировки со стороны сайта.
Материалы проверяются последовательно. Между запросами к одному хосту выдерживается 3–4 секунды, включая каталоги, курсы и перенаправления. Ограничение общее для API и worker и сохраняется в PostgreSQL. После HTTP429/503 действует пауза из `Retry-After` (не менее минуты), после HTTP403 — пять минут. Повторных попыток в цикле нет. Ссылки, пропущенные во время паузы, отмечаются как ограниченные с датой окончания паузы; это не подтверждает, что материал удалён. Их можно обновить последующей полной проверкой. Общий таймаут загрузки с перенаправлениями — 30 секунд, включая паузы. Паузы снижают нагрузку, но не гарантируют отсутствие блокировки со стороны сайта.
## Расписание
+3 -2
View File
@@ -45,7 +45,8 @@ export async function resolvePublicHost(host:string){
export async function retrieve(raw:string,source=false){
let url=safeUrl(raw);if(!url)throw new Error('Адрес не разрешён политикой безопасности');
await waitForHost(new URL(url).hostname);
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),12000);
// Four redirects can add up to 16 seconds of intentional host pacing.
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
const aborted=new Promise<never>((_,reject)=>controller.signal.addEventListener('abort',()=>reject(new DOMException('Истекло время ожидания','AbortError')),{once:true}));
try{
for(let hop=0;hop<5;hop++){
@@ -102,4 +103,4 @@ if(document.querySelector('input[type="password"]')&&text.length<10000)return {.
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 instanceof HostCooldown?'blocked':error.name==='AbortError'?'timeout':'network',reason:error.name==='AbortError'?'Сервер не ответил за 12 секунд.':`Не удалось загрузить: ${error.message.slice(0,180)}`,checkedAt:new Date().toISOString()};}}
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 instanceof HostCooldown?'blocked':error.name==='AbortError'?'timeout':'network',reason:error.name==='AbortError'?'Превышен общий лимит ожидания: 30 секунд, включая паузы между перенаправлениями.':`Не удалось загрузить: ${error.message.slice(0,180)}`,checkedAt:new Date().toISOString()};}}
+1 -1
View File
@@ -30,7 +30,7 @@ export async function waitForHost(host:string,signal?:AbortSignal){
export function retryDelay(value:string|undefined,now:number){
const milliseconds=value&&/^\d+$/.test(value.trim())?Number(value)*1000:value?Date.parse(value)-now:0;
// Ignore malformed values; keep a minimum pause even if the server says zero.
return Math.min(86400000,Math.max(60000,Number.isFinite(milliseconds)?milliseconds:0));
return Math.min(8640000000000000-now,Math.max(60000,Number.isNaN(milliseconds)?0:milliseconds));
}
export async function limitHost(host:string,code:number,retryAfter?:string){
if(![403,429,503].includes(code))return;
+2 -1
View File
@@ -15,7 +15,8 @@ test('Retry-After handles seconds, dates, invalid and excessive values',()=>{
const now=Date.parse('2026-10-29T00:00:00Z');
assert.equal(retryDelay('120',now),120000);assert.equal(retryDelay('Thu, 29 Oct 2026 00:02:00 GMT',now),120000);
for(const value of [undefined,'garbage','0','-1','Wed, 28 Oct 2026 00:00:00 GMT'])assert.equal(retryDelay(value,now),60000);
assert.equal(retryDelay('99999999999999999999999999',now),86400000);
assert.equal(retryDelay('172800',now),172800000);
for(const value of ['99999999999999999999999999','9'.repeat(400)])assert.equal(new Date(now+retryDelay(value,now)).toISOString(),'+275760-09-13T00:00:00.000Z');
});
test('cooldown survives reconnect, cannot be shortened and prevents a network attempt',async()=>{
await limitHost('limited.org',429,'600');await limitHost('limited.org',503,'1');await closeDatabase();