Парсер: часы ночной паузы в настройках админки

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:33:07 +04:00
1 parent d32f37457b
commit 5470036a69
9 files changed
+95 -30

No files matched your search

+2 -2
View File
@@ -81,8 +81,8 @@ pnpm dev:web # http://localhost:5174
отдельно по объектам и по клиентам.
- **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой
«кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число
страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. **Ночная пауза**: с 22:00 до 8:00 по Еревану
парсер и проверка снятых не работают (экономия токенов Jina), сторож парсера ночью молчит.
страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. **Ночная пауза** (в тех же настройках, по умолчанию 22:00–8:00
по Еревану): парсер и проверка снятых не работают (экономия токенов Jina), сторож парсера ночью молчит.
- **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со
следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты
суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md).
+4
View File
@@ -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 });
+3 -3
View File
@@ -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
View File
@@ -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 };
+22 -9
View File
@@ -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');
});
});
+19 -10
View File
@@ -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;
}
+6
View File
@@ -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),
/**
+23
View File
@@ -10,6 +10,9 @@ interface Settings {
freshDays: number;
pauseSec: number;
maxPages: number;
quietHours: boolean;
quietFrom: number;
quietTo: number;
categories: number[];
useKey: boolean;
removalCheck: boolean;
@@ -65,6 +68,26 @@ export function ParserSettings() {
{number('maxPages', x.maxPages, 1, 10, x.pagesHint)}
</div>
<div className="space-y-4 border-t border-line pt-5">
<h3 className="font-medium">{x.quietTitle}</h3>
<p className="text-sm text-muted">{x.quietHint}</p>
<Toggle checked={f.quietHours} onChange={(v) => setF({ ...f, quietHours: v })} label={x.quietOn} />
<div className="grid gap-4 sm:grid-cols-3">
{(['quietFrom', 'quietTo'] as const).map((field) => (
<div key={field}>
<label className="label" htmlFor={`ps-${field}`}>{x[field]}</label>
<select
id={`ps-${field}`} className="field" disabled={!f.quietHours}
value={f[field]} onChange={(e) => setF({ ...f, [field]: Number(e.target.value) })}
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{`${String(h).padStart(2, '0')}:00`}</option>)}
</select>
</div>
))}
</div>
{f.quietHours && f.quietFrom === f.quietTo && <p className="text-sm text-warn" role="status">{x.quietSame}</p>}
</div>
<fieldset>
<legend className="label">{x.categories}</legend>
<div className="flex flex-wrap gap-2">
+6
View File
@@ -103,6 +103,8 @@ const ru = {
keyHint: 'С ключом проход быстрее и известно точное время размещения, но ключ платный. Без ключа парсер работает медленнее. Если баланс ключа кончился, парсер сам переходит на работу без ключа.',
useKey: 'Использовать ключ', key: 'Текущий ключ', keyNone: 'не задан', keyEnv: 'из настроек сервера', keySet: 'задан здесь',
keyNew: 'Вставьте новый ключ, чтобы заменить', keyClear: 'Убрать ключ', keyConfirm: 'Убрать ключ, заданный в админке?',
quietTitle: 'Ночная пауза', quietHint: 'В эти часы по Еревану парсер и проверка снятых объявлений не работают — так не тратятся токены ридера. Сторож парсера в паузу не тревожит.',
quietOn: 'Останавливать парсер на ночь', quietFrom: 'С (Ереван)', quietTo: 'До (Ереван)', quietSame: 'Начало и конец совпадают — паузы не будет',
removalTitle: 'Отметка «Снято с List.am»', removalCheck: 'Проверять, не сняты ли объявления, по которым есть лиды',
recheckHours: 'Перепроверять объявление раз в, часов', removalBatch: 'Объявлений за заход (раз в 10 минут)', batchHint: (n: number) => `До ${n} проверок в сутки`,
},
@@ -336,6 +338,8 @@ const hy: Dict = {
keyHint: 'Բանալիով անցումն ավելի արագ է, և հայտնի է տեղադրման ճշգրիտ ժամը, բայց բանալին վճարովի է։ Առանց բանալու պարսերն ավելի դանդաղ է։ Եթե բանալու մնացորդը սպառվի, պարսերն ինքը կանցնի առանց բանալու աշխատանքի։',
useKey: 'Օգտագործել բանալին', key: 'Ընթացիկ բանալի', keyNone: 'նշված չէ', keyEnv: 'սերվերի կարգավորումներից', keySet: 'նշված է այստեղ',
keyNew: 'Տեղադրեք նոր բանալի՝ փոխարինելու համար', keyClear: 'Հեռացնել բանալին', keyConfirm: 'Հեռացնե՞լ ադմինկայում նշված բանալին',
quietTitle: 'Գիշերային դադար', quietHint: 'Այս ժամերին (Երևանի ժամանակով) պարսերը և հանված հայտարարությունների ստուգումը չեն աշխատում՝ ընթերցիչի տոկենները չեն ծախսվում։ Պարսերի պահակը դադարի ժամանակ չի անհանգստացնում։',
quietOn: 'Գիշերը կանգնեցնել պարսերը', quietFrom: 'Սկսած (Երևան)', quietTo: 'Մինչև (Երևան)', quietSame: 'Սկիզբը և ավարտը համընկնում են՝ դադար չի լինի',
removalTitle: '«Հանվել է List.am-ից» նշում', removalCheck: 'Ստուգել՝ արդյոք հանված են լիդ ունեցող հայտարարությունները',
recheckHours: 'Վերստուգել հայտարարությունը, ժամը մեկ', removalBatch: 'Հայտարարություններ մեկ մոտեցմամբ (10 րոպեն մեկ)', batchHint: (n) => `Օրական մինչև ${n} ստուգում`,
},
@@ -567,6 +571,8 @@ const en: Dict = {
keyHint: 'With a key a run is faster and the exact posting time is known, but the key is paid. Without it the parser is slower. If the key balance runs out, the parser switches to keyless mode by itself.',
useKey: 'Use the key', key: 'Current key', keyNone: 'not set', keyEnv: 'from server settings', keySet: 'set here',
keyNew: 'Paste a new key to replace', keyClear: 'Remove key', keyConfirm: 'Remove the key set in the admin panel?',
quietTitle: 'Night pause', quietHint: 'During these hours (Yerevan time) the parser and the removal check do not run, so reader tokens are not spent. The parser watchdog stays silent during the pause.',
quietOn: 'Stop the parser at night', quietFrom: 'From (Yerevan)', quietTo: 'Until (Yerevan)', quietSame: 'Start and end are the same, so there is no pause',
removalTitle: '“Removed from List.am” mark', removalCheck: 'Check whether listings with leads were removed',
recheckHours: 'Recheck a listing every, hours', removalBatch: 'Listings per batch (every 10 minutes)', batchHint: (n) => `Up to ${n} checks a day`,
},