fix(news): кодировка страниц (1251 и пр.), детектор кракозябр, фильтр темы источника, отказ модели НЕТ_НОВОСТИ вместо поста «источник сломан»

This commit is contained in:
2026-10-19 21:36:05 +03:00
parent 66c071d5bd
commit bf9629b8ec
2 changed files with 113 additions and 13 deletions
+59 -3
View File
@@ -30,8 +30,53 @@ const DENY_DOMAINS = [
'vk.com', 'ok.ru', 't.me', 'telegram.me', 'twitter.com', 'x.com', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'twitter.com', 'x.com',
'youtube.com', 'youtu.be', 'rutube.ru', 'pinterest.com', 'instagram.com', 'youtube.com', 'youtu.be', 'rutube.ru', 'pinterest.com', 'instagram.com',
'facebook.com', 'tiktok.com', 'facebook.com', 'tiktok.com',
'pikabu.ru', // юзерские посты/видео, не источник новостей (18.10 — «лодочный мотор»)
]; ];
// ── Кодировка страницы ───────────────────────────────────────────────────────
// Раньше axios отдавал текст как UTF-8 всегда, и страницы в windows-1251
// превращались в кракозябры, а модель потом писала пост «про сломанный текст».
let _iconv = null;
try { _iconv = require('iconv-lite'); } catch { /* fallback — TextDecoder */ }
function detectCharset(contentType, buf) {
const fromHeader = /charset=["']?([\w-]+)/i.exec(contentType || '');
if (fromHeader) return fromHeader[1].toLowerCase();
// meta charset — в первых 4 КБ, читаем как latin1 (ASCII-часть не пострадает)
const head = buf.slice(0, 4096).toString('latin1');
const m = /<meta[^>]+charset=["']?([\w-]+)/i.exec(head);
return m ? m[1].toLowerCase() : 'utf-8';
}
function decodeBody(buf, charset) {
const cs = (charset || 'utf-8').replace(/^cp-?1251$/, 'windows-1251');
if (cs === 'utf-8' || cs === 'utf8') return buf.toString('utf-8');
try {
if (_iconv && _iconv.encodingExists(cs)) return _iconv.decode(buf, cs);
return new TextDecoder(cs).decode(buf);
} catch {
return buf.toString('utf-8');
}
}
/**
* Похоже ли на битую кодировку/мусор. Ловим: символы-замены U+FFFD,
* типичную «мозаику» UTF-8-как-1251 (аб...) и 1251-как-latin1 (Ð Ñ),
* и просто мало нормальных букв.
*/
function looksGarbled(text) {
const t = String(text || '');
if (!t.length) return true;
const sample = t.slice(0, 5000);
const repl = (sample.match(/\uFFFD/g) || []).length;
if (repl / sample.length > 0.005) return true;
if (/(?:[РС][\u0080-\u00BF\u0400-\u040F\u0450-\u045F°±µ¶·]){6,}/.test(sample)) return true;
if (/(?:[ÐÑ][\u0080-\u00BF]){6,}/.test(sample)) return true;
const letters = (sample.match(/[A-Za-zА-Яа-яЁё0-9]/g) || []).length;
return letters / sample.length < 0.6;
}
// ── Redis / квота ──────────────────────────────────────────────────────────── // ── Redis / квота ────────────────────────────────────────────────────────────
let _redis = null; let _redis = null;
@@ -271,7 +316,7 @@ async function fetchArticle(url) {
timeout: PAGE_TIMEOUT_MS, timeout: PAGE_TIMEOUT_MS,
maxContentLength: MAX_PAGE_BYTES, maxContentLength: MAX_PAGE_BYTES,
maxRedirects: 5, maxRedirects: 5,
responseType: 'text', responseType: 'arraybuffer',
headers: { headers: {
'User-Agent': USER_AGENT, 'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml', 'Accept': 'text/html,application/xhtml+xml',
@@ -288,9 +333,12 @@ async function fetchArticle(url) {
return { url, ok: false, error: `unsupported content-type: ${ctype.slice(0, 40)}` }; return { url, ok: false, error: `unsupported content-type: ${ctype.slice(0, 40)}` };
} }
const buf = Buffer.from(res.data);
const html = decodeBody(buf, detectCharset(ctype, buf));
let $; let $;
try { try {
$ = cheerio.load(String(res.data)); $ = cheerio.load(html);
} catch (err) { } catch (err) {
return { url, ok: false, error: 'html parse failed' }; return { url, ok: false, error: 'html parse failed' };
} }
@@ -330,6 +378,9 @@ async function fetchArticle(url) {
if (text.length < MIN_TEXT_CHARS) { if (text.length < MIN_TEXT_CHARS) {
return { url, ok: false, error: `too little text (${text.length} chars)` }; return { url, ok: false, error: `too little text (${text.length} chars)` };
} }
if (looksGarbled(title + ' ' + text)) {
return { url, ok: false, error: 'garbled text (encoding/мусор)' };
}
let domain = ''; let domain = '';
try { domain = new URL(url).hostname.replace(/^www\./, ''); } catch {} try { domain = new URL(url).hostname.replace(/^www\./, ''); } catch {}
@@ -355,7 +406,7 @@ async function fetchArticle(url) {
* @returns {Promise<{query:string, sources:Array, tried:number}>} * @returns {Promise<{query:string, sources:Array, tried:number}>}
*/ */
async function gatherSources(query, opts = {}) { async function gatherSources(query, opts = {}) {
const { want = 3, freshDays = 7, sortByTime = false, searchType = 'SEARCH_TYPE_RU' } = opts; const { want = 3, freshDays = 7, sortByTime = false, searchType = 'SEARCH_TYPE_RU', accept = null } = opts;
const { docs } = await searchWeb(query, { count: want * 5, freshDays, sortByTime, searchType }); const { docs } = await searchWeb(query, { count: want * 5, freshDays, sortByTime, searchType });
@@ -374,6 +425,10 @@ async function gatherSources(query, opts = {}) {
console.warn(`[news-search] skip ${doc.url}: ${page.error}`); console.warn(`[news-search] skip ${doc.url}: ${page.error}`);
continue; continue;
} }
if (accept && !accept(page)) {
console.warn(`[news-search] skip ${doc.url}: не по теме`);
continue;
}
if (dom) seenDomains.add(dom); if (dom) seenDomains.add(dom);
sources.push({ sources.push({
url: page.url, url: page.url,
@@ -395,4 +450,5 @@ module.exports = {
gatherSources, gatherSources,
getQuotaStatus, getQuotaStatus,
parseWebXml, parseWebXml,
looksGarbled,
}; };
+48 -4
View File
@@ -27,6 +27,35 @@ const QUERY_BANK = [
'опенсорс модель ИИ выпуск', 'опенсорс модель ИИ выпуск',
]; ];
// Источник должен быть про нашу тематику (ИИ / разработка / кибербез).
// Нужно минимум 2 разных попадания в заголовке + начале текста — одно слово
// «модель» или «AI» в меню сайта не в счёт. Иначе на фолбэке без свежести
// Яндекс подсовывает что угодно (18.10.2026 — видео про лодочный мотор).
const NO_CYR = '(?<![А-Яа-яЁё])';
const NO_CYR_END = '(?![А-Яа-яЁё])';
const TOPIC_PATTERNS = [
new RegExp(`${NO_CYR}ИИ${NO_CYR_END}`),
/искусственн\w* интеллект/i,
/нейросет/i,
/\b(?:AI|LLM|GPT|AGI)\b/,
/машинн\w* обучени|machine learning|deep learning/i,
/OpenAI|Anthropic|DeepMind|ChatGPT|Claude|Gemini|Llama|Mistral|DeepSeek|Qwen|Copilot|Hugging ?Face/i,
/языков\w* модел|large language model|нейронн\w* модел/i,
/ИИ-агент|AI.?agent|агентн\w* (?:систем|ИИ)/i,
/кибербез|уязвимост|вредонос|ransomware|фишинг|хакер/i,
/разработчик|программист|open.?source|опенсорс|GitHub/i,
/автоматизац|no-code|n8n/i,
];
function isOnTopic(page) {
const hay = `${page.title || ''}\n${(page.text || '').slice(0, 3000)}`;
const hits = TOPIC_PATTERNS.filter(re => re.test(hay)).length;
return hits >= 2;
}
// Маркер, которым модель отказывается писать, если в источниках нет новости.
const NO_NEWS_MARKER = 'НЕТ_НОВОСТИ';
function pickQuery(offsetDays = 0) { function pickQuery(offsetDays = 0) {
const start = Date.UTC(new Date().getUTCFullYear(), 0, 0); const start = Date.UTC(new Date().getUTCFullYear(), 0, 0);
const dayOfYear = Math.floor((Date.now() - start) / 86400000) + offsetDays; const dayOfYear = Math.floor((Date.now() - start) / 86400000) + offsetDays;
@@ -48,18 +77,18 @@ async function alreadyGeneratedToday() {
* если свежих новостей по теме не нашлось. * если свежих новостей по теме не нашлось.
*/ */
async function gatherWithFallback(baseQuery) { async function gatherWithFallback(baseQuery) {
let gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 3, sortByTime: true }); let gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 3, sortByTime: true, accept: isOnTopic });
if (gathered.sources.length) return gathered; if (gathered.sources.length) return gathered;
console.log(`[news] "${baseQuery}": нет свежих (3 дня) — пробую без ограничения по времени`); console.log(`[news] "${baseQuery}": нет свежих (3 дня) — пробую без ограничения по времени`);
gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 14, sortByTime: false }); gathered = await newsSearch.gatherSources(baseQuery, { want: 2, freshDays: 14, sortByTime: false, accept: isOnTopic });
if (gathered.sources.length) return gathered; if (gathered.sources.length) return gathered;
// Последняя попытка — соседний запрос из банка (вдруг эта тема сегодня пустая) // Последняя попытка — соседний запрос из банка (вдруг эта тема сегодня пустая)
const altQuery = pickQuery(1); const altQuery = pickQuery(1);
if (altQuery !== baseQuery) { if (altQuery !== baseQuery) {
console.log(`[news] "${baseQuery}": пусто вообще — пробую альтернативный запрос "${altQuery}"`); console.log(`[news] "${baseQuery}": пусто вообще — пробую альтернативный запрос "${altQuery}"`);
gathered = await newsSearch.gatherSources(altQuery, { want: 2, freshDays: 7, sortByTime: false }); gathered = await newsSearch.gatherSources(altQuery, { want: 2, freshDays: 7, sortByTime: false, accept: isOnTopic });
} }
return gathered; return gathered;
} }
@@ -95,6 +124,7 @@ async function generateMorningNews(opts = {}) {
Объём — 2000-3000 знаков. Объём — 2000-3000 знаков.
В САМОМ КОНЦЕ отдельной строкой добавь: "Источник: ${primary.url}" В САМОМ КОНЦЕ отдельной строкой добавь: "Источник: ${primary.url}"
Не выдумывай цифры и детали, которых нет в источниках. Если источники противоречат друг другу — честно об этом скажи. Не выдумывай цифры и детали, которых нет в источниках. Если источники противоречат друг другу — честно об этом скажи.
ВАЖНО: если в источниках нет конкретной новости по теме блога (ИИ, разработка, автоматизация, кибербезопасность) — текст битый/нечитаемый, источник не по теме, или там только заголовок без содержания — НЕ пиши пост о том, что источник сломан или пуст. Вместо поста ответь ровно одним словом: ${NO_NEWS_MARKER}
СЫРЬЁ (источники, вычитанные целиком): СЫРЬЁ (источники, вычитанные целиком):
${sourcesBlock}`; ${sourcesBlock}`;
@@ -105,13 +135,27 @@ ${sourcesBlock}`;
const topic = primary.title || baseQuery; const topic = primary.title || baseQuery;
const article = await articles.generateAndSaveArticle({ let article;
try {
article = await articles.generateAndSaveArticle({
topic, topic,
category: 'news', category: 'news',
tags: ['новости'], tags: ['новости'],
autoPublish: true, autoPublish: true,
customPrompt, customPrompt,
}); });
} catch (err) {
// Сюда же попадает отказ модели (NO_NEWS_MARKER короче минимума — articles.js не сохраняет)
console.warn(`[news] генерация не сохранена (query="${baseQuery}", source=${primary.url}): ${err.message}`);
return { ok: false, error: err.message, query: baseQuery, source: primary.url };
}
// Страховка: модель отказалась, но ответ всё же прошёл по длине
if (String(article.content || '').includes(NO_NEWS_MARKER)) {
await query(`UPDATE articles SET status='draft', published_at=NULL WHERE id=$1`, [article.id]);
console.warn(`[news] модель отказалась (нет новости в источниках) — article=${article.id} снят в черновик`);
return { ok: false, error: 'model: no news in sources', query: baseQuery, source: primary.url };
}
// Страховка: если модель не оставила явную ссылку — дописываем сами. // Страховка: если модель не оставила явную ссылку — дописываем сами.
if (!article.content.includes(primary.url)) { if (!article.content.includes(primary.url)) {