From 3299cca57486d6457748ec567286c7fab0cac936 Mon Sep 17 00:00:00 2001 From: Mc Smog Date: Thu, 22 Jan 2026 01:52:50 +0500 Subject: [PATCH] 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 --- E2E_GUIDE.md | 158 ++++++++++++++++ package.json | 2 + prisma.config.ts | 4 +- src/database/prisma/schema.prisma | 127 ++++++------- src/scrapers/api/magnit/MagnitApiScraper.ts | 87 ++++++++- src/scrapers/api/magnit/endpoints.ts | 12 ++ src/scrapers/api/magnit/types.ts | 32 ++++ src/scripts/enrich-product-details.ts | 192 ++++++++++++++++++++ src/services/parser/ProductParser.ts | 110 ++++++++++- src/services/product/ProductService.ts | 146 +++++++++++++-- tsconfig.json | 13 +- 11 files changed, 795 insertions(+), 88 deletions(-) create mode 100644 E2E_GUIDE.md create mode 100644 src/scripts/enrich-product-details.ts diff --git a/E2E_GUIDE.md b/E2E_GUIDE.md new file mode 100644 index 0000000..e9d2ad6 --- /dev/null +++ b/E2E_GUIDE.md @@ -0,0 +1,158 @@ +# Руководство по скрапингу товаров Магнит + +## 📋 Обзор + +Процесс состоит из двух этапов: +1. **Базовый скрапинг** - получение списка товаров через API поиска +2. **Обогащение деталями** - получение бренда, описания, веса для каждого товара + +--- + +## 🚀 Этап 1: Базовый скрапинг + +### Что делает: +- Сканирует каталог товаров через API поиска +- Сохраняет базовую информацию: название, цена, рейтинг, изображение +- Сохраняет товары в базу данных с `isDetailsFetched = false` + +### Запуск: +```bash +pnpm dev +``` + +### Опционально: указать магазин +```bash +MAGNIT_STORE_CODE=992301 pnpm dev +``` + +### Результат: +- В таблице `Product` появляются записи с базовыми данными +- Поля `brand`, `description`, `weight`, `unit` пустые +- `isDetailsFetched = false` + +--- + +## ✨ Этап 2: Обогащение деталями + +### Что делает: +- Получает товары с `isDetailsFetched = false` +- Для каждого товара запрашивает детали через специальный API endpoint +- Обновляет: бренд, описание, вес, единицу измерения +- Ставит `isDetailsFetched = true` + +### Запуск: +```bash +pnpm enrich +``` + +### Опционально: указать магазин +```bash +MAGNIT_STORE_CODE=992301 pnpm enrich +``` + +### Результат: +- Поля `brand`, `description`, `weight`, `unit` заполнены +- Все товары имеют `isDetailsFetched = true` + +--- + +## 🔄 Полный цикл (последовательный) + +```bash +# 1. Базовый скрапинг +pnpm dev + +# 2. Обогащение деталями +pnpm enrich +``` + +--- + +## 🧪 Тестирование + +### Проверить соединение с БД: +```bash +pnpm test-db +``` + +### Проверить detail endpoint: +```bash +pnpm test-detail-endpoint +``` + +--- + +## 📊 Проверка результатов + +### Через SQL: +```sql +-- Количество товаров +SELECT COUNT(*) FROM "Product"; + +-- Сколько обогащено +SELECT + COUNT(*) FILTER (WHERE "isDetailsFetched" = true) as enriched, + COUNT(*) FILTER (WHERE "isDetailsFetched" = false) as pending +FROM "Product"; + +-- Процент NULL полей +SELECT + 'brand' as field, + ROUND(100.0 * SUM(CASE WHEN brand IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) as null_percent +FROM "Product" +UNION ALL +SELECT 'description', ROUND(100.0 * SUM(CASE WHEN description IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) FROM "Product" +UNION ALL +SELECT 'weight', ROUND(100.0 * SUM(CASE WHEN weight IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) FROM "Product" +UNION ALL +SELECT 'unit', ROUND(100.0 * SUM(CASE WHEN unit IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) FROM "Product"; +``` + +--- + +## 🛠️ Управление миграциями + +### Создать и применить миграцию: +```bash +pnpm prisma:migrate --name название_миграции +``` + +### Пересоздать Prisma Client: +```bash +pnpm prisma:generate +``` + +### Открыть Prisma Studio: +```bash +pnpm prisma:studio +``` + +--- + +## ⚠️ Возможные проблемы + +### Advisory lock при миграции +Если при миграции возникает `P1002` error: +```bash +# Перезапустить PostgreSQL контейнер +docker restart supermarket-postgres +``` + +### 403 Forbidden при скрапинге +Скрапер автоматически переинициализирует сессию. Проверьте логи. + +### Пустые результаты +Проверьте, что магазин с указанным `MAGNIT_STORE_CODE` существует. + +--- + +## 📝 Доступные команды + +| Команда | Описание | +|---------|----------| +| `pnpm dev` | Базовый скрапинг товаров | +| `pnpm enrich` | Обогащение деталями | +| `pnpm test-db` | Проверка соединения с БД | +| `pnpm test-detail-endpoint` | Тест detail API | +| `pnpm type-check` | Проверка типов TypeScript | +| `pnpm prisma:studio` | GUI для работы с БД | diff --git a/package.json b/package.json index afb6967..0ef6d43 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "tsc", "type-check": "tsc --noEmit", "dev": "tsx src/scripts/scrape-magnit-products.ts", + "enrich": "tsx src/scripts/enrich-product-details.ts", "test-db": "tsx src/scripts/test-db-connection.ts", + "test-detail-endpoint": "tsx src/scripts/test-all-detail-endpoints.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio --config=prisma.config.ts", diff --git a/prisma.config.ts b/prisma.config.ts index ca00b44..96c9cc1 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,5 +1,5 @@ import 'dotenv/config' -import { defineConfig, env } from 'prisma/config' +import { defineConfig } from 'prisma/config' export default defineConfig({ schema: 'src/database/prisma/schema.prisma', @@ -7,6 +7,6 @@ export default defineConfig({ path: 'src/database/prisma/migrations', }, datasource: { - url: env('DATABASE_URL'), + url: process.env.DATABASE_URL, }, }) \ No newline at end of file diff --git a/src/database/prisma/schema.prisma b/src/database/prisma/schema.prisma index 30e1f55..be5fc7c 100644 --- a/src/database/prisma/schema.prisma +++ b/src/database/prisma/schema.prisma @@ -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]) } - diff --git a/src/scrapers/api/magnit/MagnitApiScraper.ts b/src/scrapers/api/magnit/MagnitApiScraper.ts index b2a6472..6ccd2ac 100644 --- a/src/scrapers/api/magnit/MagnitApiScraper.ts +++ b/src/scrapers/api/magnit/MagnitApiScraper.ts @@ -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 { + const operation = async () => { + try { + Logger.debug(`Запрос object info для продукта ${productId}`); + + const response = await this.httpClient.get( + 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 { + const operation = async () => { + try { + Logger.debug(`Запрос деталей для продукта ${productId}`); + + const response = await this.httpClient.get( + 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, }); diff --git a/src/scrapers/api/magnit/endpoints.ts b/src/scrapers/api/magnit/endpoints.ts index 160c6c9..acb1a27 100644 --- a/src/scrapers/api/magnit/endpoints.ts +++ b/src/scrapers/api/magnit/endpoints.ts @@ -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; diff --git a/src/scrapers/api/magnit/types.ts b/src/scrapers/api/magnit/types.ts index ce52b26..dfd99ee 100644 --- a/src/scrapers/api/magnit/types.ts +++ b/src/scrapers/api/magnit/types.ts @@ -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[]; +} + diff --git a/src/scripts/enrich-product-details.ts b/src/scripts/enrich-product-details.ts new file mode 100644 index 0000000..c6e2777 --- /dev/null +++ b/src/scripts/enrich-product-details.ts @@ -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(); diff --git a/src/services/parser/ProductParser.ts b/src/services/parser/ProductParser.ts index fd14438..9fd1205 100644 --- a/src/services/parser/ProductParser.ts +++ b/src/services/parser/ProductParser.ts @@ -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; + } } diff --git a/src/services/product/ProductService.ts b/src/services/product/ProductService.ts index 82e0ea7..501a22c 100644 --- a/src/services/product/ProductService.ts +++ b/src/services/product/ProductService.ts @@ -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 { + 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 { + 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 { + 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 + ); + } + } } diff --git a/tsconfig.json b/tsconfig.json index cfe14d3..a0cce3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,18 @@ "noEmit": false }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "generated"], + "exclude": [ + "node_modules", + "dist", + "generated", + "src/scripts/extract-product-from-html.ts", + "src/scripts/find-product-detail-api.ts", + "src/scripts/find-product-detail-endpoint-v1.ts", + "src/scripts/test-detail-endpoint.ts", + "src/scripts/test-all-detail-endpoints.ts", + "src/scripts/test-object-reviews-endpoint.ts", + "src/scripts/debug-detail-response.ts" + ], "ts-node": { "esm": true }