From 19c0426cdc7b1f88f4b3cdbce03642aec25fca44 Mon Sep 17 00:00:00 2001 From: Mc Smog Date: Sun, 28 Dec 2025 23:29:30 +0500 Subject: [PATCH] Initial commit: Supermarket scraper MVP --- .claude/settings.local.json | 7 + ...upermarket_scraper_system_1af4ed29.plan.md | 993 +++++++++++ .cursor/rules/requestly-test-rules.mdc | 173 ++ .env.example | 8 + .gitignore | 37 + .../environments/global.json | 4 + .requestly-supermarket/magnit/Categories.json | 167 ++ .../magnit/Main catalog.json | 21 + .requestly-supermarket/magnit/auth.json | 4 + .requestly-supermarket/requestly.json | 3 + CLAUDE.md | 286 +++ README.md | 130 ++ cursor_forbidden_api.md | 423 +++++ docker-compose.yml | 24 + package.json | 40 + pnpm-lock.yaml | 1562 +++++++++++++++++ prisma.config.ts | 12 + src/config/database.ts | 24 + src/database/client.ts | 11 + src/database/prisma/schema.prisma | 103 ++ src/scrapers/api/magnit/MagnitApiScraper.ts | 278 +++ src/scrapers/api/magnit/endpoints.ts | 9 + src/scrapers/api/magnit/types.ts | 80 + src/scripts/scrape-magnit-products.ts | 56 + src/scripts/test-db-connection.ts | 18 + src/services/parser/ProductParser.ts | 84 + src/services/product/ProductService.ts | 208 +++ src/utils/errors.ts | 29 + src/utils/logger.ts | 20 + tsconfig.json | 25 + 30 files changed, 4839 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .cursor/plans/supermarket_scraper_system_1af4ed29.plan.md create mode 100644 .cursor/rules/requestly-test-rules.mdc create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .requestly-supermarket/environments/global.json create mode 100644 .requestly-supermarket/magnit/Categories.json create mode 100644 .requestly-supermarket/magnit/Main catalog.json create mode 100644 .requestly-supermarket/magnit/auth.json create mode 100644 .requestly-supermarket/requestly.json create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 cursor_forbidden_api.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 prisma.config.ts create mode 100644 src/config/database.ts create mode 100644 src/database/client.ts create mode 100644 src/database/prisma/schema.prisma create mode 100644 src/scrapers/api/magnit/MagnitApiScraper.ts create mode 100644 src/scrapers/api/magnit/endpoints.ts create mode 100644 src/scrapers/api/magnit/types.ts create mode 100644 src/scripts/scrape-magnit-products.ts create mode 100644 src/scripts/test-db-connection.ts create mode 100644 src/services/parser/ProductParser.ts create mode 100644 src/services/product/ProductService.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/logger.ts create mode 100644 tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c5ce083 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)" + ] + } +} diff --git a/.cursor/plans/supermarket_scraper_system_1af4ed29.plan.md b/.cursor/plans/supermarket_scraper_system_1af4ed29.plan.md new file mode 100644 index 0000000..4e9b078 --- /dev/null +++ b/.cursor/plans/supermarket_scraper_system_1af4ed29.plan.md @@ -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. **Масштабируемость**: Архитектура должна позволять легко добавлять новые магазины и методы скрапинга + + +``` \ No newline at end of file diff --git a/.cursor/rules/requestly-test-rules.mdc b/.cursor/rules/requestly-test-rules.mdc new file mode 100644 index 0000000..f253b03 --- /dev/null +++ b/.cursor/rules/requestly-test-rules.mdc @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f219b00 --- /dev/null +++ b/.env.example @@ -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" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1aaad2 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/.requestly-supermarket/environments/global.json b/.requestly-supermarket/environments/global.json new file mode 100644 index 0000000..5a2a8bb --- /dev/null +++ b/.requestly-supermarket/environments/global.json @@ -0,0 +1,4 @@ +{ + "name": "Global Variables", + "variables": {} +} \ No newline at end of file diff --git a/.requestly-supermarket/magnit/Categories.json b/.requestly-supermarket/magnit/Categories.json new file mode 100644 index 0000000..bd0ebed --- /dev/null +++ b/.requestly-supermarket/magnit/Categories.json @@ -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": {} + } + } +} \ No newline at end of file diff --git a/.requestly-supermarket/magnit/Main catalog.json b/.requestly-supermarket/magnit/Main catalog.json new file mode 100644 index 0000000..ec1f389 --- /dev/null +++ b/.requestly-supermarket/magnit/Main catalog.json @@ -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": [] + } +} \ No newline at end of file diff --git a/.requestly-supermarket/magnit/auth.json b/.requestly-supermarket/magnit/auth.json new file mode 100644 index 0000000..2209fad --- /dev/null +++ b/.requestly-supermarket/magnit/auth.json @@ -0,0 +1,4 @@ +{ + "authConfigStore": {}, + "currentAuthType": "INHERIT" +} \ No newline at end of file diff --git a/.requestly-supermarket/requestly.json b/.requestly-supermarket/requestly.json new file mode 100644 index 0000000..1a11d92 --- /dev/null +++ b/.requestly-supermarket/requestly.json @@ -0,0 +1,3 @@ +{ + "version": "0.0.3" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9943ec8 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a804a28 --- /dev/null +++ b/README.md @@ -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 diff --git a/cursor_forbidden_api.md b/cursor_forbidden_api.md new file mode 100644 index 0000000..8da3d17 --- /dev/null +++ b/cursor_forbidden_api.md @@ -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. + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01b5b6e --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/package.json b/package.json new file mode 100644 index 0000000..afb6967 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5048d59 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1562 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@prisma/adapter-pg': + specifier: ^7.2.0 + version: 7.2.0 + '@prisma/client': + specifier: ^7.2.0 + version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) + axios: + specifier: ^1.13.2 + version: 1.13.2 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + pg: + specifier: ^8.16.3 + version: 8.16.3 + playwright: + specifier: ^1.57.0 + version: 1.57.0 + devDependencies: + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + prisma: + specifier: ^7.2.0 + version: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@electric-sql/pglite-socket@0.0.6': + resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7': + resolution: {integrity: sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==} + peerDependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': + resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.6': + resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@mrleebo/prisma-ast@0.12.1': + resolution: {integrity: sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==} + engines: {node: '>=16'} + + '@prisma/adapter-pg@7.2.0': + resolution: {integrity: sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w==} + + '@prisma/client-runtime-utils@7.2.0': + resolution: {integrity: sha512-dn7oB53v0tqkB0wBdMuTNFNPdEbfICEUe82Tn9FoKAhJCUkDH+fmyEp0ClciGh+9Hp2Tuu2K52kth2MTLstvmA==} + + '@prisma/client@7.2.0': + resolution: {integrity: sha512-JdLF8lWZ+LjKGKpBqyAlenxd/kXjd1Abf/xK+6vUA7R7L2Suo6AFTHFRpPSdAKCan9wzdFApsUpSa/F6+t1AtA==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@7.2.0': + resolution: {integrity: sha512-qmvSnfQ6l/srBW1S7RZGfjTQhc44Yl3ldvU6y3pgmuLM+83SBDs6UQVgMtQuMRe9J3gGqB0RF8wER6RlXEr6jQ==} + + '@prisma/debug@6.8.2': + resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/dev@0.17.0': + resolution: {integrity: sha512-6sGebe5jxX+FEsQTpjHLzvOGPn6ypFQprcs3jcuIWv1Xp/5v6P/rjfdvAwTkP2iF6pDx2tCd8vGLNWcsWzImTA==} + + '@prisma/driver-adapter-utils@7.2.0': + resolution: {integrity: sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ==} + + '@prisma/engines-version@7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3': + resolution: {integrity: sha512-KezsjCZDsbjNR7SzIiVlUsn9PnLePI7r5uxABlwL+xoerurZTfgQVbIjvjF2sVr3Uc0ZcsnREw3F84HvbggGdA==} + + '@prisma/engines@7.2.0': + resolution: {integrity: sha512-HUeOI/SvCDsHrR9QZn24cxxZcujOjcS3w1oW/XVhnSATAli5SRMOfp/WkG3TtT5rCxDA4xOnlJkW7xkho4nURA==} + + '@prisma/fetch-engine@7.2.0': + resolution: {integrity: sha512-Z5XZztJ8Ap+wovpjPD2lQKnB8nWFGNouCrglaNFjxIWAGWz0oeHXwUJRiclIoSSXN/ptcs9/behptSk8d0Yy6w==} + + '@prisma/get-platform@6.8.2': + resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/query-plan-executor@6.18.0': + resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==} + + '@prisma/studio-core@0.9.0': + resolution: {integrity: sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.10.6: + resolution: {integrity: sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==} + engines: {node: '>=16.9.0'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + engines: {node: '>=0.10.0'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + nypm@0.6.2: + resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + + prisma@7.2.0: + resolution: {integrity: sha512-jSdHWgWOgFF24+nRyyNRVBIgGDQEsMEF8KPHvhBBg3jWyR9fUAK0Nq9ThUmiGlNgq2FA7vSk/ZoCvefod+a8qg==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + + remeda@2.21.3: + resolution: {integrity: sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + zeptomatch@2.0.2: + resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} + +snapshots: + + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite-tools@0.2.7(@electric-sql/pglite@0.3.2)': + dependencies: + '@electric-sql/pglite': 0.3.2 + + '@electric-sql/pglite@0.3.2': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@hono/node-server@1.19.6(hono@4.10.6)': + dependencies: + hono: 4.10.6 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mrleebo/prisma-ast@0.12.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + + '@prisma/adapter-pg@7.2.0': + dependencies: + '@prisma/driver-adapter-utils': 7.2.0 + pg: 8.16.3 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.2.0': {} + + '@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.2.0 + optionalDependencies: + prisma: 7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@7.2.0': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.8.2': {} + + '@prisma/debug@7.2.0': {} + + '@prisma/dev@0.17.0(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.3.2 + '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2) + '@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2) + '@hono/node-server': 1.19.6(hono@4.10.6) + '@mrleebo/prisma-ast': 0.12.1 + '@prisma/get-platform': 6.8.2 + '@prisma/query-plan-executor': 6.18.0 + foreground-child: 3.3.1 + get-port-please: 3.1.2 + hono: 4.10.6 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.21.3 + std-env: 3.9.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.0.2 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/engines-version@7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3': {} + + '@prisma/engines@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + '@prisma/engines-version': 7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3 + '@prisma/fetch-engine': 7.2.0 + '@prisma/get-platform': 7.2.0 + + '@prisma/fetch-engine@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + '@prisma/engines-version': 7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3 + '@prisma/get-platform': 7.2.0 + + '@prisma/get-platform@6.8.2': + dependencies: + '@prisma/debug': 6.8.2 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/query-plan-executor@6.18.0': {} + + '@prisma/studio-core@0.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@types/react': 19.2.7 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@standard-schema/spec@1.1.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + + '@types/pg@8.16.0': + dependencies: + '@types/node': 25.0.3 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + arg@4.1.3: {} + + asynckit@0.4.0: {} + + aws-ssl-profiles@1.1.2: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + confbox@0.2.2: {} + + consola@3.4.2: {} + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + destr@2.0.5: {} + + diff@4.0.2: {} + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + follow-redirects@1.15.11: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-port-please@3.1.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.2 + pathe: 2.0.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grammex@3.1.12: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.10.6: {} + + http-status-codes@2.3.0: {} + + iconv-lite@0.7.1: + dependencies: + safer-buffer: 2.1.2 + + is-property@1.0.2: {} + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + lilconfig@2.1.0: {} + + lodash@4.17.21: {} + + long@5.3.2: {} + + lru.min@1.1.3: {} + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.1 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.3 + + node-fetch-native@1.6.7: {} + + nypm@0.6.2: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + tinyexec: 1.0.2 + + ohash@2.0.11: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + + prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@prisma/config': 7.2.0 + '@prisma/dev': 0.17.0(typescript@5.9.3) + '@prisma/engines': 7.2.0 + '@prisma/studio-core': 0.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-from-env@1.1.0: {} + + pure-rand@6.1.0: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react@19.2.3: {} + + readdirp@4.1.2: {} + + regexp-to-ast@0.5.0: {} + + remeda@2.21.3: + dependencies: + type-fest: 4.41.0 + + resolve-pkg-maps@1.0.0: {} + + retry@0.12.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + seq-queue@0.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + split2@4.2.0: {} + + sqlstring@2.3.3: {} + + std-env@3.9.0: {} + + tinyexec@1.0.2: {} + + ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.0.3 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + v8-compile-cache-lib@3.0.1: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + xtend@4.0.2: {} + + yn@3.1.1: {} + + zeptomatch@2.0.2: + dependencies: + grammex: 3.1.12 diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..ca00b44 --- /dev/null +++ b/prisma.config.ts @@ -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'), + }, +}) \ No newline at end of file diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..d0b3ab4 --- /dev/null +++ b/src/config/database.ts @@ -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('✅ Отключение от базы данных'); +} + diff --git a/src/database/client.ts b/src/database/client.ts new file mode 100644 index 0000000..f347055 --- /dev/null +++ b/src/database/client.ts @@ -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; + diff --git a/src/database/prisma/schema.prisma b/src/database/prisma/schema.prisma new file mode 100644 index 0000000..30e1f55 --- /dev/null +++ b/src/database/prisma/schema.prisma @@ -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]) +} + diff --git a/src/scrapers/api/magnit/MagnitApiScraper.ts b/src/scrapers/api/magnit/MagnitApiScraper.ts new file mode 100644 index 0000000..a45c814 --- /dev/null +++ b/src/scrapers/api/magnit/MagnitApiScraper.ts @@ -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; + + 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 { + 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 { + 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( + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Сохранение товаров в базу данных + */ + async saveToDatabase( + products: ProductItem[], + prisma: PrismaClient + ): Promise { + try { + Logger.info(`Начало сохранения ${products.length} товаров в БД...`); + + const productService = new ProductService(prisma); + + // Получаем или создаем магазин + const store = await productService.getOrCreateStore( + this.config.storeCode, + 'Магнит' + ); + + // Собираем все категории из товаров + const categoryMap = new Map(); + 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 { + 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); + } + } +} + diff --git a/src/scrapers/api/magnit/endpoints.ts b/src/scrapers/api/magnit/endpoints.ts new file mode 100644 index 0000000..160c6c9 --- /dev/null +++ b/src/scrapers/api/magnit/endpoints.ts @@ -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; + diff --git a/src/scrapers/api/magnit/types.ts b/src/scrapers/api/magnit/types.ts new file mode 100644 index 0000000..ce52b26 --- /dev/null +++ b/src/scrapers/api/magnit/types.ts @@ -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[]; +} + diff --git a/src/scripts/scrape-magnit-products.ts b/src/scripts/scrape-magnit-products.ts new file mode 100644 index 0000000..046abbe --- /dev/null +++ b/src/scripts/scrape-magnit-products.ts @@ -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(); + diff --git a/src/scripts/test-db-connection.ts b/src/scripts/test-db-connection.ts new file mode 100644 index 0000000..7c77e76 --- /dev/null +++ b/src/scripts/test-db-connection.ts @@ -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(); + diff --git a/src/services/parser/ProductParser.ts b/src/services/parser/ProductParser.ts new file mode 100644 index 0000000..fd14438 --- /dev/null +++ b/src/services/parser/ProductParser.ts @@ -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 // Map + ): CreateProductData[] { + return items.map(item => { + const categoryId = item.category?.id + ? categoryMap.get(item.category.id) + : undefined; + + return this.parseProductItem(item, storeId, categoryId); + }); + } +} + diff --git a/src/services/product/ProductService.ts b/src/services/product/ProductService.ts new file mode 100644 index 0000000..82e0ea7 --- /dev/null +++ b/src/services/product/ProductService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + ); + } + } +} + diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..1e4ab2c --- /dev/null +++ b/src/utils/errors.ts @@ -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'; + } +} + diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..bf0a9cd --- /dev/null +++ b/src/utils/logger.ts @@ -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); + } + } +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cfe14d3 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} +