Files
mkd/backend/reviewParser.js
2026-02-04 00:17:04 +05:00

1089 lines
47 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Сервис парсинга отзывов с Яндекс Карт и 2ГИС
*/
// Опциональные зависимости для парсинга
let axios, puppeteer;
try {
axios = require('axios');
puppeteer = require('puppeteer');
} catch (err) {
console.warn('[reviewParser] Модули axios и/или puppeteer не установлены. Парсинг будет недоступен.');
console.warn('[reviewParser] Установите их: npm install axios puppeteer');
}
class ReviewParser {
constructor(pool) {
this.pool = pool;
this.browser = null; // Будет инициализирован при первом использовании
}
/**
* Получение или создание браузера Puppeteer
* @returns {Promise<Browser>}
*/
async getBrowser() {
if (!puppeteer) {
throw new Error('Puppeteer не установлен. Установите: npm install puppeteer');
}
if (!this.browser) {
this.browser = await puppeteer.launch({
headless: 'new', // Используем новый headless режим
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--disable-blink-features=AutomationControlled', // Отключаем автоматизацию
'--disable-features=IsolateOrigins,site-per-process',
'--window-size=1920,1080'
],
ignoreHTTPSErrors: true,
defaultViewport: {
width: 1920,
height: 1080
}
});
}
return this.browser;
}
/**
* Закрытие браузера
*/
async closeBrowser() {
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
/**
* Retry механизм для выполнения функции с повторными попытками
* @param {Function} fn - функция для выполнения
* @param {number} maxRetries - максимальное количество попыток
* @param {number} delay - задержка между попытками (мс)
* @returns {Promise<any>}
*/
async retry(fn, maxRetries = 3, delay = 1000) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (i < maxRetries - 1) {
console.log(`[reviewParser] Попытка ${i + 1} не удалась, повтор через ${delay}мс...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Экспоненциальная задержка
}
}
}
throw lastError;
}
/**
* Парсинг отзывов с указанного источника
* @param {string} source - 'yandex_maps' или '2gis'
* @param {boolean} testMode - тестовый режим (не сохраняет в БД)
* @returns {Promise<{parsed: number, errors: string[], found: number, valid: number, invalid: number}>}
*/
async parseReviews(source, testMode = false) {
const errors = [];
let parsed = 0;
let found = 0;
let valid = 0;
let invalid = 0;
console.log(`[reviewParser] Начало парсинга отзывов из источника: ${source}`);
// Проверяем наличие необходимых модулей
if (!axios) {
const errorMsg = 'Модуль axios не установлен. Установите: npm install axios';
errors.push(errorMsg);
console.error(`[reviewParser] ${errorMsg}`);
return { parsed: 0, errors, found: 0, valid: 0, invalid: 0 };
}
if (!puppeteer) {
const errorMsg = 'Модуль puppeteer не установлен. Установите: npm install puppeteer';
errors.push(errorMsg);
console.error(`[reviewParser] ${errorMsg}`);
return { parsed: 0, errors, found: 0, valid: 0, invalid: 0 };
}
try {
// Получаем настройки парсинга
const client = await this.pool.connect();
try {
const settingsResult = await client.query(
'SELECT * FROM parsing_settings WHERE source = $1 AND enabled = true',
[source]
);
if (settingsResult.rows.length === 0) {
throw new Error(`Настройки парсинга для ${source} не найдены или отключены`);
}
const settings = settingsResult.rows[0];
console.log(`[reviewParser] Настройки найдены для ${source}, URL: ${settings.url_template || 'не указан'}`);
if (!settings.url_template) {
throw new Error(`URL шаблон не настроен для ${source}`);
}
// Парсим в зависимости от источника
let reviews = [];
if (source === 'yandex_maps') {
reviews = await this.parseYandexMaps(settings);
} else if (source === '2gis') {
reviews = await this.parse2GIS(settings);
} else {
throw new Error(`Неподдерживаемый источник: ${source}`);
}
found = reviews.length;
console.log(`[reviewParser] Найдено отзывов на странице: ${found}`);
// Валидируем и фильтруем отзывы
const validReviews = [];
for (const review of reviews) {
const validation = await this.validateReview(review, client);
if (validation.valid) {
validReviews.push(review);
valid++;
} else {
invalid++;
errors.push(`Невалидный отзыв: ${validation.error}`);
console.warn(`[reviewParser] Пропущен невалидный отзыв: ${validation.error}`);
}
}
console.log(`[reviewParser] Валидных отзывов: ${valid}, невалидных: ${invalid}`);
// Сохраняем отзывы в БД (если не тестовый режим)
if (!testMode && validReviews.length > 0) {
for (const review of validReviews) {
try {
// Проверяем, не существует ли уже такой отзыв (по source_url или тексту)
const existing = await client.query(
`SELECT id FROM reviews
WHERE source = $1
AND (source_url = $2 OR (text = $3 AND date = $4))
LIMIT 1`,
[source, review.source_url || null, review.text, review.date]
);
if (existing.rows.length === 0) {
await client.query(
`INSERT INTO reviews (building_id, source, source_url, author_name, text, rating, date, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'new')`,
[
review.building_id,
source,
review.source_url,
review.author_name,
review.text,
review.rating,
review.date
]
);
parsed++;
console.log(`[reviewParser] Сохранен отзыв от ${review.author_name || 'Аноним'}`);
} else {
console.log(`[reviewParser] Отзыв уже существует, пропущен`);
}
} catch (err) {
errors.push(`Ошибка сохранения отзыва: ${err.message}`);
console.error('[reviewParser] Error saving review:', err);
}
}
// Обновляем время последнего парсинга
await client.query(
'UPDATE parsing_settings SET last_parsed_at = NOW() WHERE source = $1',
[source]
);
} else {
parsed = validReviews.length;
}
console.log(`[reviewParser] Парсинг завершен. Сохранено: ${parsed}, ошибок: ${errors.length}`);
return { parsed, errors, found, valid, invalid };
} finally {
client.release();
}
} catch (err) {
const errorMsg = `Ошибка парсинга: ${err.message}`;
errors.push(errorMsg);
console.error('[reviewParser] Error parsing reviews:', err);
console.error('[reviewParser] Stack trace:', err.stack);
return { parsed, errors, found, valid, invalid };
}
}
/**
* Парсинг отзывов с Яндекс Карт с использованием Puppeteer
* @param {object} settings - настройки парсинга
* @returns {Promise<Array>}
*/
async parseYandexMaps(settings) {
const reviews = [];
try {
console.log('[reviewParser] Начало парсинга Яндекс Карт с Puppeteer');
// Если есть API ключ, используем API (TODO: реализовать)
if (settings.api_key) {
console.log('[reviewParser] Парсинг через Яндекс Карты API (не реализовано, используем web scraping)');
}
if (!settings.url_template) {
console.warn('[reviewParser] URL шаблон не указан для Яндекс Карт');
return reviews;
}
// Используем Puppeteer для парсинга динамического контента
const browser = await this.getBrowser();
const page = await browser.newPage();
try {
// Устанавливаем реалистичные заголовки для обхода защиты
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36');
// Устанавливаем дополнительные заголовки
await page.setExtraHTTPHeaders({
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Cache-Control': 'max-age=0'
});
// Убираем признаки автоматизации
await page.evaluateOnNewDocument(() => {
// Переопределяем webdriver
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
});
// Добавляем chrome объект
window.chrome = {
runtime: {},
};
// Переопределяем permissions
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// Добавляем плагины
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5],
});
// Добавляем языки
Object.defineProperty(navigator, 'languages', {
get: () => ['ru-RU', 'ru', 'en-US', 'en'],
});
});
console.log(`[reviewParser] Загрузка страницы: ${settings.url_template}`);
// Загружаем страницу с ожиданием загрузки контента
await this.retry(async () => {
await page.goto(settings.url_template, {
waitUntil: 'networkidle2',
timeout: 60000
});
// Ждем немного для загрузки динамического контента
await new Promise(resolve => setTimeout(resolve, 2000));
// Проверяем, не появилась ли капча или страница с ошибкой
const pageContent = await page.content();
const captchaCheck = await page.evaluate(() => {
const bodyText = document.body.textContent.toLowerCase();
return bodyText.includes('robot') ||
bodyText.includes('капча') ||
bodyText.includes('not a robot') ||
bodyText.includes('автоматизированные запросы') ||
document.querySelector('[class*="captcha"]') !== null ||
document.querySelector('[id*="captcha"]') !== null ||
document.querySelector('[class*="smartcaptcha"]') !== null;
});
if (captchaCheck) {
console.error('[reviewParser] Обнаружена капча Яндекс. Страница заблокирована защитой.');
console.error('[reviewParser] Рекомендации:');
console.error('[reviewParser] 1. Используйте прокси или VPN');
console.error('[reviewParser] 2. Парсите вручную через браузер');
console.error('[reviewParser] 3. Используйте API Яндекс Карт (требуется ключ)');
throw new Error('Обнаружена капча Яндекс. Парсинг заблокирован защитой Яндекс Карт. Попробуйте использовать прокси или парсить вручную.');
}
// Проверяем, загрузилась ли страница с отзывами
const hasReviews = await page.evaluate(() => {
return document.querySelector('[class*="review"]') !== null ||
document.querySelector('[data-review-id]') !== null ||
document.body.textContent.includes('отзыв');
});
if (!hasReviews) {
console.warn('[reviewParser] На странице не найдены отзывы. Возможно, их нет или страница не загрузилась полностью.');
}
}, 3, 3000);
// Ждем загрузки отзывов - пробуем разные селекторы
const selectors = [
'[data-review-id]',
'.business-reviews-card-view__review',
'.review-item',
'[class*="review"]',
'[class*="Review"]'
];
let reviewsLoaded = false;
for (const selector of selectors) {
try {
await page.waitForSelector(selector, { timeout: 5000 });
reviewsLoaded = true;
console.log(`[reviewParser] Найден селектор отзывов: ${selector}`);
break;
} catch (err) {
// Пробуем следующий селектор
}
}
if (!reviewsLoaded) {
console.warn('[reviewParser] Не удалось найти элементы отзывов, пробуем парсить страницу...');
// Ждем немного для загрузки динамического контента
await new Promise(resolve => setTimeout(resolve, 3000));
}
// Извлекаем отзывы из страницы
const extractedReviews = await page.evaluate(() => {
const reviews = [];
// Пробуем разные селекторы для поиска отзывов
const reviewSelectors = [
'[data-review-id]',
'.business-reviews-card-view__review',
'.review-item',
'article[class*="review"]',
'div[class*="review"]'
];
let reviewElements = [];
for (const selector of reviewSelectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
reviewElements = Array.from(elements);
break;
}
}
// Если не нашли через селекторы, ищем по структуре
if (reviewElements.length === 0) {
// Ищем элементы с ссылками на профили пользователей
const userLinks = document.querySelectorAll('a[href*="/user/"], a[href*="/maps/user/"]');
if (userLinks.length > 0) {
userLinks.forEach(link => {
const reviewContainer = link.closest('article, div[class*="review"], div[class*="card"]');
if (reviewContainer && !reviewElements.includes(reviewContainer)) {
reviewElements.push(reviewContainer);
}
});
}
}
reviewElements.forEach((elem, index) => {
try {
// Ищем автора
let authorName = '';
const authorLink = elem.querySelector('a[href*="/user/"], a[href*="/maps/user/"]');
if (authorLink) {
authorName = authorLink.textContent.trim();
} else {
const authorElem = elem.querySelector('strong, b, [class*="author"], [class*="name"], [class*="user"]');
if (authorElem) {
authorName = authorElem.textContent.trim();
}
}
// Ищем текст отзыва
let text = '';
const textSelectors = [
'[class*="review-text"]',
'[class*="text"]',
'p',
'div[class*="body"]',
'div[class*="content"]'
];
for (const selector of textSelectors) {
const textElem = elem.querySelector(selector);
if (textElem) {
const textContent = textElem.textContent.trim();
if (textContent.length > 20) {
text = textContent;
break;
}
}
}
// Если не нашли через селекторы, берем весь текст и очищаем
if (!text || text.length < 10) {
text = elem.textContent.trim();
// Удаляем имя автора и дату из начала
if (authorName) {
text = text.replace(new RegExp(`^${authorName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*?\\d{4}`, 'i'), '').trim();
}
}
// Ищем рейтинг
let rating = 5; // По умолчанию
// Пробуем найти рейтинг в атрибутах
const ratingElem = elem.querySelector('[data-rating], [data-stars], [class*="rating"], [class*="star"]');
if (ratingElem) {
const ratingAttr = ratingElem.getAttribute('data-rating') || ratingElem.getAttribute('data-stars');
if (ratingAttr) {
rating = parseInt(ratingAttr);
} else {
// Считаем заполненные звезды
const filledStars = ratingElem.querySelectorAll('[class*="filled"], [class*="active"], [class*="full"]');
if (filledStars.length > 0) {
rating = filledStars.length;
} else {
// Считаем все звезды
const stars = ratingElem.querySelectorAll('[class*="star"], [class*="Star"]');
if (stars.length > 0) {
rating = stars.length;
}
}
}
}
// Пробуем найти рейтинг в тексте
if (rating === 5) {
const ratingMatch = elem.textContent.match(/(\d+)\s*(?:звезд|star|★|из\s*5)/i);
if (ratingMatch) {
rating = parseInt(ratingMatch[1]);
}
}
// Конвертируем рейтинг из 5-балльной в 10-балльную шкалу
if (rating <= 5) {
rating = rating * 2; // 1->2, 2->4, 3->6, 4->8, 5->10
}
// Ищем дату
let dateText = '';
const dateElem = elem.querySelector('[class*="date"], time, [datetime]');
if (dateElem) {
dateText = dateElem.getAttribute('datetime') || dateElem.textContent.trim();
} else {
// Пробуем найти дату в тексте
const dateMatch = elem.textContent.match(/(\d{1,2}[.\s]+(?:[а-я]+|\d{1,2})[.\s]+\d{4}|[а-я]+ \d{1,2}, \d{4}|\d{4}-\d{2}-\d{2})/i);
if (dateMatch) {
dateText = dateMatch[0];
}
}
// Ищем ссылку на оригинальный отзыв
let sourceUrl = '';
const reviewLink = elem.querySelector('a[href*="/review/"], a[href*="/reviews/"]');
if (reviewLink) {
const href = reviewLink.getAttribute('href');
if (href) {
sourceUrl = href.startsWith('http') ? href : `https://yandex.ru${href}`;
}
}
if (text && text.length >= 10) {
reviews.push({
author_name: authorName || 'Аноним',
text: text.substring(0, 5000),
rating: Math.max(1, Math.min(10, rating)),
date: dateText,
source_url: sourceUrl || settings.url_template
});
}
} catch (err) {
console.error('[reviewParser] Ошибка парсинга элемента отзыва:', err);
}
});
return reviews;
}, settings.url_template);
// Парсим даты для каждого отзыва
for (const review of extractedReviews) {
review.date = this.parseDate(review.date);
review.building_id = this.extractBuildingId(settings);
reviews.push(review);
}
console.log(`[reviewParser] Всего распарсено отзывов с Яндекс Карт: ${reviews.length}`);
} finally {
await page.close();
}
} catch (err) {
console.error('[reviewParser] Error parsing Yandex Maps:', err);
throw err;
}
return reviews;
}
/**
* Парсинг отзывов с 2ГИС с использованием Puppeteer
* @param {object} settings - настройки парсинга
* @returns {Promise<Array>}
*/
async parse2GIS(settings) {
const reviews = [];
try {
console.log('[reviewParser] Начало парсинга 2ГИС с Puppeteer');
if (settings.api_key) {
console.log('[reviewParser] Парсинг через 2ГИС API (не реализовано, используем web scraping)');
}
if (!settings.url_template) {
console.warn('[reviewParser] URL шаблон не указан для 2ГИС');
return reviews;
}
// Используем Puppeteer для парсинга динамического контента
const browser = await this.getBrowser();
const page = await browser.newPage();
try {
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36');
// Устанавливаем дополнительные заголовки для 2ГИС
await page.setExtraHTTPHeaders({
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
});
// Убираем признаки автоматизации для 2ГИС
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
});
window.chrome = { runtime: {} };
});
console.log(`[reviewParser] Загрузка страницы 2ГИС: ${settings.url_template}`);
await this.retry(async () => {
await page.goto(settings.url_template, {
waitUntil: 'networkidle2',
timeout: 60000
});
await new Promise(resolve => setTimeout(resolve, 3000));
}, 3, 3000);
// Ждём появления контента отзывов по тексту (2ГИС часто меняет классы)
try {
await page.waitForFunction(
() => {
const body = document.body && document.body.innerText ? document.body.innerText : '';
return body.includes('Полезно') || body.includes('Читать целиком') || body.includes('официальный ответ');
},
{ timeout: 15000 }
);
console.log('[reviewParser] Контент отзывов 2ГИС обнаружен на странице');
} catch (e) {
console.warn('[reviewParser] Таймаут ожидания контента отзывов 2ГИС, продолжаем...');
}
// Прокрутка для подгрузки ленивого контента
await page.evaluate(() => {
const scrollContainer = document.querySelector('[class*="scroll"], [class*="Scroll"], [class*="review"]') || document.documentElement;
if (scrollContainer.scrollHeight > scrollContainer.clientHeight) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
scrollContainer.scrollTop = 0;
}
});
await new Promise(resolve => setTimeout(resolve, 2000));
// Селекторы для проверки наличия блоков отзывов (опционально)
const selectors = [
'[data-review-id]',
'.review-item',
'.reviews__item',
'[class*="review"]',
'[class*="Review"]',
'[class*="feedback"]'
];
let reviewsLoaded = false;
for (const selector of selectors) {
try {
const count = await page.$$eval(selector, els => els.length);
if (count > 0) {
reviewsLoaded = true;
console.log(`[reviewParser] Найден селектор отзывов 2ГИС: ${selector} (элементов: ${count})`);
break;
}
} catch (err) {
// ignore
}
}
if (!reviewsLoaded) {
console.warn('[reviewParser] Классы отзывов не найдены, парсинг по структуре (ссылки «Читать целиком»)');
}
// Извлекаем отзывы из страницы 2ГИС
const baseUrl = settings.url_template;
const extractedReviews = await page.evaluate((baseUrlForEval) => {
const reviews = [];
const reviewSelectors = [
'[data-review-id]',
'.review-item',
'.reviews__item',
'article[class*="review"]',
'div[class*="review"]',
'div[class*="feedback"]'
];
let reviewElements = [];
for (const selector of reviewSelectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
reviewElements = Array.from(elements);
break;
}
}
// Fallback 1: любой элемент с текстом «Читать целиком» (не только <a>)
if (reviewElements.length === 0) {
const allNodes = document.querySelectorAll('a, button, span, div');
const expandNodes = Array.from(allNodes).filter(el => {
const t = (el.textContent || '').trim();
return t === 'Читать целиком' || t === 'Читать полностью' || t.replace(/\s+/g, ' ').includes('Читать целиком');
});
const seen = new Set();
expandNodes.forEach(node => {
let block = node.closest('article') || node.closest('[class*="card"]') || node.closest('[class*="item"]') || node.parentElement;
for (let i = 0; i < 20 && block && block !== document.body; i++) {
const text = (block.textContent || '').trim();
if (text.length > 120 && text.length < 50000 && !seen.has(block)) {
seen.add(block);
reviewElements.push(block);
break;
}
block = block.parentElement;
}
});
}
// Fallback 2: блоки по кнопке/счётчику «Полезно» (у каждого отзыва есть «Полезно» или «Полезно N»)
if (reviewElements.length === 0) {
const allEls = document.querySelectorAll('*');
const usefulNodes = Array.from(allEls).filter(el => {
const t = (el.textContent || '').trim();
return (t === 'Полезно' || /^Полезно\s*\d*\s*$/.test(t)) && t.length < 25;
});
const seen = new Set();
usefulNodes.forEach(node => {
let block = node.parentElement;
for (let i = 0; i < 25 && block && block !== document.body; i++) {
const text = (block.textContent || '').trim();
if (text.length > 200 && text.length < 50000 && !seen.has(block)) {
const lower = text.toLowerCase();
if (/[А-Яа-я]{3,}.*[А-Яа-я]{3,}/.test(text) && (lower.includes('полезно') || lower.includes('читать'))) {
seen.add(block);
reviewElements.push(block);
break;
}
}
block = block.parentElement;
}
});
}
reviewElements.forEach((elem) => {
try {
// Ищем автора
let authorName = '';
const authorSelectors = [
'.review-item__author',
'[class*="author"]',
'[class*="user"]',
'strong',
'b'
];
for (const selector of authorSelectors) {
const authorElem = elem.querySelector(selector);
if (authorElem) {
authorName = authorElem.textContent.trim();
if (authorName) break;
}
}
// Fallback: первая строка блока часто — имя автора (2ГИС: «Имя Фамилия», «Аббревиатура Имя»)
if (!authorName) {
const fullText = elem.textContent.trim();
const firstLine = fullText.split(/\n/)[0].trim();
if (firstLine && firstLine.length < 80 && !/^\d+ отзыв/.test(firstLine) && !/^Уфа,|^[А-Яа-я]+, \d/.test(firstLine)) {
authorName = firstLine;
}
}
// Ищем текст отзыва
let text = '';
const textSelectors = [
'.review-item__text',
'[class*="review-text"]',
'[class*="text"]',
'p',
'div[class*="body"]',
'div[class*="content"]'
];
for (const selector of textSelectors) {
const textElem = elem.querySelector(selector);
if (textElem) {
const textContent = textElem.textContent.trim();
if (textContent.length > 20) {
text = textContent;
break;
}
}
}
if (!text || text.length < 10) {
text = elem.textContent.trim();
if (authorName) {
text = text.replace(new RegExp(`^${authorName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*?\\d{4}`, 'i'), '').trim();
}
}
// Убираем из текста блок «официальный ответ» и служебные подписи 2ГИС
const officialMatch = text.match(/\s*(?:\d{1,2}\s+\w+\s+\d{4},?\s+)?официальный ответ/i);
if (officialMatch) {
text = text.substring(0, text.indexOf(officialMatch[0])).trim();
}
text = text.replace(/\s*Читать целиком\s*/gi, ' ').replace(/\s*Полезно\s*\d*\s*/gi, ' ').replace(/\s*Посещен\w*\s*Отзыв подтверждён\s*/gi, ' ').trim();
// Ищем рейтинг
let rating = 5;
const ratingElem = elem.querySelector('[data-rating], [class*="rating"], [class*="star"]');
if (ratingElem) {
const ratingAttr = ratingElem.getAttribute('data-rating');
if (ratingAttr) {
rating = parseInt(ratingAttr);
} else {
const filledStars = ratingElem.querySelectorAll('[class*="filled"], [class*="active"], [class*="full"]');
if (filledStars.length > 0) {
rating = filledStars.length;
}
}
}
// Конвертируем рейтинг из 5-балльной в 10-балльную шкалу
if (rating <= 5) {
rating = rating * 2;
}
// Ищем дату
let dateText = '';
const dateElem = elem.querySelector('[class*="date"], time, [datetime]');
if (dateElem) {
dateText = dateElem.getAttribute('datetime') || dateElem.textContent.trim();
} else {
const dateMatch = elem.textContent.match(/(\d{1,2}[.\s]+(?:[а-я]+|\d{1,2})[.\s]+\d{4}|[а-я]+ \d{1,2}, \d{4}|\d{4}-\d{2}-\d{2})/i);
if (dateMatch) {
dateText = dateMatch[0];
}
}
// Ищем ссылку на оригинальный отзыв
let sourceUrl = '';
const reviewLink = elem.querySelector('a[href*="/review/"], a[href*="/reviews/"]');
if (reviewLink) {
const href = reviewLink.getAttribute('href');
if (href) {
sourceUrl = href.startsWith('http') ? href : `https://2gis.ru${href}`;
}
}
if (text && text.length >= 10) {
reviews.push({
author_name: authorName || 'Аноним',
text: text.substring(0, 5000),
rating: Math.max(1, Math.min(10, rating)),
date: dateText,
source_url: sourceUrl || baseUrlForEval
});
}
} catch (err) {
// игнорируем ошибки по одному элементу
}
});
const bodyText = document.body ? (document.body.innerText || '').substring(0, 800) : '';
const iframes = document.querySelectorAll('iframe');
return {
reviews,
debug: {
reviewBlocksFound: reviewElements.length,
bodySnippet: bodyText.replace(/\s+/g, ' ').trim().substring(0, 400),
hasPolezno: bodyText.includes('Полезно'),
hasChitat: bodyText.includes('Читать'),
iframeCount: iframes.length
}
};
}, baseUrl);
const extractedList = Array.isArray(extractedReviews) ? extractedReviews : (extractedReviews && extractedReviews.reviews) || [];
const debugInfo = extractedReviews && extractedReviews.debug;
if (extractedList.length === 0 && debugInfo) {
console.log('[reviewParser] 2ГИС отладка: блоков:', debugInfo.reviewBlocksFound, ', «Полезно»:', debugInfo.hasPolezno, ', «Читать»:', debugInfo.hasChitat, ', iframe:', debugInfo.iframeCount || 0);
if (debugInfo.bodySnippet) {
console.log('[reviewParser] 2ГИС фрагмент:', debugInfo.bodySnippet);
}
}
// Парсим даты для каждого отзыва
for (const review of extractedList) {
review.date = this.parseDate(review.date);
review.building_id = this.extractBuildingId(settings);
reviews.push(review);
}
console.log(`[reviewParser] Всего распарсено отзывов с 2ГИС: ${reviews.length}`);
} finally {
await page.close();
}
} catch (err) {
console.error('[reviewParser] Error parsing 2GIS:', err);
throw err;
}
return reviews;
}
/**
* Парсинг даты из текста
* @param {string} dateText - текст с датой
* @returns {string} - дата в формате YYYY-MM-DD
*/
parseDate(dateText) {
if (!dateText) {
return new Date().toISOString().split('T')[0];
}
// Пытаемся распарсить различные форматы дат
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// "Сегодня" -> сегодняшняя дата
if (dateText.toLowerCase().includes('сегодня')) {
return today.toISOString().split('T')[0];
}
// "Вчера" -> вчерашняя дата
if (dateText.toLowerCase().includes('вчера')) {
return yesterday.toISOString().split('T')[0];
}
// Пытаемся распарсить формат "Month DD, YYYY" (например, "July 15, 2025")
const englishDateMatch = dateText.match(/([a-z]+)\s+(\d{1,2}),\s+(\d{4})/i);
if (englishDateMatch) {
const monthNames = {
'january': 1, 'february': 2, 'march': 3, 'april': 4,
'may': 5, 'june': 6, 'july': 7, 'august': 8,
'september': 9, 'october': 10, 'november': 11, 'december': 12,
'января': 1, 'февраля': 2, 'марта': 3, 'апреля': 4,
'мая': 5, 'июня': 6, 'июля': 7, 'августа': 8,
'сентября': 9, 'октября': 10, 'ноября': 11, 'декабря': 12
};
const monthName = englishDateMatch[1].toLowerCase();
const month = monthNames[monthName];
const day = parseInt(englishDateMatch[2]);
const year = parseInt(englishDateMatch[3]);
if (month && day && year) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
}
// Пытаемся распарсить дату в формате "DD.MM.YYYY" или "DD MMM YYYY"
const dateMatch = dateText.match(/(\d{1,2})[.\s]+(\d{1,2}|[а-я]+)[.\s]+(\d{4})/i);
if (dateMatch) {
const day = parseInt(dateMatch[1]);
const month = parseInt(dateMatch[2]) || this.monthNameToNumber(dateMatch[2]);
const year = parseInt(dateMatch[3]);
if (month && day && year) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
}
// Пытаемся использовать встроенный парсер дат
try {
const parsedDate = new Date(dateText);
if (!isNaN(parsedDate.getTime())) {
return parsedDate.toISOString().split('T')[0];
}
} catch (err) {
// Игнорируем ошибки парсинга
}
// По умолчанию возвращаем сегодняшнюю дату
return today.toISOString().split('T')[0];
}
/**
* Преобразование названия месяца в число
*/
monthNameToNumber(monthName) {
const months = {
'января': 1, 'февраля': 2, 'марта': 3, 'апреля': 4,
'мая': 5, 'июня': 6, 'июля': 7, 'августа': 8,
'сентября': 9, 'октября': 10, 'ноября': 11, 'декабря': 12
};
return months[monthName.toLowerCase()] || null;
}
/**
* Извлечение building_id из настроек или URL
* @param {object} settings - настройки парсинга
* @returns {string|null} - building_id или null
*/
extractBuildingId(settings) {
// Сначала проверяем, есть ли building_id в настройках (в поле settings JSONB)
if (settings.settings) {
let settingsObj = settings.settings;
// Если settings - это строка JSON, парсим её
if (typeof settingsObj === 'string') {
try {
settingsObj = JSON.parse(settingsObj);
} catch (err) {
console.warn('[reviewParser] Ошибка парсинга settings JSON:', err);
settingsObj = {};
}
}
// Если settings - это объект, проверяем building_id
if (typeof settingsObj === 'object' && settingsObj !== null) {
const buildingId = settingsObj.building_id;
if (buildingId) {
console.log(`[reviewParser] Building ID из настроек: ${buildingId}`);
return buildingId;
}
}
}
// Пытаемся извлечь из URL (если есть параметр building_id)
if (settings.url_template) {
try {
const url = new URL(settings.url_template);
const buildingIdParam = url.searchParams.get('building_id');
if (buildingIdParam) {
console.log(`[reviewParser] Building ID из URL параметра: ${buildingIdParam}`);
return buildingIdParam;
}
} catch (err) {
// URL может быть относительным или некорректным
console.warn('[reviewParser] Не удалось распарсить URL:', err);
}
}
// Если ничего не найдено, возвращаем null
console.warn('[reviewParser] Building ID не найден в настройках или URL. Отзывы будут сохранены без building_id.');
return null;
}
/**
* Валидация отзыва перед сохранением
* @param {object} review - объект отзыва
* @param {object} client - клиент БД для проверки building_id
* @returns {object} - {valid: boolean, error?: string}
*/
async validateReview(review, client) {
// Проверяем наличие обязательных полей
if (!review.text || typeof review.text !== 'string') {
return { valid: false, error: 'Текст отзыва отсутствует или неверного типа' };
}
// Проверяем минимальную длину текста
if (review.text.trim().length < 10) {
return { valid: false, error: 'Текст отзыва слишком короткий (минимум 10 символов)' };
}
// Проверяем максимальную длину текста
if (review.text.length > 10000) {
return { valid: false, error: 'Текст отзыва слишком длинный (максимум 10000 символов)' };
}
// Проверяем рейтинг (теперь 1-10 вместо 1-5)
if (review.rating === undefined || review.rating === null) {
return { valid: false, error: 'Рейтинг отсутствует' };
}
const rating = parseInt(review.rating);
if (isNaN(rating) || rating < 1 || rating > 10) {
return { valid: false, error: `Рейтинг должен быть числом от 1 до 10, получено: ${review.rating}` };
}
// Проверяем дату
if (!review.date) {
return { valid: false, error: 'Дата отзыва отсутствует' };
}
// Проверяем формат даты (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(review.date)) {
return { valid: false, error: `Неверный формат даты: ${review.date}, ожидается YYYY-MM-DD` };
}
// Проверяем, что building_id существует в БД (если указан)
if (review.building_id) {
try {
const buildingCheck = await client.query(
'SELECT id FROM buildings WHERE id = $1',
[review.building_id]
);
if (buildingCheck.rows.length === 0) {
return { valid: false, error: `Building ID ${review.building_id} не найден в БД` };
}
} catch (err) {
// Если ошибка при проверке, не блокируем сохранение, но логируем
console.warn(`[reviewParser] Не удалось проверить building_id ${review.building_id}:`, err);
}
}
return { valid: true };
}
}
// Экспортируем функцию для создания парсера с pool
function createReviewParser(pool) {
return new ReviewParser(pool);
}
// Экспортируем класс для использования в server.js
createReviewParser.ReviewParser = ReviewParser;
module.exports = createReviewParser;