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',
|
'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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user