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:
+94
-2
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const articlesSvc = require('../services/articles');
|
||||
const autoPublish = require('../services/articleAutoPublish');
|
||||
const autoSeries = require('../services/articleAutoSeries');
|
||||
const { query } = require('../config/db');
|
||||
|
||||
// GET /api/articles — список опубликованных
|
||||
@@ -23,6 +25,15 @@ router.get('/tags', async (_, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
|
||||
// GET /api/articles/home — данные для главной страницы (hero, byCategory, popular, recent)
|
||||
router.get('/home', async (req, res) => {
|
||||
try {
|
||||
const data = await articlesSvc.getHomeArticles();
|
||||
res.json(data);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/articles/admin — все статьи для админки (включая черновики)
|
||||
router.get('/admin', async (req, res) => {
|
||||
try {
|
||||
@@ -38,6 +49,61 @@ router.get('/admin', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/articles/admin/search — typeahead-поиск по статьям.
|
||||
// Параметры: q (подстрока в title), status (default published), category, limit (default 20),
|
||||
// channel_id (если задан — пометит already_in_channel, was_published_in_channel)
|
||||
router.get('/admin/search', async (req, res) => {
|
||||
try {
|
||||
const q = (req.query.q || '').trim();
|
||||
const status = req.query.status || 'published';
|
||||
const category = req.query.category || null;
|
||||
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
||||
const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null;
|
||||
|
||||
const params = [];
|
||||
let where = [];
|
||||
if (status && status !== 'any') { params.push(status); where.push(`status=$${params.length}`); }
|
||||
if (category) { params.push(category); where.push(`category=$${params.length}`); }
|
||||
if (q) { params.push(`%${q.toLowerCase()}%`); where.push(`lower(title) LIKE $${params.length}`); }
|
||||
|
||||
params.push(limit);
|
||||
const sql = `
|
||||
SELECT id, slug, title, excerpt, cover_url, category, status, published_at
|
||||
FROM articles
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY published_at DESC NULLS LAST, created_at DESC
|
||||
LIMIT $${params.length}`;
|
||||
const { rows: items } = await query(sql, params);
|
||||
|
||||
// Если задан channel_id — для каждого item ищем, был ли уже опубликован в этом канале (через scheduled_posts.status='sent')
|
||||
if (channelId && items.length) {
|
||||
const ids = items.map(a => a.id);
|
||||
const { rows: sent } = await query(
|
||||
`SELECT article_id, MAX(published_at) AS last_sent_at
|
||||
FROM scheduled_posts
|
||||
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='sent'
|
||||
GROUP BY article_id`,
|
||||
[channelId, ids]
|
||||
);
|
||||
const sentMap = Object.fromEntries(sent.map(r => [r.article_id, r.last_sent_at]));
|
||||
const { rows: pending } = await query(
|
||||
`SELECT article_id, MIN(scheduled_at) AS next_scheduled_at
|
||||
FROM scheduled_posts
|
||||
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='pending'
|
||||
GROUP BY article_id`,
|
||||
[channelId, ids]
|
||||
);
|
||||
const pendingMap = Object.fromEntries(pending.map(r => [r.article_id, r.next_scheduled_at]));
|
||||
for (const it of items) {
|
||||
it.was_sent_to_channel = sentMap[it.id] || null;
|
||||
it.next_scheduled_at = pendingMap[it.id] || null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ items, count: items.length });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/articles/id/:id — одна статья по числовому id
|
||||
router.get('/id/:id', async (req, res) => {
|
||||
try {
|
||||
@@ -50,9 +116,18 @@ router.get('/id/:id', async (req, res) => {
|
||||
// POST /api/articles/generate
|
||||
router.post('/generate', async (req, res) => {
|
||||
try {
|
||||
const { topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools' } = req.body;
|
||||
const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools' } = req.body;
|
||||
if (!topic) return res.status(400).json({ error: 'topic is required' });
|
||||
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish, category });
|
||||
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category });
|
||||
// Hook: автопубликация в каналы
|
||||
if (article && article.status === 'published') {
|
||||
autoPublish.scheduleForArticle(article.id).catch(err => {
|
||||
console.error('[articles] auto-publish hook failed:', err.message);
|
||||
});
|
||||
autoSeries.addToSeries(article.id).catch(err => {
|
||||
console.error('[articles] auto-series hook failed:', err.message);
|
||||
});
|
||||
}
|
||||
res.json(article);
|
||||
} catch (err) {
|
||||
console.error('[Articles] generate', err);
|
||||
@@ -84,11 +159,28 @@ router.patch('/:id', async (req, res) => {
|
||||
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
fields.push(`updated_at=NOW()`);
|
||||
vals.push(req.params.id);
|
||||
|
||||
// Сначала проверим прежний status — чтобы понимать, был ли переход draft → published
|
||||
const { rows: prevRows } = await query(`SELECT status FROM articles WHERE id=$1`, [req.params.id]);
|
||||
const prevStatus = prevRows[0]?.status;
|
||||
|
||||
const { rows } = await query(
|
||||
`UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
// Hook: если статья только что стала published
|
||||
const newStatus = rows[0].status;
|
||||
if (newStatus === 'published' && prevStatus !== 'published') {
|
||||
autoPublish.scheduleForArticle(rows[0].id).catch(err => {
|
||||
console.error('[articles] auto-publish hook failed:', err.message);
|
||||
});
|
||||
autoSeries.addToSeries(rows[0].id).catch(err => {
|
||||
console.error('[articles] auto-series hook failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
@@ -28,13 +28,13 @@ router.patch('/settings/:category', async (req, res) => {
|
||||
try {
|
||||
const { enabled, per_day, run_hour, run_minute } = req.body;
|
||||
const fields = []; const vals = []; let i = 1;
|
||||
if (enabled !== undefined) { fields.push(`enabled=${i++}`); vals.push(enabled); }
|
||||
if (per_day !== undefined) { fields.push(`per_day=${i++}`); vals.push(per_day); }
|
||||
if (run_hour !== undefined) { fields.push(`run_hour=${i++}`); vals.push(run_hour); }
|
||||
if (run_minute !== undefined) { fields.push(`run_minute=${i++}`); vals.push(run_minute); }
|
||||
if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
|
||||
if (per_day !== undefined) { fields.push(`per_day=$${i++}`); vals.push(per_day); }
|
||||
if (run_hour !== undefined) { fields.push(`run_hour=$${i++}`); vals.push(run_hour); }
|
||||
if (run_minute !== undefined) { fields.push(`run_minute=$${i++}`); vals.push(run_minute); }
|
||||
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
vals.push(req.params.category);
|
||||
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=${i}`, vals);
|
||||
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=$${i}`, vals);
|
||||
res.json({ ok: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
@@ -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 router = express.Router();
|
||||
const channelsSvc = require('../services/channels');
|
||||
const settings = require('../services/settings');
|
||||
const autoPublish = require('../services/articleAutoPublish');
|
||||
const { query } = require('../config/db');
|
||||
|
||||
const getUserId = (req) => {
|
||||
@@ -50,7 +52,6 @@ router.post('/admin', async (req, res) => {
|
||||
vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null,
|
||||
niche||null, audience||null, goal]
|
||||
);
|
||||
// Создаём style и schedule по умолчанию
|
||||
await query(`INSERT INTO channel_style (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
|
||||
await query(`INSERT INTO channel_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
|
||||
res.json(rows[0]);
|
||||
@@ -60,13 +61,18 @@ router.post('/admin', async (req, res) => {
|
||||
// PATCH /api/channels/admin/:id — обновить системный канал
|
||||
router.patch('/admin/:id', async (req, res) => {
|
||||
try {
|
||||
const allowed = ['name','platform','tg_channel_id','tg_username','bot_token',
|
||||
const allowed = [
|
||||
'name','platform','tg_channel_id','tg_username','bot_token',
|
||||
'vk_group_id','vk_access_token','max_channel_id','max_access_token',
|
||||
'niche','audience','goal','is_active'];
|
||||
'niche','audience','goal','is_active',
|
||||
// autopublish
|
||||
'auto_publish_enabled','auto_publish_categories','auto_publish_delay_min',
|
||||
'auto_publish_template','auto_publish_with_cover','auto_publish_button_text','auto_publish_image_source',
|
||||
];
|
||||
const fields = []; const vals = []; let i = 1;
|
||||
for (const key of allowed) {
|
||||
if (req.body[key] !== undefined) {
|
||||
fields.push(`${key}=${i++}`);
|
||||
fields.push(`${key}=$${i++}`);
|
||||
vals.push(req.body[key]);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +80,7 @@ router.patch('/admin/:id', async (req, res) => {
|
||||
fields.push(`updated_at=NOW()`);
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await query(
|
||||
`UPDATE channels SET ${fields.join(',')} WHERE id=${i} AND is_system=true RETURNING *`,
|
||||
`UPDATE channels SET ${fields.join(',')} WHERE id=$${i} AND is_system=true RETURNING *`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -90,45 +96,40 @@ router.delete('/admin/:id', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// POST /api/channels/admin/:id/publish — опубликовать статью в канал
|
||||
// POST /api/channels/admin/:id/publish — опубликовать статью в канал ПРЯМО СЕЙЧАС
|
||||
// Использует channel.auto_publish_template (если есть) и channel.auto_publish_with_cover.
|
||||
router.post('/admin/:id/publish', async (req, res) => {
|
||||
try {
|
||||
const { article_id, custom_text } = req.body;
|
||||
const { article_id, custom_text, with_cover } = req.body;
|
||||
const { rows } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
|
||||
const channel = rows[0];
|
||||
|
||||
let text = custom_text;
|
||||
// Если текст не передан — берём статью и генерируем пост
|
||||
if (!text && article_id) {
|
||||
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
|
||||
if (!arts.length) return res.status(404).json({ error: 'Article not found' });
|
||||
const art = arts[0];
|
||||
// Простой текст поста из заголовка и excerpt
|
||||
text = `*${art.title}*\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`;
|
||||
}
|
||||
if (!text) return res.status(400).json({ error: 'text or article_id required' });
|
||||
// Создаём временный scheduled_post на NOW и сразу запускаем runner на него.
|
||||
const { rows: spRows } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at, status)
|
||||
VALUES ($1,$2,$3,NOW(),'pending') RETURNING *`,
|
||||
[channel.id, article_id || null, custom_text || null]
|
||||
);
|
||||
const sp = spRows[0];
|
||||
|
||||
const result = { ok: true, platform: channel.platform, text };
|
||||
|
||||
// Telegram
|
||||
if (channel.platform === 'telegram' && channel.bot_token && channel.tg_channel_id) {
|
||||
const axios = require('axios');
|
||||
const tgRes = await axios.post(
|
||||
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`,
|
||||
{ chat_id: channel.tg_channel_id, text, parse_mode: 'Markdown', disable_web_page_preview: false },
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
result.tg_message_id = tgRes.data?.result?.message_id;
|
||||
// Сохраняем пост
|
||||
// Точечный запуск
|
||||
const runner = require('../services/scheduledPostsRunner');
|
||||
try {
|
||||
const { messageId } = await runner.publishOne(sp);
|
||||
await query(
|
||||
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
|
||||
VALUES ($1,$2,'published',NOW(),$3)`,
|
||||
[channel.id, text, result.tg_message_id || null]
|
||||
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
|
||||
[sp.id]
|
||||
);
|
||||
return res.json({ ok: true, platform: channel.platform, tg_message_id: messageId || null, scheduled_post_id: sp.id });
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
|
||||
await query(
|
||||
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
|
||||
[String(msg).slice(0, 1000), sp.id]
|
||||
);
|
||||
return res.status(500).json({ error: msg });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.description || err.message;
|
||||
res.status(500).json({ error: msg });
|
||||
@@ -146,70 +147,6 @@ router.get('/admin/:id/posts', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channels = await channelsSvc.listChannels(userId);
|
||||
res.json(channels);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/channels/:id — один канал со всеми настройками
|
||||
router.get('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
|
||||
if (!channel) return res.status(404).json({ error: 'Channel not found' });
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/channels — создать канал
|
||||
router.post('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.createChannel(userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
console.error('[Route] POST /channels', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/channels/:id — обновить
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/channels/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
await channelsSvc.deleteChannel(req.params.id, userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ── Publish slots ─────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/channels/admin/:id/slots
|
||||
@@ -251,11 +188,11 @@ router.delete('/admin/:id/slots/:slotId', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/channels/admin/:id/scheduled — запланированные посты
|
||||
// GET /api/channels/admin/:id/scheduled — запланированные посты канала (pending+failed+sent последние)
|
||||
router.get('/admin/:id/scheduled', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await query(
|
||||
`SELECT sp.*, a.title as article_title, a.slug as article_slug
|
||||
`SELECT sp.*, a.title as article_title, a.slug as article_slug, a.category as article_category
|
||||
FROM scheduled_posts sp
|
||||
LEFT JOIN articles a ON a.id = sp.article_id
|
||||
WHERE sp.channel_id=$1
|
||||
@@ -267,15 +204,80 @@ router.get('/admin/:id/scheduled', async (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/channels/admin/:id/schedule — поставить пост в очередь
|
||||
// scheduled_at: если не передан — берём ближайший слот канала (через autoPublish.pickScheduleTime).
|
||||
router.post('/admin/:id/schedule', async (req, res) => {
|
||||
try {
|
||||
const { article_id, custom_text, scheduled_at } = req.body;
|
||||
if (!scheduled_at) return res.status(400).json({ error: 'scheduled_at required' });
|
||||
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
|
||||
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const when = scheduled_at ? new Date(scheduled_at) : await autoPublish.pickScheduleTime(chs[0]);
|
||||
const { rows } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[req.params.id, article_id || null, custom_text || null, scheduled_at]
|
||||
[req.params.id, article_id || null, custom_text || null, when]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── User routes (НЕ системные, для tool) ──────────────────────────────────────
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channels = await channelsSvc.listChannels(userId);
|
||||
res.json(channels);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
|
||||
if (!channel) return res.status(404).json({ error: 'Channel not found' });
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.createChannel(userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
console.error('[Route] POST /channels', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
await channelsSvc.deleteChannel(req.params.id, userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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 { query } = require('../config/db');
|
||||
const axios = require('axios');
|
||||
const settings = require('../services/settings');
|
||||
|
||||
// POST /api/posts/publish - publish a post to Telegram immediately
|
||||
router.post('/publish', async (req, res) => {
|
||||
@@ -18,7 +19,7 @@ router.post('/publish', async (req, res) => {
|
||||
const ch = rows[0];
|
||||
if (!ch.bot_token || !ch.tg_channel_id) return res.status(400).json({ error: 'Channel has no bot_token or tg_channel_id' });
|
||||
|
||||
const tgRes = await axios.post(`https://api.telegram.org/bot${ch.bot_token}/sendMessage`, {
|
||||
const tgRes = await axios.post(`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${ch.bot_token}/sendMessage`, {
|
||||
chat_id: ch.tg_channel_id,
|
||||
text: content,
|
||||
parse_mode: 'HTML',
|
||||
|
||||
@@ -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 {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const { channel_id, content, image_url, topic, status, scheduled_at } = req.body;
|
||||
const { channel_id, content, image_url, image_credit, topic, status, scheduled_at } = req.body;
|
||||
if (!channel_id || !content) return res.status(400).json({ error: 'channel_id and content required' });
|
||||
const post = await svc.savePost({
|
||||
userId, channelId: channel_id, content,
|
||||
imageUrl: image_url, topic,
|
||||
imageUrl: image_url,
|
||||
imageCredit: image_credit ?? null,
|
||||
topic,
|
||||
status: status || 'draft',
|
||||
scheduledAt: scheduled_at,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user