feat(news): модуль поиска источников — Yandex web search + чтение страниц

Основа для утренних новостных постов. До сих пор в генерации не было
веб-поиска вообще — модель писала 'что нового' по памяти.

- searchWeb(): Yandex Search API v2 /web/search, та же схема авторизации,
  что и в photo-search (Api-Key + base64 XML). Поддержка свежести
  (оператор date:>=), сортировки по дате, RU/COM выдачи.
- fetchArticle(): тянет саму страницу и вытаскивает основной текст через
  cheerio (не сниппет — по сниппетам модель дофантазирует факты).
  Отсев не-HTML, коротких/JS-страниц.
- gatherSources(): поиск + вычитка N источников, по одному на домен —
  перекрёстное подтверждение фактов и меньше липкости к формулировкам.
- Своя дневная квота в Redis (news_search:count), чтобы не съедать
  лимит фото-поиска. Настройка NEWS_SEARCH_DAILY_LIMIT (по умолчанию 50).
This commit is contained in:
Alexey Pavlov
2026-07-30 16:36:40 +03:00
parent 821d9ac66c
commit bf60d458da
+398
View File
@@ -0,0 +1,398 @@
// news-search.js
// Поиск источников для новостных постов: Yandex Search API (web) + вытягивание
// ПОЛНОГО текста найденной страницы.
//
// Зачем это вообще: генерация статей до сих пор шла БЕЗ веб-поиска — модель
// писала «что нового» по памяти, отсюда пустые дайджесты. Здесь мы даём ей
// реальные источники. Принципиально важно, что мы читаем саму страницу, а не
// сниппет из выдачи: по сниппету модель неизбежно дофантазирует детали и цифры.
//
// Квота: отдельный дневной счётчик в Redis (news_search:count:YYYY-MM-DD),
// чтобы поиск новостей не съедал лимит фото-поиска (у того свой счётчик).
const axios = require('axios');
const cheerio = require('cheerio');
const { XMLParser } = require('fast-xml-parser');
const Redis = require('ioredis');
const settings = require('./settings');
const config = require('../config');
const YANDEX_WEB_ENDPOINT = 'https://searchapi.api.cloud.yandex.net/v2/web/search';
const USER_AGENT = 'Mozilla/5.0 (compatible; ZeroPost/1.0; +https://zeropost.ru)';
const PAGE_TIMEOUT_MS = 15000;
const MAX_PAGE_BYTES = 3 * 1024 * 1024;
const MAX_TEXT_CHARS = 12000; // больше модели и не нужно
const MIN_TEXT_CHARS = 400; // меньше — считаем, что текст не вытащился
// Домены, с которых читать бессмысленно (соцсети/видео/агрегаторы без текста).
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',
];
// ── Redis / квота ────────────────────────────────────────────────────────────
let _redis = null;
function getRedis() {
if (!_redis) {
_redis = new Redis({
host: config.redis.host,
port: config.redis.port,
lazyConnect: false,
maxRetriesPerRequest: 3,
});
_redis.on('error', (err) => console.error('[news-search] redis error:', err.message));
}
return _redis;
}
function dailyKey() {
return `news_search:count:${new Date().toISOString().slice(0, 10)}`;
}
async function getDailyCount() {
try {
const v = await getRedis().get(dailyKey());
return parseInt(v) || 0;
} catch {
return 0;
}
}
async function incrementDaily() {
try {
const r = getRedis();
const k = dailyKey();
const count = await r.incr(k);
if (count === 1) await r.expire(k, 172800); // 48h TTL
return count;
} catch (err) {
console.error('[news-search] incr failed:', err.message);
return 0;
}
}
async function getQuotaStatus() {
const limit = parseInt(await settings.get('NEWS_SEARCH_DAILY_LIMIT', '50'));
const used = await getDailyCount();
return { used, limit, remaining: Math.max(0, limit - used) };
}
// ── Парсинг XML выдачи ───────────────────────────────────────────────────────
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
parseAttributeValue: false,
trimValues: true,
});
/**
* Вытаскивает текст из узла, который может быть строкой, объектом с #text
* или смешанным контентом с подсветкой <hlword>. Порядок слов приблизительный —
* этого достаточно, полноценный текст мы всё равно берём со страницы.
*/
function flattenText(node) {
if (node == null) return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(flattenText).join(' ');
if (typeof node === 'object') {
const parts = [];
for (const [k, v] of Object.entries(node)) {
if (k.startsWith('@_')) continue;
parts.push(flattenText(v));
}
return parts.join(' ');
}
return '';
}
function normalizeSpace(s) {
return String(s || '').replace(/\s+/g, ' ').trim();
}
function parseWebXml(base64Data) {
const xmlText = Buffer.from(base64Data, 'base64').toString('utf-8');
const parsed = xmlParser.parse(xmlText);
const response = parsed?.yandexsearch?.response;
if (!response) throw new Error('Unexpected Yandex response: no <response>');
if (response.error) {
const errText = typeof response.error === 'object'
? (response.error['#text'] || JSON.stringify(response.error))
: response.error;
throw new Error(`Yandex error: ${errText}`);
}
const grouping = response.results?.grouping;
if (!grouping) return { total: 0, docs: [] };
const groups = Array.isArray(grouping.group) ? grouping.group : [grouping.group].filter(Boolean);
const docs = [];
for (const group of groups) {
const groupDocs = Array.isArray(group.doc) ? group.doc : [group.doc].filter(Boolean);
for (const doc of groupDocs) {
const passages = doc.passages?.passage;
const passageText = Array.isArray(passages)
? passages.map(flattenText).join(' ')
: flattenText(passages);
docs.push({
url: normalizeSpace(flattenText(doc.url)),
domain: normalizeSpace(flattenText(doc.domain)),
title: normalizeSpace(flattenText(doc.title)).slice(0, 300),
headline: normalizeSpace(flattenText(doc.headline)).slice(0, 300),
passage: normalizeSpace(passageText).slice(0, 600),
modtime: normalizeSpace(flattenText(doc.modtime)) || null,
});
}
}
const foundArr = Array.isArray(response.found) ? response.found : (response.found ? [response.found] : []);
const foundAll = foundArr.find(f => f['@_priority'] === 'all');
const total = foundAll ? parseInt(flattenText(foundAll)) : docs.length;
return { total, docs };
}
// ── Поиск ────────────────────────────────────────────────────────────────────
/**
* Веб-поиск через Yandex Search API.
*
* @param {string} query
* @param {object} opts
* @param {number} opts.count сколько результатов вернуть (по умолчанию 10)
* @param {number} opts.freshDays ограничить свежестью N дней (оператор date:>=)
* @param {boolean} opts.sortByTime сортировать по дате, а не по релевантности
* @param {string} opts.searchType SEARCH_TYPE_RU (по умолчанию) | SEARCH_TYPE_COM
* @returns {Promise<{total:number, docs:Array}>}
*/
async function searchWeb(query, opts = {}) {
const {
count = 10,
freshDays = null,
sortByTime = false,
searchType = 'SEARCH_TYPE_RU',
} = opts;
if (!query || !String(query).trim()) throw new Error('query is required');
const limit = parseInt(await settings.get('NEWS_SEARCH_DAILY_LIMIT', '50'));
const used = await getDailyCount();
if (used >= limit) {
const err = new Error(`Daily news search limit reached: ${used}/${limit}`);
err.code = 'DAILY_LIMIT_EXCEEDED';
throw err;
}
const apiKey = await settings.get('YANDEX_SEARCH_API_KEY', '');
const folderId = await settings.get('YANDEX_SEARCH_FOLDER_ID', '');
if (!apiKey || !folderId) {
throw new Error('Yandex Search API not configured (YANDEX_SEARCH_API_KEY / YANDEX_SEARCH_FOLDER_ID)');
}
let queryText = String(query).trim();
if (freshDays && Number(freshDays) > 0) {
const from = new Date(Date.now() - Number(freshDays) * 86400000);
const stamp = from.toISOString().slice(0, 10).replace(/-/g, '');
queryText += ` date:>=${stamp}`;
}
const requestBody = {
query: {
searchType,
queryText,
familyMode: 'FAMILY_MODE_MODERATE',
page: '0',
fixTypoMode: 'FIX_TYPO_MODE_ON',
},
sortSpec: {
sortMode: sortByTime ? 'SORT_MODE_BY_TIME' : 'SORT_MODE_BY_RELEVANCE',
sortOrder: 'SORT_ORDER_DESC',
},
groupSpec: {
groupMode: 'GROUP_MODE_DEEP',
groupsOnPage: String(Math.min(Math.max(count, 1), 50)),
docsInGroup: '1',
},
maxPassages: '4',
region: '225',
l10n: 'LOCALIZATION_RU',
folderId,
responseFormat: 'FORMAT_XML',
userAgent: USER_AGENT,
};
await incrementDaily();
let response;
try {
response = await axios.post(YANDEX_WEB_ENDPOINT, requestBody, {
headers: {
'Authorization': `Api-Key ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 20000,
});
} catch (err) {
const status = err.response?.status;
const data = err.response?.data;
const detail = data?.message || data?.code || err.message;
const e = new Error(`Yandex web search failed (${status || 'no-response'}): ${detail}`);
e.status = status;
throw e;
}
if (!response.data?.rawData) throw new Error('Yandex response missing rawData field');
const { total, docs } = parseWebXml(response.data.rawData);
const clean = docs
.filter(d => d.url && /^https?:\/\//i.test(d.url))
.filter(d => !DENY_DOMAINS.some(bad => (d.domain || '').includes(bad)));
return { total, docs: clean.slice(0, count) };
}
// ── Чтение страницы ──────────────────────────────────────────────────────────
/**
* Тянет страницу и вытаскивает основной текст.
* Возвращает null, если это не HTML, страница не открылась или текста мало.
*/
async function fetchArticle(url) {
let res;
try {
res = await axios.get(url, {
timeout: PAGE_TIMEOUT_MS,
maxContentLength: MAX_PAGE_BYTES,
maxRedirects: 5,
responseType: 'text',
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'ru,en;q=0.8',
},
validateStatus: s => s >= 200 && s < 400,
});
} catch (err) {
return { url, ok: false, error: (err.message || '').slice(0, 120) };
}
const ctype = String(res.headers?.['content-type'] || '');
if (ctype && !/html|xml|text\/plain/i.test(ctype)) {
return { url, ok: false, error: `unsupported content-type: ${ctype.slice(0, 40)}` };
}
let $;
try {
$ = cheerio.load(String(res.data));
} catch (err) {
return { url, ok: false, error: 'html parse failed' };
}
$('script, style, noscript, iframe, svg, form, nav, header, footer, aside').remove();
$('[class*="comment"], [id*="comment"], [class*="sidebar"], [class*="banner"], [class*="cookie"]').remove();
const title = normalizeSpace(
$('meta[property="og:title"]').attr('content') ||
$('title').first().text() ||
$('h1').first().text()
).slice(0, 300);
const publishedAt = normalizeSpace(
$('meta[property="article:published_time"]').attr('content') ||
$('meta[itemprop="datePublished"]').attr('content') ||
$('time[datetime]').first().attr('datetime') ||
''
) || null;
// Пытаемся найти именно тело статьи, иначе — весь body.
const candidates = ['article', 'main', '[role="main"]', '.post-content', '.entry-content',
'.article-body', '.article__body', '#content', '.content'];
let text = '';
for (const sel of candidates) {
const node = $(sel).first();
if (node.length) {
const t = normalizeSpace(node.text());
if (t.length > text.length) text = t;
}
if (text.length > 1500) break;
}
if (text.length < MIN_TEXT_CHARS) {
text = normalizeSpace($('body').text());
}
if (text.length < MIN_TEXT_CHARS) {
return { url, ok: false, error: `too little text (${text.length} chars)` };
}
let domain = '';
try { domain = new URL(url).hostname.replace(/^www\./, ''); } catch {}
return {
url,
ok: true,
domain,
title,
publishedAt,
text: text.slice(0, MAX_TEXT_CHARS),
textLength: text.length,
};
}
// ── Сбор источников под одну новость ─────────────────────────────────────────
/**
* Ищет по запросу и вычитывает несколько источников целиком.
* По одному источнику на домен — чтобы не липнуть к формулировкам одного текста
* и иметь перекрёстное подтверждение фактов.
*
* @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 { docs } = await searchWeb(query, { count: want * 5, freshDays, sortByTime, searchType });
const seenDomains = new Set();
const sources = [];
let tried = 0;
for (const doc of docs) {
if (sources.length >= want) break;
const dom = (doc.domain || '').replace(/^www\./, '');
if (dom && seenDomains.has(dom)) continue;
tried++;
const page = await fetchArticle(doc.url);
if (!page.ok) {
console.warn(`[news-search] skip ${doc.url}: ${page.error}`);
continue;
}
if (dom) seenDomains.add(dom);
sources.push({
url: page.url,
domain: page.domain || dom,
title: page.title || doc.title,
publishedAt: page.publishedAt || doc.modtime || null,
snippet: doc.passage || '',
text: page.text,
textLength: page.textLength,
});
}
return { query, sources, tried };
}
module.exports = {
searchWeb,
fetchArticle,
gatherSources,
getQuotaStatus,
parseWebXml,
};