diff --git a/README.md b/README.md index 81d9e3e..be592c7 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ pnpm dev:web # http://localhost:5174 отдельно по объектам и по клиентам. - **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой «кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число - страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. + страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. **Ночная пауза**: с 22:00 до 8:00 по Еревану + парсер и проверка снятых не работают (экономия токенов Jina), сторож парсера ночью молчит. - **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md). diff --git a/apps/api/src/ingest/watchdog.ts b/apps/api/src/ingest/watchdog.ts index adcf637..0f1be2e 100644 --- a/apps/api/src/ingest/watchdog.ts +++ b/apps/api/src/ingest/watchdog.ts @@ -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 { + // Ночью парсер спит по расписанию — это не простой. + 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= 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; } /** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */ diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index fce16b2..73cd7fd 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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)); diff --git a/apps/api/src/parser/quiet-hours.test.ts b/apps/api/src/parser/quiet-hours.test.ts new file mode 100644 index 0000000..99c74e0 --- /dev/null +++ b/apps/api/src/parser/quiet-hours.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/parser/quiet-hours.ts b/apps/api/src/parser/quiet-hours.ts new file mode 100644 index 0000000..6630915 --- /dev/null +++ b/apps/api/src/parser/quiet-hours.ts @@ -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; +} diff --git a/apps/api/src/watchdog.int.test.ts b/apps/api/src/watchdog.int.test.ts index 83ad2cd..c9bbb5f 100644 --- a/apps/api/src/watchdog.int.test.ts +++ b/apps/api/src/watchdog.int.test.ts @@ -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('Парсер стоит'); + }); });