Initial commit: Supermarket scraper MVP
This commit is contained in:
278
src/scrapers/api/magnit/MagnitApiScraper.ts
Normal file
278
src/scrapers/api/magnit/MagnitApiScraper.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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,
|
||||
} from './types.js';
|
||||
import { ProductService } from '../../../services/product/ProductService.js';
|
||||
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
||||
import { PrismaClient } from '../../../../generated/prisma/client.js';
|
||||
|
||||
export interface MagnitScraperConfig {
|
||||
storeCode: string;
|
||||
storeType?: string;
|
||||
catalogType?: string;
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
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: Required<MagnitScraperConfig>;
|
||||
|
||||
constructor(config: MagnitScraperConfig) {
|
||||
this.config = {
|
||||
storeCode: config.storeCode,
|
||||
storeType: config.storeType || '6',
|
||||
catalogType: config.catalogType || '1',
|
||||
headless: config.headless !== false,
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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: 'networkidle',
|
||||
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
|
||||
*/
|
||||
async searchGoods(
|
||||
pagination: { limit: number; offset: number } = { limit: 100, offset: 0 },
|
||||
categories: number[] = []
|
||||
): Promise<SearchGoodsResponse> {
|
||||
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,
|
||||
};
|
||||
|
||||
Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`);
|
||||
|
||||
const response = await this.httpClient.post<SearchGoodsResponse>(
|
||||
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
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех товаров без фильтрации по категориям
|
||||
*/
|
||||
async scrapeAllProducts(
|
||||
limit: number = 100
|
||||
): Promise<ProductItem[]> {
|
||||
try {
|
||||
Logger.info('Начало скрапинга всех товаров...');
|
||||
const allProducts: ProductItem[] = [];
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
Logger.info(`Получение товаров: offset=${offset}, limit=${limit}`);
|
||||
|
||||
const response = await this.searchGoods({ limit, offset }, []);
|
||||
|
||||
if (response.items.length === 0) {
|
||||
hasMore = false;
|
||||
Logger.info('Товары закончились, скрапинг завершен');
|
||||
break;
|
||||
}
|
||||
|
||||
allProducts.push(...response.items);
|
||||
Logger.info(`Всего получено товаров: ${allProducts.length}`);
|
||||
|
||||
// Если получили меньше товаров, чем запрашивали, значит это последняя страница
|
||||
if (response.items.length < limit) {
|
||||
hasMore = false;
|
||||
Logger.info('Получена последняя страница товаров');
|
||||
} else {
|
||||
offset += limit;
|
||||
// Задержка между запросами для rate limiting
|
||||
await this.delay(300);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}`);
|
||||
return allProducts;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка при скрапинге всех товаров:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Задержка в миллисекундах
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение товаров в базу данных
|
||||
*/
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user