feat(news): утренняя новость по реальным источникам (Yandex search + свой разбор)

- newsGenerator.js: gatherSources → факты+разбор со ссылкой, 2000-3000 знаков
- newsScheduler.js: тик раз в день в 08:00 МСК (NEWS_GENERATE_HOUR)
- autogen.js: DAILY_COUNT 4→1 — вечером теперь одна статья по ротации,
  а не пачка из четырёх (переход на ритм утро-новость/обед-заметка/вечер-статья)
This commit is contained in:
Alexey Pavlov
2026-09-19 18:16:40 +03:00
parent 80521bae92
commit 333b4c9003
4 changed files with 209 additions and 5 deletions
+3
View File
@@ -173,6 +173,9 @@ const start = async () => {
// Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК // Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК
require('./src/workers/zeroNotesScheduler').start(); require('./src/workers/zeroNotesScheduler').start();
// Утренняя новость (реальные источники через Yandex) — 08:00 МСК
require('./src/workers/newsScheduler').start();
app.listen(config.port, () => { app.listen(config.port, () => {
console.log(`[Engine] Running on port ${config.port}`); console.log(`[Engine] Running on port ${config.port}`);
}); });
+6 -5
View File
@@ -210,10 +210,11 @@ async function runAutogen({ forceCategory = null } = {}) {
[] []
); );
// Ротация: скользящее окно из 4 категорий сдвигается на 1 каждый день. // Ротация: скользящее окно категорий сдвигается на 1 каждый день.
// Это гарантирует что за 8 дней каждая категория выйдет минимум 4 раза, // 18.09.2026: переход на ритм "1 статья вечером" (было 4/день) — теперь
// и каждый день читатель видит другой набор. // утро закрывает новость (newsGenerator/newsScheduler), обед — заметка Зеро,
const DAILY_COUNT = 4; // вечер — ОДНА статья по ротации (за 8 дней каждая категория выйдет 1 раз).
const DAILY_COUNT = 1;
const total = allEnabled.length; const total = allEnabled.length;
let categoriesForToday; let categoriesForToday;
if (total <= DAILY_COUNT) { if (total <= DAILY_COUNT) {
@@ -294,7 +295,7 @@ async function runAutogen({ forceCategory = null } = {}) {
/** /**
* Возвращает категории которые активны сегодня по ротации (4 из 8). * Возвращает категории которые активны сегодня по ротации (4 из 8).
*/ */
function getTodayCategories(allCategories, dailyCount = 4) { function getTodayCategories(allCategories, dailyCount = 1) {
if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c); if (allCategories.length <= dailyCount) return allCategories.map(c => c.category || c);
const now = new Date(); const now = new Date();
const start = Date.UTC(now.getUTCFullYear(), 0, 0); const start = Date.UTC(now.getUTCFullYear(), 0, 0);
+126
View File
@@ -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 };
+74
View File
@@ -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 };