fix: allow redirect pauses and honor long server cooldowns
This commit is contained in:
1 parent
8534fd2775
commit
3cceb1a453
4 files changed
+7
-5
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user