Files
zeropost-engine/src/services/channelStats.js
T
Nik (Claude) a370b8f7d8 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
2026-06-07 14:03:56 +03:00

133 lines
4.6 KiB
JavaScript

// Сбор статистики 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 };