Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
65 lines
3.9 KiB
TypeScript
65 lines
3.9 KiB
TypeScript
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';
|
|
|
|
// Сторож парсера: если парсер выключен или давно не запускался, суперадминам приходит сообщение
|
|
// в Telegram; пока проблема не ушла — напоминание раз в несколько часов, после — «снова работает».
|
|
// 20.10 парсер простоял почти двое суток выключенным, и это заметили только по пропавшему объекту.
|
|
|
|
/** Сколько минут без нового прохода считается «стоит». Глубокий проход идёт ~10 минут. */
|
|
export const STALL_MINUTES = 20;
|
|
const REMIND_HOURS = 6;
|
|
|
|
export interface WatchdogState {
|
|
/** Когда отправлено последнее сообщение о простое; null — парсер в порядке. */
|
|
alertedAt: Date | null;
|
|
}
|
|
|
|
const ago = (from: Date, now: Date) => {
|
|
const min = Math.round((now.getTime() - from.getTime()) / 60_000);
|
|
return min < 120 ? `${min} мин` : `${Math.round(min / 60)} ч`;
|
|
};
|
|
|
|
/** Что не так с парсером, или null, если всё в порядке. */
|
|
export async function parserProblem(db: Db, now = new Date()): Promise<string | 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'));
|
|
const since = row?.at ?? now;
|
|
// Только что выключили руками — даём время включить обратно.
|
|
return now.getTime() - since.getTime() >= stall ? `выключен в настройках (уже ${ago(since, now)})` : null;
|
|
}
|
|
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(parser, now)?.getTime() ?? 0));
|
|
return now.getTime() - since.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
|
|
}
|
|
|
|
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
|
|
export async function runParserWatchdog(db: Db, state: WatchdogState, now = new Date()): Promise<void> {
|
|
const problem = await parserProblem(db, now);
|
|
const remind = state.alertedAt && now.getTime() - state.alertedAt.getTime() >= REMIND_HOURS * 3_600_000;
|
|
if (problem && (!state.alertedAt || remind)) {
|
|
await notify(db, `⚠️ Парсер стоит: ${problem}. Новые объекты не приходят.\nВключить — Админка → Парсер.`);
|
|
state.alertedAt = now;
|
|
} else if (!problem && state.alertedAt) {
|
|
await notify(db, '✅ Парсер снова работает.');
|
|
state.alertedAt = null;
|
|
}
|
|
}
|
|
|
|
async function notify(db: Db, text: string) {
|
|
const admins = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt)));
|
|
await enqueueRaw(db, admins.map((a) => a.id), text);
|
|
}
|