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 { query } = require('./src/config/db');
const { scheduleForArticle } = require('./src/services/articleAutoPublish'); const { scheduleForArticle } = require('./src/services/articleAutoPublish');
const { nextDripSlot } = require('./src/services/dripScheduler');
const AUTO_APPROVE_HOUR_MSK = 7; const AUTO_APPROVE_HOUR_MSK = 7;
let lastRunDate = null; let lastRunDate = null;
@@ -22,11 +23,14 @@ async function runDraftAutoApprove() {
console.log(`[DraftApprove] approving ${drafts.length} drafts`); console.log(`[DraftApprove] approving ${drafts.length} drafts`);
for (const draft of drafts) { for (const draft of drafts) {
const slot = await nextDripSlot();
await query( await query(
`UPDATE articles SET status='published', published_at=NOW() WHERE id=$1`, `UPDATE articles SET status='published', published_at=$2 WHERE id=$1`,
[draft.id] [draft.id, slot]
); );
await scheduleForArticle(draft.id); 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)}"`); console.log(`[DraftApprove] approved article=${draft.id} "${draft.title.slice(0, 50)}"`);
} }
} catch (err) { } catch (err) {
+1 -1
View File
@@ -17,7 +17,7 @@ router.get('/:slug/articles', async (req, res) => {
const offset = parseInt(req.query.offset) || 0; const offset = parseInt(req.query.offset) || 0;
const { rows } = await query( const { rows } = await query(
`SELECT id,slug,title,excerpt,cover_url,tags,category,author,reading_time,published_at `SELECT id,slug,title,excerpt,cover_url,tags,category,author,reading_time,published_at
FROM articles WHERE status='published' AND category=$1 FROM articles WHERE status='published' AND published_at <= NOW() AND category=$1
ORDER BY published_at DESC LIMIT $2 OFFSET $3`, ORDER BY published_at DESC LIMIT $2 OFFSET $3`,
[req.params.slug, limit, offset] [req.params.slug, limit, offset]
); );
+32 -7
View File
@@ -3,6 +3,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { query } = require('../config/db'); const { query } = require('../config/db');
const { scheduleForArticle } = require('../services/articleAutoPublish'); const { scheduleForArticle } = require('../services/articleAutoPublish');
const { nextDripSlot, describeNextSlot } = require('../services/dripScheduler');
const { generateCover, COVER_STYLES } = require('../services/covers'); const { generateCover, COVER_STYLES } = require('../services/covers');
// GET /api/drafts — список черновиков // GET /api/drafts — список черновиков
@@ -46,22 +47,46 @@ router.patch('/:id', async (req, res) => {
} }
}); });
// PATCH /api/drafts/:id/approve — одобрить черновик вручную // PATCH /api/drafts/:id/approve — одобрить черновик вручную.
// По умолчанию ставит published_at в следующий свободный slot (drip distribution).
// ?immediate=true — публикует сразу (published_at = NOW).
router.patch('/:id/approve', async (req, res) => { router.patch('/:id/approve', async (req, res) => {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
const immediate = req.query.immediate === 'true' || req.body?.immediate === true;
const slot = immediate ? new Date() : await nextDripSlot();
const { rows } = await query( const { rows } = await query(
`UPDATE articles SET status='published', published_at=NOW() `UPDATE articles SET status='published', published_at=$2
WHERE id=$1 AND status='draft' WHERE id=$1 AND status='draft'
RETURNING id, title, slug`, RETURNING id, title, slug, published_at`,
[id] [id, slot]
); );
if (!rows.length) return res.status(404).json({ error: 'Draft not found' }); if (!rows.length) return res.status(404).json({ error: 'Draft not found' });
const scheduled = await scheduleForArticle(id); const scheduled = await scheduleForArticle(id);
const slot = scheduled[0]?.scheduled_at; const channelSlot = scheduled[0]?.scheduled_at;
console.log(`[DraftApprove] manual approve article=${id} "${rows[0].title.slice(0,50)}"`); const mskLabel = slot.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
res.json({ ok: true, article: rows[0], scheduled_at: slot }); console.log(`[DraftApprove] manual approve article=${id} "${rows[0].title.slice(0,50)}" → ${immediate ? 'NOW' : 'slot ' + mskLabel}`);
res.json({
ok: true,
article: rows[0],
published_at: slot,
published_at_msk: mskLabel,
immediate,
scheduled_at: channelSlot,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/drafts/next-slot — посмотреть какой будет слот для следующего approve
router.get('/next-slot', async (_req, res) => {
try {
const info = await describeNextSlot();
res.json({ at: info.at, msk: info.mskLabel });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
+1 -1
View File
@@ -87,7 +87,7 @@ router.post('/backfill-channel/:channelId', async (req, res) => {
let sql = ` let sql = `
SELECT a.id, a.slug, a.title, a.category, a.published_at SELECT a.id, a.slug, a.title, a.category, a.published_at
FROM articles a FROM articles a
WHERE a.status='published' WHERE a.status='published' AND a.published_at <= NOW()
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM scheduled_posts sp SELECT 1 FROM scheduled_posts sp
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent') WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
+7 -7
View File
@@ -35,7 +35,7 @@ function estimateReadingTime(text) {
*/ */
async function listArticles({ limit = 20, offset = 0, tag = null, category = null } = {}) { async function listArticles({ limit = 20, offset = 0, tag = null, category = null } = {}) {
let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at
FROM articles WHERE status='published'`; FROM articles WHERE status='published' AND published_at <= NOW()`;
const params = []; const params = [];
if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; } if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
if (category) { params.push(category); sql += ` AND category=$${params.length}`; } if (category) { params.push(category); sql += ` AND category=$${params.length}`; }
@@ -50,7 +50,7 @@ async function getArticleBySlug(slug) {
`SELECT a.*, j.tokens_in, j.tokens_out `SELECT a.*, j.tokens_in, j.tokens_out
FROM articles a FROM articles a
LEFT JOIN generation_jobs j ON j.id = a.job_id LEFT JOIN generation_jobs j ON j.id = a.job_id
WHERE a.slug=$1 AND a.status='published'`, WHERE a.slug=$1 AND a.status='published' AND a.published_at <= NOW()`,
[slug] [slug]
); );
if (!rows.length) return null; if (!rows.length) return null;
@@ -62,7 +62,7 @@ async function getArticleBySlug(slug) {
async function getAllTags() { async function getAllTags() {
const { rows } = await query( const { rows } = await query(
`SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt `SELECT DISTINCT jsonb_array_elements_text(tags) as tag, COUNT(*) as cnt
FROM articles WHERE status='published' FROM articles WHERE status='published' AND published_at <= NOW()
GROUP BY tag ORDER BY cnt DESC LIMIT 30` GROUP BY tag ORDER BY cnt DESC LIMIT 30`
); );
return rows; return rows;
@@ -207,7 +207,7 @@ async function getHomeArticles() {
// Hero — самая свежая опубликованная статья с обложкой // Hero — самая свежая опубликованная статья с обложкой
const heroRes = await query( const heroRes = await query(
`${select} FROM articles `${select} FROM articles
WHERE status='published' AND cover_url IS NOT NULL WHERE status='published' AND published_at <= NOW() AND cover_url IS NOT NULL
ORDER BY published_at DESC LIMIT 1` ORDER BY published_at DESC LIMIT 1`
); );
const hero = heroRes.rows[0] || null; const hero = heroRes.rows[0] || null;
@@ -219,7 +219,7 @@ async function getHomeArticles() {
SELECT ${select.replace('SELECT ', '')}, SELECT ${select.replace('SELECT ', '')},
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
FROM articles FROM articles
WHERE status='published' AND id <> $1 WHERE status='published' AND published_at <= NOW() AND id <> $1
) t WHERE rn <= 3 ) t WHERE rn <= 3
ORDER BY category, rn`, ORDER BY category, rn`,
[heroId] [heroId]
@@ -234,7 +234,7 @@ async function getHomeArticles() {
// Популярное за 30 дней: топ-3 по views (только если views > 0) // Популярное за 30 дней: топ-3 по views (только если views > 0)
const popRes = await query( const popRes = await query(
`${select} FROM articles `${select} FROM articles
WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days' WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days' AND published_at <= NOW()
ORDER BY views DESC, published_at DESC LIMIT 3` ORDER BY views DESC, published_at DESC LIMIT 3`
); );
const popular = popRes.rows; const popular = popRes.rows;
@@ -246,7 +246,7 @@ async function getHomeArticles() {
const usedArr = Array.from(usedIds).filter(Boolean); const usedArr = Array.from(usedIds).filter(Boolean);
const recentRes = await query( const recentRes = await query(
`${select} FROM articles `${select} FROM articles
WHERE status='published' AND id <> ALL($1::int[]) WHERE status='published' AND published_at <= NOW() AND id <> ALL($1::int[])
ORDER BY published_at DESC LIMIT 6`, ORDER BY published_at DESC LIMIT 6`,
[usedArr.length ? usedArr : [0]] [usedArr.length ? usedArr : [0]]
); );
+115
View File
@@ -0,0 +1,115 @@
/**
* dripScheduler.js — равномерное распределение публикаций статей по дню.
*
* Зачем:
* Автогенерация даёт 4+ статей в день одним пачкой (одна за другой в течение
* часа). Если у каждой published_at=NOW(), на сайте они появляются скопом.
* Распределяем — растягиваем по слотам (например 09/13/17/21 МСК).
*
* Логика nextDripSlot():
* 1. Читаем app_settings.SITE_PUBLISH_SLOTS (CSV "HH:MM,HH:MM,...", default
* "09:00,13:00,17:00,21:00" — каждые 4 часа в МСК).
* 2. Перебираем дни вперёд начиная с сегодня:
* - для каждого слота вычисляем абсолютный UTC-момент
* - если слот ещё впереди и в этот час (slot ± 60 мин) нет другой
* статьи с published_at — этот слот наш
* 3. Если все слоты на 14 дней вперёд заняты — возвращаем NOW() (fallback).
*
* Учёт временной зоны:
* Slots задаются в Москве (UTC+3). Преобразуем slot.hour→UTC при сравнении.
*/
const { query } = require('../config/db');
const settings = require('./settings');
const MSK_OFFSET_MIN = 180;
const HORIZON_DAYS = 14;
const SLOT_BUSY_WINDOW_MIN = 60; // считаем слот занятым если в ±60 мин уже есть статья
async function getSlots() {
const raw = await settings.get('SITE_PUBLISH_SLOTS', '09:00,13:00,17:00,21:00');
const parts = String(raw).split(',').map(s => s.trim()).filter(Boolean);
const out = [];
for (const p of parts) {
const m = /^(\d{1,2}):(\d{2})$/.exec(p);
if (!m) continue;
const h = Math.max(0, Math.min(23, parseInt(m[1], 10)));
const min = Math.max(0, Math.min(59, parseInt(m[2], 10)));
out.push({ h, min });
}
return out.length ? out.sort((a, b) => a.h - b.h || a.min - b.min) : [{ h: 13, min: 0 }];
}
/**
* Превращает (год/месяц/день в локальной MSK, слот.h, слот.m) в Date в UTC.
* Возвращает абсолютный Date.
*/
function slotToUtcDate(mskYear, mskMonth, mskDay, slot) {
// Slot в MSK → UTC = MSK - 3h
const utcHour = slot.h - 3;
const d = new Date(Date.UTC(mskYear, mskMonth, mskDay, utcHour, slot.min, 0, 0));
return d;
}
/**
* Текущая дата в MSK (компоненты), для перебора дней начиная с сегодня.
*/
function mskDateParts(now = new Date()) {
const msk = new Date(now.getTime() + MSK_OFFSET_MIN * 60_000);
return {
year: msk.getUTCFullYear(),
month: msk.getUTCMonth(),
day: msk.getUTCDate(),
};
}
/**
* Главная функция. Возвращает ISO Date для нового published_at.
*/
async function nextDripSlot() {
const slots = await getSlots();
const now = new Date();
const startMsk = mskDateParts(now);
for (let offset = 0; offset < HORIZON_DAYS; offset++) {
const dayMsk = new Date(Date.UTC(startMsk.year, startMsk.month, startMsk.day + offset));
const y = dayMsk.getUTCFullYear();
const m = dayMsk.getUTCMonth();
const d = dayMsk.getUTCDate();
for (const slot of slots) {
const slotUtc = slotToUtcDate(y, m, d, slot);
if (slotUtc <= now) continue;
// Считаем слот занятым если есть статья (любого статуса draft/published)
// с published_at в окне ±SLOT_BUSY_WINDOW_MIN мин
const winStart = new Date(slotUtc.getTime() - SLOT_BUSY_WINDOW_MIN * 60_000);
const winEnd = new Date(slotUtc.getTime() + SLOT_BUSY_WINDOW_MIN * 60_000);
const { rows } = await query(
`SELECT 1 FROM articles
WHERE status='published'
AND published_at >= $1 AND published_at < $2
LIMIT 1`,
[winStart, winEnd]
);
if (rows.length === 0) return slotUtc;
}
}
// Все слоты заняты на горизонте — публикуем сразу
return now;
}
/**
* Утилита — описание следующего слота для логов/UI.
*/
async function describeNextSlot() {
const dt = await nextDripSlot();
const msk = new Date(dt.getTime() + MSK_OFFSET_MIN * 60_000);
const hh = String(msk.getUTCHours()).padStart(2, '0');
const mm = String(msk.getUTCMinutes()).padStart(2, '0');
const dd = String(msk.getUTCDate()).padStart(2, '0');
const mo = String(msk.getUTCMonth() + 1).padStart(2, '0');
return { at: dt, mskLabel: `${dd}.${mo} ${hh}:${mm} МСК` };
}
module.exports = { nextDripSlot, describeNextSlot, getSlots };