feat: P4 metrics collector + /api/metrics; P5 from-url generator (cheerio)

This commit is contained in:
Nik (Claude)
2026-06-08 11:08:59 +03:00
parent 008323fa74
commit 771f964370
7 changed files with 837 additions and 0 deletions
+19
View File
@@ -111,4 +111,23 @@ router.post('/topics-ideas', async (req, res) => {
}
});
// POST /api/generate/from-url — прочитать URL и написать пост в стиле канала
router.post('/from-url', async (req, res) => {
try {
const { channelId, url } = req.body;
const userId = parseInt(req.headers['x-user-id']) || null;
if (!channelId || !url) return res.status(400).json({ error: 'channelId and url required' });
const channel = await channelsSvc.getChannel(userId, channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const { generateFromUrl } = require('../services/fromUrl');
const result = await generateFromUrl({ url, channelId, channel });
res.json(result);
} catch (err) {
console.error('[Route] POST /generate/from-url', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+152
View File
@@ -0,0 +1,152 @@
/**
* GET /api/metrics/channel/:channelId — статистика канала
* POST /api/metrics/collect — принудительный сбор (admin/cron)
* GET /api/metrics/best-time/:channelId — лучшие дни/часы публикаций
*/
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const { collectMetrics } = require('../services/metricsCollector');
// ── POST /api/metrics/collect — принудительный сбор ──────────────────────────
router.post('/collect', async (req, res) => {
try {
const result = await collectMetrics();
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── GET /api/metrics/channel/:channelId — сводка канала ──────────────────────
router.get('/channel/:channelId', async (req, res) => {
try {
const { channelId } = req.params;
const days = parseInt(req.query.days) || 30;
const since = new Date(Date.now() - days * 86400_000);
// Посты по статусам
const { rows: statusStats } = await query(`
SELECT status, COUNT(*) as count
FROM posts
WHERE channel_id=$1 AND created_at > $2
GROUP BY status
`, [channelId, since]);
// Реакции топ-5 по постам
const { rows: topPosts } = await query(`
SELECT p.id, p.tg_message_id, p.published_at,
p.reactions, p.forwards,
left(p.content, 100) AS preview
FROM posts p
WHERE p.channel_id=$1
AND p.published_at > $2
AND p.reactions != '{}'
ORDER BY (
SELECT COALESCE(SUM(value::int), 0)
FROM jsonb_each_text(p.reactions)
) DESC
LIMIT 5
`, [channelId, since]);
// Общие реакции за период
const { rows: reactionTotals } = await query(`
SELECT key as emoji, SUM(value::int) as total
FROM posts p, jsonb_each_text(p.reactions)
WHERE p.channel_id=$1 AND p.published_at > $2
GROUP BY key
ORDER BY total DESC
LIMIT 10
`, [channelId, since]);
// Всего публикаций за период
const { rows: totals } = await query(`
SELECT COUNT(*) as total_posts,
COUNT(CASE WHEN tg_message_id IS NOT NULL THEN 1 END) as published_to_tg,
SUM(COALESCE(forwards,0)) as total_forwards
FROM posts
WHERE channel_id=$1 AND published_at > $2
`, [channelId, since]);
res.json({
channel_id: parseInt(channelId),
days,
totals: totals[0],
by_status: statusStats,
top_posts: topPosts,
reaction_totals: reactionTotals,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── GET /api/metrics/best-time/:channelId — лучший день/час ──────────────────
router.get('/best-time/:channelId', async (req, res) => {
try {
const { channelId } = req.params;
const days = parseInt(req.query.days) || 90;
const since = new Date(Date.now() - days * 86400_000);
// Публикации по дням недели
const { rows: byDow } = await query(`
SELECT EXTRACT(ISODOW FROM published_at AT TIME ZONE 'Europe/Moscow') AS dow,
COUNT(*) as count
FROM posts
WHERE channel_id=$1 AND published_at > $2 AND status='published'
GROUP BY dow
ORDER BY dow
`, [channelId, since]);
// Публикации по часам (МСК)
const { rows: byHour } = await query(`
SELECT EXTRACT(HOUR FROM published_at AT TIME ZONE 'Europe/Moscow') AS hour,
COUNT(*) as count
FROM posts
WHERE channel_id=$1 AND published_at > $2 AND status='published'
GROUP BY hour
ORDER BY hour
`, [channelId, since]);
const DOW_LABELS = ['', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
res.json({
channel_id: parseInt(channelId),
days,
by_dow: byDow.map(r => ({ dow: r.dow, label: DOW_LABELS[r.dow] || '?', count: parseInt(r.count) })),
by_hour: byHour.map(r => ({ hour: parseInt(r.hour), count: parseInt(r.count) })),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── GET /api/metrics/user-posts/:channelId — метрики user_posts ──────────────
router.get('/user-posts/:channelId', async (req, res) => {
try {
const userId = parseInt(req.headers['x-user-id']);
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { channelId } = req.params;
const days = parseInt(req.query.days) || 30;
const since = new Date(Date.now() - days * 86400_000);
const { rows } = await query(`
SELECT id, tg_message_id, status, published_at,
reactions, forwards, metrics_at,
left(content, 120) AS preview, topic, image_url
FROM user_posts
WHERE channel_id=$1 AND user_id=$2
AND published_at > $3
ORDER BY published_at DESC
LIMIT 50
`, [channelId, userId, since]);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;