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,140 @@
|
||||
// Генерация 8 новых поз Зеро — расширенный набор.
|
||||
// Запуск: cd /var/www/zeropost-engine && node scripts/generate-zero-poses-v2.js
|
||||
|
||||
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.
|
||||
Style: clean modern vector illustration, flat design with soft shading, no outlines.
|
||||
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald.
|
||||
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 circuits.
|
||||
`.trim();
|
||||
|
||||
const NEW_POSES = [
|
||||
{
|
||||
name: 'swimming',
|
||||
desc: 'Zero is swimming or floating happily in stylized blue water waves. Small stick arms doing a swim stroke, surrounded by abstract blue and teal wave shapes. Big happy smile, eyes bright. Playful aquatic mood.',
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
desc: 'Zero sits quietly, looking thoughtfully into the distance. One small stick arm raised slightly touching chin area in a thinking gesture. Eyes are slightly narrowed and upward-looking. Calm, contemplative expression. A small thought-bubble or single floating dot above.',
|
||||
},
|
||||
{
|
||||
name: 'coffee',
|
||||
desc: 'Zero holds an oversized steaming coffee mug (the mug is almost as big as Zero itself) with both stick arms. Cozy content smile, eyes half-closed in morning satisfaction. Steam rising in decorative curls. Warm amber and cream color accents from the mug.',
|
||||
},
|
||||
{
|
||||
name: 'telescope',
|
||||
desc: 'Zero peers through a small stylized telescope (geometric, teal colored). One eye closed, one looking through the eyepiece. Expression of curiosity and discovery. Stars or geometric star shapes floating in background.',
|
||||
},
|
||||
{
|
||||
name: 'rocket',
|
||||
desc: 'Zero sits on top of or rides a small stylized rocket (geometric, emerald and white colored). Both stick arms raised in excitement. Big grin, eyes wide with excitement. Abstract motion lines and geometric stars around. Launch/deploy energy.',
|
||||
},
|
||||
{
|
||||
name: 'bug',
|
||||
desc: 'Zero stands next to a large stylized bug (geometric beetle shape, in a contrasting color like amber or red). Zero has a detective expression — slightly furrowed brow, focused eyes, one stick arm pointing at the bug. Bug-hunting / debugging moment.',
|
||||
},
|
||||
{
|
||||
name: 'sleep',
|
||||
desc: 'Zero is sleeping peacefully. Eyes closed (shown as two curved lines). Small content smile. Floating ZZZ letters nearby (styled as geometric shapes, not text). A small pillow or star shape underneath. Soft, calm, night-time mood with dark blue accents.',
|
||||
},
|
||||
{
|
||||
name: 'thumbsup',
|
||||
desc: 'Zero gives an enthusiastic thumbs up with one raised stick arm (the thumb is stylized as a geometric shape). Big confident smile, eyes bright and happy. Possibly a small star or sparkle nearby. Approval, recommendation, "great choice" energy.',
|
||||
},
|
||||
];
|
||||
|
||||
const ATTEMPTS = 8;
|
||||
const LOG = '/tmp/zero-poses-v2.log';
|
||||
|
||||
function log(msg) {
|
||||
const line = `[${new Date().toISOString().slice(11,19)}] ${msg}`;
|
||||
fs.appendFileSync(LOG, line + '\n');
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
async function generateOne(prompt, attempt = 1) {
|
||||
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.5';
|
||||
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)`);
|
||||
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,50)})`);
|
||||
return generateOne(prompt, attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBytes(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, '');
|
||||
log(`=== generating ${NEW_POSES.length} new Zero poses ===`);
|
||||
const done = [], failed = [];
|
||||
|
||||
for (const p of NEW_POSES) {
|
||||
const outPath = path.join(UPLOADS_DIR, `zero-${p.name}.webp`);
|
||||
if (fs.existsSync(outPath)) {
|
||||
log(`[${p.name}] already exists, skip`);
|
||||
done.push(p.name);
|
||||
continue;
|
||||
}
|
||||
log(`[${p.name}] starting...`);
|
||||
const t = Date.now();
|
||||
try {
|
||||
const bytes = await generateOne(`${CHARACTER_BASE}\n\nPose: ${p.desc}`);
|
||||
const out = await saveBytes(bytes, p.name);
|
||||
const elapsed = ((Date.now() - t) / 1000).toFixed(0);
|
||||
log(`[${p.name}] ✅ saved (${elapsed}s) → ${out}`);
|
||||
done.push(p.name);
|
||||
} catch (err) {
|
||||
log(`[${p.name}] ❌ FAILED: ${err.message.slice(0, 100)}`);
|
||||
failed.push(p.name);
|
||||
}
|
||||
}
|
||||
|
||||
log(`=== DONE: ${done.length} ok, ${failed.length} failed ===`);
|
||||
if (failed.length) log('Failed: ' + failed.join(', '));
|
||||
process.exit(0);
|
||||
})();
|
||||
Reference in New Issue
Block a user