Парсер: часы ночной паузы в настройках админки
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d32f37457b
commit
5470036a69
9 files changed
+95
-30
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user