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",
|
"build": "tsc",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"dev": "tsx src/scripts/scrape-magnit-products.ts",
|
"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-db": "tsx src/scripts/test-db-connection.ts",
|
||||||
|
"test-detail-endpoint": "tsx src/scripts/test-all-detail-endpoints.ts",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio --config=prisma.config.ts",
|
"prisma:studio": "prisma studio --config=prisma.config.ts",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import { defineConfig, env } from 'prisma/config'
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'src/database/prisma/schema.prisma',
|
schema: 'src/database/prisma/schema.prisma',
|
||||||
@@ -7,6 +7,6 @@ export default defineConfig({
|
|||||||
path: 'src/database/prisma/migrations',
|
path: 'src/database/prisma/migrations',
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env('DATABASE_URL'),
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -8,77 +8,79 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Store {
|
model Store {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
type String // "web" | "app"
|
type String // "web" | "app"
|
||||||
code String? // storeCode для API (например "992301")
|
code String? // storeCode для API (например "992301")
|
||||||
url String?
|
url String?
|
||||||
region String?
|
region String?
|
||||||
address String?
|
address String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
products Product[]
|
products Product[]
|
||||||
sessions ScrapingSession[]
|
sessions ScrapingSession[]
|
||||||
|
|
||||||
@@index([code])
|
@@index([code])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
externalId Int? // ID из внешнего API
|
externalId Int? // ID из внешнего API
|
||||||
name String
|
name String
|
||||||
parentId Int?
|
parentId Int?
|
||||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||||
children Category[] @relation("CategoryHierarchy")
|
children Category[] @relation("CategoryHierarchy")
|
||||||
description String?
|
description String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
products Product[]
|
products Product[]
|
||||||
|
|
||||||
@@index([externalId])
|
@@index([externalId])
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
externalId String // ID из API (например "1000300796")
|
externalId String // ID из API (например "1000300796")
|
||||||
storeId Int
|
storeId Int
|
||||||
categoryId Int?
|
categoryId Int?
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
url String?
|
url String?
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
currentPrice Decimal @db.Decimal(10, 2)
|
currentPrice Decimal @db.Decimal(10, 2)
|
||||||
unit String? // единица измерения
|
unit String? // единица измерения
|
||||||
weight String? // вес/объем
|
weight String? // вес/объем
|
||||||
brand String?
|
brand String?
|
||||||
|
|
||||||
// Промо-информация
|
// Промо-информация
|
||||||
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
|
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
|
||||||
discountPercent Decimal? @db.Decimal(5, 2) // процент скидки
|
discountPercent Decimal? @db.Decimal(5, 2) // процент скидки
|
||||||
promotionEndDate DateTime? // дата окончания акции
|
promotionEndDate DateTime? // дата окончания акции
|
||||||
|
|
||||||
// Рейтинги
|
// Рейтинги
|
||||||
rating Decimal? @db.Decimal(3, 2) // рейтинг товара
|
rating Decimal? @db.Decimal(3, 2) // рейтинг товара
|
||||||
scoresCount Int? // количество оценок
|
scoresCount Int? // количество оценок
|
||||||
commentsCount Int? // количество комментариев
|
commentsCount Int? // количество комментариев
|
||||||
|
|
||||||
// Остаток и бейджи
|
// Остаток и бейджи
|
||||||
quantity Int? // остаток на складе
|
quantity Int? // остаток на складе
|
||||||
badges String? // массив бейджей в формате JSON
|
badges String? // массив бейджей в формате JSON
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
// Детальная информация
|
||||||
updatedAt DateTime @updatedAt
|
isDetailsFetched Boolean @default(false) // были ли получены детали через detail endpoint
|
||||||
|
|
||||||
store Store @relation(fields: [storeId], references: [id])
|
createdAt DateTime @default(now())
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id])
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
@@unique([externalId, storeId])
|
@@unique([externalId, storeId])
|
||||||
@@index([storeId])
|
@@index([storeId])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@ -86,18 +88,17 @@ model Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ScrapingSession {
|
model ScrapingSession {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
storeId Int
|
storeId Int
|
||||||
sourceType String // "api" | "web" | "app"
|
sourceType String // "api" | "web" | "app"
|
||||||
status String // "pending" | "running" | "completed" | "failed"
|
status String // "pending" | "running" | "completed" | "failed"
|
||||||
startedAt DateTime @default(now())
|
startedAt DateTime @default(now())
|
||||||
finishedAt DateTime?
|
finishedAt DateTime?
|
||||||
error String?
|
error String?
|
||||||
|
|
||||||
store Store @relation(fields: [storeId], references: [id])
|
store Store @relation(fields: [storeId], references: [id])
|
||||||
|
|
||||||
@@index([storeId])
|
@@index([storeId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([startedAt])
|
@@index([startedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
SearchGoodsRequest,
|
SearchGoodsRequest,
|
||||||
SearchGoodsResponse,
|
SearchGoodsResponse,
|
||||||
ProductItem,
|
ProductItem,
|
||||||
|
ObjectInfo,
|
||||||
|
ObjectReviewsResponse,
|
||||||
|
ProductDetailsResponse,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { ProductService } from '../../../services/product/ProductService.js';
|
import { ProductService } from '../../../services/product/ProductService.js';
|
||||||
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
||||||
@@ -90,7 +93,7 @@ export class MagnitApiScraper {
|
|||||||
// Переход на главную страницу для получения cookies
|
// Переход на главную страницу для получения cookies
|
||||||
Logger.info('Переход на главную страницу magnit.ru...');
|
Logger.info('Переход на главную страницу magnit.ru...');
|
||||||
await this.page.goto('https://magnit.ru/', {
|
await this.page.goto('https://magnit.ru/', {
|
||||||
waitUntil: 'networkidle',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: 30000,
|
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)
|
* Переинициализация сессии (при 403 или истечении cookies)
|
||||||
* ВАЖНО: Не закрываем браузер, только обновляем cookies
|
* ВАЖНО: Не закрываем браузер, только обновляем cookies
|
||||||
@@ -204,7 +287,7 @@ export class MagnitApiScraper {
|
|||||||
|
|
||||||
// Переход на главную страницу для обновления cookies
|
// Переход на главную страницу для обновления cookies
|
||||||
await this.page.goto('https://magnit.ru/', {
|
await this.page.goto('https://magnit.ru/', {
|
||||||
waitUntil: 'networkidle',
|
waitUntil: 'domcontentloaded',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,17 @@ export const ENDPOINTS = {
|
|||||||
SEARCH_GOODS: `${MAGNIT_API_BASE}/goods/search`,
|
SEARCH_GOODS: `${MAGNIT_API_BASE}/goods/search`,
|
||||||
PRODUCT_STORES: (productId: string, storeCode: string) =>
|
PRODUCT_STORES: (productId: string, storeCode: string) =>
|
||||||
`${MAGNIT_API_BASE}/goods/${productId}/stores/${storeCode}`,
|
`${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;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -78,3 +78,35 @@ export interface SearchGoodsResponse {
|
|||||||
fastCategoriesExtended?: FastCategory[];
|
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';
|
import { CreateProductData } from '../product/ProductService.js';
|
||||||
|
|
||||||
export class ProductParser {
|
export class ProductParser {
|
||||||
@@ -80,5 +80,113 @@ export class ProductParser {
|
|||||||
return this.parseProductItem(item, storeId, categoryId);
|
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;
|
commentsCount?: number;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
badges?: string;
|
badges?: string;
|
||||||
|
isDetailsFetched?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProductService {
|
export class ProductService {
|
||||||
@@ -45,27 +46,36 @@ export class ProductService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
// Обновляем существующий товар
|
// Обновляем существующий товар
|
||||||
Logger.debug(`Обновление товара: ${data.externalId}`);
|
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({
|
return await this.prisma.product.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: updateData,
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} 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
|
"noEmit": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"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": {
|
"ts-node": {
|
||||||
"esm": true
|
"esm": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user