- 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>
193 lines
5.7 KiB
TypeScript
193 lines
5.7 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
|