feat(publish): синхрон сайт/ТГ + защита от залпа + заметки Зеро по механике черновиков

ЭТАП 4 — расписание публикаций (раз и навсегда):

1. Синхронизация сайт ↔ Telegram:
   - articleAutoPublish.pickScheduleTime теперь ставит TG-пост на
     articles.published_at (тот же момент, когда статья появляется на сайте).
     Слоты канала publish_slots больше НЕ выбирают время независимо.
   - Единый источник времени — published_at (drip-слот сайта).
   - Слоты сайта и ТГ синхронизированы в БД: 08:11/12:11/16:11/20:11.

2. Защита от залпа (scheduledPostsRunner):
   - посты просроченные >3ч → status='skipped' (не спамим канал задним числом).
   - публикация по ОДНОМУ за тик (LIMIT 1), не пачкой.

3. Заметки Зеро (zeroNotes) — механика как у черновиков статей:
   - nextPublishSlot всегда = ЗАВТРА publishHour (сегодня сгенерили → завтра
     публикуем). Час настраивается ZERO_NOTES_PUBLISH_HOUR (13:00).
   - autoApproveOldDrafts (09:00 МСК): если не подтверждён к утру дня
     публикации — авто-одобряется и выходит в свой слот.
   - publishReady limit:1 — строго одна заметка за тик.

