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

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(cat:*)"
]
}
}

View File

@@ -0,0 +1,993 @@
---
name: Supermarket Scraper System
overview: Создание комплексной системы скрапинга товаров из магазинов Магнит и Пятёрочка с веб-скрапингом (Playwright), автоматизацией Android-приложений (Appium), хранением в PostgreSQL с pgvector для embeddings, интеграцией LangChain для LLM-запросов, и планировщиком для автоматического обновления данных.
todos: []
---
# План разработки системы скрапинга товаров супермаркетов
## Архитектура системы
```javascript
Data Sources
API Web Scrapers Android App Scrapers
Scrapers (Playwright) (Appium)
- Magnit - magnit.ru - Магнит Android App
API - 5ka.ru - 5ka Android App
POST Fallback/ Для доставки и
/webgate Дополнение позиций доставки
Data Processing Layer
- Parsing & Normalize
- Embeddings Generation
PostgreSQL Database
- pgvector extension
- Products, Categories
- Price History
- Stores
LangChain + LLM
- SQL Agent
- Query Interface
```
## Структура проекта
```javascript
supermarket/
package.json
tsconfig.json
.env.example
docker-compose.yml # PostgreSQL с pgvector
src/
config/
database.ts # DB connection & setup
appium.ts # Appium config
llm.ts # LangChain/LLM config
database/
prisma/
schema.prisma # Prisma schema
migrations/ # Автоматические миграции Prisma
client.ts # Prisma Client instance
seeders/ # Initial data (Prisma seed)
scrapers/
api/
base/
BaseApiScraper.ts
magnit/
MagnitApiScraper.ts
types.ts
endpoints.ts # API endpoints и параметры
5ka/
5kaApiScraper.ts
types.ts
endpoints.ts
web/
base/
BaseWebScraper.ts
magnit/
MagnitWebScraper.ts
types.ts
selectors.ts
5ka/
5kaWebScraper.ts
types.ts
selectors.ts
android/
base/
BaseAppScraper.ts
magnit/
MagnitAppScraper.ts
selectors.ts
5ka/
5kaAppScraper.ts
selectors.ts
services/
auth/
WebAuthService.ts
AppAuthService.ts
SessionManager.ts # Управление сессиями для API
embeddings/
EmbeddingService.ts # Генерация embeddings для товаров
parser/
ProductParser.ts # Парсинг и нормализация данных
scheduler/
SchedulerService.ts # Cron-like планировщик
api/
server.ts # HTTP сервер (Express/Fastify)
routes/
index.ts # Главный роутер
scrapers.ts # Endpoints для скрапинга
embeddings.ts # Endpoints для embeddings
scheduler.ts # Endpoints для планировщика
health.ts # Health check endpoints
controllers/
ScraperController.ts
EmbeddingController.ts
SchedulerController.ts
middlewares/
auth.ts # Аутентификация (опционально)
errorHandler.ts
requestLogger.ts
types/
requests.ts # Типы для API запросов
llm/
agents/
SQLAgent.ts # LangChain SQL Agent
chains/
QueryChain.ts # Цепочки запросов
prompts/
prompts.ts # Промпты для LLM
scripts/
scrape-api.ts # Ручной запуск API-скрапинга
scrape-web.ts # Ручной запуск веб-скрапинга (fallback)
scrape-android.ts # Ручной запуск Android-скрапинга
generate-embeddings.ts # Генерация embeddings
migrate.ts # Запуск Prisma миграций (prisma migrate)
start-server.ts # Запуск API сервера
utils/
logger.ts
errors.ts
tests/
scrapers/
services/
```
## База данных (PostgreSQL + pgvector + Prisma)
### Prisma Schema - Основные модели:
```prisma
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[]
}
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?
embedding Unsupported("vector(1536)")? // pgvector (добавится в Этапе 3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products Product[]
}
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?
embedding Unsupported("vector(1536)")? // pgvector (добавится в Этапе 3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
store Store @relation(fields: [storeId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
priceHistory PriceHistory[]
@@unique([externalId, storeId])
@@index([storeId])
@@index([categoryId])
}
model PriceHistory {
id Int @id @default(autoincrement())
productId Int
price Decimal @db.Decimal(10, 2)
oldPrice Decimal? @db.Decimal(10, 2) // старая цена при акции
source String // "api" | "web" | "app"
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
@@index([productId])
@@index([createdAt])
}
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])
}
```
**Примечания:**
- `Unsupported("vector(1536)")` - используется для pgvector, Prisma не имеет нативной поддержки
- Для работы с pgvector используем Prisma Raw queries
- Embeddings добавляются в Этапе 3 (после настройки базовой схемы)
## Этапы реализации (приоритеты разработки)
### Этап 1: База данных и Prisma (MVP - начало)
**Цель**: Настроить БД для сохранения товаров
1. Инициализация TypeScript проекта
2. Настройка PostgreSQL с pgvector (Docker)
3. Установка и настройка Prisma ORM
4. Создание Prisma schema:
- Модель Store (магазины)
- Модель Category (категории)
- Модель Product (товары) - базовые поля без embeddings
- Модель PriceHistory (история цен)
- Модель ScrapingSession (сессии скрапинга)
5. Создание миграций Prisma
6. Настройка Prisma Client и подключения к БД
7. Тестирование подключения и базовых операций
### Этап 2: API скрапинг Магнита и сохранение в БД (MVP - продолжение)
**Цель**: Получить данные через API Магнита и сохранить в БД
1. Настройка базовых зависимостей (playwright, axios, dotenv)
2. Реализация MagnitApiScraper с обходом защиты (Playwright для сессии)
- Метод инициализации сессии через Playwright
- Метод поиска товаров (POST /webgate/v2/goods/search)
- Получение 2-3 страниц товаров для тестирования
- Парсинг и нормализация данных из API ответа
3. Интеграция MagnitApiScraper с БД через Prisma
4. Сохранение товаров в БД через Prisma Client
5. Сохранение истории цен при каждом скрапинге
6. Тестирование на нескольких категориях товаров
7. Проверка сохранения данных в PostgreSQL
### Этап 3: Embeddings и векторный поиск
**Цель**: Добавить embeddings для товаров и категорий
1. Установка и настройка pgvector расширения в PostgreSQL
2. Обновление Prisma schema:
- Добавление поля `embedding vector(1536)` в модель Product
- Добавление поля `embedding vector(1536)` в модель Category
3. Создание миграции для pgvector
4. Интеграция с моделью для embeddings (OpenAI embeddings или Ollama)
5. Реализация EmbeddingService:
- Генерация embeddings для названий и описаний товаров
- Батчовая обработка для эффективности
6. Обновление индексов pgvector для быстрого поиска
7. Функции векторного поиска в PostgreSQL через Prisma Raw queries
### Этап 4: API-скрапинг - полная реализация
1. Базовый класс BaseApiScraper с общим функционалом
- HTTP клиент (axios) с настройкой заголовков
- Retry logic и обработка ошибок
- Rate limiting
- Интеграция с Playwright для получения сессий
2. MagnitApiScraper для API magnit.ru/webgate/v2/goods/search
- Инициализация через Playwright (получение cookies и device-id)
- Метод `initialize()` - запуск браузера, получение сессии
- Метод `searchGoods()` - POST запрос для поиска товаров
- Метод `getProductStores()` - GET запрос для информации о товаре в магазине
- Обработка 403 Forbidden (автоматическое обновление сессии)
- Получение списка категорий
- Пагинация товаров по категориям
- Обработка ответа API (items, prices, promotions, ratings)
- Маппинг API ответов в единый формат
- Управление жизненным циклом браузера (переиспользование сессии)
3. 5kaApiScraper (если доступен API, аналогичная структура)
4. Сервис парсинга и нормализации товаров
5. Сохранение данных в БД
### Этап 5: Расширение функционала (после MVP)
**Этап 5.1: 5ka API скрапинг**
1. Реализация 5kaApiScraper (если доступен API)
2. Интеграция с БД через Prisma
**Этап 5.2: Веб-скрапинг (Playwright) - fallback/дополнение**
1. Базовый класс BaseWebScraper с общим функционалом
2. MagnitWebScraper с авторизацией (если API недоступен)
3. 5kaWebScraper с авторизацией
4. Интеграция как резервный метод
**Этап 5.3: Android-скрапинг (Appium)**
1. Настройка Appium и WebDriverIO
2. Базовый класс BaseAppScraper
3. MagnitAppScraper для Android приложения
4. 5kaAppScraper для Android приложения
5. Сервис авторизации в приложениях
### Этап 6: LangChain интеграция
1. Настройка LangChain SQL Agent
2. Подключение к PostgreSQL через Prisma
3. Промпты для работы с товарами и ценами
4. Реализация запросов типа "самый дешевый попкорн"
### Этап 7: Планировщик и автоматизация
1. SchedulerService с cron-like функционалом
- node-cron для расписаний
- Управление задачами (запуск, остановка, статус)
- Конфигурация расписаний через конфиг-файл
2. REST API Server для внешних интеграций
- HTTP сервер (Express или Fastify)
- API endpoints для триггеров из n8n и других сервисов
- Документация API (OpenAPI/Swagger)
3. API Endpoints:
- `POST /api/scrapers/api/magnit` - Запуск API-скрапинга Магнита
- `POST /api/scrapers/api/5ka` - Запуск API-скрапинга 5ka
- `POST /api/scrapers/web/magnit` - Запуск веб-скрапинга Магнита
- `POST /api/scrapers/web/5ka` - Запуск веб-скрапинга 5ka
- `POST /api/scrapers/android/magnit` - Запуск Android-скрапинга Магнита
- `POST /api/scrapers/android/5ka` - Запуск Android-скрапинга 5ka
- `POST /api/embeddings/generate` - Генерация embeddings для товаров
- `POST /api/scheduler/jobs` - Создание задачи в планировщике
- `GET /api/scheduler/jobs` - Список задач планировщика
- `GET /api/scheduler/jobs/:id` - Статус конкретной задачи
- `DELETE /api/scheduler/jobs/:id` - Удаление задачи
- `POST /api/scheduler/jobs/:id/trigger` - Ручной запуск задачи
- `GET /api/health` - Health check
- `GET /api/stats` - Статистика скрапинга
4. Конфигурация расписаний обновления
5. Логирование и обработка ошибок
6. CLI для ручного запуска скриптов (как альтернатива API)
### Этап 8: Обработка истории цен
1. Автоматическое создание записей в price_history (уже в Этапе 2)
2. Анализ изменений цен
3. Уведомления о значительных изменениях (опционально)
## Технологический стек
- **Runtime**: Node.js + TypeScript
- **API Scraping**: Axios/Fetch для HTTP запросов (приоритетный метод)
- **Web Scraping**: Playwright (fallback метод)
- **Mobile Automation**: Appium + WebDriverIO
- **Database**: PostgreSQL 15+ с расширением pgvector
- **ORM**: Prisma
- **LLM Integration**: LangChain + OpenAI/Ollama
- **Scheduler**: node-cron
- **HTTP Server**: Express или Fastify для REST API
- **API Documentation**: OpenAPI/Swagger
- **Docker**: для PostgreSQL с pgvector
## Ключевые зависимости
```json
{
"dependencies": {
"axios": "^1.6.2",
"playwright": "^1.40.0",
"webdriverio": "^8.25.0",
"@wdio/appium-service": "^8.25.0",
"pg": "^8.11.0",
"pgvector": "^0.1.8",
"typeorm": "^0.3.17",
"langchain": "^0.1.0",
"@langchain/openai": "^0.0.5",
"langchain-sql": "^0.1.0",
"openai": "^4.20.0",
"node-cron": "^3.0.3",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"swagger-ui-express": "^5.0.0",
"swagger-jsdoc": "^6.2.8"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/node-cron": "^3.0.11",
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/swagger-ui-express": "^4.1.6",
"@types/swagger-jsdoc": "^6.0.4",
"typescript": "^5.3.0",
"ts-node": "^10.9.0"
}
}
```
## API Магнита - Структура данных и обход защиты
### Основной Endpoint: `POST https://magnit.ru/webgate/v2/goods/search`
**Request Body:**
```typescript
{
sort: { order: "desc" | "asc", type: "popularity" | "price" | "name" },
pagination: { limit: number, offset: number },
categories: number[], // ID категорий
includeAdultGoods: boolean,
storeCode: string, // Код магазина (например "992301")
storeType: string, // Тип магазина (например "6")
catalogType: string // Тип каталога (например "1")
}
```
### Дополнительный Endpoint: `GET https://magnit.ru/webgate/v2/goods/{productId}/stores/{storeCode}`
Используется для получения информации о товаре в конкретном магазине:
- **URL**: `/webgate/v2/goods/1000257489/stores/992301?storetype=6&catalogtype=1`
- **Метод**: GET
- **Проблема**: Возвращает 403 Forbidden без правильных заголовков и cookies
**Response Structure (search endpoint):**
```typescript
{
category: { id: number, title: string, fullMatch: boolean },
items: Array<{
id: string,
productId: string,
name: string,
price: number, // Цена в копейках (24999 = 249.99 руб)
promotion?: {
isPromotion: boolean,
oldPrice?: number,
discountPercent?: number,
endDate: string
},
gallery: Array<{ url: string, type: string }>,
ratings: {
rating: number,
scoresCount: number,
commentsCount: number
},
quantity: number, // Остаток на складе
badges: Array<{ text: string, backgroundColor: string }>,
storeCode: string,
// ... другие поля
}>,
fastCategoriesExtended: Array<{ id: number, title: string }>
}
```
### Проблема 403 Forbidden и обход защиты
API Магнита защищен и требует:
1. **Обязательные заголовки:**
- `x-device-id` - Device ID из cookie `mg_udi` (формат UUID: `9D4909B8-9F79-5B1D-8457-EA519EC9AA3F`)
- `x-client-name: magnit`
- `x-device-platform: Web`
- `x-app-version: 2025.12.12-16.56` (актуальная версия)
- `x-new-magnit: true`
- `x-platform-version: Windows Chrome 130`
- `referer: https://magnit.ru/`
- `user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...`
2. **Обязательные cookies:**
- `shopCode=992301`
- `mg_udi` - Device ID (UUID формат)
- `oxxfgh`, `uwyii`, `uwyiert` - сессионные cookies
- `nmg_dt`, `nmg_sp`, `mg_uac`, `mg_adlt` - другие служебные cookies
3. **Заголовки браузера:**
- `accept: */*`
- `accept-language: ru,en-US;q=0.9,en;q=0.8`
- `accept-encoding: gzip, deflate, br, zstd`
- `sec-ch-ua`, `sec-fetch-dest`, `sec-fetch-mode`, `sec-fetch-site`
### Решение: Гибридный подход (Playwright + Axios)
**Вариант 1: Playwright для инициализации сессии, затем Axios для запросов**
```typescript
// Псевдокод реализации
class MagnitApiScraper {
// 1. Инициализация через Playwright
async initialize() {
// Запуск браузера
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
// Переход на главную для получения cookies
await page.goto('https://magnit.ru/', { waitUntil: 'networkidle' });
// Извлечение cookies
cookies = await page.context().cookies();
deviceId = cookies.find(c => c.name === 'mg_udi')?.value;
// Настройка axios с cookies и заголовками
httpClient.defaults.headers.cookie = formatCookies(cookies);
httpClient.defaults.headers['x-device-id'] = deviceId;
// ... остальные заголовки
}
// 2. API запросы через axios
async getProductStores(productId: string, storeCode: string) {
// Автоматическое обновление сессии при 403
if (response.status === 403) {
await this.initialize();
return this.getProductStores(productId, storeCode);
}
}
}
```
**Вариант 2: Использование Playwright Request API (рекомендуется)**
```typescript
// Использование встроенного request API Playwright
async getProductStores(productId: string, storeCode: string) {
// Playwright автоматически передает cookies и контекст браузера
const response = await page.request.get(
`https://magnit.ru/webgate/v2/goods/${productId}/stores/${storeCode}`,
{
params: { storetype: '6', catalogtype: '1' },
headers: {
'accept': '*/*',
'referer': 'https://magnit.ru/',
'x-app-version': '2025.12.12-16.56',
'x-client-name': 'magnit',
'x-device-platform': 'Web',
'x-new-magnit': 'true',
}
}
);
return response.json();
}
```
### Структура реализации в проекте
```typescript
// src/scrapers/api/magnit/MagnitApiScraper.ts
export class MagnitApiScraper extends BaseApiScraper {
private browser: Browser | null = null;
private page: Page | null = null;
private deviceId: string = '';
async initialize() {
// Запуск Playwright, получение cookies и device-id
// Сохранение сессии для переиспользования
}
async searchGoods(params: SearchParams) {
// POST /webgate/v2/goods/search
// Использование page.request.post() или axios с cookies
}
async getProductStores(productId: string, storeCode: string) {
// GET /webgate/v2/goods/{productId}/stores/{storeCode}
// Обработка 403, автоматическое обновление сессии
}
async close() {
// Закрытие браузера
}
}
```
### Важные моменты реализации
- **Инициализация сессии**: Один раз при старте скрапера, переиспользование cookies
- **Обновление сессии**: Автоматическое обновление при получении 403
- **Управление браузером**: Переиспользование одного браузера для всех запросов
- **Rate limiting**: Добавление задержек между запросами (200-500ms)
- **Обработка ошибок**: Retry logic с экспоненциальной задержкой
- **Кэширование**: Сохранение device-id и cookies в файл для переиспользования между запусками
**Важные моменты:**
- Цены в API хранятся в копейках (24999 = 249.99 руб)
- Нужно получать список категорий отдельным запросом или парсить с сайта
- Пагинация через `offset` и `limit`
- Для каждого магазина нужен свой `storeCode`
- **Обязательно использовать Playwright для получения cookies перед API запросами**
## REST API Endpoints для внешних интеграций
### База URL: `http://localhost:3000/api` (или переменная окружения `API_PORT`)
### Endpoints для скрапинга
#### `POST /api/scrapers/api/magnit`
Запуск API-скрапинга Магнита**Request Body:**
```typescript
{
storeCode?: string; // Код магазина (по умолчанию из конфига)
categories?: number[]; // ID категорий для скрапинга
pagination?: { // Параметры пагинации
limit: number;
offset: number;
};
}
```
**Response:**
```typescript
{
jobId: string; // ID задачи
status: "started" | "running" | "completed" | "failed";
startedAt: string;
}
```
#### `POST /api/scrapers/api/5ka`
Запуск API-скрапинга 5ka (аналогичная структура)
#### `POST /api/scrapers/web/magnit`
Запуск веб-скрапинга Магнита через Playwright
#### `POST /api/scrapers/web/5ka`
Запуск веб-скрапинга 5ka через Playwright
#### `POST /api/scrapers/android/magnit`
Запуск Android-скрапинга Магнита через Appium
#### `POST /api/scrapers/android/5ka`
Запуск Android-скрапинга 5ka через Appium
### Endpoints для embeddings
#### `POST /api/embeddings/generate`
Генерация embeddings для товаров**Request Body:**
```typescript
{
productIds?: number[]; // Конкретные товары (если не указано - все)
batchSize?: number; // Размер батча (по умолчанию 100)
force?: boolean; // Перегенерировать существующие
}
```
**Response:**
```typescript
{
jobId: string;
totalProducts: number;
processed: number;
status: string;
}
```
### Endpoints для планировщика
#### `POST /api/scheduler/jobs`
Создание задачи в планировщике**Request Body:**
```typescript
{
name: string; // Название задачи
schedule: string; // Cron выражение (например "0 0 * * *")
taskType: "api_scrape" | "web_scrape" | "android_scrape" | "generate_embeddings";
taskConfig: { // Конфигурация задачи
store: "magnit" | "5ka";
categories?: number[];
// ... другие параметры
};
enabled?: boolean; // Включена ли задача (по умолчанию true)
}
```
#### `GET /api/scheduler/jobs`
Список всех задач планировщика**Response:**
```typescript
{
jobs: Array<{
id: string;
name: string;
schedule: string;
taskType: string;
enabled: boolean;
lastRun?: string;
nextRun?: string;
status: "idle" | "running" | "error";
}>;
}
```
#### `GET /api/scheduler/jobs/:id`
Получить информацию о конкретной задаче
#### `PUT /api/scheduler/jobs/:id`
Обновить задачу (можно изменить schedule, enabled и т.д.)
#### `DELETE /api/scheduler/jobs/:id`
Удалить задачу из планировщика
#### `POST /api/scheduler/jobs/:id/trigger`
Ручной запуск задачи немедленно (вне расписания)**Response:**
```typescript
{
jobId: string;
status: "triggered";
startedAt: string;
}
```
### Утилитарные endpoints
#### `GET /api/health`
Health check endpoint**Response:**
```typescript
{
status: "ok" | "error";
timestamp: string;
database: "connected" | "disconnected";
services: {
scrapers: "available" | "unavailable";
embeddings: "available" | "unavailable";
};
}
```
#### `GET /api/stats`
Статистика скрапинга**Response:**
```typescript
{
totalProducts: number;
totalCategories: number;
lastScrape: {
magnit?: string;
"5ka"?: string;
};
priceHistory: {
totalRecords: number;
lastUpdate: string;
};
}
```
#### `GET /api/jobs/:jobId`
Статус конкретной задачи**Response:**
```typescript
{
jobId: string;
status: "pending" | "running" | "completed" | "failed";
startedAt: string;
finishedAt?: string;
progress?: {
total: number;
processed: number;
percentage: number;
};
error?: string;
result?: any;
}
```
### Интеграция с n8n
Для интеграции с n8n и другими автоматизационными сервисами:
1. **HTTP Request Node** в n8n:
- URL: `http://your-server:3000/api/scrapers/api/magnit`
- Method: POST
- Body: JSON с параметрами
2. **Webhook для обратных вызовов** (опционально):
- `POST /api/webhooks/:webhookId` - регистрация webhook
- При завершении задачи отправка POST запроса на указанный URL
3. **Polling статуса**:
- Использовать `GET /api/jobs/:jobId` для проверки статуса
- Или настроить webhook для уведомлений
### Аутентификация (опционально)
Для защиты API можно добавить:
- API Key authentication через заголовок `X-API-Key`
- JWT токены
- Базовая аутентификация
```typescript
// Пример middleware для API ключа
app.use('/api', (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (apiKey === process.env.API_KEY) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
});
```
## Важные моменты
1. **Приоритет API**: Использовать API как основной метод получения данных для Магнита
2. **Авторизация и обход защиты**:
- **Для API Магнита**: Обязательно использовать Playwright для получения cookies и device-id перед API запросами
- Инициализация сессии через браузер (один раз при старте)
- Переиспользование cookies и device-id для всех запросов
- Автоматическое обновление сессии при получении 403 Forbidden
- Использование `page.request` API Playwright (рекомендуется) или передача cookies в axios
- **Для веба**: Сохранение cookies/session для Playwright
- **Для приложений**: Автоматизация входа через Appium
3. **Обработка ошибок**: Robust error handling для сетевых проблем, изменения API и структуры сайтов
4. **Rate Limiting**: Ограничение частоты запросов чтобы не блокировали (задержки между запросами)
5. **Мониторинг**: Логирование всех операций скрапинга (API запросы, ответы, ошибки)
6. **Масштабируемость**: Архитектура должна позволять легко добавлять новые магазины и методы скрапинга
7. **Единый формат данных**: Нормализация данных из разных источников (API/Web/App) в единую структуру
8. **REST API для интеграций**: Все операции доступны через REST API для интеграции с n8n и другими сервисами
9. **Асинхронная обработка**: Задачи скрапинга выполняются асинхронно, возвращается jobId для отслеживания статуса
10. **Мониторинг задач**: Возможность отслеживать прогресс выполнения задач через API endpoints
// Пример middleware для API ключаapp.use('/api', (req, res, next) => {const apiKey = req.headers['x-api-key'];if (apiKey === process.env.API_KEY) {next();} else {res.status(401).json({ error: 'Unauthorized' });}});
```javascript
## Порядок разработки (Roadmap)
### Фаза 1: Минимальный прототип (MVP)
1. Этап 1: База данных с Prisma - настройка БД
2. Этап 2: API скрапинг Магнита - получение данных и сохранение в БД
3. Этап 3: Embeddings - семантический поиск
### Фаза 2: Расширение функционала
4. Этап 4: Полная реализация API скрапинга
5. Этап 5: Веб и Android скрапинг (опционально)
6. Этап 6: LangChain интеграция
7. Этап 7: Планировщик и REST API
8. Этап 8: Аналитика цен
**Текущий фокус**: Этап 1 (БД) Этап 2 (API + БД) Этап 3 (Embeddings)
## Важные моменты
1. **Приоритет разработки**: Сначала настройка БД (Этап 1), потом API скрапинг с сохранением в БД (Этап 2), затем embeddings (Этап 3). Веб/Android скрапинг - позже
2. **ORM выбор**: Используется Prisma (не TypeORM)
3. **Приоритет API**: Использовать API как основной метод получения данных для Магнита
4. **Авторизация и обход защиты**:
- **Для API Магнита**: Обязательно использовать Playwright для получения cookies и device-id перед API запросами
- Инициализация сессии через браузер (один раз при старте)
- Переиспользование cookies и device-id для всех запросов
- Автоматическое обновление сессии при получении 403 Forbidden
- Использование `page.request` API Playwright (рекомендуется) или передача cookies в axios
- **Для веба**: Сохранение cookies/session для Playwright
- **Для приложений**: Автоматизация входа через Appium
3. **Обработка ошибок**: Robust error handling для сетевых проблем, изменения API и структуры сайтов
4. **Rate Limiting**: Ограничение частоты запросов чтобы не блокировали (задержки между запросами)
5. **Мониторинг**: Логирование всех операций скрапинга (API запросы, ответы, ошибки)
6. **Масштабируемость**: Архитектура должна позволять легко добавлять новые магазины и методы скрапинга
7. **Единый формат данных**: Нормализация данных из разных источников (API/Web/App) в единую структуру
8. **Масштабируемость**: Архитектура должна позволять легко добавлять новые магазины и методы скрапинга
```

