1089 lines
47 KiB
JavaScript
Executable File
1089 lines
47 KiB
JavaScript
Executable File
/**
|
||
* Сервис парсинга отзывов с Яндекс Карт и 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;
|