From bf9629b8ec602ddc02348f327b554d3a979981b4 Mon Sep 17 00:00:00 2001 From: ZeroDay Date: Mon, 19 Oct 2026 21:36:05 +0300 Subject: [PATCH] =?UTF-8?q?fix(news):=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=20(1251=20=D0=B8=20=D0=BF=D1=80.),=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20=D0=BA=D1=80=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B7=D1=8F=D0=B1=D1=80,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=B0,=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=9D=D0=95?= =?UTF-8?q?=D0=A2=5F=D0=9D=D0=9E=D0=92=D0=9E=D0=A1=D0=A2=D0=98=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=BC=D0=B0=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/news-search.js | 62 +++++++++++++++++++++++++++++++-- src/services/newsGenerator.js | 64 +++++++++++++++++++++++++++++------ 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/services/news-search.js b/src/services/news-search.js index 24eb48d..59744f9 100644 --- a/src/services/news-search.js +++ b/src/services/news-search.js @@ -30,8 +30,53 @@ const DENY_DOMAINS = [ 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'twitter.com', 'x.com', 'youtube.com', 'youtu.be', 'rutube.ru', 'pinterest.com', 'instagram.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 = /]+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 / квота ──────────────────────────────────────────────────────────── let _redis = null; @@ -271,7 +316,7 @@ async function fetchArticle(url) { timeout: PAGE_TIMEOUT_MS, maxContentLength: MAX_PAGE_BYTES, maxRedirects: 5, - responseType: 'text', + responseType: 'arraybuffer', headers: { 'User-Agent': USER_AGENT, '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)}` }; } + const buf = Buffer.from(res.data); + const html = decodeBody(buf, detectCharset(ctype, buf)); + let $; try { - $ = cheerio.load(String(res.data)); + $ = cheerio.load(html); } catch (err) { return { url, ok: false, error: 'html parse failed' }; } @@ -330,6 +378,9 @@ async function fetchArticle(url) { if (text.length < MIN_TEXT_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 = ''; 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}>} */ 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 }); @@ -374,6 +425,10 @@ async function gatherSources(query, opts = {}) { console.warn(`[news-search] skip ${doc.url}: ${page.error}`); continue; } + if (accept && !accept(page)) { + console.warn(`[news-search] skip ${doc.url}: не по теме`); + continue; + } if (dom) seenDomains.add(dom); sources.push({ url: page.url, @@ -395,4 +450,5 @@ module.exports = { gatherSources, getQuotaStatus, parseWebXml, + looksGarbled, }; diff --git a/src/services/newsGenerator.js b/src/services/newsGenerator.js index fa81488..ec90349 100644 --- a/src/services/newsGenerator.js +++ b/src/services/newsGenerator.js @@ -27,6 +27,35 @@ const QUERY_BANK = [ 'опенсорс модель ИИ выпуск', ]; +// Источник должен быть про нашу тематику (ИИ / разработка / кибербез). +// Нужно минимум 2 разных попадания в заголовке + начале текста — одно слово +// «модель» или «AI» в меню сайта не в счёт. Иначе на фолбэке без свежести +// Яндекс подсовывает что угодно (18.10.2026 — видео про лодочный мотор). +const NO_CYR = '(? re.test(hay)).length; + return hits >= 2; +} + +// Маркер, которым модель отказывается писать, если в источниках нет новости. +const NO_NEWS_MARKER = 'НЕТ_НОВОСТИ'; + function pickQuery(offsetDays = 0) { const start = Date.UTC(new Date().getUTCFullYear(), 0, 0); const dayOfYear = Math.floor((Date.now() - start) / 86400000) + offsetDays; @@ -48,18 +77,18 @@ async function alreadyGeneratedToday() { * если свежих новостей по теме не нашлось. */ 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; 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; // Последняя попытка — соседний запрос из банка (вдруг эта тема сегодня пустая) const altQuery = pickQuery(1); if (altQuery !== baseQuery) { 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; } @@ -95,6 +124,7 @@ async function generateMorningNews(opts = {}) { Объём — 2000-3000 знаков. В САМОМ КОНЦЕ отдельной строкой добавь: "Источник: ${primary.url}" Не выдумывай цифры и детали, которых нет в источниках. Если источники противоречат друг другу — честно об этом скажи. +ВАЖНО: если в источниках нет конкретной новости по теме блога (ИИ, разработка, автоматизация, кибербезопасность) — текст битый/нечитаемый, источник не по теме, или там только заголовок без содержания — НЕ пиши пост о том, что источник сломан или пуст. Вместо поста ответь ровно одним словом: ${NO_NEWS_MARKER} СЫРЬЁ (источники, вычитанные целиком): ${sourcesBlock}`; @@ -105,13 +135,27 @@ ${sourcesBlock}`; const topic = primary.title || baseQuery; - const article = await articles.generateAndSaveArticle({ - topic, - category: 'news', - tags: ['новости'], - autoPublish: true, - customPrompt, - }); + let article; + try { + article = await articles.generateAndSaveArticle({ + topic, + category: 'news', + tags: ['новости'], + autoPublish: true, + 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)) {