Files
supermarket/src/scrapers/api/magnit/MagnitApiScraper.ts
Mc Smog 3299cca574 feat: add product detail enrichment for Magnit products
- Add isDetailsFetched field to Product model
- Add fetchProductDetails() and fetchProductObjectInfo() methods to MagnitApiScraper
- Add ProductParser methods for detail parsing
- Add ProductService methods: getProductsNeedingDetails(), updateProductDetails(), markAsDetailsFetched()
- Add enrich-product-details.ts script with statistics tracking
- Update package.json with "enrich" script command
- Add E2E_GUIDE.md documentation
- Exclude debug scripts from tsconfig type-check (temporary)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 01:52:50 +05:00

652 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
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<string>(); // Для 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<void> {
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<SearchGoodsResponse> {
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<SearchGoodsResponse>(
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<ObjectInfo> {
const operation = async () => {
try {
Logger.debug(`Запрос object info для продукта ${productId}`);
const response = await this.httpClient.get<ObjectReviewsResponse>(
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<ProductDetailsResponse> {
const operation = async () => {
try {
Logger.debug(`Запрос деталей для продукта ${productId}`);
const response = await this.httpClient.get<ProductDetailsResponse>(
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<void> {
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<ProductItem[]> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Сохранение товаров в базу данных (legacy режим)
*/
async saveToDatabase(
products: ProductItem[],
prisma: PrismaClient
): Promise<number> {
try {
Logger.info(`Начало сохранения ${products.length} товаров в БД...`);
const productService = new ProductService(prisma);
// Получаем или создаем магазин
const store = await productService.getOrCreateStore(
this.config.storeCode,
'Магнит'
);
// Собираем все категории из товаров
const categoryMap = new Map<number, number>();
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<number> {
const productService = new ProductService(prisma);
let totalSaved = 0;
// Получаем магазин один раз в начале
const store = await productService.getOrCreateStore(
this.config.storeCode,
'Магнит'
);
// Глобальная map категорий (кеш)
const globalCategoryMap = new Map<number, number>();
const result = await this.scrapeAllProductsStreaming(
async (batch, batchIndex, totalProcessed) => {
Logger.info(
`Сохранение батча #${batchIndex} (${batch.length} товаров) в БД...`
);
// Собираем категории из текущего батча
const batchCategories = new Map<number, { id: number; title: string }>();
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<void> {
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);
}
}
}