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
+52 -12
View File
@@ -3,6 +3,7 @@ const path = require('path');
const axios = require('axios');
const config = require('../config');
const { query } = require('../config/db');
const localGen = require('./localCoverGenerator');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -159,11 +160,34 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
throw new Error('No image data');
}
/**
* Резервный путь — Pollinations.AI (https://pollinations.ai).
* 100% бесплатно, без API ключа, без регистрации.
* GET запрос → JPEG обложка за ~1-2 секунды.
* Используется только когда aiprimetech.io недоступен.
*/
async function generateCoverViaPollinations({ prompt }) {
// Pollinations: простой GET по URL, сразу возвращает бинарный JPEG
const encoded = encodeURIComponent(prompt.slice(0, 1000)); // лимит на длину URL
const url = `https://image.pollinations.ai/prompt/${encoded}?width=1600&height=900&model=flux&nologo=true`;
const res = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 90_000, // Pollinations иногда медленный при нагрузке
headers: { 'User-Agent': 'ZeroPost/1.0 blog-cover-generator' },
});
if (!res.data || res.data.byteLength < 5000) {
throw new Error(`Pollinations returned too small response: ${res.data?.byteLength} bytes`);
}
return {
bytes: Buffer.from(res.data),
format: 'jpg',
};
}
/**
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/
async function generateCover({ articleId, title, tags = [] }) {
// Передаём articleId в buildCoverPrompt для детерминированного выбора стиля
const prompt = buildCoverPrompt({ title, tags, articleId });
const styleIdx = pickStyleIndex(articleId);
const styleName = COVER_STYLES[styleIdx].name;
@@ -171,20 +195,36 @@ async function generateCover({ articleId, title, tags = [] }) {
let img;
let usedPath = 'responses';
// Пробуем все внешние API, при любой ошибке — сразу local SVG
try {
img = await generateCoverViaResponses({ prompt });
} catch (err) {
const msg = err.response?.data?.error?.message || err.message;
console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
// Пробуем legacy
try {
img = await generateCoverViaImagesEndpoint({ prompt });
usedPath = 'images-legacy';
} catch (err2) {
const msg2 = err2.response?.data?.error?.message || err2.message;
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
throw new Error(`Both image paths failed: ${msg}`);
img = await generateCoverViaResponses({ prompt });
} catch (err) {
const msg = err.response?.data?.error?.message || err.message;
console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
try {
img = await generateCoverViaImagesEndpoint({ prompt });
usedPath = 'images-legacy';
} catch (err2) {
const msg2 = err2.response?.data?.error?.message || err2.message;
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
try {
img = await generateCoverViaPollinations({ prompt });
usedPath = 'pollinations';
console.log(`[Cover] article=${articleId} using Pollinations.AI fallback`);
} catch (err3) {
console.warn(`[Cover] Pollinations fallback failed: ${err3.message.slice(0, 200)}`);
throw new Error('all_external_failed');
}
}
}
} catch (outerErr) {
// Все внешние API упали — используем локальную SVG-генерацию
console.log(`[Cover] article=${articleId} → local SVG generator (all external APIs unavailable)`);
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
return localUrl;
}
// Сохраняем оригинал