Initial commit: Supermarket scraper MVP
This commit is contained in:
24
src/config/database.ts
Normal file
24
src/config/database.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '../../generated/prisma/client.js';
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
export async function connectDatabase() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('✅ Подключение к базе данных установлено');
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка подключения к базе данных:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase() {
|
||||
await prisma.$disconnect();
|
||||
console.log('✅ Отключение от базы данных');
|
||||
}
|
||||
|
||||
11
src/database/client.ts
Normal file
11
src/database/client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '../../generated/prisma/client.js';
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
export default prisma;
|
||||
|
||||
103
src/database/prisma/schema.prisma
Normal file
103
src/database/prisma/schema.prisma
Normal file
@@ -0,0 +1,103 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../../../generated/prisma"
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@index([code])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId Int? // ID из внешнего API
|
||||
name String
|
||||
parentId Int?
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
description String?
|
||||
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?
|
||||
|
||||
// Промо-информация
|
||||
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
|
||||
discountPercent Decimal? @db.Decimal(5, 2) // процент скидки
|
||||
promotionEndDate DateTime? // дата окончания акции
|
||||
|
||||
// Рейтинги
|
||||
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])
|
||||
|
||||
@@unique([externalId, storeId])
|
||||
@@index([storeId])
|
||||
@@index([categoryId])
|
||||
@@index([externalId])
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
@@index([storeId])
|
||||
@@index([status])
|
||||
@@index([startedAt])
|
||||
}
|
||||
|
||||
278
src/scrapers/api/magnit/MagnitApiScraper.ts
Normal file
278
src/scrapers/api/magnit/MagnitApiScraper.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { chromium, Browser, Page, BrowserContext } from 'playwright';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { Logger } from '../../../utils/logger.js';
|
||||
import { APIError } from '../../../utils/errors.js';
|
||||
import { ENDPOINTS } from './endpoints.js';
|
||||
import {
|
||||
SearchGoodsRequest,
|
||||
SearchGoodsResponse,
|
||||
ProductItem,
|
||||
} from './types.js';
|
||||
import { ProductService } from '../../../services/product/ProductService.js';
|
||||
import { ProductParser } from '../../../services/parser/ProductParser.js';
|
||||
import { PrismaClient } from '../../../../generated/prisma/client.js';
|
||||
|
||||
export interface MagnitScraperConfig {
|
||||
storeCode: string;
|
||||
storeType?: string;
|
||||
catalogType?: string;
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
export class MagnitApiScraper {
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private page: Page | null = null;
|
||||
private httpClient: AxiosInstance;
|
||||
private deviceId: string = '';
|
||||
private cookies: string = '';
|
||||
private config: Required<MagnitScraperConfig>;
|
||||
|
||||
constructor(config: MagnitScraperConfig) {
|
||||
this.config = {
|
||||
storeCode: config.storeCode,
|
||||
storeType: config.storeType || '6',
|
||||
catalogType: config.catalogType || '1',
|
||||
headless: config.headless !== false,
|
||||
};
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: 'https://magnit.ru',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'ru,en-US;q=0.9,en;q=0.8',
|
||||
'Referer': 'https://magnit.ru/',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация сессии через Playwright для получения cookies и device-id
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
Logger.info('Инициализация браузера через Playwright...');
|
||||
|
||||
this.browser = await chromium.launch({
|
||||
headless: this.config.headless,
|
||||
});
|
||||
|
||||
this.context = await this.browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
|
||||
this.page = await this.context.newPage();
|
||||
|
||||
// Переход на главную страницу для получения cookies
|
||||
Logger.info('Переход на главную страницу magnit.ru...');
|
||||
await this.page.goto('https://magnit.ru/', {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Получение cookies
|
||||
const cookies = await this.context.cookies();
|
||||
this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
|
||||
// Извлечение device-id из cookie mg_udi
|
||||
const mgUdiCookie = cookies.find(c => c.name === 'mg_udi');
|
||||
if (mgUdiCookie) {
|
||||
this.deviceId = mgUdiCookie.value;
|
||||
Logger.info(`Device ID получен: ${this.deviceId.substring(0, 20)}...`);
|
||||
} else {
|
||||
Logger.warn('Cookie mg_udi не найден, device-id будет пустым');
|
||||
}
|
||||
|
||||
// Настройка заголовков для HTTP клиента
|
||||
this.httpClient.defaults.headers.common['Cookie'] = this.cookies;
|
||||
if (this.deviceId) {
|
||||
this.httpClient.defaults.headers.common['x-device-id'] = this.deviceId;
|
||||
}
|
||||
this.httpClient.defaults.headers.common['x-client-name'] = 'magnit';
|
||||
this.httpClient.defaults.headers.common['x-device-platform'] = 'Web';
|
||||
this.httpClient.defaults.headers.common['x-new-magnit'] = 'true';
|
||||
|
||||
Logger.info('✅ Сессия успешно инициализирована');
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка инициализации сессии:', error);
|
||||
throw new APIError(
|
||||
`Не удалось инициализировать сессию: ${error instanceof Error ? error.message : String(error)}`,
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск товаров через API
|
||||
*/
|
||||
async searchGoods(
|
||||
pagination: { limit: number; offset: number } = { limit: 100, offset: 0 },
|
||||
categories: number[] = []
|
||||
): Promise<SearchGoodsResponse> {
|
||||
try {
|
||||
const requestBody: SearchGoodsRequest = {
|
||||
sort: {
|
||||
order: 'desc',
|
||||
type: 'popularity',
|
||||
},
|
||||
pagination,
|
||||
categories,
|
||||
includeAdultGoods: true,
|
||||
storeCode: this.config.storeCode,
|
||||
storeType: this.config.storeType,
|
||||
catalogType: this.config.catalogType,
|
||||
};
|
||||
|
||||
Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`);
|
||||
|
||||
const response = await this.httpClient.post<SearchGoodsResponse>(
|
||||
ENDPOINTS.SEARCH_GOODS,
|
||||
requestBody
|
||||
);
|
||||
|
||||
Logger.debug(`Получено товаров: ${response.data.items.length}`);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const statusCode = error.response?.status || 0;
|
||||
Logger.error(
|
||||
`Ошибка API запроса: ${statusCode} - ${error.message}`,
|
||||
error.response?.data
|
||||
);
|
||||
throw new APIError(
|
||||
`Ошибка API запроса: ${error.message}`,
|
||||
statusCode,
|
||||
error.response?.data
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех товаров без фильтрации по категориям
|
||||
*/
|
||||
async scrapeAllProducts(
|
||||
limit: number = 100
|
||||
): Promise<ProductItem[]> {
|
||||
try {
|
||||
Logger.info('Начало скрапинга всех товаров...');
|
||||
const allProducts: ProductItem[] = [];
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
Logger.info(`Получение товаров: offset=${offset}, limit=${limit}`);
|
||||
|
||||
const response = await this.searchGoods({ limit, offset }, []);
|
||||
|
||||
if (response.items.length === 0) {
|
||||
hasMore = false;
|
||||
Logger.info('Товары закончились, скрапинг завершен');
|
||||
break;
|
||||
}
|
||||
|
||||
allProducts.push(...response.items);
|
||||
Logger.info(`Всего получено товаров: ${allProducts.length}`);
|
||||
|
||||
// Если получили меньше товаров, чем запрашивали, значит это последняя страница
|
||||
if (response.items.length < limit) {
|
||||
hasMore = false;
|
||||
Logger.info('Получена последняя страница товаров');
|
||||
} else {
|
||||
offset += limit;
|
||||
// Задержка между запросами для rate limiting
|
||||
await this.delay(300);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}`);
|
||||
return allProducts;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка при скрапинге всех товаров:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Задержка в миллисекундах
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение товаров в базу данных
|
||||
*/
|
||||
async saveToDatabase(
|
||||
products: ProductItem[],
|
||||
prisma: PrismaClient
|
||||
): Promise<number> {
|
||||
try {
|
||||
Logger.info(`Начало сохранения ${products.length} товаров в БД...`);
|
||||
|
||||
const productService = new ProductService(prisma);
|
||||
|
||||
// Получаем или создаем магазин
|
||||
const store = await productService.getOrCreateStore(
|
||||
this.config.storeCode,
|
||||
'Магнит'
|
||||
);
|
||||
|
||||
// Собираем все категории из товаров
|
||||
const categoryMap = new Map<number, number>();
|
||||
for (const product of products) {
|
||||
if (product.category?.id) {
|
||||
const category = await productService.getOrCreateCategory(
|
||||
product.category.id,
|
||||
product.category.title
|
||||
);
|
||||
categoryMap.set(product.category.id, category.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим товары
|
||||
const parsedProducts = ProductParser.parseProductItems(
|
||||
products,
|
||||
store.id,
|
||||
categoryMap
|
||||
);
|
||||
|
||||
// Сохраняем товары
|
||||
const saved = await productService.saveProducts(parsedProducts);
|
||||
|
||||
Logger.info(`✅ Сохранено товаров в БД: ${saved}`);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка сохранения товаров в БД:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрытие браузера и очистка ресурсов
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
if (this.page) {
|
||||
await this.page.close();
|
||||
this.page = null;
|
||||
}
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
}
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
Logger.info('✅ Браузер закрыт');
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка при закрытии браузера:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
src/scrapers/api/magnit/endpoints.ts
Normal file
9
src/scrapers/api/magnit/endpoints.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const MAGNIT_BASE_URL = 'https://magnit.ru';
|
||||
export const MAGNIT_API_BASE = `${MAGNIT_BASE_URL}/webgate/v2`;
|
||||
|
||||
export const ENDPOINTS = {
|
||||
SEARCH_GOODS: `${MAGNIT_API_BASE}/goods/search`,
|
||||
PRODUCT_STORES: (productId: string, storeCode: string) =>
|
||||
`${MAGNIT_API_BASE}/goods/${productId}/stores/${storeCode}`,
|
||||
} as const;
|
||||
|
||||
80
src/scrapers/api/magnit/types.ts
Normal file
80
src/scrapers/api/magnit/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Типы для запросов API Магнита
|
||||
|
||||
export interface SearchGoodsRequest {
|
||||
sort: {
|
||||
order: 'desc' | 'asc';
|
||||
type: 'popularity' | 'price' | 'name';
|
||||
};
|
||||
pagination: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
categories: number[]; // пустой массив для всех товаров
|
||||
includeAdultGoods: boolean;
|
||||
storeCode: string;
|
||||
storeType: string;
|
||||
catalogType: string;
|
||||
}
|
||||
|
||||
// Типы для ответов API Магнита
|
||||
|
||||
export interface Promotion {
|
||||
isPromotion: boolean;
|
||||
oldPrice?: number; // в копейках
|
||||
discountPercent?: number;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface GalleryItem {
|
||||
url: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Ratings {
|
||||
rating: number;
|
||||
scoresCount: number;
|
||||
commentsCount: number;
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
text: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface CategoryInfo {
|
||||
id: number;
|
||||
title: string;
|
||||
fullMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
price: number; // в копейках
|
||||
promotion?: Promotion;
|
||||
gallery: GalleryItem[];
|
||||
ratings: Ratings;
|
||||
quantity: number; // остаток на складе
|
||||
badges: Badge[];
|
||||
storeCode: string;
|
||||
category?: CategoryInfo;
|
||||
// Дополнительные поля, которые могут быть в ответе
|
||||
description?: string;
|
||||
brand?: string;
|
||||
weight?: string;
|
||||
unit?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface FastCategory {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SearchGoodsResponse {
|
||||
category?: CategoryInfo;
|
||||
items: ProductItem[];
|
||||
fastCategoriesExtended?: FastCategory[];
|
||||
}
|
||||
|
||||
56
src/scripts/scrape-magnit-products.ts
Normal file
56
src/scripts/scrape-magnit-products.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dotenv/config';
|
||||
import { MagnitApiScraper } from '../scrapers/api/magnit/MagnitApiScraper.js';
|
||||
import { connectDatabase, disconnectDatabase, prisma } from '../config/database.js';
|
||||
import { Logger } from '../utils/logger.js';
|
||||
|
||||
async function main() {
|
||||
const storeCode = process.env.MAGNIT_STORE_CODE || process.argv[2];
|
||||
|
||||
if (!storeCode) {
|
||||
Logger.error('Не указан код магазина. Используйте переменную окружения MAGNIT_STORE_CODE или передайте как аргумент');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Logger.info(`🚀 Запуск скрапинга для магазина: ${storeCode}`);
|
||||
|
||||
const scraper = new MagnitApiScraper({
|
||||
storeCode,
|
||||
storeType: process.env.MAGNIT_STORE_TYPE || '6',
|
||||
catalogType: process.env.MAGNIT_CATALOG_TYPE || '1',
|
||||
headless: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Подключение к БД
|
||||
await connectDatabase();
|
||||
|
||||
// Инициализация скрапера
|
||||
await scraper.initialize();
|
||||
|
||||
// Получение всех товаров
|
||||
const products = await scraper.scrapeAllProducts(100);
|
||||
|
||||
Logger.info(`📦 Получено товаров: ${products.length}`);
|
||||
|
||||
if (products.length > 0) {
|
||||
// Сохранение в БД
|
||||
const saved = await scraper.saveToDatabase(products, prisma);
|
||||
Logger.info(`✅ Успешно сохранено товаров: ${saved}`);
|
||||
} else {
|
||||
Logger.warn('⚠️ Товары не найдены');
|
||||
}
|
||||
|
||||
Logger.info('✅ Скрапинг завершен успешно');
|
||||
} catch (error) {
|
||||
Logger.error('❌ Ошибка при скрапинге:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Закрытие браузера
|
||||
await scraper.close();
|
||||
// Отключение от БД
|
||||
await disconnectDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
18
src/scripts/test-db-connection.ts
Normal file
18
src/scripts/test-db-connection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
import { connectDatabase, disconnectDatabase } from '../config/database.js';
|
||||
|
||||
async function testConnection() {
|
||||
try {
|
||||
console.log('🔌 Тестирование подключения к базе данных...');
|
||||
await connectDatabase();
|
||||
console.log('✅ Подключение успешно!');
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка подключения:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await disconnectDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
||||
|
||||
84
src/services/parser/ProductParser.ts
Normal file
84
src/services/parser/ProductParser.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ProductItem } from '../../scrapers/api/magnit/types.js';
|
||||
import { CreateProductData } from '../product/ProductService.js';
|
||||
|
||||
export class ProductParser {
|
||||
/**
|
||||
* Конвертация цены из копеек в рубли
|
||||
*/
|
||||
private static priceFromKopecks(kopecks: number): number {
|
||||
return kopecks / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг даты из строки
|
||||
*/
|
||||
private static parseDate(dateString?: string): Date | undefined {
|
||||
if (!dateString) return undefined;
|
||||
try {
|
||||
return new Date(dateString);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразование массива бейджей в JSON строку
|
||||
*/
|
||||
private static badgesToJson(badges: Array<{ text: string; backgroundColor: string }>): string | undefined {
|
||||
if (!badges || badges.length === 0) return undefined;
|
||||
return JSON.stringify(badges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразование ProductItem из API в CreateProductData для БД
|
||||
*/
|
||||
static parseProductItem(
|
||||
item: ProductItem,
|
||||
storeId: number,
|
||||
categoryId?: number
|
||||
): CreateProductData {
|
||||
return {
|
||||
externalId: item.productId,
|
||||
storeId,
|
||||
categoryId,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
imageUrl: item.gallery && item.gallery.length > 0 ? item.gallery[0].url : undefined,
|
||||
currentPrice: this.priceFromKopecks(item.price),
|
||||
unit: item.unit,
|
||||
weight: item.weight,
|
||||
brand: item.brand,
|
||||
oldPrice: item.promotion?.oldPrice
|
||||
? this.priceFromKopecks(item.promotion.oldPrice)
|
||||
: undefined,
|
||||
discountPercent: item.promotion?.discountPercent
|
||||
? item.promotion.discountPercent
|
||||
: undefined,
|
||||
promotionEndDate: this.parseDate(item.promotion?.endDate),
|
||||
rating: item.ratings?.rating,
|
||||
scoresCount: item.ratings?.scoresCount,
|
||||
commentsCount: item.ratings?.commentsCount,
|
||||
quantity: item.quantity,
|
||||
badges: this.badgesToJson(item.badges),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг массива товаров
|
||||
*/
|
||||
static parseProductItems(
|
||||
items: ProductItem[],
|
||||
storeId: number,
|
||||
categoryMap: Map<number, number> // Map<externalCategoryId, categoryId>
|
||||
): CreateProductData[] {
|
||||
return items.map(item => {
|
||||
const categoryId = item.category?.id
|
||||
? categoryMap.get(item.category.id)
|
||||
: undefined;
|
||||
|
||||
return this.parseProductItem(item, storeId, categoryId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
208
src/services/product/ProductService.ts
Normal file
208
src/services/product/ProductService.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { PrismaClient, Product, Store, Category } from '../../../generated/prisma/client.js';
|
||||
import { Logger } from '../../utils/logger.js';
|
||||
import { DatabaseError } from '../../utils/errors.js';
|
||||
|
||||
export interface CreateProductData {
|
||||
externalId: string;
|
||||
storeId: number;
|
||||
categoryId?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
currentPrice: number;
|
||||
unit?: string;
|
||||
weight?: string;
|
||||
brand?: string;
|
||||
oldPrice?: number;
|
||||
discountPercent?: number;
|
||||
promotionEndDate?: Date;
|
||||
rating?: number;
|
||||
scoresCount?: number;
|
||||
commentsCount?: number;
|
||||
quantity?: number;
|
||||
badges?: string;
|
||||
}
|
||||
|
||||
export class ProductService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* Сохранение одного товара
|
||||
*/
|
||||
async saveProduct(data: CreateProductData): Promise<Product> {
|
||||
try {
|
||||
// Проверяем, существует ли товар
|
||||
const existing = await this.prisma.product.findUnique({
|
||||
where: {
|
||||
externalId_storeId: {
|
||||
externalId: data.externalId,
|
||||
storeId: data.storeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Обновляем существующий товар
|
||||
Logger.debug(`Обновление товара: ${data.externalId}`);
|
||||
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,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Создаем новый товар
|
||||
Logger.debug(`Создание нового товара: ${data.externalId}`);
|
||||
return await this.prisma.product.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка сохранения товара:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось сохранить товар: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение нескольких товаров батчами
|
||||
*/
|
||||
async saveProducts(products: CreateProductData[]): Promise<number> {
|
||||
try {
|
||||
Logger.info(`Сохранение ${products.length} товаров...`);
|
||||
let saved = 0;
|
||||
|
||||
// Обрабатываем батчами по 50 товаров
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < products.length; i += batchSize) {
|
||||
const batch = products.slice(i, i + batchSize);
|
||||
const promises = batch.map(product => this.saveProduct(product));
|
||||
await Promise.all(promises);
|
||||
saved += batch.length;
|
||||
Logger.info(`Сохранено товаров: ${saved}/${products.length}`);
|
||||
}
|
||||
|
||||
Logger.info(`✅ Всего сохранено товаров: ${saved}`);
|
||||
return saved;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка сохранения товаров:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск товара по externalId и storeId
|
||||
*/
|
||||
async findByExternalId(
|
||||
externalId: string,
|
||||
storeId: number
|
||||
): Promise<Product | null> {
|
||||
try {
|
||||
return await this.prisma.product.findUnique({
|
||||
where: {
|
||||
externalId_storeId: {
|
||||
externalId,
|
||||
storeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка поиска товара:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось найти товар: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение или создание магазина
|
||||
*/
|
||||
async getOrCreateStore(
|
||||
code: string,
|
||||
name: string = 'Магнит'
|
||||
): Promise<Store> {
|
||||
try {
|
||||
let store = await this.prisma.store.findFirst({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!store) {
|
||||
Logger.info(`Создание нового магазина: ${code}`);
|
||||
store = await this.prisma.store.create({
|
||||
data: {
|
||||
name,
|
||||
type: 'web',
|
||||
code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return store;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка получения/создания магазина:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось получить/создать магазин: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение или создание категории
|
||||
*/
|
||||
async getOrCreateCategory(
|
||||
externalId: number,
|
||||
name: string
|
||||
): Promise<Category> {
|
||||
try {
|
||||
let category = await this.prisma.category.findFirst({
|
||||
where: { externalId },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
Logger.info(`Создание новой категории: ${name} (${externalId})`);
|
||||
category = await this.prisma.category.create({
|
||||
data: {
|
||||
externalId,
|
||||
name,
|
||||
},
|
||||
});
|
||||
} else if (category.name !== name) {
|
||||
// Обновляем название категории, если изменилось
|
||||
category = await this.prisma.category.update({
|
||||
where: { id: category.id },
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
return category;
|
||||
} catch (error) {
|
||||
Logger.error('Ошибка получения/создания категории:', error);
|
||||
throw new DatabaseError(
|
||||
`Не удалось получить/создать категорию: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
src/utils/errors.ts
Normal file
29
src/utils/errors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export class ScraperError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ScraperError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends Error {
|
||||
constructor(message: string, public readonly originalError?: Error) {
|
||||
super(message);
|
||||
this.name = 'DatabaseError';
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly response?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
}
|
||||
}
|
||||
|
||||
20
src/utils/logger.ts
Normal file
20
src/utils/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class Logger {
|
||||
static info(message: string, ...args: any[]) {
|
||||
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
}
|
||||
|
||||
static error(message: string, ...args: any[]) {
|
||||
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
}
|
||||
|
||||
static warn(message: string, ...args: any[]) {
|
||||
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
}
|
||||
|
||||
static debug(message: string, ...args: any[]) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`[DEBUG] ${new Date().toISOString()} - ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user