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:
158
E2E_GUIDE.md
Normal file
158
E2E_GUIDE.md
Normal file
@@ -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 для работы с БД |
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -8,76 +8,78 @@ 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
|
||||
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[]
|
||||
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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
products Product[]
|
||||
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
|
||||
quantity Int? // остаток на складе
|
||||
badges String? // массив бейджей в формате JSON
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Детальная информация
|
||||
isDetailsFetched Boolean @default(false) // были ли получены детали через detail endpoint
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
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])
|
||||
@@ -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?
|
||||
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])
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user