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 { 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) {
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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]]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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