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