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