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:
@@ -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 };
|
||||
Reference in New Issue
Block a user