Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ const envSchema = z.object({
.string()
.default("NPPWEB procurement monitor/1.0 (+https://example.local/nppweb)"),
FEDRESURS_BASE_URL: z.string().url().default("https://bankrot.fedresurs.ru"),
FEDRESURS_API_URL: z.string().url().optional(),
FEDRESURS_API_LOGIN: z
.string()
.optional()
.transform((value) => value?.trim() || undefined),
FEDRESURS_API_PASSWORD: z
.string()
.optional()
.transform((value) => value?.trim() || undefined),
FEDRESURS_API_LOOKBACK_DAYS: z.coerce.number().int().positive().max(31).default(31),
FEDRESURS_SEARCH_URL: z.string().url().default("https://bankrot.fedresurs.ru/Messages.aspx"),
FEDRESURS_MAX_ITEMS: z.coerce.number().int().positive().max(20).default(5),
FEDRESURS_USER_AGENT: z
Expand Down
134 changes: 134 additions & 0 deletions src/sources/fedresurs/fedresurs-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import type { Logger } from "pino";
import { describeOutboundHttpError, fetch } from "../../http-client";
import { withRetries } from "../../utils/retry";
import {
parseFedresursApiMessage,
parseFedresursDetailPage,
parseFedresursSearchResults
} from "./fedresurs-parser";
import type {
FedresursApiAuthResponse,
FedresursApiMessage,
FedresursApiSearchResponse,
FedresursClientConfig,
FedresursParsedMessage,
FedresursSearchResultLink
Expand All @@ -14,6 +18,45 @@ import type {
export class FedresursClient {
constructor(private readonly config: FedresursClientConfig) {}

hasOfficialApiConfig(): boolean {
return Boolean(this.config.apiUrl && this.config.apiLogin && this.config.apiPassword);
}

async listRecentMessagesFromApi(
logger: Logger,
requestTimeoutMs: number
): Promise<Array<{ rawJson: string; message: FedresursParsedMessage }>> {
if (!this.hasOfficialApiConfig()) {
return [];
}

const jwt = await this.authenticate(logger, requestTimeoutMs);
const now = new Date();
const lookbackDays = Math.max(1, Math.min(this.config.apiLookbackDays ?? 31, 31));
const from = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
const apiResponse = await this.fetchJson<FedresursApiSearchResponse>(
this.buildApiUrl("v1/messages", {
DatePublishBegin: formatFedresursApiDate(from),
DatePublishEnd: formatFedresursApiDate(now),
IncludeContent: "true",
IncludeBankruptInfo: "true",
IsAnnulled: "false",
IsLocked: "false",
Limit: String(this.config.maxItems),
Offset: "0"
}),
logger,
requestTimeoutMs,
"messages api",
jwt
);

return (apiResponse.pageData ?? []).slice(0, this.config.maxItems).map((item) => ({
rawJson: JSON.stringify(item, null, 2),
message: parseFedresursApiMessage(item, { baseUrl: this.config.baseUrl })
}));
}

async listMessageLinks(
logger: Logger,
requestTimeoutMs: number
Expand All @@ -38,6 +81,30 @@ export class FedresursClient {
};
}

private async authenticate(logger: Logger, requestTimeoutMs: number): Promise<string> {
const response = await this.fetchJson<FedresursApiAuthResponse>(
this.buildApiUrl("v1/auth"),
logger,
requestTimeoutMs,
"auth api",
undefined,
{
method: "POST",
body: JSON.stringify({
login: this.config.apiLogin,
password: this.config.apiPassword
})
}
);

const jwt = response.jwt?.trim();
if (!jwt) {
throw new Error("Fedresurs auth api did not return JWT token");
}

return jwt;
}

private async fetchText(
url: string,
logger: Logger,
Expand Down Expand Up @@ -83,4 +150,71 @@ export class FedresursClient {
}
);
}

private async fetchJson<T>(
url: string,
logger: Logger,
requestTimeoutMs: number,
resourceName: string,
jwt?: string,
init?: { method?: string; body?: string }
): Promise<T> {
return withRetries(
async () => {
let response;
try {
response = await fetch(url, {
method: init?.method ?? "GET",
body: init?.body,
signal: AbortSignal.timeout(requestTimeoutMs),
headers: {
accept: "application/json",
"content-type": init?.body ? "application/json" : undefined,
authorization: jwt ? `Bearer ${jwt}` : undefined,
"user-agent": this.config.userAgent
}
});
} catch (error) {
throw describeOutboundHttpError(error, url);
}

if (!response.ok) {
throw new Error(`Fedresurs ${resourceName} returned HTTP ${response.status}`);
}

return (await response.json()) as T;
},
2,
1000,
{
onRetry: ({ attempt, delayMs, error }) => {
logger.warn(
{
url,
resourceName,
attempt,
delayMs,
err: error
},
"fedresurs request failed; retry scheduled"
);
}
}
);
}

private buildApiUrl(path: string, query?: Record<string, string>): string {
const baseUrl = this.config.apiUrl ?? "";
const url = new URL(path.replace(/^\/+/, ""), `${baseUrl.replace(/\/+$/, "")}/`);

for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value);
}

return url.toString();
}
}

function formatFedresursApiDate(value: Date): string {
return value.toISOString().replace(/\.\d{3}Z$/, "");
}
21 changes: 15 additions & 6 deletions src/sources/fedresurs/fedresurs-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import type { FedresursParsedMessage } from "./types";

export function mapFedresursMessageToCollectedRecord(input: {
message: FedresursParsedMessage;
html: string;
rawDocument: string;
artifactKind?: "RAW_HTML" | "RAW_JSON";
artifactContentType?: string;
artifactFileExtension?: "html" | "json";
}): CollectedRawRecord {
const { message, html } = input;
const {
message,
rawDocument,
artifactKind = "RAW_HTML",
artifactContentType = "text/html; charset=utf-8",
artifactFileExtension = "html"
} = input;

return {
url: message.externalUrl,
Expand Down Expand Up @@ -34,10 +43,10 @@ export function mapFedresursMessageToCollectedRecord(input: {
},
artifacts: [
{
kind: "RAW_HTML",
fileName: `fedresurs-${message.externalId}.html`,
contentType: "text/html; charset=utf-8",
body: html,
kind: artifactKind,
fileName: `fedresurs-${message.externalId}.${artifactFileExtension}`,
contentType: artifactContentType,
body: rawDocument,
metadata: {
externalId: message.externalId,
externalUrl: message.externalUrl,
Expand Down
38 changes: 38 additions & 0 deletions src/sources/fedresurs/fedresurs-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
parseFedresursApiMessage,
parseFedresursDetailPage,
parseFedresursSearchResults
} from "./fedresurs-parser";
Expand Down Expand Up @@ -85,3 +86,40 @@ describe("parseFedresursDetailPage", () => {
expect(message.checksum).toHaveLength(64);
});
});

describe("parseFedresursApiMessage", () => {
it("maps official api payload into parser fields", () => {
const message = parseFedresursApiMessage(
{
guid: "2133d803-3d1f-7639-fed4-43e183df196c",
number: "12345678",
datePublish: "2026-04-07T12:30:45.000",
content:
"<p>В отношении должника введена процедура наблюдения.</p><p>Назначен временный управляющий.</p>",
type: "Observation",
bankruptInfo: {
guid: "05b663e5-2d69-4d20-9a53-f85d89fcb212",
type: "Company",
data: {
name: 'ООО "Стройресурс"',
inn: "7701234567",
ogrn: "1027700123456"
}
}
},
{ baseUrl: "https://bankrot.fedresurs.ru" }
);

expect(message.externalId).toBe("12345678");
expect(message.externalUrl).toBe(
"https://bankrot.fedresurs.ru/sfactmessages/2133d803-3d1f-7639-fed4-43e183df196c"
);
expect(message.messageType).toBe("Observation");
expect(message.subjectName).toBe('ООО "Стройресурс"');
expect(message.subjectInn).toBe("7701234567");
expect(message.subjectOgrn).toBe("1027700123456");
expect(message.publishedAt).toBe("2026-04-07T12:30:45+03:00");
expect(message.description).toContain("процедура наблюдения");
expect(message.checksum).toHaveLength(64);
});
});
82 changes: 81 additions & 1 deletion src/sources/fedresurs/fedresurs-parser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createHash } from "node:crypto";
import { load, type CheerioAPI, type Cheerio } from "cheerio";
import type { AnyNode } from "domhandler";
import type { FedresursParsedMessage, FedresursSearchResultLink } from "./types";
import type {
FedresursApiMessage,
FedresursParsedMessage,
FedresursSearchResultLink
} from "./types";

const SEARCH_LINK_PATTERNS = ["/MessageWindow.aspx", "/TradeLotInfo.aspx"];

Expand Down Expand Up @@ -108,6 +112,37 @@ export function parseFedresursDetailPage(html: string, detailUrl: string): Fedre
};
}

export function parseFedresursApiMessage(
message: FedresursApiMessage,
options: { baseUrl: string }
): FedresursParsedMessage {
const externalId = cleanText(message.number) || message.guid;
const externalUrl = resolveUrl(options.baseUrl, `/sfactmessages/${message.guid}`) ?? options.baseUrl;
const subject = message.bankruptInfo?.data;
const subjectName = buildParticipantName(subject);
const description = extractTextFromHtml(message.content);
const checksum = createHash("sha256").update(JSON.stringify(message)).digest("hex");

return {
externalId,
externalUrl,
sourcePageUrl: externalUrl,
sourceName: "fedresurs",
sourceType: "bankruptcy",
messageType: cleanText(message.type) || undefined,
subjectName,
subjectInn: normalizeIdentifier(subject?.inn ?? undefined),
subjectOgrn: normalizeIdentifier(subject?.ogrn ?? subject?.ogrnip ?? undefined),
publishedAt: normalizeApiDateTime(message.datePublish),
eventDate: normalizeApiDateTime(message.dateEvent),
title:
cleanText(message.type) ||
(cleanText(message.number) ? `Сообщение №${cleanText(message.number)}` : undefined),
description,
checksum
};
}

function findFirstValue($: CheerioAPI, labels: string[]): string | undefined {
for (const label of labels) {
const value = findValueByLabel($, label);
Expand Down Expand Up @@ -289,3 +324,48 @@ function normalizeIdentifier(value: string | undefined): string | undefined {
const normalized = value.replace(/[^\d]/g, "");
return normalized || undefined;
}

function normalizeApiDateTime(value: string | null | undefined): string | undefined {
const normalized = cleanText(value);
if (!normalized) {
return undefined;
}

const withoutMs = normalized.replace(/\.\d+/, "");
if (/([+-]\d{2}:\d{2}|Z)$/i.test(withoutMs)) {
return withoutMs.replace(/Z$/i, "+00:00");
}

return `${withoutMs}+03:00`;
}

function buildParticipantName(
participant: {
name?: string | null;
firstName?: string | null;
lastName?: string | null;
middleName?: string | null;
} | null | undefined
): string | undefined {
const directName = cleanText(participant?.name);
if (directName) {
return directName;
}

const fullName = [participant?.lastName, participant?.firstName, participant?.middleName]
.map((value) => cleanText(value))
.filter(Boolean)
.join(" ");

return fullName || undefined;
}

function extractTextFromHtml(value: string | null | undefined): string | undefined {
const html = cleanText(value);
if (!html) {
return undefined;
}

const text = cleanText(load(`<div>${html}</div>`).text());
return text || html;
}
Loading
Loading