651 lines
16 KiB
Markdown
651 lines
16 KiB
Markdown
# Спека: 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
|