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:
@@ -8,77 +8,79 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Store {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type String // "web" | "app"
|
||||
code String? // storeCode для API (например "992301")
|
||||
url String?
|
||||
region String?
|
||||
address String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
sessions ScrapingSession[]
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type String // "web" | "app"
|
||||
code String? // storeCode для API (например "992301")
|
||||
url String?
|
||||
region String?
|
||||
address String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
sessions ScrapingSession[]
|
||||
|
||||
@@index([code])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId Int? // ID из внешнего API
|
||||
id Int @id @default(autoincrement())
|
||||
externalId Int? // ID из внешнего API
|
||||
name String
|
||||
parentId Int?
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
|
||||
@@index([externalId])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String // ID из API (например "1000300796")
|
||||
storeId Int
|
||||
categoryId Int?
|
||||
name String
|
||||
description String?
|
||||
url String?
|
||||
imageUrl String?
|
||||
currentPrice Decimal @db.Decimal(10, 2)
|
||||
unit String? // единица измерения
|
||||
weight String? // вес/объем
|
||||
brand String?
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String // ID из API (например "1000300796")
|
||||
storeId Int
|
||||
categoryId Int?
|
||||
name String
|
||||
description String?
|
||||
url String?
|
||||
imageUrl String?
|
||||
currentPrice Decimal @db.Decimal(10, 2)
|
||||
unit String? // единица измерения
|
||||
weight String? // вес/объем
|
||||
brand String?
|
||||
|
||||
// Промо-информация
|
||||
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
|
||||
discountPercent Decimal? @db.Decimal(5, 2) // процент скидки
|
||||
promotionEndDate DateTime? // дата окончания акции
|
||||
|
||||
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
|
||||
discountPercent Decimal? @db.Decimal(5, 2) // процент скидки
|
||||
promotionEndDate DateTime? // дата окончания акции
|
||||
|
||||
// Рейтинги
|
||||
rating Decimal? @db.Decimal(3, 2) // рейтинг товара
|
||||
scoresCount Int? // количество оценок
|
||||
commentsCount Int? // количество комментариев
|
||||
|
||||
rating Decimal? @db.Decimal(3, 2) // рейтинг товара
|
||||
scoresCount Int? // количество оценок
|
||||
commentsCount Int? // количество комментариев
|
||||
|
||||
// Остаток и бейджи
|
||||
quantity Int? // остаток на складе
|
||||
badges String? // массив бейджей в формате JSON
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
|
||||
quantity Int? // остаток на складе
|
||||
badges String? // массив бейджей в формате JSON
|
||||
|
||||
// Детальная информация
|
||||
isDetailsFetched Boolean @default(false) // были ли получены детали через detail endpoint
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@unique([externalId, storeId])
|
||||
@@index([storeId])
|
||||
@@index([categoryId])
|
||||
@@ -86,18 +88,17 @@ model Product {
|
||||
}
|
||||
|
||||
model ScrapingSession {
|
||||
id Int @id @default(autoincrement())
|
||||
storeId Int
|
||||
sourceType String // "api" | "web" | "app"
|
||||
status String // "pending" | "running" | "completed" | "failed"
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
error String?
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id])
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
storeId Int
|
||||
sourceType String // "api" | "web" | "app"
|
||||
status String // "pending" | "running" | "completed" | "failed"
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
error String?
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id])
|
||||
|
||||
@@index([storeId])
|
||||
@@index([status])
|
||||
@@index([startedAt])
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
SearchGoodsRequest,
|
||||
SearchGoodsResponse,
|
||||
ProductItem,
|
||||
ObjectInfo,
|
||||
ObjectReviewsResponse,
|
||||
ProductDetailsResponse,
|
||||
} from './types.js';
|
||||
import { ProductService } from '../../../services/product/ProductService.js';
|
||||
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
||||
@@ -90,7 +93,7 @@ export class MagnitApiScraper {
|
||||
// Переход на главную страницу для получения cookies
|
||||
Logger.info('Переход на главную страницу magnit.ru...');
|
||||
await this.page.goto('https://magnit.ru/', {
|
||||
waitUntil: 'networkidle',
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
@@ -188,6 +191,86 @@ export class MagnitApiScraper {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение 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
|
||||
@@ -204,7 +287,7 @@ export class MagnitApiScraper {
|
||||
|
||||
// Переход на главную страницу для обновления cookies
|
||||
await this.page.goto('https://magnit.ru/', {
|
||||
waitUntil: 'networkidle',
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,5 +5,17 @@ export const ENDPOINTS = {
|
||||
SEARCH_GOODS: `${MAGNIT_API_BASE}/goods/search`,
|
||||
PRODUCT_STORES: (productId: string, storeCode: string) =>
|
||||
`${MAGNIT_API_BASE}/goods/${productId}/stores/${storeCode}`,
|
||||
|
||||
// Product detail endpoints
|
||||
OBJECT_INFO: (productId: string) =>
|
||||
`${MAGNIT_BASE_URL}/webgate/v1/listing/user-reviews-and-object-info?service=dostavka&objectType=product&objectId=${productId}`,
|
||||
|
||||
PRODUCT_DETAILS: (
|
||||
productId: string,
|
||||
storeCode: string,
|
||||
storeType = '2',
|
||||
catalogType = '1'
|
||||
) =>
|
||||
`${MAGNIT_API_BASE}/goods/${productId}/stores/${storeCode}?storetype=${storeType}&catalogtype=${catalogType}`,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -78,3 +78,35 @@ export interface SearchGoodsResponse {
|
||||
fastCategoriesExtended?: FastCategory[];
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// Types for Product Detail Endpoints
|
||||
// =====================================================
|
||||
|
||||
// Object info from /webgate/v1/listing/user-reviews-and-object-info
|
||||
export interface ObjectInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
average_rating: number;
|
||||
count_ratings: number;
|
||||
count_reviews: number;
|
||||
}
|
||||
|
||||
export interface ObjectReviewsResponse {
|
||||
my_reviews: any[];
|
||||
object_info: ObjectInfo;
|
||||
}
|
||||
|
||||
// Product details from /webgate/v2/goods/{productId}/stores/{storeCode}
|
||||
export interface ProductDetailParameter {
|
||||
name: string;
|
||||
value: string;
|
||||
parameters?: ProductDetailParameter[];
|
||||
}
|
||||
|
||||
export interface ProductDetailsResponse {
|
||||
categories: number[];
|
||||
details: ProductDetailParameter[];
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProductItem } from '../../scrapers/api/magnit/types.js';
|
||||
import { ProductItem, ProductDetailsResponse, ObjectInfo } from '../../scrapers/api/magnit/types.js';
|
||||
import { CreateProductData } from '../product/ProductService.js';
|
||||
|
||||
export class ProductParser {
|
||||
@@ -80,5 +80,113 @@ export class ProductParser {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface CreateProductData {
|
||||
commentsCount?: number;
|
||||
quantity?: number;
|
||||
badges?: string;
|
||||
isDetailsFetched?: boolean;
|
||||
}
|
||||
|
||||
export class ProductService {
|
||||
@@ -45,27 +46,36 @@ export class ProductService {
|
||||
if (existing) {
|
||||
// Обновляем существующий товар
|
||||
Logger.debug(`Обновление товара: ${data.externalId}`);
|
||||
|
||||
// Если isDetailsFetched не передан явно, сохраняем текущее значение
|
||||
const updateData: any = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
url: data.url,
|
||||
imageUrl: data.imageUrl,
|
||||
currentPrice: data.currentPrice,
|
||||
unit: data.unit,
|
||||
weight: data.weight,
|
||||
brand: data.brand,
|
||||
oldPrice: data.oldPrice,
|
||||
discountPercent: data.discountPercent,
|
||||
promotionEndDate: data.promotionEndDate,
|
||||
rating: data.rating,
|
||||
scoresCount: data.scoresCount,
|
||||
commentsCount: data.commentsCount,
|
||||
quantity: data.quantity,
|
||||
badges: data.badges,
|
||||
categoryId: data.categoryId,
|
||||
};
|
||||
|
||||
// Обновляем isDetailsFetched только если передано явно
|
||||
if (data.isDetailsFetched !== undefined) {
|
||||
updateData.isDetailsFetched = data.isDetailsFetched;
|
||||
}
|
||||
|
||||
return await this.prisma.product.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
url: data.url,
|
||||
imageUrl: data.imageUrl,
|
||||
currentPrice: data.currentPrice,
|
||||
unit: data.unit,
|
||||
weight: data.weight,
|
||||
brand: data.brand,
|
||||
oldPrice: data.oldPrice,
|
||||
discountPercent: data.discountPercent,
|
||||
promotionEndDate: data.promotionEndDate,
|
||||
rating: data.rating,
|
||||
scoresCount: data.scoresCount,
|
||||
commentsCount: data.commentsCount,
|
||||
quantity: data.quantity,
|
||||
badges: data.badges,
|
||||
categoryId: data.categoryId,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
// Создаем новый товар
|
||||
@@ -204,5 +214,103 @@ export class ProductService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение товаров, для которых не были получены детали
|
||||
*/
|
||||
async getProductsNeedingDetails(storeCode: string, limit?: number): Promise<Product[]> {
|
||||
try {
|
||||
// Сначала находим store по code
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { code: storeCode },
|
||||
});
|
||||
|
||||
if (!store) {
|
||||
throw new DatabaseError(`Магазин с кодом ${storeCode} не найден`);
|
||||
}
|
||||
|
||||
return await this.prisma.product.findMany({
|
||||
where: {
|
||||
storeId: store.id,
|
||||
isDetailsFetched: false,
|
||||
},
|
||||
take: limit,
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка получения товаров для обогащения:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось получить товары: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление деталей товара (бренд, описание, вес, единица измерения, рейтинг)
|
||||
*/
|
||||
async updateProductDetails(
|
||||
externalId: string,
|
||||
storeId: number,
|
||||
details: {
|
||||
brand?: string;
|
||||
description?: string;
|
||||
weight?: string;
|
||||
unit?: string;
|
||||
categoryId?: number;
|
||||
rating?: number;
|
||||
scoresCount?: number;
|
||||
commentsCount?: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
): Promise<Product> {
|
||||
try {
|
||||
return await this.prisma.product.update({
|
||||
where: {
|
||||
externalId_storeId: {
|
||||
externalId,
|
||||
storeId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...details,
|
||||
isDetailsFetched: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка обновления деталей товара:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось обновить детали товара: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметить товар как обработанный (даже если детали не были получены)
|
||||
*/
|
||||
async markAsDetailsFetched(externalId: string, storeId: number): Promise<Product> {
|
||||
try {
|
||||
return await this.prisma.product.update({
|
||||
where: {
|
||||
externalId_storeId: {
|
||||
externalId,
|
||||
storeId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
isDetailsFetched: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка отметки товара как обработанного:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось отметить товар: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user