Files
backend/src/integrations/ncr/ncr-ratings.client.ts
T
codex-bot d37fa2c903
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
2026-10-28 17:11:41 +03:00

129 lines
4.5 KiB
TypeScript

import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6;
const ISIN_COLUMN = 3;
const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = {
agency: 'NCR';
date: string;
observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
};
@Injectable()
export class NcrRatingsClient {
// Public registry contains no user data. Share one download across all issues.
private readonly cache = new LRUCache<
string,
Promise<Map<string, NcrRating>>
>({ max: 1, ttl: REGISTRY_CACHE_TTL_MS });
async rating(isin: string): Promise<NcrRating | null> {
const key = isin.trim().toUpperCase();
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
let pending = this.cache.get('registry');
if (!pending) {
pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating.
if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 });
}
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
});
this.cache.set('registry', pending);
}
return (await pending).get(key) ?? null;
}
private async registry(): Promise<Map<string, NcrRating>> {
const response = await fetch(registryUrl, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) throw new Error('NCR unavailable');
const $ = load(await response.text());
const table = $('#issues-table');
const headings = table
.find('thead th')
.map((_, cell) => $(cell).text().trim())
.get();
if (
table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата'
) {
throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => {
const cells = $(row).find('td');
if (cells.length !== COLUMN_COUNT) throw new Error('NCR row changed');
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if (
!withdrawn &&
!/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
raw,
)
) {
throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(),
);
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if (
!Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
) {
throw new Error('NCR date invalid');
}
const rating: NcrRating = {
agency: 'NCR',
value: withdrawn ? null : raw,
status: withdrawn ? 'withdrawn' : 'active',
date,
sourceUrl: `https://ratings.ru${href}`,
observedAt,
};
const previous = result.get(isin);
if (
previous &&
previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status)
) {
throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating);
});
if (!result.size) throw new Error('NCR registry empty');
return result;
}
}