feat: enhanced Magnit scraper with streaming mode and retry logic
- Add streaming mode for memory-efficient large catalog scraping - Implement retry logic with exponential backoff - Add auto session reinitialization on 403 errors - Add configurable options (pageSize, maxProducts, rateLimitDelay) - Add maxIterations protection against infinite loops - Add retry.ts utility module with withRetry and withRetryAndReinit - Update .env.example with new scraping options - Add pgAdmin and CloudBeaver to docker-compose Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
11
.env.example
11
.env.example
@@ -6,3 +6,14 @@ MAGNIT_STORE_CODE="992301"
|
|||||||
MAGNIT_STORE_TYPE="6"
|
MAGNIT_STORE_TYPE="6"
|
||||||
MAGNIT_CATALOG_TYPE="1"
|
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)
|
||||||
|
|||||||
@@ -19,6 +19,36 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
pgadmin_data:
|
||||||
|
cloudbeaver_data:
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,37 @@ import {
|
|||||||
import { ProductService } from '../../../services/product/ProductService.js';
|
import { ProductService } from '../../../services/product/ProductService.js';
|
||||||
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
||||||
import { PrismaClient } from '../../../../generated/prisma/client.js';
|
import { PrismaClient } from '../../../../generated/prisma/client.js';
|
||||||
|
import { withRetryAndReinit } from '../../../utils/retry.js';
|
||||||
|
|
||||||
export interface MagnitScraperConfig {
|
export interface MagnitScraperConfig {
|
||||||
storeCode: string;
|
storeCode: string;
|
||||||
storeType?: string;
|
storeType?: string;
|
||||||
catalogType?: string;
|
catalogType?: string;
|
||||||
headless?: boolean;
|
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 {
|
export class MagnitApiScraper {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private context: BrowserContext | null = null;
|
private context: BrowserContext | null = null;
|
||||||
@@ -26,15 +49,14 @@ export class MagnitApiScraper {
|
|||||||
private httpClient: AxiosInstance;
|
private httpClient: AxiosInstance;
|
||||||
private deviceId: string = '';
|
private deviceId: string = '';
|
||||||
private cookies: string = '';
|
private cookies: string = '';
|
||||||
private config: Required<MagnitScraperConfig>;
|
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) {
|
constructor(config: MagnitScraperConfig) {
|
||||||
this.config = {
|
this.config = config;
|
||||||
storeCode: config.storeCode,
|
|
||||||
storeType: config.storeType || '6',
|
|
||||||
catalogType: config.catalogType || '1',
|
|
||||||
headless: config.headless !== false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.httpClient = axios.create({
|
this.httpClient = axios.create({
|
||||||
baseURL: 'https://magnit.ru',
|
baseURL: 'https://magnit.ru',
|
||||||
@@ -53,9 +75,9 @@ export class MagnitApiScraper {
|
|||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
Logger.info('Инициализация браузера через Playwright...');
|
Logger.info('Инициализация браузера через Playwright...');
|
||||||
|
|
||||||
this.browser = await chromium.launch({
|
this.browser = await chromium.launch({
|
||||||
headless: this.config.headless,
|
headless: this.config.headless !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.context = await this.browser.newContext({
|
this.context = await this.browser.newContext({
|
||||||
@@ -105,50 +127,111 @@ export class MagnitApiScraper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поиск товаров через API
|
* Поиск товаров через API с retry логикой
|
||||||
*/
|
*/
|
||||||
async searchGoods(
|
async searchGoods(
|
||||||
pagination: { limit: number; offset: number } = { limit: 100, offset: 0 },
|
pagination: { limit: number; offset: number } = { limit: 50, offset: 0 },
|
||||||
categories: number[] = []
|
categories: number[] = []
|
||||||
): Promise<SearchGoodsResponse> {
|
): Promise<SearchGoodsResponse> {
|
||||||
try {
|
const operation = async () => {
|
||||||
const requestBody: SearchGoodsRequest = {
|
try {
|
||||||
sort: {
|
const requestBody: SearchGoodsRequest = {
|
||||||
order: 'desc',
|
sort: {
|
||||||
type: 'popularity',
|
order: 'desc',
|
||||||
},
|
type: 'popularity',
|
||||||
pagination,
|
},
|
||||||
categories,
|
pagination,
|
||||||
includeAdultGoods: true,
|
categories,
|
||||||
storeCode: this.config.storeCode,
|
includeAdultGoods: true,
|
||||||
storeType: this.config.storeType,
|
storeCode: this.config.storeCode,
|
||||||
catalogType: this.config.catalogType,
|
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<SearchGoodsResponse>(
|
const response = await this.httpClient.post<SearchGoodsResponse>(
|
||||||
ENDPOINTS.SEARCH_GOODS,
|
ENDPOINTS.SEARCH_GOODS,
|
||||||
requestBody
|
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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<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: '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(
|
async scrapeAllProducts(
|
||||||
limit: number = 100
|
limit: number = 50
|
||||||
): Promise<ProductItem[]> {
|
): Promise<ProductItem[]> {
|
||||||
try {
|
try {
|
||||||
Logger.info('Начало скрапинга всех товаров...');
|
Logger.info('Начало скрапинга всех товаров...');
|
||||||
const allProducts: ProductItem[] = [];
|
const allProducts: ProductItem[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let hasMore = true;
|
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) {
|
while (hasMore) {
|
||||||
Logger.info(`Получение товаров: offset=${offset}, limit=${limit}`);
|
// Защита от бесконечного цикла
|
||||||
|
iterations++;
|
||||||
const response = await this.searchGoods({ limit, offset }, []);
|
if (iterations > maxIterations) {
|
||||||
|
Logger.error(
|
||||||
if (response.items.length === 0) {
|
`Достигнут максимум итераций (${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;
|
hasMore = false;
|
||||||
Logger.info('Товары закончились, скрапинг завершен');
|
Logger.info('API вернул пустой массив товаров. Скрапинг завершен.');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
allProducts.push(...response.items);
|
allProducts.push(...response.items);
|
||||||
Logger.info(`Всего получено товаров: ${allProducts.length}`);
|
Logger.info(
|
||||||
|
`Получено: ${response.items.length} | ` +
|
||||||
|
`Всего собрано: ${allProducts.length} | ` +
|
||||||
|
`Итерация: ${iterations}`
|
||||||
|
);
|
||||||
|
|
||||||
// Если получили меньше товаров, чем запрашивали, значит это последняя страница
|
// НОВАЯ ЛОГИКА: Продолжаем пока API возвращает данные
|
||||||
if (response.items.length < limit) {
|
// Увеличиваем offset на ФАКТИЧЕСКОЕ количество полученных товаров
|
||||||
hasMore = false;
|
offset += response.items.length;
|
||||||
Logger.info('Получена последняя страница товаров');
|
|
||||||
} else {
|
// Rate limiting
|
||||||
offset += limit;
|
const delay = this.config.rateLimitDelay ?? 300;
|
||||||
// Задержка между запросами для rate limiting
|
await this.delay(delay);
|
||||||
await this.delay(300);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}`);
|
Logger.info(
|
||||||
|
`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}, итераций: ${iterations}`
|
||||||
|
);
|
||||||
return allProducts;
|
return allProducts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Ошибка при скрапинге всех товаров:', 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(
|
async saveToDatabase(
|
||||||
products: ProductItem[],
|
products: ProductItem[],
|
||||||
@@ -252,6 +470,78 @@ export class MagnitApiScraper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Потоковое сохранение в БД с обработкой батчами
|
||||||
|
* Более эффективно для больших каталогов
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Закрытие браузера и очистка ресурсов
|
* Закрытие браузера и очистка ресурсов
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,19 +5,50 @@ import { Logger } from '../utils/logger.js';
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const storeCode = process.env.MAGNIT_STORE_CODE || process.argv[2];
|
const storeCode = process.env.MAGNIT_STORE_CODE || process.argv[2];
|
||||||
|
|
||||||
if (!storeCode) {
|
if (!storeCode) {
|
||||||
Logger.error('Не указан код магазина. Используйте переменную окружения MAGNIT_STORE_CODE или передайте как аргумент');
|
Logger.error('Не указан код магазина. Используйте переменную окружения MAGNIT_STORE_CODE или передайте как аргумент');
|
||||||
process.exit(1);
|
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(`🚀 Запуск скрапинга для магазина: ${storeCode}`);
|
||||||
|
Logger.info(`Режим: ${useStreaming ? 'STREAMING' : 'LEGACY'}`);
|
||||||
|
if (maxProducts) {
|
||||||
|
Logger.info(`Лимит товаров: ${maxProducts}`);
|
||||||
|
}
|
||||||
|
|
||||||
const scraper = new MagnitApiScraper({
|
const scraper = new MagnitApiScraper({
|
||||||
storeCode,
|
storeCode,
|
||||||
storeType: process.env.MAGNIT_STORE_TYPE || '6',
|
storeType: process.env.MAGNIT_STORE_TYPE || '6',
|
||||||
catalogType: process.env.MAGNIT_CATALOG_TYPE || '1',
|
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 {
|
try {
|
||||||
@@ -27,20 +58,34 @@ async function main() {
|
|||||||
// Инициализация скрапера
|
// Инициализация скрапера
|
||||||
await scraper.initialize();
|
await scraper.initialize();
|
||||||
|
|
||||||
// Получение всех товаров
|
let saved = 0;
|
||||||
const products = await scraper.scrapeAllProducts(100);
|
|
||||||
|
|
||||||
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 {
|
} 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('✅ Скрапинг завершен успешно');
|
Logger.info('✅ Скрапинг завершен успешно');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('❌ Ошибка при скрапинге:', error);
|
Logger.error('❌ Ошибка при скрапинге:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
135
src/utils/retry.ts
Normal file
135
src/utils/retry.ts
Normal file
@@ -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<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: Partial<RetryOptions> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специальная утилита для retry с автоматической переинициализацией сессии
|
||||||
|
export interface RetryWithReinitOptions extends RetryOptions {
|
||||||
|
reinitOn403?: boolean; // default: true
|
||||||
|
onReinit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withRetryAndReinit<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: Partial<RetryWithReinitOptions> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user