Настройки в app_settings: SITE_PUBLISH_SLOTS, ZERO_SITE_URL_BASE,
ZERO_NOTES_APPROVE_HOUR=9, GENERATE_HOUR=13, PUBLISH_HOUR=13.
This commit is contained in:
Aleksei Pavlov
2026-06-20 11:07:10 +03:00
parent a09ee4a5fb
commit 749d717a94
5 changed files with 79 additions and 67 deletions
+34 -51
View File
@@ -1,62 +1,45 @@
// Авто-публикация статей в каналы. // Авто-публикация статей в каналы.
// //
// СИНХРОНИЗАЦИЯ С САЙТОМ (главный принцип):
// TG-пост ставится на ТО ЖЕ время, когда статья появляется на сайте —
// на articles.published_at. Сайт и Telegram больше не разъезжаются.
// (published_at выставляется dripScheduler'ом при approve черновика —
// в слоты SITE_PUBLISH_SLOTS = 08:11/12:11/16:11/20:11.)
//
// Логика: // Логика:
// 1. При сохранении статьи со status='published' — engine вызывает scheduleForArticle(articleId) // 1. При публикации статьи engine вызывает scheduleForArticle(articleId)
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть) // 2. Находим системные каналы с auto_publish_enabled=true где категория подходит
// 3. Для каждого канала ищем ближайший подходящий момент: // 3. scheduled_at = articles.published_at (если оно в будущем),
// - если delay_min > 0 → now + delay_min // иначе now + delay_min (или NOW). Слоты канала publish_slots больше
// - иначе — ближайший publish_slot канала в будущем // НЕ используются для выбора времени — единый источник это published_at.
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW) // 4. Дедуп: один article × один channel = одна запись (skip если pending/sent)
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent) // 5. Раннер (scheduledPostsRunner) отправит когда scheduled_at <= NOW,
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у // с защитой от залпа (пропускает посты старше SKIP_OLDER_THAN_H часов).
const { query } = require('../config/db'); const { query } = require('../config/db');
/** /**
* Подобрать ближайший момент публикации для канала. * Момент публикации в TG = момент появления на сайте (published_at статьи).
* Если published_at не задан/в прошлом — используем delay_min или NOW.
* @param {object} channel
* @param {object} article — должен содержать published_at
* @returns Date * @returns Date
*/ */
async function pickScheduleTime(channel) { async function pickScheduleTime(channel, article) {
const now = new Date(); const now = new Date();
// Главный путь: синхрон с сайтом — ставим на published_at статьи
if (article && article.published_at) {
const pub = new Date(article.published_at);
if (pub > now) return pub; // статья выйдет на сайте в будущем → TG тогда же
return now; // статья уже на сайте → публикуем в TG сейчас
}
// Fallback (нет published_at): прежнее поведение через delay
if (channel.auto_publish_delay_min > 0) { if (channel.auto_publish_delay_min > 0) {
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000); return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
} }
// Ищем publish_slots return now;
const { rows: slots } = await query(
`SELECT slot_hour, slot_minute FROM publish_slots
WHERE channel_id=$1 AND enabled=true
ORDER BY slot_hour, slot_minute`,
[channel.id]
);
if (slots.length === 0) {
return now; // публикуем сразу
}
// Получаем уже занятые слоты (pending)
const { rows: taken } = await query(
`SELECT scheduled_at FROM scheduled_posts WHERE channel_id=$1 AND status='pending'`,
[channel.id]
);
const takenTimes = new Set(taken.map(r => new Date(r.scheduled_at).getTime()));
// Ищем ближайший незанятый слот на ближайшие 7 дней
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
const base = new Date(now);
base.setDate(base.getDate() + dayOffset);
for (const s of slots) {
const t = new Date(base);
t.setHours(s.slot_hour, s.slot_minute, 0, 0);
if (t > now && !takenTimes.has(t.getTime())) {
return t;
}
}
}
// Fallback — завтра первый слот
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(slots[0].slot_hour, slots[0].slot_minute, 0, 0);
return tomorrow;
} }
/** /**
@@ -66,7 +49,7 @@ async function pickScheduleTime(channel) {
*/ */
async function scheduleForArticle(articleId) { async function scheduleForArticle(articleId) {
const { rows: arts } = await query( const { rows: arts } = await query(
`SELECT id, slug, title, category, status FROM articles WHERE id=$1`, `SELECT id, slug, title, category, status, published_at FROM articles WHERE id=$1`,
[articleId] [articleId]
); );
if (!arts.length || arts[0].status !== 'published') return []; if (!arts.length || arts[0].status !== 'published') return [];
@@ -84,23 +67,23 @@ async function scheduleForArticle(articleId) {
const created = []; const created = [];
for (const ch of channels) { for (const ch of channels) {
// Дедуп // Дедуп — одна запись на (channel, article) в активных статусах
const { rows: existing } = await query( const { rows: existing } = await query(
`SELECT id FROM scheduled_posts `SELECT id FROM scheduled_posts
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent') WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent','sending')
LIMIT 1`, LIMIT 1`,
[ch.id, article.id] [ch.id, article.id]
); );
if (existing.length) continue; if (existing.length) continue;
const scheduledAt = await pickScheduleTime(ch); const scheduledAt = await pickScheduleTime(ch, article);
const { rows: inserted } = await query( const { rows: inserted } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status) `INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
VALUES ($1,$2,$3,'pending') RETURNING *`, VALUES ($1,$2,$3,'pending') RETURNING *`,
[ch.id, article.id, scheduledAt] [ch.id, article.id, scheduledAt]
); );
created.push(inserted[0]); created.push(inserted[0]);
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()}`); console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()} (synced to published_at)`);
} }
return created; return created;
} }
+22 -2
View File
@@ -396,11 +396,31 @@ async function publishOne(scheduledPost) {
return { messageId, channel, article }; return { messageId, channel, article };
} }
// Защита от залпа: посты просроченные более чем на SKIP_OLDER_THAN_H часов
// не отправляются (помечаются 'skipped'). Иначе после простоя раннера или
// массового ретрая всё накопившееся улетело бы в канал пачкой.
const SKIP_OLDER_THAN_H = 3;
async function runScheduled() { async function runScheduled() {
// 1) Помечаем слишком старые pending как skipped (не спамим канал задним числом)
const { rows: skipped } = await query(
`UPDATE scheduled_posts
SET status='skipped',
error='auto-skipped: просрочено более ${SKIP_OLDER_THAN_H}ч'
WHERE status='pending'
AND scheduled_at < NOW() - INTERVAL '${SKIP_OLDER_THAN_H} hours'
RETURNING id, article_id`
);
if (skipped.length) {
console.log(`[scheduled-runner] skipped ${skipped.length} stale post(s): ${skipped.map(s => s.id).join(', ')}`);
}
// 2) Берём ОДИН готовый пост за тик (не пачкой) — публикации идут по одной
// с интервалом в минуту, даже если накопилось несколько свежих.
const { rows } = await query( const { rows } = await query(
`SELECT * FROM scheduled_posts `SELECT * FROM scheduled_posts
WHERE status='pending' AND scheduled_at <= NOW() WHERE status='pending' AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC LIMIT 20` ORDER BY scheduled_at ASC LIMIT 1`
); );
const results = []; const results = [];
for (const sp of rows) { for (const sp of rows) {
@@ -422,7 +442,7 @@ async function runScheduled() {
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`); console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
} }
} }
return { processed: rows.length, results }; return { processed: rows.length, skipped: skipped.length, results };
} }
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT }; module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
+12 -8
View File
@@ -4,7 +4,7 @@
* Цикл одной заметки: * Цикл одной заметки:
* 1) 13:00 МСК — generateDraft(channelId) → status='draft', scheduled_at=ЗАВТРА 13:00 * 1) 13:00 МСК — generateDraft(channelId) → status='draft', scheduled_at=ЗАВТРА 13:00
* 2) Вечером — редактор вручную: approveManual / editContent / skip / regenerate * 2) Вечером — редактор вручную: approveManual / editContent / skip / regenerate
* 3) 07:00 МСК — autoApproveOldDrafts(): остальные draft → 'approved' (approved_by='auto') * 3) 09:00 МСК — autoApproveOldDrafts(): неподтверждённые draft → 'approved' (approved_by='auto')
* 4) 13:00 следующего дня — runner забирает 'approved' и публикует * 4) 13:00 следующего дня — runner забирает 'approved' и публикует
* *
* Здесь только бизнес-логика. Расписание — в src/workers/zeroNotesScheduler.js. * Здесь только бизнес-логика. Расписание — в src/workers/zeroNotesScheduler.js.
@@ -36,13 +36,14 @@ function nowMsk() {
} }
/** /**
* Следующий публикационный слот в МСК (13:00 по умолчанию). * Публикационный слот — ВСЕГДА СЛЕДУЮЩИЙ ДЕНЬ в publishHour МСК (13:00 по умолчанию).
* Если сейчас < сегодняшнего 13:00 МСК — возвращаем сегодня в 13:00 МСК, * Механика как у черновиков статей: сегодня сгенерили → завтра в 13:00 публикуем
* иначе — завтра в 13:00 МСК. Возвращаем как UTC Date для записи в TIMESTAMPTZ. * (если подтвердили; иначе авто-одобрение в 09:00 того же дня).
* Возвращаем UTC Date для записи в TIMESTAMPTZ.
*/ */
function nextPublishSlot({ publishHourMsk = 13, publishMinMsk = 0 } = {}) { function nextPublishSlot({ publishHourMsk = 13, publishMinMsk = 0 } = {}) {
const { date: nowM, hour, minute } = nowMsk(); const { date: nowM } = nowMsk();
const isFuture = hour < publishHourMsk || (hour === publishHourMsk && minute < publishMinMsk); const isFuture = false; // всегда планируем на завтра — заметка должна "отлежаться" сутки на подтверждение
const baseUtc = new Date(nowM.getTime() - MSK_OFFSET_MIN * 60_000); const baseUtc = new Date(nowM.getTime() - MSK_OFFSET_MIN * 60_000);
baseUtc.setUTCHours(publishHourMsk - 3, publishMinMsk, 0, 0); // UTC-эквивалент МСК-часа baseUtc.setUTCHours(publishHourMsk - 3, publishMinMsk, 0, 0); // UTC-эквивалент МСК-часа
if (!isFuture) baseUtc.setUTCDate(baseUtc.getUTCDate() + 1); if (!isFuture) baseUtc.setUTCDate(baseUtc.getUTCDate() + 1);
@@ -271,16 +272,19 @@ async function editContent(noteId, { content, theme, pose, imageUrl, scheduledAt
* Запускается в 07:00 МСК — переводит все вчерашние draft в готовые к публикации в 13:00. * Запускается в 07:00 МСК — переводит все вчерашние draft в готовые к публикации в 13:00.
*/ */
async function autoApproveOldDrafts() { async function autoApproveOldDrafts() {
// Запускается в 09:00 МСК (ZERO_NOTES_APPROVE_HOUR). Авто-одобряет драфты,
// чей день публикации уже наступил (scheduled_at сегодня или раньше), а юзер
// не подтвердил вручную. Так заметка всё равно выйдет в свой слот (13:00).
const { rows } = await query( const { rows } = await query(
`UPDATE zero_notes `UPDATE zero_notes
SET status='approved', approved_at=NOW(), approved_by='auto', updated_at=NOW() SET status='approved', approved_at=NOW(), approved_by='auto', updated_at=NOW()
WHERE status='draft' WHERE status='draft'
AND scheduled_at IS NOT NULL AND scheduled_at IS NOT NULL
AND scheduled_at <= NOW() + INTERVAL '8 hours' AND scheduled_at <= NOW() + INTERVAL '6 hours'
RETURNING id, channel_id, theme_bucket` RETURNING id, channel_id, theme_bucket`
); );
if (rows.length) { if (rows.length) {
console.log(`[zeroNotes] auto-approved ${rows.length} drafts: ${rows.map(r => r.id).join(', ')}`); console.log(`[zeroNotes] auto-approved ${rows.length} draft(s) (не подтверждены к утру): ${rows.map(r => r.id).join(', ')}`);
} }
return rows; return rows;
} }
+7 -5
View File
@@ -49,15 +49,17 @@ async function getChannel(channelId) {
} }
/** /**
* Кнопка "Открыть на сайте" — опционально, если ZERO_SITE_URL_BASE задан. * Кнопка "Читать на сайте" — ведёт на страницу заметок Зеро.
* Пример: ZERO_SITE_URL_BASE=https://zeropost.ru/zero → https://zeropost.ru/zero/123 * ZERO_SITE_URL_BASE по умолчанию https://zeropost.ru/zero.
* Отдельной страницы /zero/:id пока нет, поэтому ведём на общий /zero
* (если появится — добавить `/${noteId}`).
*/ */
async function buildReplyMarkup(noteId) { async function buildReplyMarkup(_noteId) {
const base = await settings.get('ZERO_SITE_URL_BASE', ''); const base = await settings.get('ZERO_SITE_URL_BASE', 'https://zeropost.ru/zero');
if (!base) return undefined; if (!base) return undefined;
return { return {
inline_keyboard: [[ inline_keyboard: [[
{ text: '💬 Открыть на сайте', url: `${base.replace(/\/$/, '')}/${noteId}` }, { text: '💬 Читать на сайте', url: base.replace(/\/$/, '') },
]], ]],
}; };
} }
+4 -1
View File
@@ -70,7 +70,10 @@ async function tick() {
if (hour === genHour) await runGeneration(ymd); if (hour === genHour) await runGeneration(ymd);
if (hour === appHour) await runAutoApprove(ymd); if (hour === appHour) await runAutoApprove(ymd);
// публикация approved-заметок в TG (каждую минуту) // публикация approved-заметок в TG (каждую минуту)
const published = await zeroRunner.publishReady({ limit: 3 }); // limit:1 — строго одна заметка за тик. Заметки генерятся 1/день, и даже
// если накопилось несколько approved (например после ручного ретрая), они
// НЕ улетят пачкой — будут публиковаться по одной с интервалом в минуту.
const published = await zeroRunner.publishReady({ limit: 1 });
if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`); if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`);
} catch (err) { } catch (err) {
console.error(`[zeroNotes/scheduler] tick error: ${err.message}`); console.error(`[zeroNotes/scheduler] tick error: ${err.message}`);