- 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>
652 lines
23 KiB
TypeScript
652 lines
23 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|
||
|