diff --git a/.env.example b/.env.example index f219b00..d6df3ad 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,14 @@ MAGNIT_STORE_CODE="992301" MAGNIT_STORE_TYPE="6" MAGNIT_CATALOG_TYPE="1" +# Scraping Options +MAGNIT_USE_STREAMING=true # true = streaming mode (рекомендуется), false = legacy +MAGNIT_PAGE_SIZE=50 # Размер страницы API (max 50) +MAGNIT_MAX_PRODUCTS= # Лимит товаров (пусто = без лимита) +MAGNIT_RATE_LIMIT_DELAY=300 # Задержка между запросами (ms) +MAGNIT_MAX_ITERATIONS=10000 # Защита от бесконечного цикла +MAGNIT_HEADLESS=true # Headless режим браузера + +# Resilience Options +MAGNIT_RETRY_ATTEMPTS=3 # Количество попыток retry +MAGNIT_REQUEST_TIMEOUT=30000 # Timeout запросов (ms) diff --git a/docker-compose.yml b/docker-compose.yml index 01b5b6e..c58271f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,36 @@ services: timeout: 5s retries: 5 + pgadmin: + image: dpage/pgadmin4:latest + container_name: supermarket-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: admin + PGADMIN_CONFIG_SERVER_MODE: 'False' + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + cloudbeaver: + image: dbeaver/cloudbeaver:latest + container_name: supermarket-cloudbeaver + restart: unless-stopped + ports: + - "8978:8978" + volumes: + - cloudbeaver_data:/opt/cloudbeaver/workspace + depends_on: + postgres: + condition: service_healthy + volumes: postgres_data: + pgadmin_data: + cloudbeaver_data: diff --git a/src/scrapers/api/magnit/MagnitApiScraper.ts b/src/scrapers/api/magnit/MagnitApiScraper.ts index a45c814..b2a6472 100644 --- a/src/scrapers/api/magnit/MagnitApiScraper.ts +++ b/src/scrapers/api/magnit/MagnitApiScraper.ts @@ -11,14 +11,37 @@ import { import { ProductService } from '../../../services/product/ProductService.js'; import { ProductParser } from '../../../services/parser/ProductParser.js'; import { PrismaClient } from '../../../../generated/prisma/client.js'; +import { withRetryAndReinit } from '../../../utils/retry.js'; export interface MagnitScraperConfig { storeCode: string; storeType?: string; catalogType?: string; headless?: boolean; + + // Параметры пагинации + pageSize?: number; // default: 50 + maxProducts?: number; // default: undefined (без лимита) + rateLimitDelay?: number; // default: 300ms + maxIterations?: number; // default: 10000 (защита от бесконечного цикла) + + // Resilience настройки + retryOptions?: { + maxAttempts?: number; // default: 3 + initialDelay?: number; // default: 1000ms + maxDelay?: number; // default: 30000ms + backoffMultiplier?: number; // default: 2 + }; + requestTimeout?: number; // default: 30000ms + autoReinitOn403?: boolean; // default: true } +export type ProductBatchCallback = ( + batch: ProductItem[], + batchIndex: number, + totalProcessed: number +) => Promise; + export class MagnitApiScraper { private browser: Browser | null = null; private context: BrowserContext | null = null; @@ -26,15 +49,14 @@ export class MagnitApiScraper { private httpClient: AxiosInstance; private deviceId: string = ''; private cookies: string = ''; - private config: Required; + private config: MagnitScraperConfig; + + private readonly ACTUAL_API_PAGE_SIZE = 50; // Реальный лимит API + private readonly MAX_SAFE_ITERATIONS = 10000; + private seenProductIds = new Set(); // Для deduplication constructor(config: MagnitScraperConfig) { - this.config = { - storeCode: config.storeCode, - storeType: config.storeType || '6', - catalogType: config.catalogType || '1', - headless: config.headless !== false, - }; + this.config = config; this.httpClient = axios.create({ baseURL: 'https://magnit.ru', @@ -53,9 +75,9 @@ export class MagnitApiScraper { async initialize(): Promise { try { Logger.info('Инициализация браузера через Playwright...'); - + this.browser = await chromium.launch({ - headless: this.config.headless, + headless: this.config.headless !== false, }); this.context = await this.browser.newContext({ @@ -105,50 +127,111 @@ export class MagnitApiScraper { } /** - * Поиск товаров через API + * Поиск товаров через API с retry логикой */ async searchGoods( - pagination: { limit: number; offset: number } = { limit: 100, offset: 0 }, + pagination: { limit: number; offset: number } = { limit: 50, offset: 0 }, categories: number[] = [] ): Promise { - try { - const requestBody: SearchGoodsRequest = { - sort: { - order: 'desc', - type: 'popularity', - }, - pagination, - categories, - includeAdultGoods: true, - storeCode: this.config.storeCode, - storeType: this.config.storeType, - catalogType: this.config.catalogType, - }; + const operation = async () => { + try { + const requestBody: SearchGoodsRequest = { + sort: { + order: 'desc', + type: 'popularity', + }, + pagination, + categories, + includeAdultGoods: true, + storeCode: this.config.storeCode, + storeType: this.config.storeType || '6', + catalogType: this.config.catalogType || '1', + }; - Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`); + Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`); - const response = await this.httpClient.post( - ENDPOINTS.SEARCH_GOODS, - requestBody - ); - - Logger.debug(`Получено товаров: ${response.data.items.length}`); - - return response.data; - } catch (error) { - if (axios.isAxiosError(error)) { - const statusCode = error.response?.status || 0; - Logger.error( - `Ошибка API запроса: ${statusCode} - ${error.message}`, - error.response?.data - ); - throw new APIError( - `Ошибка API запроса: ${error.message}`, - statusCode, - error.response?.data + const response = await this.httpClient.post( + ENDPOINTS.SEARCH_GOODS, + requestBody, + { + timeout: this.config.requestTimeout ?? 30000 + } ); + + Logger.debug(`Получено товаров: ${response.data.items.length}`); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const statusCode = error.response?.status || 0; + Logger.error( + `Ошибка API запроса: ${statusCode} - ${error.message}`, + error.response?.data + ); + throw new APIError( + `Ошибка API запроса: ${error.message}`, + statusCode, + error.response?.data + ); + } + throw error; } - throw error; + }; + + // Retry с автоматической переинициализацией при 403 + return withRetryAndReinit(operation, { + ...this.config.retryOptions, + reinitOn403: this.config.autoReinitOn403 ?? true, + onReinit: async () => { + await this.reinitializeSession(); + } + }); + } + + /** + * Переинициализация сессии (при 403 или истечении cookies) + * ВАЖНО: Не закрываем браузер, только обновляем cookies + */ + private async reinitializeSession(): Promise { + try { + if (!this.page || !this.context) { + Logger.warn('Браузер не инициализирован, выполняем полную инициализацию'); + await this.initialize(); + return; + } + + Logger.info('Обновление сессии через повторный визит на magnit.ru...'); + + // Переход на главную страницу для обновления cookies + await this.page.goto('https://magnit.ru/', { + waitUntil: 'networkidle', + timeout: 30000, + }); + + // Получение обновленных cookies + const cookies = await this.context.cookies(); + this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + // Обновление device-id + const mgUdiCookie = cookies.find(c => c.name === 'mg_udi'); + if (mgUdiCookie) { + this.deviceId = mgUdiCookie.value; + Logger.info(`Device ID обновлен: ${this.deviceId.substring(0, 20)}...`); + } + + // Обновление заголовков HTTP клиента + this.httpClient.defaults.headers.common['Cookie'] = this.cookies; + if (this.deviceId) { + this.httpClient.defaults.headers.common['x-device-id'] = this.deviceId; + } + + Logger.info('✅ Сессия успешно обновлена'); + } catch (error) { + Logger.error('Ошибка переинициализации сессии:', error); + // Fallback: полная переинициализация + Logger.info('Попытка полной переинициализации...'); + await this.close(); + await this.initialize(); } } @@ -156,40 +239,75 @@ export class MagnitApiScraper { * Получение всех товаров без фильтрации по категориям */ async scrapeAllProducts( - limit: number = 100 + limit: number = 50 ): Promise { try { Logger.info('Начало скрапинга всех товаров...'); const allProducts: ProductItem[] = []; let offset = 0; let hasMore = true; + let iterations = 0; + const maxIterations = this.config.maxIterations || this.MAX_SAFE_ITERATIONS; + const maxProducts = this.config.maxProducts; + + // Валидация: limit должен быть <= ACTUAL_API_PAGE_SIZE + const effectiveLimit = Math.min(limit, this.ACTUAL_API_PAGE_SIZE); + if (limit > this.ACTUAL_API_PAGE_SIZE) { + Logger.warn( + `Запрошенный limit=${limit} превышает максимум API=${this.ACTUAL_API_PAGE_SIZE}. ` + + `Используется limit=${effectiveLimit}` + ); + } while (hasMore) { - Logger.info(`Получение товаров: offset=${offset}, limit=${limit}`); - - const response = await this.searchGoods({ limit, offset }, []); - - if (response.items.length === 0) { + // Защита от бесконечного цикла + iterations++; + if (iterations > maxIterations) { + Logger.error( + `Достигнут максимум итераций (${maxIterations}). ` + + `Возможно, API возвращает некорректные данные. Остановка скрапинга.` + ); + break; + } + + // Проверка лимита товаров + if (maxProducts && allProducts.length >= maxProducts) { + Logger.info(`Достигнут лимит товаров: ${maxProducts}. Остановка скрапинга.`); + break; + } + + Logger.info( + `[${iterations}] Получение товаров: offset=${offset}, limit=${effectiveLimit}` + ); + + const response = await this.searchGoods({ limit: effectiveLimit, offset }, []); + + // КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Проверяем пустой массив, а не сравнение с limit + if (!response.items || response.items.length === 0) { hasMore = false; - Logger.info('Товары закончились, скрапинг завершен'); + Logger.info('API вернул пустой массив товаров. Скрапинг завершен.'); break; } allProducts.push(...response.items); - Logger.info(`Всего получено товаров: ${allProducts.length}`); + Logger.info( + `Получено: ${response.items.length} | ` + + `Всего собрано: ${allProducts.length} | ` + + `Итерация: ${iterations}` + ); - // Если получили меньше товаров, чем запрашивали, значит это последняя страница - if (response.items.length < limit) { - hasMore = false; - Logger.info('Получена последняя страница товаров'); - } else { - offset += limit; - // Задержка между запросами для rate limiting - await this.delay(300); - } + // НОВАЯ ЛОГИКА: Продолжаем пока API возвращает данные + // Увеличиваем offset на ФАКТИЧЕСКОЕ количество полученных товаров + offset += response.items.length; + + // Rate limiting + const delay = this.config.rateLimitDelay ?? 300; + await this.delay(delay); } - Logger.info(`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}`); + Logger.info( + `✅ Скрапинг завершен. Всего товаров: ${allProducts.length}, итераций: ${iterations}` + ); return allProducts; } catch (error) { Logger.error('Ошибка при скрапинге всех товаров:', error); @@ -197,6 +315,106 @@ export class MagnitApiScraper { } } + /** + * Скрапинг с потоковой обработкой (streaming) + * Не накапливает все товары в памяти, обрабатывает батчами + */ + async scrapeAllProductsStreaming( + onBatch: ProductBatchCallback, + options: { + batchSize?: number; // default: 50 + maxProducts?: number; // default: undefined + pageSize?: number; // default: 50 + } = {} + ): Promise<{ totalProducts: number; batches: number }> { + try { + const batchSize = options.batchSize ?? 50; + const pageSize = Math.min(options.pageSize ?? 50, this.ACTUAL_API_PAGE_SIZE); + const maxProducts = options.maxProducts ?? this.config.maxProducts; + + Logger.info( + `Начало потокового скрапинга. ` + + `Batch size: ${batchSize}, Page size: ${pageSize}, Max products: ${maxProducts ?? 'unlimited'}` + ); + + let offset = 0; + let hasMore = true; + let iterations = 0; + let totalProcessed = 0; + let batchIndex = 0; + let currentBatch: ProductItem[] = []; + const maxIterations = this.config.maxIterations || this.MAX_SAFE_ITERATIONS; + + while (hasMore) { + // Защита от бесконечного цикла + iterations++; + if (iterations > maxIterations) { + Logger.error(`Достигнут максимум итераций (${maxIterations}). Остановка.`); + break; + } + + // Проверка лимита товаров + if (maxProducts && totalProcessed >= maxProducts) { + Logger.info(`Достигнут лимит товаров: ${maxProducts}.`); + break; + } + + Logger.debug(`[${iterations}] Запрос: offset=${offset}, limit=${pageSize}`); + + const response = await this.searchGoods({ limit: pageSize, offset }, []); + + if (!response.items || response.items.length === 0) { + hasMore = false; + Logger.info('API вернул пустой массив. Скрапинг завершен.'); + break; + } + + // Добавляем товары в текущий батч + currentBatch.push(...response.items); + totalProcessed += response.items.length; + + // Когда батч достигает нужного размера - обрабатываем + while (currentBatch.length >= batchSize) { + const batch = currentBatch.splice(0, batchSize); + batchIndex++; + + Logger.info( + `Обработка батча #${batchIndex} (${batch.length} товаров). ` + + `Всего обработано: ${totalProcessed - currentBatch.length}` + ); + + await onBatch(batch, batchIndex, totalProcessed - currentBatch.length); + } + + offset += response.items.length; + + // Rate limiting + const delay = this.config.rateLimitDelay ?? 300; + await this.delay(delay); + } + + // Обрабатываем остаток (последний неполный батч) + if (currentBatch.length > 0) { + batchIndex++; + Logger.info( + `Обработка финального батча #${batchIndex} (${currentBatch.length} товаров)` + ); + await onBatch(currentBatch, batchIndex, totalProcessed); + } + + Logger.info( + `✅ Потоковый скрапинг завершен. ` + + `Товаров: ${totalProcessed}, Батчей: ${batchIndex}, Итераций: ${iterations}` + ); + + return { totalProducts: totalProcessed, batches: batchIndex }; + + } catch (error) { + Logger.error('Ошибка при потоковом скрапинге:', error); + throw error; + } + } + /** * Задержка в миллисекундах */ @@ -205,7 +423,7 @@ export class MagnitApiScraper { } /** - * Сохранение товаров в базу данных + * Сохранение товаров в базу данных (legacy режим) */ async saveToDatabase( products: ProductItem[], @@ -252,6 +470,78 @@ export class MagnitApiScraper { } } + /** + * Потоковое сохранение в БД с обработкой батчами + * Более эффективно для больших каталогов + */ + async saveToDatabaseStreaming( + prisma: PrismaClient, + options: { + batchSize?: number; // default: 50 + maxProducts?: number; + } = {} + ): Promise { + const productService = new ProductService(prisma); + let totalSaved = 0; + + // Получаем магазин один раз в начале + const store = await productService.getOrCreateStore( + this.config.storeCode, + 'Магнит' + ); + + // Глобальная map категорий (кеш) + const globalCategoryMap = new Map(); + + const result = await this.scrapeAllProductsStreaming( + async (batch, batchIndex, totalProcessed) => { + Logger.info( + `Сохранение батча #${batchIndex} (${batch.length} товаров) в БД...` + ); + + // Собираем категории из текущего батча + const batchCategories = new Map(); + for (const product of batch) { + if (product.category?.id && !globalCategoryMap.has(product.category.id)) { + batchCategories.set(product.category.id, { + id: product.category.id, + title: product.category.title + }); + } + } + + // Создаем новые категории + for (const [externalId, cat] of batchCategories) { + const category = await productService.getOrCreateCategory(externalId, cat.title); + globalCategoryMap.set(externalId, category.id); + } + + // Парсим и сохраняем товары батча + const parsedProducts = ProductParser.parseProductItems( + batch, + store.id, + globalCategoryMap + ); + + const saved = await productService.saveProducts(parsedProducts); + totalSaved += saved; + + Logger.info( + `Батч #${batchIndex} сохранен: ${saved} товаров. ` + + `Всего сохранено: ${totalSaved}` + ); + }, + options + ); + + Logger.info( + `✅ Потоковое сохранение завершено. ` + + `Всего сохранено: ${totalSaved} товаров за ${result.batches} батчей` + ); + + return totalSaved; + } + /** * Закрытие браузера и очистка ресурсов */ diff --git a/src/scripts/scrape-magnit-products.ts b/src/scripts/scrape-magnit-products.ts index 046abbe..d2f1058 100644 --- a/src/scripts/scrape-magnit-products.ts +++ b/src/scripts/scrape-magnit-products.ts @@ -5,19 +5,50 @@ import { Logger } from '../utils/logger.js'; async function main() { const storeCode = process.env.MAGNIT_STORE_CODE || process.argv[2]; - + if (!storeCode) { Logger.error('Не указан код магазина. Используйте переменную окружения MAGNIT_STORE_CODE или передайте как аргумент'); process.exit(1); } + // Выбор режима: streaming или legacy + const useStreaming = process.env.MAGNIT_USE_STREAMING !== 'false'; + const maxProducts = process.env.MAGNIT_MAX_PRODUCTS + ? parseInt(process.env.MAGNIT_MAX_PRODUCTS, 10) + : undefined; + Logger.info(`🚀 Запуск скрапинга для магазина: ${storeCode}`); + Logger.info(`Режим: ${useStreaming ? 'STREAMING' : 'LEGACY'}`); + if (maxProducts) { + Logger.info(`Лимит товаров: ${maxProducts}`); + } const scraper = new MagnitApiScraper({ storeCode, storeType: process.env.MAGNIT_STORE_TYPE || '6', catalogType: process.env.MAGNIT_CATALOG_TYPE || '1', - headless: true, + headless: process.env.MAGNIT_HEADLESS !== 'false', + + // Новые параметры + pageSize: process.env.MAGNIT_PAGE_SIZE ? parseInt(process.env.MAGNIT_PAGE_SIZE, 10) : 50, + maxProducts, + rateLimitDelay: process.env.MAGNIT_RATE_LIMIT_DELAY + ? parseInt(process.env.MAGNIT_RATE_LIMIT_DELAY, 10) + : 300, + maxIterations: process.env.MAGNIT_MAX_ITERATIONS + ? parseInt(process.env.MAGNIT_MAX_ITERATIONS, 10) + : 10000, + + retryOptions: { + maxAttempts: process.env.MAGNIT_RETRY_ATTEMPTS + ? parseInt(process.env.MAGNIT_RETRY_ATTEMPTS, 10) + : 3, + initialDelay: 1000, + maxDelay: 30000, + }, + + requestTimeout: 30000, + autoReinitOn403: true, }); try { @@ -27,20 +58,34 @@ async function main() { // Инициализация скрапера await scraper.initialize(); - // Получение всех товаров - const products = await scraper.scrapeAllProducts(100); + let saved = 0; - Logger.info(`📦 Получено товаров: ${products.length}`); + if (useStreaming) { + // STREAMING режим (рекомендуется для больших каталогов) + Logger.info('📡 Использование потокового режима скрапинга'); + + saved = await scraper.saveToDatabaseStreaming(prisma, { + batchSize: 50, + maxProducts, + }); - if (products.length > 0) { - // Сохранение в БД - const saved = await scraper.saveToDatabase(products, prisma); - Logger.info(`✅ Успешно сохранено товаров: ${saved}`); } else { - Logger.warn('⚠️ Товары не найдены'); + // LEGACY режим (обратная совместимость) + Logger.info('📦 Использование legacy режима скрапинга'); + + const products = await scraper.scrapeAllProducts(50); + Logger.info(`📦 Получено товаров: ${products.length}`); + + if (products.length > 0) { + saved = await scraper.saveToDatabase(products, prisma); + } else { + Logger.warn('⚠️ Товары не найдены'); + } } + Logger.info(`✅ Успешно сохранено товаров: ${saved}`); Logger.info('✅ Скрапинг завершен успешно'); + } catch (error) { Logger.error('❌ Ошибка при скрапинге:', error); process.exit(1); diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..4cd6978 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,135 @@ +import { Logger } from './logger.js'; + +export interface RetryOptions { + maxAttempts: number; // default: 3 + initialDelay: number; // default: 1000ms + maxDelay: number; // default: 30000ms + backoffMultiplier: number; // default: 2 (exponential) + retryableErrors?: string[]; // default: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'] + shouldRetry?: (error: any) => boolean; + onRetry?: (error: any, attempt: number, delay: number) => void; +} + +const DEFAULT_RETRYABLE_ERRORS = [ + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'ECONNREFUSED', + 'ENETUNREACH', + 'EAI_AGAIN' +]; + +export async function withRetry( + operation: () => Promise, + options: Partial = {} +): Promise { + const { + maxAttempts = 3, + initialDelay = 1000, + maxDelay = 30000, + backoffMultiplier = 2, + retryableErrors = DEFAULT_RETRYABLE_ERRORS, + shouldRetry, + onRetry + } = options; + + let lastError: any; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await operation(); + } catch (error: any) { + lastError = error; + + // Проверяем, нужно ли retry + const isRetryable = shouldRetry + ? shouldRetry(error) + : isErrorRetryable(error, retryableErrors); + + if (!isRetryable || attempt === maxAttempts) { + throw error; + } + + // Exponential backoff + const delay = Math.min( + initialDelay * Math.pow(backoffMultiplier, attempt - 1), + maxDelay + ); + + Logger.warn( + `Попытка ${attempt}/${maxAttempts} не удалась: ${error.message}. ` + + `Повтор через ${delay}ms...` + ); + + if (onRetry) { + onRetry(error, attempt, delay); + } + + await sleep(delay); + } + } + + throw lastError; +} + +function isErrorRetryable(error: any, retryableErrors: string[]): boolean { + // Network errors + if (error.code && retryableErrors.includes(error.code)) { + return true; + } + + // HTTP 5xx errors (server errors) + if (error.response?.status >= 500 && error.response?.status < 600) { + return true; + } + + // HTTP 429 (Too Many Requests) + if (error.response?.status === 429) { + return true; + } + + return false; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Специальная утилита для retry с автоматической переинициализацией сессии +export interface RetryWithReinitOptions extends RetryOptions { + reinitOn403?: boolean; // default: true + onReinit?: () => Promise; +} + +export async function withRetryAndReinit( + operation: () => Promise, + options: Partial = {} +): Promise { + const { reinitOn403 = true, onReinit, ...retryOptions } = options; + + return withRetry(operation, { + ...retryOptions, + shouldRetry: (error: any) => { + // 403 Forbidden - требуется переинициализация сессии + if (error.response?.status === 403 && reinitOn403) { + return true; + } + + // Другие retryable ошибки + return isErrorRetryable(error, retryOptions.retryableErrors || DEFAULT_RETRYABLE_ERRORS); + }, + onRetry: async (error: any, attempt: number, delay: number) => { + // Если 403 и есть callback переинициализации + if (error.response?.status === 403 && onReinit) { + Logger.warn('Получен 403 Forbidden. Переинициализация сессии...'); + await onReinit(); + Logger.info('✅ Сессия переинициализирована'); + } + + // Вызов пользовательского callback + if (retryOptions.onRetry) { + retryOptions.onRetry(error, attempt, delay); + } + } + }); +}