Files
zeropost-engine/draftAutoApprove.js
T
Alexey Pavlov 4a11c63ed5 fix(autogen): устранить дубли черновиков и накопление backlog
- autogen: атомарный захват категорий (UPDATE ... RETURNING, last_run_at
  ставится ДО генерации), окно ±5 -> ±2 мин. Убирает дубли одной категории
  (advisory-lock на пуле не сериализовал, last_run_at ставился после ~90с гена).
- draftAutoApprove: дренаж backlog — 4 старейших черновика/день, не трогая
  сегодняшнюю свежую партию. Раньше брало строго 'вчера' + LIMIT 4, остальное
  копилось навсегда.
- articles: guard минимальной длины (800 симв) — не сохранять обрезки в draft
  (куцые digest с веб-поиском).
- drafts route: GET /api/drafts/:id с полным content (чтение статьи целиком).
- promptBuilder: запрет однообразных временных зачинов ('...назад').
2026-06-30 00:58:01 +03:00

154 lines
7.0 KiB
JavaScript

// draftAutoApprove.js
// Каждый день в 07:00 МСК переводит черновики вчерашней генерации → 'published'
// и ставит их в слоты ТЕКУЩЕГО дня (08:11/12:11/16:11/20:11).
//
// Механика (как у заметок Зеро):
// 17:00 — автогенерация создаёт 4 черновика (по 1 на категорию)
// 07:00 след.дня — авто-одобрение: берём только черновики созданные ВЧЕРА,
// раскладываем по слотам сегодняшнего дня по порядку
// В любой момент — редактор может одобрить черновик вручную (кнопка в /admin/drafts)
// тогда статья получает ближайший свободный слот сегодня
const { query } = require('./src/config/db');
const { scheduleForArticle } = require('./src/services/articleAutoPublish');
const AUTO_APPROVE_HOUR_MSK = 7;
let lastRunDate = null;
/**
* Возвращает слоты сегодняшнего дня в UTC (из SITE_PUBLISH_SLOTS настройки).
* Слоты заданы в МСК (UTC+3).
*/
async function getTodaySlots() {
const { rows } = await query(
`SELECT value FROM app_settings WHERE key='SITE_PUBLISH_SLOTS' LIMIT 1`
);
const raw = rows[0]?.value || '08:11,12:11,16:11,20:11';
const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
const now = new Date();
// Сегодня в МСК
const mskNow = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const y = mskNow.getUTCFullYear();
const m = mskNow.getUTCMonth();
const d = mskNow.getUTCDate();
return parts.map(p => {
const [h, min] = p.split(':').map(Number);
// Конвертируем МСК → UTC (MSK = UTC+3)
const slot = new Date(Date.UTC(y, m, d, h - 3, min, 0, 0));
return slot;
}).sort((a, b) => a - b);
}
async function runDraftAutoApprove() {
try {
// Берём самые СТАРЫЕ черновики, созданные НЕ сегодня (по МСК).
// Раньше окно было «строго вчера» + LIMIT 4 — поэтому всё, что не одобрили
// на следующий день (дубли, излишки), оставалось в draft навсегда и копилось.
// Теперь дренируем backlog по 4 старейших в день, при этом сегодняшнюю свежую
// партию не трогаем (она станет доступна завтра). В связке с дедупом автогена
// (макс 4/день) это даёт устойчивое состояние без накопления.
const { rows: drafts } = await query(
`SELECT id, title, category, created_at FROM articles
WHERE status='draft'
AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date
ORDER BY created_at ASC
LIMIT 4`
);
if (!drafts.length) {
console.log('[DraftApprove] нет черновиков для авто-одобрения');
return;
}
console.log(`[DraftApprove] авто-одобряем ${drafts.length} черновиков → слоты сегодняшнего дня`);
// Получаем слоты сегодняшнего дня
const todaySlots = await getTodaySlots();
const now = new Date();
// Проверяем какие слоты уже заняты (pending или published сегодня)
const { rows: takenRows } = await query(
`SELECT scheduled_at FROM scheduled_posts
WHERE status IN ('pending','sent','sending')
AND scheduled_at >= NOW()::date
AND scheduled_at < (NOW()::date + INTERVAL '1 day')`,
);
const takenTimes = new Set(takenRows.map(r => new Date(r.scheduled_at).getTime()));
// Ищем свободные слоты (только в будущем и не занятые)
const freeSlots = todaySlots.filter(s => s > now && !takenTimes.has(s.getTime()));
if (freeSlots.length === 0) {
console.log('[DraftApprove] все сегодняшние слоты заняты — черновики остаются');
return;
}
if (drafts.length > freeSlots.length) {
console.log(`[DraftApprove] черновиков ${drafts.length} > свободных слотов ${freeSlots.length} — лишние останутся в draft`);
}
// Раскладываем черновики по свободным слотам (1 к 1, не больше числа слотов)
for (let i = 0; i < Math.min(drafts.length, freeSlots.length); i++) {
const draft = drafts[i];
const slot = freeSlots[i];
await query(
`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] article=${draft.id} "${draft.title.slice(0, 50)}" → ${mskLabel} МСК`);
}
} catch (err) {
console.error('[DraftApprove] error:', err.message);
}
}
function startDraftAutoApproveScheduler() {
console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK, дренаж backlog: 4 старейших/день, не трогая сегодняшние)');
// Catch-up при старте: если сейчас уже после AUTO_APPROVE_HOUR_MSK —
// проверить есть ли вчерашние черновики которые пропустили тик.
(async () => {
const nowMsk = new Date(Date.now() + 3 * 60 * 60 * 1000);
const hourMsk = nowMsk.getUTCHours();
if (hourMsk >= AUTO_APPROVE_HOUR_MSK) {
try {
const { rows: missed } = await query(`
SELECT COUNT(*) AS cnt FROM articles
WHERE status='draft'
AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date
`);
if (parseInt(missed[0]?.cnt, 10) > 0) {
console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)');
await runAutoApprove();
}
} catch (err) { console.error('[DraftApprove] catch-up error:', err.message); }
}
})();
setInterval(() => {
const now = new Date();
const msk = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const hour = msk.getUTCHours();
const minute = msk.getUTCMinutes();
const dateStr = msk.toISOString().slice(0, 10);
if (hour === AUTO_APPROVE_HOUR_MSK && minute === 0 && lastRunDate !== dateStr) {
lastRunDate = dateStr;
console.log(`[DraftApprove] triggered at ${msk.toISOString()}`);
runDraftAutoApprove();
}
}, 60_000);
}
module.exports = { startDraftAutoApproveScheduler, runDraftAutoApprove };