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>
This commit is contained in:
192
src/scripts/enrich-product-details.ts
Normal file
192
src/scripts/enrich-product-details.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'dotenv/config';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { MagnitApiScraper } from '../scrapers/api/magnit/MagnitApiScraper.js';
|
||||
import { ProductService } from '../services/product/ProductService.js';
|
||||
import { ProductParser } from '../services/parser/ProductParser.js';
|
||||
import { Logger } from '../utils/logger.js';
|
||||
|
||||
interface EnrichmentStats {
|
||||
total: number;
|
||||
success: number;
|
||||
errors: number;
|
||||
withBrand: number;
|
||||
withDescription: number;
|
||||
withWeight: number;
|
||||
withUnit: number;
|
||||
withRating: number;
|
||||
withScoresCount: number;
|
||||
withCommentsCount: number;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
Logger.info('=== Обогащение товаров детальной информацией ===\n');
|
||||
|
||||
const storeCode = process.env.MAGNIT_STORE_CODE || '992301';
|
||||
|
||||
// Флаг: использовать ли второй endpoint для рейтингов
|
||||
const fetchObjectInfo = process.env.FETCH_OBJECT_INFO === 'true';
|
||||
if (fetchObjectInfo) {
|
||||
Logger.info('📊 Режим: с получением рейтингов (OBJECT_INFO endpoint)\n');
|
||||
} else {
|
||||
Logger.info('📦 Режим: только детали товаров (PRODUCT_DETAILS endpoint)\n');
|
||||
Logger.info(' Включи рейтинги: FETCH_OBJECT_INFO=true pnpm enrich\n');
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
const productService = new ProductService(prisma);
|
||||
const scraper = new MagnitApiScraper({
|
||||
storeCode,
|
||||
rateLimitDelay: 300,
|
||||
});
|
||||
|
||||
const stats: EnrichmentStats = {
|
||||
total: 0,
|
||||
success: 0,
|
||||
errors: 0,
|
||||
withBrand: 0,
|
||||
withDescription: 0,
|
||||
withWeight: 0,
|
||||
withUnit: 0,
|
||||
withRating: 0,
|
||||
withScoresCount: 0,
|
||||
withCommentsCount: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Инициализация скрапера
|
||||
await scraper.initialize();
|
||||
Logger.info('✅ Скрапер инициализирован\n');
|
||||
|
||||
// Получаем товары, для которых не были получены детали
|
||||
const batchSize = 100;
|
||||
let processedCount = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
Logger.info(`Получение порции товаров (до ${batchSize})...`);
|
||||
const products = await productService.getProductsNeedingDetails(storeCode, batchSize);
|
||||
|
||||
if (products.length === 0) {
|
||||
Logger.info('Все товары обработаны');
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
Logger.info(`Обработка ${products.length} товаров...\n`);
|
||||
|
||||
for (const product of products) {
|
||||
stats.total++;
|
||||
processedCount++;
|
||||
|
||||
try {
|
||||
Logger.info(`[${processedCount}] Обработка товара: ${product.externalId} - ${product.name}`);
|
||||
|
||||
// Запрос деталей из PRODUCT_DETAILS endpoint
|
||||
const detailsResponse = await scraper.fetchProductDetails(product.externalId);
|
||||
const parsedDetails = ProductParser.parseProductDetails(detailsResponse);
|
||||
|
||||
// Если включён флаг - запрашиваем рейтинги из OBJECT_INFO endpoint
|
||||
let objectInfoData: {
|
||||
description?: string;
|
||||
rating?: number;
|
||||
scoresCount?: number;
|
||||
commentsCount?: number;
|
||||
imageUrl?: string;
|
||||
} = {};
|
||||
|
||||
if (fetchObjectInfo) {
|
||||
try {
|
||||
const objectInfo = await scraper.fetchProductObjectInfo(product.externalId);
|
||||
objectInfoData = ProductParser.parseObjectInfo(objectInfo);
|
||||
} catch (err) {
|
||||
// Если OBJECT_INFO недоступен - не фатально, продолжаем
|
||||
Logger.debug(` ⚠️ OBJECT_INFO недоступен: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Мёрджим данные (OBJECT_INFO имеет приоритет для description)
|
||||
const mergedDetails = {
|
||||
...parsedDetails,
|
||||
...objectInfoData,
|
||||
description: objectInfoData.description || parsedDetails.description,
|
||||
};
|
||||
|
||||
// Подсчет статистики
|
||||
if (mergedDetails.brand) stats.withBrand++;
|
||||
if (mergedDetails.description) stats.withDescription++;
|
||||
if (mergedDetails.weight) stats.withWeight++;
|
||||
if (mergedDetails.unit) stats.withUnit++;
|
||||
if (mergedDetails.rating !== undefined) stats.withRating++;
|
||||
if (mergedDetails.scoresCount !== undefined) stats.withScoresCount++;
|
||||
if (mergedDetails.commentsCount !== undefined) stats.withCommentsCount++;
|
||||
|
||||
// Убираем categoryId из деталей для обновления (категория может не существовать в БД)
|
||||
const { categoryId, ...detailsForUpdate } = mergedDetails;
|
||||
|
||||
// Обновление товара в БД
|
||||
await productService.updateProductDetails(
|
||||
product.externalId,
|
||||
product.storeId,
|
||||
detailsForUpdate
|
||||
);
|
||||
|
||||
stats.success++;
|
||||
|
||||
Logger.info(
|
||||
` ✅ Обновлено: ` +
|
||||
`${mergedDetails.brand ? 'brand ' : ''}` +
|
||||
`${mergedDetails.description ? 'description ' : ''}` +
|
||||
`${mergedDetails.weight ? 'weight ' : ''}` +
|
||||
`${mergedDetails.unit ? 'unit ' : ''}` +
|
||||
`${mergedDetails.rating !== undefined ? 'rating ' : ''}` +
|
||||
`${mergedDetails.scoresCount !== undefined ? 'scores ' : ''}` +
|
||||
`${mergedDetails.commentsCount !== undefined ? 'comments ' : ''}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
stats.errors++;
|
||||
|
||||
// Отмечаем товар как обработанный, даже если произошла ошибка
|
||||
// чтобы не пытаться получить детали снова
|
||||
try {
|
||||
await productService.markAsDetailsFetched(product.externalId, product.storeId);
|
||||
Logger.warn(` ⚠️ Ошибка, товар пропущен: ${error.message}`);
|
||||
} catch (markError) {
|
||||
Logger.error(` ❌ Ошибка отметки товара: ${markError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting между запросами
|
||||
if (processedCount % 10 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`\n--- Прогресс: обработано ${processedCount} товаров ---\n`);
|
||||
}
|
||||
|
||||
// Вывод статистики
|
||||
Logger.info('\n=== СТАТИСТИКА ОБОГАЩЕНИЯ ===');
|
||||
Logger.info(`Всего обработано: ${stats.total}`);
|
||||
Logger.info(`Успешно: ${stats.success}`);
|
||||
Logger.info(`Ошибок: ${stats.errors}`);
|
||||
Logger.info(`\nПолучено полей:`);
|
||||
Logger.info(` Бренды: ${stats.withBrand} (${((stats.withBrand / stats.total) * 100).toFixed(1)}%)`);
|
||||
Logger.info(` Описания: ${stats.withDescription} (${((stats.withDescription / stats.total) * 100).toFixed(1)}%)`);
|
||||
Logger.info(` Вес: ${stats.withWeight} (${((stats.withWeight / stats.total) * 100).toFixed(1)}%)`);
|
||||
Logger.info(` Единицы: ${stats.withUnit} (${((stats.withUnit / stats.total) * 100).toFixed(1)}%)`);
|
||||
if (fetchObjectInfo) {
|
||||
Logger.info(` Рейтинги: ${stats.withRating} (${((stats.withRating / stats.total) * 100).toFixed(1)}%)`);
|
||||
Logger.info(` Кол-во оценок: ${stats.withScoresCount} (${((stats.withScoresCount / stats.total) * 100).toFixed(1)}%)`);
|
||||
Logger.info(` Кол-во отзывов: ${stats.withCommentsCount} (${((stats.withCommentsCount / stats.total) * 100).toFixed(1)}%)`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Logger.error('Критическая ошибка:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await scraper.close();
|
||||
Logger.info('\n✅ Работа завершена');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user