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