import { chromium, Browser, Page, BrowserContext } from 'playwright'; import axios, { AxiosInstance } from 'axios'; import { Logger } from '../../../utils/logger.js'; import { APIError } from '../../../utils/errors.js'; import { ENDPOINTS } from './endpoints.js'; import { SearchGoodsRequest, SearchGoodsResponse, ProductItem, ObjectInfo, ObjectReviewsResponse, ProductDetailsResponse, } from './types.js'; 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; private page: Page | null = null; private httpClient: AxiosInstance; private deviceId: string = ''; private cookies: string = ''; 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 = config; this.httpClient = axios.create({ baseURL: 'https://magnit.ru', headers: { 'Content-Type': 'application/json', 'Accept': '*/*', 'Accept-Language': 'ru,en-US;q=0.9,en;q=0.8', 'Referer': 'https://magnit.ru/', }, }); } /** * Инициализация сессии через Playwright для получения cookies и device-id */ async initialize(): Promise { try { Logger.info('Инициализация браузера через Playwright...'); this.browser = await chromium.launch({ headless: this.config.headless !== false, }); this.context = await this.browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36', viewport: { width: 1920, height: 1080 }, }); this.page = await this.context.newPage(); // Переход на главную страницу для получения cookies Logger.info('Переход на главную страницу magnit.ru...'); await this.page.goto('https://magnit.ru/', { waitUntil: 'domcontentloaded', timeout: 30000, }); // Получение cookies const cookies = await this.context.cookies(); this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; '); // Извлечение device-id из cookie mg_udi const mgUdiCookie = cookies.find(c => c.name === 'mg_udi'); if (mgUdiCookie) { this.deviceId = mgUdiCookie.value; Logger.info(`Device ID получен: ${this.deviceId.substring(0, 20)}...`); } else { Logger.warn('Cookie mg_udi не найден, device-id будет пустым'); } // Настройка заголовков для HTTP клиента this.httpClient.defaults.headers.common['Cookie'] = this.cookies; if (this.deviceId) { this.httpClient.defaults.headers.common['x-device-id'] = this.deviceId; } this.httpClient.defaults.headers.common['x-client-name'] = 'magnit'; this.httpClient.defaults.headers.common['x-device-platform'] = 'Web'; this.httpClient.defaults.headers.common['x-new-magnit'] = 'true'; Logger.info('✅ Сессия успешно инициализирована'); } catch (error) { Logger.error('Ошибка инициализации сессии:', error); throw new APIError( `Не удалось инициализировать сессию: ${error instanceof Error ? error.message : String(error)}`, 0 ); } } /** * Поиск товаров через API с retry логикой */ async searchGoods( pagination: { limit: number; offset: number } = { limit: 50, offset: 0 }, categories: number[] = [] ): Promise { 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}`); 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; } }; // Retry с автоматической переинициализацией при 403 return withRetryAndReinit(operation, { ...this.config.retryOptions, reinitOn403: this.config.autoReinitOn403 ?? true, onReinit: async () => { await this.reinitializeSession(); } }); } /** * Получение object info (описание, рейтинг, отзывы) для товара * Endpoint: /webgate/v1/listing/user-reviews-and-object-info */ async fetchProductObjectInfo(productId: string): Promise { const operation = async () => { try { Logger.debug(`Запрос object info для продукта ${productId}`); const response = await this.httpClient.get( ENDPOINTS.OBJECT_INFO(productId), { timeout: this.config.requestTimeout ?? 30000 } ); return response.data.object_info; } catch (error) { if (axios.isAxiosError(error)) { const statusCode = error.response?.status || 0; Logger.error( `Ошибка получения object info: ${statusCode} - ${error.message}` ); throw new APIError( `Ошибка получения object info: ${error.message}`, statusCode, error.response?.data ); } throw error; } }; return withRetryAndReinit(operation, { ...this.config.retryOptions, reinitOn403: this.config.autoReinitOn403 ?? true, onReinit: async () => { await this.reinitializeSession(); } }); } /** * Получение детальной информации о товаре (бренд, описание, вес, единица измерения) * Endpoint: /webgate/v2/goods/{productId}/stores/{storeCode} */ async fetchProductDetails(productId: string): Promise { const operation = async () => { try { Logger.debug(`Запрос деталей для продукта ${productId}`); const response = await this.httpClient.get( ENDPOINTS.PRODUCT_DETAILS(productId, this.config.storeCode), { timeout: this.config.requestTimeout ?? 30000 } ); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const statusCode = error.response?.status || 0; Logger.error( `Ошибка получения деталей товара: ${statusCode} - ${error.message}` ); throw new APIError( `Ошибка получения деталей товара: ${error.message}`, statusCode, error.response?.data ); } throw error; } }; 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: 'domcontentloaded', 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(); } } /** * Получение всех товаров без фильтрации по категориям */ async scrapeAllProducts( 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) { // Защита от бесконечного цикла 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('API вернул пустой массив товаров. Скрапинг завершен.'); break; } allProducts.push(...response.items); Logger.info( `Получено: ${response.items.length} | ` + `Всего собрано: ${allProducts.length} | ` + `Итерация: ${iterations}` ); // НОВАЯ ЛОГИКА: Продолжаем пока API возвращает данные // Увеличиваем offset на ФАКТИЧЕСКОЕ количество полученных товаров offset += response.items.length; // Rate limiting const delay = this.config.rateLimitDelay ?? 300; await this.delay(delay); } Logger.info( `✅ Скрапинг завершен. Всего товаров: ${allProducts.length}, итераций: ${iterations}` ); return allProducts; } catch (error) { Logger.error('Ошибка при скрапинге всех товаров:', error); throw error; } } /** * Скрапинг с потоковой обработкой (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; } } /** * Задержка в миллисекундах */ private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Сохранение товаров в базу данных (legacy режим) */ async saveToDatabase( products: ProductItem[], prisma: PrismaClient ): Promise { try { Logger.info(`Начало сохранения ${products.length} товаров в БД...`); const productService = new ProductService(prisma); // Получаем или создаем магазин const store = await productService.getOrCreateStore( this.config.storeCode, 'Магнит' ); // Собираем все категории из товаров const categoryMap = new Map(); for (const product of products) { if (product.category?.id) { const category = await productService.getOrCreateCategory( product.category.id, product.category.title ); categoryMap.set(product.category.id, category.id); } } // Парсим товары const parsedProducts = ProductParser.parseProductItems( products, store.id, categoryMap ); // Сохраняем товары const saved = await productService.saveProducts(parsedProducts); Logger.info(`✅ Сохранено товаров в БД: ${saved}`); return saved; } catch (error) { Logger.error('Ошибка сохранения товаров в БД:', error); throw error; } } /** * Потоковое сохранение в БД с обработкой батчами * Более эффективно для больших каталогов */ 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; } /** * Закрытие браузера и очистка ресурсов */ async close(): Promise { try { if (this.page) { await this.page.close(); this.page = null; } if (this.context) { await this.context.close(); this.context = null; } if (this.browser) { await this.browser.close(); this.browser = null; } Logger.info('✅ Браузер закрыт'); } catch (error) { Logger.error('Ошибка при закрытии браузера:', error); } } }