move next to web-front

This commit is contained in:
2026-05-17 01:08:19 +03:00
parent cc219cdb26
commit 9e53a7b4fc
52 changed files with 1442 additions and 175 deletions
+1
View File
@@ -1,3 +1,4 @@
registry=https://registry.npmjs.org/
audit=false
fund=false
update-notifier=false
+3 -1
View File
@@ -1,6 +1,8 @@
## 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
+6 -6
View File
@@ -10,9 +10,9 @@ RUN npm install -g npm@11.6.4 \
FROM deps AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY next-env.d.ts next.config.ts tsconfig.json vitest.config.ts ./
COPY src src
COPY public public
COPY tsconfig.json vitest.config.ts ./
COPY web-front web-front
COPY ingestion ingestion
RUN npm run ci:quality
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
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/web-front/.next/standalone ./
COPY --from=build /app/web-front/.next/static ./.next/static
COPY --from=build /app/web-front/public ./public
USER nodeapp
EXPOSE 3000
+325 -155
View File
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -4,17 +4,25 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "next dev web-front",
"build": "next build web-front",
"start": "next start web-front",
"typecheck": "tsc --noEmit",
"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"
},
"dependencies": {
"next": "16.2.4",
"pg": "8.20.0",
"react": "19.2.5",
"react-dom": "19.2.5"
"react-dom": "19.2.5",
"zod": "4.4.2"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.4",
@@ -31,6 +39,7 @@
"@emnapi/runtime": "1.10.0",
"@tailwindcss/postcss": "4.2.4",
"@types/node": "24.10.1",
"@types/pg": "8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.2.4",
+650
View File
@@ -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
View File
@@ -13,6 +13,7 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
@@ -24,18 +25,17 @@
],
"paths": {
"@/*": [
"./src/*"
"./web-front/src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
"web-front/**/*.ts",
"web-front/**/*.tsx",
"ingestion/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"web-front/.next"
]
}
+2 -2
View File
@@ -4,11 +4,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
include: ['web-front/src/**/*.test.ts', 'ingestion/src/**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@': path.resolve(__dirname, 'web-front/src'),
},
},
});
View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+28
View File
@@ -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 path from 'node:path';
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';
type PrimitiveDbSchema = {
videos: Video[];
dashcamCatalog?: DashcamCatalogExport;
dashcamCatalogImportedAt?: string;
};
type MemorySnapshot = {
@@ -64,8 +67,16 @@ async function ensureDbFile(filePath: string) {
function parseSchema(raw: string): PrimitiveDbSchema {
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
const catalog = parsed.dashcamCatalog
? DashcamCatalogExportSchema.safeParse(parsed.dashcamCatalog)
: undefined;
return {
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
...(catalog?.success ? { dashcamCatalog: catalog.data } : {}),
...(typeof parsed.dashcamCatalogImportedAt === 'string'
? { dashcamCatalogImportedAt: parsed.dashcamCatalogImportedAt }
: {}),
};
}
+20
View File
@@ -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"
]
}