diff --git a/index.js b/index.js index 10756b7..360de38 100644 --- a/index.js +++ b/index.js @@ -173,6 +173,9 @@ const start = async () => { // Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК require('./src/workers/zeroNotesScheduler').start(); + // Утренняя новость (реальные источники через Yandex) — 08:00 МСК + require('./src/workers/newsScheduler').start(); + app.listen(config.port, () => { console.log(`[Engine] Running on port ${config.port}`); }); diff --git a/src/services/autogen.js b/src/services/autogen.js index b6e231c..eb82b5a 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -210,10 +210,11 @@ async function runAutogen({ forceCategory = null } = {}) { [] ); - // Ротация: скользящее окно из 4 категорий сдвигается на 1 каждый день. - // Это гарантирует что за 8 дней каждая категория выйдет минимум 4 раза, - // и каждый день читатель видит другой набор. - const DAILY_COUNT = 4; + // Ротация: скользящее окно категорий сдвигается на 1 каждый день. + // 18.09.2026: переход на ритм "1 статья вечером" (было 4/день) — теперь + // утро закрывает новость (newsGenerator/newsScheduler), обед — заметка Зеро, + // вечер — ОДНА статья по ротации (за 8 дней каждая категория выйдет 1 раз). + const DAILY_COUNT = 1; const total = allEnabled.length; let categoriesForToday; if (total <= DAILY_COUNT) { @@ -294,7 +295,7 @@ async function runAutogen({ forceCategory = null } = {}) { /** * Возвращает категории которые активны сегодня по ротации (4 из 8). */ -function getTodayCategories(allCategories, dailyCount = 4) { +function getTodayCategories(allCategories, dailyCount = 1) { if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c); const now = new Date(); const start = Date.UTC(now.getUTCFullYear(), 0, 0); diff --git a/src/services/newsGenerator.js b/src/services/newsGenerator.js new file mode 100644 index 0000000..fa81488 --- /dev/null +++ b/src/services/newsGenerator.js @@ -0,0 +1,126 @@ +/** + * newsGenerator.js — утренняя новость. + * + * В отличие от обычных статей (autogen.js — темы из банка, пишутся по памяти + * модели), новость строится на РЕАЛЬНЫХ источниках: news-search.js ищет через + * Yandex Web Search и вычитывает страницы целиком, а модель пишет факты + + * свой разбор со ссылкой на источник — без пересказа близко к тексту. + * + * Расписание — src/workers/newsScheduler.js (запускает раз в день утром). + */ + +const newsSearch = require('./news-search'); +const articles = require('./articles'); +const { query } = require('../config/db'); + +// Банк поисковых запросов — ротация по дню года, чтобы не повторяться подряд. +const QUERY_BANK = [ + 'новости искусственный интеллект', + 'новая модель ИИ релиз анонс', + 'OpenAI Anthropic Google DeepMind новости', + 'ИИ инструменты для разработчиков новости', + 'кибербезопасность атака ИИ новости', + 'автоматизация бизнеса ИИ кейс новости', + 'LLM новый релиз обновление модели', + 'AI agent агент новости', + 'нейросети регулирование закон новости', + 'опенсорс модель ИИ выпуск', +]; + +function pickQuery(offsetDays = 0) { + const start = Date.UTC(new Date().getUTCFullYear(), 0, 0); + const dayOfYear = Math.floor((Date.now() - start) / 86400000) + offsetDays; + return QUERY_BANK[((dayOfYear % QUERY_BANK.length) + QUERY_BANK.length) % QUERY_BANK.length]; +} + +/** + * Уже генерили новость сегодня? (антидубль на случай двойного тика) + */ +async function alreadyGeneratedToday() { + const { rows } = await query( + `SELECT 1 FROM articles WHERE category='news' AND created_at >= CURRENT_DATE LIMIT 1` + ); + return rows.length > 0; +} + +/** + * Собрать источники под запрос с фолбэком на менее строгие условия, + * если свежих новостей по теме не нашлось. + */ +async function gatherWithFallback(baseQuery) { + let gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 3, sortByTime: true }); + if (gathered.sources.length) return gathered; + + console.log(`[news] "${baseQuery}": нет свежих (3 дня) — пробую без ограничения по времени`); + gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 14, sortByTime: false }); + if (gathered.sources.length) return gathered; + + // Последняя попытка — соседний запрос из банка (вдруг эта тема сегодня пустая) + const altQuery = pickQuery(1); + if (altQuery !== baseQuery) { + console.log(`[news] "${baseQuery}": пусто вообще — пробую альтернативный запрос "${altQuery}"`); + gathered = await newsSearch.gatherSources(altQuery, { want: 2, freshDays: 7, sortByTime: false }); + } + return gathered; +} + +/** + * Сгенерировать и опубликовать утреннюю новость. + * @param {object} [opts] + * @param {boolean} [opts.dryRun] — не сохранять, вернуть только собранные данные + */ +async function generateMorningNews(opts = {}) { + const { dryRun = false } = opts; + + if (!dryRun && await alreadyGeneratedToday()) { + console.log('[news] уже генерили сегодня, пропуск'); + return { ok: false, skipped: true }; + } + + const baseQuery = pickQuery(); + const gathered = await gatherWithFallback(baseQuery); + + if (!gathered.sources.length) { + console.warn(`[news] источников не найдено (запрос="${baseQuery}")`); + return { ok: false, error: 'no sources found', query: baseQuery }; + } + + const primary = gathered.sources[0]; + const sourcesBlock = gathered.sources + .map((s, i) => `Источник ${i + 1}: ${s.title} (${s.domain}, ${s.url})\n${s.text.slice(0, 4000)}`) + .join('\n\n---\n\n'); + + const customPrompt = `Это НОВОСТНОЙ пост, а не обзорная статья "как что-то попробовал". +Пиши по присланным ниже источникам: только факты + свой разбор/реакция от Зеро — не пересказывай близко к тексту оригинала, формулируй своими словами. +Объём — 2000-3000 знаков. +В САМОМ КОНЦЕ отдельной строкой добавь: "Источник: ${primary.url}" +Не выдумывай цифры и детали, которых нет в источниках. Если источники противоречат друг другу — честно об этом скажи. + +СЫРЬЁ (источники, вычитанные целиком): +${sourcesBlock}`; + + if (dryRun) { + return { ok: true, dryRun: true, query: baseQuery, primary, sourcesCount: gathered.sources.length, customPrompt }; + } + + const topic = primary.title || baseQuery; + + const article = await articles.generateAndSaveArticle({ + topic, + category: 'news', + tags: ['новости'], + autoPublish: true, + customPrompt, + }); + + // Страховка: если модель не оставила явную ссылку — дописываем сами. + if (!article.content.includes(primary.url)) { + await query(`UPDATE articles SET content = content || $1 WHERE id=$2`, + [`\n\nИсточник: ${primary.url}`, article.id]); + } + + console.log(`[news] OK article=${article.id} query="${baseQuery}" source=${primary.url}`); + return { ok: true, article, query: baseQuery, source: primary.url }; +} + +module.exports = { generateMorningNews, pickQuery, gatherWithFallback }; diff --git a/src/workers/newsScheduler.js b/src/workers/newsScheduler.js new file mode 100644 index 0000000..31514eb --- /dev/null +++ b/src/workers/newsScheduler.js @@ -0,0 +1,74 @@ +/** + * newsScheduler.js — расписание для утренней новости. + * + * Тик каждые 60 сек, в МСК. В NEWS_GENERATE_HOUR (по умолчанию 08:00) — + * один раз в день дёргает newsGenerator.generateMorningNews(), который сам + * публикует статью сразу (autoPublish=true) — черновик/ревью не нужен, + * это новость дня, ей нет смысла отлёживаться. + * + * Защита от двойного запуска — как в zeroNotesScheduler: in-memory флаг по + * slot-ключу (YYYY-MM-DDTHH:00). + */ + +const newsGen = require('../services/newsGenerator'); +const settings = require('../services/settings'); + +const TICK_MS = 60_000; +const MSK_OFFSET_MIN = 180; + +let lastRunSlot = null; + +function nowMsk() { + const now = new Date(); + const msk = new Date(now.getTime() + MSK_OFFSET_MIN * 60_000); + return { hour: msk.getUTCHours(), ymd: msk.toISOString().slice(0, 10) }; +} + +async function generateHourMsk() { + return parseInt(await settings.get('NEWS_GENERATE_HOUR', '8'), 10); +} + +async function tick() { + try { + const { hour, ymd } = nowMsk(); + const genHour = await generateHourMsk(); + if (hour !== genHour) return; + + const slotKey = `${ymd}T${String(genHour).padStart(2, '0')}:00`; + if (lastRunSlot === slotKey) return; + lastRunSlot = slotKey; + + console.log(`[news/scheduler] ${genHour}:00 МСК — генерация утренней новости`); + const res = await newsGen.generateMorningNews(); + if (res.ok && res.article) { + console.log(`[news/scheduler] опубликовано: article #${res.article.id}`); + } else if (!res.skipped) { + console.warn(`[news/scheduler] не удалось: ${res.error || 'unknown'}`); + } + } catch (err) { + console.error(`[news/scheduler] tick error: ${err.message}`); + } +} + +let intervalRef = null; + +function start() { + if (intervalRef) { + console.log('[news/scheduler] already started'); + return; + } + intervalRef = setInterval(tick, TICK_MS); + setTimeout(tick, 30_000); + generateHourMsk().then(h => + console.log(`[news/scheduler] started, tick every ${TICK_MS / 1000}s, generate=${h}:00 MSK`) + ).catch(() => {}); +} + +function stop() { + if (intervalRef) { + clearInterval(intervalRef); + intervalRef = null; + } +} + +module.exports = { start, stop, tick };