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
+3
View File
@@ -1,3 +1,6 @@
node_modules/ node_modules/
.env .env
*.log *.log
.env.bak
*.bak
deploy.sh
+8
View File
@@ -13,6 +13,10 @@ const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories'); const categoriesRoutes = require('./src/routes/categories');
const autogenRoutes = require('./src/routes/autogen'); const autogenRoutes = require('./src/routes/autogen');
const userPostsRoutes = require('./src/routes/userPosts'); const userPostsRoutes = require('./src/routes/userPosts');
const settingsRoutes = require('./src/routes/settings');
const photoSearchRoutes = require('./src/routes/photo-search');
const scheduledPostsRoutes = require('./src/routes/scheduledPosts');
const channelStatsRoutes = require('./src/routes/channelStats');
// Start queue worker // Start queue worker
require('./src/workers/generation'); require('./src/workers/generation');
@@ -48,6 +52,10 @@ app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes); app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes); app.use('/api/autogen', autogenRoutes);
app.use('/api/user-posts', userPostsRoutes); app.use('/api/user-posts', userPostsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/photo-search', photoSearchRoutes);
app.use('/api/scheduled-posts', scheduledPostsRoutes);
app.use('/api/channel-stats', channelStatsRoutes);
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
+31
View File
@@ -13,6 +13,7 @@
"bull": "^4.16.5", "bull": "^4.16.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0", "ioredis": "^5.11.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",
@@ -971,6 +972,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-xml-parser": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1895,6 +1914,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"bull": "^4.16.5", "bull": "^4.16.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0", "ioredis": "^5.11.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",
+140
View File
@@ -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);
})();
+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);
});
+37
View File
@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const settings = require('/var/www/zeropost-engine/src/services/settings');
const { query } = require('/var/www/zeropost-engine/src/config/db');
(async () => {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const files = [
'/var/www/zeropost-uploads/zero-avatar-1780332236389.webp',
'/var/www/zeropost-uploads/zero-laptop-1780332301106.webp',
];
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
const media = files.map((_, i) => ({
type: 'photo',
media: `attach://photo${i}`,
caption: i === 0 ? `🧪 Тест Зеро — 2 ракурса:\n1. аватар (анфас)\n2. за ноутбуком\n\n(третий не пошёл, шлюз поломался)\n\nКак тебе персонаж? Стоит дорабатывать или искать другой образ?` : undefined,
}));
form.append('media', JSON.stringify(media));
files.forEach((f, i) => form.append(`photo${i}`, fs.createReadStream(f)));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
headers: form.getHeaders(),
timeout: 60000, maxContentLength: Infinity, maxBodyLength: Infinity,
});
const ids = (res.data?.result || []).map(m => m.message_id);
console.log('sent ids:', ids.join(','));
})().catch(e => console.error('FAIL:', e.response?.data || e.message));
+143
View File
@@ -0,0 +1,143 @@
// Одноразовый скрипт: генерирует обложку для welcome-поста и отправляет в TG.
// Запуск: node scripts/send-welcome-post.js
//
// Шлём файл через multipart напрямую — иначе TG прокси (CF Worker) не вытягивает
// файлы с zeropost.ru и валится с "Bad Request: failed to get HTTP URL content".
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
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 settings = require(path.join(ROOT, 'src/services/settings'));
const { query } = require(path.join(ROOT, 'src/config/db'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
const WELCOME_PROMPT = `Abstract editorial cover illustration for a technology blog about AI, cybersecurity, automation and development.
Style: flat geometric shapes, smooth flowing curves, layered planes, vector-clean lines. Modern editorial illustration.
Color palette: emerald green (#10b981, #34d399) as primary brand color, soft teal accents, warm off-white background (#fafaf9), subtle dark navy accents. Sophisticated and clean.
Mood: clean, modern, calm, intellectual, welcoming — Stripe Press / Linear / Anthropic brand aesthetic.
Composition: balanced asymmetry with four implied zones representing four content pillars, flowing diagonal forms, generous negative space, sense of intellectual depth and curiosity. Wide 16:9 format.
Strictly: no text, no letters, no logos, no human faces, no robots, no brains, no glowing nodes, no circuit boards, no clocks, no screens.`;
async function generateCover() {
console.log('[welcome] generating cover via /v1/responses + image_generation...');
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${WELCOME_PROMPT}`;
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
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) {
throw new Error('No image returned from /responses');
}
const bytes = Buffer.from(imgCall.result, 'base64');
console.log(`[welcome] got ${(bytes.length / 1024).toFixed(0)}KB image`);
const ts = Date.now();
const ext = imgCall.output_format || 'png';
const origName = `welcome-${ts}.${ext}`;
const origPath = path.join(UPLOADS_DIR, origName);
fs.writeFileSync(origPath, bytes);
let finalLocal = origPath;
if (sharp) {
try {
const webpName = `welcome-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
await sharp(bytes)
.resize(1600, null, { withoutEnlargement: true })
.webp({ quality: 86 })
.toFile(webpPath);
const size = fs.statSync(webpPath).size;
console.log(`[welcome] optimized → ${webpName} (${(size / 1024).toFixed(0)}KB)`);
finalLocal = webpPath;
} catch (e) {
console.warn(`[welcome] sharp skipped: ${e.message}`);
}
}
return { localPath: finalLocal };
}
const WELCOME_TEXT = `👋 Это *ZeroPost* — блог про ИИ, кибербезопасность, автоматизацию и разработку.
Эксперимент: контент пишет ИИ, а человек только держит курс. Получается технологический блог без воды и хайпа — разборы инструментов, рабочие промпты, реальные кейсы.
В канале — анонсы новых материалов с сайта. Каждая заметка отрабатывает один практический вопрос: «как сделать X в инструменте Y и не пожалеть».
🌐 Все материалы: [zeropost.ru](https://zeropost.ru)
📌 Закрепите этот пост, чтобы не потерять`;
async function sendToTelegram(localPath) {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
if (!chs.length) throw new Error('Channel 1 not found');
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const reply_markup = {
inline_keyboard: [[{ text: '🌐 Открыть сайт', url: 'https://zeropost.ru' }]],
};
console.log(`[welcome] uploading ${localPath} to TG via multipart...`);
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', WELCOME_TEXT.slice(0, 1024));
form.append('parse_mode', 'Markdown');
form.append('reply_markup', JSON.stringify(reply_markup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const messageId = res.data?.result?.message_id;
console.log(`[welcome] sent, message_id=${messageId}`);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, WELCOME_TEXT, messageId]
);
return messageId;
}
(async () => {
try {
const { localPath } = await generateCover();
console.log(`[welcome] cover ready at ${localPath}`);
const messageId = await sendToTelegram(localPath);
console.log(`[welcome] DONE. TG message_id=${messageId}. Длина caption=${WELCOME_TEXT.length}`);
process.exit(0);
} catch (err) {
console.error('[welcome] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();
+63
View File
@@ -0,0 +1,63 @@
// Welcome-пост от имени Зеро.
// Использует готовый zero-avatar.webp.
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const settings = require('/var/www/zeropost-engine/src/services/settings');
const { query } = require('/var/www/zeropost-engine/src/config/db');
const TEXT = `Привет! Я Зеро 👋
Веду этот канал — пишу про ИИ, кибербезопасность, автоматизацию и разработку. Каждый день — короткая заметка про что-то, что я попробовал.
Что внутри:
🤖 ИИ-инструменты, которые реально работают
💻 Разработка с ИИ-помощниками
⚡ Автоматизация без боли
🔒 Безопасность для обычных людей
Без хайпа, без «революционных открытий», без картинок с роботами. Только то, что заходит в работе.
🌐 Полная версия каждой заметки — на сайте
📌 Закрепи этот пост, чтобы не потерять`;
(async () => {
try {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const localPath = '/var/www/zeropost-uploads/zero-avatar.webp';
if (!fs.existsSync(localPath)) throw new Error('zero-avatar.webp not found');
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', TEXT.slice(0, 1024));
form.append('parse_mode', 'Markdown');
form.append('reply_markup', JSON.stringify({
inline_keyboard: [[{ text: '🌐 Открыть сайт', url: 'https://zeropost.ru' }]],
}));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000, maxContentLength: Infinity, maxBodyLength: Infinity,
});
const messageId = res.data?.result?.message_id;
console.log(`sent message_id=${messageId}, длина caption=${TEXT.length}`);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, TEXT, messageId]
);
} catch (err) {
console.error('FAIL:', err.response?.data || err.message);
process.exit(1);
}
})();
+28
View File
@@ -0,0 +1,28 @@
const path = require('path');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const axios = require('axios');
const config = require('/var/www/zeropost-engine/src/config');
(async () => {
const prompt = `A small friendly emerald green geometric mascot character, soft rounded square shape, two simple black dot eyes, small smile, flat vector illustration style, warm off-white background. No text.`;
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: process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2',
input: wrapped,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{ headers: { Authorization: `Bearer ${config.ai.imageApiKey}` }, timeout: 300_000 }
);
console.log('output types:', (res.data?.output || []).map(o => o.type));
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
if (imgCall) console.log('status:', imgCall.status, 'has result:', !!imgCall.result);
else console.log('full:', JSON.stringify(res.data).slice(0, 500));
} catch (e) {
console.error('FAIL:', e.response?.data || e.message);
}
})();
+147
View File
@@ -0,0 +1,147 @@
// Тест: генерируем 3 ракурса Зеро. Каждая картинка — до 5 попыток (GPT-5.2 капризничает с tool_choice).
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
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 settings = require(path.join(ROOT, 'src/services/settings'));
const { query } = require(path.join(ROOT, 'src/config/db'));
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 a chubby tetris block), about the size of a small companion creature.
Color: bright emerald green (#10b981) with a slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small understated mouth that expresses the current emotion.
Personality: friendly, curious, enthusiastic, a bit nerdy.
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, 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: 'avatar',
desc: `Front-facing portrait pose. Zero is looking directly at the viewer with curious wide-open eyes and a small confident smile. Sits centered in the frame. Used as channel avatar.`,
},
{
name: 'laptop',
desc: `Zero sits at a tiny laptop (the laptop is also stylized geometric, in cream/emerald colors). Eyes focused on the screen, small thoughtful expression. Cozy setup with a small plant nearby. The mascot is enthusiastic about coding.`,
},
{
name: 'eureka',
desc: `Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot above the head representing an idea. Eyes wide and sparkly, big enthusiastic smile. Confetti or stylized geometric sparkles around.`,
},
];
async function generateOne(prompt, attempt = 1) {
const MAX_ATTEMPTS = 5;
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 < MAX_ATTEMPTS) {
console.log(`[zero] retry attempt=${attempt + 1} (no image_generation_call)`);
return generateOne(prompt, attempt + 1);
}
throw new Error(`No image after ${MAX_ATTEMPTS} attempts`);
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < MAX_ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
console.log(`[zero] retry attempt=${attempt + 1} (${msg.slice(0, 60)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function processBytes(bytes, name) {
const ts = Date.now();
const webpName = `zero-${name}-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
if (sharp) {
await sharp(bytes)
.resize(1024, 1024, { fit: 'cover' })
.webp({ quality: 88 })
.toFile(webpPath);
} else {
fs.writeFileSync(webpPath, bytes);
}
return webpPath;
}
(async () => {
try {
console.log('[zero] generating 3 poses sequentially (with retries)...');
const results = [];
for (const p of POSES) {
console.log(`[zero] starting ${p.name}...`);
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
const bytes = await generateOne(prompt);
const localPath = await processBytes(bytes, p.name);
console.log(`[zero] ${p.name}${localPath}`);
results.push({ name: p.name, path: localPath });
}
// Шлём все 3 в TG как media group с подписью
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
const media = results.map((r, i) => ({
type: 'photo',
media: `attach://photo${i}`,
caption: i === 0
? `🧪 Тест Зеро — три позы:\n1. аватар 2. за ноутом 3. эврика`
: undefined,
}));
form.append('media', JSON.stringify(media));
results.forEach((r, i) => {
form.append(`photo${i}`, fs.createReadStream(r.path));
});
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const msgIds = (res.data?.result || []).map(m => m.message_id);
console.log(`[zero] sent media group, message_ids=${msgIds.join(',')}`);
console.log('[zero] DONE');
process.exit(0);
} catch (err) {
console.error('[zero] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();
+94 -2
View File
@@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const articlesSvc = require('../services/articles'); const articlesSvc = require('../services/articles');
const autoPublish = require('../services/articleAutoPublish');
const autoSeries = require('../services/articleAutoSeries');
const { query } = require('../config/db'); const { query } = require('../config/db');
// GET /api/articles — список опубликованных // GET /api/articles — список опубликованных
@@ -23,6 +25,15 @@ router.get('/tags', async (_, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// GET /api/articles/home — данные для главной страницы (hero, byCategory, popular, recent)
router.get('/home', async (req, res) => {
try {
const data = await articlesSvc.getHomeArticles();
res.json(data);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/admin — все статьи для админки (включая черновики) // GET /api/articles/admin — все статьи для админки (включая черновики)
router.get('/admin', async (req, res) => { router.get('/admin', async (req, res) => {
try { try {
@@ -38,6 +49,61 @@ router.get('/admin', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// GET /api/articles/admin/search — typeahead-поиск по статьям.
// Параметры: q (подстрока в title), status (default published), category, limit (default 20),
// channel_id (если задан — пометит already_in_channel, was_published_in_channel)
router.get('/admin/search', async (req, res) => {
try {
const q = (req.query.q || '').trim();
const status = req.query.status || 'published';
const category = req.query.category || null;
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null;
const params = [];
let where = [];
if (status && status !== 'any') { params.push(status); where.push(`status=$${params.length}`); }
if (category) { params.push(category); where.push(`category=$${params.length}`); }
if (q) { params.push(`%${q.toLowerCase()}%`); where.push(`lower(title) LIKE $${params.length}`); }
params.push(limit);
const sql = `
SELECT id, slug, title, excerpt, cover_url, category, status, published_at
FROM articles
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY published_at DESC NULLS LAST, created_at DESC
LIMIT $${params.length}`;
const { rows: items } = await query(sql, params);
// Если задан channel_id — для каждого item ищем, был ли уже опубликован в этом канале (через scheduled_posts.status='sent')
if (channelId && items.length) {
const ids = items.map(a => a.id);
const { rows: sent } = await query(
`SELECT article_id, MAX(published_at) AS last_sent_at
FROM scheduled_posts
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='sent'
GROUP BY article_id`,
[channelId, ids]
);
const sentMap = Object.fromEntries(sent.map(r => [r.article_id, r.last_sent_at]));
const { rows: pending } = await query(
`SELECT article_id, MIN(scheduled_at) AS next_scheduled_at
FROM scheduled_posts
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='pending'
GROUP BY article_id`,
[channelId, ids]
);
const pendingMap = Object.fromEntries(pending.map(r => [r.article_id, r.next_scheduled_at]));
for (const it of items) {
it.was_sent_to_channel = sentMap[it.id] || null;
it.next_scheduled_at = pendingMap[it.id] || null;
}
}
res.json({ items, count: items.length });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/id/:id — одна статья по числовому id // GET /api/articles/id/:id — одна статья по числовому id
router.get('/id/:id', async (req, res) => { router.get('/id/:id', async (req, res) => {
try { try {
@@ -50,9 +116,18 @@ router.get('/id/:id', async (req, res) => {
// POST /api/articles/generate // POST /api/articles/generate
router.post('/generate', async (req, res) => { router.post('/generate', async (req, res) => {
try { try {
const { topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools' } = req.body; const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools' } = req.body;
if (!topic) return res.status(400).json({ error: 'topic is required' }); if (!topic) return res.status(400).json({ error: 'topic is required' });
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish, category }); const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category });
// Hook: автопубликация в каналы
if (article && article.status === 'published') {
autoPublish.scheduleForArticle(article.id).catch(err => {
console.error('[articles] auto-publish hook failed:', err.message);
});
autoSeries.addToSeries(article.id).catch(err => {
console.error('[articles] auto-series hook failed:', err.message);
});
}
res.json(article); res.json(article);
} catch (err) { } catch (err) {
console.error('[Articles] generate', err); console.error('[Articles] generate', err);
@@ -84,11 +159,28 @@ router.patch('/:id', async (req, res) => {
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
fields.push(`updated_at=NOW()`); fields.push(`updated_at=NOW()`);
vals.push(req.params.id); vals.push(req.params.id);
// Сначала проверим прежний status — чтобы понимать, был ли переход draft → published
const { rows: prevRows } = await query(`SELECT status FROM articles WHERE id=$1`, [req.params.id]);
const prevStatus = prevRows[0]?.status;
const { rows } = await query( const { rows } = await query(
`UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`, `UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`,
vals vals
); );
if (!rows.length) return res.status(404).json({ error: 'Not found' }); if (!rows.length) return res.status(404).json({ error: 'Not found' });
// Hook: если статья только что стала published
const newStatus = rows[0].status;
if (newStatus === 'published' && prevStatus !== 'published') {
autoPublish.scheduleForArticle(rows[0].id).catch(err => {
console.error('[articles] auto-publish hook failed:', err.message);
});
autoSeries.addToSeries(rows[0].id).catch(err => {
console.error('[articles] auto-series hook failed:', err.message);
});
}
res.json(rows[0]); res.json(rows[0]);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
+5 -5
View File
@@ -28,13 +28,13 @@ router.patch('/settings/:category', async (req, res) => {
try { try {
const { enabled, per_day, run_hour, run_minute } = req.body; const { enabled, per_day, run_hour, run_minute } = req.body;
const fields = []; const vals = []; let i = 1; const fields = []; const vals = []; let i = 1;
if (enabled !== undefined) { fields.push(`enabled=${i++}`); vals.push(enabled); } if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
if (per_day !== undefined) { fields.push(`per_day=${i++}`); vals.push(per_day); } if (per_day !== undefined) { fields.push(`per_day=$${i++}`); vals.push(per_day); }
if (run_hour !== undefined) { fields.push(`run_hour=${i++}`); vals.push(run_hour); } if (run_hour !== undefined) { fields.push(`run_hour=$${i++}`); vals.push(run_hour); }
if (run_minute !== undefined) { fields.push(`run_minute=${i++}`); vals.push(run_minute); } if (run_minute !== undefined) { fields.push(`run_minute=$${i++}`); vals.push(run_minute); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.category); vals.push(req.params.category);
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=${i}`, vals); await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=$${i}`, vals);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
+31
View File
@@ -0,0 +1,31 @@
const express = require('express');
const router = express.Router();
const stats = require('../services/channelStats');
const { query } = require('../config/db');
// POST /api/channel-stats/collect — собрать статистику (cron, раз в час)
router.post('/collect', async (req, res) => {
try {
const results = await stats.collectAll();
res.json({ ok: true, results });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/channel-stats/:channelId/summary — сводка по каналу
router.get('/:channelId/summary', async (req, res) => {
try {
const summary = await stats.getChannelSummary(parseInt(req.params.channelId));
res.json(summary);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/channel-stats/:channelId/history?days=30 — история подписчиков
router.get('/:channelId/history', async (req, res) => {
try {
const days = Math.min(parseInt(req.query.days) || 30, 365);
const history = await stats.getMembersHistory(parseInt(req.params.channelId), days);
res.json(history);
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;
+104 -102
View File
@@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const channelsSvc = require('../services/channels'); const channelsSvc = require('../services/channels');
const settings = require('../services/settings');
const autoPublish = require('../services/articleAutoPublish');
const { query } = require('../config/db'); const { query } = require('../config/db');
const getUserId = (req) => { const getUserId = (req) => {
@@ -50,7 +52,6 @@ router.post('/admin', async (req, res) => {
vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null, vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null,
niche||null, audience||null, goal] niche||null, audience||null, goal]
); );
// Создаём style и schedule по умолчанию
await query(`INSERT INTO channel_style (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]); await query(`INSERT INTO channel_style (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
await query(`INSERT INTO channel_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]); await query(`INSERT INTO channel_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
res.json(rows[0]); res.json(rows[0]);
@@ -60,13 +61,18 @@ router.post('/admin', async (req, res) => {
// PATCH /api/channels/admin/:id — обновить системный канал // PATCH /api/channels/admin/:id — обновить системный канал
router.patch('/admin/:id', async (req, res) => { router.patch('/admin/:id', async (req, res) => {
try { try {
const allowed = ['name','platform','tg_channel_id','tg_username','bot_token', const allowed = [
'name','platform','tg_channel_id','tg_username','bot_token',
'vk_group_id','vk_access_token','max_channel_id','max_access_token', 'vk_group_id','vk_access_token','max_channel_id','max_access_token',
'niche','audience','goal','is_active']; 'niche','audience','goal','is_active',
// autopublish
'auto_publish_enabled','auto_publish_categories','auto_publish_delay_min',
'auto_publish_template','auto_publish_with_cover','auto_publish_button_text','auto_publish_image_source',
];
const fields = []; const vals = []; let i = 1; const fields = []; const vals = []; let i = 1;
for (const key of allowed) { for (const key of allowed) {
if (req.body[key] !== undefined) { if (req.body[key] !== undefined) {
fields.push(`${key}=${i++}`); fields.push(`${key}=$${i++}`);
vals.push(req.body[key]); vals.push(req.body[key]);
} }
} }
@@ -74,7 +80,7 @@ router.patch('/admin/:id', async (req, res) => {
fields.push(`updated_at=NOW()`); fields.push(`updated_at=NOW()`);
vals.push(req.params.id); vals.push(req.params.id);
const { rows } = await query( const { rows } = await query(
`UPDATE channels SET ${fields.join(',')} WHERE id=${i} AND is_system=true RETURNING *`, `UPDATE channels SET ${fields.join(',')} WHERE id=$${i} AND is_system=true RETURNING *`,
vals vals
); );
if (!rows.length) return res.status(404).json({ error: 'Not found' }); if (!rows.length) return res.status(404).json({ error: 'Not found' });
@@ -90,45 +96,40 @@ router.delete('/admin/:id', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// POST /api/channels/admin/:id/publish — опубликовать статью в канал // POST /api/channels/admin/:id/publish — опубликовать статью в канал ПРЯМО СЕЙЧАС
// Использует channel.auto_publish_template (если есть) и channel.auto_publish_with_cover.
router.post('/admin/:id/publish', async (req, res) => { router.post('/admin/:id/publish', async (req, res) => {
try { try {
const { article_id, custom_text } = req.body; const { article_id, custom_text, with_cover } = req.body;
const { rows } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]); const { rows } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Channel not found' }); if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
const channel = rows[0]; const channel = rows[0];
let text = custom_text; // Создаём временный scheduled_post на NOW и сразу запускаем runner на него.
// Если текст не передан — берём статью и генерируем пост const { rows: spRows } = await query(
if (!text && article_id) { `INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at, status)
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]); VALUES ($1,$2,$3,NOW(),'pending') RETURNING *`,
if (!arts.length) return res.status(404).json({ error: 'Article not found' }); [channel.id, article_id || null, custom_text || null]
const art = arts[0]; );
// Простой текст поста из заголовка и excerpt const sp = spRows[0];
text = `*${art.title}*\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`;
}
if (!text) return res.status(400).json({ error: 'text or article_id required' });
const result = { ok: true, platform: channel.platform, text }; // Точечный запуск
const runner = require('../services/scheduledPostsRunner');
// Telegram try {
if (channel.platform === 'telegram' && channel.bot_token && channel.tg_channel_id) { const { messageId } = await runner.publishOne(sp);
const axios = require('axios');
const tgRes = await axios.post(
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`,
{ chat_id: channel.tg_channel_id, text, parse_mode: 'Markdown', disable_web_page_preview: false },
{ timeout: 15000 }
);
result.tg_message_id = tgRes.data?.result?.message_id;
// Сохраняем пост
await query( await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id) `UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
VALUES ($1,$2,'published',NOW(),$3)`, [sp.id]
[channel.id, text, result.tg_message_id || null]
); );
return res.json({ ok: true, platform: channel.platform, tg_message_id: messageId || null, scheduled_post_id: sp.id });
} catch (err) {
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
await query(
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
[String(msg).slice(0, 1000), sp.id]
);
return res.status(500).json({ error: msg });
} }
res.json(result);
} catch (err) { } catch (err) {
const msg = err.response?.data?.description || err.message; const msg = err.response?.data?.description || err.message;
res.status(500).json({ error: msg }); res.status(500).json({ error: msg });
@@ -146,70 +147,6 @@ router.get('/admin/:id/posts', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
router.get('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channels = await channelsSvc.listChannels(userId);
res.json(channels);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/channels/:id — один канал со всеми настройками
router.get('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/channels — создать канал
router.post('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.createChannel(userId, req.body);
res.json(channel);
} catch (err) {
console.error('[Route] POST /channels', err);
res.status(500).json({ error: err.message });
}
});
// PATCH /api/channels/:id — обновить
router.patch('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/channels/:id
router.delete('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
await channelsSvc.deleteChannel(req.params.id, userId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
// ── Publish slots ───────────────────────────────────────────────────────────── // ── Publish slots ─────────────────────────────────────────────────────────────
// GET /api/channels/admin/:id/slots // GET /api/channels/admin/:id/slots
@@ -251,11 +188,11 @@ router.delete('/admin/:id/slots/:slotId', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// GET /api/channels/admin/:id/scheduled — запланированные посты // GET /api/channels/admin/:id/scheduled — запланированные посты канала (pending+failed+sent последние)
router.get('/admin/:id/scheduled', async (req, res) => { router.get('/admin/:id/scheduled', async (req, res) => {
try { try {
const { rows } = await query( const { rows } = await query(
`SELECT sp.*, a.title as article_title, a.slug as article_slug `SELECT sp.*, a.title as article_title, a.slug as article_slug, a.category as article_category
FROM scheduled_posts sp FROM scheduled_posts sp
LEFT JOIN articles a ON a.id = sp.article_id LEFT JOIN articles a ON a.id = sp.article_id
WHERE sp.channel_id=$1 WHERE sp.channel_id=$1
@@ -267,15 +204,80 @@ router.get('/admin/:id/scheduled', async (req, res) => {
}); });
// POST /api/channels/admin/:id/schedule — поставить пост в очередь // POST /api/channels/admin/:id/schedule — поставить пост в очередь
// scheduled_at: если не передан — берём ближайший слот канала (через autoPublish.pickScheduleTime).
router.post('/admin/:id/schedule', async (req, res) => { router.post('/admin/:id/schedule', async (req, res) => {
try { try {
const { article_id, custom_text, scheduled_at } = req.body; const { article_id, custom_text, scheduled_at } = req.body;
if (!scheduled_at) return res.status(400).json({ error: 'scheduled_at required' }); const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
const when = scheduled_at ? new Date(scheduled_at) : await autoPublish.pickScheduleTime(chs[0]);
const { rows } = await query( const { rows } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at) `INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at)
VALUES ($1,$2,$3,$4) RETURNING *`, VALUES ($1,$2,$3,$4) RETURNING *`,
[req.params.id, article_id || null, custom_text || null, scheduled_at] [req.params.id, article_id || null, custom_text || null, when]
); );
res.json(rows[0]); res.json(rows[0]);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// ── User routes (НЕ системные, для tool) ──────────────────────────────────────
router.get('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channels = await channelsSvc.listChannels(userId);
res.json(channels);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.createChannel(userId, req.body);
res.json(channel);
} catch (err) {
console.error('[Route] POST /channels', err);
res.status(500).json({ error: err.message });
}
});
router.patch('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
await channelsSvc.deleteChannel(req.params.id, userId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+51
View File
@@ -0,0 +1,51 @@
const express = require('express');
const router = express.Router();
const photoSearch = require('../services/photo-search');
// GET /api/photo-search/quota
router.get('/quota', async (req, res) => {
try {
const data = await photoSearch.getQuotaStatus();
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/photo-search/by-query
// Body: { query: string, profile?: 'sports'|'general'|..., num?: 1..20 }
router.post('/by-query', async (req, res) => {
try {
const { query, profile, num } = req.body || {};
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'query (string) is required' });
}
const result = await photoSearch.searchByQuery({
query: query.trim(),
profileSlug: profile || 'general',
num: Math.min(Math.max(parseInt(num) || 6, 1), 20),
});
res.json(result);
} catch (err) {
if (err.code === 'DAILY_LIMIT_EXCEEDED') {
return res.status(429).json({ error: err.message, code: err.code });
}
console.error('[photo-search] by-query failed:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/photo-search/profiles — список профилей (для UI селектора)
router.get('/profiles', async (req, res) => {
try {
const { query } = require('../config/db');
const { rows } = await query(
'SELECT id, slug, name, description, domains FROM photo_search_profiles ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+2 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { query } = require('../config/db'); const { query } = require('../config/db');
const axios = require('axios'); const axios = require('axios');
const settings = require('../services/settings');
// POST /api/posts/publish - publish a post to Telegram immediately // POST /api/posts/publish - publish a post to Telegram immediately
router.post('/publish', async (req, res) => { router.post('/publish', async (req, res) => {
@@ -18,7 +19,7 @@ router.post('/publish', async (req, res) => {
const ch = rows[0]; const ch = rows[0];
if (!ch.bot_token || !ch.tg_channel_id) return res.status(400).json({ error: 'Channel has no bot_token or tg_channel_id' }); if (!ch.bot_token || !ch.tg_channel_id) return res.status(400).json({ error: 'Channel has no bot_token or tg_channel_id' });
const tgRes = await axios.post(`https://api.telegram.org/bot${ch.bot_token}/sendMessage`, { const tgRes = await axios.post(`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${ch.bot_token}/sendMessage`, {
chat_id: ch.tg_channel_id, chat_id: ch.tg_channel_id,
text: content, text: content,
parse_mode: 'HTML', parse_mode: 'HTML',
+135
View File
@@ -0,0 +1,135 @@
const express = require('express');
const router = express.Router();
const runner = require('../services/scheduledPostsRunner');
const autoPublish = require('../services/articleAutoPublish');
const { query } = require('../config/db');
// POST /api/scheduled-posts/run-scheduled — обработать очередь (cron)
router.post('/run-scheduled', async (req, res) => {
try {
const result = await runner.runScheduled();
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/preview — пред-просмотр текста по шаблону
router.post('/preview', async (req, res) => {
try {
const { article_id, template } = req.body;
if (!article_id) return res.status(400).json({ error: 'article_id required' });
const { rows } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
if (!rows.length) return res.status(404).json({ error: 'Article not found' });
const text = runner.renderTemplate(template, rows[0]);
res.json({
text,
cover_url: rows[0].cover_url || null,
length: text.length,
caption_safe: text.length <= 1024,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/schedule-article/:articleId — вручную поставить уже опубликованную статью в очередь
router.post('/schedule-article/:articleId', async (req, res) => {
try {
const created = await autoPublish.scheduleForArticle(req.params.articleId);
res.json({ ok: true, scheduled: created.length, items: created });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/scheduled-posts/queue — общая очередь по всем каналам
router.get('/queue', async (req, res) => {
try {
const { rows } = await query(
`SELECT sp.*, c.name AS channel_name, c.platform,
a.title AS article_title, a.slug AS article_slug, a.category
FROM scheduled_posts sp
JOIN channels c ON c.id = sp.channel_id
LEFT JOIN articles a ON a.id = sp.article_id
WHERE sp.status IN ('pending','failed')
ORDER BY sp.scheduled_at ASC LIMIT 100`
);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/scheduled-posts/:id — отменить запланированный пост
router.delete('/:id', async (req, res) => {
try {
const { rowCount } = await query(
`DELETE FROM scheduled_posts WHERE id=$1 AND status='pending'`,
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: 'Not found or already sent' });
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/backfill-channel/:channelId
// Заливает все опубликованные статьи (или только из категорий канала) в очередь
// с заданным интервалом. Дубли (уже отправленные / уже в очереди) пропускаются.
// Body: { interval_min?: 3, limit?: 50, order?: 'asc'|'desc', categories?: string[] }
router.post('/backfill-channel/:channelId', async (req, res) => {
try {
const channelId = parseInt(req.params.channelId);
const intervalMin = parseInt(req.body?.interval_min) || 3;
const limit = Math.min(parseInt(req.body?.limit) || 50, 100);
const order = (req.body?.order === 'desc') ? 'DESC' : 'ASC';
const categories = Array.isArray(req.body?.categories) ? req.body.categories : null;
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1`, [channelId]);
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
const channel = chs[0];
// Берём статьи: published, не in queue/sent в этом канале
const params = [channelId];
let sql = `
SELECT a.id, a.slug, a.title, a.category, a.published_at
FROM articles a
WHERE a.status='published'
AND NOT EXISTS (
SELECT 1 FROM scheduled_posts sp
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
)`;
if (categories && categories.length) {
params.push(categories);
sql += ` AND a.category = ANY($${params.length}::text[])`;
}
// Если у канала задан фильтр категорий — учитываем его (но только если categories не передан явно)
else if (Array.isArray(channel.auto_publish_categories) && channel.auto_publish_categories.length) {
params.push(channel.auto_publish_categories);
sql += ` AND a.category = ANY($${params.length}::text[])`;
}
sql += ` ORDER BY a.published_at ${order} LIMIT ${limit}`;
const { rows: arts } = await query(sql, params);
const now = Date.now();
const created = [];
for (let i = 0; i < arts.length; i++) {
const when = new Date(now + (i + 1) * intervalMin * 60_000);
const { rows: ins } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
VALUES ($1,$2,$3,'pending') RETURNING *`,
[channelId, arts[i].id, when]
);
created.push({
scheduled_post_id: ins[0].id,
article_id: arts[i].id,
title: arts[i].title,
scheduled_at: when,
});
}
res.json({
ok: true,
channel: { id: channel.id, name: channel.name },
scheduled: created.length,
first_at: created[0]?.scheduled_at || null,
last_at: created[created.length - 1]?.scheduled_at || null,
items: created,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const settings = require('../services/settings');
// GET /api/settings/admin?category=photo_search — список всех настроек, опц. фильтр.
router.get('/admin', async (req, res) => {
try {
const rows = await settings.list();
const cat = req.query.category;
const filtered = cat ? rows.filter(r => r.category === cat) : rows;
res.json(filtered);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/settings/admin/:key — обновить значение одной настройки
router.put('/admin/:key', async (req, res) => {
try {
const { value } = req.body || {};
const row = await settings.set(req.params.key, value ?? null);
if (!row) return res.status(404).json({ error: 'Setting key not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/settings/admin/invalidate — принудительно сбросить кэш
router.post('/admin/invalidate', async (req, res) => {
settings.invalidate();
res.json({ ok: true });
});
module.exports = router;
+4 -2
View File
@@ -24,11 +24,13 @@ router.post('/', async (req, res) => {
try { try {
const userId = getUserId(req); const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'Unauthorized' }); if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { channel_id, content, image_url, topic, status, scheduled_at } = req.body; const { channel_id, content, image_url, image_credit, topic, status, scheduled_at } = req.body;
if (!channel_id || !content) return res.status(400).json({ error: 'channel_id and content required' }); if (!channel_id || !content) return res.status(400).json({ error: 'channel_id and content required' });
const post = await svc.savePost({ const post = await svc.savePost({
userId, channelId: channel_id, content, userId, channelId: channel_id, content,
imageUrl: image_url, topic, imageUrl: image_url,
imageCredit: image_credit ?? null,
topic,
status: status || 'draft', status: status || 'draft',
scheduledAt: scheduled_at, scheduledAt: scheduled_at,
}); });
+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 };
+133
View File
@@ -0,0 +1,133 @@
// Автоматическое добавление статей в серии.
//
// Логика:
// 1. При публикации статьи — Claude haiku анализирует заголовок + excerpt
// 2. Определяет наиболее подходящую серию (или ни одну)
// 3. Добавляет article.id в series.article_ids если его там ещё нет
//
// Серии и их описания для Claude:
// prompts — промпты, инструкции, генерация текста/изображений, работа с LLM как инструментом
// mcp-agents — RAG, агенты, MCP, Telegram/API боты, интеграции ИИ с внешними системами
// cases — автоматизация рабочих процессов, реальные кейсы, Make/Zapier/n8n, CRM, email
const axios = require('axios');
const { query } = require('../config/db');
const config = require('../config');
const SERIES_DESCRIPTIONS = [
{
slug: 'prompts',
name: 'Промпт-инжиниринг',
keywords: 'промпты, инструкции для ИИ, генерация текста, генерация изображений, работа с ChatGPT/Claude как инструментом, техники промптинга, few-shot, chain-of-thought, техдокументация с ИИ',
},
{
slug: 'mcp-agents',
name: 'MCP и агенты',
keywords: 'RAG, векторные базы данных, ИИ-агенты, MCP, автономные боты, Telegram-бот с ИИ, интеграции ИИ с API, LangChain, LlamaIndex, инструменты для агентов',
},
{
slug: 'cases',
name: 'Кейсы и автоматизации',
keywords: 'автоматизация рабочих процессов, Make, Zapier, n8n, CRM, email-маркетинг, реальные кейсы применения ИИ в работе, экономия времени, пайплайны',
},
{
slug: 'ai-security',
name: 'Безопасность в эпоху ИИ',
keywords: 'кибербезопасность с ИИ, prompt injection, OSINT, социальная инженерия, атаки на LLM, безопасность продакшна, анализ малвари, защита данных, LLM уязвимости',
},
];
/**
* Определить подходящую серию для статьи через Claude haiku.
* Возвращает slug серии или null если статья ни к одной не подходит.
*/
async function detectSeries(article) {
const seriesList = SERIES_DESCRIPTIONS.map(s =>
`- "${s.slug}" (${s.name}): ${s.keywords}`
).join('\n');
const prompt = `Ты — редактор блога ZeroPost. Определи, подходит ли эта статья к одной из серий блога.
СТАТЬЯ:
Заголовок: ${article.title}
Описание: ${article.excerpt || ''}
Категория: ${article.category || ''}
СЕРИИ БЛОГА:
${seriesList}
Отвечай ТОЛЬКО одним словом — slug серии (prompts / mcp-agents / cases) или "none" если статья ни к одной не подходит достаточно хорошо.
Выбирай серию только если уверен на 80%+. Лучше "none" чем неточное попадание.`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/messages`,
{
model: config.ai.models?.post || 'claude-haiku-4-5-20251001',
max_tokens: 10,
messages: [{ role: 'user', content: prompt }],
},
{
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
timeout: 15000,
}
);
const raw = res.data?.content?.[0]?.text?.trim().toLowerCase() || 'none';
// Извлекаем только slug без лишнего текста
const valid = SERIES_DESCRIPTIONS.map(s => s.slug);
const found = valid.find(s => raw.includes(s));
return found || null;
} catch (err) {
console.warn('[AutoSeries] Claude detection failed:', err.message.slice(0, 100));
return null;
}
}
/**
* Добавить статью в подходящую серию.
* Идемпотентно — не добавляет дубли.
* @returns { slug, seriesTitle } или null
*/
async function addToSeries(articleId) {
// Загружаем статью
const { rows: arts } = await query(
`SELECT id, title, excerpt, category, status FROM articles WHERE id=$1`,
[articleId]
);
if (!arts.length || arts[0].status !== 'published') return null;
const article = arts[0];
// Определяем серию
const slug = await detectSeries(article);
if (!slug) {
console.log(`[AutoSeries] article=${articleId} → no suitable series`);
return null;
}
// Загружаем серию
const { rows: series } = await query(
`SELECT id, title, article_ids FROM series WHERE slug=$1`,
[slug]
);
if (!series.length) return null;
const s = series[0];
const currentIds = (s.article_ids || []).map(Number);
if (currentIds.includes(articleId)) {
console.log(`[AutoSeries] article=${articleId} already in series "${slug}"`);
return { slug, seriesTitle: s.title, alreadyIn: true };
}
// Добавляем в конец
const newIds = [...currentIds, articleId];
await query(
`UPDATE series SET article_ids=$1::jsonb, updated_at=NOW() WHERE id=$2`,
[JSON.stringify(newIds), s.id]
);
console.log(`[AutoSeries] article=${articleId} "${article.title.slice(0,40)}" → series "${slug}" (${newIds.length} total)`);
return { slug, seriesTitle: s.title, articleCount: newIds.length };
}
module.exports = { addToSeries, detectSeries, SERIES_DESCRIPTIONS };
+113 -12
View File
@@ -1,6 +1,8 @@
const { query } = require('../config/db'); const { query } = require('../config/db');
const ai = require('./ai'); const ai = require('./ai');
const covers = require('./covers'); const covers = require('./covers');
// Ленивый импорт чтобы избежать circular dependency
function getAutoPublish() { return require('./articleAutoPublish'); }
/** /**
* Slug из заголовка — транслит для русского. * Slug из заголовка — транслит для русского.
@@ -35,8 +37,8 @@ async function listArticles({ limit = 20, offset = 0, tag = null, category = nul
let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at
FROM articles WHERE status='published'`; FROM articles WHERE status='published'`;
const params = []; const params = [];
if (tag) { sql += ` AND tags ? ${params.length + 1}`; params.push(tag); } if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
if (category) { sql += ` AND category=${params.length + 1}`; params.push(category); } if (category) { params.push(category); sql += ` AND category=$${params.length}`; }
sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(limit, offset); params.push(limit, offset);
const { rows } = await query(sql, params); const { rows } = await query(sql, params);
@@ -79,23 +81,40 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
const jobId = jobRows[0].id; const jobId = jobRows[0].id;
try { try {
// Универсальный "channel" для блога — с правилами человечности и нашим стилем // ZeroPost — блог от лица персонажа «Зеро».
// Дружелюбный энтузиаст, делится тем что попробовал. От первого лица.
const blogChannel = { const blogChannel = {
name: 'ZeroPost', name: 'ZeroPost',
niche: 'Практические материалы про ИИ для людей, которые применяют его в работе', niche: 'ИИ, кибербезопасность, автоматизация и разработка — то что Зеро попробовал и хочет рассказать',
audience: 'Маркетологи, продакты, разработчики, основатели — те, кто хочет применять ИИ практически', audience: 'Технари, разработчики, продакты, IT-специалисты — те кому интересны рабочие приёмы, а не теория',
goal: 'expert', goal: 'personal',
language: 'ru', language: 'ru',
region: 'ru', region: 'ru',
author_persona: {
name: 'Зеро',
identity: 'Я — Зеро, ИИ-маскот канала ZeroPost. Дружелюбный любопытный энтузиаст, который пробует разные инструменты и делится тем что узнал. Открыто говорю что я ИИ, не скрываю.',
voice: 'От первого лица. Конкретные истории > абстрактные советы. Признаю когда что-то не получилось.',
forbidden_phrases: ['как ИИ, я', 'я как языковая модель', 'в современном мире', 'революционный', 'уникальный', 'важно отметить', 'стоит подчеркнуть', 'поистине', 'давайте рассмотрим', 'итак', 'таким образом', 'в данной статье'],
},
style: { style: {
tone: 'friendly', tone: 'friendly_enthusiast',
formality: 'informal', formality: 'informal',
humor: 'dry', humor: 'dry',
post_length: 'long', post_length: 'medium-long',
structure: 'headers', structure: 'headers',
emoji_level: 'none', emoji_level: 'minimal',
hashtags_mode: 'none', hashtags_mode: 'none',
banned_words: ['революционный','уникальный','в современном мире','важно отметить','стоит подчеркнуть','поистине'], first_person: true,
rules: [
'Пиши от первого лица: «я попробовал», «у меня сломалось», «обжёгся», «нашёл штуку».',
'Конкретные истории и примеры > общие фразы. Лучше «вчера запустил X и получил Y» чем «X помогает делать Y».',
'Признавай ошибки и тупики: «сначала пошёл не туда», «потратил час на ерунду».',
'Сухой юмор допускается, но без клоунады.',
'Не пиши «статья», «материал», «данная публикация» — пиши «пост», «разбор», «история», «заметка».',
'Никогда не упоминай что текст пишется автоматически или массово.',
'Не начинай с «В современном мире...» / «Итак...» / «Давайте...» — начинай с конкретики или с мини-истории.',
'Не давай советов в стиле «вам нужно сделать X» — пиши «я делаю X, потому что Y».',
],
}, },
}; };
@@ -115,12 +134,20 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
const slug = `${slugify(title)}-${jobId}`; const slug = `${slugify(title)}-${jobId}`;
const readingTime = estimateReadingTime(content); const readingTime = estimateReadingTime(content);
// Дедуп тегов + удаление category-slug из tags (он живёт в отдельной колонке).
const cleanTags = Array.from(new Set(
(tags || [])
.filter(t => t && typeof t === 'string')
.map(t => t.trim())
.filter(t => t.length > 0 && t.toLowerCase() !== category.toLowerCase())
));
const { rows: artRows } = await query( const { rows: artRows } = await query(
`INSERT INTO articles (slug, title, excerpt, content, tags, category, reading_time, status, job_id, seo_title, seo_descr) `INSERT INTO articles (slug, title, excerpt, content, tags, category, reading_time, status, job_id, seo_title, seo_descr)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[ [
slug, title, excerpt, content, slug, title, excerpt, content,
JSON.stringify(tags), JSON.stringify(cleanTags),
category, category,
readingTime, readingTime,
autoPublish ? 'published' : 'draft', autoPublish ? 'published' : 'draft',
@@ -135,13 +162,23 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
[content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId] [content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId]
); );
// Фоновая генерация обложки — не блокирует возврат статьи // Фоновые задачи после сохранения — не блокируют возврат статьи
setImmediate(() => { setImmediate(() => {
// Генерация обложки
covers.generateCover({ covers.generateCover({
articleId: artRows[0].id, articleId: artRows[0].id,
title: artRows[0].title, title: artRows[0].title,
tags: artRows[0].tags || [], tags: artRows[0].tags || [],
}).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200))); }).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200)));
// Авто-публикация в каналы (если статья опубликована)
if (artRows[0].status === 'published') {
getAutoPublish().scheduleForArticle(artRows[0].id)
.catch(err => console.error('[Article] auto-publish hook failed:', err.message));
// Авто-добавление в серию
require('./articleAutoSeries').addToSeries(artRows[0].id)
.catch(err => console.error('[Article] auto-series hook failed:', err.message));
}
}); });
return artRows[0]; return artRows[0];
@@ -154,6 +191,68 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
} }
} }
/**
* Собирает данные для главной страницы в одном вызове.
* - hero: 1 свежая статья (с обложкой)
* - byCategory: по 3 свежих на каждую из 4 категорий, исключая hero
* - popular: до 3 статей по views за последние 30 дней (если есть просмотры)
* - recent: 6 свежих, исключая hero и byCategory
*/
async function getHomeArticles() {
const select = `SELECT id, slug, title, excerpt, cover_url, tags, category, reading_time, views, published_at`;
// Hero — самая свежая опубликованная статья с обложкой
const heroRes = await query(
`${select} FROM articles
WHERE status='published' AND cover_url IS NOT NULL
ORDER BY published_at DESC LIMIT 1`
);
const hero = heroRes.rows[0] || null;
const heroId = hero ? hero.id : 0;
// По 3 на каждую категорию (DISTINCT ON), исключая hero
const catRes = await query(
`SELECT * FROM (
SELECT ${select.replace('SELECT ', '')},
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
FROM articles
WHERE status='published' AND id <> $1
) t WHERE rn <= 3
ORDER BY category, rn`,
[heroId]
);
const byCategory = {};
for (const row of catRes.rows) {
const { rn, ...rest } = row;
if (!byCategory[row.category]) byCategory[row.category] = [];
byCategory[row.category].push(rest);
}
// Популярное за 30 дней: топ-3 по views (только если views > 0)
const popRes = await query(
`${select} FROM articles
WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days'
ORDER BY views DESC, published_at DESC LIMIT 3`
);
const popular = popRes.rows;
const popularIds = popular.map(p => p.id);
// Recent — 6 свежих, исключая hero и попавшие в byCategory и popular
const usedIds = new Set([heroId, ...popularIds]);
for (const arr of Object.values(byCategory)) for (const a of arr) usedIds.add(a.id);
const usedArr = Array.from(usedIds).filter(Boolean);
const recentRes = await query(
`${select} FROM articles
WHERE status='published' AND id <> ALL($1::int[])
ORDER BY published_at DESC LIMIT 6`,
[usedArr.length ? usedArr : [0]]
);
const recent = recentRes.rows;
return { hero, byCategory, popular, recent };
}
module.exports = { module.exports = {
slugify, slugify,
listArticles, listArticles,
@@ -161,3 +260,5 @@ module.exports = {
getAllTags, getAllTags,
generateAndSaveArticle, generateAndSaveArticle,
}; };
module.exports.getHomeArticles = getHomeArticles;
+2 -2
View File
@@ -79,7 +79,7 @@ async function getNextTopic(category) {
const unused = bank.filter(t => !usedTitles.some(u => u.includes(t.slice(0, 20).toLowerCase()))); const unused = bank.filter(t => !usedTitles.some(u => u.includes(t.slice(0, 20).toLowerCase())));
const pool = unused.length > 0 ? unused : bank; const pool = unused.length > 0 ? unused : bank;
const topic = pool[Math.floor(Math.random() * pool.length)]; const topic = pool[Math.floor(Math.random() * pool.length)];
return { id: null, topic, tags: [category], keywords: [] }; return { id: null, topic, tags: [], keywords: [] };
} }
/** /**
@@ -92,7 +92,7 @@ async function runAutogenForCategory(category) {
try { try {
const article = await generateAndSaveArticle({ const article = await generateAndSaveArticle({
topic, topic,
tags: [...tags, category], tags: tags,
keywords, keywords,
autoPublish: true, autoPublish: true,
category, category,
+132
View File
@@ -0,0 +1,132 @@
// Сбор статистики TG-каналов.
// Сейчас: getChatMemberCount (подписчики).
// Потом: TGStat API (views, ERR, прирост).
//
// Вызывается из cron'а раз в час: POST /api/channel-stats/collect
const axios = require('axios');
const { query } = require('../config/db');
const settings = require('./settings');
/**
* Собрать подписчиков для одного канала через Bot API.
*/
async function collectMembersForChannel(channel) {
if (!channel.bot_token || !channel.tg_channel_id) return null;
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
try {
const res = await axios.post(
`${base}/bot${channel.bot_token}/getChatMemberCount`,
{ chat_id: channel.tg_channel_id },
{ timeout: 10000 }
);
if (!res.data?.ok) return null;
return res.data.result; // число
} catch (err) {
console.warn(`[stats] getChatMemberCount failed channel=${channel.id}: ${err.message}`);
return null;
}
}
/**
* Собрать и сохранить статистику для всех активных системных TG-каналов.
*/
async function collectAll() {
const { rows: channels } = await query(
`SELECT id, name, platform, bot_token, tg_channel_id
FROM channels
WHERE is_system = true AND is_active = true AND platform = 'telegram'`
);
const results = [];
for (const ch of channels) {
const members = await collectMembersForChannel(ch);
if (members === null) {
results.push({ channel_id: ch.id, name: ch.name, ok: false });
continue;
}
// Сохраняем только если значение изменилось или нет записи за последние 55 мин
// (чтобы не дублировать при частых вызовах)
const { rows: last } = await query(
`SELECT members FROM channel_stats
WHERE channel_id=$1 AND captured_at > NOW() - INTERVAL '55 minutes'
ORDER BY captured_at DESC LIMIT 1`,
[ch.id]
);
if (last.length && last[0].members === members) {
results.push({ channel_id: ch.id, name: ch.name, ok: true, members, saved: false, reason: 'no change' });
continue;
}
await query(
`INSERT INTO channel_stats (channel_id, members) VALUES ($1, $2)`,
[ch.id, members]
);
console.log(`[stats] channel=${ch.name} members=${members}`);
results.push({ channel_id: ch.id, name: ch.name, ok: true, members, saved: true });
}
return results;
}
/**
* Получить историю подписчиков за последние N дней.
*/
async function getMembersHistory(channelId, days = 30) {
const { rows } = await query(
`SELECT
date_trunc('hour', captured_at) AS hour,
MAX(members) AS members
FROM channel_stats
WHERE channel_id=$1
AND captured_at > NOW() - INTERVAL '${parseInt(days)} days'
GROUP BY 1
ORDER BY 1 ASC`,
[channelId]
);
return rows;
}
/**
* Получить текущую сводку по каналу.
*/
async function getChannelSummary(channelId) {
// Последнее значение
const { rows: latest } = await query(
`SELECT members, captured_at FROM channel_stats
WHERE channel_id=$1 ORDER BY captured_at DESC LIMIT 1`,
[channelId]
);
// 24 часа назад
const { rows: yesterday } = await query(
`SELECT members FROM channel_stats
WHERE channel_id=$1
AND captured_at BETWEEN NOW() - INTERVAL '25 hours' AND NOW() - INTERVAL '23 hours'
ORDER BY captured_at DESC LIMIT 1`,
[channelId]
);
// 7 дней назад
const { rows: weekAgo } = await query(
`SELECT members FROM channel_stats
WHERE channel_id=$1
AND captured_at BETWEEN NOW() - INTERVAL '7 days 1 hour' AND NOW() - INTERVAL '6 days 23 hours'
ORDER BY captured_at DESC LIMIT 1`,
[channelId]
);
// Кол-во постов
const { rows: postsCount } = await query(
`SELECT COUNT(*) AS cnt FROM posts WHERE channel_id=$1`, [channelId]
);
const current = latest[0]?.members ?? null;
const prev24h = yesterday[0]?.members ?? null;
const prev7d = weekAgo[0]?.members ?? null;
return {
members: current,
captured_at: latest[0]?.captured_at ?? null,
delta_24h: current !== null && prev24h !== null ? current - prev24h : null,
delta_7d: current !== null && prev7d !== null ? current - prev7d : null,
posts_total: parseInt(postsCount[0]?.cnt ?? 0),
};
}
module.exports = { collectAll, collectMembersForChannel, getMembersHistory, getChannelSummary };
+52 -12
View File
@@ -3,6 +3,7 @@ const path = require('path');
const axios = require('axios'); const axios = require('axios');
const config = require('../config'); const config = require('../config');
const { query } = require('../config/db'); const { query } = require('../config/db');
const localGen = require('./localCoverGenerator');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; 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'); 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. * Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/ */
async function generateCover({ articleId, title, tags = [] }) { async function generateCover({ articleId, title, tags = [] }) {
// Передаём articleId в buildCoverPrompt для детерминированного выбора стиля
const prompt = buildCoverPrompt({ title, tags, articleId }); const prompt = buildCoverPrompt({ title, tags, articleId });
const styleIdx = pickStyleIndex(articleId); const styleIdx = pickStyleIndex(articleId);
const styleName = COVER_STYLES[styleIdx].name; const styleName = COVER_STYLES[styleIdx].name;
@@ -171,20 +195,36 @@ async function generateCover({ articleId, title, tags = [] }) {
let img; let img;
let usedPath = 'responses'; let usedPath = 'responses';
// Пробуем все внешние API, при любой ошибке — сразу local SVG
try { 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 { try {
img = await generateCoverViaImagesEndpoint({ prompt }); img = await generateCoverViaResponses({ prompt });
usedPath = 'images-legacy'; } catch (err) {
} catch (err2) { const msg = err.response?.data?.error?.message || err.message;
const msg2 = err2.response?.data?.error?.message || err2.message; console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`); try {
throw new Error(`Both image paths failed: ${msg}`); 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;
} }
// Сохраняем оригинал // Сохраняем оригинал
+174
View File
@@ -0,0 +1,174 @@
/**
* Локальный генератор обложек через SVG → WebP (sharp).
* Работает без внешних API — мгновенно, бесплатно, без лимитов.
* Используется как надёжный fallback когда все API недоступны.
*
* Каждая статья получает детерминированный уникальный дизайн
* в фирменной палитре ZeroPost.
*/
const fs = require('fs');
const path = require('path');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
// Палитры в стиле ZeroPost (emerald, teal, amber, blue, slate)
const PALETTES = [
{ name: 'emerald', bg: '#f0fdf4', c1: '#10b981', c2: '#34d399', c3: '#6ee7b7', c4: '#064e3b', accent: '#059669' },
{ name: 'midnight', bg: '#0f172a', c1: '#10b981', c2: '#1e293b', c3: '#334155', c4: '#94a3b8', accent: '#34d399' },
{ name: 'amber', bg: '#fffbeb', c1: '#f59e0b', c2: '#fcd34d', c3: '#fde68a', c4: '#78350f', accent: '#d97706' },
{ name: 'blue', bg: '#eff6ff', c1: '#3b82f6', c2: '#93c5fd', c3: '#dbeafe', c4: '#1e3a8a', accent: '#2563eb' },
{ name: 'coral', bg: '#fff7f3', c1: '#ef4444', c2: '#fb7185', c3: '#fecdd3', c4: '#881337', accent: '#f43f5e' },
{ name: 'violet', bg: '#faf5ff', c1: '#8b5cf6', c2: '#c4b5fd', c3: '#ede9fe', c4: '#4c1d95', accent: '#7c3aed' },
];
// Детерминированный псевдо-рандом по seed
function seededRand(seed) {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
/**
* Генерирует SVG-обложку 1600×900.
*/
function generateCoverSVG(articleId, title = '', category = '') {
const rand = seededRand((articleId || 1) * 7919 + 42);
const palette = PALETTES[(articleId || 0) % PALETTES.length];
// Выбор дизайн-паттерна по id
const patternIdx = Math.floor(rand() * 5);
let shapes = '';
if (patternIdx === 0) {
// Концентрические круги + смещённые
const cx = 900 + rand() * 300 - 150;
const cy = 450 + rand() * 200 - 100;
for (let i = 5; i > 0; i--) {
const r = i * 120 + rand() * 40;
const op = 0.08 + i * 0.06;
shapes += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${palette.c1}" opacity="${op.toFixed(2)}"/>`;
}
shapes += `<circle cx="${cx - 200}" cy="${cy + 100}" r="${140 + rand()*60}" fill="${palette.c2}" opacity="0.15"/>`;
shapes += `<circle cx="${cx - 350}" cy="${cy - 150}" r="${90 + rand()*40}" fill="${palette.accent}" opacity="0.2"/>`;
} else if (patternIdx === 1) {
// Диагональные полосы + геометрия
for (let i = 0; i < 6; i++) {
const x = -100 + i * 310 + rand() * 60;
const op = 0.05 + rand() * 0.12;
const w = 180 + rand() * 120;
shapes += `<rect x="${x}" y="-50" width="${w}" height="1000" fill="${palette.c1}" opacity="${op.toFixed(2)}" transform="rotate(${-15 + rand()*10} 800 450)"/>`;
}
shapes += `<circle cx="${400 + rand()*200}" cy="${200 + rand()*150}" r="${100 + rand()*80}" fill="${palette.c2}" opacity="0.18"/>`;
shapes += `<circle cx="${1000 + rand()*200}" cy="${550 + rand()*150}" r="${120 + rand()*60}" fill="${palette.accent}" opacity="0.12"/>`;
} else if (patternIdx === 2) {
// Волны (кривые Безье)
const waves = 4;
for (let i = 0; i < waves; i++) {
const y = 150 + i * 200 + rand() * 60;
const amp = 60 + rand() * 80;
const op = 0.08 + rand() * 0.15;
shapes += `<path d="M -100 ${y} C 200 ${y - amp}, 400 ${y + amp}, 600 ${y} S 1000 ${y - amp}, 1200 ${y} S 1600 ${y + amp}, 1700 ${y}"
stroke="${i % 2 === 0 ? palette.c1 : palette.c2}" stroke-width="${20 + rand()*30}" fill="none" opacity="${op.toFixed(2)}" stroke-linecap="round"/>`;
}
shapes += `<circle cx="${rand()*400 + 100}" cy="${rand()*300 + 100}" r="${60 + rand()*80}" fill="${palette.accent}" opacity="0.15"/>`;
shapes += `<circle cx="${rand()*400 + 1000}" cy="${rand()*300 + 400}" r="${50 + rand()*70}" fill="${palette.c1}" opacity="0.1"/>`;
} else if (patternIdx === 3) {
// Сетка из прямоугольников
const cols = 6, rows = 4;
const cw = 1600 / cols, ch = 900 / rows;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (rand() > 0.5) {
const op = 0.04 + rand() * 0.1;
const fill = rand() > 0.5 ? palette.c1 : palette.c2;
shapes += `<rect x="${c * cw + 4}" y="${r * ch + 4}" width="${cw - 8}" height="${ch - 8}"
fill="${fill}" opacity="${op.toFixed(2)}" rx="${rand() * 20}"/>`;
}
}
}
// Большой акцентный круг
shapes += `<circle cx="${400 + rand()*400}" cy="${300 + rand()*300}" r="${200 + rand()*100}"
fill="${palette.c1}" opacity="0.12"/>`;
} else {
// Треугольные формы и многоугольники
for (let i = 0; i < 5; i++) {
const cx = rand() * 1600;
const cy = rand() * 900;
const size = 100 + rand() * 200;
const op = 0.08 + rand() * 0.15;
const angle = rand() * 360;
const pts = [0, 1, 2].map(j => {
const a = (angle + j * 120) * Math.PI / 180;
return `${(cx + Math.cos(a) * size).toFixed(0)},${(cy + Math.sin(a) * size).toFixed(0)}`;
}).join(' ');
shapes += `<polygon points="${pts}" fill="${rand() > 0.5 ? palette.c1 : palette.accent}" opacity="${op.toFixed(2)}"/>`;
}
}
// Частицы-точки (всегда)
const dots = Array.from({ length: 20 }, () => {
const x = rand() * 1600, y = rand() * 900;
const r = 2 + rand() * 5;
const op = 0.1 + rand() * 0.2;
return `<circle cx="${x.toFixed(0)}" cy="${y.toFixed(0)}" r="${r.toFixed(1)}" fill="${palette.c4}" opacity="${op.toFixed(2)}"/>`;
}).join('');
// Мягкий градиент-оверлей
const overlay = `
<defs>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="${palette.bg}" stop-opacity="0"/>
<stop offset="100%" stop-color="${palette.bg}" stop-opacity="0.3"/>
</radialGradient>
</defs>
<rect width="1600" height="900" fill="url(#vignette)"/>`;
return `<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="900" fill="${palette.bg}"/>
${shapes}
${dots}
${overlay}
</svg>`;
}
/**
* Сгенерировать и сохранить SVG-обложку для статьи.
* Возвращает /uploads/cover-{id}-{ts}.webp
*/
async function generateLocalCover({ articleId, title = '', category = '' }) {
let sharp;
try {
sharp = require('sharp');
} catch (e) {
throw new Error('sharp not available: ' + e.message);
}
const svg = generateCoverSVG(articleId, title, category);
const ts = Date.now();
const filename = `cover-${articleId}-${ts}.webp`;
const outPath = path.join(UPLOADS_DIR, filename);
await sharp(Buffer.from(svg))
.resize(1600, 900)
.webp({ quality: 88 })
.toFile(outPath);
const size = fs.statSync(outPath).size;
console.log(`[Cover] local SVG generated: article=${articleId}${filename} (${(size/1024).toFixed(0)}KB) palette=${PALETTE_NAME(articleId)}`);
return `/uploads/${filename}`;
}
function PALETTE_NAME(id) {
return PALETTES[(id || 0) % PALETTES.length].name;
}
module.exports = { generateLocalCover, generateCoverSVG };
+262
View File
@@ -0,0 +1,262 @@
// Поиск фото через Yandex Search API (Image search v2)
//
// Архитектура:
// - Запрос → searchapi.api.cloud.yandex.net/v2/image/search
// - Ответ: JSON { rawData: base64 }, внутри base64 — XML с результатами
// - Парсим XML, нормализуем в массив объектов
// - Фильтруем по whitelist доменов из photo_search_profiles
// - Фильтруем по min-size (отсев иконок)
// - Считаем суточный лимит в Redis (ключ photo_search:count:YYYY-MM-DD)
//
// Если меняем провайдера (yandex → serpapi) — этот модуль будет адаптером,
// логика квот и фильтрации профилей остаётся.
const axios = require('axios');
const { XMLParser } = require('fast-xml-parser');
const Redis = require('ioredis');
const settings = require('./settings');
const config = require('../config');
const { query: dbQuery } = require('../config/db');
const YANDEX_ENDPOINT = 'https://searchapi.api.cloud.yandex.net/v2/image/search';
const MIN_DIMENSION_PX = 400;
const USER_AGENT = 'Mozilla/5.0 (compatible; ZeroPost/1.0)';
let _redis = null;
function getRedis() {
if (!_redis) {
_redis = new Redis({
host: config.redis.host,
port: config.redis.port,
lazyConnect: false,
maxRetriesPerRequest: 3,
});
_redis.on('error', (err) => console.error('[photo-search] redis error:', err.message));
}
return _redis;
}
// ── Квоты (Redis daily counter) ──────────────────────────────────────────────
function dailyKey() {
return `photo_search:count:${new Date().toISOString().slice(0, 10)}`;
}
async function getDailyCount() {
try {
const v = await getRedis().get(dailyKey());
return parseInt(v) || 0;
} catch {
return 0;
}
}
async function incrementDaily() {
try {
const r = getRedis();
const k = dailyKey();
const count = await r.incr(k);
if (count === 1) await r.expire(k, 172800); // 48h TTL
return count;
} catch (err) {
console.error('[photo-search] incr failed:', err.message);
return 0;
}
}
async function getQuotaStatus() {
const limit = parseInt(await settings.get('YANDEX_SEARCH_DAILY_LIMIT', '300'));
const used = await getDailyCount();
return { used, limit, remaining: Math.max(0, limit - used) };
}
// ── Парсинг XML ответа Yandex ────────────────────────────────────────────────
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
parseAttributeValue: false,
trimValues: true,
});
function parseYandexXml(base64Data) {
const xmlText = Buffer.from(base64Data, 'base64').toString('utf-8');
const parsed = xmlParser.parse(xmlText);
const response = parsed?.yandexsearch?.response;
if (!response) {
throw new Error('Unexpected Yandex response: no <response>');
}
if (response.error) {
const errText = typeof response.error === 'object' ? response.error['#text'] || JSON.stringify(response.error) : response.error;
throw new Error(`Yandex error: ${errText}`);
}
const grouping = response.results?.grouping;
if (!grouping) return { total: 0, docs: [] };
const groups = Array.isArray(grouping.group) ? grouping.group : [grouping.group].filter(Boolean);
const docs = [];
for (const group of groups) {
const groupDocs = Array.isArray(group.doc) ? group.doc : [group.doc].filter(Boolean);
for (const doc of groupDocs) {
const imgProps = doc['image-properties'] || {};
const titleText = typeof doc.title === 'object' ? (doc.title['#text'] || '') : (doc.title || '');
const passageText = typeof doc.passage === 'object' ? (doc.passage['#text'] || '') : (doc.passage || '');
docs.push({
imageUrl: imgProps['image-link'] || doc.url || null,
thumbUrl: imgProps['thumbnail-link'] || null,
sourceUrl: imgProps['html-link'] || null,
sourceDomain: doc.domain || null,
title: String(titleText).slice(0, 200),
passage: String(passageText).slice(0, 200),
width: parseInt(imgProps['original-width']) || 0,
height: parseInt(imgProps['original-height']) || 0,
thumbWidth: parseInt(imgProps['thumbnail-width']) || 0,
thumbHeight: parseInt(imgProps['thumbnail-height']) || 0,
});
}
}
const foundArr = Array.isArray(response.found) ? response.found : (response.found ? [response.found] : []);
const foundAll = foundArr.find(f => f['@_priority'] === 'all');
const total = foundAll ? parseInt(foundAll['#text']) : docs.length;
return { total, docs };
}
// ── Фильтрация результатов ───────────────────────────────────────────────────
function matchesDomain(domain, whitelist) {
if (!domain || !whitelist || whitelist.length === 0) return true;
const d = domain.toLowerCase();
return whitelist.some(allowed => {
const a = allowed.toLowerCase();
return d === a || d.endsWith('.' + a);
});
}
function meetsMinSize(doc) {
if (!doc.width || !doc.height) return true; // unknown size — пропускаем
return Math.min(doc.width, doc.height) >= MIN_DIMENSION_PX;
}
// ── Profile lookup ───────────────────────────────────────────────────────────
async function getProfileDomains(slug) {
if (!slug) return [];
const { rows } = await dbQuery(
'SELECT domains FROM photo_search_profiles WHERE slug=$1',
[slug]
);
return rows[0]?.domains || [];
}
// ── Main: searchByQuery ──────────────────────────────────────────────────────
async function searchByQuery({ query, profileSlug = 'general', num = 6 }) {
if (!query || typeof query !== 'string') {
throw new Error('query is required');
}
// Квота
const limit = parseInt(await settings.get('YANDEX_SEARCH_DAILY_LIMIT', '300'));
const used = await getDailyCount();
if (used >= limit) {
const err = new Error(`Daily photo search limit reached: ${used}/${limit}`);
err.code = 'DAILY_LIMIT_EXCEEDED';
throw err;
}
// Credentials
const apiKey = await settings.get('YANDEX_SEARCH_API_KEY', '');
const folderId = await settings.get('YANDEX_SEARCH_FOLDER_ID', '');
if (!apiKey || !folderId) {
throw new Error('Yandex Search API not configured (YANDEX_SEARCH_API_KEY / YANDEX_SEARCH_FOLDER_ID)');
}
// Profile
const domains = await getProfileDomains(profileSlug);
// Запросим с запасом — потом отфильтруем
const docsOnPage = Math.min(Math.max(num * 4, 10), 50);
const requestBody = {
query: {
searchType: 'SEARCH_TYPE_RU',
queryText: query.trim(),
familyMode: 'FAMILY_MODE_MODERATE',
page: '0',
fixTypoMode: 'FIX_TYPO_MODE_ON',
},
imageSpec: {
format: 'IMAGE_FORMAT_UNSPECIFIED',
size: 'IMAGE_SIZE_LARGE',
orientation: 'IMAGE_ORIENTATION_UNSPECIFIED',
color: 'IMAGE_COLOR_UNSPECIFIED',
},
docsOnPage: String(docsOnPage),
folderId,
userAgent: USER_AGENT,
};
await incrementDaily();
const startMs = Date.now();
let response;
try {
response = await axios.post(YANDEX_ENDPOINT, requestBody, {
headers: {
'Authorization': `Api-Key ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 20000,
});
} catch (err) {
const status = err.response?.status;
const data = err.response?.data;
const detail = data?.message || data?.code || err.message;
const e = new Error(`Yandex Search API request failed (${status || 'no-response'}): ${detail}`);
e.status = status;
throw e;
}
const elapsedMs = Date.now() - startMs;
if (!response.data?.rawData) {
throw new Error('Yandex response missing rawData field');
}
const { total, docs } = parseYandexXml(response.data.rawData);
// Фильтрация
let filtered = docs.filter(meetsMinSize);
if (domains.length > 0) {
filtered = filtered.filter(d => matchesDomain(d.sourceDomain, domains));
}
// Дедуп по imageUrl (на всякий случай)
const seen = new Set();
const dedup = [];
for (const d of filtered) {
if (!d.imageUrl || seen.has(d.imageUrl)) continue;
seen.add(d.imageUrl);
dedup.push(d);
}
const items = dedup.slice(0, num);
return {
items,
total,
raw_count: docs.length,
filtered_count: filtered.length,
elapsed_ms: elapsedMs,
quota: { used: used + 1, limit, remaining: Math.max(0, limit - used - 1) },
profile: profileSlug,
domains: domains,
};
}
module.exports = { searchByQuery, getQuotaStatus, parseYandexXml };
+33 -2
View File
@@ -196,9 +196,40 @@ ${style.banned_topics?.length ? `НЕ трогай темы: ${style.banned_topi
*/ */
function buildArticleSystemPrompt(channel, keywords = []) { function buildArticleSystemPrompt(channel, keywords = []) {
const lang = channel?.language === 'en' ? 'английском' : 'русском'; const lang = channel?.language === 'en' ? 'английском' : 'русском';
return `Ты — опытный русскоязычный автор и редактор. Пишешь живые, читаемые статьи для русской аудитории на ${lang} языке. const persona = channel?.author_persona;
ГЛАВНОЕ: текст должен звучать так, будто его написал думающий человек, а не ИИ. Если статья звучит "по-нейросетевому" — она провалена. // Секция персонажа — если у канала задан author_persona, ставим её ПЕРВОЙ.
// Это перебивает все остальные инструкции по тону.
const personaSection = persona ? `═══════════════════════════════════════════════════════════
ТЫ — ${persona.name.toUpperCase()}
═══════════════════════════════════════════════════════════
${persona.identity}
ГОЛОС: ${persona.voice}
Правила голоса Зеро (ВАЖНО):
${(channel?.style?.rules || []).map(r => `- ${r}`).join('\n')}
ЗАПРЕЩЁННЫЕ ФРАЗЫ (никогда не используй):
${(persona.forbidden_phrases || []).map(f => `- "${f}"`).join('\n')}
КРИТИЧЕСКИ ВАЖНО:
- Пиши ОТ ПЕРВОГО ЛИЦА. Не "вы делаете" — а "я делаю". Не "стоит попробовать" — а "я попробовал".
- Начинай статью с МИНИ-ИСТОРИИ или конкретного наблюдения, а не с обобщения.
ХОРОШО: "На прошлой неделе я решил..." / "Сижу, отлаживаю..." / "Заметил странную штуку..."
ПЛОХО: "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..."
- Используй "я", "мне", "у меня" регулярно. Если за абзац ни одного личного местоимения — переписывай.
- Признавай ошибки: "сначала сделал не так", "запутался", "час потратил на ерунду", "оказалось всё проще".
- Не называй статью "статьёй". Говори "пост", "разбор", "история", "заметка".
═══════════════════════════════════════════════════════════
` : '';
return `Ты — ${persona ? persona.name + ' — ИИ-маскот блога ZeroPost' : 'опытный русскоязычный автор и редактор'}. Пишешь живые, читаемые ${persona ? 'заметки' : 'статьи'} для русской аудитории на ${lang} языке.
${personaSection}
ГЛАВНОЕ: текст должен звучать так, будто его написал думающий человек, а не ИИ. Если ${persona ? 'заметка' : 'статья'} звучит "по-нейросетевому" — она провалена.
═══════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════
ЯЗЫК И СТИЛЬ — критично, читай внимательно ЯЗЫК И СТИЛЬ — критично, читай внимательно
+277
View File
@@ -0,0 +1,277 @@
// Раннер scheduled_posts (системные публикации статей в каналы).
// Дёргается cron'ом раз в минуту.
//
// Логика:
// - article + channel.auto_publish_template → текст-тизер
// - в TG: inline-кнопка «Читать на сайте →» (без URL в тексте)
// - в VK/MAX: URL автоматически подмешивается в конец текста (кнопок нет)
// - cover статьи прикрепляется если auto_publish_with_cover=true
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const { query } = require('../config/db');
const settings = require('./settings');
const zeroChar = require('./zeroCharacter');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
/**
* Если photoUrl указывает на наш собственный /uploads — открываем файл
* с диска и шлём как multipart. Это надёжнее, чем заставлять Telegram-прокси
* самостоятельно тянуть файл с zeropost.ru (бывают таймауты / sandbox-ограничения CF Worker'а).
*/
function resolveLocalPhoto(photoUrl) {
if (!photoUrl) return null;
// Формы: /uploads/x.webp, https://zeropost.ru/uploads/x.webp
let pathname = photoUrl;
try {
const u = new URL(photoUrl);
pathname = u.pathname;
} catch {}
if (!pathname.startsWith('/uploads/')) return null;
const filename = pathname.replace(/^\/uploads\//, '');
// Защита от path traversal
if (filename.includes('..') || filename.includes('/')) return null;
const local = path.join(UPLOADS_DIR, filename);
if (!fs.existsSync(local)) return null;
return local;
}
const DEFAULT_TEMPLATE = '{categoryEmoji} *{categoryLabel}*\n\n*{title}*\n\n{excerpt}';
const DEFAULT_BUTTON_TEXT = '📖 Читать на сайте →';
// Маппинг slug → emoji + русское название для плейсхолдеров
const CATEGORY_META = {
'ai-tools': { emoji: '🤖', label: 'AI Tools' },
'cybersec': { emoji: '🔒', label: 'Cybersec' },
'automation': { emoji: '⚡', label: 'Automation' },
'ai-dev': { emoji: '💻', label: 'AI Dev' },
};
function articleUrl(article) {
return `https://zeropost.ru/blog/${article.slug}`;
}
function renderTemplate(template, article) {
const tpl = (template && template.trim()) || DEFAULT_TEMPLATE;
const url = articleUrl(article);
const meta = CATEGORY_META[article.category] || { emoji: '📝', label: article.category || '' };
return tpl
.replaceAll('{title}', article.title || '')
.replaceAll('{excerpt}', article.excerpt || '')
.replaceAll('{url}', url)
.replaceAll('{category}', article.category || '')
.replaceAll('{categoryEmoji}', meta.emoji)
.replaceAll('{categoryLabel}', meta.label);
}
/**
* Telegram. Если есть article — добавляем inline-кнопку «Читать на сайте».
* Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
*/
async function publishToTelegram({ channel, text, photoUrl, article }) {
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
// Inline-кнопка — только если есть статья и кнопка не отключена
const buttonText = channel.auto_publish_button_text === null
? null
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
let reply_markup = undefined;
if (article && buttonText) {
reply_markup = {
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
};
}
if (photoUrl) {
const localPath = resolveLocalPhoto(photoUrl);
if (localPath) {
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', text.slice(0, 1024));
form.append('parse_mode', 'Markdown');
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return res.data?.result?.message_id;
}
// Внешний URL — оставляем старое поведение
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, {
chat_id: channel.tg_channel_id,
photo: photoUrl,
caption: text.slice(0, 1024),
parse_mode: 'Markdown',
reply_markup,
}, { timeout: 30000 });
return res.data?.result?.message_id;
}
const res = await axios.post(`${base}/bot${channel.bot_token}/sendMessage`, {
chat_id: channel.tg_channel_id,
text: text.slice(0, 4096),
parse_mode: 'Markdown',
disable_web_page_preview: !article, // если есть кнопка — превью сайта не нужно
reply_markup,
}, { timeout: 15000 });
return res.data?.result?.message_id;
}
async function publishToVK({ channel, text, photoUrl, article }) {
if (!channel.vk_group_id || !channel.vk_access_token) {
throw new Error('VK не настроен');
}
// VK не поддерживает кнопки в постах — добавляем ссылку в конец текста, если её там ещё нет
let finalText = text;
if (article) {
const url = articleUrl(article);
if (!finalText.includes(url)) {
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
finalText = `${finalText}\n\n${buttonText}\n${url}`;
}
}
const params = new URLSearchParams({
owner_id: '-' + String(channel.vk_group_id).replace(/^-/, ''),
from_group: '1',
message: finalText,
access_token: channel.vk_access_token,
v: '5.199',
});
const res = await axios.post('https://api.vk.com/method/wall.post', params, { timeout: 15000 });
if (res.data?.error) throw new Error(`VK: ${res.data.error.error_msg}`);
return res.data?.response?.post_id;
}
async function publishToMax({ channel, text, photoUrl, article }) {
if (!channel.max_channel_id || !channel.max_access_token) {
throw new Error('MAX не настроен');
}
// Заглушка — точный endpoint MAX заполним когда подключим живой канал
throw new Error('MAX публикация не реализована');
}
async function publishOne(scheduledPost) {
const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [scheduledPost.channel_id]);
if (!chRows.length) throw new Error('Channel not found');
const channel = chRows[0];
let text = scheduledPost.custom_text;
let photoUrl = null;
let article = null;
if (scheduledPost.article_id) {
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [scheduledPost.article_id]);
if (!arts.length) throw new Error('Article not found');
article = arts[0];
if (!text) text = renderTemplate(channel.auto_publish_template, article);
// Выбор картинки:
// image_source='zero' — иллюстрация Зеро по позе
// image_source='cover' — обложка статьи (старое поведение)
// image_source='none' — без картинки
const imgSource = channel.auto_publish_image_source || 'cover';
// 'alternating' — чётные article_id = обложка статьи, нечётные = Зеро.
// Это даёт визуальное разнообразие без ручного управления.
const useZero = imgSource === 'zero'
|| (imgSource === 'alternating' && article.id % 2 === 1);
const useCover = imgSource === 'cover'
|| (imgSource === 'alternating' && article.id % 2 === 0);
if (useZero && channel.auto_publish_with_cover !== false) {
const picked = zeroChar.pickPose({
title: article.title,
excerpt: article.excerpt,
category: article.category,
});
if (picked.exists) {
photoUrl = `/uploads/zero-${picked.pose}.webp`;
console.log(`[scheduled-runner] Zero pose=${picked.pose} (${picked.source}) article=${article.id}`);
} else if (article.cover_url) {
// Fallback на обложку если поза ещё не сгенерирована
photoUrl = article.cover_url.startsWith('http')
? article.cover_url
: `https://zeropost.ru${article.cover_url}`;
console.log(`[scheduled-runner] Zero fallback to cover (pose not ready) article=${article.id}`);
}
} else if (useCover && channel.auto_publish_with_cover) {
if (article.cover_url) {
photoUrl = article.cover_url.startsWith('http')
? article.cover_url
: `https://zeropost.ru${article.cover_url}`;
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
} else {
// Обложки нет (ещё генерируется) — fallback на Зеро
const picked = zeroChar.pickPose({
title: article.title,
excerpt: article.excerpt,
category: article.category,
});
if (picked.exists) {
photoUrl = `/uploads/zero-${picked.pose}.webp`;
console.log(`[scheduled-runner] cover fallback → Zero pose=${picked.pose} article=${article.id}`);
}
}
}
// imgSource === 'none' → photoUrl остаётся null
}
if (!text) throw new Error('Empty text and no article');
let messageId;
if (channel.platform === 'telegram' || !channel.platform) {
messageId = await publishToTelegram({ channel, text, photoUrl, article });
} else if (channel.platform === 'vk') {
messageId = await publishToVK({ channel, text, photoUrl, article });
} else if (channel.platform === 'max') {
messageId = await publishToMax({ channel, text, photoUrl, article });
} else {
throw new Error(`Платформа ${channel.platform} не поддерживается`);
}
// Логируем в posts
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, text, channel.platform === 'telegram' ? (messageId || null) : null]
);
return { messageId, channel, article };
}
async function runScheduled() {
const { rows } = await query(
`SELECT * FROM scheduled_posts
WHERE status='pending' AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC LIMIT 20`
);
const results = [];
for (const sp of rows) {
try {
const { messageId } = await publishOne(sp);
await query(
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
[sp.id]
);
results.push({ id: sp.id, ok: true, message_id: messageId });
console.log(`[scheduled-runner] sent id=${sp.id} channel=${sp.channel_id} article=${sp.article_id}`);
} catch (err) {
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
await query(
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
[String(msg).slice(0, 1000), sp.id]
);
results.push({ id: sp.id, ok: false, error: msg });
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
}
}
return { processed: rows.length, results };
}
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
+76
View File
@@ -0,0 +1,76 @@
// Централизованный доступ к app_settings: кэш в памяти + invalidate.
// Источники значения (по приоритету):
// 1. app_settings.value (из БД)
// 2. process.env[key] (fallback на ENV, для секретов и dev)
// 3. defaultValue (зашитый в вызывающем коде)
//
// Кэш TTL = 60 сек, плюс ручной invalidate() после UPDATE из admin UI.
const { query } = require('../config/db');
const TTL_MS = 60_000;
let cache = { data: null, ts: 0 };
async function refreshCache() {
const { rows } = await query('SELECT key, value FROM app_settings');
cache = {
data: Object.fromEntries(rows.map(r => [r.key, r.value])),
ts: Date.now(),
};
return cache.data;
}
async function ensureFresh() {
if (!cache.data || Date.now() - cache.ts > TTL_MS) {
try {
await refreshCache();
} catch (err) {
console.error('[settings] refresh failed, using stale/env:', err.message);
if (!cache.data) cache.data = {};
}
}
return cache.data;
}
async function get(key, defaultValue) {
const data = await ensureFresh();
const fromDb = data[key];
if (fromDb !== undefined && fromDb !== null && fromDb !== '') return fromDb;
if (process.env[key]) return process.env[key];
return defaultValue;
}
async function getMany(keys) {
const data = await ensureFresh();
const out = {};
for (const k of keys) {
out[k] = data[k] ?? process.env[k] ?? null;
}
return out;
}
async function list() {
const { rows } = await query(
`SELECT key, value, description, category, is_secret, updated_at
FROM app_settings ORDER BY category, key`
);
return rows;
}
async function set(key, value) {
const { rows } = await query(
`UPDATE app_settings
SET value=$2, updated_at=NOW()
WHERE key=$1
RETURNING key, value, description, category, is_secret, updated_at`,
[key, value === '' ? null : value]
);
invalidate();
return rows[0] || null;
}
function invalidate() {
cache = { data: null, ts: 0 };
}
module.exports = { get, getMany, list, set, invalidate };
+8 -7
View File
@@ -1,14 +1,15 @@
const { query } = require('../config/db'); const { query } = require('../config/db');
const axios = require('axios'); const axios = require('axios');
const settings = require('../services/settings');
/** /**
* Сохранить пост в базу (как черновик или сразу запланированный). * Сохранить пост в базу (как черновик или сразу запланированный).
*/ */
async function savePost({ userId, channelId, content, imageUrl = null, topic = null, status = 'draft', scheduledAt = null }) { async function savePost({ userId, channelId, content, imageUrl = null, imageCredit = null, topic = null, status = 'draft', scheduledAt = null }) {
const { rows } = await query( const { rows } = await query(
`INSERT INTO user_posts (user_id, channel_id, content, image_url, topic, status, scheduled_at) `INSERT INTO user_posts (user_id, channel_id, content, image_url, image_credit, topic, status, scheduled_at)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[userId, channelId, content, imageUrl, topic, status, scheduledAt] [userId, channelId, content, imageUrl, imageCredit, topic, status, scheduledAt]
); );
return rows[0]; return rows[0];
} }
@@ -30,7 +31,7 @@ async function getPost(userId, postId) {
} }
async function updatePost(userId, postId, data) { async function updatePost(userId, postId, data) {
const allowed = ['content','image_url','status','scheduled_at','topic']; const allowed = ['content','image_url','image_credit','status','scheduled_at','topic'];
const fields = []; const vals = []; let i = 1; const fields = []; const vals = []; let i = 1;
for (const key of allowed) { for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); } if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); }
@@ -63,7 +64,7 @@ async function publishToTelegram(post, channel) {
? post.image_url ? post.image_url
: `https://app.zeropost.ru${post.image_url}`; : `https://app.zeropost.ru${post.image_url}`;
const res = await axios.post( const res = await axios.post(
`https://api.telegram.org/bot${channel.bot_token}/sendPhoto`, `${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendPhoto`,
{ {
chat_id: channel.tg_channel_id, chat_id: channel.tg_channel_id,
photo: photoUrl, photo: photoUrl,
@@ -75,7 +76,7 @@ async function publishToTelegram(post, channel) {
return { ok: true, message_id: res.data?.result?.message_id }; return { ok: true, message_id: res.data?.result?.message_id };
} else { } else {
const res = await axios.post( const res = await axios.post(
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`, `${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendMessage`,
{ {
chat_id: channel.tg_channel_id, chat_id: channel.tg_channel_id,
text: post.content, text: post.content,
+124
View File
@@ -0,0 +1,124 @@
// Маппинг постов на иллюстрации с персонажем Зеро.
// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp
//
// Логика выбора:
// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу
// 2. Иначе — берём позу по категории
// 3. Если в локации файла нет — fallback на 'avatar'
const fs = require('fs');
const path = require('path');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt.
// Порядок важен: первое срабатывание побеждает.
const EMOTIONAL_TRIGGERS = [
// "Получилось / заработало / победа" → victory
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
// "Не работает / сломалось / провал" → facepalm
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
// "Нашёл / открыл / классный" → eureka
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
// "Запутался / непонятно / разбираемся" → confused
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
// "Устал / долго / ночь" → tired
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
// "Изучаю / разбор / гайд / шпаргалка" → reading или present
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
// "Расследую / разбираю / копаю" → magnifier
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
// "Аналитика / метрики / графики" → chart
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
// "Запуск / деплой" → rocket
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
// "Баг / отладка" → bug
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
// "Рекомендация / топ" → thumbsup
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
// "Плавание / спорт" → swimming
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
// "Думаю / вопрос" → thinking
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
// "Исследование" → telescope
{ pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
// "Подумать / поразмышлять / медитация" → meditate
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
];
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
const CATEGORY_POSES = {
'ai-tools': 'tools',
'cybersec': 'lock',
'automation': 'gears',
'ai-dev': 'coding',
};
const FALLBACK_POSE = 'avatar';
/**
* Выбирает имя позы Зеро под пост.
* @param {{ title?: string, excerpt?: string, category?: string }} ctx
* @returns {{ pose: string, path: string|null, exists: boolean }}
*/
function pickPose({ title = '', excerpt = '', category = '' }) {
const haystack = `${title} ${excerpt}`.toLowerCase();
// 1. Эмоциональные триггеры
for (const t of EMOTIONAL_TRIGGERS) {
for (const w of t.words) {
if (haystack.includes(w)) {
return resolve(t.pose, 'emotional');
}
}
}
// 2. По категории
const catPose = CATEGORY_POSES[category];
if (catPose) return resolve(catPose, 'category');
// 3. Fallback
return resolve(FALLBACK_POSE, 'fallback');
}
function resolve(name, source) {
const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
const exists = fs.existsSync(localPath);
// Если позы нет — пробуем avatar
if (!exists && name !== FALLBACK_POSE) {
const fbPath = path.join(UPLOADS_DIR, `zero-${FALLBACK_POSE}.webp`);
if (fs.existsSync(fbPath)) {
return { pose: FALLBACK_POSE, path: fbPath, exists: true, source: `${source}-fallback` };
}
return { pose: name, path: null, exists: false, source };
}
return { pose: name, path: exists ? localPath : null, exists, source };
}
/**
* Список доступных поз (для UI).
*/
function listAvailablePoses() {
const out = [];
for (const name of [
'avatar', 'coding', 'tools', 'lock', 'gears',
'eureka', 'confused', 'facepalm', 'victory', 'tired',
'reading', 'magnifier', 'chart', 'meditate', 'present',
'swimming', 'thinking', 'coffee', 'telescope', 'rocket', 'bug', 'sleep', 'thumbsup',
]) {
const p = path.join(UPLOADS_DIR, `zero-${name}.webp`);
out.push({ name, exists: fs.existsSync(p), path: p, url: `/uploads/zero-${name}.webp` });
}
return out;
}
module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS };