feat: complete dashcam catalog ingestion demo
Master Production Deploy / production (push) Failing after 49s
Master Production Deploy / production (push) Failing after 49s
This commit is contained in:
@@ -1,17 +1,12 @@
|
||||
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;
|
||||
const exportDir = process.env.DASHCAM_CATALOG_EXPORT_DIR ?? process.argv[2];
|
||||
|
||||
if (!manifestUrl) {
|
||||
throw new Error('Set DASHCAM_CATALOG_MANIFEST_URL or pass manifest URL as the first argument');
|
||||
if (!exportDir) {
|
||||
throw new Error('Set DASHCAM_CATALOG_EXPORT_DIR or pass the shared export directory as the first argument');
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Set DASHCAM_CATALOG_EXPORT_TOKEN');
|
||||
}
|
||||
|
||||
const result = await importCatalogExport({ manifestUrl, token });
|
||||
const result = await importCatalogExport({ exportDir });
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -365,6 +365,216 @@ th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-page {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 56px;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.catalog-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.catalog-header__eyebrow,
|
||||
.catalog-product__brand {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.catalog-header__subtitle {
|
||||
max-width: 46rem;
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.catalog-header__meta {
|
||||
display: grid;
|
||||
min-width: 126px;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.catalog-header__meta strong {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.catalog-header__meta span,
|
||||
.catalog-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.catalog-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.catalog-status span + span::before {
|
||||
content: '•';
|
||||
margin-right: 16px;
|
||||
color: var(--border-strong);
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.catalog-product {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.catalog-product__media {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 182px;
|
||||
background: var(--surface-inverse);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.catalog-product__media img {
|
||||
width: 100%;
|
||||
max-height: 132px;
|
||||
object-fit: contain;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.catalog-product__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.catalog-product__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.catalog-product h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.catalog-product__source-count {
|
||||
flex: none;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 6px;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.catalog-product__title {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.catalog-product__specs,
|
||||
.catalog-product__details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalog-product__specs div,
|
||||
.catalog-product__details dl div {
|
||||
min-width: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.catalog-product__specs dt,
|
||||
.catalog-product__details dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.catalog-product__specs dd,
|
||||
.catalog-product__details dd {
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.catalog-product__details summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.catalog-product__details dl {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.catalog-product__sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.catalog-product__sources a {
|
||||
border-bottom: 1px solid var(--accent-soft-border);
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.catalog-empty {
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-1);
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-empty h2,
|
||||
.catalog-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.catalog-empty p {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar,
|
||||
.workbench {
|
||||
@@ -383,6 +593,10 @@ th {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
@@ -400,6 +614,36 @@ th {
|
||||
.video-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-page {
|
||||
width: min(100% - 20px, 1180px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.catalog-header__meta {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
|
||||
.catalog-product {
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.catalog-product__media {
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.catalog-product__specs,
|
||||
.catalog-product__details dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Script from 'next/script';
|
||||
import './globals.css';
|
||||
|
||||
const YANDEX_METRIKA_ID = 109268682;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Video Registry',
|
||||
description: 'Operational video registry with primitive storage and aggressive caching.',
|
||||
title: 'videoreg.ru - автомобильные видеорегистраторы',
|
||||
description: 'Каталог автомобильных видеорегистраторов из одобренных source cards.',
|
||||
};
|
||||
|
||||
type RootLayoutProps = {
|
||||
@@ -12,8 +15,31 @@ type RootLayoutProps = {
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html id="top" lang="en">
|
||||
<body>{children}</body>
|
||||
<html id="top" lang="ru">
|
||||
<body>
|
||||
{children}
|
||||
<Script id="yandex-metrika" strategy="afterInteractive">
|
||||
{`
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=${YANDEX_METRIKA_ID}', 'ym');
|
||||
|
||||
ym(${YANDEX_METRIKA_ID}, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
`}
|
||||
</Script>
|
||||
<noscript>
|
||||
<div>
|
||||
<img
|
||||
src={`https://mc.yandex.ru/watch/${YANDEX_METRIKA_ID}`}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-20
@@ -1,27 +1,17 @@
|
||||
import { getCachedVideoDashboard } from '@/entities/video/api/videoCache';
|
||||
import { RegisterVideoForm } from '@/features/register-video/ui/RegisterVideoForm';
|
||||
import { CachePanel } from '@/widgets/cache-panel/ui/CachePanel';
|
||||
import { VideoDashboard } from '@/widgets/video-dashboard/ui/VideoDashboard';
|
||||
import { readDashcamCatalog } from '@/entities/dashcam-catalog/api/catalogRepository';
|
||||
import { DashcamCatalog } from '@/widgets/dashcam-catalog/ui/DashcamCatalog';
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Home() {
|
||||
const dashboard = await getCachedVideoDashboard();
|
||||
const catalog = await readDashcamCatalog();
|
||||
|
||||
return (
|
||||
<main className="page-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">videoreg.ru</p>
|
||||
<h1>Video Registry</h1>
|
||||
</div>
|
||||
<CachePanel metadata={dashboard.cache} />
|
||||
</header>
|
||||
|
||||
<div className="workbench">
|
||||
<RegisterVideoForm />
|
||||
<VideoDashboard dashboard={dashboard} />
|
||||
</div>
|
||||
</main>
|
||||
<DashcamCatalog
|
||||
catalog={catalog.catalog}
|
||||
exportInfo={catalog.export}
|
||||
hasSharedExportIssue={catalog.error === 'shared_export_unavailable'}
|
||||
source={catalog.source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'server-only';
|
||||
|
||||
import { readPrimitiveDb } from '@/shared/db/primitiveJsonDb';
|
||||
import {
|
||||
readCatalogExport,
|
||||
type ReadCatalogExportResult,
|
||||
} from './importCatalogExport';
|
||||
import type { DashcamCatalogExport } from '../model/schema';
|
||||
|
||||
export type DashcamCatalogReadModel = {
|
||||
catalog?: DashcamCatalogExport;
|
||||
error?: 'shared_export_unavailable';
|
||||
source: 'shared_export' | 'read_model' | 'empty';
|
||||
export?: Pick<ReadCatalogExportResult, 'createdAt' | 'exportId' | 'itemCount' | 'sha256'>;
|
||||
};
|
||||
|
||||
export async function readDashcamCatalog(): Promise<DashcamCatalogReadModel> {
|
||||
const exportDir = process.env.DASHCAM_CATALOG_EXPORT_DIR;
|
||||
|
||||
if (exportDir) {
|
||||
try {
|
||||
const result = await readCatalogExport({ exportDir });
|
||||
|
||||
return {
|
||||
catalog: result.catalog,
|
||||
source: 'shared_export',
|
||||
export: {
|
||||
createdAt: result.createdAt,
|
||||
exportId: result.exportId,
|
||||
itemCount: result.itemCount,
|
||||
sha256: result.sha256,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
const readModel = await readPrimitiveDb();
|
||||
|
||||
if (readModel.dashcamCatalog) {
|
||||
return {
|
||||
catalog: readModel.dashcamCatalog,
|
||||
source: 'read_model',
|
||||
error: 'shared_export_unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'empty',
|
||||
error: 'shared_export_unavailable',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const readModel = await readPrimitiveDb();
|
||||
|
||||
if (readModel.dashcamCatalog) {
|
||||
return {
|
||||
catalog: readModel.dashcamCatalog,
|
||||
source: 'read_model',
|
||||
};
|
||||
}
|
||||
|
||||
return { source: 'empty' };
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } 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 () => {
|
||||
it('validates a shared-volume 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');
|
||||
const exportDir = path.join(tempDir, 'exports');
|
||||
|
||||
try {
|
||||
const catalog = {
|
||||
@@ -29,6 +21,7 @@ describe('importCatalogExport', () => {
|
||||
id: 'ibox-f5',
|
||||
brand: 'iBOX',
|
||||
model: 'F5 WiFi',
|
||||
canonicalName: 'iBOX F5 Wi-Fi',
|
||||
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
|
||||
specs: {
|
||||
channels: 1,
|
||||
@@ -51,32 +44,25 @@ describe('importCatalogExport', () => {
|
||||
};
|
||||
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();
|
||||
const artifactPath = path.join(exportDir, 'artifacts', 'export_1', 'catalog.json');
|
||||
|
||||
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 });
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, catalogBytes);
|
||||
await writeFile(
|
||||
path.join(exportDir, 'manifest.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
exportId: 'export_1',
|
||||
createdAt: '2026-05-16T21:00:00.000Z',
|
||||
artifactPath: 'artifacts/export_1/catalog.json',
|
||||
sha256: digest,
|
||||
itemCount: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await importCatalogExport({
|
||||
manifestUrl: 'https://exports.example.test/manifest.json',
|
||||
token: 'secret',
|
||||
exportDir,
|
||||
dbFilePath,
|
||||
fetcher,
|
||||
now: new Date('2026-05-16T22:00:00.000Z'),
|
||||
});
|
||||
const stored = JSON.parse(await readFile(dbFilePath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
@@ -7,19 +7,18 @@ import {
|
||||
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;
|
||||
export type ReadCatalogExportOptions = {
|
||||
exportDir?: string;
|
||||
};
|
||||
|
||||
export type ImportCatalogExportOptions = ReadCatalogExportOptions & {
|
||||
dbFilePath?: string;
|
||||
fetcher?: FetchLike;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
@@ -30,6 +29,15 @@ export type ImportCatalogExportResult = {
|
||||
dbFilePath: string;
|
||||
};
|
||||
|
||||
export type ReadCatalogExportResult = {
|
||||
catalog: DashcamCatalogExport;
|
||||
createdAt: string;
|
||||
exportDir: string;
|
||||
exportId: string;
|
||||
itemCount: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
function getDefaultDbFilePath() {
|
||||
if (process.env.VIDEOREG_DB_FILE) {
|
||||
return process.env.VIDEOREG_DB_FILE;
|
||||
@@ -38,6 +46,14 @@ function getDefaultDbFilePath() {
|
||||
return path.join(process.cwd(), 'data', 'videoreg.json');
|
||||
}
|
||||
|
||||
function getDefaultExportDir() {
|
||||
if (process.env.DASHCAM_CATALOG_EXPORT_DIR) {
|
||||
return process.env.DASHCAM_CATALOG_EXPORT_DIR;
|
||||
}
|
||||
|
||||
return path.join(process.cwd(), 'data', 'catalog-exports');
|
||||
}
|
||||
|
||||
function sha256(buffer: Buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
@@ -68,20 +84,6 @@ async function writeJsonAtomically(filePath: string, payload: PrimitiveDbCache)
|
||||
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;
|
||||
@@ -90,14 +92,24 @@ function parseJson(buffer: Buffer, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
function resolveArtifactPath(exportDir: string, artifactPath: string) {
|
||||
if (path.isAbsolute(artifactPath)) {
|
||||
throw new Error('Catalog manifest artifactPath must be relative to the shared export directory');
|
||||
}
|
||||
|
||||
const manifestBytes = await fetchJsonBytes(fetcher, options.manifestUrl, options.token);
|
||||
const resolvedExportDir = path.resolve(exportDir);
|
||||
const resolvedArtifactPath = path.resolve(resolvedExportDir, artifactPath);
|
||||
const relativePath = path.relative(resolvedExportDir, resolvedArtifactPath);
|
||||
|
||||
if (relativePath === '' || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {
|
||||
throw new Error('Catalog manifest artifactPath escapes the shared export directory');
|
||||
}
|
||||
|
||||
return resolvedArtifactPath;
|
||||
}
|
||||
|
||||
function validateExport(manifestBytes: Buffer, artifactBytes: Buffer) {
|
||||
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()) {
|
||||
@@ -118,17 +130,49 @@ export async function importCatalogExport(options: ImportCatalogExportOptions):
|
||||
throw new Error(`Catalog itemCount mismatch: manifest=${manifest.itemCount}, artifact=${catalog.products.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
manifest,
|
||||
sha256: artifactHash,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCatalogExport(
|
||||
options: ReadCatalogExportOptions = {},
|
||||
): Promise<ReadCatalogExportResult> {
|
||||
const exportDir = path.resolve(options.exportDir ?? getDefaultExportDir());
|
||||
const manifestBytes = await readFile(path.join(exportDir, 'manifest.json'));
|
||||
const manifest = parseCatalogExportManifest(parseJson(manifestBytes, 'manifest'));
|
||||
const artifactPath = manifest.artifactPath ?? path.posix.join('artifacts', manifest.exportId, 'catalog.json');
|
||||
const artifactBytes = await readFile(resolveArtifactPath(exportDir, artifactPath));
|
||||
const validated = validateExport(manifestBytes, artifactBytes);
|
||||
|
||||
return {
|
||||
catalog: validated.catalog,
|
||||
createdAt: validated.manifest.createdAt,
|
||||
exportDir,
|
||||
exportId: validated.manifest.exportId,
|
||||
itemCount: validated.manifest.itemCount,
|
||||
sha256: validated.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importCatalogExport(options: ImportCatalogExportOptions = {}): Promise<ImportCatalogExportResult> {
|
||||
const dbFilePath = options.dbFilePath ?? getDefaultDbFilePath();
|
||||
const importedAt = (options.now ?? new Date()).toISOString();
|
||||
const exportResult = await readCatalogExport(options);
|
||||
|
||||
const current = await readExistingDb(dbFilePath);
|
||||
await writeJsonAtomically(dbFilePath, {
|
||||
...current,
|
||||
dashcamCatalog: catalog,
|
||||
dashcamCatalog: exportResult.catalog,
|
||||
dashcamCatalogImportedAt: importedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
exportId: manifest.exportId,
|
||||
itemCount: manifest.itemCount,
|
||||
sha256: artifactHash,
|
||||
exportId: exportResult.exportId,
|
||||
itemCount: exportResult.itemCount,
|
||||
sha256: exportResult.sha256,
|
||||
dbFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DashcamCatalogExportSchema } from './schema';
|
||||
import { CatalogExportManifestSchema, DashcamCatalogExportSchema } from './schema';
|
||||
|
||||
const baseProduct = {
|
||||
id: 'ibox-f5',
|
||||
brand: 'iBOX',
|
||||
model: 'F5 WiFi',
|
||||
canonicalName: 'iBOX F5 Wi-Fi',
|
||||
title: 'Видеорегистратор для автомобиля iBOX F5 + WiFi с радар-детектором',
|
||||
specs: {
|
||||
channels: 1,
|
||||
@@ -66,4 +67,45 @@ describe('dashcam catalog export schema', () => {
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('accepts a local shared-volume artifact path and MVideo source metadata', () => {
|
||||
expect(
|
||||
CatalogExportManifestSchema.parse({
|
||||
schemaVersion: 1,
|
||||
exportId: 'export_1',
|
||||
createdAt: '2026-05-16T21:00:00.000Z',
|
||||
artifactPath: 'artifacts/export_1/catalog.json',
|
||||
sha256: 'a'.repeat(64),
|
||||
itemCount: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
artifactPath: 'artifacts/export_1/catalog.json',
|
||||
});
|
||||
|
||||
expect(
|
||||
DashcamCatalogExportSchema.parse({
|
||||
schemaVersion: 1,
|
||||
exportId: 'export_1',
|
||||
createdAt: '2026-05-16T21:00:00.000Z',
|
||||
products: [
|
||||
{
|
||||
...baseProduct,
|
||||
sources: [
|
||||
{
|
||||
...baseProduct.sources[0],
|
||||
source: 'mvideo',
|
||||
url: 'https://www.mvideo.ru/products/test-4254724',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
products: [
|
||||
{
|
||||
sources: [{ source: 'mvideo' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@ const isoDateTime = z.string().refine((value) => !Number.isNaN(Date.parse(value)
|
||||
});
|
||||
|
||||
const exportedSpecValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
||||
export const MarketSourceSchema = z.enum(['yandex_market', 'mvideo']);
|
||||
|
||||
export const ExportedProductImageSchema = z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
originalUrl: z.string().url(),
|
||||
source: z.literal('yandex_market'),
|
||||
source: MarketSourceSchema,
|
||||
sha256: z.string().min(1).optional(),
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
@@ -19,7 +20,7 @@ export const ExportedProductImageSchema = z
|
||||
|
||||
export const ExportedProductSourceSchema = z
|
||||
.object({
|
||||
source: z.literal('yandex_market'),
|
||||
source: MarketSourceSchema,
|
||||
sourceProductId: z.string().min(1).optional(),
|
||||
url: z.string().url(),
|
||||
stableContentHash: z.string().min(1),
|
||||
@@ -32,6 +33,7 @@ export const ExportedDashcamProductSchema = z
|
||||
id: z.string().min(1),
|
||||
brand: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
canonicalName: z.string().min(1).optional(),
|
||||
title: z.string().min(1),
|
||||
specs: z.record(z.string().min(1), exportedSpecValueSchema),
|
||||
images: z.array(ExportedProductImageSchema),
|
||||
@@ -55,11 +57,15 @@ export const CatalogExportManifestSchema = z
|
||||
schemaVersion: z.literal(1),
|
||||
exportId: z.string().min(1),
|
||||
createdAt: isoDateTime,
|
||||
artifactUrl: z.string().url(),
|
||||
artifactPath: z.string().min(1).optional(),
|
||||
artifactUrl: z.string().url().optional(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/i),
|
||||
itemCount: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.refine((manifest) => Boolean(manifest.artifactPath || manifest.artifactUrl), {
|
||||
message: 'Expected artifactPath or artifactUrl',
|
||||
});
|
||||
|
||||
export type CatalogExportManifest = z.infer<typeof CatalogExportManifestSchema>;
|
||||
export type DashcamCatalogExport = z.infer<typeof DashcamCatalogExportSchema>;
|
||||
@@ -67,6 +73,7 @@ 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 type MarketSource = z.infer<typeof MarketSourceSchema>;
|
||||
|
||||
export function parseCatalogExportManifest(input: unknown) {
|
||||
return CatalogExportManifestSchema.parse(input);
|
||||
|
||||
@@ -49,7 +49,7 @@ function getDbFilePath() {
|
||||
return process.env.VIDEOREG_DB_FILE;
|
||||
}
|
||||
|
||||
return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json');
|
||||
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'data', 'videoreg.json');
|
||||
}
|
||||
|
||||
async function ensureDbFile(filePath: string) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DashcamCatalogExport, ExportedDashcamProduct, ExportedSpecValue } from '@/entities/dashcam-catalog/model/schema';
|
||||
import type { DashcamCatalogReadModel } from '@/entities/dashcam-catalog/api/catalogRepository';
|
||||
|
||||
type DashcamCatalogProps = {
|
||||
catalog: DashcamCatalogExport | undefined;
|
||||
source: DashcamCatalogReadModel['source'];
|
||||
exportInfo?: DashcamCatalogReadModel['export'];
|
||||
hasSharedExportIssue?: boolean;
|
||||
};
|
||||
|
||||
const preferredSpecKeys = [
|
||||
'resolution_front',
|
||||
'resolution_max',
|
||||
'channels',
|
||||
'wifi',
|
||||
'night_mode',
|
||||
'gps',
|
||||
'screen',
|
||||
'battery_power',
|
||||
'car_power',
|
||||
] as const;
|
||||
|
||||
const sourceLabels = {
|
||||
mvideo: 'М.Видео',
|
||||
yandex_market: 'Яндекс Маркет',
|
||||
};
|
||||
|
||||
const specLabels: Record<string, string> = {
|
||||
battery_power: 'Резервное питание',
|
||||
car_power: 'Питание от автомобиля',
|
||||
channels: 'Камер',
|
||||
gps: 'GPS',
|
||||
night_mode: 'Ночной режим',
|
||||
resolution_front: 'Разрешение',
|
||||
resolution_max: 'Макс. разрешение',
|
||||
screen: 'Экран',
|
||||
wifi: 'Wi-Fi',
|
||||
};
|
||||
|
||||
function formatSpecValue(value: ExportedSpecValue) {
|
||||
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
|
||||
if (value === 'battery') return 'Аккумулятор';
|
||||
if (value === 'capacitor') return 'Конденсатор';
|
||||
if (value === 'hybrid') return 'Аккумулятор + конденсатор';
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function ProductCard({ product }: { product: ExportedDashcamProduct }) {
|
||||
const productSpecs = preferredSpecKeys
|
||||
.filter((key) => product.specs[key] !== undefined)
|
||||
.map((key) => [key, product.specs[key]] as const);
|
||||
const remainingSpecs = Object.entries(product.specs).filter(([key]) => !preferredSpecKeys.includes(key as never));
|
||||
|
||||
return (
|
||||
<article className="catalog-product">
|
||||
<div className="catalog-product__media" aria-hidden="true">
|
||||
<img alt="" src="/dashcam-product.svg" />
|
||||
</div>
|
||||
<div className="catalog-product__body">
|
||||
<div className="catalog-product__heading">
|
||||
<div>
|
||||
<p className="catalog-product__brand">{product.brand}</p>
|
||||
<h2>{product.canonicalName ?? product.model}</h2>
|
||||
</div>
|
||||
<span className="catalog-product__source-count">{product.sources.length} источник</span>
|
||||
</div>
|
||||
<p className="catalog-product__title">{product.title}</p>
|
||||
|
||||
{productSpecs.length > 0 ? (
|
||||
<dl className="catalog-product__specs">
|
||||
{productSpecs.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{specLabels[key] ?? key}</dt>
|
||||
<dd>{formatSpecValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{remainingSpecs.length > 0 ? (
|
||||
<details className="catalog-product__details">
|
||||
<summary>Все характеристики ({Object.keys(product.specs).length})</summary>
|
||||
<dl>
|
||||
{remainingSpecs.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{specLabels[key] ?? key}</dt>
|
||||
<dd>{formatSpecValue(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<div className="catalog-product__sources">
|
||||
{product.sources.map((source) => (
|
||||
<a href={source.url} key={`${source.source}-${source.url}`} rel="noreferrer" target="_blank">
|
||||
{sourceLabels[source.source]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashcamCatalog({ catalog, source, exportInfo, hasSharedExportIssue }: DashcamCatalogProps) {
|
||||
const products = catalog?.products ?? [];
|
||||
|
||||
return (
|
||||
<main className="catalog-page">
|
||||
<header className="catalog-header">
|
||||
<div>
|
||||
<p className="catalog-header__eyebrow">videoreg.ru</p>
|
||||
<h1>Автомобильные видеорегистраторы</h1>
|
||||
<p className="catalog-header__subtitle">Проверенный каталог карточек из публичных источников.</p>
|
||||
</div>
|
||||
<div className="catalog-header__meta">
|
||||
<strong>{products.length}</strong>
|
||||
<span>одобренных моделей</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="catalog-status" aria-label="Catalog status">
|
||||
<span>Источник: {source === 'shared_export' ? 'shared export' : source === 'read_model' ? 'read model' : 'ожидается export'}</span>
|
||||
{exportInfo ? <span>Обновлён: {formatDate(exportInfo.createdAt)}</span> : null}
|
||||
{hasSharedExportIssue ? <span>Последний shared export недоступен, показана сохранённая версия.</span> : null}
|
||||
</section>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<section className="catalog-grid" aria-label="Dashcam catalog">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="catalog-empty">
|
||||
<h2>Каталог ожидает moderation</h2>
|
||||
<p>Одобренные карточки появятся после первого export из ingestion.</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user