@fin.cx/opendata 4.6.0 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.smartconfig.json +6 -2
  2. package/dist_ts/00_commitinfo_data.js +3 -3
  3. package/dist_ts/financialstatements/amounts.d.ts +58 -0
  4. package/dist_ts/financialstatements/amounts.js +130 -0
  5. package/dist_ts/financialstatements/classes.financialstatementerror.d.ts +24 -0
  6. package/dist_ts/financialstatements/classes.financialstatementerror.js +10 -0
  7. package/dist_ts/financialstatements/constants.d.ts +16 -0
  8. package/dist_ts/financialstatements/constants.js +20 -0
  9. package/dist_ts/financialstatements/dates.d.ts +16 -0
  10. package/dist_ts/financialstatements/dates.js +72 -0
  11. package/dist_ts/financialstatements/html.d.ts +21 -0
  12. package/dist_ts/financialstatements/html.js +92 -0
  13. package/dist_ts/financialstatements/index.d.ts +6 -0
  14. package/dist_ts/financialstatements/index.js +11 -0
  15. package/dist_ts/financialstatements/interfaces.d.ts +258 -0
  16. package/dist_ts/financialstatements/interfaces.js +2 -0
  17. package/dist_ts/financialstatements/layout.d.ts +20 -0
  18. package/dist_ts/financialstatements/layout.html.d.ts +8 -0
  19. package/dist_ts/financialstatements/layout.html.js +125 -0
  20. package/dist_ts/financialstatements/layout.js +2 -0
  21. package/dist_ts/financialstatements/layout.pdf.d.ts +6 -0
  22. package/dist_ts/financialstatements/layout.pdf.js +229 -0
  23. package/dist_ts/financialstatements/mediatypes.d.ts +3 -0
  24. package/dist_ts/financialstatements/mediatypes.js +11 -0
  25. package/dist_ts/financialstatements/outline.d.ts +22 -0
  26. package/dist_ts/financialstatements/outline.js +26 -0
  27. package/dist_ts/financialstatements/parse.esef.d.ts +13 -0
  28. package/dist_ts/financialstatements/parse.esef.js +349 -0
  29. package/dist_ts/financialstatements/parse.hgb.d.ts +9 -0
  30. package/dist_ts/financialstatements/parse.hgb.js +1014 -0
  31. package/dist_ts/financialstatements/parse.statement.d.ts +8 -0
  32. package/dist_ts/financialstatements/parse.statement.js +25 -0
  33. package/dist_ts/financialstatements/plugins.d.ts +10 -0
  34. package/dist_ts/financialstatements/plugins.js +15 -0
  35. package/dist_ts/financialstatements/read.d.ts +10 -0
  36. package/dist_ts/financialstatements/read.js +113 -0
  37. package/dist_ts/handelsregister/classes.handelsregisterclient.d.ts +8 -2
  38. package/dist_ts/handelsregister/classes.handelsregisterclient.js +14 -5
  39. package/dist_ts/handelsregister/classes.handelsregistererror.d.ts +3 -1
  40. package/dist_ts/handelsregister/classes.handelsregistererror.js +1 -1
  41. package/dist_ts/handelsregister/classes.portalsession.d.ts +19 -3
  42. package/dist_ts/handelsregister/classes.portalsession.js +67 -9
  43. package/dist_ts/handelsregister/interfaces.d.ts +9 -2
  44. package/package.json +16 -6
  45. package/readme.md +86 -5
  46. package/ts/00_commitinfo_data.ts +2 -2
  47. package/ts/financialstatements/amounts.ts +181 -0
  48. package/ts/financialstatements/classes.financialstatementerror.ts +35 -0
  49. package/ts/financialstatements/constants.ts +24 -0
  50. package/ts/financialstatements/dates.ts +89 -0
  51. package/ts/financialstatements/html.ts +107 -0
  52. package/ts/financialstatements/index.ts +17 -0
  53. package/ts/financialstatements/interfaces.ts +268 -0
  54. package/ts/financialstatements/layout.html.ts +130 -0
  55. package/ts/financialstatements/layout.pdf.ts +268 -0
  56. package/ts/financialstatements/layout.ts +16 -0
  57. package/ts/financialstatements/mediatypes.ts +14 -0
  58. package/ts/financialstatements/outline.ts +44 -0
  59. package/ts/financialstatements/parse.esef.ts +394 -0
  60. package/ts/financialstatements/parse.hgb.ts +1235 -0
  61. package/ts/financialstatements/parse.statement.ts +26 -0
  62. package/ts/financialstatements/plugins.ts +29 -0
  63. package/ts/financialstatements/read.ts +135 -0
  64. package/ts/handelsregister/classes.handelsregisterclient.ts +18 -4
  65. package/ts/handelsregister/classes.handelsregistererror.ts +3 -0
  66. package/ts/handelsregister/classes.portalsession.ts +78 -9
  67. package/ts/handelsregister/interfaces.ts +9 -2
@@ -0,0 +1,26 @@
1
+ import { FinancialStatementError } from './classes.financialstatementerror.js';
2
+ import { maxStatementBytes } from './constants.js';
3
+ import { decodeHtml } from './html.js';
4
+ import { htmlLayout } from './layout.html.js';
5
+ import { pdfLayout } from './layout.pdf.js';
6
+ import { statementMediaTypeOf } from './mediatypes.js';
7
+ import { readHgbStatement } from './parse.hgb.js';
8
+ import type { IHgbStatement } from './interfaces.js';
9
+
10
+ /**
11
+ * Reads a statement under the HGB from the HTML or the PDF a register shows: the balance sheet
12
+ * (§ 266 HGB) and, where one is published, the income statement (§ 275 HGB). Everything it returns
13
+ * was printed and adds up; where the current year's balance sheet cannot be read with certainty it
14
+ * throws `unparseable` rather than guess. A PDF is read from its text layer only.
15
+ */
16
+ export const parseStatementDocument = async (bytesArg: Uint8Array, mediaTypeArg: string): Promise<IHgbStatement> => {
17
+ const mediaType = statementMediaTypeOf(mediaTypeArg);
18
+ if (mediaType !== 'text/html' && mediaType !== 'application/xhtml+xml' && mediaType !== 'application/pdf') {
19
+ throw new FinancialStatementError('unsupported_media_type', `A statement in "${mediaTypeArg}" is not read here; HTML and PDF are.`);
20
+ }
21
+ if (bytesArg.byteLength > maxStatementBytes) {
22
+ throw new FinancialStatementError('too_large', `The document has ${bytesArg.byteLength} bytes, more than the ${maxStatementBytes} read here.`);
23
+ }
24
+ const blocks = mediaType === 'application/pdf' ? await pdfLayout(bytesArg) : htmlLayout(decodeHtml(bytesArg));
25
+ return readHgbStatement(blocks);
26
+ };
@@ -0,0 +1,29 @@
1
+ // This entry point stands alone: it imports nothing from the rest of the package and needs no storage.
2
+
3
+ // node native scope
4
+ import * as path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+
7
+ export {
8
+ path,
9
+ createRequire,
10
+ }
11
+
12
+ // @push.rocks scope
13
+ import * as smartarchiveZip from '@push.rocks/smartarchive/web/zip';
14
+ import * as smartxmlXpath from '@push.rocks/smartxml/xpath';
15
+
16
+ export {
17
+ smartarchiveZip,
18
+ smartxmlXpath,
19
+ }
20
+
21
+ // third party scope
22
+ import * as parse5 from 'parse5';
23
+
24
+ export {
25
+ parse5,
26
+ }
27
+
28
+ /** PDF.js for Node.js (its legacy build), loaded when the first PDF is read */
29
+ export const loadPdfjs = () => import('pdfjs-dist/legacy/build/pdf.mjs');
@@ -0,0 +1,135 @@
1
+ import { FinancialStatementError } from './classes.financialstatementerror.js';
2
+ import { maxStatementBytes, unternehmensregisterNotice } from './constants.js';
3
+ import { statementMediaTypeOf } from './mediatypes.js';
4
+ import { parseEsefPackage, parseEsefReport } from './parse.esef.js';
5
+ import { parseStatementDocument } from './parse.statement.js';
6
+ import type {
7
+ IFiling,
8
+ IFinancialStatement,
9
+ IReadFinancialStatementOptions,
10
+ IStatementParseFailure,
11
+ TFilingKind,
12
+ TFilingSource,
13
+ TParsedStatement,
14
+ TSizeClass,
15
+ } from './interfaces.js';
16
+
17
+ const filingSources: TFilingSource[] = ['unternehmensregister', 'bundesanzeiger', 'issuer', 'unknown'];
18
+ const filingKinds: TFilingKind[] = ['annual_accounts', 'consolidated_accounts', 'deposited_balance_sheet', 'annual_financial_report'];
19
+ const sizeClasses: TSizeClass[] = ['micro', 'small', 'medium', 'large'];
20
+ const parseFailureCodes = new Set<string>(['invalid_package', 'unreadable', 'no_text', 'unparseable', 'too_large']);
21
+
22
+ /** the inline XBRL namespace, which marks an HTML document as an ESEF report */
23
+ const inlineXbrlNamespace = 'http://www.xbrl.org/2013/inlineXBRL';
24
+
25
+ const invalid = (messageArg: string): never => {
26
+ throw new FinancialStatementError('invalid_input', messageArg);
27
+ };
28
+
29
+ const isIsoDate = (valueArg: string): boolean => {
30
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(valueArg)) {
31
+ return false;
32
+ }
33
+ const date = new Date(`${valueArg}T00:00:00Z`);
34
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === valueArg;
35
+ };
36
+
37
+ const filingOf = (inputArg: IReadFinancialStatementOptions['filing']): IFiling => {
38
+ if (!filingSources.includes(inputArg?.source)) {
39
+ invalid(`The filing's source "${inputArg?.source}" is not one of ${filingSources.join(', ')}.`);
40
+ }
41
+ if (!filingKinds.includes(inputArg.kind)) {
42
+ invalid(`The filing's kind "${inputArg.kind}" is not one of ${filingKinds.join(', ')}.`);
43
+ }
44
+ if (inputArg.sizeClass != null && !sizeClasses.includes(inputArg.sizeClass)) {
45
+ invalid(`The filing's size class "${inputArg.sizeClass}" is not one of ${sizeClasses.join(', ')}.`);
46
+ }
47
+ for (const field of ['fiscalYearStart', 'fiscalYearEnd', 'publishedOn'] as const) {
48
+ const value = inputArg[field];
49
+ if (value != null && (typeof value !== 'string' || !isIsoDate(value))) {
50
+ invalid(`The filing's ${field} "${value}" is not an ISO date (YYYY-MM-DD).`);
51
+ }
52
+ }
53
+ if (inputArg.source === 'unternehmensregister' && inputArg.publishedOn == null) {
54
+ invalid('A filing from the Unternehmensregister needs publishedOn, the day it was posted or deposited, for its origin note.');
55
+ }
56
+ return {
57
+ source: inputArg.source,
58
+ kind: inputArg.kind,
59
+ companyName: inputArg.companyName ?? null,
60
+ registerReference: inputArg.registerReference ?? null,
61
+ lei: inputArg.lei ?? null,
62
+ fiscalYearStart: inputArg.fiscalYearStart ?? null,
63
+ fiscalYearEnd: inputArg.fiscalYearEnd ?? null,
64
+ publishedOn: inputArg.publishedOn ?? null,
65
+ title: inputArg.title ?? null,
66
+ sizeClass: inputArg.sizeClass ?? null,
67
+ documentId: inputArg.documentId ?? null,
68
+ sourceUrl: inputArg.sourceUrl ?? null,
69
+ };
70
+ };
71
+
72
+ const sha256Of = async (bytesArg: Uint8Array<ArrayBuffer>): Promise<string> => {
73
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytesArg));
74
+ return [...digest].map((byteArg) => byteArg.toString(16).padStart(2, '0')).join('');
75
+ };
76
+
77
+ /**
78
+ * Keeps a published statement as a caller stores it: the filing as the caller states it, the
79
+ * document byte for byte with its SHA-256, and what could be read from it: an ESEF report by
80
+ * `parseEsefPackage` or `parseEsefReport`, any other HTML or PDF by `parseStatementDocument`. A
81
+ * document that cannot be read with certainty is kept with `parsed` null and `parseFailure` saying
82
+ * why. Throws `invalid_input`, `unsupported_media_type` and, for a document larger than
83
+ * `maxStatementBytes`, `too_large`, as nothing would be kept.
84
+ */
85
+ export const readFinancialStatement = async (optionsArg: IReadFinancialStatementOptions): Promise<IFinancialStatement> => {
86
+ if (!(optionsArg?.bytes instanceof Uint8Array)) {
87
+ invalid('bytes must be a Uint8Array.');
88
+ }
89
+ const filing = filingOf(optionsArg.filing);
90
+ if (optionsArg.bytes.byteLength > maxStatementBytes) {
91
+ throw new FinancialStatementError(
92
+ 'too_large',
93
+ `The document has ${optionsArg.bytes.byteLength} bytes, more than the ${maxStatementBytes} read here.`
94
+ );
95
+ }
96
+ const mediaType = statementMediaTypeOf(String(optionsArg.mediaType));
97
+ if (!mediaType) {
98
+ throw new FinancialStatementError(
99
+ 'unsupported_media_type',
100
+ `A statement in "${optionsArg.mediaType}" is not read here; HTML, PDF, XHTML and ZIP packages are.`
101
+ );
102
+ }
103
+ const bytes = new Uint8Array(optionsArg.bytes);
104
+ const sha256 = await sha256Of(bytes);
105
+ let parsed: TParsedStatement | null = null;
106
+ let parseFailure: IStatementParseFailure | null = null;
107
+ try {
108
+ if (mediaType === 'application/zip') {
109
+ parsed = await parseEsefPackage(bytes);
110
+ } else if (
111
+ mediaType === 'application/xhtml+xml' ||
112
+ (mediaType === 'text/html' && new TextDecoder('windows-1252').decode(bytes.subarray(0, 65536)).includes(inlineXbrlNamespace))
113
+ ) {
114
+ parsed = parseEsefReport(bytes);
115
+ } else {
116
+ parsed = await parseStatementDocument(bytes, mediaType);
117
+ }
118
+ } catch (error) {
119
+ if (!(error instanceof FinancialStatementError) || !parseFailureCodes.has(error.code)) {
120
+ throw error;
121
+ }
122
+ parseFailure = { code: error.code as IStatementParseFailure['code'], message: error.message };
123
+ }
124
+ return {
125
+ filing,
126
+ raw: { bytes, mediaType, sha256 },
127
+ parsed,
128
+ parseFailure,
129
+ obtainedAt: (optionsArg.now ?? Date.now)(),
130
+ notice:
131
+ filing.source === 'unternehmensregister'
132
+ ? unternehmensregisterNotice(filing.publishedOn as string, filing.kind === 'deposited_balance_sheet')
133
+ : null,
134
+ };
135
+ };
@@ -120,14 +120,18 @@ const decodeUtf8 = (bytesArg: Uint8Array): string => new TextDecoder('utf-8').de
120
120
  * plain HTTP and without storage.
