a370b8f7d8
- Персонаж Зеро: 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
133 lines
4.6 KiB
JavaScript
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 };
|