diff --git a/README.md b/README.md index be592c7..d8e3a9d 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/apps/api/src/archive.int.test.ts b/apps/api/src/archive.int.test.ts index f7a72a8..53cd9c0 100644 --- a/apps/api/src/archive.int.test.ts +++ b/apps/api/src/archive.int.test.ts @@ -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 }); diff --git a/apps/api/src/ingest/watchdog.ts b/apps/api/src/ingest/watchdog.ts index 0f1be2e..2dc8d60 100644 --- a/apps/api/src/ingest/watchdog.ts +++ b/apps/api/src/ingest/watchdog.ts @@ -25,9 +25,9 @@ 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); + // В ночную паузу парсер спит по расписанию — это не простой. + 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= stall ? `не запускался ${ago(last.at, now)}` : null; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 73cd7fd..80e4a53 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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 }; diff --git a/apps/api/src/parser/quiet-hours.test.ts b/apps/api/src/parser/quiet-hours.test.ts index 99c74e0..fbfbfc2 100644 --- a/apps/api/src/parser/quiet-hours.test.ts +++ b/apps/api/src/parser/quiet-hours.test.ts @@ -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'); }); }); diff --git a/apps/api/src/parser/quiet-hours.ts b/apps/api/src/parser/quiet-hours.ts index 6630915..1841394 100644 --- a/apps/api/src/parser/quiet-hours.ts +++ b/apps/api/src/parser/quiet-hours.ts @@ -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; } diff --git a/apps/api/src/parser/settings.ts b/apps/api/src/parser/settings.ts index 6b3722a..e4a8daf 100644 --- a/apps/api/src/parser/settings.ts +++ b/apps/api/src/parser/settings.ts @@ -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), /** diff --git a/apps/web/src/crm/ParserSettings.tsx b/apps/web/src/crm/ParserSettings.tsx index 001b024..bd5750f 100644 --- a/apps/web/src/crm/ParserSettings.tsx +++ b/apps/web/src/crm/ParserSettings.tsx @@ -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)} +
+

{x.quietTitle}

+

{x.quietHint}

+ setF({ ...f, quietHours: v })} label={x.quietOn} /> +
+ {(['quietFrom', 'quietTo'] as const).map((field) => ( +
+ + +
+ ))} +
+ {f.quietHours && f.quietFrom === f.quietTo &&

{x.quietSame}

} +
+
{x.categories}
diff --git a/apps/web/src/crm/i18n.ts b/apps/web/src/crm/i18n.ts index c82ccb4..900e27d 100644 --- a/apps/web/src/crm/i18n.ts +++ b/apps/web/src/crm/i18n.ts @@ -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`, },