import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { load } from 'cheerio'; import { LRUCache } from 'lru-cache'; 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> >({ max: 1, ttl: REGISTRY_CACHE_TTL_MS }); async rating(isin: string): Promise { 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: 'NCR_UNAVAILABLE' }); }); this.cache.set('registry', pending); } return (await pending).get(key) ?? null; } private async registry(): Promise> { 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 !== 1 || 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(); 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(2).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(0, 10) !== 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; } }