@fin.cx/opendata 3.6.0 → 4.1.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 (93) hide show
  1. package/.smartconfig.json +23 -20
  2. package/dist_ts/00_commitinfo_data.js +3 -3
  3. package/dist_ts/handelsregister/classes.handelsregisterclient.d.ts +64 -0
  4. package/dist_ts/handelsregister/classes.handelsregisterclient.js +397 -0
  5. package/dist_ts/handelsregister/classes.handelsregistererror.d.ts +56 -0
  6. package/dist_ts/handelsregister/classes.handelsregistererror.js +37 -0
  7. package/dist_ts/handelsregister/classes.portalsession.d.ts +46 -0
  8. package/dist_ts/handelsregister/classes.portalsession.js +160 -0
  9. package/dist_ts/handelsregister/classes.slidingwindowgate.d.ts +32 -0
  10. package/dist_ts/handelsregister/classes.slidingwindowgate.js +55 -0
  11. package/dist_ts/handelsregister/constants.d.ts +13 -0
  12. package/dist_ts/handelsregister/constants.js +19 -0
  13. package/dist_ts/handelsregister/data.codelists.d.ts +29 -0
  14. package/dist_ts/handelsregister/data.codelists.js +616 -0
  15. package/dist_ts/handelsregister/forms.d.ts +27 -0
  16. package/dist_ts/handelsregister/forms.js +77 -0
  17. package/dist_ts/handelsregister/html.d.ts +26 -0
  18. package/dist_ts/handelsregister/html.js +82 -0
  19. package/dist_ts/handelsregister/index.d.ts +9 -0
  20. package/dist_ts/handelsregister/index.js +10 -0
  21. package/dist_ts/handelsregister/interfaces.d.ts +240 -0
  22. package/dist_ts/handelsregister/interfaces.js +2 -0
  23. package/dist_ts/handelsregister/parse.searchresults.d.ts +35 -0
  24. package/dist_ts/handelsregister/parse.searchresults.js +175 -0
  25. package/dist_ts/handelsregister/parse.structuredcontent.d.ts +6 -0
  26. package/dist_ts/handelsregister/parse.structuredcontent.js +306 -0
  27. package/dist_ts/handelsregister/plugins.d.ts +4 -0
  28. package/dist_ts/handelsregister/plugins.js +8 -0
  29. package/dist_ts/handelsregister/references.d.ts +38 -0
  30. package/dist_ts/handelsregister/references.js +146 -0
  31. package/dist_ts/holidays/calendar.d.ts +32 -0
  32. package/dist_ts/holidays/calendar.js +200 -0
  33. package/dist_ts/holidays/classes.holidaycalendarerror.d.ts +14 -0
  34. package/dist_ts/holidays/classes.holidaycalendarerror.js +10 -0
  35. package/dist_ts/holidays/data.holidays.d.ts +46 -0
  36. package/dist_ts/holidays/data.holidays.js +124 -0
  37. package/dist_ts/holidays/index.d.ts +4 -0
  38. package/dist_ts/holidays/index.js +7 -0
  39. package/dist_ts/holidays/interfaces.d.ts +42 -0
  40. package/dist_ts/holidays/interfaces.js +2 -0
  41. package/dist_ts/index.d.ts +0 -2
  42. package/dist_ts/index.js +1 -3
  43. package/dist_ts/plugins.d.ts +2 -20
  44. package/dist_ts/plugins.js +3 -22
  45. package/package.json +40 -37
  46. package/readme.md +250 -119
  47. package/ts/00_commitinfo_data.ts +2 -2
  48. package/ts/handelsregister/classes.handelsregisterclient.ts +474 -0
  49. package/ts/handelsregister/classes.handelsregistererror.ts +82 -0
  50. package/ts/handelsregister/classes.portalsession.ts +191 -0
  51. package/ts/handelsregister/classes.slidingwindowgate.ts +74 -0
  52. package/ts/handelsregister/constants.ts +30 -0
  53. package/ts/handelsregister/data.codelists.ts +640 -0
  54. package/ts/handelsregister/forms.ts +99 -0
  55. package/ts/handelsregister/html.ts +113 -0
  56. package/ts/handelsregister/index.ts +27 -0
  57. package/ts/handelsregister/interfaces.ts +272 -0
  58. package/ts/handelsregister/parse.searchresults.ts +241 -0
  59. package/ts/handelsregister/parse.structuredcontent.ts +338 -0
  60. package/ts/handelsregister/plugins.ts +15 -0
  61. package/ts/handelsregister/references.ts +189 -0
  62. package/ts/holidays/calendar.ts +261 -0
  63. package/ts/holidays/classes.holidaycalendarerror.ts +20 -0
  64. package/ts/holidays/data.holidays.ts +177 -0
  65. package/ts/holidays/index.ts +18 -0
  66. package/ts/holidays/interfaces.ts +87 -0
  67. package/ts/index.ts +0 -2
  68. package/ts/plugins.ts +0 -39
  69. package/dist_ts/classes.businessrecord.d.ts +0 -38
  70. package/dist_ts/classes.businessrecord.js +0 -90
  71. package/dist_ts/classes.handelsregister.d.ts +0 -134
  72. package/dist_ts/classes.handelsregister.js +0 -307
  73. package/dist_ts/classes.jsonldata.d.ts +0 -43
  74. package/dist_ts/classes.jsonldata.js +0 -68
  75. package/dist_ts/classes.main.opendata.d.ts +0 -25
  76. package/dist_ts/classes.main.opendata.js +0 -91
  77. package/dist_ts/laws/classes.lawrecord.d.ts +0 -24
  78. package/dist_ts/laws/classes.lawrecord.js +0 -181
  79. package/dist_ts/laws/classes.lawservice.d.ts +0 -52
  80. package/dist_ts/laws/classes.lawservice.js +0 -740
  81. package/dist_ts/laws/index.d.ts +0 -3
  82. package/dist_ts/laws/index.js +0 -4
  83. package/dist_ts/laws/interfaces.law.d.ts +0 -35
  84. package/dist_ts/laws/interfaces.law.js +0 -2
  85. package/readme.hints.md +0 -89
  86. package/ts/classes.businessrecord.ts +0 -67
  87. package/ts/classes.handelsregister.ts +0 -385
  88. package/ts/classes.jsonldata.ts +0 -114
  89. package/ts/classes.main.opendata.ts +0 -120
  90. package/ts/laws/classes.lawrecord.ts +0 -81
  91. package/ts/laws/classes.lawservice.ts +0 -1010
  92. package/ts/laws/index.ts +0 -3
  93. package/ts/laws/interfaces.law.ts +0 -48
@@ -0,0 +1,474 @@
1
+ import { HandelsregisterError, type THandelsregisterStep } from './classes.handelsregistererror.js';
2
+ import { PortalSession, type ICallProgress, type IPortalResponse } from './classes.portalsession.js';
3
+ import {
4
+ defaultMaxResponseBytes,
5
+ defaultPortalUrl,
6
+ defaultRequestTimeoutMs,
7
+ defaultUserAgent,
8
+ registerTypes,
9
+ structuredContentNotice,
10
+ } from './constants.js';
11
+ import { registerCourts } from './data.codelists.js';
12
+ import * as forms from './forms.js';
13
+ import * as html from './html.js';
14
+ import type {
15
+ IHandelsregisterClientOptions,
16
+ IRegisterCallOptions,
17
+ IRegisterCourt,
18
+ IRegisterEntry,
19
+ IRegisterMatch,
20
+ IRegisterReferenceInput,
21
+ IRegisterRequestGate,
22
+ IRegisterSearchResult,
23
+ IRegisterStructuredContent,
24
+ TRegisterCountedRequestKind,
25
+ TRegisterSearchQuery,
26
+ TRegisterType,
27
+ } from './interfaces.js';
28
+ import { readResultList, type IParsedResultList, type IResultRow } from './parse.searchresults.js';
29
+ import { parseStructuredContent } from './parse.structuredcontent.js';
30
+ import { parseRegisterNumber, type IRegisterNumberParts } from './references.js';
31
+
32
+ const searchFormPath = '/rp_web/erweitertesuche/welcome.xhtml';
33
+ const searchFormId = 'form';
34
+ const field = {
35
+ keywords: 'form:schlagwoerter',
36
+ keywordMode: 'form:schlagwortOptionen',
37
+ seat: 'form:NiederlassungSitz',
38
+ registerType: 'form:registerArt_input',
39
+ registerNumber: 'form:registerNummer',
40
+ court: 'form:registergericht_input',
41
+ pageSize: 'form:ergebnisseProSeite_input',
42
+ includeClosed: 'form:auchGeloeschte_input',
43
+ searchButton: 'form:btnSuche',
44
+ };
45
+ const searchFormMessagesId = 'form:messagesSuchMaske';
46
+ /**
47
+ * The page the portal shows when it refuses a retrieval: for the TOR network and for addresses it
48
+ * has blocked (the portal's FAQ, question 6)
49
+ */
50
+ const blockedRetrievalPattern = /Leider können wir den Dokumentenabruf[^.]*\./;
51
+ const keywordModes = { all: '1', any: '2', exact: '3' } as const;
52
+ const maxKeywordLength = 500;
53
+
54
+ /** a result list and the address of the page that holds it */
55
+ interface ISearchOutcome {
56
+ list: IParsedResultList;
57
+ pageUrl: string;
58
+ }
59
+
60
+ /** what the portal's search form is filled with */
61
+ interface ISearchPlan {
62
+ keywords: string;
63
+ keywordMode: string;
64
+ seat: string;
65
+ registerType: string;
66
+ digits: string;
67
+ courtCode: string;
68
+ includeClosed: boolean;
69
+ }
70
+
71
+ const invalidQuery = (messageArg: string): HandelsregisterError =>
72
+ new HandelsregisterError('invalid_query', messageArg, { countedRequests: 0 });
73
+
74
+ /** the error with the step and the counted requests of the call it ended */
75
+ const inCall = (errorArg: HandelsregisterError, progressArg: ICallProgress): HandelsregisterError =>
76
+ new HandelsregisterError(errorArg.code, errorArg.message, {
77
+ status: errorArg.status,
78
+ retryAt: errorArg.retryAt,
79
+ step: errorArg.step ?? progressArg.step,
80
+ countedRequests: progressArg.countedRequests,
81
+ matches: errorArg.matches,
82
+ portalMessage: errorArg.portalMessage,
83
+ cause: errorArg.cause,
84
+ });
85
+
86
+ const checkType = (typeArg: string | undefined): TRegisterType | undefined => {
87
+ if (typeArg === undefined) {
88
+ return undefined;
89
+ }
90
+ const type = registerTypes.find((knownArg) => knownArg === typeArg);
91
+ if (!type) {
92
+ throw invalidQuery(`Unknown register type; use one of ${registerTypes.join(', ')}.`);
93
+ }
94
+ return type;
95
+ };
96
+
97
+ const checkCourt = (courtCodeArg: string | undefined): string => {
98
+ if (courtCodeArg === undefined) {
99
+ return '';
100
+ }
101
+ if (!registerCourts.some((courtArg) => courtArg.code === courtCodeArg)) {
102
+ throw invalidQuery('Unknown register court code; see registerCourts.');
103
+ }
104
+ return courtCodeArg;
105
+ };
106
+
107
+ const checkNumber = (numberArg: string): IRegisterNumberParts => {
108
+ const parts = parseRegisterNumber(numberArg);
109
+ if (!parts) {
110
+ throw invalidQuery('A register number is digits, optionally followed by a location suffix such as HB.');
111
+ }
112
+ return parts;
113
+ };
114
+
115
+ const decodeUtf8 = (bytesArg: Uint8Array): string => new TextDecoder('utf-8').decode(bytesArg);
116
+
117
+ /**
118
+ * Looks up entries of the German commercial, cooperative, partnership, association and civil-law
119
+ * partnership registers on the register portal of the federal states (handelsregister.de), over
120
+ * plain HTTP and without storage.
121
+ *
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:
125
+ * - `search()` makes one counted search;
126
+ * - `getEntry()` makes one counted search and, when the portal offers structured content for the
127
+ * entry, one counted retrieval.
128
+ *
129
+ * Each search also loads the search form and the result list, two pages the terms do not count.
130
+ *
131
+ * The portal's terms allow single lookups for information only, at most 60 searches or
132
+ * retrievals per hour, and no systematic retrieval to build or update a copy of the registers.
133
+ */
134
+ export class HandelsregisterClient {
135
+ private gate: IRegisterRequestGate;
136
+ private portalUrl: string;
137
+ private userAgent: string;
138
+ private requestTimeoutMs: number;
139
+ private maxResponseBytes: number;
140
+ private now: () => number;
141
+
142
+ constructor(optionsArg: IHandelsregisterClientOptions) {
143
+ if (!optionsArg || !optionsArg.gate) {
144
+ throw new TypeError('HandelsregisterClient needs a gate; see SlidingWindowGate.');
145
+ }
146
+ this.gate = optionsArg.gate;
147
+ this.portalUrl = optionsArg.portalUrl ?? defaultPortalUrl;
148
+ this.userAgent = optionsArg.userAgent ?? defaultUserAgent;
149
+ this.requestTimeoutMs = optionsArg.requestTimeoutMs ?? defaultRequestTimeoutMs;
150
+ this.maxResponseBytes = optionsArg.maxResponseBytes ?? defaultMaxResponseBytes;
151
+ this.now = optionsArg.now ?? Date.now;
152
+ const protocol = new URL(this.portalUrl).protocol;
153
+ if (protocol !== 'https:' && protocol !== 'http:') {
154
+ throw new TypeError('portalUrl must be an http or https URL.');
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Searches the registers by name or by register number. Returns every row the portal lists, in
160
+ * its order, and whether the portal cut the list at its maximum. One counted search.
161
+ *
162
+ * A number is searched by its digits: rows with any location suffix are returned.
163
+ */
164
+ public async search(queryArg: TRegisterSearchQuery, optionsArg: IRegisterCallOptions = {}): Promise<IRegisterSearchResult> {
165
+ const plan = this.planSearch(queryArg);
166
+ const progress: ICallProgress = { countedRequests: 0 };
167
+ const { list } = await this.runSearch(plan, progress, this.openSession(optionsArg, progress));
168
+ const result: IRegisterSearchResult = {
169
+ matches: list.rows.map((rowArg) => rowArg.match),
170
+ truncated: list.truncated,
171
+ retrievedAt: this.now(),
172
+ countedRequests: progress.countedRequests,
173
+ notice: structuredContentNotice,
174
+ };
175
+ if (list.portalNotice) {
176
+ result.portalNotice = list.portalNotice;
177
+ }
178
+ return result;
179
+ }
180
+
181
+ /**
182
+ * Retrieves one register sheet: its row in the result list and, when the portal offers it, the
183
+ * parsed structured content (SI). Closed sheets are included, so a deleted entry is found with
184
+ * its status. One counted search, plus one counted retrieval when there is structured content.
185
+ *
186
+ * Fails with `not_found` when no sheet has the number at that court (with the entries whose
187
+ * location suffix differs in `matches`), and with `ambiguous` when a number without suffix fits
188
+ * several sheets. Never picks one of several.
189
+ */
190
+ public async getEntry(referenceArg: IRegisterReferenceInput, optionsArg: IRegisterCallOptions = {}): Promise<IRegisterEntry> {
191
+ const type = checkType(referenceArg.type);
192
+ if (!type) {
193
+ throw invalidQuery('A reference needs a register type.');
194
+ }
195
+ const courtCode = checkCourt(referenceArg.courtCode);
196
+ if (!courtCode) {
197
+ throw invalidQuery('A reference needs a register court.');
198
+ }
199
+ const number = checkNumber(referenceArg.number);
200
+ const progress: ICallProgress = { countedRequests: 0 };
201
+ const session = this.openSession(optionsArg, progress);
202
+ const outcome = await this.runSearch(
203
+ { keywords: '', keywordMode: keywordModes.all, seat: '', registerType: type, digits: number.digits, courtCode, includeClosed: true },
204
+ progress,
205
+ session
206
+ );
207
+
208
+ const row = this.pickRow(outcome.list, { courtCode, type, number }, progress);
209
+ let structuredContent: IRegisterStructuredContent | null = null;
210
+ let retrievedAt = this.now();
211
+ if (row.match.documents.includes('SI')) {
212
+ structuredContent = await this.retrieveStructuredContent(row, outcome, session, progress);
213
+ retrievedAt = this.now();
214
+ }
215
+ return {
216
+ match: row.match,
217
+ structuredContent,
218
+ retrievedAt,
219
+ countedRequests: progress.countedRequests,
220
+ notice: structuredContentNotice,
221
+ };
222
+ }
223
+
224
+ private openSession(optionsArg: IRegisterCallOptions, progressArg: ICallProgress): PortalSession {
225
+ return new PortalSession(
226
+ {
227
+ portalUrl: this.portalUrl,
228
+ userAgent: this.userAgent,
229
+ requestTimeoutMs: this.requestTimeoutMs,
230
+ maxResponseBytes: this.maxResponseBytes,
231
+ signal: optionsArg.signal,
232
+ },
233
+ progressArg
234
+ );
235
+ }
236
+
237
+ private planSearch(queryArg: TRegisterSearchQuery): ISearchPlan {
238
+ if ('name' in queryArg) {
239
+ const keywords = queryArg.name.replace(/\s+/g, ' ').trim();
240
+ if (!keywords) {
241
+ throw invalidQuery('A name search needs at least one keyword.');
242
+ }
243
+ if (keywords.length > maxKeywordLength) {
244
+ throw invalidQuery(`A name search takes at most ${maxKeywordLength} characters.`);
245
+ }
246
+ const mode = queryArg.match ?? 'all';
247
+ if (!(mode in keywordModes)) {
248
+ throw invalidQuery('match is all, any or exact.');
249
+ }
250
+ return {
251
+ keywords,
252
+ keywordMode: keywordModes[mode],
253
+ seat: (queryArg.seat ?? '').replace(/\s+/g, ' ').trim(),
254
+ registerType: checkType(queryArg.type) ?? '',
255
+ digits: '',
256
+ courtCode: checkCourt(queryArg.courtCode),
257
+ includeClosed: queryArg.includeClosed === true,
258
+ };
259
+ }
260
+ const type = checkType(queryArg.type);
261
+ if (!type) {
262
+ throw invalidQuery('A number search needs a register type.');
263
+ }
264
+ return {
265
+ keywords: '',
266
+ keywordMode: keywordModes.all,
267
+ seat: '',
268
+ registerType: type,
269
+ digits: checkNumber(queryArg.number).digits,
270
+ courtCode: checkCourt(queryArg.courtCode),
271
+ includeClosed: queryArg.includeClosed === true,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Asks the gate for one counted request. A refusal that is a `HandelsregisterError` reaches the
277
+ * caller with the step it guarded and the requests the call had counted before it; any other
278
+ * error of the gate reaches the caller unchanged.
279
+ */
280
+ private async acquireSlot(kindArg: TRegisterCountedRequestKind, stepArg: THandelsregisterStep, progressArg: ICallProgress): Promise<void> {
281
+ try {
282
+ await this.gate.acquire({ kind: kindArg });
283
+ } catch (error) {
284
+ if (error instanceof HandelsregisterError) {
285
+ throw inCall(error, { step: stepArg, countedRequests: progressArg.countedRequests });
286
+ }
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ private async runSearch(planArg: ISearchPlan, progressArg: ICallProgress, sessionArg: PortalSession): Promise<ISearchOutcome> {
292
+ await this.acquireSlot('search', 'search', progressArg);
293
+ try {
294
+ progressArg.step = 'search_form';
295
+ const formPage = await sessionArg.get(sessionArg.resolve(searchFormPath));
296
+ const formDocument = this.htmlDocumentOf(formPage, 'The search form is not a web page.');
297
+ if (html.documentLanguage(formDocument) !== 'de') {
298
+ throw new HandelsregisterError('unexpected_response', 'The search form is not in German.');
299
+ }
300
+ const form = forms.readForm(formDocument, searchFormId);
301
+ if (!form) {
302
+ throw new HandelsregisterError('unexpected_response', 'The page holds no search form.');
303
+ }
304
+ const missing = Object.values(field).find((nameArg) => !form.controlNames.has(nameArg));
305
+ if (missing) {
306
+ throw new HandelsregisterError('unexpected_response', 'The search form lacks a field this version fills.');
307
+ }
308
+ const courts = this.courtsOf(form);
309
+ if (planArg.courtCode && !courts.some((courtArg) => courtArg.code === planArg.courtCode)) {
310
+ throw new HandelsregisterError('invalid_query', 'The portal does not offer this register court.');
311
+ }
312
+ if (planArg.registerType && !(form.options.get(field.registerType) ?? []).some((optionArg) => optionArg.value === planArg.registerType)) {
313
+ throw new HandelsregisterError('unexpected_response', 'The search form does not offer this register type.');
314
+ }
315
+ if (!(form.options.get(field.pageSize) ?? []).some((optionArg) => optionArg.value === '100')) {
316
+ throw new HandelsregisterError('unexpected_response', 'The search form does not offer 100 results per page.');
317
+ }
318
+
319
+ const fields = [...form.fields];
320
+ forms.setField(fields, field.keywords, planArg.keywords);
321
+ forms.setField(fields, field.keywordMode, planArg.keywordMode);
322
+ forms.setField(fields, field.seat, planArg.seat);
323
+ forms.setField(fields, field.registerType, planArg.registerType);
324
+ forms.setField(fields, field.registerNumber, planArg.digits);
325
+ forms.setField(fields, field.court, planArg.courtCode);
326
+ forms.setField(fields, field.pageSize, '100');
327
+ if (planArg.includeClosed) {
328
+ forms.setField(fields, field.includeClosed, 'on');
329
+ } else {
330
+ forms.removeField(fields, field.includeClosed);
331
+ }
332
+ forms.setField(fields, field.searchButton, '');
333
+
334
+ progressArg.step = 'search';
335
+ progressArg.countedRequests += 1;
336
+ const resultPage = await sessionArg.post(sessionArg.resolve(form.action, formPage.url), fields);
337
+
338
+ progressArg.step = 'result_list';
339
+ const resultText = decodeUtf8(resultPage.body);
340
+ try {
341
+ return { list: readResultList(resultText, courts), pageUrl: resultPage.url };
342
+ } catch (error) {
343
+ if (error instanceof HandelsregisterError && error.code === 'unexpected_response') {
344
+ this.throwIfBlocked(resultText);
345
+ const message = this.searchFormMessage(resultText);
346
+ if (message) {
347
+ throw new HandelsregisterError('unexpected_response', 'The portal answered the search with a message instead of a result list.', {
348
+ portalMessage: message,
349
+ });
350
+ }
351
+ }
352
+ throw error;
353
+ }
354
+ } catch (error) {
355
+ throw error instanceof HandelsregisterError ? inCall(error, progressArg) : error;
356
+ }
357
+ }
358
+
359
+ private pickRow(
360
+ listArg: IParsedResultList,
361
+ referenceArg: { courtCode: string; type: TRegisterType; number: IRegisterNumberParts },
362
+ progressArg: ICallProgress
363
+ ): IResultRow {
364
+ const sameNumber = listArg.rows.filter((rowArg) => {
365
+ const reference = rowArg.match.reference;
366
+ const number = parseRegisterNumber(reference.number);
367
+ return reference.court.code === referenceArg.courtCode && reference.type === referenceArg.type && number?.digits === referenceArg.number.digits;
368
+ });
369
+ const fitting = referenceArg.number.suffix
370
+ ? sameNumber.filter((rowArg) => parseRegisterNumber(rowArg.match.reference.number)?.suffix === referenceArg.number.suffix)
371
+ : sameNumber;
372
+ const matches: IRegisterMatch[] = sameNumber.map((rowArg) => rowArg.match);
373
+ if (fitting.length === 0) {
374
+ throw new HandelsregisterError('not_found', 'No register sheet has this number at this court.', {
375
+ step: 'result_list',
376
+ countedRequests: progressArg.countedRequests,
377
+ matches,
378
+ });
379
+ }
380
+ if (fitting.length > 1) {
381
+ throw new HandelsregisterError('ambiguous', 'Several register sheets have this number at this court.', {
382
+ step: 'result_list',
383
+ countedRequests: progressArg.countedRequests,
384
+ matches,
385
+ });
386
+ }
387
+ return fitting[0];
388
+ }
389
+
390
+ private async retrieveStructuredContent(
391
+ rowArg: IResultRow,
392
+ outcomeArg: ISearchOutcome,
393
+ sessionArg: PortalSession,
394
+ progressArg: ICallProgress
395
+ ): Promise<IRegisterStructuredContent> {
396
+ await this.acquireSlot('retrieval', 'structured_content', progressArg);
397
+ try {
398
+ progressArg.step = 'structured_content';
399
+ const resultForm = outcomeArg.list.form;
400
+ const submission = rowArg.documentSubmissions.get('SI');
401
+ if (!resultForm || !submission) {
402
+ throw new HandelsregisterError('unexpected_response', 'The result list offers structured content without a way to retrieve it.');
403
+ }
404
+ const fields: Array<[string, string]> = [...resultForm.fields];
405
+ for (const [name, value] of submission) {
406
+ forms.setField(fields, name, value);
407
+ }
408
+ progressArg.countedRequests += 1;
409
+ const answer = await sessionArg.post(sessionArg.resolve(resultForm.action, outcomeArg.pageUrl), fields);
410
+ if (/html/i.test(answer.contentType)) {
411
+ this.throwIfBlocked(decodeUtf8(answer.body));
412
+ throw new HandelsregisterError('unexpected_response', 'The portal answered with a page instead of the structured content.');
413
+ }
414
+ let xml: string;
415
+ try {
416
+ xml = new TextDecoder('utf-8', { fatal: true }).decode(answer.body);
417
+ } catch (error) {
418
+ throw new HandelsregisterError('invalid_structured_content', 'The structured content is not valid UTF-8.', { cause: error });
419
+ }
420
+ const content = parseStructuredContent(xml);
421
+ this.checkContentBelongsToRow(content, rowArg.match);
422
+ return content;
423
+ } catch (error) {
424
+ throw error instanceof HandelsregisterError ? inCall(error, progressArg) : error;
425
+ }
426
+ }
427
+
428
+ /** the structured content must describe the sheet of the row it was retrieved for */
429
+ private checkContentBelongsToRow(contentArg: IRegisterStructuredContent, matchArg: IRegisterMatch): void {
430
+ const reference = matchArg.reference;
431
+ const contentNumber = contentArg.register.number ? parseRegisterNumber(contentArg.register.number) : undefined;
432
+ const rowNumber = parseRegisterNumber(reference.number);
433
+ if (
434
+ (contentArg.register.courtCode !== undefined && contentArg.register.courtCode !== reference.court.code) ||
435
+ (contentArg.register.type !== undefined && contentArg.register.type !== reference.type) ||
436
+ (contentNumber !== undefined && contentNumber.digits !== rowNumber?.digits)
437
+ ) {
438
+ throw new HandelsregisterError('unexpected_response', 'The structured content belongs to another register sheet.');
439
+ }
440
+ }
441
+
442
+ private htmlDocumentOf(pageArg: IPortalResponse, messageArg: string): html.THtmlDocument {
443
+ if (!/html/i.test(pageArg.contentType)) {
444
+ throw new HandelsregisterError('unexpected_response', messageArg);
445
+ }
446
+ return html.parseHtml(decodeUtf8(pageArg.body));
447
+ }
448
+
449
+ /** the register courts the search form offers */
450
+ private courtsOf(formArg: forms.IPortalForm): IRegisterCourt[] {
451
+ return (formArg.options.get(field.court) ?? [])
452
+ .filter((optionArg) => optionArg.value !== '')
453
+ .map((optionArg) => ({ code: optionArg.value, name: optionArg.label }));
454
+ }
455
+
456
+ /** refuses with the portal's words when the page is its notice of a blocked retrieval */
457
+ private throwIfBlocked(htmlArg: string): void {
458
+ const document = html.parseHtml(htmlArg);
459
+ const notice = blockedRetrievalPattern.exec(html.textOf(document));
460
+ if (notice) {
461
+ throw new HandelsregisterError('refused', 'The register portal refused the retrieval from this address.', {
462
+ portalMessage: notice[0],
463
+ });
464
+ }
465
+ }
466
+
467
+ /** the message the search form shows, when the portal answered with the form again */
468
+ private searchFormMessage(htmlArg: string): string | undefined {
469
+ const document = html.parseHtml(htmlArg);
470
+ const messages = html.findById(document, searchFormMessagesId);
471
+ const text = messages ? html.textOf(messages) : '';
472
+ return text ? text : undefined;
473
+ }
474
+ }
@@ -0,0 +1,82 @@
1
+ import type { IRegisterMatch } from './interfaces.js';
2
+
3
+ /**
4
+ * Why a call failed:
5
+ * - `invalid_query`: the query or reference cannot be sent as it is (nothing was sent);
6
+ * - `rate_limited`: `SlidingWindowGate` refused the request; `retryAt` says when the next is possible;
7
+ * - `aborted`: the caller's signal aborted the call;
8
+ * - `timeout`: a request to the portal took longer than the client's limit;
9
+ * - `unreachable`: the portal could not be reached (name resolution, connection, TLS);
10
+ * - `refused`: the portal answered with an HTTP error, for example 403 when it blocks an address,
11
+ * 429 or 503 when it is overloaded (`status` carries the code), or with its notice that it refuses
12
+ * retrievals from this address (`portalMessage` carries its words);
13
+ * - `too_large`: an answer exceeded the client's size limit;
14
+ * - `unexpected_response`: an answer did not have the shape this version reads: the portal changed
15
+ * its pages, answered in another language, or showed an error page;
16
+ * - `not_found`: no register sheet matches the reference;
17
+ * - `ambiguous`: several register sheets match the reference; `matches` lists them;
18
+ * - `invalid_structured_content`: the structured register content could not be read.
19
+ */
20
+ export type THandelsregisterErrorCode =
21
+ | 'invalid_query'
22
+ | 'rate_limited'
23
+ | 'aborted'
24
+ | 'timeout'
25
+ | 'unreachable'
26
+ | 'refused'
27
+ | 'too_large'
28
+ | 'unexpected_response'
29
+ | 'not_found'
30
+ | 'ambiguous'
31
+ | 'invalid_structured_content';
32
+
33
+ /** the step of a call that failed */
34
+ export type THandelsregisterStep = 'search_form' | 'search' | 'result_list' | 'structured_content';
35
+
36
+ export interface IHandelsregisterErrorDetails {
37
+ status?: number;
38
+ retryAt?: number;
39
+ step?: THandelsregisterStep;
40
+ countedRequests?: number;
41
+ matches?: IRegisterMatch[];
42
+ portalMessage?: string;
43
+ cause?: unknown;
44
+ }
45
+
46
+ /**
47
+ * The typed error of the register client. Its message names the failure and never contains
48
+ * register data, so it can be logged.
49
+ */
50
+ export class HandelsregisterError extends Error {
51
+ public readonly code: THandelsregisterErrorCode;
52
+ /** the HTTP status of a refusal */
53
+ public readonly status?: number;
54
+ /** epoch ms of the next possible request (`rate_limited`) */
55
+ public readonly retryAt?: number;
56
+ /** the step that failed */
57
+ public readonly step?: THandelsregisterStep;
58
+ /**
59
+ * counted requests the call had sent before it failed. A request that was sent may have counted
60
+ * at the portal even when its answer did not arrive.
61
+ */
62
+ public readonly countedRequests?: number;
63
+ /**
64
+ * the candidates of an `ambiguous` reference, and for `not_found` the entries with the same
65
+ * number whose location suffix differs
66
+ */
67
+ public readonly matches?: IRegisterMatch[];
68
+ /** a message the portal showed instead of the expected page, German */
69
+ public readonly portalMessage?: string;
70
+
71
+ constructor(codeArg: THandelsregisterErrorCode, messageArg: string, detailsArg: IHandelsregisterErrorDetails = {}) {
72
+ super(messageArg, detailsArg.cause === undefined ? undefined : { cause: detailsArg.cause });
73
+ this.name = 'HandelsregisterError';
74
+ this.code = codeArg;
75
+ this.status = detailsArg.status;
76
+ this.retryAt = detailsArg.retryAt;
77
+ this.step = detailsArg.step;
78
+ this.countedRequests = detailsArg.countedRequests;
79
+ this.matches = detailsArg.matches;
80
+ this.portalMessage = detailsArg.portalMessage;
81
+ }
82
+ }