Парсер: ночная пауза с 22:00 до 8:00 по Еревану
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9749da5ae7
commit
d32f37457b
6 files changed
+70
-7
No files matched your search
@@ -81,7 +81,8 @@ pnpm dev:web # http://localhost:5174
|
|||||||
отдельно по объектам и по клиентам.
|
отдельно по объектам и по клиентам.
|
||||||
- **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой
|
- **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой
|
||||||
«кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число
|
«кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число
|
||||||
страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса.
|
страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. **Ночная пауза**: с 22:00 до 8:00 по Еревану
|
||||||
|
парсер и проверка снятых не работают (экономия токенов Jina), сторож парсера ночью молчит.
|
||||||
- **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со
|
- **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со
|
||||||
следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты
|
следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты
|
||||||
суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md).
|
суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
|
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||||
import type { Db } from '../db/client.ts';
|
import type { Db } from '../db/client.ts';
|
||||||
import { parserRuns, settings, users } from '../db/schema.ts';
|
import { parserRuns, settings, users } from '../db/schema.ts';
|
||||||
|
import { isQuietHours, lastQuietEnd } from '../parser/quiet-hours.ts';
|
||||||
import { loadParserSettings } from '../parser/settings.ts';
|
import { loadParserSettings } from '../parser/settings.ts';
|
||||||
import { enqueueRaw } from '../telegram/outbox.ts';
|
import { enqueueRaw } from '../telegram/outbox.ts';
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ const ago = (from: Date, now: Date) => {
|
|||||||
|
|
||||||
/** Что не так с парсером, или null, если всё в порядке. */
|
/** Что не так с парсером, или null, если всё в порядке. */
|
||||||
export async function parserProblem(db: Db, now = new Date()): Promise<string | null> {
|
export async function parserProblem(db: Db, now = new Date()): Promise<string | null> {
|
||||||
|
// Ночью парсер спит по расписанию — это не простой.
|
||||||
|
if (isQuietHours(now)) return null;
|
||||||
const parser = await loadParserSettings(db);
|
const parser = await loadParserSettings(db);
|
||||||
const stall = STALL_MINUTES * 60_000;
|
const stall = STALL_MINUTES * 60_000;
|
||||||
if (!parser.enabled) {
|
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);
|
const [last] = await db.select({ at: parserRuns.startedAt }).from(parserRuns).orderBy(desc(parserRuns.id)).limit(1);
|
||||||
if (!last) return null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
|
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { runLeadJobs } from './leads/service.ts';
|
|||||||
import { loadConfig } from './lib/config.ts';
|
import { loadConfig } from './lib/config.ts';
|
||||||
import { runGroupPosts } from './group/service.ts';
|
import { runGroupPosts } from './group/service.ts';
|
||||||
import { ListAmListingSource } from './parser/listam-source.ts';
|
import { ListAmListingSource } from './parser/listam-source.ts';
|
||||||
|
import { isQuietHours } from './parser/quiet-hours.ts';
|
||||||
import { loadParserSettings } from './parser/settings.ts';
|
import { loadParserSettings } from './parser/settings.ts';
|
||||||
import { startPolling } from './telegram/bot.ts';
|
import { startPolling } from './telegram/bot.ts';
|
||||||
import { HttpTelegram } from './telegram/client.ts';
|
import { HttpTelegram } from './telegram/client.ts';
|
||||||
@@ -84,15 +85,18 @@ if (source) {
|
|||||||
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
|
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
|
||||||
// прохода, а темп запросов к ридеру выдерживает сам источник.
|
// прохода, а темп запросов к ридеру выдерживает сам источник.
|
||||||
// Включение и пауза между проходами берутся из настроек парсера в админке.
|
// Включение и пауза между проходами берутся из настроек парсера в админке.
|
||||||
|
// С 22:00 до 8:00 по Еревану парсер спит, чтобы не тратить токены ридера.
|
||||||
loop(
|
loop(
|
||||||
'parser',
|
'parser',
|
||||||
async () => Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000,
|
async () => (isQuietHours() ? 60_000 : Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000),
|
||||||
async () => {
|
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 минут, каждое — раз в сутки.
|
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки. Ночью тоже спит.
|
||||||
loop('removal-check', 600_000, () => runRemovalCheck({ db, source }));
|
loop('removal-check', 600_000, async () => {
|
||||||
|
if (!isQuietHours()) await runRemovalCheck({ db, source });
|
||||||
|
});
|
||||||
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
|
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
|
||||||
const watchdog: WatchdogState = { alertedAt: null };
|
const watchdog: WatchdogState = { alertedAt: null };
|
||||||
loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog));
|
loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ describe('сторож парсера', () => {
|
|||||||
it('пишет суперадмину о простое, напоминает раз в 6 часов и сообщает о восстановлении', async () => {
|
it('пишет суперадмину о простое, напоминает раз в 6 часов и сообщает о восстановлении', async () => {
|
||||||
const [admin] = await t.db.insert(users).values({ name: 'Юрий', role: 'superadmin', telegramId: 777 }).returning();
|
const [admin] = await t.db.insert(users).values({ name: 'Юрий', role: 'superadmin', telegramId: 777 }).returning();
|
||||||
const state: WatchdogState = { alertedAt: null };
|
const state: WatchdogState = { alertedAt: null };
|
||||||
const now = new Date();
|
// 10:00 по Еревану — днём, вне ночной паузы.
|
||||||
|
const now = new Date('2026-10-26T06:00:00Z');
|
||||||
|
|
||||||
// Проход был 5 минут назад — всё в порядке.
|
// Проход был 5 минут назад — всё в порядке.
|
||||||
await t.db.insert(parserRuns).values({ startedAt: new Date(now.getTime() - 5 * MIN), status: 'ok' });
|
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));
|
await runParserWatchdog(t.db, state, new Date(back.getTime() + 30 * MIN));
|
||||||
expect((await sent(admin!.id)).at(-1)).toContain('выключен в настройках (уже 30 мин)');
|
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('Парсер стоит');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user