move next to web-front
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
audit=false
|
audit=false
|
||||||
fund=false
|
fund=false
|
||||||
update-notifier=false
|
update-notifier=false
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## videoreg.ru
|
## videoreg.ru
|
||||||
|
|
||||||
Small Next.js video registry using TypeScript, App Router, FSD-oriented folders, and a primitive file-backed JSON database.
|
Small video registry using TypeScript, a Next.js web frontend, App Router, FSD-oriented folders, and a primitive file-backed JSON database.
|
||||||
|
|
||||||
|
The Next.js app lives under `web-front/`. Ingestion code lives under `ingestion/`.
|
||||||
|
|
||||||
### Local development
|
### Local development
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ RUN npm install -g npm@11.6.4 \
|
|||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY next-env.d.ts next.config.ts tsconfig.json vitest.config.ts ./
|
COPY tsconfig.json vitest.config.ts ./
|
||||||
COPY src src
|
COPY web-front web-front
|
||||||
COPY public public
|
COPY ingestion ingestion
|
||||||
RUN npm run ci:quality
|
RUN npm run ci:quality
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
@@ -25,9 +25,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp && mkdir -p /data && chown nodeapp:nodeapp /data
|
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp && mkdir -p /data && chown nodeapp:nodeapp /data
|
||||||
|
|
||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/web-front/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/web-front/.next/static ./.next/static
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/web-front/public ./public
|
||||||
|
|
||||||
USER nodeapp
|
USER nodeapp
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
Generated
+325
-155
File diff suppressed because it is too large
Load Diff
+13
-4
@@ -4,17 +4,25 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev web-front",
|
||||||
"build": "next build",
|
"build": "next build web-front",
|
||||||
"start": "next start",
|
"start": "next start web-front",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"catalog:import": "node --experimental-strip-types web-front/scripts/importCatalogExport.ts",
|
||||||
|
"ingestion:migrate": "node --experimental-strip-types ingestion/src/migrate.ts",
|
||||||
|
"ingestion:worker": "node --experimental-strip-types ingestion/src/worker.ts",
|
||||||
|
"ingestion:admin": "node --experimental-strip-types ingestion/src/adminServer.ts",
|
||||||
|
"ingestion:export": "node --experimental-strip-types ingestion/src/exportServer.ts",
|
||||||
|
"ingestion:export-catalog": "node --experimental-strip-types ingestion/src/exportCatalog.ts",
|
||||||
"ci:quality": "npm run typecheck && npm test && npm run build"
|
"ci:quality": "npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"pg": "8.20.0",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-dom": "19.2.5"
|
"react-dom": "19.2.5",
|
||||||
|
"zod": "4.4.2"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.4",
|
"@next/swc-darwin-arm64": "16.2.4",
|
||||||
@@ -31,6 +39,7 @@
|
|||||||
"@emnapi/runtime": "1.10.0",
|
"@emnapi/runtime": "1.10.0",
|
||||||
"@tailwindcss/postcss": "4.2.4",
|
"@tailwindcss/postcss": "4.2.4",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "24.10.1",
|
||||||
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"tailwindcss": "4.2.4",
|
"tailwindcss": "4.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,650 @@
|
|||||||
|
# Спека: Yandex Market Dashcam Catalog Ingestion
|
||||||
|
|
||||||
|
## 1. Цель
|
||||||
|
|
||||||
|
Построить поддерживаемый ingestion engine для каталога автомобильных видеорегистраторов.
|
||||||
|
|
||||||
|
Целевая категория:
|
||||||
|
|
||||||
|
`Автомобильные видеорегистраторы`
|
||||||
|
|
||||||
|
Публичное приложение должно получать только approved canonical catalog:
|
||||||
|
|
||||||
|
* название
|
||||||
|
* бренд
|
||||||
|
* модель
|
||||||
|
* нормализованные характеристики
|
||||||
|
* изображения
|
||||||
|
* source metadata
|
||||||
|
* дату первого обнаружения
|
||||||
|
* дату последнего обновления
|
||||||
|
|
||||||
|
В MVP не парсим и не экспортируем:
|
||||||
|
|
||||||
|
* цены
|
||||||
|
* продавцов
|
||||||
|
* наличие
|
||||||
|
* тексты отзывов
|
||||||
|
* авторов отзывов
|
||||||
|
* review content
|
||||||
|
|
||||||
|
Общий aggregate rating можно сохранять внутри ingestion как source signal, но он не входит в публичный export и не участвует в `stableContentHash`.
|
||||||
|
|
||||||
|
Главная мысль: не "краулер, который пролезет", а каталоговый ingestion engine, который можно поддерживать годами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Границы и запреты
|
||||||
|
|
||||||
|
Не реализовывать:
|
||||||
|
|
||||||
|
* обход rate limiter
|
||||||
|
* маскировку под реальных пользователей
|
||||||
|
* ротацию User-Agent / browser fingerprint
|
||||||
|
* прокси-фермы
|
||||||
|
* headless stealth
|
||||||
|
* обход captcha
|
||||||
|
* агрессивную параллельную загрузку
|
||||||
|
* регулярный browser-based crawl
|
||||||
|
|
||||||
|
В MVP fetcher работает только через обычный HTTP fetch.
|
||||||
|
|
||||||
|
Headless browser допустим только как ручной diagnostic tool для анализа структуры страницы. Он не является fallback в регулярном crawl path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Источники
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
* публичные category pages Яндекс Маркета
|
||||||
|
* публичные product pages
|
||||||
|
* JSON-LD, если доступен в HTML
|
||||||
|
* embedded state, если доступен в HTML
|
||||||
|
* sitemap, но только в ограниченных границах
|
||||||
|
|
||||||
|
### Preferred official fallback
|
||||||
|
|
||||||
|
* Yandex Market Partner API, если доступен по условиям проекта
|
||||||
|
* фиды продавцов, если доступны легально
|
||||||
|
* фиды брендов
|
||||||
|
* официальные сайты производителей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deployment Model
|
||||||
|
|
||||||
|
Ingestion живет отдельно от облачного runtime приложения.
|
||||||
|
|
||||||
|
Код хранится в этом же репозитории:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ingestion/
|
||||||
|
src/
|
||||||
|
migrations/
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Стек ingestion деплоится отдельным `docker-compose.yml`.
|
||||||
|
|
||||||
|
Минимальные сервисы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
postgres
|
||||||
|
ingestion-worker
|
||||||
|
ingestion-admin
|
||||||
|
export-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Основное Next.js-приложение не подключается напрямую к ingestion DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Source of Truth и Bridge
|
||||||
|
|
||||||
|
Source of truth для каталога: PostgreSQL ingestion stack.
|
||||||
|
|
||||||
|
Текущее примитивное хранилище приложения (`data/videoreg.json`) является только read-model cache.
|
||||||
|
|
||||||
|
Bridge работает со стороны приложения:
|
||||||
|
|
||||||
|
1. ingestion публикует versioned HTTPS export;
|
||||||
|
2. приложение вручную запускает operator import command;
|
||||||
|
3. import command скачивает manifest;
|
||||||
|
4. проверяет `schemaVersion`, `exportId`, `createdAt`, `sha256`, `itemCount`;
|
||||||
|
5. скачивает catalog artifact;
|
||||||
|
6. валидирует payload через shared Zod schema;
|
||||||
|
7. атомарно заменяет `data/videoreg.json`.
|
||||||
|
|
||||||
|
В MVP import запускается вручную оператором, не по расписанию.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Export Contract
|
||||||
|
|
||||||
|
Export доступен по HTTPS со статичным токеном:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Токен передается через env import-команды. Токен не передается в query string.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CatalogExportManifest = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
exportId: string;
|
||||||
|
createdAt: string;
|
||||||
|
artifactUrl: string;
|
||||||
|
sha256: string;
|
||||||
|
itemCount: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catalog Artifact
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DashcamCatalogExport = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
exportId: string;
|
||||||
|
createdAt: string;
|
||||||
|
products: ExportedDashcamProduct[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExportedDashcamProduct = {
|
||||||
|
id: string;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
title: string;
|
||||||
|
specs: Record<string, string | number | boolean>;
|
||||||
|
images: ExportedProductImage[];
|
||||||
|
sources: ExportedProductSource[];
|
||||||
|
discoveredAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExportedProductImage = {
|
||||||
|
url: string;
|
||||||
|
originalUrl: string;
|
||||||
|
source: "yandex_market";
|
||||||
|
sha256?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExportedProductSource = {
|
||||||
|
source: "yandex_market";
|
||||||
|
sourceProductId?: string;
|
||||||
|
url: string;
|
||||||
|
stableContentHash: string;
|
||||||
|
fetchedAt: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime validation: shared Zod schema в репозитории. TypeScript-типы без runtime-валидации недостаточны.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Image Policy
|
||||||
|
|
||||||
|
Ingestion кеширует approved images и публикует versioned image artifact URLs.
|
||||||
|
|
||||||
|
Export содержит:
|
||||||
|
|
||||||
|
* URL versioned image artifact
|
||||||
|
* original source URL
|
||||||
|
* source metadata
|
||||||
|
* optional checksum/dimensions
|
||||||
|
|
||||||
|
Приложение не должно hotlink-ить source images напрямую.
|
||||||
|
|
||||||
|
Юридическое решение о хранении и показе изображений должно быть принято отдельно до production-публикации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Crawl Policy
|
||||||
|
|
||||||
|
Нагрузка:
|
||||||
|
|
||||||
|
* 1 запрос за 5-15 секунд
|
||||||
|
* максимум 100-300 страниц в сутки на источник
|
||||||
|
* jitter между запросами
|
||||||
|
* exponential backoff при 429/403/5xx
|
||||||
|
* остановка источника при captcha
|
||||||
|
* отдельный дневной budget на category pages, product pages, sitemap
|
||||||
|
* не скачивать повторно страницы, если stable content не изменился
|
||||||
|
|
||||||
|
HTTP-only fetcher должен явно детектить:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rate_limited
|
||||||
|
blocked
|
||||||
|
captcha
|
||||||
|
not_found
|
||||||
|
parse_failed
|
||||||
|
needs_manual_review
|
||||||
|
```
|
||||||
|
|
||||||
|
При `captcha`, `403`, `429` источник ставится на паузу. Не пытаться обходить ограничение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Discovery Strategy
|
||||||
|
|
||||||
|
Discovery работает sitemap-first, но не обходит широкий sitemap всего Маркета.
|
||||||
|
|
||||||
|
Границы discovery задают:
|
||||||
|
|
||||||
|
* ручные seed category URLs
|
||||||
|
* ручные seed product URL patterns
|
||||||
|
* product URLs, найденные из category HTTP discovery
|
||||||
|
* уже известные product URLs
|
||||||
|
|
||||||
|
Sitemap используется для:
|
||||||
|
|
||||||
|
* diff известных URL
|
||||||
|
* refresh кандидатных URL
|
||||||
|
* обнаружение URL рядом с уже известными границами
|
||||||
|
|
||||||
|
Category pages используются вторично:
|
||||||
|
|
||||||
|
* первая страница категории
|
||||||
|
* пагинация, если доступна без JS
|
||||||
|
* top listings
|
||||||
|
* новые product URLs, если они есть в HTML
|
||||||
|
|
||||||
|
Если category HTML бедный без JS, discovery не должен переходить в regular headless crawl. Такие случаи уходят в `needs_manual_review` или official fallback.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DiscoveredProduct = {
|
||||||
|
source: "yandex_market";
|
||||||
|
categoryUrl?: string;
|
||||||
|
productUrl: string;
|
||||||
|
sourceProductId?: string;
|
||||||
|
titlePreview?: string;
|
||||||
|
discoveredAt: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Fetch Queue
|
||||||
|
|
||||||
|
Очередь реализуется в PostgreSQL, без Redis/BullMQ в MVP.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pending
|
||||||
|
-> fetching
|
||||||
|
-> fetched
|
||||||
|
-> parsed
|
||||||
|
-> normalized
|
||||||
|
-> deduplicated
|
||||||
|
-> needs_review
|
||||||
|
-> approved
|
||||||
|
-> exported
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибочные состояния:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rate_limited
|
||||||
|
blocked
|
||||||
|
captcha
|
||||||
|
not_found
|
||||||
|
parse_failed
|
||||||
|
needs_manual_review
|
||||||
|
```
|
||||||
|
|
||||||
|
Для каждой задачи хранить:
|
||||||
|
|
||||||
|
* source
|
||||||
|
* URL
|
||||||
|
* job type
|
||||||
|
* state
|
||||||
|
* attempts
|
||||||
|
* nextRunAt
|
||||||
|
* lastError
|
||||||
|
* createdAt
|
||||||
|
* updatedAt
|
||||||
|
|
||||||
|
Backoff и daily budgets считаются в PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Parser
|
||||||
|
|
||||||
|
Parser извлекает только разрешенные поля:
|
||||||
|
|
||||||
|
* title
|
||||||
|
* brand
|
||||||
|
* model
|
||||||
|
* specs
|
||||||
|
* images
|
||||||
|
* sourceProductId
|
||||||
|
* aggregate rating как internal-only signal
|
||||||
|
|
||||||
|
Приоритет источников внутри страницы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
JSON-LD > embedded state > semantic DOM > fallback DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
Не извлекать:
|
||||||
|
|
||||||
|
* prices
|
||||||
|
* sellers
|
||||||
|
* availability
|
||||||
|
* review texts
|
||||||
|
* review authors
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ParsedMarketProduct = {
|
||||||
|
source: "yandex_market";
|
||||||
|
sourceProductId?: string;
|
||||||
|
title: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
aggregateRating?: {
|
||||||
|
value: number;
|
||||||
|
scale?: number;
|
||||||
|
ratingCount?: number;
|
||||||
|
};
|
||||||
|
images: string[];
|
||||||
|
specs: Record<string, string | number | boolean>;
|
||||||
|
sourceUrl: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Snapshot и Checksum Model
|
||||||
|
|
||||||
|
Сырой snapshot хранить всегда. Парсер должен уметь переобрабатывать старые страницы.
|
||||||
|
|
||||||
|
Нельзя использовать hash всего HTML как сигнал "страница изменилась": динамическая верстка, цены, рекомендации, счетчики и timestamps будут постоянно шуметь.
|
||||||
|
|
||||||
|
Для каждого snapshot хранить два хэша:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ProductSourceSnapshot = {
|
||||||
|
source: "yandex_market";
|
||||||
|
url: string;
|
||||||
|
fetchedAt: string;
|
||||||
|
httpStatus: number;
|
||||||
|
rawSnapshotHash: string;
|
||||||
|
stableContentHash?: string;
|
||||||
|
rawHtmlPath?: string;
|
||||||
|
parsedJson: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### `rawSnapshotHash`
|
||||||
|
|
||||||
|
Хэш точного raw snapshot для аудита и debug.
|
||||||
|
|
||||||
|
### `stableContentHash`
|
||||||
|
|
||||||
|
Source-specific semantic hash. Для Яндекс Маркета в MVP включать:
|
||||||
|
|
||||||
|
* sourceProductId
|
||||||
|
* canonical product URL
|
||||||
|
* title
|
||||||
|
* brand
|
||||||
|
* model
|
||||||
|
* normalized specs
|
||||||
|
* image identifiers / original image URLs
|
||||||
|
|
||||||
|
Исключать:
|
||||||
|
|
||||||
|
* price
|
||||||
|
* sellers
|
||||||
|
* availability
|
||||||
|
* aggregate rating
|
||||||
|
* review count
|
||||||
|
* timestamps
|
||||||
|
* layout
|
||||||
|
* recommendations
|
||||||
|
* counters
|
||||||
|
* tracking data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Normalization
|
||||||
|
|
||||||
|
Нужно привести хаотичные характеристики к единому виду.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resolution_front": "3840x2160",
|
||||||
|
"channels": 2,
|
||||||
|
"sensor_front": "Sony STARVIS 2",
|
||||||
|
"gps": true,
|
||||||
|
"wifi": true,
|
||||||
|
"screen": true,
|
||||||
|
"parking_mode": true,
|
||||||
|
"memory_card_max_gb": 256,
|
||||||
|
"view_angle_degrees": 140
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Компоненты normalizer:
|
||||||
|
|
||||||
|
* brand dictionary
|
||||||
|
* model cleaner
|
||||||
|
* spec mapper
|
||||||
|
* unit converter
|
||||||
|
* boolean detector
|
||||||
|
* alias dictionary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Deduplication
|
||||||
|
|
||||||
|
Дедупликация работает до модерации. Единица модерации: canonical product, уже сгруппированный из вариантов и source URLs.
|
||||||
|
|
||||||
|
Сравнивать:
|
||||||
|
|
||||||
|
* brand
|
||||||
|
* normalized model name
|
||||||
|
* aliases
|
||||||
|
* sourceProductId
|
||||||
|
* image perceptual hash
|
||||||
|
* specs similarity
|
||||||
|
* product URL history
|
||||||
|
|
||||||
|
Статусы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
new_product
|
||||||
|
same_product
|
||||||
|
possible_duplicate
|
||||||
|
variant
|
||||||
|
oem_clone
|
||||||
|
```
|
||||||
|
|
||||||
|
`possible_duplicate`, `variant`, `oem_clone` не должны попадать в public export без ручного решения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Moderation Admin
|
||||||
|
|
||||||
|
Админка живет внутри отдельного ingestion stack.
|
||||||
|
|
||||||
|
Она работает напрямую с ingestion PostgreSQL, snapshots, очередями и canonical products.
|
||||||
|
|
||||||
|
MVP-экраны:
|
||||||
|
|
||||||
|
* новые canonical products
|
||||||
|
* bulk select
|
||||||
|
* "разрешить все"
|
||||||
|
* товары с `parse_failed`
|
||||||
|
* возможные дубли
|
||||||
|
* конфликтующие характеристики
|
||||||
|
* история изменений
|
||||||
|
* ручное объединение моделей
|
||||||
|
* ручное редактирование canonical specs
|
||||||
|
|
||||||
|
Публикация в export разрешена только после ручной модерации. Bulk approve допустим, но утверждает canonical products, а не отдельные source URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Database
|
||||||
|
|
||||||
|
Минимальная PostgreSQL-модель:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
products
|
||||||
|
product_variants
|
||||||
|
product_sources
|
||||||
|
source_snapshots
|
||||||
|
product_specs
|
||||||
|
product_images
|
||||||
|
source_ratings
|
||||||
|
crawl_jobs
|
||||||
|
crawl_errors
|
||||||
|
moderation_queue
|
||||||
|
export_runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Принципы:
|
||||||
|
|
||||||
|
* raw HTML/JSON snapshots и image artifacts хранить на mounted volume
|
||||||
|
* в PostgreSQL хранить пути, checksum, metadata и связи
|
||||||
|
* уникальность по `source + url`
|
||||||
|
* ingestion DB является source of truth
|
||||||
|
* `data/videoreg.json` приложения является replaceable read-model cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Sync Schedule
|
||||||
|
|
||||||
|
### Частый легкий sync
|
||||||
|
|
||||||
|
Каждые 6-12 часов:
|
||||||
|
|
||||||
|
* первая страница категории
|
||||||
|
* sitemap diff в пределах известных границ
|
||||||
|
* новые product URLs
|
||||||
|
* изменение top listings, если доступно без JS
|
||||||
|
|
||||||
|
### Глубокий sync
|
||||||
|
|
||||||
|
Раз в 7-14 дней:
|
||||||
|
|
||||||
|
* все известные карточки
|
||||||
|
* характеристики
|
||||||
|
* изображения
|
||||||
|
* source metadata
|
||||||
|
|
||||||
|
### New product detection
|
||||||
|
|
||||||
|
Каждый день:
|
||||||
|
|
||||||
|
* category diff
|
||||||
|
* bounded sitemap diff
|
||||||
|
* known model aliases search
|
||||||
|
* official fallback feeds, если доступны
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. MVP Phases
|
||||||
|
|
||||||
|
### Phase 1 - Legal discovery
|
||||||
|
|
||||||
|
* проверить robots.txt
|
||||||
|
* проверить sitemap
|
||||||
|
* проверить доступность API/фидов
|
||||||
|
* описать crawl budget
|
||||||
|
* описать allowed URL patterns
|
||||||
|
* зафиксировать image storage policy
|
||||||
|
|
||||||
|
### Phase 2 - Shared contract
|
||||||
|
|
||||||
|
* добавить shared Zod schema для manifest/catalog export
|
||||||
|
* добавить TypeScript-типы из схем
|
||||||
|
* описать `schemaVersion`
|
||||||
|
* добавить app import command
|
||||||
|
* заменить `data/videoreg.json` validated catalog payload
|
||||||
|
|
||||||
|
### Phase 3 - Ingestion stack skeleton
|
||||||
|
|
||||||
|
* создать `ingestion/`
|
||||||
|
* добавить `docker-compose.yml`
|
||||||
|
* поднять PostgreSQL
|
||||||
|
* добавить migrations
|
||||||
|
* добавить mounted volume для snapshots/images
|
||||||
|
* добавить healthcheck
|
||||||
|
|
||||||
|
### Phase 4 - Queue and fetcher
|
||||||
|
|
||||||
|
* реализовать PostgreSQL-backed queue
|
||||||
|
* реализовать HTTP-only fetcher
|
||||||
|
* добавить rate limits, retry, backoff
|
||||||
|
* stop on captcha / 403 / 429
|
||||||
|
* сохранять raw snapshots
|
||||||
|
* считать `rawSnapshotHash`
|
||||||
|
|
||||||
|
### Phase 5 - Parser
|
||||||
|
|
||||||
|
* category parser
|
||||||
|
* bounded sitemap discovery
|
||||||
|
* product parser
|
||||||
|
* specs parser
|
||||||
|
* image parser
|
||||||
|
* aggregate rating parser as internal-only
|
||||||
|
* source-specific `stableContentHash`
|
||||||
|
|
||||||
|
### Phase 6 - Normalizer
|
||||||
|
|
||||||
|
* brand dictionary
|
||||||
|
* model cleaner
|
||||||
|
* spec mapper
|
||||||
|
* unit converter
|
||||||
|
* boolean detector
|
||||||
|
|
||||||
|
### Phase 7 - Deduplication and moderation
|
||||||
|
|
||||||
|
* exact match
|
||||||
|
* fuzzy match
|
||||||
|
* alias match
|
||||||
|
* canonical product grouping
|
||||||
|
* moderation queue
|
||||||
|
* bulk approve
|
||||||
|
|
||||||
|
### Phase 8 - Export and import
|
||||||
|
|
||||||
|
* publish versioned manifest
|
||||||
|
* publish versioned catalog artifact
|
||||||
|
* publish versioned image artifacts
|
||||||
|
* protect endpoints with Bearer token
|
||||||
|
* verify checksum in app import
|
||||||
|
* atomic replace of `data/videoreg.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Definition of Done
|
||||||
|
|
||||||
|
Система считается готовой, если:
|
||||||
|
|
||||||
|
* ingestion stack запускается отдельно через `docker-compose.yml`
|
||||||
|
* PostgreSQL является source of truth
|
||||||
|
* приложение импортирует только approved canonical catalog
|
||||||
|
* `data/videoreg.json` заменяется только после manifest/checksum/schema validation
|
||||||
|
* crawler не создает заметной нагрузки на источник
|
||||||
|
* crawler останавливается при ограничениях
|
||||||
|
* crawler не использует regular headless/browser stealth
|
||||||
|
* raw snapshots хранятся всегда
|
||||||
|
* есть `rawSnapshotHash` и source-specific `stableContentHash`
|
||||||
|
* старые snapshots можно переобработать новым parser
|
||||||
|
* дубли попадают в moderation queue
|
||||||
|
* bulk approve работает по canonical products
|
||||||
|
* public export не содержит цены, продавцов, наличие и тексты отзывов
|
||||||
|
* public export не содержит aggregate rating в MVP
|
||||||
|
* approved images отдаются как versioned image artifacts
|
||||||
+7
-7
@@ -13,6 +13,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
@@ -24,18 +25,17 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./web-front/src/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"web-front/**/*.ts",
|
||||||
"**/*.ts",
|
"web-front/**/*.tsx",
|
||||||
"**/*.tsx",
|
"ingestion/**/*.ts"
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules",
|
||||||
|
"web-front/.next"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,11 +4,11 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['web-front/src/**/*.test.ts', 'ingestion/src/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'web-front/src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
import { importCatalogExport } from '../src/entities/dashcam-catalog/api/importCatalogExport.ts';
|
||||||
|
|
||||||
|
const manifestUrl = process.env.DASHCAM_CATALOG_MANIFEST_URL ?? process.argv[2];
|
||||||
|
const token = process.env.DASHCAM_CATALOG_EXPORT_TOKEN;
|
||||||
|
|
||||||
|
if (!manifestUrl) {
|
||||||
|
throw new Error('Set DASHCAM_CATALOG_MANIFEST_URL or pass manifest URL as the first argument');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Set DASHCAM_CATALOG_EXPORT_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importCatalogExport({ manifestUrl, token });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
status: 'imported',
|
||||||
|
exportId: result.exportId,
|
||||||
|
itemCount: result.itemCount,
|
||||||
|
sha256: result.sha256,
|
||||||
|
dbFilePath: result.dbFilePath,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { importCatalogExport } from './importCatalogExport';
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown) {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('importCatalogExport', () => {
|
||||||
|
it('validates manifest, checksum and atomically updates the primitive read model', async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'videoreg-catalog-import-'));
|
||||||
|
const dbFilePath = path.join(tempDir, 'videoreg.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const catalog = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportId: 'export_1',
|
||||||
|
createdAt: '2026-05-16T21:00:00.000Z',
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'ibox-f5',
|
||||||
|
brand: 'iBOX',
|
||||||
|
model: 'F5 WiFi',
|
||||||
|
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
|
||||||
|
specs: {
|
||||||
|
channels: 1,
|
||||||
|
wifi: true,
|
||||||
|
},
|
||||||
|
images: [],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
source: 'yandex_market',
|
||||||
|
sourceProductId: '5197387201',
|
||||||
|
url: 'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
|
||||||
|
stableContentHash: 'stable-hash',
|
||||||
|
fetchedAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
discoveredAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const catalogBytes = Buffer.from(JSON.stringify(catalog));
|
||||||
|
const digest = createHash('sha256').update(catalogBytes).digest('hex');
|
||||||
|
const fetcher = async (input: string | URL) => {
|
||||||
|
const url = input.toString();
|
||||||
|
|
||||||
|
if (url === 'https://exports.example.test/manifest.json') {
|
||||||
|
return jsonResponse({
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportId: 'export_1',
|
||||||
|
createdAt: '2026-05-16T21:00:00.000Z',
|
||||||
|
artifactUrl: 'https://exports.example.test/catalog.json',
|
||||||
|
sha256: digest,
|
||||||
|
itemCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === 'https://exports.example.test/catalog.json') {
|
||||||
|
return jsonResponse(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await importCatalogExport({
|
||||||
|
manifestUrl: 'https://exports.example.test/manifest.json',
|
||||||
|
token: 'secret',
|
||||||
|
dbFilePath,
|
||||||
|
fetcher,
|
||||||
|
now: new Date('2026-05-16T22:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const stored = JSON.parse(await readFile(dbFilePath, 'utf8')) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
exportId: 'export_1',
|
||||||
|
itemCount: 1,
|
||||||
|
sha256: digest,
|
||||||
|
});
|
||||||
|
expect(stored.dashcamCatalog).toEqual(catalog);
|
||||||
|
expect(stored.dashcamCatalogImportedAt).toBe('2026-05-16T22:00:00.000Z');
|
||||||
|
expect(stored.videos).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
parseCatalogExportManifest,
|
||||||
|
parseDashcamCatalogExport,
|
||||||
|
type DashcamCatalogExport,
|
||||||
|
} from '../model/schema.ts';
|
||||||
|
|
||||||
|
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
type PrimitiveDbCache = {
|
||||||
|
videos?: unknown[];
|
||||||
|
dashcamCatalog?: DashcamCatalogExport;
|
||||||
|
dashcamCatalogImportedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportCatalogExportOptions = {
|
||||||
|
manifestUrl: string;
|
||||||
|
token: string;
|
||||||
|
dbFilePath?: string;
|
||||||
|
fetcher?: FetchLike;
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportCatalogExportResult = {
|
||||||
|
exportId: string;
|
||||||
|
itemCount: number;
|
||||||
|
sha256: string;
|
||||||
|
dbFilePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDefaultDbFilePath() {
|
||||||
|
if (process.env.VIDEOREG_DB_FILE) {
|
||||||
|
return process.env.VIDEOREG_DB_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(process.cwd(), 'data', 'videoreg.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer: Buffer) {
|
||||||
|
return createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readExistingDb(filePath: string): Promise<PrimitiveDbCache> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as PrimitiveDbCache;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return { videos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomically(filePath: string, payload: PrimitiveDbCache) {
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
|
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
await rename(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsonBytes(fetcher: FetchLike, url: string, token: string) {
|
||||||
|
const response = await fetcher(url, {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Catalog export request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(buffer: Buffer, label: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(buffer.toString('utf8')) as unknown;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid ${label} JSON: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCatalogExport(options: ImportCatalogExportOptions): Promise<ImportCatalogExportResult> {
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const dbFilePath = options.dbFilePath ?? getDefaultDbFilePath();
|
||||||
|
const importedAt = (options.now ?? new Date()).toISOString();
|
||||||
|
|
||||||
|
const manifestBytes = await fetchJsonBytes(fetcher, options.manifestUrl, options.token);
|
||||||
|
const manifest = parseCatalogExportManifest(parseJson(manifestBytes, 'manifest'));
|
||||||
|
const artifactBytes = await fetchJsonBytes(fetcher, manifest.artifactUrl, options.token);
|
||||||
|
const artifactHash = sha256(artifactBytes);
|
||||||
|
|
||||||
|
if (artifactHash.toLowerCase() !== manifest.sha256.toLowerCase()) {
|
||||||
|
throw new Error(`Catalog artifact checksum mismatch: expected ${manifest.sha256}, got ${artifactHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = parseDashcamCatalogExport(parseJson(artifactBytes, 'catalog artifact'));
|
||||||
|
|
||||||
|
if (catalog.exportId !== manifest.exportId) {
|
||||||
|
throw new Error(`Catalog exportId mismatch: manifest=${manifest.exportId}, artifact=${catalog.exportId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.createdAt !== manifest.createdAt) {
|
||||||
|
throw new Error(`Catalog createdAt mismatch: manifest=${manifest.createdAt}, artifact=${catalog.createdAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.products.length !== manifest.itemCount) {
|
||||||
|
throw new Error(`Catalog itemCount mismatch: manifest=${manifest.itemCount}, artifact=${catalog.products.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await readExistingDb(dbFilePath);
|
||||||
|
await writeJsonAtomically(dbFilePath, {
|
||||||
|
...current,
|
||||||
|
dashcamCatalog: catalog,
|
||||||
|
dashcamCatalogImportedAt: importedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportId: manifest.exportId,
|
||||||
|
itemCount: manifest.itemCount,
|
||||||
|
sha256: artifactHash,
|
||||||
|
dbFilePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DashcamCatalogExportSchema } from './schema';
|
||||||
|
|
||||||
|
const baseProduct = {
|
||||||
|
id: 'ibox-f5',
|
||||||
|
brand: 'iBOX',
|
||||||
|
model: 'F5 WiFi',
|
||||||
|
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
|
||||||
|
specs: {
|
||||||
|
channels: 1,
|
||||||
|
wifi: true,
|
||||||
|
resolution_front: '2304x1296',
|
||||||
|
},
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://cdn.example.test/images/ibox-f5/orig.jpg',
|
||||||
|
originalUrl: 'https://avatars.mds.yandex.net/get-mpic/18787582/2a/orig',
|
||||||
|
source: 'yandex_market',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
source: 'yandex_market',
|
||||||
|
sourceProductId: '5197387201',
|
||||||
|
url: 'https://market.yandex.ru/card/ibox-f5-wifi-videoregistrator-s-signaturnym-radar-detektorom/5197387201',
|
||||||
|
stableContentHash: 'abc123',
|
||||||
|
fetchedAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
discoveredAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-16T20:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('dashcam catalog export schema', () => {
|
||||||
|
it('accepts approved canonical catalog payload', () => {
|
||||||
|
expect(
|
||||||
|
DashcamCatalogExportSchema.parse({
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportId: 'export_1',
|
||||||
|
createdAt: '2026-05-16T21:00:00.000Z',
|
||||||
|
products: [baseProduct],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportId: 'export_1',
|
||||||
|
createdAt: '2026-05-16T21:00:00.000Z',
|
||||||
|
products: [baseProduct],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects internal-only aggregate rating in public export', () => {
|
||||||
|
expect(() =>
|
||||||
|
DashcamCatalogExportSchema.parse({
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportId: 'export_1',
|
||||||
|
createdAt: '2026-05-16T21:00:00.000Z',
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
...baseProduct,
|
||||||
|
aggregateRating: {
|
||||||
|
value: 4.8,
|
||||||
|
ratingCount: 17,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const isoDateTime = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
|
||||||
|
message: 'Expected an ISO-compatible date-time string',
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportedSpecValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
||||||
|
|
||||||
|
export const ExportedProductImageSchema = z
|
||||||
|
.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
originalUrl: z.string().url(),
|
||||||
|
source: z.literal('yandex_market'),
|
||||||
|
sha256: z.string().min(1).optional(),
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ExportedProductSourceSchema = z
|
||||||
|
.object({
|
||||||
|
source: z.literal('yandex_market'),
|
||||||
|
sourceProductId: z.string().min(1).optional(),
|
||||||
|
url: z.string().url(),
|
||||||
|
stableContentHash: z.string().min(1),
|
||||||
|
fetchedAt: isoDateTime,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ExportedDashcamProductSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
brand: z.string().min(1),
|
||||||
|
model: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
specs: z.record(z.string().min(1), exportedSpecValueSchema),
|
||||||
|
images: z.array(ExportedProductImageSchema),
|
||||||
|
sources: z.array(ExportedProductSourceSchema).min(1),
|
||||||
|
discoveredAt: isoDateTime,
|
||||||
|
updatedAt: isoDateTime,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const DashcamCatalogExportSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
exportId: z.string().min(1),
|
||||||
|
createdAt: isoDateTime,
|
||||||
|
products: z.array(ExportedDashcamProductSchema),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const CatalogExportManifestSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
exportId: z.string().min(1),
|
||||||
|
createdAt: isoDateTime,
|
||||||
|
artifactUrl: z.string().url(),
|
||||||
|
sha256: z.string().regex(/^[a-f0-9]{64}$/i),
|
||||||
|
itemCount: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CatalogExportManifest = z.infer<typeof CatalogExportManifestSchema>;
|
||||||
|
export type DashcamCatalogExport = z.infer<typeof DashcamCatalogExportSchema>;
|
||||||
|
export type ExportedDashcamProduct = z.infer<typeof ExportedDashcamProductSchema>;
|
||||||
|
export type ExportedProductImage = z.infer<typeof ExportedProductImageSchema>;
|
||||||
|
export type ExportedProductSource = z.infer<typeof ExportedProductSourceSchema>;
|
||||||
|
export type ExportedSpecValue = z.infer<typeof exportedSpecValueSchema>;
|
||||||
|
|
||||||
|
export function parseCatalogExportManifest(input: unknown) {
|
||||||
|
return CatalogExportManifestSchema.parse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDashcamCatalogExport(input: unknown) {
|
||||||
|
return DashcamCatalogExportSchema.parse(input);
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import 'server-only';
|
|||||||
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DB_MEMORY_TTL_MS } from '@/shared/config/env';
|
import { DB_MEMORY_TTL_MS } from '@/shared/config/env';
|
||||||
|
import { DashcamCatalogExportSchema, type DashcamCatalogExport } from '@/entities/dashcam-catalog/model/schema';
|
||||||
import type { Video } from '@/entities/video/model/types';
|
import type { Video } from '@/entities/video/model/types';
|
||||||
|
|
||||||
type PrimitiveDbSchema = {
|
type PrimitiveDbSchema = {
|
||||||
videos: Video[];
|
videos: Video[];
|
||||||
|
dashcamCatalog?: DashcamCatalogExport;
|
||||||
|
dashcamCatalogImportedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MemorySnapshot = {
|
type MemorySnapshot = {
|
||||||
@@ -64,8 +67,16 @@ async function ensureDbFile(filePath: string) {
|
|||||||
|
|
||||||
function parseSchema(raw: string): PrimitiveDbSchema {
|
function parseSchema(raw: string): PrimitiveDbSchema {
|
||||||
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
|
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
|
||||||
|
const catalog = parsed.dashcamCatalog
|
||||||
|
? DashcamCatalogExportSchema.safeParse(parsed.dashcamCatalog)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
|
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
|
||||||
|
...(catalog?.success ? { dashcamCatalog: catalog.data } : {}),
|
||||||
|
...(typeof parsed.dashcamCatalogImportedAt === 'string'
|
||||||
|
? { dashcamCatalogImportedAt: parsed.dashcamCatalogImportedAt }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user