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:
+6
-2
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
+32
-7
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]]
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user