From a370b8f7d838de1ae41a815daaff2dd32c0105a4 Mon Sep 17 00:00:00 2001 From: "Nik (Claude)" Date: Sun, 7 Jun 2026 14:03:56 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=97=D0=B5=D1=80=D0=BE-=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=B6,=20auto-publish,=20auto-se?= =?UTF-8?q?ries,=20channel-stats,=20fallback=20covers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации - Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover - Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр) - Auto-series: Claude haiku определяет серию для каждой статьи автоматически - Channel stats: подписчики, история, delta 24h/7d - Photo-search: Yandex API, профили доменов, Redis лимиты - Scheduled posts runner: backfill, preview, queue, cancel - promptBuilder: author_persona Зеро, голос от первого лица - Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры - AI model: gpt-5.5 для image generation --- .gitignore | 3 + index.js | 8 + package-lock.json | 31 +++ package.json | 1 + scripts/generate-zero-poses-v2.js | 140 ++++++++++++++ scripts/generate-zero-poses.js | 151 +++++++++++++++ scripts/send-2-zero.js | 37 ++++ scripts/send-welcome-post.js | 143 ++++++++++++++ scripts/send-zero-welcome.js | 63 ++++++ scripts/test-image-api.js | 28 +++ scripts/test-zero-character.js | 147 ++++++++++++++ src/routes/articles.js | 96 +++++++++- src/routes/autogen.js | 10 +- src/routes/channelStats.js | 31 +++ src/routes/channels.js | 206 ++++++++++---------- src/routes/photo-search.js | 51 +++++ src/routes/posts.js | 3 +- src/routes/scheduledPosts.js | 135 +++++++++++++ src/routes/settings.js | 35 ++++ src/routes/userPosts.js | 6 +- src/services/articleAutoPublish.js | 96 ++++++++++ src/services/articleAutoSeries.js | 133 +++++++++++++ src/services/articles.js | 125 ++++++++++-- src/services/autogen.js | 4 +- src/services/channelStats.js | 132 +++++++++++++ src/services/covers.js | 64 +++++-- src/services/localCoverGenerator.js | 174 +++++++++++++++++ src/services/photo-search.js | 262 +++++++++++++++++++++++++ src/services/promptBuilder.js | 35 +++- src/services/scheduledPostsRunner.js | 277 +++++++++++++++++++++++++++ src/services/settings.js | 76 ++++++++ src/services/userPosts.js | 15 +- src/services/zeroCharacter.js | 124 ++++++++++++ 33 files changed, 2695 insertions(+), 147 deletions(-) create mode 100644 scripts/generate-zero-poses-v2.js create mode 100644 scripts/generate-zero-poses.js create mode 100644 scripts/send-2-zero.js create mode 100644 scripts/send-welcome-post.js create mode 100644 scripts/send-zero-welcome.js create mode 100644 scripts/test-image-api.js create mode 100644 scripts/test-zero-character.js create mode 100644 src/routes/channelStats.js create mode 100644 src/routes/photo-search.js create mode 100644 src/routes/scheduledPosts.js create mode 100644 src/routes/settings.js create mode 100644 src/services/articleAutoPublish.js create mode 100644 src/services/articleAutoSeries.js create mode 100644 src/services/channelStats.js create mode 100644 src/services/localCoverGenerator.js create mode 100644 src/services/photo-search.js create mode 100644 src/services/scheduledPostsRunner.js create mode 100644 src/services/settings.js create mode 100644 src/services/zeroCharacter.js 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 ` + + ${shapes} + ${dots} + ${overlay} +`; +} + +/** + * Сгенерировать и сохранить 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 };