From bdff84e579bcf26dabd59e5ec9273d1fe1a4308e Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 13:01:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(articles):=20drip=20scheduling=20=E2=80=94?= =?UTF-8?q?=20distribute=20published=5Fat=20across=20day=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: автоген генерит несколько статей подряд (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. --- draftAutoApprove.js | 8 ++- src/routes/categories.js | 2 +- src/routes/drafts.js | 39 +++++++++--- src/routes/scheduledPosts.js | 2 +- src/services/articles.js | 14 ++--- src/services/dripScheduler.js | 115 ++++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 src/services/dripScheduler.js diff --git a/draftAutoApprove.js b/draftAutoApprove.js index 545f90d..d9655f9 100644 --- a/draftAutoApprove.js +++ b/draftAutoApprove.js @@ -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) { diff --git a/src/routes/categories.js b/src/routes/categories.js index 91e9168..5a0d9c2 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -17,7 +17,7 @@ router.get('/:slug/articles', async (req, res) => { const offset = parseInt(req.query.offset) || 0; const { rows } = await query( `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`, [req.params.slug, limit, offset] ); diff --git a/src/routes/drafts.js b/src/routes/drafts.js index 844c9be..1d591c3 100644 --- a/src/routes/drafts.js +++ b/src/routes/drafts.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const { query } = require('../config/db'); const { scheduleForArticle } = require('../services/articleAutoPublish'); +const { nextDripSlot, describeNextSlot } = require('../services/dripScheduler'); const { generateCover, COVER_STYLES } = require('../services/covers'); // 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) => { try { 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( - `UPDATE articles SET status='published', published_at=NOW() + `UPDATE articles SET status='published', published_at=$2 WHERE id=$1 AND status='draft' - RETURNING id, title, slug`, - [id] + RETURNING id, title, slug, published_at`, + [id, slot] ); if (!rows.length) return res.status(404).json({ error: 'Draft not found' }); const scheduled = await scheduleForArticle(id); - const slot = scheduled[0]?.scheduled_at; - console.log(`[DraftApprove] manual approve article=${id} "${rows[0].title.slice(0,50)}"`); - res.json({ ok: true, article: rows[0], scheduled_at: slot }); + const channelSlot = scheduled[0]?.scheduled_at; + const mskLabel = slot.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' }); + 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) { res.status(500).json({ error: err.message }); } diff --git a/src/routes/scheduledPosts.js b/src/routes/scheduledPosts.js index 6c94a97..a10403c 100644 --- a/src/routes/scheduledPosts.js +++ b/src/routes/scheduledPosts.js @@ -87,7 +87,7 @@ router.post('/backfill-channel/:channelId', async (req, res) => { let sql = ` SELECT a.id, a.slug, a.title, a.category, a.published_at FROM articles a - WHERE a.status='published' + WHERE a.status='published' AND a.published_at <= NOW() AND NOT EXISTS ( SELECT 1 FROM scheduled_posts sp WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent') diff --git a/src/services/articles.js b/src/services/articles.js index 2eb8ead..ada0d45 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -35,7 +35,7 @@ function estimateReadingTime(text) { */ 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 - FROM articles WHERE status='published'`; + FROM articles WHERE status='published' AND published_at <= NOW()`; const params = []; if (tag) { params.push(tag); sql += ` AND tags ? $${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 FROM articles a 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] ); if (!rows.length) return null; @@ -62,7 +62,7 @@ async function getArticleBySlug(slug) { async function getAllTags() { const { rows } = await query( `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` ); return rows; @@ -207,7 +207,7 @@ async function getHomeArticles() { // Hero — самая свежая опубликованная статья с обложкой const heroRes = await query( `${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` ); const hero = heroRes.rows[0] || null; @@ -219,7 +219,7 @@ async function getHomeArticles() { SELECT ${select.replace('SELECT ', '')}, ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn FROM articles - WHERE status='published' AND id <> $1 + WHERE status='published' AND published_at <= NOW() AND id <> $1 ) t WHERE rn <= 3 ORDER BY category, rn`, [heroId] @@ -234,7 +234,7 @@ async function getHomeArticles() { // Популярное за 30 дней: топ-3 по views (только если views > 0) const popRes = await query( `${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` ); const popular = popRes.rows; @@ -246,7 +246,7 @@ async function getHomeArticles() { const usedArr = Array.from(usedIds).filter(Boolean); const recentRes = await query( `${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`, [usedArr.length ? usedArr : [0]] ); diff --git a/src/services/dripScheduler.js b/src/services/dripScheduler.js new file mode 100644 index 0000000..77ebf1c --- /dev/null +++ b/src/services/dripScheduler.js @@ -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 };