Архитектура
Платформа NPPWEB построена как разделённый конвейер, где внешние источники, нормализация, доменная модель и UI разведены по разным сервисам и разным репозиториям.
Это сделано специально: сбор с государственных площадок нестабилен, и его нельзя смешивать с бизнес-хранилищем и аналитикой.
Сквозная схема
Внешние площадки
EIS / EASUZ / RNP / FNS / Fedresurs / GIS Torgi
|
v
scrape-helper
collect -> artifacts -> source.raw.v1
|
v
RabbitMQ
|
v
processing-worker
validate -> normalize -> ingest
|
v
npp-backend
Postgres + GraphQL + reports + analytics
|
v
npp-webАрхитектура как набор независимых слоёв
NPPWEB специально не устроен как единый монолит. В нём разведены четыре разных типа ответственности:
Интеграционный слойВнешние площадки нестабильны, могут менять HTML, throttling и правила доступа. Поэтому логика подключения и парсинга вынесена вscrape-helper.Транспортный слойМежду сбором и доменной моделью стоит RabbitMQ. Это позволяет не связывать парсер и backend синхронным HTTP-вызовом.Слой нормализацииprocessing-workerпревращает внешний payload не просто в JSON, а в унифицированную бизнес-форму, пригодную для аналитики.Доменный слойnpp-backendхранит состояние системы, считает агрегаты и является единственным владельцем бизнес-сущностей.
Такое разделение важно не только архитектурно, но и эксплуатационно: каждый слой может деградировать по-своему, и это нужно уметь диагностировать отдельно.
Главные архитектурные принципы
Сбор не пишет напрямую в базу
scrape-helper не создаёт Procurement, Report или Supplier. Он только:
- обращается к площадке;
- извлекает карточки;
- прикладывает артефакты;
- публикует transport-level событие.
Это позволяет безопасно менять парсер, не ломая доменную модель.
Нормализация отделена от парсинга
Парсер знает внешний HTML или JSON. Worker знает, как привести данные разных источников к общей форме.
Поэтому:
- изменение площадки чаще всего чинится в
scrape-helper; - изменение derived-полей и нормализованной логики чаще живёт в
processing-worker; - изменение доменной модели и аналитики живёт в
npp-backend.
Backend — единственный владелец состояния
Только npp-backend отвечает за:
- хранение пользователей и сессий;
Source,SourceRun,Procurement,Supplier,Report;- агрегаты и аналитические витрины;
- GraphQL-контракт для фронта.
Frontend не должен вычислять бизнес-метрики самостоятельно.
Поток данных подробнее
1. Сбор данных
scrape-helper запускает источники по расписанию или вручную через control API.
Каждый источник оформлен как SourceAdapter с одним единым контрактом:
export interface SourceAdapter {
code: string;
name: string;
collect(context: SourceRunContext): Promise<CollectedRawRecord[]>;
}На выходе адаптер не возвращает доменную сущность. Он возвращает:
- URL источника;
- сырой payload;
- metadata;
- набор артефактов, например
RAW_HTML,RAW_JSON,REPORT_FILE.
Принципиально важно, что adapter не возвращает Procurement или другую доменную запись. Это транспортный слой, а не бизнес-модель.
Ниже показан упрощённый контракт адаптера:
export interface SourceAdapter {
code: string;
name: string;
collect(context: SourceRunContext): Promise<CollectedRawRecord[]>;
}2. Transport layer
После сбора scrape-helper:
- загружает артефакты в MinIO/S3;
- формирует
RawSourceEvent; - валидирует его по схемам
contracts; - публикует в RabbitMQ;
- отправляет проблемные элементы в quarantine queue.
Типовой фрагмент формирования raw-события выглядит так:
const rawEvent: RawSourceEvent = {
eventId: randomUUID(),
runKey,
source: adapter.code,
payloadVersion: "v1",
collectedAt,
url: input.record.url,
artifacts,
raw: enrichRawPayload(input.record.raw, input.record.url, input.collectedAt, artifacts)
};
validateRaw(rawEvent);
await publisher.publish(rawEvent);Отдельно сервис репортит SourceRun состояния в backend:
RUNNINGSUCCESSFAILED
Это и есть источник данных для operational-экранов по здоровью пайплайна.
3. Нормализация
processing-worker читает source.raw.v1 и определяет, во что превращать событие:
procurementregistryrisk_signalcompany_profileauction
В этой точке:
- вычисляются общие поля;
- добавляются source-specific derived fields;
- строятся enrichment-поля вроде
targetStationName,supplierNormalized,analyticsCategory; - проверяется, нужно ли quarantining на уровне контента.
Упрощённо worker ведёт себя так:
const raw = queue.parseMessage<RawSourceEvent>(message);
validators.validateRaw(raw);
const quarantinedEvent = detectQuarantinableRawEvent(raw);
if (quarantinedEvent) {
await reportQuarantinedRawEvent(apiGraphqlUrl, ingestToken, quarantinedEvent);
queue.ack(message);
return;
}
const normalized = normalizeRawEvent(raw);
validators.validateNormalized(normalized);
await sendToBackend(apiGraphqlUrl, ingestToken, normalized);
await queue.publish(queueNormalized, normalized);
queue.ack(message);Здесь хорошо видно три принципа:
- worker сначала валидирует transport-событие;
- затем может остановить поток через quarantine;
- только после этого создаёт normalized event и делает ingest.
4. Ingest и доменная запись
npp-backend принимает нормализованное событие по GraphQL и кладёт его в нужную доменную таблицу.
В зависимости от типа это могут быть:
ProcurementRegistryRecordSupplierRiskSignalSupplierCompanyProfileAuctionItem
Также backend хранит:
SourceSourceRunRawEventArtifactNormalizedItem
Это даёт трассировку от аналитического экрана назад до исходного артефакта.
Ключевой принцип backend: он хранит не только конечную бизнес-запись, но и историю её происхождения.
Упрощённый фрагмент идемпотентной загрузки:
const contentHash = createHash("sha256")
.update(JSON.stringify({
externalId: input.externalId,
source: input.source,
payloadVersion: input.payloadVersion,
title: input.title,
rawPayload: input.rawPayload ?? null
}))
.digest("hex");
const idempotencyKey = createHash("sha256")
.update(`${input.source}:${input.externalId}:${input.payloadVersion}:${contentHash}`)
.digest("hex");Это защищает систему от повторной доставки одного и того же сообщения и позволяет backend безопасно работать в асинхронной среде.
5. Аналитика и UI
npp-backend считает агрегаты поверх доменной модели, а npp-web только отображает их через GraphQL.
Ключевая идея:
- backend решает, что считать срочной закупкой, атомным контуром и риском по источнику;
- frontend не дублирует эту логику;
- отчёты и аналитика воспроизводимы и не зависят от локального UI-state.
Это один из самых важных принципов проекта: frontend не должен быть вторым backend.
Для клиента GraphQL выглядит как тонкий типизированный слой доступа:
export const ANALYTICS_QUERY = gql`
query AnalyticsSummary {
analyticsSummary {
closingSoonCount
overdueCount
highValueCount
atRiskSources
runSuccessRate
publicationEfficiency
}
}
`;Основные технические компоненты
| Компонент | Роль |
|---|---|
| Postgres | основное хранилище backend |
| Redis | служебный runtime backend |
| RabbitMQ | transport между сбором и нормализацией |
| MinIO | хранение артефактов |
| Xray proxy | доступ к ограниченным внешним площадкам |
| GitHub Pages | публикация документации |
Доменные сущности
Ключевые объекты платформы:
Source— каталог подключённых источников;SourceRun— запуск источника со статусом и счётчиками;RawEvent— зафиксированное сырое сообщение transport-слоя;Artifact— HTML, JSON, PDF и другие исходные документы;Procurement— нормализованная закупка или договор;Supplier— сущность поставщика, объединяющая несколько контуров;SupplierRiskSignal— риск-сигналы, например по Fedresurs;SupplierCompanyProfile— профили компаний, например из FNS;RegistryRecord— записи РНП;Report— snapshot-отчёт.
Почему в аналитике бывают “пустые” экраны
Архитектурно важно понимать: не каждый успешно собранный источник автоматически наполняет каждый экран.
Примеры:
- источник может успешно формировать
RegistryRecord, но конкретный экран читать толькоProcurement; - закупка может существовать, но не иметь связанного
supplierId; RNPиFNSмогут обогащать supplier-domain, но не участвовать в конкретном графике без дополнительных агрегатов;Fedresursможет быть нужен не для закупок, а для watchlist по рискам.
Поэтому техническое “данные есть” и аналитическое “экран наполнен” — это не одно и то же.
Границы ответственности
| Сервис | За что отвечает | Чего не должен делать |
|---|---|---|
scrape-helper | доступ к площадкам, raw-сбор, артефакты, source runs | писать бизнес-сущности в Postgres |
processing-worker | нормализация, derived fields, ingest | хранить состояние системы |
npp-backend | доменная модель, auth, отчёты, аналитика, GraphQL | зависеть от UI или прямого формата внешней площадки |
npp-web | отображение и пользовательские сценарии | считать бизнес-метрики на клиенте |
Как читать сбой по слоям
Хорошая эксплуатационная диагностика в NPPWEB почти всегда идёт сверху вниз:
- пользователь видит пустой экран или неверную метрику;
- проверяется GraphQL-ответ backend;
- затем доменные таблицы и отчётные агрегаты;
- потом normalized / raw-поток;
- и только в конце внешний источник и его HTML/API.
Это позволяет не чинить “не тот” слой и быстрее локализовать проблему.
Когда меняется какой слой
Если меняется:
- внешний HTML или API площадки — первым меняется
scrape-helper; - схема события — первым меняется
contracts; - форма normalized payload — меняются
contracts,processing-worker, затем backend; - GraphQL API — меняются
contracts,npp-backend,npp-web; - бизнес-метрика или отчёт — первым меняется
npp-backend; - только визуальное представление — меняется
npp-web.