Files
mkd/backend/reviewParser.js

1089 lines
47 KiB
JavaScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
/**
* Сервис парсинга отзывов с Яндекс Карт и 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;