Парсер: часы ночной паузы в настройках админки

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-26 00:33:07 +04:00
1 parent d32f37457b
commit 5470036a69
9 files changed
+95 -30

No files matched your search

+4
View File
@@ -365,6 +365,10 @@ describe('суперадмин: общая база объявлений', () =>
expect(saved).toMatchObject({ pauseSec: 30, freshDays: 3, jinaKey: { source: 'settings', tail: '1234' } });
expect(JSON.stringify(saved)).not.toContain('jina_secret');
expect((await admin.patch('/api/admin/parser/settings', { freshDays: 0 })).status).toBe(400);
expect(before).toMatchObject({ quietHours: true, quietFrom: 22, quietTo: 8 });
expect((await admin.patch('/api/admin/parser/settings', { quietFrom: 24 })).status).toBe(400);
expect((await admin.patch('/api/admin/parser/settings', { quietFrom: 1, quietTo: 6 })).body).toMatchObject({ quietFrom: 1, quietTo: 6 });
await admin.patch('/api/admin/parser/settings', { quietFrom: 22, quietTo: 8 });
// Выключенная категория не читается; срок свежести действует на блок «Топ» — его порядок ротируется.
await admin.patch('/api/admin/parser/settings', { categories: [56], freshDays: 1 });
+3 -3
View File
@@ -25,9 +25,9 @@ const ago = (from: Date, now: Date) => {
/** Что не так с парсером, или null, если всё в порядке. */
export async function parserProblem(db: Db, now = new Date()): Promise<string | null> {
// Ночью парсер спит по расписанию — это не простой.
if (isQuietHours(now)) return null;
const parser = await loadParserSettings(db);
// В ночную паузу парсер спит по расписанию — это не простой.
if (isQuietHours(parser, now)) return null;
const stall = STALL_MINUTES * 60_000;
if (!parser.enabled) {
const [row] = await db.select({ at: settings.updatedAt }).from(settings).where(eq(settings.key, 'parser'));
@@ -38,7 +38,7 @@ export async function parserProblem(db: Db, now = new Date()): Promise<string |
const [last] = await db.select({ at: parserRuns.startedAt }).from(parserRuns).orderBy(desc(parserRuns.id)).limit(1);
if (!last) return null;
// Простой считается с конца ночной паузы: последний проход был вечером, а утром парсер только проснулся.
const since = new Date(Math.max(last.at.getTime(), lastQuietEnd(now).getTime()));
const since = new Date(Math.max(last.at.getTime(), lastQuietEnd(parser, now)?.getTime() ?? 0));
return now.getTime() - since.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
}
+10 -6
View File
@@ -84,18 +84,22 @@ loop('billing', 600_000, () => runBillingJobs(db));
if (source) {
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
// прохода, а темп запросов к ридеру выдерживает сам источник.
// Включение и пауза между проходами берутся из настроек парсера в админке.
// С 22:00 до 8:00 по Еревану парсер спит, чтобы не тратить токены ридера.
// Включение, пауза между проходами и ночная пауза берутся из настроек парсера в админке.
// В ночную паузу (по умолчанию 22:00–8:00 по Еревану) парсер спит, чтобы не тратить токены ридера.
loop(
'parser',
async () => (isQuietHours() ? 60_000 : Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000),
async () => {
if (!isQuietHours() && (await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log });
const s = await loadParserSettings(db);
return isQuietHours(s) ? 60_000 : Math.max(1, s.pauseSec) * 1000;
},
async () => {
const s = await loadParserSettings(db);
if (s.enabled && !isQuietHours(s)) await runIngest({ db, source, log: console.log });
},
);
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки. Ночью тоже спит.
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки. В ночную паузу спит.
loop('removal-check', 600_000, async () => {
if (!isQuietHours()) await runRemovalCheck({ db, source });
if (!isQuietHours(await loadParserSettings(db))) await runRemovalCheck({ db, source });
});
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
const watchdog: WatchdogState = { alertedAt: null };
+22 -9
View File
@@ -1,16 +1,29 @@
import { describe, expect, it } from 'vitest';
import { isQuietHours, lastQuietEnd } from './quiet-hours.ts';
const night = { quietHours: true, quietFrom: 22, quietTo: 8 };
describe('ночная пауза парсера', () => {
it('с 22:00 до 8:00 по Еревану (UTC+4)', () => {
expect(isQuietHours(new Date('2026-10-26T17:59:00Z'))).toBe(false); // 21:59
expect(isQuietHours(new Date('2026-10-26T18:00:00Z'))).toBe(true); // 22:00
expect(isQuietHours(new Date('2026-10-26T23:00:00Z'))).toBe(true); // 3:00
expect(isQuietHours(new Date('2026-10-27T03:59:00Z'))).toBe(true); // 7:59
expect(isQuietHours(new Date('2026-10-27T04:00:00Z'))).toBe(false); // 8:00
it('22:00–8:00 по Еревану (UTC+4), через полночь', () => {
expect(isQuietHours(night, new Date('2026-10-26T17:59:00Z'))).toBe(false); // 21:59
expect(isQuietHours(night, new Date('2026-10-26T18:00:00Z'))).toBe(true); // 22:00
expect(isQuietHours(night, new Date('2026-10-26T23:00:00Z'))).toBe(true); // 3:00
expect(isQuietHours(night, new Date('2026-10-27T03:59:00Z'))).toBe(true); // 7:59
expect(isQuietHours(night, new Date('2026-10-27T04:00:00Z'))).toBe(false); // 8:00
});
it('последнее пробуждение — ближайшие прошедшие 8:00', () => {
expect(lastQuietEnd(new Date('2026-10-27T05:00:00Z')).toISOString()).toBe('2026-10-27T04:00:00.000Z');
expect(lastQuietEnd(new Date('2026-10-27T02:00:00Z')).toISOString()).toBe('2026-10-26T04:00:00.000Z');
it('интервал без перехода через полночь, выключенная и пустая пауза', () => {
const early = { quietHours: true, quietFrom: 1, quietTo: 6 };
expect(isQuietHours(early, new Date('2026-10-26T20:30:00Z'))).toBe(false); // 0:30
expect(isQuietHours(early, new Date('2026-10-26T21:30:00Z'))).toBe(true); // 1:30
expect(isQuietHours(early, new Date('2026-10-27T02:00:00Z'))).toBe(false); // 6:00
expect(isQuietHours({ ...night, quietHours: false }, new Date('2026-10-26T23:00:00Z'))).toBe(false);
expect(isQuietHours({ ...night, quietTo: 22 }, new Date('2026-10-26T23:00:00Z'))).toBe(false);
expect(lastQuietEnd({ ...night, quietHours: false })).toBeNull();
});
it('последнее пробуждение — ближайший прошедший час окончания', () => {
expect(lastQuietEnd(night, new Date('2026-10-27T05:00:00Z'))?.toISOString()).toBe('2026-10-27T04:00:00.000Z');
expect(lastQuietEnd(night, new Date('2026-10-27T02:00:00Z'))?.toISOString()).toBe('2026-10-26T04:00:00.000Z');
// Конец в 2:00 по Еревану = 22:00 UTC предыдущих суток.
expect(lastQuietEnd({ quietHours: true, quietFrom: 23, quietTo: 2 }, new Date('2026-10-27T05:00:00Z'))?.toISOString()).toBe('2026-10-26T22:00:00.000Z');
});
});
+19 -10
View File
@@ -1,22 +1,31 @@
// Ночная пауза: с 22:00 до 8:00 по Еревану парсер и проверка «снято с List.am» не ходят в ридер —
// ночью новых объявлений мало, а токены Jina тратятся. В Армении нет перехода на летнее время: UTC+4.
// Ночная пауза: в эти часы по Еревану парсер и проверка «снято с List.am» не ходят в ридер —
// ночью новых объявлений мало, а токены Jina тратятся. Часы задаются в настройках парсера
// (по умолчанию 22:00–8:00). В Армении нет перехода на летнее время: всегда UTC+4.
const OFFSET_HOURS = 4;
export const QUIET_FROM = 22;
export const QUIET_TO = 8;
export interface QuietHours {
quietHours: boolean;
/** Час начала паузы по Еревану, 0–23. */
quietFrom: number;
/** Час окончания паузы по Еревану, 0–23. Равен началу — паузы нет. */
quietTo: number;
}
const yerevanHour = (now: Date) => (now.getUTCHours() + OFFSET_HOURS) % 24;
/** Сейчас ночная пауза. */
export function isQuietHours(now = new Date()): boolean {
/** Сейчас пауза. Интервал может переходить через полночь (22 → 8) или нет (1 → 6). */
export function isQuietHours(q: QuietHours, now = new Date()): boolean {
if (!q.quietHours || q.quietFrom === q.quietTo) return false;
const h = yerevanHour(now);
return h >= QUIET_FROM || h < QUIET_TO;
return q.quietFrom < q.quietTo ? h >= q.quietFrom && h < q.quietTo : h >= q.quietFrom || h < q.quietTo;
}
/** Когда закончилась последняя ночная пауза (последние 8:00 по Еревану, не позже now). */
export function lastQuietEnd(now = new Date()): Date {
/** Когда закончилась последняя пауза (последний час quietTo по Еревану, не позже now); null — паузы нет. */
export function lastQuietEnd(q: QuietHours, now = new Date()): Date | null {
if (!q.quietHours || q.quietFrom === q.quietTo) return null;
const end = new Date(now);
end.setUTCHours(QUIET_TO - OFFSET_HOURS, 0, 0, 0);
end.setUTCHours((q.quietTo - OFFSET_HOURS + 24) % 24, 0, 0, 0);
if (end > now) end.setUTCDate(end.getUTCDate() - 1);
return end;
}
+6
View File
@@ -15,6 +15,12 @@ export const ParserSettings = z.object({
freshDays: z.number().int().min(1).max(30).default(3),
/** Пауза между проходами, секунд. */
pauseSec: z.number().int().min(0).max(3600).default(5),
/** Ночная пауза: в эти часы по Еревану парсер и проверка снятых не работают (экономия токенов ридера). */
quietHours: z.boolean().default(true),
/** Начало паузы, час по Еревану. */
quietFrom: z.number().int().min(0).max(23).default(22),
/** Конец паузы, час по Еревану. Равен началу — паузы нет. */
quietTo: z.number().int().min(0).max(23).default(8),
/** Сколько страниц категории читать за проход, пока на них есть незнакомые объявления. */
maxPages: z.number().int().min(1).max(10).default(3),
/**