View File

@@ -0,0 +1,173 @@
---
globs: .requestly-supermarket/**/*.json
alwaysApply: false
---
This rule provides instructions to generate API tests for Requestly requests:
- The Requestly HTTP requests are stored as JSON files in `.requestly-supermarket/` directory and subdirectories.
- The `name` key shows the name of the request.
- The `request.url` key shows the URL of the request.
- The `request.body` key shows the body of the request. It is a string representation of a JSON body.
- The `request.scripts.postResponse` key holds the API test. It is a string representation of a JavaScript code.
## Test Format
The test format is:
```js
rq.test("Test name", () => {
...
})
```
## Requestly Specific Methods
The tests use Chai.js syntax with Requestly abstraction on top. Requestly provides a few methods on top of Chai.js.
### Assertion
Use `rq.expect` for assertions:
```js
rq.expect(actualValue).to.equal(expectedValue);
```
### Accessing Request and Response Objects
Use `rq.request` and `rq.response` to access the request and response objects. Use `rq.response.body` to access the response body. Convert it to JSON before working with it.
### Checking Statuses
Requestly provides custom status code names for some of the most common status codes. You can use these to check the response status:
```js
// Success responses
rq.response.to.be.ok // 2XX
rq.response.to.be.success // 200
rq.response.to.be.accepted // 202
// Client error responses
rq.response.to.be.badRequest // 400
rq.response.to.be.unauthorized // 401
rq.response.to.be.forbidden // 403
rq.response.to.be.notFound // 404
rq.response.to.be.rateLimited // 429
rq.response.to.be.clientError // 4XX
// Server error responses
rq.response.to.be.serverError // 5XX
// Other status categories
rq.response.to.be.error // 4XX or 5XX
rq.response.to.be.info // 1XX
rq.response.to.be.redirection // 3XX
```
### Checking Response Body
Use `.to.have.body` to check the response body verbatim:
```js
// Checks if response body exactly matches expected value.
rq.response.to.have.body(expectedValue: string)
```
Use `.to.have.jsonBody` to check for the existence of a particular JSON key and its value:
```js
// Checks if a path exists in the response.
rq.response.to.have.jsonBody(path: string)
rq.response.to.have.jsonBody("user.name");
// Checks if a JSON path has a specific value.
rq.response.to.have.jsonBody(path: string, value: any)
rq.response.to.have.jsonBody("user.name", "John");
```
Use `.to.have.jsonSchema` to validate against a JSON schema:
```js
// Validates the response body against a JSON schema. Accepts optional Ajv configuration.
rq.response.to.have.jsonSchema(schema: object, ajvOptions?: AjvOptions)
rq.response.to.have.jsonSchema({
type: "object",
required: ["id", "name"],
properties: {
id: { type: "number" },
name: { type: "string" },
email: { type: "string", format: "email" }
}
}, { allErrors: true });
```
### Environment and Global Variables
Use `rq.environment.get("variable_name")` to get the value of an environment variable. Use `rq.globals.get("variable_name")` to get the value of a global variable. The `.requestly-supermarket/environments/global.json` has a list of all global variables. The other JSON files in the folder hold the different environments. Each key in the `variables` property is the name of the variable.
## Test Categories
The most common tests are:
- Response status check
- Response body check
- Data validation
## Common Test Patterns
### Response Status Check
```js
rq.test("Request is successful", () => {
rq.response.to.be.ok
})
```
### Response Body Check
```js
// Check individual properties
rq.test("Response has property_name",()=>{
var body = JSON.parse(rq.response.body)
rq.expect(body).to.have.property("property_name").that.is.a("type")
})
// Validate against JSON schema
rq.test("Match JSON schema", () => {
rq.response.to.have.jsonSchema({
...
})
})
```
### Data Validation
```js
rq.test("Returns valid data", () => {
rq.expect(body.property_name).to.equal("expected_value")
})
```
## Best Practices
- Group related tests together
- Use descriptive test names
- Test one concept per test
- Include both positive and negative cases
- First, check response status, then check the body, then check data validation
- Test for required fields
- Validate data types
- Check array contents
- Verify nested structures
## Specific to This Project
This project contains API requests for:
- Magnit API endpoints (Russian supermarket chain)
- The API uses POST requests with JSON bodies
- Responses typically include arrays of items with fields like `id`, `name`, `price`, etc.
- Prices are stored in kopecks (e.g., 24999 = 249.99 rubles)
- Responses may include pagination information
- Some endpoints require authentication via headers or cookies

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/supermarket?schema=public"
# Magnit API Configuration
MAGNIT_STORE_CODE="992301"
MAGNIT_STORE_TYPE="6"
MAGNIT_CATALOG_TYPE="1"

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Dependencies
node_modules/
.pnpm-store/
# Environment variables
.env
.env.local
# Build output
dist/
build/
# Prisma
src/database/prisma/migrations/
generated/
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Playwright
test-results/
playwright-report/
playwright/.cache/