121
121
  *
122
122
  * Every call opens its own visit to the portal and forgets it afterwards: nothing is cached or
123
- * shared between calls. The client never repeats a request by itself, never logs, and asks its
124
- * gate before every request the portal's terms count:
123
+ * shared between calls. The client never logs, and asks its gate before every request the portal's
124
+ * terms count:
125
125
  * - `search()` makes one counted search;
126
126
  * - `getEntry()` makes one counted search and, when the portal offers structured content for the
127
127
  * entry, one counted retrieval.
128
128
  *
129
129
  * Each search also loads the search form and the result list, two pages the terms do not count.
130
130
  *
131
+ * The client repeats a request only when the portal closed the connection without answering it,
132
+ * and only once: the portal's front end does that to have the request sent again. A counted request
133
+ * sent once more asks the gate again and counts again.
134
+ *
131
135
  * The portal's terms allow single lookups for information only, at most 60 searches or
132
136
  * retrievals per hour, and no systematic retrieval to build or update a copy of the registers.
133
137
  */
@@ -288,6 +292,12 @@ export class HandelsregisterClient {
288
292
  }
289
293
  }
290
294
 
295
+ /** asks the gate for a counted request the portal dropped before it is sent once more, and counts it */
296
+ private async countAgain(kindArg: TRegisterCountedRequestKind, stepArg: THandelsregisterStep, progressArg: ICallProgress): Promise<void> {
297
+ await this.acquireSlot(kindArg, stepArg, progressArg);
298
+ progressArg.countedRequests += 1;
299
+ }
300
+
291
301
  private async runSearch(planArg: ISearchPlan, progressArg: ICallProgress, sessionArg: PortalSession): Promise<ISearchOutcome> {
292
302
  await this.acquireSlot('search', 'search', progressArg);
293
303
  try {
@@ -333,7 +343,9 @@ export class HandelsregisterClient {
333
343
 
334
344
  progressArg.step = 'search';
335
345
  progressArg.countedRequests += 1;
336
- const resultPage = await sessionArg.post(sessionArg.resolve(form.action, formPage.url), fields);
346
+ const resultPage = await sessionArg.post(sessionArg.resolve(form.action, formPage.url), fields, () =>
347
+ this.countAgain('search', 'search', progressArg)
348
+ );
337
349
 
338
350
  progressArg.step = 'result_list';
339
351
  const resultText = decodeUtf8(resultPage.body);
@@ -406,7 +418,9 @@ export class HandelsregisterClient {
406
418
  forms.setField(fields, name, value);
407
419
  }
408
420
  progressArg.countedRequests += 1;
409
- const answer = await sessionArg.post(sessionArg.resolve(resultForm.action, outcomeArg.pageUrl), fields);
421
+ const answer = await sessionArg.post(sessionArg.resolve(resultForm.action, outcomeArg.pageUrl), fields, () =>
422
+ this.countAgain('retrieval', 'structured_content', progressArg)
423
+ );
410
424
  if (/html/i.test(answer.contentType)) {
411
425
  this.throwIfBlocked(decodeUtf8(answer.body));
412
426
  throw new HandelsregisterError('unexpected_response', 'The portal answered with a page instead of the structured content.');
@@ -7,6 +7,8 @@ import type { IRegisterMatch } from './interfaces.js';
7
7
  * - `aborted`: the caller's signal aborted the call;
8
8
  * - `timeout`: a request to the portal took longer than the client's limit;
9
9
  * - `unreachable`: the portal could not be reached (name resolution, connection, TLS);
10
+ * - `dropped`: the portal closed the connection without answering, also when the request was sent
11
+ * once more. Its front end does so when it lost its back end's answer;
10
12
  * - `refused`: the portal answered with an HTTP error, for example 403 when it blocks an address,
11
13
  * 429 or 503 when it is overloaded (`status` carries the code), or with its notice that it refuses
12
14
  * retrievals from this address (`portalMessage` carries its words);
@@ -23,6 +25,7 @@ export type THandelsregisterErrorCode =
23
25
  | 'aborted'
24
26
  | 'timeout'
25
27
  | 'unreachable'
28
+ | 'dropped'
26
29
  | 'refused'
27
30
  | 'too_large'
28
31
  | 'unexpected_response'
@@ -25,6 +25,26 @@ export interface IPortalResponse {
25
25
  const redirectStatuses = new Set([301, 302, 303]);
26
26
  const maxRedirects = 3;
27
27
 
28
+ /**
29
+ * error codes of a connection the other side closed or reset before it answered: undici's
30
+ * `UND_ERR_SOCKET` (the `fetch` of Node.js: "other side closed") and the system's `ECONNRESET` and
31
+ * `EPIPE`
32
+ */
33
+ const closedConnectionCodes = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE']);
34
+
35
+ /** whether a failed `fetch` failed because the connection was closed before any answer arrived */
36
+ const closedWithoutAnswer = (errorArg: unknown): boolean => {
37
+ let current: unknown = errorArg;
38
+ for (let depth = 0; depth < 4 && current instanceof Error; depth++) {
39
+ const code = (current as Error & { code?: unknown }).code;
40
+ if (typeof code === 'string' && closedConnectionCodes.has(code)) {
41
+ return true;
42
+ }
43
+ current = current.cause;
44
+ }
45
+ return false;
46
+ };
47
+
28
48
  /**
29
49
  * One visit to the register portal: a cookie jar, the fixed language and a limit for every
30
50
  * request. It lives for one call of the client and is dropped afterwards, so nothing is shared
@@ -45,17 +65,25 @@ export class PortalSession {
45
65
  return new URL(targetArg, baseArg).toString();
46
66
  }
47
67
 
48
- public async get(urlArg: string): Promise<IPortalResponse> {
49
- return this.request('GET', urlArg);
68
+ /**
69
+ * Loads a page. Like `post()`, it sends the request once more when the portal closes the
70
+ * connection without answering; `beforeRepeatArg` runs before that.
71
+ */
72
+ public async get(urlArg: string, beforeRepeatArg?: () => Promise<void>): Promise<IPortalResponse> {
73
+ return this.request('GET', urlArg, undefined, beforeRepeatArg);
50
74
  }
51
75
 
52
- /** submits a form as `application/x-www-form-urlencoded` */
53
- public async post(urlArg: string, fieldsArg: Array<[string, string]>): Promise<IPortalResponse> {
54
- return this.request('POST', urlArg, new URLSearchParams(fieldsArg).toString());
76
+ /**
77
+ * Submits a form as `application/x-www-form-urlencoded`. When the portal closes the connection
78
+ * without answering, the form is sent once more after `beforeRepeatArg`; a redirect it answers
79
+ * with is followed the same way, without `beforeRepeatArg`.
80
+ */
81
+ public async post(urlArg: string, fieldsArg: Array<[string, string]>, beforeRepeatArg?: () => Promise<void>): Promise<IPortalResponse> {
82
+ return this.request('POST', urlArg, new URLSearchParams(fieldsArg).toString(), beforeRepeatArg);
55
83
  }
56
84
 
57
85
  private fail(
58
- codeArg: 'aborted' | 'timeout' | 'unreachable' | 'refused' | 'too_large' | 'unexpected_response',
86
+ codeArg: 'aborted' | 'timeout' | 'unreachable' | 'dropped' | 'refused' | 'too_large' | 'unexpected_response',
59
87
  messageArg: string,
60
88
  extraArg: { status?: number; cause?: unknown } = {}
61
89
  ): HandelsregisterError {
@@ -66,7 +94,12 @@ export class PortalSession {
66
94
  });
67
95
  }
68
96
 
69
- private async request(methodArg: 'GET' | 'POST', urlArg: string, bodyArg?: string): Promise<IPortalResponse> {
97
+ private async request(
98
+ methodArg: 'GET' | 'POST',
99
+ urlArg: string,
100
+ bodyArg?: string,
101
+ beforeRepeatArg?: () => Promise<void>
102
+ ): Promise<IPortalResponse> {
70
103
  let method = methodArg;
71
104
  let url = urlArg;
72
105
  let body = bodyArg;
@@ -74,7 +107,7 @@ export class PortalSession {
74
107
  if (new URL(url).origin !== this.origin) {
75
108
  throw this.fail('unexpected_response', 'The portal pointed to another site.');
76
109
  }
77
- const response = await this.send(method, url, body);
110
+ const response = await this.sendRepeating(method, url, body, redirects === 0 ? beforeRepeatArg : undefined);
78
111
  if (redirectStatuses.has(response.status)) {
79
112
  const location = response.headers.get('location');
80
113
  await response.body?.cancel();
@@ -100,6 +133,39 @@ export class PortalSession {
100
133
  }
101
134
  }
102
135
 
136
+ /**
137
+ * Sends a request, and once more when the portal closed the connection without answering it. The
138
+ * portal's front end, Apache httpd with mod_proxy_http before the application servers, closes a
139
+ * kept-alive connection without any answer when it could not read its back end's answer, so that
140
+ * the client sends the request again, as browsers do. Every request of this client reads; sending
141
+ * one again changes nothing at the portal. A second close without an answer ends in `dropped`.
142
+ */
143
+ private async sendRepeating(
144
+ methodArg: 'GET' | 'POST',
145
+ urlArg: string,
146
+ bodyArg: string | undefined,
147
+ beforeRepeatArg?: () => Promise<void>
148
+ ): Promise<Response> {
149
+ try {
150
+ return await this.send(methodArg, urlArg, bodyArg);
151
+ } catch (error) {
152
+ if (!(error instanceof HandelsregisterError) || error.code !== 'dropped') {
153
+ throw error;
154
+ }
155
+ }
156
+ await beforeRepeatArg?.();
157
+ try {
158
+ return await this.send(methodArg, urlArg, bodyArg);
159
+ } catch (error) {
160
+ if (error instanceof HandelsregisterError && error.code === 'dropped') {
161
+ throw this.fail('dropped', 'The register portal closed the connection without answering, also when the request was sent once more.', {
162
+ cause: error.cause,
163
+ });
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+
103
169
  private async send(methodArg: 'GET' | 'POST', urlArg: string, bodyArg?: string): Promise<Response> {
104
170
  if (this.settings.signal?.aborted) {
105
171
  throw this.fail('aborted', 'The call was aborted.', { cause: this.settings.signal.reason });
@@ -125,7 +191,10 @@ export class PortalSession {
125
191
  signal: this.requestSignal(),
126
192
  });
127
193
  } catch (error) {
128
- throw this.failureOf(error);
194
+ const failure = this.failureOf(error);
195
+ throw failure.code === 'unreachable' && closedWithoutAnswer(error)
196
+ ? this.fail('dropped', 'The register portal closed the connection without answering.', { cause: error })
197
+ : failure;
129
198
  }
130
199
  for (const cookie of response.headers.getSetCookie()) {
131
200
  const pair = cookie.split(';', 1)[0];
@@ -92,7 +92,10 @@ export interface IRegisterSearchResult {
92
92
  portalNotice?: string;
93
93
  /** epoch ms: when the result list arrived */
94
94
  retrievedAt: number;
95
- /** requests of this call that count against the portal's limit of 60 per hour (always 1) */
95
+ /**
96
+ * requests of this call that count against the portal's limit of 60 per hour: 1, or 2 when the
97
+ * portal dropped the search without an answer and it was sent once more
98
+ */
96
99
  countedRequests: number;
97
100
  notice: IStructuredContentNotice;
98
101
  }
@@ -202,7 +205,11 @@ export interface IRegisterEntry {
202
205
  structuredContent: IRegisterStructuredContent | null;
203
206
  /** epoch ms: when the last answer of the portal arrived */
204
207
  retrievedAt: number;
205
- /** requests of this call that count against the portal's limit: 2, or 1 without structured content */
208
+ /**
209
+ * requests of this call that count against the portal's limit: 2, or 1 without structured content;
210
+ * one more for the search or the retrieval when the portal dropped it without an answer and it was
211
+ * sent once more
212
+ */
206
213
  countedRequests: number;
207
214
  notice: IStructuredContentNotice;
208
215
  }