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:
Nik (Claude)
2026-06-07 14:03:56 +03:00
parent 8968eed3e0
commit a370b8f7d8
33 changed files with 2695 additions and 147 deletions
+96
View File
@@ -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 };