@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.
- package/.smartconfig.json +6 -2
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/financialstatements/amounts.d.ts +58 -0
- package/dist_ts/financialstatements/amounts.js +130 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.d.ts +24 -0
- package/dist_ts/financialstatements/classes.financialstatementerror.js +10 -0
- package/dist_ts/financialstatements/constants.d.ts +16 -0
- package/dist_ts/financialstatements/constants.js +20 -0
- package/dist_ts/financialstatements/dates.d.ts +16 -0
- package/dist_ts/financialstatements/dates.js +72 -0
- package/dist_ts/financialstatements/html.d.ts +21 -0
- package/dist_ts/financialstatements/html.js +92 -0
- package/dist_ts/financialstatements/index.d.ts +6 -0
- package/dist_ts/financialstatements/index.js +11 -0
- package/dist_ts/financialstatements/interfaces.d.ts +258 -0
- package/dist_ts/financialstatements/interfaces.js +2 -0
- package/dist_ts/financialstatements/layout.d.ts +20 -0
- package/dist_ts/financialstatements/layout.html.d.ts +8 -0
- package/dist_ts/financialstatements/layout.html.js +125 -0
- package/dist_ts/financialstatements/layout.js +2 -0
- package/dist_ts/financialstatements/layout.pdf.d.ts +6 -0
- package/dist_ts/financialstatements/layout.pdf.js +229 -0
- package/dist_ts/financialstatements/mediatypes.d.ts +3 -0
- package/dist_ts/financialstatements/mediatypes.js +11 -0
- package/dist_ts/financialstatements/outline.d.ts +22 -0
- package/dist_ts/financialstatements/outline.js +26 -0
- package/dist_ts/financialstatements/parse.esef.d.ts +13 -0
- package/dist_ts/financialstatements/parse.esef.js +349 -0
- package/dist_ts/financialstatements/parse.hgb.d.ts +9 -0
- package/dist_ts/financialstatements/parse.hgb.js +1014 -0
- package/dist_ts/financialstatements/parse.statement.d.ts +8 -0
- package/dist_ts/financialstatements/parse.statement.js +25 -0
- package/dist_ts/financialstatements/plugins.d.ts +10 -0
- package/dist_ts/financialstatements/plugins.js +15 -0
- package/dist_ts/financialstatements/read.d.ts +10 -0
- package/dist_ts/financialstatements/read.js +113 -0
- package/dist_ts/handelsregister/classes.handelsregisterclient.d.ts +8 -2
- package/dist_ts/handelsregister/classes.handelsregisterclient.js +14 -5
- package/dist_ts/handelsregister/classes.handelsregistererror.d.ts +3 -1
- package/dist_ts/handelsregister/classes.handelsregistererror.js +1 -1
- package/dist_ts/handelsregister/classes.portalsession.d.ts +19 -3
- package/dist_ts/handelsregister/classes.portalsession.js +67 -9
- package/dist_ts/handelsregister/interfaces.d.ts +9 -2
- package/package.json +16 -6
- package/readme.md +86 -5
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/financialstatements/amounts.ts +181 -0
- package/ts/financialstatements/classes.financialstatementerror.ts +35 -0
- package/ts/financialstatements/constants.ts +24 -0
- package/ts/financialstatements/dates.ts +89 -0
- package/ts/financialstatements/html.ts +107 -0
- package/ts/financialstatements/index.ts +17 -0
- package/ts/financialstatements/interfaces.ts +268 -0
- package/ts/financialstatements/layout.html.ts +130 -0
- package/ts/financialstatements/layout.pdf.ts +268 -0
- package/ts/financialstatements/layout.ts +16 -0
- package/ts/financialstatements/mediatypes.ts +14 -0
- package/ts/financialstatements/outline.ts +44 -0
- package/ts/financialstatements/parse.esef.ts +394 -0
- package/ts/financialstatements/parse.hgb.ts +1235 -0
- package/ts/financialstatements/parse.statement.ts +26 -0
- package/ts/financialstatements/plugins.ts +29 -0
- package/ts/financialstatements/read.ts +135 -0
- package/ts/handelsregister/classes.handelsregisterclient.ts +18 -4
- package/ts/handelsregister/classes.handelsregistererror.ts +3 -0
- package/ts/handelsregister/classes.portalsession.ts +78 -9
- 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
|
|
124
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
}
|