diff --git a/src/services/news-search.js b/src/services/news-search.js new file mode 100644 index 0000000..24eb48d --- /dev/null +++ b/src/services/news-search.js @@ -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 + * или смешанным контентом с подсветкой . Порядок слов приблизительный — + * этого достаточно, полноценный текст мы всё равно берём со страницы. + */ +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 '); + + 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, +};