feat(articles): drip scheduling — distribute published_at across day slots

Зачем: автоген генерит несколько статей подряд (4 категории за 15 минут).
Раньше у всех published_at=NOW(), и на сайте они появлялись скопом, выглядя
как спам. Теперь распределяем по слотам сайта (по умолчанию 09/13/17/21 МСК).

Архитектура:
  src/services/dripScheduler.js — nextDripSlot() читает app_settings.SITE_PUBLISH_SLOTS
    (CSV 'HH:MM,HH:MM,...'), default '09:00,13:00,17:00,21:00'. Перебирает дни
    вперёд (14 дней горизонт), для каждого слота проверяет ±60 мин окно —
    если уже есть опубликованная статья, считает занятым. Первый свободный
    слот возвращается. Slots в MSK, конвертируются в UTC для сравнения с NOW().

При approve draft → published:
  routes/drafts.js — PATCH /:id/approve по умолчанию ставит slot, ?immediate=true
    публикует немедленно. GET /next-slot возвращает ближайший свободный для
    UI-предпросмотра.
  draftAutoApprove.js — авто-approve в 07:00 МСК тоже использует slot.

Публичные SQL дополнены 'AND published_at <= NOW()' чтобы будущие статьи
не светились на сайте раньше времени:
  - services/articles.js: 7 мест (list/get/count/hero/grid/popular/recent)
  - routes/categories.js: GET /:slug/articles
  - routes/scheduledPosts.js: TG-publish source query

Опционально: SITE_PUBLISH_SLOTS можно редактировать в /admin/settings.
This commit is contained in:
Aleksei Pavlov
2026-06-19 13:01:36 +03:00
parent b02bdba4e6
commit bdff84e579
6 changed files with 162 additions and 18 deletions
+6 -2
View File
@@ -4,6 +4,7 @@
const { query } = require('./src/config/db');
const { scheduleForArticle } = require('./src/services/articleAutoPublish');
const { nextDripSlot } = require('./src/services/dripScheduler');
const AUTO_APPROVE_HOUR_MSK = 7;
let lastRunDate = null;
@@ -22,11 +23,14 @@ async function runDraftAutoApprove() {
console.log(`[DraftApprove] approving ${drafts.length} drafts`);
for (const draft of drafts) {
const slot = await nextDripSlot();
await query(
`UPDATE articles SET status='published', published_at=NOW() WHERE id=$1`,
[draft.id]
`UPDATE articles SET status='published', published_at=$2 WHERE id=$1`,
[draft.id, slot]
);
await scheduleForArticle(draft.id);
const mskLabel = slot.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
console.log(`[DraftApprove] slot ${mskLabel} for article=${draft.id}`);
console.log(`[DraftApprove] approved article=${draft.id} "${draft.title.slice(0, 50)}"`);
}
} catch (err) {