View File

@@ -0,0 +1,4 @@
{
"name": "Global Variables",
"variables": {}
}

View File

@@ -0,0 +1,167 @@
{
"name": "Categories",
"request": {
"type": "http",
"url": "https://magnit.ru/webgate/v3/categories/store/996609",
"scripts": {
"preRequest": "",
"postResponse": ""
},
"method": "GET",
"queryParams": [
{
"key": "storetype",
"value": "2",
"id": 0.11631935501020285,
"isEnabled": true
},
{
"key": "catalogtype",
"value": "1",
"id": 0.21212945737858968,
"isEnabled": true
}
],
"headers": [
{
"key": "accept",
"value": "*/*",
"id": 0.9208483398936451,
"isEnabled": true
},
{
"key": "accept-language",
"value": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"id": 0.5782516963515132,
"isEnabled": true
},
{
"key": "baggage",
"value": "sentry-environment=production,sentry-public_key=7992e659d14bf5d8da08d2e6a0706d0a,sentry-trace_id=1bb28815f0b04e50a7f3b7767b42600a,sentry-sampled=false,sentry-sample_rand=0.946176375553128,sentry-sample_rate=0.05",
"id": 0.2529278273373119,
"isEnabled": true
},
{
"key": "connection",
"value": "keep-alive",
"id": 0.6377232633350611,
"isEnabled": true
},
{
"key": "content-type",
"value": "application/json",
"id": 0.3318929332466112,
"isEnabled": true
},
{
"key": "cookie",
"value": "mg_udi=3EFAC6E9-754D-4C65-E333-DFFB3F023A96; oxxfgh=L!a486bb84-5c1a-818f-5a56-6bd0f789e1f6%230%231800000%235000%231800000%2312840; uwyii=9eef69ab-9cdd-bd54-7a88-cd2df2a4fef0; nmg_sp=Y; nmg_dt=DELIVERY_TYPE_OFFLINE; uwyiert=0fd25fde-fb39-1718-1dbe-8c0fde0bc652; shopCode=996609; x_shop_type=GM; nmg_dt=; shopCode=996609; x_shop_type=GM",
"id": 0.4911493705816905,
"isEnabled": true
},
{
"key": "referer",
"value": "https://magnit.ru/",
"id": 0.21189772221788505,
"isEnabled": true
},
{
"key": "sec-ch-ua",
"value": "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
"id": 0.2898431839604667,
"isEnabled": true
},
{
"key": "sec-ch-ua-mobile",
"value": "?0",
"id": 0.08476226485618565,
"isEnabled": true
},
{
"key": "sec-ch-ua-platform",
"value": "\"Windows\"",
"id": 0.0960593554603133,
"isEnabled": true
},
{
"key": "sec-fetch-dest",
"value": "empty",
"id": 0.5934997325716578,
"isEnabled": true
},
{
"key": "sec-fetch-mode",
"value": "cors",
"id": 0.6461661057903885,
"isEnabled": true
},
{
"key": "sec-fetch-site",
"value": "same-origin",
"id": 0.8786371734076515,
"isEnabled": true
},
{
"key": "sentry-trace",
"value": "1bb28815f0b04e50a7f3b7767b42600a-b8a8212e692663f3-0",
"id": 0.7276998757439723,
"isEnabled": true
},
{
"key": "user-agent",
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"id": 0.06166521712603079,
"isEnabled": true
},
{
"key": "x-app-version",
"value": "2025.12.23-18.3",
"id": 0.5297005409846487,
"isEnabled": true
},
{
"key": "x-client-name",
"value": "magnit",
"id": 0.26652309876522895,
"isEnabled": true
},
{
"key": "x-device-id",
"value": "3EFAC6E9-754D-4C65-E333-DFFB3F023A96",
"id": 0.5131162707609229,
"isEnabled": true
},
{
"key": "x-device-platform",
"value": "Web",
"id": 0.2120633056200163,
"isEnabled": true
},
{
"key": "x-device-tag",
"value": "disabled",
"id": 0.20737427473876036,
"isEnabled": true
},
{
"key": "x-new-magnit",
"value": "true",
"id": 0.8026349312399848,
"isEnabled": true
},
{
"key": "x-platform-version",
"value": "Windows Chrome 143",
"id": 0.01777672622574311,
"isEnabled": true
}
],
"body": "",
"bodyContainer": {},
"contentType": "application/json",
"auth": {
"currentAuthType": "INHERIT",
"authConfigStore": {}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "Main catalog",
"request": {
"type": "http",
"url": "https://magnit.ru/webgate/v2/goods/search",
"scripts": {
"preRequest": "",
"postResponse": ""
},
"method": "POST",
"queryParams": [],
"headers": [],
"body": "{\n \"sort\": {\n \"order\": \"desc\",\n \"type\": \"popularity\"\n },\n \"pagination\": {\n \"limit\": 45,\n \"offset\": 0\n },\n \"categories\": [\n 63829\n ],\n \"includeAdultGoods\": true,\n \"storeCode\": \"992301\",\n \"storeType\": \"6\",\n \"catalogType\": \"1\"\n}",
"contentType": "application/json",
"auth": {
"currentAuthType": "INHERIT",
"authConfigStore": {}
},
"pathVariables": []
}
}

View File

@@ -0,0 +1,4 @@
{
"authConfigStore": {},
"currentAuthType": "INHERIT"
}

View File

@@ -0,0 +1,3 @@
{
"version": "0.0.3"
}

286
CLAUDE.md Normal file
View File

@@ -0,0 +1,286 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Supermarket scraper system for Russian supermarkets (Магнит and planned: Пятёрочка/5ka). The system scrapes product data via API (primary method), with planned support for web scraping (Playwright) and Android app scraping (Appium). Data is stored in PostgreSQL with Prisma ORM, with planned integration for pgvector embeddings and LangChain for LLM-based queries.
**Current Status**: MVP phase - API scraping for Magnit is functional. Database and basic scraping infrastructure are complete.
## Development Commands
### Package Manager
This project uses **pnpm** (not npm or yarn).
### Essential Commands
```bash
# Install dependencies
pnpm install
# Install Playwright browsers (required for scraping)
pnpm exec playwright install chromium
# Type checking (no build output)
pnpm type-check
# Build TypeScript to dist/
pnpm build
# Run Magnit API scraper
pnpm dev
# Test database connection
pnpm test-db
```
### Prisma Commands
```bash
# Generate Prisma Client (required after schema changes)
pnpm prisma:generate
# Create and apply migrations
pnpm prisma:migrate
# Open Prisma Studio (database GUI)
pnpm prisma:studio
# Format schema.prisma file
pnpm prisma:format
```
### Database Setup
```bash
# Start PostgreSQL via Docker
docker-compose up -d
# Stop PostgreSQL
docker-compose down
```
### Running Scripts Directly
```bash
# Run scraper with tsx
tsx src/scripts/scrape-magnit-products.ts
# Run with specific store code
MAGNIT_STORE_CODE=992301 tsx src/scripts/scrape-magnit-products.ts
```
## Architecture Overview
### Core Design Pattern
**Layered architecture** with clear separation of concerns:
```
Scripts (orchestration)
Scrapers (data acquisition)
Parser (transformation)
Services (business logic)
Database (persistence via Prisma)
```
### Key Architectural Components
**1. Scrapers** (`src/scrapers/api/magnit/`)
- **MagnitApiScraper**: Main scraper class implementing hybrid Playwright + Axios approach
- **Lifecycle management**: `initialize()``scrapeAllProducts()``saveToDatabase()``close()`
- **Authentication pattern**: Uses Playwright to obtain session cookies and device ID, then makes API requests via Axios
- **Pagination**: Offset-based with 100 items per request, 300ms rate limiting between requests
**2. Services** (`src/services/`)
- **ProductService**: Handles all database persistence with batch operations (50 items per batch)
- **ProductParser**: Transforms API responses to database schema format (price conversion, date parsing, etc.)
- **Pattern**: All services accept PrismaClient via dependency injection
**3. Database** (`src/database/`)
- **Prisma ORM** with PostgreSQL adapter (`@prisma/adapter-pg`)
- **Schema location**: `src/database/prisma/schema.prisma`
- **Models**: Store, Category (hierarchical), Product, ScrapingSession
- **Key constraint**: `(externalId, storeId)` unique for upsert operations
**4. Scripts** (`src/scripts/`)
- **scrape-magnit-products.ts**: Main entry point demonstrating full flow
- **test-db-connection.ts**: Database connection verification
### Data Flow
```
1. MagnitApiScraper.initialize()
- Launch Chromium via Playwright
- Navigate to magnit.ru
- Extract mg_udi cookie as deviceId
- Configure Axios with headers + cookies
2. MagnitApiScraper.scrapeAllProducts()
- POST to /webgate/v2/goods/search
- Paginate with limit=100, offset increments
- Return ProductItem[] array
3. MagnitApiScraper.saveToDatabase()
- Get/create Store via ProductService
- Extract unique categories from products
- Get/create Categories via ProductService
- Parse products via ProductParser
- Batch save via ProductService.saveProducts()
4. Database persistence
- Upsert based on (externalId, storeId)
- Related entities created idempotently
```
## Magnit API Authentication & Anti-Bot Bypass
### Critical Implementation Detail
The Magnit API (`https://magnit.ru/webgate/v2/goods/search`) is **protected** and requires a hybrid approach:
**Hybrid Pattern: Playwright (session init) + Axios (API requests)**
1. **Use Playwright** to visit magnit.ru and obtain:
- `mg_udi` cookie (Device ID in UUID format)
- Other session cookies (`oxxfgh`, `uwyii`, etc.)
2. **Extract required values**:
- `x-device-id` header = `mg_udi` cookie value
- All cookies as Cookie header string
3. **Make API requests via Axios** with:
- Required headers: `x-device-id`, `x-client-name: magnit`, `x-device-platform: Web`, `x-app-version`, `x-new-magnit: true`
- Cookie header from Playwright session
- Standard browser headers (User-Agent, Referer, etc.)
4. **Handle 403 errors**: Automatically re-initialize Playwright session
### API Details
**Endpoint**: `POST https://magnit.ru/webgate/v2/goods/search`
**Request payload**:
```typescript
{
sort: { order: "desc", type: "popularity" },
pagination: { limit: 100, offset: 0 },
categories: [], // Empty = all products
includeAdultGoods: false,
storeCode: "992301", // From env var
storeType: "6",
catalogType: "1"
}
```
**Response structure**:
- `items[]` - array of products
- Prices in **kopecks** (24999 = 249.99 rubles) - must convert to rubles
- Promotion data: `promotion.oldPrice`, `promotion.discountPercent`, `promotion.endDate`
- Ratings: `ratings.rating`, `ratings.scoresCount`, `ratings.commentsCount`
**Rate limiting**: 300ms delay between requests (implemented in scraper)
## Database Schema Patterns
### Upsert Strategy
Products use composite unique constraint `(externalId, storeId)` for idempotent updates:
```typescript
// ProductService automatically handles upsert via Prisma
await prisma.product.upsert({
where: { externalId_storeId: { externalId, storeId } },
update: { /* latest data */ },
create: { /* new product */ }
})
```
### Category Hierarchy
Self-referential relationship via `parentId`:
```prisma
model Category {
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
}
```
### Store Types
Store `type` field indicates data source:
- `"web"` - from web scraping
- `"app"` - from Android app scraping
- API-based stores use `code` field (e.g., "992301")
## Configuration & Environment
### Required Environment Variables
```bash
DATABASE_URL=postgresql://user:password@localhost:5432/supermarket
MAGNIT_STORE_CODE=992301 # Store code for scraping
```
### TypeScript Configuration
- Target: ES2023
- Module: ESNext
- Strict mode enabled
- Output: `dist/` directory
## Planned Features (Not Yet Implemented)
Reference `.cursor/plans/supermarket_scraper_system_1af4ed29.plan.md` for full roadmap.
**Phase 1 (MVP - mostly complete)**:
- ✅ Database setup with Prisma
- ✅ Magnit API scraping with authentication bypass
- ⏳ pgvector embeddings (planned)
**Phase 2 (Future)**:
- Web scraping via Playwright (fallback method)
- Android app scraping via Appium
- Pyaterochka/5ka scraper
- LangChain integration for LLM queries
- REST API server for external integrations (n8n, etc.)
- Scheduler service with cron-like functionality
- Price history tracking and analytics
## Requestly Integration
The project includes Requestly HTTP request testing integration. API test files are stored in `.requestly-supermarket/` directory.
**Key testing patterns** (from `.cursor/rules/requestly-test-rules.mdc`):
- Use `rq.test()` for test definitions
- Use `rq.expect()` for Chai.js-style assertions
- Access response via `rq.response.body` (parse as JSON before use)
- Status checks: `rq.response.to.be.ok`, `rq.response.to.be.success`
- JSON validation: `rq.response.to.have.jsonBody(path, value)`
- Prices stored in kopecks (24999 = 249.99 rubles)
## Important Development Notes
### When Adding New Scrapers
1. Extend base patterns from `src/scrapers/api/magnit/`
2. Implement `initialize()`, `scrapeAllProducts()`, `saveToDatabase()`, `close()` lifecycle
3. Use ProductParser for data transformation
4. Use ProductService for database operations (never call Prisma directly from scrapers)
### When Modifying Database Schema
1. Update `src/database/prisma/schema.prisma`
2. Run `pnpm prisma:generate` to update Prisma Client
3. Run `pnpm prisma:migrate` to create and apply migrations
4. Update related TypeScript types in `src/scrapers/*/types.ts`
### Error Handling Pattern
Custom error classes in `src/utils/errors.ts`:
- `ScraperError` - scraping failures
- `DatabaseError` - database operations
- `APIError` - HTTP/API failures (includes statusCode and response body)
### Logging Pattern
Static logger in `src/utils/logger.ts`:
- `Logger.info()`, `Logger.error()`, `Logger.warn()`, `Logger.debug()`
- Debug messages gated by `DEBUG` environment variable
- ISO timestamp formatting
## Testing
No test framework currently configured. Manual testing via:
- `pnpm test-db` - database connection
- `pnpm dev` - full scraping run
- Prisma Studio - data inspection

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# Supermarket Scraper - Magnit API
Система скрапинга товаров из магазинов Магнит через API.
## Требования
- Node.js 18+
- pnpm
- Docker и Docker Compose (для PostgreSQL)
## Установка
1. Установите зависимости:
```bash
pnpm install
```
2. Установите браузеры для Playwright:
```bash
pnpm exec playwright install chromium
```
3. Настройте переменные окружения:
```bash
cp .env.example .env
```
Отредактируйте `.env` и укажите:
- `DATABASE_URL` - строка подключения к PostgreSQL
- `MAGNIT_STORE_CODE` - код магазина для скрапинга (например, "992301")
4. Запустите PostgreSQL через Docker:
```bash
docker-compose up -d
```
5. Сгенерируйте Prisma Client:
```bash
pnpm prisma:generate
```
6. Создайте миграции Prisma:
```bash
pnpm prisma:migrate
```
## Использование
### Тестирование подключения к БД
```bash
pnpm test-db
```
### Запуск скрапинга
```bash
pnpm dev
```
Или с указанием кода магазина:
```bash
MAGNIT_STORE_CODE=992301 pnpm dev
```
## Структура проекта
```
supermarket/
├── src/
│ ├── config/ # Конфигурация
│ ├── database/ # Prisma схема и клиент
│ ├── scrapers/ # Скраперы API
│ ├── services/ # Сервисы (Product, Parser)
│ ├── scripts/ # CLI скрипты
│ └── utils/ # Утилиты (Logger, Errors)
├── docker-compose.yml # PostgreSQL контейнер
└── package.json
```
## API Магнита
- **Endpoint**: `POST /webgate/v2/goods/search`
- **Защита**: Запрос на каталог работает без защиты, но для детальной информации требуется Playwright сессия
## База данных
Используется PostgreSQL с расширением pgvector (для будущих embeddings).
Модели:
- `Store` - магазины
- `Category` - категории товаров
- `Product` - товары со всеми полями из API
- `ScrapingSession` - сессии скрапинга
## Разработка
### Prisma команды
```bash
# Генерация клиента
pnpm prisma:generate
# Создание миграции
pnpm prisma:migrate
# Открыть Prisma Studio
pnpm prisma:studio
```
### Сборка
```bash
pnpm build
```
## Важные моменты
1. Запрос на каталог работает без защиты - можно использовать обычный HTTP запрос
2. Для получения детальной информации о товарах требуется инициализация сессии через Playwright
3. Все товары получаются без фильтрации по категориям (пустой массив `categories: []`)
4. Rate limiting: задержка 300ms между запросами
## Записки
### Интеграция Requestly с Cursor
Написал нейросеткой по быстрому правило по статье https://requestly.com/blog/how-to-use-cursor-to-generate-api-testcases-in-requestly/
Может быть кривое правило
## todos

423
cursor_forbidden_api.md Normal file
View File

@@ -0,0 +1,423 @@
# Ошибка Forbidden в API Магнит
_Exported on 12/14/2025 at 03:06:00 GMT+5 from Cursor (2.2.20)_
---
**User**
@.cursor/plans/supermarket_scraper_system_1af4ed29.plan.md
Вопрос сейчас только про api магнит
https://magnit.ru/webgate/v2/goods/1000257489/stores/992301?storetype=6&catalogtype=1
ответ Forbidden
Transaction ID: 2b7f613f-b7fe-4f95-9dd4-b30c1f5cb4a5
Хотя в браузере работает у них на сайте
https://magnit.ru/product/1000257489-chay_chernyy_greenfield_english_edition_100pak?shopCode=992301&shopType=6
вот заголовки в браузере
URL запроса:
https://magnit.ru/webgate/v2/goods/1000257489/stores/992301?storetype=6&catalogtype=1
Метод запроса:
GET
Код статуса:
200 OK
Удаленный адрес:
178.248.238.72:443
Правило для URL перехода:
origin
content-encoding:
gzip
content-type:
application/json
date:
Sat, 13 Dec 2025 21:41:24 GMT
document-policy:
js-profiling
referrer-policy:
origin
server:
QRATOR
strict-transport-security:
max-age=31536000; includeSubDomains
vary:
Accept-Encoding,Accept-Encoding
x-content-type-options:
nosniff
x-frame-options:
SAMEORIGIN
x-trace-id:
544850473b9c35d2d1c00538cc1d16c6
x-xss-protection:
1
:authority:
magnit.ru
:method:
GET
:path:
/webgate/v2/goods/1000257489/stores/992301?storetype=6&catalogtype=1
:scheme:
https
accept:
*/*
accept-encoding:
gzip, deflate, br, zstd
accept-language:
ru,en-US;q=0.9,en;q=0.8
baggage:
sentry-environment=production,sentry-public_key=7992e659d14bf5d8da08d2e6a0706d0a,sentry-trace_id=596895b1cc7b4c05935d288b1cc91e8a,sentry-sampled=false,sentry-sample_rand=0.6716192411675364,sentry-sample_rate=0.05
content-type:
application/json
cookie:
nmg_dt=; shopCode=992301; x_shop_type=ME; mg_udi=9D4909B8-9F79-5B1D-8457-EA519EC9AA3F; oxxfgh=ef3934b0-03b7-44fc-b6d8-9b0dd7412ea3%230%237884000000%235000%231800000%2312840; uwyii=b172df84-99bb-4b86-51b5-909db1474363; nmg_sp=Y; mg_uac=1; mg_adlt=Y; uwyiert=65182acc-a917-c963-d7fe-a14c0cab4e8d
dnt:
1
priority:
u=1, i
referer:
https://magnit.ru/
sec-ch-ua:
"Not?A_Brand";v="99", "Chromium";v="130"
sec-ch-ua-mobile:
?0
sec-ch-ua-platform:
"Windows"
sec-fetch-dest:
empty
sec-fetch-mode:
cors
sec-fetch-site:
same-origin
sec-gpc:
1
sentry-trace:
596895b1cc7b4c05935d288b1cc91e8a-b581de5bc3a814d5-0
user-agent:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
x-app-version:
2025.12.12-16.56
x-client-name:
magnit
x-device-id:
9D4909B8-9F79-5B1D-8457-EA519EC9AA3F
x-device-platform:
Web
x-device-tag:
disabled
x-new-magnit:
true
x-platform-version:
Windows Chrome 130
---
**Cursor**
API Магнита возвращает 403, потому что требуется авторизация и корректные заголовки. Анализирую требования и способы их соблюдения:
API Магнита возвращает 403, потому что требуется авторизация и корректные заголовки. По заголовкам из браузера видно, что нужны:
## Проблема
API проверяет:
1. Cookies (сессия)
2. Заголовки, имитирующие браузер
3. Referer и origin
## Решение
### Вариант 1: Использовать Playwright для получения cookies и заголовков
Сначала зайти на сайт через Playwright, получить cookies, затем использовать их в HTTP-запросах:
```typescript
// src/scrapers/api/magnit/MagnitApiScraper.ts
import { chromium, Browser, Page } from 'playwright';
import axios, { AxiosInstance } from 'axios';
export class MagnitApiScraper {
private browser: Browser | null = null;
private page: Page | null = null;
private httpClient: AxiosInstance;
private cookies: string = '';
private deviceId: string = '';
constructor() {
this.httpClient = axios.create({
baseURL: 'https://magnit.ru',
headers: {
'accept': '*/*',
'accept-language': 'ru,en-US;q=0.9,en;q=0.8',
'content-type': 'application/json',
'referer': 'https://magnit.ru/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'x-app-version': '2025.12.12-16.56',
'x-client-name': 'magnit',
'x-device-platform': 'Web',
'x-device-tag': 'disabled',
'x-new-magnit': 'true',
'x-platform-version': 'Windows Chrome 130',
}
});
}
async initialize() {
// Запускаем браузер и получаем cookies
this.browser = await chromium.launch({ headless: true });
this.page = await this.browser.newPage();
// Переходим на главную страницу
await this.page.goto('https://magnit.ru/', { waitUntil: 'networkidle' });
// Получаем cookies
const cookies = await this.page.context().cookies();
this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Извлекаем device-id из cookies
const mgUdiCookie = cookies.find(c => c.name === 'mg_udi');
if (mgUdiCookie) {
this.deviceId = mgUdiCookie.value;
} else {
// Генерируем новый device-id если нет
this.deviceId = this.generateDeviceId();
}
// Обновляем заголовки с device-id
this.httpClient.defaults.headers['x-device-id'] = this.deviceId;
this.httpClient.defaults.headers['cookie'] = this.cookies;
}
async getProductStores(productId: string, storeCode: string = '992301') {
if (!this.page) {
await this.initialize();
}
try {
const response = await this.httpClient.get(
`/webgate/v2/goods/${productId}/stores/${storeCode}`,
{
params: {
storetype: '6',
catalogtype: '1'
}
}
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 403) {
// Если 403, обновляем сессию
await this.initialize();
return this.getProductStores(productId, storeCode);
}
throw error;
}
}
private generateDeviceId(): string {
// Генерация UUID формата: 9D4909B8-9F79-5B1D-8457-EA519EC9AA3F
return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, () => {
return (Math.random() * 16 | 0).toString(16).toUpperCase();
});
}
async close() {
if (this.browser) {
await this.browser.close();
}
}
}
```
### Вариант 2: Использовать Playwright напрямую для API-запросов
Делать запросы через контекст браузера Playwright:
```typescript
async getProductStoresViaBrowser(productId: string, storeCode: string = '992301') {
if (!this.page) {
await this.initialize();
}
// Используем request API Playwright, который автоматически использует cookies браузера
const response = await this.page!.request.get(
`https://magnit.ru/webgate/v2/goods/${productId}/stores/${storeCode}?storetype=6&catalogtype=1`,
{
headers: {
'accept': '*/*',
'referer': 'https://magnit.ru/',
'x-app-version': '2025.12.12-16.56',
'x-client-name': 'magnit',
'x-device-platform': 'Web',
'x-new-magnit': 'true',
}
}
);
if (!response.ok()) {
throw new Error(`API request failed: ${response.status()}`);
}
return await response.json();
}
```
## Важные моменты
1. Обязательные заголовки:
- `x-device-id` — из cookie `mg_udi`
- `x-client-name: magnit`
- `x-device-platform: Web`
- `referer: https://magnit.ru/`
- `cookie` — все cookies из сессии
2. Cookies, которые нужно передавать:
- `shopCode=992301`
- `mg_udi` (device-id)
- `oxxfgh`, `uwyii`, `uwyiert` (сессионные)
3. Рекомендация: использовать Playwright для инициализации сессии и получения cookies, затем делать запросы через `page.request` или передавать cookies в axios.
Нужна полная реализация `MagnitApiScraper` с этой логикой?
```typescript
// src/scrapers/api/magnit/MagnitApiScraper.ts
import { chromium, Browser, Page } from 'playwright';
import axios, { AxiosInstance } from 'axios';
export class MagnitApiScraper {
private browser: Browser | null = null;
private page: Page | null = null;
private httpClient: AxiosInstance;
private cookies: string = '';
private deviceId: string = '';
constructor() {
this.httpClient = axios.create({
baseURL: 'https://magnit.ru',
headers: {
'accept': '*/*',
'accept-language': 'ru,en-US;q=0.9,en;q=0.8',
'content-type': 'application/json',
'referer': 'https://magnit.ru/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'x-app-version': '2025.12.12-16.56',
'x-client-name': 'magnit',
'x-device-platform': 'Web',
'x-device-tag': 'disabled',
'x-new-magnit': 'true',
'x-platform-version': 'Windows Chrome 130',
}
});
}
async initialize() {
// Запускаем браузер и получаем cookies
this.browser = await chromium.launch({ headless: true });
this.page = await this.browser.newPage();
// Переходим на главную страницу
await this.page.goto('https://magnit.ru/', { waitUntil: 'networkidle' });
// Получаем cookies
const cookies = await this.page.context().cookies();
this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Извлекаем device-id из cookies
const mgUdiCookie = cookies.find(c => c.name === 'mg_udi');
if (mgUdiCookie) {
this.deviceId = mgUdiCookie.value;
} else {
// Генерируем новый device-id если нет
this.deviceId = this.generateDeviceId();
}
// Обновляем заголовки с device-id
this.httpClient.defaults.headers['x-device-id'] = this.deviceId;
this.httpClient.defaults.headers['cookie'] = this.cookies;
}
async getProductStores(productId: string, storeCode: string = '992301') {
if (!this.page) {
await this.initialize();
}
try {
const response = await this.httpClient.get(
`/webgate/v2/goods/${productId}/stores/${storeCode}`,
{
params: {
storetype: '6',
catalogtype: '1'
}
}
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 403) {
// Если 403, обновляем сессию
await this.initialize();
return this.getProductStores(productId, storeCode);
}
throw error;
}
}
private generateDeviceId(): string {
// Генерация UUID формата: 9D4909B8-9F79-5B1D-8457-EA519EC9AA3F
return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, () => {
return (Math.random() * 16 | 0).toString(16).toUpperCase();
});
}
async close() {
if (this.browser) {
await this.browser.close();
}
}
}
```
```typescript
async getProductStoresViaBrowser(productId: string, storeCode: string = '992301') {
if (!this.page) {
await this.initialize();
}
// Используем request API Playwright, который автоматически использует cookies браузера
const response = await this.page!.request.get(
`https://magnit.ru/webgate/v2/goods/${productId}/stores/${storeCode}?storetype=6&catalogtype=1`,
{
headers: {
'accept': '*/*',
'referer': 'https://magnit.ru/',
'x-app-version': '2025.12.12-16.56',
'x-client-name': 'magnit',
'x-device-platform': 'Web',
'x-new-magnit': 'true',
}
}
);
if (!response.ok()) {
throw new Error(`API request failed: ${response.status()}`);
}
return await response.json();
}
```
Проверяю структуру проекта и план, чтобы понять текущее состояние и что нужно реализовать:
Проверяю, есть ли уже код для работы с API Магнита. Если нет, предложу решение для обхода 403 Forbidden.

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: supermarket-postgres
restart: unless-stopped
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: supermarket
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d supermarket"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "supermarket-scraper",
"version": "1.0.0",
"description": "Скрапер товаров из магазинов Магнит и Пятёрочка",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"dev": "tsx src/scripts/scrape-magnit-products.ts",
"test-db": "tsx src/scripts/test-db-connection.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio --config=prisma.config.ts",
"prisma:format": "prisma format"
},
"keywords": [
"scraper",
"magnit",
"supermarket"
],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"axios": "^1.13.2",
"dotenv": "^17.2.3",
"pg": "^8.16.3",
"playwright": "^1.57.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/pg": "^8.16.0",
"prisma": "^7.2.0",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

1562
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

12
prisma.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
schema: 'src/database/prisma/schema.prisma',
migrations: {
path: 'src/database/prisma/migrations',
},
datasource: {
url: env('DATABASE_URL'),
},
})

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

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"lib": ["ES2023"],
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowImportingTsExtensions": false,
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "generated"],
"ts-node": {
"esm": true
}
}