fix(news): кодировка страниц (1251 и пр.), детектор кракозябр, фильтр темы источника, отказ модели НЕТ_НОВОСТИ вместо поста «источник сломан»
This commit is contained in:
@@ -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 = /<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 / квота ────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user