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:
2026-01-22 01:52:50 +05:00
parent 5a763a4e13
commit 3299cca574
11 changed files with 795 additions and 88 deletions

View 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(`\олучено полей:`);
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();