Парсер: ночная пауза с 22:00 до 8:00 по Еревану

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:18:53 +04:00
1 parent 9749da5ae7
commit d32f37457b
6 files changed
+70 -7

No files matched your search

+6 -1
View File
@@ -1,6 +1,7 @@
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { parserRuns, settings, users } from '../db/schema.ts';
import { isQuietHours, lastQuietEnd } from '../parser/quiet-hours.ts';
import { loadParserSettings } from '../parser/settings.ts';
import { enqueueRaw } from '../telegram/outbox.ts';
@@ -24,6 +25,8 @@ 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);
const stall = STALL_MINUTES * 60_000;
if (!parser.enabled) {
@@ -34,7 +37,9 @@ 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;
return now.getTime() - last.at.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
// Простой считается с конца ночной паузы: последний проход был вечером, а утром парсер только проснулся.
const since = new Date(Math.max(last.at.getTime(), lastQuietEnd(now).getTime()));
return now.getTime() - since.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
}
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
+8 -4
View File
@@ -11,6 +11,7 @@ import { runLeadJobs } from './leads/service.ts';
import { loadConfig } from './lib/config.ts';
import { runGroupPosts } from './group/service.ts';
import { ListAmListingSource } from './parser/listam-source.ts';
import { isQuietHours } from './parser/quiet-hours.ts';
import { loadParserSettings } from './parser/settings.ts';
import { startPolling } from './telegram/bot.ts';
import { HttpTelegram } from './telegram/client.ts';
@@ -84,15 +85,18 @@ if (source) {
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
// прохода, а темп запросов к ридеру выдерживает сам источник.
// Включение и пауза между проходами берутся из настроек парсера в админке.
// С 22:00 до 8:00 по Еревану парсер спит, чтобы не тратить токены ридера.
loop(
'parser',
async () => Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000,
async () => (isQuietHours() ? 60_000 : Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000),
async () => {
if ((await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log });
if (!isQuietHours() && (await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log });
},
);
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки.
loop('removal-check', 600_000, () => runRemovalCheck({ db, source }));
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки. Ночью тоже спит.
loop('removal-check', 600_000, async () => {
if (!isQuietHours()) await runRemovalCheck({ db, source });
});
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
const watchdog: WatchdogState = { alertedAt: null };
loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog));
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { isQuietHours, lastQuietEnd } from './quiet-hours.ts';
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('последнее пробуждение — ближайшие прошедшие 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');
});
});
+22
View File
@@ -0,0 +1,22 @@
// Ночная пауза: с 22:00 до 8:00 по Еревану парсер и проверка «снято с List.am» не ходят в ридер —
// ночью новых объявлений мало, а токены Jina тратятся. В Армении нет перехода на летнее время: UTC+4.
const OFFSET_HOURS = 4;
export const QUIET_FROM = 22;
export const QUIET_TO = 8;
const yerevanHour = (now: Date) => (now.getUTCHours() + OFFSET_HOURS) % 24;
/** Сейчас ночная пауза. */
export function isQuietHours(now = new Date()): boolean {
const h = yerevanHour(now);
return h >= QUIET_FROM || h < QUIET_TO;
}
/** Когда закончилась последняя ночная пауза (последние 8:00 по Еревану, не позже now). */
export function lastQuietEnd(now = new Date()): Date {
const end = new Date(now);
end.setUTCHours(QUIET_TO - OFFSET_HOURS, 0, 0, 0);
if (end > now) end.setUTCDate(end.getUTCDate() - 1);
return end;
}
+16 -1
View File
@@ -17,7 +17,8 @@ describe('сторож парсера', () => {
it('пишет суперадмину о простое, напоминает раз в 6 часов и сообщает о восстановлении', async () => {
const [admin] = await t.db.insert(users).values({ name: 'Юрий', role: 'superadmin', telegramId: 777 }).returning();
const state: WatchdogState = { alertedAt: null };
const now = new Date();
// 10:00 по Еревану — днём, вне ночной паузы.
const now = new Date('2026-10-26T06:00:00Z');
// Проход был 5 минут назад — всё в порядке.
await t.db.insert(parserRuns).values({ startedAt: new Date(now.getTime() - 5 * MIN), status: 'ok' });
@@ -48,4 +49,18 @@ describe('сторож парсера', () => {
await runParserWatchdog(t.db, state, new Date(back.getTime() + 30 * MIN));
expect((await sent(admin!.id)).at(-1)).toContain('выключен в настройках (уже 30 мин)');
});
it('ночью (22:00–8:00 по Еревану) молчит, утром отсчитывает простой с 8:00', async () => {
const [admin] = await t.db.insert(users).values({ name: 'Ночь', role: 'superadmin', telegramId: 778 }).returning();
await saveParserSettings(t.db, { enabled: true });
const state: WatchdogState = { alertedAt: null };
// Последний проход — в 21:55 по Еревану.
await t.db.insert(parserRuns).values({ startedAt: new Date('2026-10-27T17:55:00Z'), status: 'ok' });
await runParserWatchdog(t.db, state, new Date('2026-10-27T22:00:00Z')); // 2:00 ночи
await runParserWatchdog(t.db, state, new Date('2026-10-28T04:10:00Z')); // 8:10 утра
expect(await sent(admin!.id)).toEqual([]);
// 8:25 — после пробуждения прошло больше 20 минут без прохода.
await runParserWatchdog(t.db, state, new Date('2026-10-28T04:25:00Z'));
expect((await sent(admin!.id)).at(-1)).toContain('Парсер стоит');
});
});