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
+151
View File
@@ -0,0 +1,151 @@
// Генерирует полный набор поз персонажа Зеро.
// Картинки сохраняются в /var/www/zeropost-uploads/zero-{name}.webp (без timestamp — фиксированные имена)
// чтобы engine мог потом выбирать по имени.
//
// Запуск: cd /var/www/zeropost-engine && node scripts/generate-zero-poses.js
// Прогресс пишется в /tmp/zero-poses.log
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
// Базовое описание персонажа — повторяется в каждом промпте.
const CHARACTER_BASE = `
Character: a small, friendly mascot named Zero.
Body: a soft rounded square shape (like a plump pixel or chubby tetris block).
Color: bright emerald green (#10b981) with slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small mouth expressing the current emotion.
Personality: friendly, curious, enthusiastic, a bit nerdy.
Style: clean modern vector illustration, flat design with soft shading, no outlines, consistent with previous Zero illustrations.
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
Composition: 1:1 square format with comfortable padding, character is the main focus.
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
`.trim();
const POSES = [
// Категорийные
{ name: 'tools', desc: 'Zero stands next to a small open toolbox filled with stylized abstract tools (wrench, paintbrush, screwdriver shapes — all geometric). Curious smile, leaning slightly forward. Represents the AI Tools category.' },
{ name: 'lock', desc: 'Zero stands next to a large stylized padlock (closed, in soft red/coral accent color). Serious focused expression, alert eyes. Small shield shape floats nearby. Represents the cybersecurity category.' },
{ name: 'gears', desc: 'Zero next to two large interlocking gears that turn together (gears in soft amber/cream color). Content smile, slight tilt of body, gears form a small mechanical system. Represents the automation category.' },
// Эмоциональные
{ name: 'eureka', desc: 'Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot/lightbulb shape above the head. Eyes wide and sparkly, big enthusiastic smile. Stylized geometric sparkles around.' },
{ name: 'confused', desc: 'Zero scratches the top of its head with one small stick arm. Eyes slightly squinted, mouth a small flat wavy line, tiny question marks floating to the side. Stuck on a problem.' },
{ name: 'facepalm', desc: 'Zero covers part of its face with one small stick arm. Eyes closed or downcast, mouth a flat resigned line. Slightly slumped posture. The moment when something goes wrong.' },
{ name: 'victory', desc: 'Zero jumps with both small stick arms raised triumphantly. Eyes shining bright, huge happy grin. Small geometric celebration confetti or rays around. It worked!' },
{ name: 'tired', desc: 'Zero sits on the floor holding a steaming coffee cup with both stick arms. Slightly droopy eyes, small content but tired smile. Maybe small zZz shape floating above. End of a long debugging session.' },
// Активности
{ name: 'reading', desc: 'Zero sits cross-legged reading an open book (geometric book shape in cream color). Eyes focused on the pages, peaceful happy expression, small reading-glasses shape floating optional. Quiet study moment.' },
{ name: 'magnifier', desc: 'Zero holds up a large magnifying glass with one stick arm, examining something carefully. One eye looks through the magnifier (enlarged through the lens, dot-eye gets bigger). Investigative curious expression.' },
{ name: 'chart', desc: 'Zero stands next to a small bar chart or upward line graph (geometric, emerald/teal bars). One stick arm raised pointing at the rising line. Pleased confident smile. Data-driven moment.' },
{ name: 'meditate', desc: 'Zero sits cross-legged in a meditation pose, eyes peacefully closed (small curved lines instead of dots), small content smile. Subtle aura/circle around it in soft teal. Reflective moment.' },
{ name: 'present', desc: 'Zero stands next to a small whiteboard/easel with simple abstract diagram (lines, dots, geometric shapes — no text or letters). One stick arm pointing at the board with a small pointer stick. Teaching pose, friendly explanatory expression.' },
];
const ATTEMPTS = 5;
const LOG_PATH = '/tmp/zero-poses.log';
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}\n`;
fs.appendFileSync(LOG_PATH, line);
console.log(msg);
}
async function generateOne(prompt, attempt = 1) {
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model, input: wrapped,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
if (attempt < ATTEMPTS) {
log(` retry ${attempt + 1}/${ATTEMPTS} (no image_generation_call)`);
return generateOne(prompt, attempt + 1);
}
throw new Error(`No image after ${ATTEMPTS} attempts`);
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
log(` retry ${attempt + 1}/${ATTEMPTS} (${msg.slice(0, 60)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function processAndSave(bytes, name) {
const outPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
if (sharp) {
await sharp(bytes)
.resize(1024, 1024, { fit: 'cover' })
.webp({ quality: 88 })
.toFile(outPath);
} else {
fs.writeFileSync(outPath, bytes);
}
return outPath;
}
(async () => {
fs.writeFileSync(LOG_PATH, '');
log(`[zero-poses] starting generation of ${POSES.length} poses`);
const startTime = Date.now();
const results = { done: [], failed: [] };
for (const p of POSES) {
// Skip if already exists
const expected = path.join(UPLOADS_DIR, `zero-${p.name}.webp`);
if (fs.existsSync(expected)) {
log(`[${p.name}] already exists, skipping`);
results.done.push({ name: p.name, path: expected, skipped: true });
continue;
}
const tStart = Date.now();
try {
log(`[${p.name}] starting...`);
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
const bytes = await generateOne(prompt);
const out = await processAndSave(bytes, p.name);
const elapsed = ((Date.now() - tStart) / 1000).toFixed(0);
log(`[${p.name}] ✓ saved → ${out} (${elapsed}s)`);
results.done.push({ name: p.name, path: out });
} catch (err) {
const msg = err.response?.data?.error?.message || err.message;
log(`[${p.name}] ✗ FAILED: ${msg}`);
results.failed.push({ name: p.name, error: msg });
}
}
const totalMin = ((Date.now() - startTime) / 60000).toFixed(1);
log(`[zero-poses] DONE in ${totalMin}min. Done: ${results.done.length}/${POSES.length}, failed: ${results.failed.length}`);
fs.writeFileSync('/tmp/zero-poses-result.json', JSON.stringify(results, null, 2));
process.exit(0);
})().catch(e => {
log(`[zero-poses] FATAL: ${e.message}`);
process.exit(1);
});