Initial commit: Supermarket scraper MVP

This commit is contained in:
2025-12-28 23:29:30 +05:00
commit 19c0426cdc
30 changed files with 4839 additions and 0 deletions

24
src/config/database.ts Normal file
View 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
View 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;

View 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])
}

View 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);
}
}
}

View 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;

View 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[];
}

View 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();

View 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();

View 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);
});
}
}

View 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
View 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
View 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);
}
}
}