diff --git a/.gitignore b/.gitignore
index 2e8157a..7649904 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
node_modules/
.env
*.log
+.env.bak
+*.bak
+deploy.sh
diff --git a/index.js b/index.js
index 5ee3ceb..92e5e92 100644
--- a/index.js
+++ b/index.js
@@ -13,6 +13,10 @@ const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories');
const autogenRoutes = require('./src/routes/autogen');
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
require('./src/workers/generation');
@@ -48,6 +52,10 @@ app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes);
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) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
diff --git a/package-lock.json b/package-lock.json
index 4fb164d..895b505 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"bull": "^4.16.5",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
@@ -971,6 +972,24 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1895,6 +1914,18 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index ccabe52..c4a7116 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"bull": "^4.16.5",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "fast-xml-parser": "^4.5.6",
"ioredis": "^5.11.0",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
diff --git a/scripts/generate-zero-poses-v2.js b/scripts/generate-zero-poses-v2.js
new file mode 100644
index 0000000..f48ab59
--- /dev/null
+++ b/scripts/generate-zero-poses-v2.js
@@ -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);
+})();
diff --git a/scripts/generate-zero-poses.js b/scripts/generate-zero-poses.js
new file mode 100644
index 0000000..bfaf903
--- /dev/null
+++ b/scripts/generate-zero-poses.js
@@ -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);
+});
diff --git a/scripts/send-2-zero.js b/scripts/send-2-zero.js
new file mode 100644
index 0000000..3e22b88
--- /dev/null
+++ b/scripts/send-2-zero.js
@@ -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));
diff --git a/scripts/send-welcome-post.js b/scripts/send-welcome-post.js
new file mode 100644
index 0000000..8b85662
--- /dev/null
+++ b/scripts/send-welcome-post.js
@@ -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);
+ }
+})();
diff --git a/scripts/send-zero-welcome.js b/scripts/send-zero-welcome.js
new file mode 100644
index 0000000..ba27c21
--- /dev/null
+++ b/scripts/send-zero-welcome.js
@@ -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);
+ }
+})();
diff --git a/scripts/test-image-api.js b/scripts/test-image-api.js
new file mode 100644
index 0000000..ee88d8e
--- /dev/null
+++ b/scripts/test-image-api.js
@@ -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);
+ }
+})();
diff --git a/scripts/test-zero-character.js b/scripts/test-zero-character.js
new file mode 100644
index 0000000..912d0b8
--- /dev/null
+++ b/scripts/test-zero-character.js
@@ -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);
+ }
+})();
diff --git a/src/routes/articles.js b/src/routes/articles.js
index 475e721..f3f4540 100644
--- a/src/routes/articles.js
+++ b/src/routes/articles.js
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const articlesSvc = require('../services/articles');
+const autoPublish = require('../services/articleAutoPublish');
+const autoSeries = require('../services/articleAutoSeries');
const { query } = require('../config/db');
// GET /api/articles — список опубликованных
@@ -23,6 +25,15 @@ router.get('/tags', async (_, res) => {
} 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 — все статьи для админки (включая черновики)
router.get('/admin', async (req, res) => {
try {
@@ -38,6 +49,61 @@ router.get('/admin', async (req, res) => {
} 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
router.get('/id/:id', async (req, res) => {
try {
@@ -50,9 +116,18 @@ router.get('/id/:id', async (req, res) => {
// POST /api/articles/generate
router.post('/generate', async (req, res) => {
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' });
- 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);
} catch (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' });
fields.push(`updated_at=NOW()`);
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(
`UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`,
vals
);
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]);
} catch (err) { res.status(500).json({ error: err.message }); }
});
diff --git a/src/routes/autogen.js b/src/routes/autogen.js
index 06bd129..efbbfc2 100644
--- a/src/routes/autogen.js
+++ b/src/routes/autogen.js
@@ -28,13 +28,13 @@ router.patch('/settings/:category', async (req, res) => {
try {
const { enabled, per_day, run_hour, run_minute } = req.body;
const fields = []; const vals = []; let i = 1;
- if (enabled !== undefined) { fields.push(`enabled=${i++}`); vals.push(enabled); }
- 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_minute !== undefined) { fields.push(`run_minute=${i++}`); vals.push(run_minute); }
+ if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
+ 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_minute !== undefined) { fields.push(`run_minute=$${i++}`); vals.push(run_minute); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
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 });
} catch (err) { res.status(500).json({ error: err.message }); }
});
diff --git a/src/routes/channelStats.js b/src/routes/channelStats.js
new file mode 100644
index 0000000..b85b12d
--- /dev/null
+++ b/src/routes/channelStats.js
@@ -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;
diff --git a/src/routes/channels.js b/src/routes/channels.js
index 00fae28..70dba4d 100644
--- a/src/routes/channels.js
+++ b/src/routes/channels.js
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const channelsSvc = require('../services/channels');
+const settings = require('../services/settings');
+const autoPublish = require('../services/articleAutoPublish');
const { query } = require('../config/db');
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,
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_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
res.json(rows[0]);
@@ -60,13 +61,18 @@ router.post('/admin', async (req, res) => {
// PATCH /api/channels/admin/:id — обновить системный канал
router.patch('/admin/:id', async (req, res) => {
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',
- '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;
for (const key of allowed) {
if (req.body[key] !== undefined) {
- fields.push(`${key}=${i++}`);
+ fields.push(`${key}=$${i++}`);
vals.push(req.body[key]);
}
}
@@ -74,7 +80,7 @@ router.patch('/admin/:id', async (req, res) => {
fields.push(`updated_at=NOW()`);
vals.push(req.params.id);
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
);
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 }); }
});
-// 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) => {
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]);
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
const channel = rows[0];
- let text = custom_text;
- // Если текст не передан — берём статью и генерируем пост
- if (!text && article_id) {
- const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
- if (!arts.length) return res.status(404).json({ error: 'Article not found' });
- const art = arts[0];
- // Простой текст поста из заголовка и excerpt
- 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' });
+ // Создаём временный scheduled_post на NOW и сразу запускаем runner на него.
+ const { rows: spRows } = await query(
+ `INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at, status)
+ VALUES ($1,$2,$3,NOW(),'pending') RETURNING *`,
+ [channel.id, article_id || null, custom_text || null]
+ );
+ const sp = spRows[0];
- const result = { ok: true, platform: channel.platform, text };
-
- // Telegram
- if (channel.platform === 'telegram' && channel.bot_token && channel.tg_channel_id) {
- 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;
- // Сохраняем пост
+ // Точечный запуск
+ const runner = require('../services/scheduledPostsRunner');
+ try {
+ const { messageId } = await runner.publishOne(sp);
await query(
- `INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
- VALUES ($1,$2,'published',NOW(),$3)`,
- [channel.id, text, result.tg_message_id || null]
+ `UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
+ [sp.id]
);
+ 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) {
const msg = err.response?.data?.description || err.message;
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 }); }
});
-
-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 ─────────────────────────────────────────────────────────────
// 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 }); }
});
-// GET /api/channels/admin/:id/scheduled — запланированные посты
+// GET /api/channels/admin/:id/scheduled — запланированные посты канала (pending+failed+sent последние)
router.get('/admin/:id/scheduled', async (req, res) => {
try {
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
LEFT JOIN articles a ON a.id = sp.article_id
WHERE sp.channel_id=$1
@@ -267,15 +204,80 @@ router.get('/admin/:id/scheduled', async (req, res) => {
});
// POST /api/channels/admin/:id/schedule — поставить пост в очередь
+// scheduled_at: если не передан — берём ближайший слот канала (через autoPublish.pickScheduleTime).
router.post('/admin/:id/schedule', async (req, res) => {
try {
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(
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at)
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]);
} 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;
diff --git a/src/routes/photo-search.js b/src/routes/photo-search.js
new file mode 100644
index 0000000..bc6df24
--- /dev/null
+++ b/src/routes/photo-search.js
@@ -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;
diff --git a/src/routes/posts.js b/src/routes/posts.js
index d0a38aa..3ccb0ed 100644
--- a/src/routes/posts.js
+++ b/src/routes/posts.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const axios = require('axios');
+const settings = require('../services/settings');
// POST /api/posts/publish - publish a post to Telegram immediately
router.post('/publish', async (req, res) => {
@@ -18,7 +19,7 @@ router.post('/publish', async (req, res) => {
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' });
- 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,
text: content,
parse_mode: 'HTML',
diff --git a/src/routes/scheduledPosts.js b/src/routes/scheduledPosts.js
new file mode 100644
index 0000000..6c94a97
--- /dev/null
+++ b/src/routes/scheduledPosts.js
@@ -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;
diff --git a/src/routes/settings.js b/src/routes/settings.js
new file mode 100644
index 0000000..3eb1c3c
--- /dev/null
+++ b/src/routes/settings.js
@@ -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;
diff --git a/src/routes/userPosts.js b/src/routes/userPosts.js
index a8c55fa..698d265 100644
--- a/src/routes/userPosts.js
+++ b/src/routes/userPosts.js
@@ -24,11 +24,13 @@ router.post('/', async (req, res) => {
try {
const userId = getUserId(req);
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' });
const post = await svc.savePost({
userId, channelId: channel_id, content,
- imageUrl: image_url, topic,
+ imageUrl: image_url,
+ imageCredit: image_credit ?? null,
+ topic,
status: status || 'draft',
scheduledAt: scheduled_at,
});
diff --git a/src/services/articleAutoPublish.js b/src/services/articleAutoPublish.js
new file mode 100644
index 0000000..90415a2
--- /dev/null
+++ b/src/services/articleAutoPublish.js
@@ -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 };
diff --git a/src/services/articleAutoSeries.js b/src/services/articleAutoSeries.js
new file mode 100644
index 0000000..df58be1
--- /dev/null
+++ b/src/services/articleAutoSeries.js
@@ -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 };
diff --git a/src/services/articles.js b/src/services/articles.js
index 92ccd27..92acada 100644
--- a/src/services/articles.js
+++ b/src/services/articles.js
@@ -1,6 +1,8 @@
const { query } = require('../config/db');
const ai = require('./ai');
const covers = require('./covers');
+// Ленивый импорт чтобы избежать circular dependency
+function getAutoPublish() { return require('./articleAutoPublish'); }
/**
* 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
FROM articles WHERE status='published'`;
const params = [];
- if (tag) { sql += ` AND tags ? ${params.length + 1}`; params.push(tag); }
- if (category) { sql += ` AND category=${params.length + 1}`; params.push(category); }
+ if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
+ if (category) { params.push(category); sql += ` AND category=$${params.length}`; }
sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(limit, offset);
const { rows } = await query(sql, params);
@@ -79,23 +81,40 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
const jobId = jobRows[0].id;
try {
- // Универсальный "channel" для блога — с правилами человечности и нашим стилем
+ // ZeroPost — блог от лица персонажа «Зеро».
+ // Дружелюбный энтузиаст, делится тем что попробовал. От первого лица.
const blogChannel = {
name: 'ZeroPost',
- niche: 'Практические материалы про ИИ для людей, которые применяют его в работе',
- audience: 'Маркетологи, продакты, разработчики, основатели — те, кто хочет применять ИИ практически',
- goal: 'expert',
+ niche: 'ИИ, кибербезопасность, автоматизация и разработка — то что Зеро попробовал и хочет рассказать',
+ audience: 'Технари, разработчики, продакты, IT-специалисты — те кому интересны рабочие приёмы, а не теория',
+ goal: 'personal',
language: 'ru',
region: 'ru',
+ author_persona: {
+ name: 'Зеро',
+ identity: 'Я — Зеро, ИИ-маскот канала ZeroPost. Дружелюбный любопытный энтузиаст, который пробует разные инструменты и делится тем что узнал. Открыто говорю что я ИИ, не скрываю.',
+ voice: 'От первого лица. Конкретные истории > абстрактные советы. Признаю когда что-то не получилось.',
+ forbidden_phrases: ['как ИИ, я', 'я как языковая модель', 'в современном мире', 'революционный', 'уникальный', 'важно отметить', 'стоит подчеркнуть', 'поистине', 'давайте рассмотрим', 'итак', 'таким образом', 'в данной статье'],
+ },
style: {
- tone: 'friendly',
+ tone: 'friendly_enthusiast',
formality: 'informal',
humor: 'dry',
- post_length: 'long',
+ post_length: 'medium-long',
structure: 'headers',
- emoji_level: 'none',
+ emoji_level: 'minimal',
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 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(
`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 *`,
[
slug, title, excerpt, content,
- JSON.stringify(tags),
+ JSON.stringify(cleanTags),
category,
readingTime,
autoPublish ? 'published' : 'draft',
@@ -135,13 +162,23 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
[content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId]
);
- // Фоновая генерация обложки — не блокирует возврат статьи
+ // Фоновые задачи после сохранения — не блокируют возврат статьи
setImmediate(() => {
+ // Генерация обложки
covers.generateCover({
articleId: artRows[0].id,
title: artRows[0].title,
tags: artRows[0].tags || [],
}).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];
@@ -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 = {
slugify,
listArticles,
@@ -161,3 +260,5 @@ module.exports = {
getAllTags,
generateAndSaveArticle,
};
+
+module.exports.getHomeArticles = getHomeArticles;
diff --git a/src/services/autogen.js b/src/services/autogen.js
index d8ffeed..5885427 100644
--- a/src/services/autogen.js
+++ b/src/services/autogen.js
@@ -79,7 +79,7 @@ async function getNextTopic(category) {
const unused = bank.filter(t => !usedTitles.some(u => u.includes(t.slice(0, 20).toLowerCase())));
const pool = unused.length > 0 ? unused : bank;
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 {
const article = await generateAndSaveArticle({
topic,
- tags: [...tags, category],
+ tags: tags,
keywords,
autoPublish: true,
category,
diff --git a/src/services/channelStats.js b/src/services/channelStats.js
new file mode 100644
index 0000000..5e0e3c9
--- /dev/null
+++ b/src/services/channelStats.js
@@ -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 };
diff --git a/src/services/covers.js b/src/services/covers.js
index e4891b7..52013d1 100644
--- a/src/services/covers.js
+++ b/src/services/covers.js
@@ -3,6 +3,7 @@ const path = require('path');
const axios = require('axios');
const config = require('../config');
const { query } = require('../config/db');
+const localGen = require('./localCoverGenerator');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
@@ -159,11 +160,34 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
throw new Error('No image data');
}
+/**
+ * Резервный путь — Pollinations.AI (https://pollinations.ai).
+ * 100% бесплатно, без API ключа, без регистрации.
+ * GET запрос → JPEG обложка за ~1-2 секунды.
+ * Используется только когда aiprimetech.io недоступен.
+ */
+async function generateCoverViaPollinations({ prompt }) {
+ // Pollinations: простой GET по URL, сразу возвращает бинарный JPEG
+ const encoded = encodeURIComponent(prompt.slice(0, 1000)); // лимит на длину URL
+ const url = `https://image.pollinations.ai/prompt/${encoded}?width=1600&height=900&model=flux&nologo=true`;
+ const res = await axios.get(url, {
+ responseType: 'arraybuffer',
+ timeout: 90_000, // Pollinations иногда медленный при нагрузке
+ headers: { 'User-Agent': 'ZeroPost/1.0 blog-cover-generator' },
+ });
+ if (!res.data || res.data.byteLength < 5000) {
+ throw new Error(`Pollinations returned too small response: ${res.data?.byteLength} bytes`);
+ }
+ return {
+ bytes: Buffer.from(res.data),
+ format: 'jpg',
+ };
+}
+
/**
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
*/
async function generateCover({ articleId, title, tags = [] }) {
- // Передаём articleId в buildCoverPrompt для детерминированного выбора стиля
const prompt = buildCoverPrompt({ title, tags, articleId });
const styleIdx = pickStyleIndex(articleId);
const styleName = COVER_STYLES[styleIdx].name;
@@ -171,20 +195,36 @@ async function generateCover({ articleId, title, tags = [] }) {
let img;
let usedPath = 'responses';
+
+ // Пробуем все внешние API, при любой ошибке — сразу local SVG
try {
- img = await generateCoverViaResponses({ prompt });
- } catch (err) {
- const msg = err.response?.data?.error?.message || err.message;
- console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
- // Пробуем legacy
try {
- img = await generateCoverViaImagesEndpoint({ prompt });
- usedPath = 'images-legacy';
- } catch (err2) {
- const msg2 = err2.response?.data?.error?.message || err2.message;
- console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
- throw new Error(`Both image paths failed: ${msg}`);
+ img = await generateCoverViaResponses({ prompt });
+ } catch (err) {
+ const msg = err.response?.data?.error?.message || err.message;
+ console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
+ try {
+ img = await generateCoverViaImagesEndpoint({ prompt });
+ usedPath = 'images-legacy';
+ } catch (err2) {
+ const msg2 = err2.response?.data?.error?.message || err2.message;
+ console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
+ try {
+ img = await generateCoverViaPollinations({ prompt });
+ usedPath = 'pollinations';
+ console.log(`[Cover] article=${articleId} using Pollinations.AI fallback`);
+ } catch (err3) {
+ console.warn(`[Cover] Pollinations fallback failed: ${err3.message.slice(0, 200)}`);
+ throw new Error('all_external_failed');
+ }
+ }
}
+ } catch (outerErr) {
+ // Все внешние API упали — используем локальную SVG-генерацию
+ console.log(`[Cover] article=${articleId} → local SVG generator (all external APIs unavailable)`);
+ const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
+ await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
+ return localUrl;
}
// Сохраняем оригинал
diff --git a/src/services/localCoverGenerator.js b/src/services/localCoverGenerator.js
new file mode 100644
index 0000000..be9e19c
--- /dev/null
+++ b/src/services/localCoverGenerator.js
@@ -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 += ``;
+ }
+ shapes += ``;
+ shapes += ``;
+
+ } 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 += ``;
+ }
+ shapes += ``;
+ shapes += ``;
+
+ } 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 += ``;
+ }
+ shapes += ``;
+ shapes += ``;
+
+ } 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 += ``;
+ }
+ }
+ }
+ // Большой акцентный круг
+ shapes += ``;
+
+ } 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 += ``;
+ }
+ }
+
+ // Частицы-точки (всегда)
+ 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 ``;
+ }).join('');
+
+ // Мягкий градиент-оверлей
+ const overlay = `
+
+
+
+
+
+
+ `;
+
+ return ``;
+}
+
+/**
+ * Сгенерировать и сохранить 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 };
diff --git a/src/services/photo-search.js b/src/services/photo-search.js
new file mode 100644
index 0000000..2d40ec7
--- /dev/null
+++ b/src/services/photo-search.js
@@ -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 ');
+ }
+ 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 };
diff --git a/src/services/promptBuilder.js b/src/services/promptBuilder.js
index 917f784..f2836dc 100644
--- a/src/services/promptBuilder.js
+++ b/src/services/promptBuilder.js
@@ -196,9 +196,40 @@ ${style.banned_topics?.length ? `НЕ трогай темы: ${style.banned_topi
*/
function buildArticleSystemPrompt(channel, keywords = []) {
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 ? 'заметка' : 'статья'} звучит "по-нейросетевому" — она провалена.
═══════════════════════════════════════════════════════════
ЯЗЫК И СТИЛЬ — критично, читай внимательно
diff --git a/src/services/scheduledPostsRunner.js b/src/services/scheduledPostsRunner.js
new file mode 100644
index 0000000..558377b
--- /dev/null
+++ b/src/services/scheduledPostsRunner.js
@@ -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 };
diff --git a/src/services/settings.js b/src/services/settings.js
new file mode 100644
index 0000000..5998784
--- /dev/null
+++ b/src/services/settings.js
@@ -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 };
diff --git a/src/services/userPosts.js b/src/services/userPosts.js
index ceaf259..63d06ec 100644
--- a/src/services/userPosts.js
+++ b/src/services/userPosts.js
@@ -1,14 +1,15 @@
const { query } = require('../config/db');
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(
- `INSERT INTO user_posts (user_id, channel_id, content, image_url, topic, status, scheduled_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
- [userId, channelId, content, imageUrl, topic, status, scheduledAt]
+ `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,$8) RETURNING *`,
+ [userId, channelId, content, imageUrl, imageCredit, topic, status, scheduledAt]
);
return rows[0];
}
@@ -30,7 +31,7 @@ async function getPost(userId, postId) {
}
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;
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); }
@@ -63,7 +64,7 @@ async function publishToTelegram(post, channel) {
? post.image_url
: `https://app.zeropost.ru${post.image_url}`;
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,
photo: photoUrl,
@@ -75,7 +76,7 @@ async function publishToTelegram(post, channel) {
return { ok: true, message_id: res.data?.result?.message_id };
} else {
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,
text: post.content,
diff --git a/src/services/zeroCharacter.js b/src/services/zeroCharacter.js
new file mode 100644
index 0000000..dffb6bb
--- /dev/null
+++ b/src/services/zeroCharacter.js
@@ -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 };