forked from admin/zeropost-engine
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,96 @@
|
||||
// Авто-публикация статей в каналы.
|
||||
//
|
||||
// Логика:
|
||||
// 1. При сохранении статьи со status='published' — engine вызывает scheduleForArticle(articleId)
|
||||
// 2. Находим все системные каналы с auto_publish_enabled=true где (categories пустой ИЛИ категория статьи там есть)
|
||||
// 3. Для каждого канала ищем ближайший подходящий момент:
|
||||
// - если delay_min > 0 → now + delay_min
|
||||
// - иначе — ближайший publish_slot канала в будущем
|
||||
// - если у канала нет слотов и delay=0 — публикуем сразу (scheduled_at = NOW)
|
||||
// 4. Дедуп: один article × один channel = одна запись в scheduled_posts (skip если уже есть pending/sent)
|
||||
// 5. Создаём scheduled_posts с pending status — runner отработает по cron'у
|
||||
|
||||
const { query } = require('../config/db');
|
||||
|
||||
/**
|
||||
* Подобрать ближайший момент публикации для канала.
|
||||
* @returns Date
|
||||
*/
|
||||
async function pickScheduleTime(channel) {
|
||||
const now = new Date();
|
||||
if (channel.auto_publish_delay_min > 0) {
|
||||
return new Date(now.getTime() + channel.auto_publish_delay_min * 60_000);
|
||||
}
|
||||
// Ищем publish_slots
|
||||
const { rows: slots } = await query(
|
||||
`SELECT slot_hour, slot_minute FROM publish_slots
|
||||
WHERE channel_id=$1 AND enabled=true
|
||||
ORDER BY slot_hour, slot_minute`,
|
||||
[channel.id]
|
||||
);
|
||||
if (slots.length === 0) {
|
||||
return now; // публикуем сразу
|
||||
}
|
||||
|
||||
// Сегодня — ближайший слот с временем > now
|
||||
const todayMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const futureToday = slots.find(s => s.slot_hour * 60 + s.slot_minute > todayMinutes);
|
||||
if (futureToday) {
|
||||
const t = new Date(now);
|
||||
t.setHours(futureToday.slot_hour, futureToday.slot_minute, 0, 0);
|
||||
return t;
|
||||
}
|
||||
// Все слоты на сегодня прошли — берём первый завтрашний
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(slots[0].slot_hour, slots[0].slot_minute, 0, 0);
|
||||
return tomorrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поставить статью на авто-публикацию во все подходящие каналы.
|
||||
* Идемпотентно: дубли в pending/sent не создаются.
|
||||
* @returns массив созданных scheduled_posts
|
||||
*/
|
||||
async function scheduleForArticle(articleId) {
|
||||
const { rows: arts } = await query(
|
||||
`SELECT id, slug, title, category, status FROM articles WHERE id=$1`,
|
||||
[articleId]
|
||||
);
|
||||
if (!arts.length || arts[0].status !== 'published') return [];
|
||||
const article = arts[0];
|
||||
|
||||
const { rows: channels } = await query(
|
||||
`SELECT * FROM channels
|
||||
WHERE is_system=true
|
||||
AND is_active=true
|
||||
AND auto_publish_enabled=true
|
||||
AND (cardinality(auto_publish_categories) = 0
|
||||
OR $1 = ANY(auto_publish_categories))`,
|
||||
[article.category]
|
||||
);
|
||||
|
||||
const created = [];
|
||||
for (const ch of channels) {
|
||||
// Дедуп
|
||||
const { rows: existing } = await query(
|
||||
`SELECT id FROM scheduled_posts
|
||||
WHERE channel_id=$1 AND article_id=$2 AND status IN ('pending','sent')
|
||||
LIMIT 1`,
|
||||
[ch.id, article.id]
|
||||
);
|
||||
if (existing.length) continue;
|
||||
|
||||
const scheduledAt = await pickScheduleTime(ch);
|
||||
const { rows: inserted } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
|
||||
VALUES ($1,$2,$3,'pending') RETURNING *`,
|
||||
[ch.id, article.id, scheduledAt]
|
||||
);
|
||||
created.push(inserted[0]);
|
||||
console.log(`[auto-publish] article=${article.id} → channel=${ch.id} at ${scheduledAt.toISOString()}`);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
module.exports = { scheduleForArticle, pickScheduleTime };
|
||||
@@ -0,0 +1,133 @@
|
||||
// Автоматическое добавление статей в серии.
|
||||
//
|
||||
// Логика:
|
||||
// 1. При публикации статьи — Claude haiku анализирует заголовок + excerpt
|
||||
// 2. Определяет наиболее подходящую серию (или ни одну)
|
||||
// 3. Добавляет article.id в series.article_ids если его там ещё нет
|
||||
//
|
||||
// Серии и их описания для Claude:
|
||||
// prompts — промпты, инструкции, генерация текста/изображений, работа с LLM как инструментом
|
||||
// mcp-agents — RAG, агенты, MCP, Telegram/API боты, интеграции ИИ с внешними системами
|
||||
// cases — автоматизация рабочих процессов, реальные кейсы, Make/Zapier/n8n, CRM, email
|
||||
|
||||
const axios = require('axios');
|
||||
const { query } = require('../config/db');
|
||||
const config = require('../config');
|
||||
|
||||
const SERIES_DESCRIPTIONS = [
|
||||
{
|
||||
slug: 'prompts',
|
||||
name: 'Промпт-инжиниринг',
|
||||
keywords: 'промпты, инструкции для ИИ, генерация текста, генерация изображений, работа с ChatGPT/Claude как инструментом, техники промптинга, few-shot, chain-of-thought, техдокументация с ИИ',
|
||||
},
|
||||
{
|
||||
slug: 'mcp-agents',
|
||||
name: 'MCP и агенты',
|
||||
keywords: 'RAG, векторные базы данных, ИИ-агенты, MCP, автономные боты, Telegram-бот с ИИ, интеграции ИИ с API, LangChain, LlamaIndex, инструменты для агентов',
|
||||
},
|
||||
{
|
||||
slug: 'cases',
|
||||
name: 'Кейсы и автоматизации',
|
||||
keywords: 'автоматизация рабочих процессов, Make, Zapier, n8n, CRM, email-маркетинг, реальные кейсы применения ИИ в работе, экономия времени, пайплайны',
|
||||
},
|
||||
{
|
||||
slug: 'ai-security',
|
||||
name: 'Безопасность в эпоху ИИ',
|
||||
keywords: 'кибербезопасность с ИИ, prompt injection, OSINT, социальная инженерия, атаки на LLM, безопасность продакшна, анализ малвари, защита данных, LLM уязвимости',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Определить подходящую серию для статьи через Claude haiku.
|
||||
* Возвращает slug серии или null если статья ни к одной не подходит.
|
||||
*/
|
||||
async function detectSeries(article) {
|
||||
const seriesList = SERIES_DESCRIPTIONS.map(s =>
|
||||
`- "${s.slug}" (${s.name}): ${s.keywords}`
|
||||
).join('\n');
|
||||
|
||||
const prompt = `Ты — редактор блога ZeroPost. Определи, подходит ли эта статья к одной из серий блога.
|
||||
|
||||
СТАТЬЯ:
|
||||
Заголовок: ${article.title}
|
||||
Описание: ${article.excerpt || ''}
|
||||
Категория: ${article.category || ''}
|
||||
|
||||
СЕРИИ БЛОГА:
|
||||
${seriesList}
|
||||
|
||||
Отвечай ТОЛЬКО одним словом — slug серии (prompts / mcp-agents / cases) или "none" если статья ни к одной не подходит достаточно хорошо.
|
||||
Выбирай серию только если уверен на 80%+. Лучше "none" чем неточное попадание.`;
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${config.ai.baseUrl}/messages`,
|
||||
{
|
||||
model: config.ai.models?.post || 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${config.ai.apiKey}` },
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
|
||||
const raw = res.data?.content?.[0]?.text?.trim().toLowerCase() || 'none';
|
||||
// Извлекаем только slug без лишнего текста
|
||||
const valid = SERIES_DESCRIPTIONS.map(s => s.slug);
|
||||
const found = valid.find(s => raw.includes(s));
|
||||
return found || null;
|
||||
} catch (err) {
|
||||
console.warn('[AutoSeries] Claude detection failed:', err.message.slice(0, 100));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить статью в подходящую серию.
|
||||
* Идемпотентно — не добавляет дубли.
|
||||
* @returns { slug, seriesTitle } или null
|
||||
*/
|
||||
async function addToSeries(articleId) {
|
||||
// Загружаем статью
|
||||
const { rows: arts } = await query(
|
||||
`SELECT id, title, excerpt, category, status FROM articles WHERE id=$1`,
|
||||
[articleId]
|
||||
);
|
||||
if (!arts.length || arts[0].status !== 'published') return null;
|
||||
const article = arts[0];
|
||||
|
||||
// Определяем серию
|
||||
const slug = await detectSeries(article);
|
||||
if (!slug) {
|
||||
console.log(`[AutoSeries] article=${articleId} → no suitable series`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Загружаем серию
|
||||
const { rows: series } = await query(
|
||||
`SELECT id, title, article_ids FROM series WHERE slug=$1`,
|
||||
[slug]
|
||||
);
|
||||
if (!series.length) return null;
|
||||
const s = series[0];
|
||||
|
||||
const currentIds = (s.article_ids || []).map(Number);
|
||||
if (currentIds.includes(articleId)) {
|
||||
console.log(`[AutoSeries] article=${articleId} already in series "${slug}"`);
|
||||
return { slug, seriesTitle: s.title, alreadyIn: true };
|
||||
}
|
||||
|
||||
// Добавляем в конец
|
||||
const newIds = [...currentIds, articleId];
|
||||
await query(
|
||||
`UPDATE series SET article_ids=$1::jsonb, updated_at=NOW() WHERE id=$2`,
|
||||
[JSON.stringify(newIds), s.id]
|
||||
);
|
||||
|
||||
console.log(`[AutoSeries] article=${articleId} "${article.title.slice(0,40)}" → series "${slug}" (${newIds.length} total)`);
|
||||
return { slug, seriesTitle: s.title, articleCount: newIds.length };
|
||||
}
|
||||
|
||||
module.exports = { addToSeries, detectSeries, SERIES_DESCRIPTIONS };
|
||||
+113
-12
@@ -1,6 +1,8 @@
|
||||
const { query } = require('../config/db');
|
||||
const ai = require('./ai');
|
||||
const covers = require('./covers');
|
||||
// Ленивый импорт чтобы избежать circular dependency
|
||||
function getAutoPublish() { return require('./articleAutoPublish'); }
|
||||
|
||||
/**
|
||||
* Slug из заголовка — транслит для русского.
|
||||
@@ -35,8 +37,8 @@ async function listArticles({ limit = 20, offset = 0, tag = null, category = nul
|
||||
let sql = `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, published_at
|
||||
FROM articles WHERE status='published'`;
|
||||
const params = [];
|
||||
if (tag) { sql += ` AND tags ? ${params.length + 1}`; params.push(tag); }
|
||||
if (category) { sql += ` AND category=${params.length + 1}`; params.push(category); }
|
||||
if (tag) { params.push(tag); sql += ` AND tags ? $${params.length}`; }
|
||||
if (category) { params.push(category); sql += ` AND category=$${params.length}`; }
|
||||
sql += ` ORDER BY published_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
||||
params.push(limit, offset);
|
||||
const { rows } = await query(sql, params);
|
||||
@@ -79,23 +81,40 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
||||
const jobId = jobRows[0].id;
|
||||
|
||||
try {
|
||||
// Универсальный "channel" для блога — с правилами человечности и нашим стилем
|
||||
// ZeroPost — блог от лица персонажа «Зеро».
|
||||
// Дружелюбный энтузиаст, делится тем что попробовал. От первого лица.
|
||||
const blogChannel = {
|
||||
name: 'ZeroPost',
|
||||
niche: 'Практические материалы про ИИ для людей, которые применяют его в работе',
|
||||
audience: 'Маркетологи, продакты, разработчики, основатели — те, кто хочет применять ИИ практически',
|
||||
goal: 'expert',
|
||||
niche: 'ИИ, кибербезопасность, автоматизация и разработка — то что Зеро попробовал и хочет рассказать',
|
||||
audience: 'Технари, разработчики, продакты, IT-специалисты — те кому интересны рабочие приёмы, а не теория',
|
||||
goal: 'personal',
|
||||
language: 'ru',
|
||||
region: 'ru',
|
||||
author_persona: {
|
||||
name: 'Зеро',
|
||||
identity: 'Я — Зеро, ИИ-маскот канала ZeroPost. Дружелюбный любопытный энтузиаст, который пробует разные инструменты и делится тем что узнал. Открыто говорю что я ИИ, не скрываю.',
|
||||
voice: 'От первого лица. Конкретные истории > абстрактные советы. Признаю когда что-то не получилось.',
|
||||
forbidden_phrases: ['как ИИ, я', 'я как языковая модель', 'в современном мире', 'революционный', 'уникальный', 'важно отметить', 'стоит подчеркнуть', 'поистине', 'давайте рассмотрим', 'итак', 'таким образом', 'в данной статье'],
|
||||
},
|
||||
style: {
|
||||
tone: 'friendly',
|
||||
tone: 'friendly_enthusiast',
|
||||
formality: 'informal',
|
||||
humor: 'dry',
|
||||
post_length: 'long',
|
||||
post_length: 'medium-long',
|
||||
structure: 'headers',
|
||||
emoji_level: 'none',
|
||||
emoji_level: 'minimal',
|
||||
hashtags_mode: 'none',
|
||||
banned_words: ['революционный','уникальный','в современном мире','важно отметить','стоит подчеркнуть','поистине'],
|
||||
first_person: true,
|
||||
rules: [
|
||||
'Пиши от первого лица: «я попробовал», «у меня сломалось», «обжёгся», «нашёл штуку».',
|
||||
'Конкретные истории и примеры > общие фразы. Лучше «вчера запустил X и получил Y» чем «X помогает делать Y».',
|
||||
'Признавай ошибки и тупики: «сначала пошёл не туда», «потратил час на ерунду».',
|
||||
'Сухой юмор допускается, но без клоунады.',
|
||||
'Не пиши «статья», «материал», «данная публикация» — пиши «пост», «разбор», «история», «заметка».',
|
||||
'Никогда не упоминай что текст пишется автоматически или массово.',
|
||||
'Не начинай с «В современном мире...» / «Итак...» / «Давайте...» — начинай с конкретики или с мини-истории.',
|
||||
'Не давай советов в стиле «вам нужно сделать X» — пиши «я делаю X, потому что Y».',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -115,12 +134,20 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
||||
const slug = `${slugify(title)}-${jobId}`;
|
||||
const readingTime = estimateReadingTime(content);
|
||||
|
||||
// Дедуп тегов + удаление category-slug из tags (он живёт в отдельной колонке).
|
||||
const cleanTags = Array.from(new Set(
|
||||
(tags || [])
|
||||
.filter(t => t && typeof t === 'string')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0 && t.toLowerCase() !== category.toLowerCase())
|
||||
));
|
||||
|
||||
const { rows: artRows } = await query(
|
||||
`INSERT INTO articles (slug, title, excerpt, content, tags, category, reading_time, status, job_id, seo_title, seo_descr)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[
|
||||
slug, title, excerpt, content,
|
||||
JSON.stringify(tags),
|
||||
JSON.stringify(cleanTags),
|
||||
category,
|
||||
readingTime,
|
||||
autoPublish ? 'published' : 'draft',
|
||||
@@ -135,13 +162,23 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
||||
[content, articleRes.usage?.prompt_tokens, articleRes.usage?.completion_tokens, jobId]
|
||||
);
|
||||
|
||||
// Фоновая генерация обложки — не блокирует возврат статьи
|
||||
// Фоновые задачи после сохранения — не блокируют возврат статьи
|
||||
setImmediate(() => {
|
||||
// Генерация обложки
|
||||
covers.generateCover({
|
||||
articleId: artRows[0].id,
|
||||
title: artRows[0].title,
|
||||
tags: artRows[0].tags || [],
|
||||
}).catch(err => console.warn('[Article] cover bg failed:', err.message.slice(0,200)));
|
||||
|
||||
// Авто-публикация в каналы (если статья опубликована)
|
||||
if (artRows[0].status === 'published') {
|
||||
getAutoPublish().scheduleForArticle(artRows[0].id)
|
||||
.catch(err => console.error('[Article] auto-publish hook failed:', err.message));
|
||||
// Авто-добавление в серию
|
||||
require('./articleAutoSeries').addToSeries(artRows[0].id)
|
||||
.catch(err => console.error('[Article] auto-series hook failed:', err.message));
|
||||
}
|
||||
});
|
||||
|
||||
return artRows[0];
|
||||
@@ -154,6 +191,68 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Собирает данные для главной страницы в одном вызове.
|
||||
* - hero: 1 свежая статья (с обложкой)
|
||||
* - byCategory: по 3 свежих на каждую из 4 категорий, исключая hero
|
||||
* - popular: до 3 статей по views за последние 30 дней (если есть просмотры)
|
||||
* - recent: 6 свежих, исключая hero и byCategory
|
||||
*/
|
||||
async function getHomeArticles() {
|
||||
const select = `SELECT id, slug, title, excerpt, cover_url, tags, category, reading_time, views, published_at`;
|
||||
|
||||
// Hero — самая свежая опубликованная статья с обложкой
|
||||
const heroRes = await query(
|
||||
`${select} FROM articles
|
||||
WHERE status='published' AND cover_url IS NOT NULL
|
||||
ORDER BY published_at DESC LIMIT 1`
|
||||
);
|
||||
const hero = heroRes.rows[0] || null;
|
||||
const heroId = hero ? hero.id : 0;
|
||||
|
||||
// По 3 на каждую категорию (DISTINCT ON), исключая hero
|
||||
const catRes = await query(
|
||||
`SELECT * FROM (
|
||||
SELECT ${select.replace('SELECT ', '')},
|
||||
ROW_NUMBER() OVER (PARTITION BY category ORDER BY published_at DESC) AS rn
|
||||
FROM articles
|
||||
WHERE status='published' AND id <> $1
|
||||
) t WHERE rn <= 3
|
||||
ORDER BY category, rn`,
|
||||
[heroId]
|
||||
);
|
||||
const byCategory = {};
|
||||
for (const row of catRes.rows) {
|
||||
const { rn, ...rest } = row;
|
||||
if (!byCategory[row.category]) byCategory[row.category] = [];
|
||||
byCategory[row.category].push(rest);
|
||||
}
|
||||
|
||||
// Популярное за 30 дней: топ-3 по views (только если views > 0)
|
||||
const popRes = await query(
|
||||
`${select} FROM articles
|
||||
WHERE status='published' AND views > 0 AND published_at > NOW() - INTERVAL '30 days'
|
||||
ORDER BY views DESC, published_at DESC LIMIT 3`
|
||||
);
|
||||
const popular = popRes.rows;
|
||||
const popularIds = popular.map(p => p.id);
|
||||
|
||||
// Recent — 6 свежих, исключая hero и попавшие в byCategory и popular
|
||||
const usedIds = new Set([heroId, ...popularIds]);
|
||||
for (const arr of Object.values(byCategory)) for (const a of arr) usedIds.add(a.id);
|
||||
const usedArr = Array.from(usedIds).filter(Boolean);
|
||||
const recentRes = await query(
|
||||
`${select} FROM articles
|
||||
WHERE status='published' AND id <> ALL($1::int[])
|
||||
ORDER BY published_at DESC LIMIT 6`,
|
||||
[usedArr.length ? usedArr : [0]]
|
||||
);
|
||||
const recent = recentRes.rows;
|
||||
|
||||
return { hero, byCategory, popular, recent };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
listArticles,
|
||||
@@ -161,3 +260,5 @@ module.exports = {
|
||||
getAllTags,
|
||||
generateAndSaveArticle,
|
||||
};
|
||||
|
||||
module.exports.getHomeArticles = getHomeArticles;
|
||||
|
||||
@@ -79,7 +79,7 @@ async function getNextTopic(category) {
|
||||
const unused = bank.filter(t => !usedTitles.some(u => u.includes(t.slice(0, 20).toLowerCase())));
|
||||
const pool = unused.length > 0 ? unused : bank;
|
||||
const topic = pool[Math.floor(Math.random() * pool.length)];
|
||||
return { id: null, topic, tags: [category], keywords: [] };
|
||||
return { id: null, topic, tags: [], keywords: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ async function runAutogenForCategory(category) {
|
||||
try {
|
||||
const article = await generateAndSaveArticle({
|
||||
topic,
|
||||
tags: [...tags, category],
|
||||
tags: tags,
|
||||
keywords,
|
||||
autoPublish: true,
|
||||
category,
|
||||
|
||||
@@ -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 };
|
||||
+52
-12
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const { query } = require('../config/db');
|
||||
const localGen = require('./localCoverGenerator');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||
|
||||
@@ -159,11 +160,34 @@ async function generateCoverViaImagesEndpoint({ prompt }) {
|
||||
throw new Error('No image data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Резервный путь — Pollinations.AI (https://pollinations.ai).
|
||||
* 100% бесплатно, без API ключа, без регистрации.
|
||||
* GET запрос → JPEG обложка за ~1-2 секунды.
|
||||
* Используется только когда aiprimetech.io недоступен.
|
||||
*/
|
||||
async function generateCoverViaPollinations({ prompt }) {
|
||||
// Pollinations: простой GET по URL, сразу возвращает бинарный JPEG
|
||||
const encoded = encodeURIComponent(prompt.slice(0, 1000)); // лимит на длину URL
|
||||
const url = `https://image.pollinations.ai/prompt/${encoded}?width=1600&height=900&model=flux&nologo=true`;
|
||||
const res = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 90_000, // Pollinations иногда медленный при нагрузке
|
||||
headers: { 'User-Agent': 'ZeroPost/1.0 blog-cover-generator' },
|
||||
});
|
||||
if (!res.data || res.data.byteLength < 5000) {
|
||||
throw new Error(`Pollinations returned too small response: ${res.data?.byteLength} bytes`);
|
||||
}
|
||||
return {
|
||||
bytes: Buffer.from(res.data),
|
||||
format: 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Главный путь генерации. Использует /v1/responses, при ошибке падает в legacy.
|
||||
*/
|
||||
async function generateCover({ articleId, title, tags = [] }) {
|
||||
// Передаём articleId в buildCoverPrompt для детерминированного выбора стиля
|
||||
const prompt = buildCoverPrompt({ title, tags, articleId });
|
||||
const styleIdx = pickStyleIndex(articleId);
|
||||
const styleName = COVER_STYLES[styleIdx].name;
|
||||
@@ -171,20 +195,36 @@ async function generateCover({ articleId, title, tags = [] }) {
|
||||
|
||||
let img;
|
||||
let usedPath = 'responses';
|
||||
|
||||
// Пробуем все внешние API, при любой ошибке — сразу local SVG
|
||||
try {
|
||||
img = await generateCoverViaResponses({ prompt });
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.error?.message || err.message;
|
||||
console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
|
||||
// Пробуем legacy
|
||||
try {
|
||||
img = await generateCoverViaImagesEndpoint({ prompt });
|
||||
usedPath = 'images-legacy';
|
||||
} catch (err2) {
|
||||
const msg2 = err2.response?.data?.error?.message || err2.message;
|
||||
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
|
||||
throw new Error(`Both image paths failed: ${msg}`);
|
||||
img = await generateCoverViaResponses({ prompt });
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.error?.message || err.message;
|
||||
console.warn(`[Cover] /responses path failed: ${msg.slice(0, 200)}`);
|
||||
try {
|
||||
img = await generateCoverViaImagesEndpoint({ prompt });
|
||||
usedPath = 'images-legacy';
|
||||
} catch (err2) {
|
||||
const msg2 = err2.response?.data?.error?.message || err2.message;
|
||||
console.warn(`[Cover] legacy path failed too: ${msg2.slice(0, 200)}`);
|
||||
try {
|
||||
img = await generateCoverViaPollinations({ prompt });
|
||||
usedPath = 'pollinations';
|
||||
console.log(`[Cover] article=${articleId} using Pollinations.AI fallback`);
|
||||
} catch (err3) {
|
||||
console.warn(`[Cover] Pollinations fallback failed: ${err3.message.slice(0, 200)}`);
|
||||
throw new Error('all_external_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (outerErr) {
|
||||
// Все внешние API упали — используем локальную SVG-генерацию
|
||||
console.log(`[Cover] article=${articleId} → local SVG generator (all external APIs unavailable)`);
|
||||
const localUrl = await localGen.generateLocalCover({ articleId, title, category: tags?.[0] || '' });
|
||||
await query('UPDATE articles SET cover_url=$1, updated_at=NOW() WHERE id=$2', [localUrl, articleId]);
|
||||
return localUrl;
|
||||
}
|
||||
|
||||
// Сохраняем оригинал
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Локальный генератор обложек через SVG → WebP (sharp).
|
||||
* Работает без внешних API — мгновенно, бесплатно, без лимитов.
|
||||
* Используется как надёжный fallback когда все API недоступны.
|
||||
*
|
||||
* Каждая статья получает детерминированный уникальный дизайн
|
||||
* в фирменной палитре ZeroPost.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||
|
||||
// Палитры в стиле ZeroPost (emerald, teal, amber, blue, slate)
|
||||
const PALETTES = [
|
||||
{ name: 'emerald', bg: '#f0fdf4', c1: '#10b981', c2: '#34d399', c3: '#6ee7b7', c4: '#064e3b', accent: '#059669' },
|
||||
{ name: 'midnight', bg: '#0f172a', c1: '#10b981', c2: '#1e293b', c3: '#334155', c4: '#94a3b8', accent: '#34d399' },
|
||||
{ name: 'amber', bg: '#fffbeb', c1: '#f59e0b', c2: '#fcd34d', c3: '#fde68a', c4: '#78350f', accent: '#d97706' },
|
||||
{ name: 'blue', bg: '#eff6ff', c1: '#3b82f6', c2: '#93c5fd', c3: '#dbeafe', c4: '#1e3a8a', accent: '#2563eb' },
|
||||
{ name: 'coral', bg: '#fff7f3', c1: '#ef4444', c2: '#fb7185', c3: '#fecdd3', c4: '#881337', accent: '#f43f5e' },
|
||||
{ name: 'violet', bg: '#faf5ff', c1: '#8b5cf6', c2: '#c4b5fd', c3: '#ede9fe', c4: '#4c1d95', accent: '#7c3aed' },
|
||||
];
|
||||
|
||||
// Детерминированный псевдо-рандом по seed
|
||||
function seededRand(seed) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
||||
return s / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует SVG-обложку 1600×900.
|
||||
*/
|
||||
function generateCoverSVG(articleId, title = '', category = '') {
|
||||
const rand = seededRand((articleId || 1) * 7919 + 42);
|
||||
const palette = PALETTES[(articleId || 0) % PALETTES.length];
|
||||
|
||||
// Выбор дизайн-паттерна по id
|
||||
const patternIdx = Math.floor(rand() * 5);
|
||||
|
||||
let shapes = '';
|
||||
|
||||
if (patternIdx === 0) {
|
||||
// Концентрические круги + смещённые
|
||||
const cx = 900 + rand() * 300 - 150;
|
||||
const cy = 450 + rand() * 200 - 100;
|
||||
for (let i = 5; i > 0; i--) {
|
||||
const r = i * 120 + rand() * 40;
|
||||
const op = 0.08 + i * 0.06;
|
||||
shapes += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${palette.c1}" opacity="${op.toFixed(2)}"/>`;
|
||||
}
|
||||
shapes += `<circle cx="${cx - 200}" cy="${cy + 100}" r="${140 + rand()*60}" fill="${palette.c2}" opacity="0.15"/>`;
|
||||
shapes += `<circle cx="${cx - 350}" cy="${cy - 150}" r="${90 + rand()*40}" fill="${palette.accent}" opacity="0.2"/>`;
|
||||
|
||||
} else if (patternIdx === 1) {
|
||||
// Диагональные полосы + геометрия
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = -100 + i * 310 + rand() * 60;
|
||||
const op = 0.05 + rand() * 0.12;
|
||||
const w = 180 + rand() * 120;
|
||||
shapes += `<rect x="${x}" y="-50" width="${w}" height="1000" fill="${palette.c1}" opacity="${op.toFixed(2)}" transform="rotate(${-15 + rand()*10} 800 450)"/>`;
|
||||
}
|
||||
shapes += `<circle cx="${400 + rand()*200}" cy="${200 + rand()*150}" r="${100 + rand()*80}" fill="${palette.c2}" opacity="0.18"/>`;
|
||||
shapes += `<circle cx="${1000 + rand()*200}" cy="${550 + rand()*150}" r="${120 + rand()*60}" fill="${palette.accent}" opacity="0.12"/>`;
|
||||
|
||||
} else if (patternIdx === 2) {
|
||||
// Волны (кривые Безье)
|
||||
const waves = 4;
|
||||
for (let i = 0; i < waves; i++) {
|
||||
const y = 150 + i * 200 + rand() * 60;
|
||||
const amp = 60 + rand() * 80;
|
||||
const op = 0.08 + rand() * 0.15;
|
||||
shapes += `<path d="M -100 ${y} C 200 ${y - amp}, 400 ${y + amp}, 600 ${y} S 1000 ${y - amp}, 1200 ${y} S 1600 ${y + amp}, 1700 ${y}"
|
||||
stroke="${i % 2 === 0 ? palette.c1 : palette.c2}" stroke-width="${20 + rand()*30}" fill="none" opacity="${op.toFixed(2)}" stroke-linecap="round"/>`;
|
||||
}
|
||||
shapes += `<circle cx="${rand()*400 + 100}" cy="${rand()*300 + 100}" r="${60 + rand()*80}" fill="${palette.accent}" opacity="0.15"/>`;
|
||||
shapes += `<circle cx="${rand()*400 + 1000}" cy="${rand()*300 + 400}" r="${50 + rand()*70}" fill="${palette.c1}" opacity="0.1"/>`;
|
||||
|
||||
} else if (patternIdx === 3) {
|
||||
// Сетка из прямоугольников
|
||||
const cols = 6, rows = 4;
|
||||
const cw = 1600 / cols, ch = 900 / rows;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (rand() > 0.5) {
|
||||
const op = 0.04 + rand() * 0.1;
|
||||
const fill = rand() > 0.5 ? palette.c1 : palette.c2;
|
||||
shapes += `<rect x="${c * cw + 4}" y="${r * ch + 4}" width="${cw - 8}" height="${ch - 8}"
|
||||
fill="${fill}" opacity="${op.toFixed(2)}" rx="${rand() * 20}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Большой акцентный круг
|
||||
shapes += `<circle cx="${400 + rand()*400}" cy="${300 + rand()*300}" r="${200 + rand()*100}"
|
||||
fill="${palette.c1}" opacity="0.12"/>`;
|
||||
|
||||
} else {
|
||||
// Треугольные формы и многоугольники
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const cx = rand() * 1600;
|
||||
const cy = rand() * 900;
|
||||
const size = 100 + rand() * 200;
|
||||
const op = 0.08 + rand() * 0.15;
|
||||
const angle = rand() * 360;
|
||||
const pts = [0, 1, 2].map(j => {
|
||||
const a = (angle + j * 120) * Math.PI / 180;
|
||||
return `${(cx + Math.cos(a) * size).toFixed(0)},${(cy + Math.sin(a) * size).toFixed(0)}`;
|
||||
}).join(' ');
|
||||
shapes += `<polygon points="${pts}" fill="${rand() > 0.5 ? palette.c1 : palette.accent}" opacity="${op.toFixed(2)}"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Частицы-точки (всегда)
|
||||
const dots = Array.from({ length: 20 }, () => {
|
||||
const x = rand() * 1600, y = rand() * 900;
|
||||
const r = 2 + rand() * 5;
|
||||
const op = 0.1 + rand() * 0.2;
|
||||
return `<circle cx="${x.toFixed(0)}" cy="${y.toFixed(0)}" r="${r.toFixed(1)}" fill="${palette.c4}" opacity="${op.toFixed(2)}"/>`;
|
||||
}).join('');
|
||||
|
||||
// Мягкий градиент-оверлей
|
||||
const overlay = `
|
||||
<defs>
|
||||
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="0%" stop-color="${palette.bg}" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="${palette.bg}" stop-opacity="0.3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#vignette)"/>`;
|
||||
|
||||
return `<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1600" height="900" fill="${palette.bg}"/>
|
||||
${shapes}
|
||||
${dots}
|
||||
${overlay}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сгенерировать и сохранить SVG-обложку для статьи.
|
||||
* Возвращает /uploads/cover-{id}-{ts}.webp
|
||||
*/
|
||||
async function generateLocalCover({ articleId, title = '', category = '' }) {
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (e) {
|
||||
throw new Error('sharp not available: ' + e.message);
|
||||
}
|
||||
|
||||
const svg = generateCoverSVG(articleId, title, category);
|
||||
const ts = Date.now();
|
||||
const filename = `cover-${articleId}-${ts}.webp`;
|
||||
const outPath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
await sharp(Buffer.from(svg))
|
||||
.resize(1600, 900)
|
||||
.webp({ quality: 88 })
|
||||
.toFile(outPath);
|
||||
|
||||
const size = fs.statSync(outPath).size;
|
||||
console.log(`[Cover] local SVG generated: article=${articleId} → ${filename} (${(size/1024).toFixed(0)}KB) palette=${PALETTE_NAME(articleId)}`);
|
||||
|
||||
return `/uploads/${filename}`;
|
||||
}
|
||||
|
||||
function PALETTE_NAME(id) {
|
||||
return PALETTES[(id || 0) % PALETTES.length].name;
|
||||
}
|
||||
|
||||
module.exports = { generateLocalCover, generateCoverSVG };
|
||||
@@ -0,0 +1,262 @@
|
||||
// Поиск фото через Yandex Search API (Image search v2)
|
||||
//
|
||||
// Архитектура:
|
||||
// - Запрос → searchapi.api.cloud.yandex.net/v2/image/search
|
||||
// - Ответ: JSON { rawData: base64 }, внутри base64 — XML с результатами
|
||||
// - Парсим XML, нормализуем в массив объектов
|
||||
// - Фильтруем по whitelist доменов из photo_search_profiles
|
||||
// - Фильтруем по min-size (отсев иконок)
|
||||
// - Считаем суточный лимит в Redis (ключ photo_search:count:YYYY-MM-DD)
|
||||
//
|
||||
// Если меняем провайдера (yandex → serpapi) — этот модуль будет адаптером,
|
||||
// логика квот и фильтрации профилей остаётся.
|
||||
|
||||
const axios = require('axios');
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
const Redis = require('ioredis');
|
||||
const settings = require('./settings');
|
||||
const config = require('../config');
|
||||
const { query: dbQuery } = require('../config/db');
|
||||
|
||||
const YANDEX_ENDPOINT = 'https://searchapi.api.cloud.yandex.net/v2/image/search';
|
||||
const MIN_DIMENSION_PX = 400;
|
||||
const USER_AGENT = 'Mozilla/5.0 (compatible; ZeroPost/1.0)';
|
||||
|
||||
let _redis = null;
|
||||
function getRedis() {
|
||||
if (!_redis) {
|
||||
_redis = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
_redis.on('error', (err) => console.error('[photo-search] redis error:', err.message));
|
||||
}
|
||||
return _redis;
|
||||
}
|
||||
|
||||
// ── Квоты (Redis daily counter) ──────────────────────────────────────────────
|
||||
|
||||
function dailyKey() {
|
||||
return `photo_search:count:${new Date().toISOString().slice(0, 10)}`;
|
||||
}
|
||||
|
||||
async function getDailyCount() {
|
||||
try {
|
||||
const v = await getRedis().get(dailyKey());
|
||||
return parseInt(v) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function incrementDaily() {
|
||||
try {
|
||||
const r = getRedis();
|
||||
const k = dailyKey();
|
||||
const count = await r.incr(k);
|
||||
if (count === 1) await r.expire(k, 172800); // 48h TTL
|
||||
return count;
|
||||
} catch (err) {
|
||||
console.error('[photo-search] incr failed:', err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function getQuotaStatus() {
|
||||
const limit = parseInt(await settings.get('YANDEX_SEARCH_DAILY_LIMIT', '300'));
|
||||
const used = await getDailyCount();
|
||||
return { used, limit, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
|
||||
// ── Парсинг XML ответа Yandex ────────────────────────────────────────────────
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
parseAttributeValue: false,
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
function parseYandexXml(base64Data) {
|
||||
const xmlText = Buffer.from(base64Data, 'base64').toString('utf-8');
|
||||
const parsed = xmlParser.parse(xmlText);
|
||||
const response = parsed?.yandexsearch?.response;
|
||||
if (!response) {
|
||||
throw new Error('Unexpected Yandex response: no <response>');
|
||||
}
|
||||
if (response.error) {
|
||||
const errText = typeof response.error === 'object' ? response.error['#text'] || JSON.stringify(response.error) : response.error;
|
||||
throw new Error(`Yandex error: ${errText}`);
|
||||
}
|
||||
|
||||
const grouping = response.results?.grouping;
|
||||
if (!grouping) return { total: 0, docs: [] };
|
||||
|
||||
const groups = Array.isArray(grouping.group) ? grouping.group : [grouping.group].filter(Boolean);
|
||||
const docs = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const groupDocs = Array.isArray(group.doc) ? group.doc : [group.doc].filter(Boolean);
|
||||
for (const doc of groupDocs) {
|
||||
const imgProps = doc['image-properties'] || {};
|
||||
const titleText = typeof doc.title === 'object' ? (doc.title['#text'] || '') : (doc.title || '');
|
||||
const passageText = typeof doc.passage === 'object' ? (doc.passage['#text'] || '') : (doc.passage || '');
|
||||
docs.push({
|
||||
imageUrl: imgProps['image-link'] || doc.url || null,
|
||||
thumbUrl: imgProps['thumbnail-link'] || null,
|
||||
sourceUrl: imgProps['html-link'] || null,
|
||||
sourceDomain: doc.domain || null,
|
||||
title: String(titleText).slice(0, 200),
|
||||
passage: String(passageText).slice(0, 200),
|
||||
width: parseInt(imgProps['original-width']) || 0,
|
||||
height: parseInt(imgProps['original-height']) || 0,
|
||||
thumbWidth: parseInt(imgProps['thumbnail-width']) || 0,
|
||||
thumbHeight: parseInt(imgProps['thumbnail-height']) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const foundArr = Array.isArray(response.found) ? response.found : (response.found ? [response.found] : []);
|
||||
const foundAll = foundArr.find(f => f['@_priority'] === 'all');
|
||||
const total = foundAll ? parseInt(foundAll['#text']) : docs.length;
|
||||
|
||||
return { total, docs };
|
||||
}
|
||||
|
||||
// ── Фильтрация результатов ───────────────────────────────────────────────────
|
||||
|
||||
function matchesDomain(domain, whitelist) {
|
||||
if (!domain || !whitelist || whitelist.length === 0) return true;
|
||||
const d = domain.toLowerCase();
|
||||
return whitelist.some(allowed => {
|
||||
const a = allowed.toLowerCase();
|
||||
return d === a || d.endsWith('.' + a);
|
||||
});
|
||||
}
|
||||
|
||||
function meetsMinSize(doc) {
|
||||
if (!doc.width || !doc.height) return true; // unknown size — пропускаем
|
||||
return Math.min(doc.width, doc.height) >= MIN_DIMENSION_PX;
|
||||
}
|
||||
|
||||
// ── Profile lookup ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getProfileDomains(slug) {
|
||||
if (!slug) return [];
|
||||
const { rows } = await dbQuery(
|
||||
'SELECT domains FROM photo_search_profiles WHERE slug=$1',
|
||||
[slug]
|
||||
);
|
||||
return rows[0]?.domains || [];
|
||||
}
|
||||
|
||||
// ── Main: searchByQuery ──────────────────────────────────────────────────────
|
||||
|
||||
async function searchByQuery({ query, profileSlug = 'general', num = 6 }) {
|
||||
if (!query || typeof query !== 'string') {
|
||||
throw new Error('query is required');
|
||||
}
|
||||
|
||||
// Квота
|
||||
const limit = parseInt(await settings.get('YANDEX_SEARCH_DAILY_LIMIT', '300'));
|
||||
const used = await getDailyCount();
|
||||
if (used >= limit) {
|
||||
const err = new Error(`Daily photo search limit reached: ${used}/${limit}`);
|
||||
err.code = 'DAILY_LIMIT_EXCEEDED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Credentials
|
||||
const apiKey = await settings.get('YANDEX_SEARCH_API_KEY', '');
|
||||
const folderId = await settings.get('YANDEX_SEARCH_FOLDER_ID', '');
|
||||
if (!apiKey || !folderId) {
|
||||
throw new Error('Yandex Search API not configured (YANDEX_SEARCH_API_KEY / YANDEX_SEARCH_FOLDER_ID)');
|
||||
}
|
||||
|
||||
// Profile
|
||||
const domains = await getProfileDomains(profileSlug);
|
||||
|
||||
// Запросим с запасом — потом отфильтруем
|
||||
const docsOnPage = Math.min(Math.max(num * 4, 10), 50);
|
||||
|
||||
const requestBody = {
|
||||
query: {
|
||||
searchType: 'SEARCH_TYPE_RU',
|
||||
queryText: query.trim(),
|
||||
familyMode: 'FAMILY_MODE_MODERATE',
|
||||
page: '0',
|
||||
fixTypoMode: 'FIX_TYPO_MODE_ON',
|
||||
},
|
||||
imageSpec: {
|
||||
format: 'IMAGE_FORMAT_UNSPECIFIED',
|
||||
size: 'IMAGE_SIZE_LARGE',
|
||||
orientation: 'IMAGE_ORIENTATION_UNSPECIFIED',
|
||||
color: 'IMAGE_COLOR_UNSPECIFIED',
|
||||
},
|
||||
docsOnPage: String(docsOnPage),
|
||||
folderId,
|
||||
userAgent: USER_AGENT,
|
||||
};
|
||||
|
||||
await incrementDaily();
|
||||
const startMs = Date.now();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await axios.post(YANDEX_ENDPOINT, requestBody, {
|
||||
headers: {
|
||||
'Authorization': `Api-Key ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data;
|
||||
const detail = data?.message || data?.code || err.message;
|
||||
const e = new Error(`Yandex Search API request failed (${status || 'no-response'}): ${detail}`);
|
||||
e.status = status;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
|
||||
if (!response.data?.rawData) {
|
||||
throw new Error('Yandex response missing rawData field');
|
||||
}
|
||||
|
||||
const { total, docs } = parseYandexXml(response.data.rawData);
|
||||
|
||||
// Фильтрация
|
||||
let filtered = docs.filter(meetsMinSize);
|
||||
if (domains.length > 0) {
|
||||
filtered = filtered.filter(d => matchesDomain(d.sourceDomain, domains));
|
||||
}
|
||||
|
||||
// Дедуп по imageUrl (на всякий случай)
|
||||
const seen = new Set();
|
||||
const dedup = [];
|
||||
for (const d of filtered) {
|
||||
if (!d.imageUrl || seen.has(d.imageUrl)) continue;
|
||||
seen.add(d.imageUrl);
|
||||
dedup.push(d);
|
||||
}
|
||||
|
||||
const items = dedup.slice(0, num);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
raw_count: docs.length,
|
||||
filtered_count: filtered.length,
|
||||
elapsed_ms: elapsedMs,
|
||||
quota: { used: used + 1, limit, remaining: Math.max(0, limit - used - 1) },
|
||||
profile: profileSlug,
|
||||
domains: domains,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { searchByQuery, getQuotaStatus, parseYandexXml };
|
||||
@@ -196,9 +196,40 @@ ${style.banned_topics?.length ? `НЕ трогай темы: ${style.banned_topi
|
||||
*/
|
||||
function buildArticleSystemPrompt(channel, keywords = []) {
|
||||
const lang = channel?.language === 'en' ? 'английском' : 'русском';
|
||||
return `Ты — опытный русскоязычный автор и редактор. Пишешь живые, читаемые статьи для русской аудитории на ${lang} языке.
|
||||
const persona = channel?.author_persona;
|
||||
|
||||
ГЛАВНОЕ: текст должен звучать так, будто его написал думающий человек, а не ИИ. Если статья звучит "по-нейросетевому" — она провалена.
|
||||
// Секция персонажа — если у канала задан author_persona, ставим её ПЕРВОЙ.
|
||||
// Это перебивает все остальные инструкции по тону.
|
||||
const personaSection = persona ? `═══════════════════════════════════════════════════════════
|
||||
ТЫ — ${persona.name.toUpperCase()}
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
${persona.identity}
|
||||
|
||||
ГОЛОС: ${persona.voice}
|
||||
|
||||
Правила голоса Зеро (ВАЖНО):
|
||||
${(channel?.style?.rules || []).map(r => `- ${r}`).join('\n')}
|
||||
|
||||
ЗАПРЕЩЁННЫЕ ФРАЗЫ (никогда не используй):
|
||||
${(persona.forbidden_phrases || []).map(f => `- "${f}"`).join('\n')}
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНО:
|
||||
- Пиши ОТ ПЕРВОГО ЛИЦА. Не "вы делаете" — а "я делаю". Не "стоит попробовать" — а "я попробовал".
|
||||
- Начинай статью с МИНИ-ИСТОРИИ или конкретного наблюдения, а не с обобщения.
|
||||
ХОРОШО: "На прошлой неделе я решил..." / "Сижу, отлаживаю..." / "Заметил странную штуку..."
|
||||
ПЛОХО: "Есть такой тип задач..." / "Многие сталкиваются с..." / "В наше время..."
|
||||
- Используй "я", "мне", "у меня" регулярно. Если за абзац ни одного личного местоимения — переписывай.
|
||||
- Признавай ошибки: "сначала сделал не так", "запутался", "час потратил на ерунду", "оказалось всё проще".
|
||||
- Не называй статью "статьёй". Говори "пост", "разбор", "история", "заметка".
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
` : '';
|
||||
|
||||
return `Ты — ${persona ? persona.name + ' — ИИ-маскот блога ZeroPost' : 'опытный русскоязычный автор и редактор'}. Пишешь живые, читаемые ${persona ? 'заметки' : 'статьи'} для русской аудитории на ${lang} языке.
|
||||
|
||||
${personaSection}
|
||||
ГЛАВНОЕ: текст должен звучать так, будто его написал думающий человек, а не ИИ. Если ${persona ? 'заметка' : 'статья'} звучит "по-нейросетевому" — она провалена.
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
ЯЗЫК И СТИЛЬ — критично, читай внимательно
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Раннер scheduled_posts (системные публикации статей в каналы).
|
||||
// Дёргается cron'ом раз в минуту.
|
||||
//
|
||||
// Логика:
|
||||
// - article + channel.auto_publish_template → текст-тизер
|
||||
// - в TG: inline-кнопка «Читать на сайте →» (без URL в тексте)
|
||||
// - в VK/MAX: URL автоматически подмешивается в конец текста (кнопок нет)
|
||||
// - cover статьи прикрепляется если auto_publish_with_cover=true
|
||||
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const FormData = require('form-data');
|
||||
const { query } = require('../config/db');
|
||||
const settings = require('./settings');
|
||||
const zeroChar = require('./zeroCharacter');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||
|
||||
/**
|
||||
* Если photoUrl указывает на наш собственный /uploads — открываем файл
|
||||
* с диска и шлём как multipart. Это надёжнее, чем заставлять Telegram-прокси
|
||||
* самостоятельно тянуть файл с zeropost.ru (бывают таймауты / sandbox-ограничения CF Worker'а).
|
||||
*/
|
||||
function resolveLocalPhoto(photoUrl) {
|
||||
if (!photoUrl) return null;
|
||||
// Формы: /uploads/x.webp, https://zeropost.ru/uploads/x.webp
|
||||
let pathname = photoUrl;
|
||||
try {
|
||||
const u = new URL(photoUrl);
|
||||
pathname = u.pathname;
|
||||
} catch {}
|
||||
if (!pathname.startsWith('/uploads/')) return null;
|
||||
const filename = pathname.replace(/^\/uploads\//, '');
|
||||
// Защита от path traversal
|
||||
if (filename.includes('..') || filename.includes('/')) return null;
|
||||
const local = path.join(UPLOADS_DIR, filename);
|
||||
if (!fs.existsSync(local)) return null;
|
||||
return local;
|
||||
}
|
||||
|
||||
const DEFAULT_TEMPLATE = '{categoryEmoji} *{categoryLabel}*\n\n*{title}*\n\n{excerpt}';
|
||||
const DEFAULT_BUTTON_TEXT = '📖 Читать на сайте →';
|
||||
|
||||
// Маппинг slug → emoji + русское название для плейсхолдеров
|
||||
const CATEGORY_META = {
|
||||
'ai-tools': { emoji: '🤖', label: 'AI Tools' },
|
||||
'cybersec': { emoji: '🔒', label: 'Cybersec' },
|
||||
'automation': { emoji: '⚡', label: 'Automation' },
|
||||
'ai-dev': { emoji: '💻', label: 'AI Dev' },
|
||||
};
|
||||
|
||||
function articleUrl(article) {
|
||||
return `https://zeropost.ru/blog/${article.slug}`;
|
||||
}
|
||||
|
||||
function renderTemplate(template, article) {
|
||||
const tpl = (template && template.trim()) || DEFAULT_TEMPLATE;
|
||||
const url = articleUrl(article);
|
||||
const meta = CATEGORY_META[article.category] || { emoji: '📝', label: article.category || '' };
|
||||
return tpl
|
||||
.replaceAll('{title}', article.title || '')
|
||||
.replaceAll('{excerpt}', article.excerpt || '')
|
||||
.replaceAll('{url}', url)
|
||||
.replaceAll('{category}', article.category || '')
|
||||
.replaceAll('{categoryEmoji}', meta.emoji)
|
||||
.replaceAll('{categoryLabel}', meta.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram. Если есть article — добавляем inline-кнопку «Читать на сайте».
|
||||
* Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
|
||||
*/
|
||||
async function publishToTelegram({ channel, text, photoUrl, article }) {
|
||||
const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
|
||||
|
||||
// Inline-кнопка — только если есть статья и кнопка не отключена
|
||||
const buttonText = channel.auto_publish_button_text === null
|
||||
? null
|
||||
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
|
||||
let reply_markup = undefined;
|
||||
if (article && buttonText) {
|
||||
reply_markup = {
|
||||
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
|
||||
};
|
||||
}
|
||||
|
||||
if (photoUrl) {
|
||||
const localPath = resolveLocalPhoto(photoUrl);
|
||||
if (localPath) {
|
||||
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL
|
||||
const form = new FormData();
|
||||
form.append('chat_id', String(channel.tg_channel_id));
|
||||
form.append('caption', text.slice(0, 1024));
|
||||
form.append('parse_mode', 'Markdown');
|
||||
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
|
||||
form.append('photo', fs.createReadStream(localPath));
|
||||
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, form, {
|
||||
headers: form.getHeaders(),
|
||||
timeout: 60000,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
return res.data?.result?.message_id;
|
||||
}
|
||||
// Внешний URL — оставляем старое поведение
|
||||
const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, {
|
||||
chat_id: channel.tg_channel_id,
|
||||
photo: photoUrl,
|
||||
caption: text.slice(0, 1024),
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup,
|
||||
}, { timeout: 30000 });
|
||||
return res.data?.result?.message_id;
|
||||
}
|
||||
const res = await axios.post(`${base}/bot${channel.bot_token}/sendMessage`, {
|
||||
chat_id: channel.tg_channel_id,
|
||||
text: text.slice(0, 4096),
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: !article, // если есть кнопка — превью сайта не нужно
|
||||
reply_markup,
|
||||
}, { timeout: 15000 });
|
||||
return res.data?.result?.message_id;
|
||||
}
|
||||
|
||||
async function publishToVK({ channel, text, photoUrl, article }) {
|
||||
if (!channel.vk_group_id || !channel.vk_access_token) {
|
||||
throw new Error('VK не настроен');
|
||||
}
|
||||
// VK не поддерживает кнопки в постах — добавляем ссылку в конец текста, если её там ещё нет
|
||||
let finalText = text;
|
||||
if (article) {
|
||||
const url = articleUrl(article);
|
||||
if (!finalText.includes(url)) {
|
||||
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
|
||||
finalText = `${finalText}\n\n${buttonText}\n${url}`;
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
owner_id: '-' + String(channel.vk_group_id).replace(/^-/, ''),
|
||||
from_group: '1',
|
||||
message: finalText,
|
||||
access_token: channel.vk_access_token,
|
||||
v: '5.199',
|
||||
});
|
||||
const res = await axios.post('https://api.vk.com/method/wall.post', params, { timeout: 15000 });
|
||||
if (res.data?.error) throw new Error(`VK: ${res.data.error.error_msg}`);
|
||||
return res.data?.response?.post_id;
|
||||
}
|
||||
|
||||
async function publishToMax({ channel, text, photoUrl, article }) {
|
||||
if (!channel.max_channel_id || !channel.max_access_token) {
|
||||
throw new Error('MAX не настроен');
|
||||
}
|
||||
// Заглушка — точный endpoint MAX заполним когда подключим живой канал
|
||||
throw new Error('MAX публикация не реализована');
|
||||
}
|
||||
|
||||
async function publishOne(scheduledPost) {
|
||||
const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [scheduledPost.channel_id]);
|
||||
if (!chRows.length) throw new Error('Channel not found');
|
||||
const channel = chRows[0];
|
||||
|
||||
let text = scheduledPost.custom_text;
|
||||
let photoUrl = null;
|
||||
let article = null;
|
||||
|
||||
if (scheduledPost.article_id) {
|
||||
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [scheduledPost.article_id]);
|
||||
if (!arts.length) throw new Error('Article not found');
|
||||
article = arts[0];
|
||||
if (!text) text = renderTemplate(channel.auto_publish_template, article);
|
||||
|
||||
// Выбор картинки:
|
||||
// image_source='zero' — иллюстрация Зеро по позе
|
||||
// image_source='cover' — обложка статьи (старое поведение)
|
||||
// image_source='none' — без картинки
|
||||
const imgSource = channel.auto_publish_image_source || 'cover';
|
||||
|
||||
// 'alternating' — чётные article_id = обложка статьи, нечётные = Зеро.
|
||||
// Это даёт визуальное разнообразие без ручного управления.
|
||||
const useZero = imgSource === 'zero'
|
||||
|| (imgSource === 'alternating' && article.id % 2 === 1);
|
||||
const useCover = imgSource === 'cover'
|
||||
|| (imgSource === 'alternating' && article.id % 2 === 0);
|
||||
|
||||
if (useZero && channel.auto_publish_with_cover !== false) {
|
||||
const picked = zeroChar.pickPose({
|
||||
title: article.title,
|
||||
excerpt: article.excerpt,
|
||||
category: article.category,
|
||||
});
|
||||
if (picked.exists) {
|
||||
photoUrl = `/uploads/zero-${picked.pose}.webp`;
|
||||
console.log(`[scheduled-runner] Zero pose=${picked.pose} (${picked.source}) article=${article.id}`);
|
||||
} else if (article.cover_url) {
|
||||
// Fallback на обложку если поза ещё не сгенерирована
|
||||
photoUrl = article.cover_url.startsWith('http')
|
||||
? article.cover_url
|
||||
: `https://zeropost.ru${article.cover_url}`;
|
||||
console.log(`[scheduled-runner] Zero fallback to cover (pose not ready) article=${article.id}`);
|
||||
}
|
||||
} else if (useCover && channel.auto_publish_with_cover) {
|
||||
if (article.cover_url) {
|
||||
photoUrl = article.cover_url.startsWith('http')
|
||||
? article.cover_url
|
||||
: `https://zeropost.ru${article.cover_url}`;
|
||||
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
|
||||
} else {
|
||||
// Обложки нет (ещё генерируется) — fallback на Зеро
|
||||
const picked = zeroChar.pickPose({
|
||||
title: article.title,
|
||||
excerpt: article.excerpt,
|
||||
category: article.category,
|
||||
});
|
||||
if (picked.exists) {
|
||||
photoUrl = `/uploads/zero-${picked.pose}.webp`;
|
||||
console.log(`[scheduled-runner] cover fallback → Zero pose=${picked.pose} article=${article.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// imgSource === 'none' → photoUrl остаётся null
|
||||
}
|
||||
|
||||
if (!text) throw new Error('Empty text and no article');
|
||||
|
||||
let messageId;
|
||||
if (channel.platform === 'telegram' || !channel.platform) {
|
||||
messageId = await publishToTelegram({ channel, text, photoUrl, article });
|
||||
} else if (channel.platform === 'vk') {
|
||||
messageId = await publishToVK({ channel, text, photoUrl, article });
|
||||
} else if (channel.platform === 'max') {
|
||||
messageId = await publishToMax({ channel, text, photoUrl, article });
|
||||
} else {
|
||||
throw new Error(`Платформа ${channel.platform} не поддерживается`);
|
||||
}
|
||||
|
||||
// Логируем в posts
|
||||
await query(
|
||||
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
|
||||
VALUES ($1,$2,'published',NOW(),$3)`,
|
||||
[channel.id, text, channel.platform === 'telegram' ? (messageId || null) : null]
|
||||
);
|
||||
|
||||
return { messageId, channel, article };
|
||||
}
|
||||
|
||||
async function runScheduled() {
|
||||
const { rows } = await query(
|
||||
`SELECT * FROM scheduled_posts
|
||||
WHERE status='pending' AND scheduled_at <= NOW()
|
||||
ORDER BY scheduled_at ASC LIMIT 20`
|
||||
);
|
||||
const results = [];
|
||||
for (const sp of rows) {
|
||||
try {
|
||||
const { messageId } = await publishOne(sp);
|
||||
await query(
|
||||
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
|
||||
[sp.id]
|
||||
);
|
||||
results.push({ id: sp.id, ok: true, message_id: messageId });
|
||||
console.log(`[scheduled-runner] sent id=${sp.id} channel=${sp.channel_id} article=${sp.article_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]
|
||||
);
|
||||
results.push({ id: sp.id, ok: false, error: msg });
|
||||
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
return { processed: rows.length, results };
|
||||
}
|
||||
|
||||
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
|
||||
@@ -0,0 +1,76 @@
|
||||
// Централизованный доступ к app_settings: кэш в памяти + invalidate.
|
||||
// Источники значения (по приоритету):
|
||||
// 1. app_settings.value (из БД)
|
||||
// 2. process.env[key] (fallback на ENV, для секретов и dev)
|
||||
// 3. defaultValue (зашитый в вызывающем коде)
|
||||
//
|
||||
// Кэш TTL = 60 сек, плюс ручной invalidate() после UPDATE из admin UI.
|
||||
|
||||
const { query } = require('../config/db');
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
let cache = { data: null, ts: 0 };
|
||||
|
||||
async function refreshCache() {
|
||||
const { rows } = await query('SELECT key, value FROM app_settings');
|
||||
cache = {
|
||||
data: Object.fromEntries(rows.map(r => [r.key, r.value])),
|
||||
ts: Date.now(),
|
||||
};
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
async function ensureFresh() {
|
||||
if (!cache.data || Date.now() - cache.ts > TTL_MS) {
|
||||
try {
|
||||
await refreshCache();
|
||||
} catch (err) {
|
||||
console.error('[settings] refresh failed, using stale/env:', err.message);
|
||||
if (!cache.data) cache.data = {};
|
||||
}
|
||||
}
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
async function get(key, defaultValue) {
|
||||
const data = await ensureFresh();
|
||||
const fromDb = data[key];
|
||||
if (fromDb !== undefined && fromDb !== null && fromDb !== '') return fromDb;
|
||||
if (process.env[key]) return process.env[key];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
async function getMany(keys) {
|
||||
const data = await ensureFresh();
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
out[k] = data[k] ?? process.env[k] ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const { rows } = await query(
|
||||
`SELECT key, value, description, category, is_secret, updated_at
|
||||
FROM app_settings ORDER BY category, key`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function set(key, value) {
|
||||
const { rows } = await query(
|
||||
`UPDATE app_settings
|
||||
SET value=$2, updated_at=NOW()
|
||||
WHERE key=$1
|
||||
RETURNING key, value, description, category, is_secret, updated_at`,
|
||||
[key, value === '' ? null : value]
|
||||
);
|
||||
invalidate();
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cache = { data: null, ts: 0 };
|
||||
}
|
||||
|
||||
module.exports = { get, getMany, list, set, invalidate };
|
||||
@@ -1,14 +1,15 @@
|
||||
const { query } = require('../config/db');
|
||||
const axios = require('axios');
|
||||
const settings = require('../services/settings');
|
||||
|
||||
/**
|
||||
* Сохранить пост в базу (как черновик или сразу запланированный).
|
||||
*/
|
||||
async function savePost({ userId, channelId, content, imageUrl = null, topic = null, status = 'draft', scheduledAt = null }) {
|
||||
async function savePost({ userId, channelId, content, imageUrl = null, imageCredit = null, topic = null, status = 'draft', scheduledAt = null }) {
|
||||
const { rows } = await query(
|
||||
`INSERT INTO user_posts (user_id, channel_id, content, image_url, topic, status, scheduled_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[userId, channelId, content, imageUrl, topic, status, scheduledAt]
|
||||
`INSERT INTO user_posts (user_id, channel_id, content, image_url, image_credit, topic, status, scheduled_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[userId, channelId, content, imageUrl, imageCredit, topic, status, scheduledAt]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
@@ -30,7 +31,7 @@ async function getPost(userId, postId) {
|
||||
}
|
||||
|
||||
async function updatePost(userId, postId, data) {
|
||||
const allowed = ['content','image_url','status','scheduled_at','topic'];
|
||||
const allowed = ['content','image_url','image_credit','status','scheduled_at','topic'];
|
||||
const fields = []; const vals = []; let i = 1;
|
||||
for (const key of allowed) {
|
||||
if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); }
|
||||
@@ -63,7 +64,7 @@ async function publishToTelegram(post, channel) {
|
||||
? post.image_url
|
||||
: `https://app.zeropost.ru${post.image_url}`;
|
||||
const res = await axios.post(
|
||||
`https://api.telegram.org/bot${channel.bot_token}/sendPhoto`,
|
||||
`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendPhoto`,
|
||||
{
|
||||
chat_id: channel.tg_channel_id,
|
||||
photo: photoUrl,
|
||||
@@ -75,7 +76,7 @@ async function publishToTelegram(post, channel) {
|
||||
return { ok: true, message_id: res.data?.result?.message_id };
|
||||
} else {
|
||||
const res = await axios.post(
|
||||
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`,
|
||||
`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendMessage`,
|
||||
{
|
||||
chat_id: channel.tg_channel_id,
|
||||
text: post.content,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Маппинг постов на иллюстрации с персонажем Зеро.
|
||||
// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp
|
||||
//
|
||||
// Логика выбора:
|
||||
// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу
|
||||
// 2. Иначе — берём позу по категории
|
||||
// 3. Если в локации файла нет — fallback на 'avatar'
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||||
|
||||
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt.
|
||||
// Порядок важен: первое срабатывание побеждает.
|
||||
const EMOTIONAL_TRIGGERS = [
|
||||
// "Получилось / заработало / победа" → victory
|
||||
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
|
||||
|
||||
// "Не работает / сломалось / провал" → facepalm
|
||||
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
|
||||
|
||||
// "Нашёл / открыл / классный" → eureka
|
||||
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
|
||||
|
||||
// "Запутался / непонятно / разбираемся" → confused
|
||||
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
|
||||
|
||||
// "Устал / долго / ночь" → tired
|
||||
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
|
||||
|
||||
// "Изучаю / разбор / гайд / шпаргалка" → reading или present
|
||||
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
|
||||
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
|
||||
|
||||
// "Расследую / разбираю / копаю" → magnifier
|
||||
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
|
||||
|
||||
// "Аналитика / метрики / графики" → chart
|
||||
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
|
||||
// "Запуск / деплой" → rocket
|
||||
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
|
||||
// "Баг / отладка" → bug
|
||||
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
|
||||
// "Рекомендация / топ" → thumbsup
|
||||
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
|
||||
// "Плавание / спорт" → swimming
|
||||
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
|
||||
// "Думаю / вопрос" → thinking
|
||||
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
|
||||
// "Исследование" → telescope
|
||||
{ pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
|
||||
|
||||
// "Подумать / поразмышлять / медитация" → meditate
|
||||
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
|
||||
];
|
||||
|
||||
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
|
||||
const CATEGORY_POSES = {
|
||||
'ai-tools': 'tools',
|
||||
'cybersec': 'lock',
|
||||
'automation': 'gears',
|
||||
'ai-dev': 'coding',
|
||||
};
|
||||
|
||||
const FALLBACK_POSE = 'avatar';
|
||||
|
||||
/**
|
||||
* Выбирает имя позы Зеро под пост.
|
||||
* @param {{ title?: string, excerpt?: string, category?: string }} ctx
|
||||
* @returns {{ pose: string, path: string|null, exists: boolean }}
|
||||
*/
|
||||
function pickPose({ title = '', excerpt = '', category = '' }) {
|
||||
const haystack = `${title} ${excerpt}`.toLowerCase();
|
||||
|
||||
// 1. Эмоциональные триггеры
|
||||
for (const t of EMOTIONAL_TRIGGERS) {
|
||||
for (const w of t.words) {
|
||||
if (haystack.includes(w)) {
|
||||
return resolve(t.pose, 'emotional');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. По категории
|
||||
const catPose = CATEGORY_POSES[category];
|
||||
if (catPose) return resolve(catPose, 'category');
|
||||
|
||||
// 3. Fallback
|
||||
return resolve(FALLBACK_POSE, 'fallback');
|
||||
}
|
||||
|
||||
function resolve(name, source) {
|
||||
const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
|
||||
const exists = fs.existsSync(localPath);
|
||||
// Если позы нет — пробуем avatar
|
||||
if (!exists && name !== FALLBACK_POSE) {
|
||||
const fbPath = path.join(UPLOADS_DIR, `zero-${FALLBACK_POSE}.webp`);
|
||||
if (fs.existsSync(fbPath)) {
|
||||
return { pose: FALLBACK_POSE, path: fbPath, exists: true, source: `${source}-fallback` };
|
||||
}
|
||||
return { pose: name, path: null, exists: false, source };
|
||||
}
|
||||
return { pose: name, path: exists ? localPath : null, exists, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Список доступных поз (для UI).
|
||||
*/
|
||||
function listAvailablePoses() {
|
||||
const out = [];
|
||||
for (const name of [
|
||||
'avatar', 'coding', 'tools', 'lock', 'gears',
|
||||
'eureka', 'confused', 'facepalm', 'victory', 'tired',
|
||||
'reading', 'magnifier', 'chart', 'meditate', 'present',
|
||||
'swimming', 'thinking', 'coffee', 'telescope', 'rocket', 'bug', 'sleep', 'thumbsup',
|
||||
]) {
|
||||
const p = path.join(UPLOADS_DIR, `zero-${name}.webp`);
|
||||
out.push({ name, exists: fs.existsSync(p), path: p, url: `/uploads/zero-${name}.webp` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
|
||||
Reference in New Issue
Block a user