Files
supermarket/src/services/parser/ProductParser.ts
Mc Smog 3299cca574 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>
2026-01-22 01:52:50 +05:00

193 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ProductItem, ProductDetailsResponse, ObjectInfo } from '../../scrapers/api/magnit/types.js';
import { CreateProductData } from '../product/ProductService.js';
export class ProductParser {
/**
* Конвертация цены из копеек в рубли
*/
private static priceFromKopecks(kopecks: number): number {
return kopecks / 100;
}
/**
* Парсинг даты из строки
*/
private static parseDate(dateString?: string): Date | undefined {
if (!dateString) return undefined;
try {
return new Date(dateString);
} catch {
return undefined;
}
}
/**
* Преобразование массива бейджей в JSON строку
*/
private static badgesToJson(badges: Array<{ text: string; backgroundColor: string }>): string | undefined {
if (!badges || badges.length === 0) return undefined;
return JSON.stringify(badges);
}
/**
* Преобразование ProductItem из API в CreateProductData для БД
*/
static parseProductItem(
item: ProductItem,
storeId: number,
categoryId?: number
): CreateProductData {
return {
externalId: item.productId,
storeId,
categoryId,
name: item.name,
description: item.description,
url: item.url,
imageUrl: item.gallery && item.gallery.length > 0 ? item.gallery[0].url : undefined,
currentPrice: this.priceFromKopecks(item.price),
unit: item.unit,
weight: item.weight,
brand: item.brand,
oldPrice: item.promotion?.oldPrice
? this.priceFromKopecks(item.promotion.oldPrice)
: undefined,
discountPercent: item.promotion?.discountPercent
? item.promotion.discountPercent
: undefined,
promotionEndDate: this.parseDate(item.promotion?.endDate),
rating: item.ratings?.rating,
scoresCount: item.ratings?.scoresCount,
commentsCount: item.ratings?.commentsCount,
quantity: item.quantity,
badges: this.badgesToJson(item.badges),
};
}
/**
* Парсинг массива товаров
*/
static parseProductItems(
items: ProductItem[],
storeId: number,
categoryMap: Map<number, number> // Map<externalCategoryId, categoryId>
): CreateProductData[] {
return items.map(item => {
const categoryId = item.category?.id
? categoryMap.get(item.category.id)
: undefined;
return this.parseProductItem(item, storeId, categoryId);
});
}
/**
* Парсинг деталей товара из ProductDetailsResponse
* Извлекает бренд, описание, вес, единицу измерения из массива details
*/
static parseProductDetails(detailsResponse: ProductDetailsResponse): {
brand?: string;
description?: string;
weight?: string;
unit?: string;
categoryId?: number;
} {
const result: {
brand?: string;
description?: string;
weight?: string;
unit?: string;
categoryId?: number;
} = {};
// Получаем первую категорию из массива
if (detailsResponse.categories && detailsResponse.categories.length > 0) {
result.categoryId = detailsResponse.categories[0];
}
// Парсим массив деталей для извлечения полей
for (const detail of detailsResponse.details) {
const name = detail.name;
const nameLower = detail.name.toLowerCase();
// Бренд - проверяем и русское и английское название
if (name === 'Бренд' || nameLower === 'brand') {
// Игнорируем "Различные бренды"
if (detail.value && !detail.value.includes('Различные бренды')) {
result.brand = detail.value;
}
}
// Описание товара
else if (name === 'Описание товара' || nameLower.includes('описание')) {
if (detail.value) {
result.description = detail.value;
}
}
// Вес - может быть на русском
else if (nameLower.includes('вес') || nameLower === 'weight') {
if (detail.value) {
result.weight = detail.value;
}
}
// Единица измерения - может быть на русском
else if (name === 'Единица измерения' || nameLower.includes('единица') || nameLower === 'unit') {
if (detail.value) {
result.unit = detail.value;
}
}
}
return result;
}
/**
* Парсинг ObjectInfo для получения рейтингов и описания
*/
static parseObjectInfo(objectInfo: ObjectInfo): {
description?: string;
rating?: number;
scoresCount?: number;
commentsCount?: number;
imageUrl?: string;
} {
const result: {
description?: string;
rating?: number;
scoresCount?: number;
commentsCount?: number;
imageUrl?: string;
} = {};
// Описание из object_info (если есть)
if (objectInfo.description) {
result.description = objectInfo.description;
}
// Рейтинг
if (objectInfo.average_rating !== undefined) {
result.rating = objectInfo.average_rating;
}
// Количество оценок
if (objectInfo.count_ratings !== undefined) {
result.scoresCount = objectInfo.count_ratings;
}
// Количество отзывов
if (objectInfo.count_reviews !== undefined) {
result.commentsCount = objectInfo.count_reviews;
}
// URL изображения (если есть)
if (objectInfo.url) {
result.imageUrl = objectInfo.url;
}
return result;
}
}