feat: Зеро-персонаж, auto-publish, auto-series, channel-stats, fallback covers
- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации - Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover - Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр) - Auto-series: Claude haiku определяет серию для каждой статьи автоматически - Channel stats: подписчики, история, delta 24h/7d - Photo-search: Yandex API, профили доменов, Redis лимиты - Scheduled posts runner: backfill, preview, queue, cancel - promptBuilder: author_persona Зеро, голос от первого лица - Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры - AI model: gpt-5.5 для image generation
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Авто-публикация статей в каналы.
|
||||
//
|
||||
// Логика:
|
||||
// 1. При сохранении статьи со status='published' — engine вызывает scheduleForArticle(articleId)
|
||||
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть)
|
||||
// 3. Для каждого канала ищем ближайший подходящий момент:
|
||||
// - если delay_min > 0 → now + delay_min
|
||||
// - иначе — ближайший publish_slot канала в будущем
|
||||
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW)
|
||||
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent)
|
||||
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у
|
||||
|
||||
const { query } = require('../config/db');
|
||||
|
||||
/**
|
||||
* Подобрать ближайший момент публикации для канала.
|
||||
* @returns Date
|
||||
*/
|
||||
async function pickScheduleTime(channel) {
|
||||
const now = new Date();
|
||||
if (channel.auto_publish_delay_min > 0) {
|
||||
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
|
||||
}
|
||||
// Ищем publish_slots
|
||||
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; // публикуем сразу
|
||||
}
|
||||
|
||||
// Сегодня — ближайший слот с временем > now
|
||||
const todayMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const futureToday = slots.find(s => s.slot_hour * 60 + s.slot_minute > todayMinutes);
|
||||
if (futureToday) {
|
||||
const t = new Date(now);
|
||||
t.setHours(futureToday.slot_hour, futureToday.slot_minute, 0, 0);
|
||||
return t;
|
||||
}
|
||||
// Все слоты на сегодня прошли — берём первый завтрашний
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(slots[0].slot_hour, slots[0].slot_minute, 0, 0);
|
||||
return tomorrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поставить статью на авто-публикацию во все подходящие каналы.
|
||||
* Идемпотентно: дубли в pending/sent не создаются.
|
||||
* @returns массив созданных scheduled_posts
|
||||
*/
|
||||
async function scheduleForArticle(articleId) {
|
||||
const { rows: arts } = await query(
|
||||
`SELECT id, slug, title, category, status FROM articles WHERE id=$1`,
|
||||
[articleId]
|
||||
);
|
||||
if (!arts.length || arts[0].status !== 'published') return [];
|
||||
const article = arts[0];
|
||||
|
||||
const { rows: channels } = await query(
|
||||
`SELECT * FROM channels
|
||||
WHERE is_system=true
|
||||
AND is_active=true
|
||||
AND auto_publish_enabled=true
|
||||
AND (cardinality(auto_publish_categories) = 0
|
||||
OR $1 = ANY(auto_publish_categories))`,
|
||||
[article.category]
|
||||
);
|
||||
|
||||
const created = [];
|
||||
for (const ch of channels) {
|
||||
// Дедуп
|
||||
const { rows: existing } = await query(
|
||||
`SELECT id FROM scheduled_posts
|
||||
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent')
|
||||
LIMIT 1`,
|
||||
[ch.id, article.id]
|
||||
);
|
||||
if (existing.length) continue;
|
||||
|
||||
const scheduledAt = await pickScheduleTime(ch);
|
||||
const { rows: inserted } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
|
||||
VALUES ($1,$2,$3,'pending') RETURNING *`,
|
||||
[ch.id, article.id, scheduledAt]
|
||||
);
|
||||
created.push(inserted[0]);
|
||||
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()}`);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
module.exports = { scheduleForArticle, pickScheduleTime };
|
||||
Reference in New Issue
Block a user