universal-parcel-scraper 0.142.0 → 0.143.0-main.1167
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/README.md +1 -1
- package/data/detection-golden.json +38 -0
- package/dist/carriers/dsv/adapter.d.ts +16 -0
- package/dist/carriers/dsv/adapter.js +115 -4
- package/dist/carriers/dsv/carrier.json +2 -1
- package/dist/carriers/dsv/numbers.json +74 -0
- package/dist/carriers/dsv/puzzle.d.ts +21 -0
- package/dist/carriers/dsv/puzzle.js +99 -0
- package/dist/carriers/dsv/schenker.d.ts +26 -0
- package/dist/carriers/dsv/schenker.js +206 -0
- package/dist/carriers/dsv/status.js +33 -1
- package/dist/carriers/dsv/statuses.json +28 -1
- package/dist/carriers/magyar-posta/adapter.d.ts +3 -1
- package/dist/carriers/magyar-posta/adapter.js +24 -2
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
|
|
|
155
155
|
<!-- GENERATED:stages -->
|
|
156
156
|
<img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
|
|
157
157
|
|
|
158
|
-
The carrier folders record 7,
|
|
158
|
+
The carrier folders record 7,309 such statuses, each filed under one stage.
|
|
159
159
|
<!-- /GENERATED:stages -->
|
|
160
160
|
|
|
161
161
|
Wording nobody recorded yet goes through a classifier that reads seven European languages.
|
|
@@ -1008,6 +1008,22 @@
|
|
|
1008
1008
|
"nz-post"
|
|
1009
1009
|
]
|
|
1010
1010
|
},
|
|
1011
|
+
{
|
|
1012
|
+
"input": "00340433660000960875",
|
|
1013
|
+
"carrier": "dhl",
|
|
1014
|
+
"confidence": "high",
|
|
1015
|
+
"candidates": [
|
|
1016
|
+
"dhl"
|
|
1017
|
+
]
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
"input": "00340433660000963159",
|
|
1021
|
+
"carrier": "dhl",
|
|
1022
|
+
"confidence": "high",
|
|
1023
|
+
"candidates": [
|
|
1024
|
+
"dhl"
|
|
1025
|
+
]
|
|
1026
|
+
},
|
|
1011
1027
|
{
|
|
1012
1028
|
"input": "00340433982034779798",
|
|
1013
1029
|
"carrier": "dhl",
|
|
@@ -45404,6 +45420,28 @@
|
|
|
45404
45420
|
"an-post"
|
|
45405
45421
|
]
|
|
45406
45422
|
},
|
|
45423
|
+
{
|
|
45424
|
+
"input": "LGG6221779",
|
|
45425
|
+
"carrier": "unknown",
|
|
45426
|
+
"confidence": "low",
|
|
45427
|
+
"candidates": [
|
|
45428
|
+
"australia-post"
|
|
45429
|
+
],
|
|
45430
|
+
"asked": [
|
|
45431
|
+
"australia-post"
|
|
45432
|
+
]
|
|
45433
|
+
},
|
|
45434
|
+
{
|
|
45435
|
+
"input": "LGG6221781",
|
|
45436
|
+
"carrier": "unknown",
|
|
45437
|
+
"confidence": "low",
|
|
45438
|
+
"candidates": [
|
|
45439
|
+
"australia-post"
|
|
45440
|
+
],
|
|
45441
|
+
"asked": [
|
|
45442
|
+
"australia-post"
|
|
45443
|
+
]
|
|
45444
|
+
},
|
|
45407
45445
|
{
|
|
45408
45446
|
"input": "LH 38304147",
|
|
45409
45447
|
"carrier": "ontrac",
|
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
2
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
3
|
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
import { dsvSchenkerRoute } from './schenker.js';
|
|
4
5
|
export declare const DSV_SEARCH_ENDPOINT = "https://mydsv.com/app/search/publicShipmentList";
|
|
5
6
|
export declare const DSV_SHIPMENT_ENDPOINT = "https://mydsv.com/app/search/shipment/";
|
|
6
7
|
export declare const DSV_STATUSES_ENDPOINT = "https://mydsv.com/app/search/shipment/new-statuses/";
|
|
8
|
+
export declare const DSV_SCHENKER_ENDPOINT = "https://www.dsv.com/nges-portal/api/public/tracking-public";
|
|
9
|
+
type SchenkerRoute = NonNullable<ReturnType<typeof dsvSchenkerRoute>>;
|
|
7
10
|
export declare class DsvTracker {
|
|
8
11
|
private readonly options;
|
|
9
12
|
constructor(options?: {
|
|
10
13
|
fetcher?: typeof fetch;
|
|
11
14
|
recorder?: StepRecorder;
|
|
12
15
|
userAgent?: string;
|
|
16
|
+
schenkerMs?: Readonly<Record<SchenkerRoute, number>>;
|
|
13
17
|
});
|
|
14
18
|
/**
|
|
15
19
|
* One JSON read. The shipment's key opens its details, so a failed read by
|
|
16
20
|
* key keeps neither the address nor the reply on its error.
|
|
17
21
|
*/
|
|
18
22
|
private json;
|
|
23
|
+
/**
|
|
24
|
+
* One read from the former DB Schenker tracker. It answers every request
|
|
25
|
+
* with HTTP 429 and a proof-of-work puzzle; the same request with the
|
|
26
|
+
* solution gets the reply. The solution stays out of failure diagnostics.
|
|
27
|
+
*/
|
|
28
|
+
private schenker;
|
|
29
|
+
/**
|
|
30
|
+
* The former DB Schenker tracker: an STT's details straight away, or a
|
|
31
|
+
* package's search, then the details of the one road shipment it finds.
|
|
32
|
+
*/
|
|
33
|
+
private schenkerShipment;
|
|
19
34
|
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
20
35
|
}
|
|
21
36
|
export declare const adapter: AdapterFactory;
|
|
37
|
+
export {};
|
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { ChallengeError, SchemaError, TransportError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
|
|
1
|
+
import { BudgetExceededError, ChallengeError, IndeterminateError, SchemaError, TransportError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
|
|
2
2
|
import { runSteps } from '../../core/runner/index.js';
|
|
3
3
|
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
4
4
|
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
5
5
|
import { decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
6
7
|
import { confirmDsvShipment, DSV_MAX_BYTES, normalizeDsvNumber, parseDsvStatuses, pickDsvShipment, PROVIDER } from './parser.js';
|
|
8
|
+
import { parseDsvPuzzles, solveDsvPuzzles } from './puzzle.js';
|
|
9
|
+
import { dsvSchenkerRoute, dsvSchenkerSttId, notFound, parseDsvSchenkerShipment, pickDsvSchenkerShipment } from './schenker.js';
|
|
7
10
|
// The services the shipment page at mydsv.com/track-shipment calls without an account.
|
|
8
11
|
export const DSV_SEARCH_ENDPOINT = 'https://mydsv.com/app/search/publicShipmentList';
|
|
9
12
|
export const DSV_SHIPMENT_ENDPOINT = 'https://mydsv.com/app/search/shipment/';
|
|
10
13
|
export const DSV_STATUSES_ENDPOINT = 'https://mydsv.com/app/search/shipment/new-statuses/';
|
|
14
|
+
// The former DB Schenker tracker's API, behind the page at www.dsv.com/mydsv/tracking-public.
|
|
15
|
+
export const DSV_SCHENKER_ENDPOINT = 'https://www.dsv.com/nges-portal/api/public/tracking-public';
|
|
16
|
+
const DEFAULT_BUDGET_MS = 15_000;
|
|
17
|
+
// The former DB Schenker tracker's longest wait. It answers within a second,
|
|
18
|
+
// but at times holds an STT's details for half a minute, and a package's
|
|
19
|
+
// search for a quarter of a minute before them; a later reply is given up,
|
|
20
|
+
// so the sources after it keep their time.
|
|
21
|
+
const SCHENKER_MS = { stt: 34_000, sscc: 48_000 };
|
|
22
|
+
// The tracker's answers when the number matches none of its shipments or reference patterns.
|
|
23
|
+
const SCHENKER_NOT_FOUND = /^TRACKING-BADREQ-(?:SHIPMENT_NOT_FOUND|NO_MATCHING_PATTERN_FOUND)$/;
|
|
11
24
|
// A web application firewall or CDN block page.
|
|
12
25
|
const BLOCKED = /<title>\s*(?:Request Rejected|Access Denied|Just a moment|Attention Required)|The requested URL was rejected|Incapsula incident|cf-chl-|cf-turnstile|_cf_chl_opt/i;
|
|
13
26
|
export class DsvTracker {
|
|
@@ -44,6 +57,95 @@ export class DsvTracker {
|
|
|
44
57
|
throw new SchemaError(PROVIDER, 'myDSV returned invalid tracking JSON', byKey ? undefined : { cause });
|
|
45
58
|
}
|
|
46
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* One read from the former DB Schenker tracker. It answers every request
|
|
62
|
+
* with HTTP 429 and a proof-of-work puzzle; the same request with the
|
|
63
|
+
* solution gets the reply. The solution stays out of failure diagnostics.
|
|
64
|
+
*/
|
|
65
|
+
async schenker(path, signal, deadline, budgetMs) {
|
|
66
|
+
const url = `${DSV_SCHENKER_ENDPOINT}${path}`;
|
|
67
|
+
const headers = { Accept: 'application/json', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.options.userAgent) };
|
|
68
|
+
const left = () => {
|
|
69
|
+
const ms = Math.floor(deadline - performance.now());
|
|
70
|
+
if (ms < 1)
|
|
71
|
+
throw new BudgetExceededError(PROVIDER, budgetMs);
|
|
72
|
+
return ms;
|
|
73
|
+
};
|
|
74
|
+
const request = async (extra, allow) => {
|
|
75
|
+
try {
|
|
76
|
+
return await fetchBounded(url, { signal, headers: { ...headers, ...extra } }, { provider: PROVIDER, timeoutMs: left(), maxBytes: DSV_MAX_BYTES, allowHttpStatuses: allow, fetcher: this.options.fetcher });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof UpstreamHttpError)
|
|
80
|
+
throw new UpstreamHttpError(PROVIDER, error.status, error.retryAfterMs);
|
|
81
|
+
if (error instanceof UpstreamNetworkError) {
|
|
82
|
+
// A reply still pending when the step's time or the lookup's ends is a spent budget, not a failure to connect.
|
|
83
|
+
const spent = signal.aborted ? signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError'
|
|
84
|
+
: deadline - performance.now() < 100;
|
|
85
|
+
if (spent)
|
|
86
|
+
throw new BudgetExceededError(PROVIDER, budgetMs);
|
|
87
|
+
throw new UpstreamNetworkError(PROVIDER, new Error('DSV\'s former DB Schenker tracker did not answer'));
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const json = (bytes) => {
|
|
93
|
+
try {
|
|
94
|
+
return parseJsonBytes(bytes, PROVIDER);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
if (BLOCKED.test(decodeText(bytes.subarray(0, 64_000))))
|
|
98
|
+
throw new ChallengeError(PROVIDER, 'DSV answered with a block page');
|
|
99
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned invalid tracking JSON');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const first = await request({}, [404, 429]);
|
|
103
|
+
// A missing route is not a missing shipment.
|
|
104
|
+
if (first.response.status === 404)
|
|
105
|
+
throw new TransportError(PROVIDER, 'DSV\'s former DB Schenker tracker is unavailable');
|
|
106
|
+
if (first.response.status !== 429)
|
|
107
|
+
return json(first.bytes);
|
|
108
|
+
const puzzle = first.response.headers.get('captcha-puzzle');
|
|
109
|
+
if (!puzzle)
|
|
110
|
+
throw new UpstreamHttpError(PROVIDER, 429);
|
|
111
|
+
const solution = await solveDsvPuzzles(parseDsvPuzzles(puzzle, PROVIDER), signal, deadline, budgetMs, PROVIDER);
|
|
112
|
+
signal.throwIfAborted();
|
|
113
|
+
const { response, bytes } = await request({ 'Captcha-Solution': solution }, [400, 404, 422, 429]);
|
|
114
|
+
if (response.status === 200)
|
|
115
|
+
return json(bytes);
|
|
116
|
+
if (response.status === 404)
|
|
117
|
+
notFound();
|
|
118
|
+
if (response.status === 400) {
|
|
119
|
+
let payload;
|
|
120
|
+
try {
|
|
121
|
+
payload = parseJsonBytes(bytes, PROVIDER);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
payload = null;
|
|
125
|
+
}
|
|
126
|
+
if (isRecord(payload) && typeof payload.code === 'string' && SCHENKER_NOT_FOUND.test(payload.code))
|
|
127
|
+
notFound();
|
|
128
|
+
throw new IndeterminateError(PROVIDER, 'DSV\'s former DB Schenker tracker refused the lookup');
|
|
129
|
+
}
|
|
130
|
+
// Another puzzle, or a refusal of the solution.
|
|
131
|
+
if (response.status === 422 || response.headers.get('captcha-puzzle')) {
|
|
132
|
+
throw new ChallengeError(PROVIDER, 'DSV\'s former DB Schenker tracker refused the solved puzzle');
|
|
133
|
+
}
|
|
134
|
+
throw new UpstreamHttpError(PROVIDER, response.status);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The former DB Schenker tracker: an STT's details straight away, or a
|
|
138
|
+
* package's search, then the details of the one road shipment it finds.
|
|
139
|
+
*/
|
|
140
|
+
async schenkerShipment(number, route, signal, remainingMs) {
|
|
141
|
+
const budgetMs = Math.min(remainingMs, (this.options.schenkerMs ?? SCHENKER_MS)[route]);
|
|
142
|
+
const deadline = performance.now() + budgetMs;
|
|
143
|
+
const read = (path) => this.schenker(path, signal, deadline, budgetMs);
|
|
144
|
+
const id = route === 'stt' ? dsvSchenkerSttId(number)
|
|
145
|
+
: pickDsvSchenkerShipment(await read(`/shipments?${new URLSearchParams({ query: number })}`), number);
|
|
146
|
+
// The page sends the id as it is, colons and commas included.
|
|
147
|
+
return parseDsvSchenkerShipment(await read(`/shipments/land/${id}`), number, id);
|
|
148
|
+
}
|
|
47
149
|
async fetch(raw, context = {}) {
|
|
48
150
|
const number = normalizeDsvNumber(raw);
|
|
49
151
|
const search = `${DSV_SEARCH_ENDPOINT}?${new URLSearchParams({ q: number })}`;
|
|
@@ -61,18 +163,27 @@ export class DsvTracker {
|
|
|
61
163
|
return parseDsvStatuses(await this.json(`${DSV_STATUSES_ENDPOINT}${key}`, signal, left(), true), shipment);
|
|
62
164
|
};
|
|
63
165
|
// A lookup that fails to reach myDSV, or hangs, gets one more try: the
|
|
64
|
-
// first gets half of the budget, so the retry fits in the rest.
|
|
65
|
-
|
|
166
|
+
// first gets half of the budget, so the retry fits in the rest. An STT or
|
|
167
|
+
// a package's SSCC that myDSV does not know goes to the former DB Schenker
|
|
168
|
+
// tracker.
|
|
169
|
+
const route = dsvSchenkerRoute(number);
|
|
170
|
+
return runSteps({ carrier: 'dsv', budgetMs: context.budgetMs ?? DEFAULT_BUDGET_MS, signal: context.signal,
|
|
66
171
|
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
67
172
|
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
68
173
|
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
174
|
+
{ id: 'schenker', recovers: error => route !== null && notInMyDsv(error),
|
|
175
|
+
run: ({ signal, remainingMs }) => this.schenkerShipment(number, route, signal, remainingMs) },
|
|
69
176
|
]);
|
|
70
177
|
}
|
|
71
178
|
}
|
|
179
|
+
/** myDSV's search answered and does not know the number. */
|
|
180
|
+
function notInMyDsv(error) {
|
|
181
|
+
return error instanceof IndeterminateError && error.reason === 'portal_not_found';
|
|
182
|
+
}
|
|
72
183
|
export const adapter = (environment) => {
|
|
73
184
|
const tracker = new DsvTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
74
185
|
return {
|
|
75
|
-
id: 'dsv', recordsSteps: true, steps: ['direct', 'retry'],
|
|
186
|
+
id: 'dsv', recordsSteps: true, steps: ['direct', 'retry', 'schenker'],
|
|
76
187
|
track: (input, context) => tracker.fetch(input.number, context),
|
|
77
188
|
};
|
|
78
189
|
};
|
|
@@ -125,6 +125,80 @@
|
|
|
125
125
|
]
|
|
126
126
|
},
|
|
127
127
|
"note": "Example value in the tool schema of an open-source DB Schenker tracking client; not a real shipment."
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"number": "LGG6221781",
|
|
131
|
+
"role": "shipment",
|
|
132
|
+
"evidence": "public_shipment_report",
|
|
133
|
+
"source": {
|
|
134
|
+
"url": "https://www.parcelpanel.com/couriers/dbschenker/",
|
|
135
|
+
"access": "full_page"
|
|
136
|
+
},
|
|
137
|
+
"context": {
|
|
138
|
+
"assessment": "confirmed"
|
|
139
|
+
},
|
|
140
|
+
"expect": {
|
|
141
|
+
"carrier": "unknown",
|
|
142
|
+
"confidence": "low",
|
|
143
|
+
"candidates": [
|
|
144
|
+
"australia-post"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"note": "ParcelPanel lists it as an example on its DB Schenker page. DSV's former DB Schenker tracker knows it as the STT of a road shipment."
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"number": "LGG6221779",
|
|
151
|
+
"role": "shipment",
|
|
152
|
+
"evidence": "public_shipment_report",
|
|
153
|
+
"source": {
|
|
154
|
+
"url": "https://www.parcelpanel.com/couriers/dbschenker/",
|
|
155
|
+
"access": "full_page"
|
|
156
|
+
},
|
|
157
|
+
"context": {
|
|
158
|
+
"assessment": "confirmed"
|
|
159
|
+
},
|
|
160
|
+
"expect": {
|
|
161
|
+
"carrier": "unknown",
|
|
162
|
+
"confidence": "low",
|
|
163
|
+
"candidates": [
|
|
164
|
+
"australia-post"
|
|
165
|
+
]
|
|
166
|
+
},
|
|
167
|
+
"note": "ParcelPanel lists it as an example on its DB Schenker page. DSV's former DB Schenker tracker knows it as the STT of a road shipment."
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"number": "00340433660000960875",
|
|
171
|
+
"role": "shipment",
|
|
172
|
+
"evidence": "public_shipment_report",
|
|
173
|
+
"source": {
|
|
174
|
+
"url": "https://www.parcelpanel.com/couriers/dbschenker/",
|
|
175
|
+
"access": "full_page"
|
|
176
|
+
},
|
|
177
|
+
"context": {
|
|
178
|
+
"assessment": "confirmed"
|
|
179
|
+
},
|
|
180
|
+
"expect": {
|
|
181
|
+
"carrier": "dhl",
|
|
182
|
+
"confidence": "high"
|
|
183
|
+
},
|
|
184
|
+
"note": "ParcelPanel lists it as an example on its DB Schenker page. DSV's former DB Schenker tracker knows it as the SSCC of a road shipment's package; DHL's 0034043 SSCC rule claims it."
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"number": "00340433660000963159",
|
|
188
|
+
"role": "shipment",
|
|
189
|
+
"evidence": "public_shipment_report",
|
|
190
|
+
"source": {
|
|
191
|
+
"url": "https://www.parcelpanel.com/couriers/dbschenker/",
|
|
192
|
+
"access": "full_page"
|
|
193
|
+
},
|
|
194
|
+
"context": {
|
|
195
|
+
"assessment": "confirmed"
|
|
196
|
+
},
|
|
197
|
+
"expect": {
|
|
198
|
+
"carrier": "dhl",
|
|
199
|
+
"confidence": "high"
|
|
200
|
+
},
|
|
201
|
+
"note": "ParcelPanel lists it as an example on its DB Schenker page. DSV's former DB Schenker tracker finds two road shipments for it; DHL's 0034043 SSCC rule claims it."
|
|
128
202
|
}
|
|
129
203
|
]
|
|
130
204
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface DsvPuzzle {
|
|
2
|
+
/** The signed token the server checks the solution against. */
|
|
3
|
+
readonly jwt: string;
|
|
4
|
+
readonly seed: Buffer;
|
|
5
|
+
/** The bound a hash must stay under, as 32 little-endian bytes. */
|
|
6
|
+
readonly target: Buffer;
|
|
7
|
+
readonly expectedHashes: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The `Captcha-Puzzle` header: base64 of comma-separated signed tokens. Each
|
|
11
|
+
* token's `puzzle` is 64 bytes. The first 32 are the seed; bytes 13 and 14
|
|
12
|
+
* are the difficulty's exponent and mantissa, so a hash must stay under
|
|
13
|
+
* mantissa × 256^(exponent − 3), read little-endian.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseDsvPuzzles(header: string, provider: string): DsvPuzzle[];
|
|
16
|
+
/**
|
|
17
|
+
* The nonce for each puzzle: eight little-endian bytes that, after the seed,
|
|
18
|
+
* give a double SHA-256 under the target. The answer is the `Captcha-Solution`
|
|
19
|
+
* header, base64 of the tokens with their nonces.
|
|
20
|
+
*/
|
|
21
|
+
export declare function solveDsvPuzzles(puzzles: readonly DsvPuzzle[], signal: AbortSignal, deadline: number, budgetMs: number, provider: string): Promise<string>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { BudgetExceededError, ChallengeError, IndeterminateError } from '../../core/errors/index.js';
|
|
3
|
+
import { isRecord } from '../../core/types.js';
|
|
4
|
+
const MAX_PUZZLES = 8;
|
|
5
|
+
// The tracker's puzzles take about 6,600 hashes each. One that asks for a
|
|
6
|
+
// thousand times more is not one a lookup can afford.
|
|
7
|
+
const MAX_EXPECTED_HASHES = 2 ** 23;
|
|
8
|
+
// Past this many times its expected work, a search would be a broken puzzle.
|
|
9
|
+
const MAX_WORK_FACTOR = 64;
|
|
10
|
+
const YIELD_EVERY = 2048;
|
|
11
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
12
|
+
function invalid(provider) {
|
|
13
|
+
return new ChallengeError(provider, `${provider} sent a proof-of-work puzzle the adapter cannot read`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The `Captcha-Puzzle` header: base64 of comma-separated signed tokens. Each
|
|
17
|
+
* token's `puzzle` is 64 bytes. The first 32 are the seed; bytes 13 and 14
|
|
18
|
+
* are the difficulty's exponent and mantissa, so a hash must stay under
|
|
19
|
+
* mantissa × 256^(exponent − 3), read little-endian.
|
|
20
|
+
*/
|
|
21
|
+
export function parseDsvPuzzles(header, provider) {
|
|
22
|
+
const tokens = Buffer.from(header.trim(), 'base64').toString('latin1').split(',');
|
|
23
|
+
if (!tokens.length || tokens.length > MAX_PUZZLES)
|
|
24
|
+
throw invalid(provider);
|
|
25
|
+
return tokens.map((jwt) => {
|
|
26
|
+
const parts = jwt.split('.');
|
|
27
|
+
if (parts.length !== 3 || !parts.every(part => BASE64URL.test(part)) || jwt.length > 2000)
|
|
28
|
+
throw invalid(provider);
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw invalid(provider);
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(payload) || typeof payload.puzzle !== 'string')
|
|
37
|
+
throw invalid(provider);
|
|
38
|
+
const puzzle = Buffer.from(payload.puzzle, 'base64');
|
|
39
|
+
if (puzzle.length !== 64)
|
|
40
|
+
throw invalid(provider);
|
|
41
|
+
const exponent = puzzle.readInt8(13);
|
|
42
|
+
const mantissa = puzzle.readInt8(14);
|
|
43
|
+
// The bound fits in 256 bits and leaves at least one hash to try.
|
|
44
|
+
if (mantissa < 1 || exponent < 3 || exponent > 34)
|
|
45
|
+
throw invalid(provider);
|
|
46
|
+
const target = Buffer.alloc(32);
|
|
47
|
+
target[exponent - 3] = mantissa;
|
|
48
|
+
const expectedHashes = 2 ** (256 - 8 * (exponent - 3)) / mantissa;
|
|
49
|
+
if (expectedHashes > MAX_EXPECTED_HASHES) {
|
|
50
|
+
throw new IndeterminateError(provider, `${provider} asked for more proof of work than a lookup can spend`, { reason: 'costly_puzzle' });
|
|
51
|
+
}
|
|
52
|
+
return { jwt, seed: puzzle.subarray(0, 32), target, expectedHashes };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Whether a hash, read as a little-endian number, is under the target. */
|
|
56
|
+
function under(hash, target) {
|
|
57
|
+
for (let index = 31; index >= 0; index -= 1) {
|
|
58
|
+
if (hash[index] !== target[index])
|
|
59
|
+
return hash[index] < target[index];
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The nonce for each puzzle: eight little-endian bytes that, after the seed,
|
|
65
|
+
* give a double SHA-256 under the target. The answer is the `Captcha-Solution`
|
|
66
|
+
* header, base64 of the tokens with their nonces.
|
|
67
|
+
*/
|
|
68
|
+
export async function solveDsvPuzzles(puzzles, signal, deadline, budgetMs, provider) {
|
|
69
|
+
const solutions = [];
|
|
70
|
+
const input = Buffer.alloc(40);
|
|
71
|
+
for (const puzzle of puzzles) {
|
|
72
|
+
puzzle.seed.copy(input, 0);
|
|
73
|
+
const limit = Math.ceil(puzzle.expectedHashes * MAX_WORK_FACTOR);
|
|
74
|
+
let found = false;
|
|
75
|
+
for (let nonce = 0; nonce < limit; nonce += 1) {
|
|
76
|
+
input.writeUInt32LE(nonce % 2 ** 32, 32);
|
|
77
|
+
input.writeUInt32LE(Math.floor(nonce / 2 ** 32), 36);
|
|
78
|
+
const hash = createHash('sha256').update(createHash('sha256').update(input).digest()).digest();
|
|
79
|
+
if (under(hash, puzzle.target)) {
|
|
80
|
+
solutions.push({ jwt: puzzle.jwt, solution: input.subarray(32).toString('base64') });
|
|
81
|
+
found = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (nonce % YIELD_EVERY === YIELD_EVERY - 1) {
|
|
85
|
+
// Let cancellation and deadline timers run during the search. The
|
|
86
|
+
// lookup's own timer ending it is the budget running out.
|
|
87
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
88
|
+
const reason = signal.reason;
|
|
89
|
+
if (performance.now() >= deadline || (reason instanceof DOMException && reason.name === 'TimeoutError')) {
|
|
90
|
+
throw new BudgetExceededError(provider, budgetMs);
|
|
91
|
+
}
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!found)
|
|
96
|
+
throw invalid(provider);
|
|
97
|
+
}
|
|
98
|
+
return Buffer.from(JSON.stringify(solutions)).toString('base64');
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* How the former DB Schenker tracker is asked for a number: an STT straight
|
|
4
|
+
* by its details, a package's SSCC through the search, any other number not
|
|
5
|
+
* at all.
|
|
6
|
+
*/
|
|
7
|
+
export declare function dsvSchenkerRoute(number: string): 'stt' | 'sscc' | null;
|
|
8
|
+
/** The details id of a road STT, without the data sources the tracker adds itself. */
|
|
9
|
+
export declare function dsvSchenkerSttId(number: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The former DB Schenker tracker's search: the shipments whose STT, package,
|
|
12
|
+
* waybill or a reference matches the number. Only a road shipment is read,
|
|
13
|
+
* and only its id; whether the number is the shipment's own is settled by
|
|
14
|
+
* its details.
|
|
15
|
+
*/
|
|
16
|
+
export declare function pickDsvSchenkerShipment(payload: unknown, rawNumber: string): string;
|
|
17
|
+
/** Neither of DSV's public trackers knows the number. */
|
|
18
|
+
export declare function notFound(): never;
|
|
19
|
+
/**
|
|
20
|
+
* A road shipment's details from the former DB Schenker tracker. The number
|
|
21
|
+
* must be one of the shipment's own ids. The history is the shipment's status
|
|
22
|
+
* rows: a code, English wording, a wall clock, a place and the reasons the
|
|
23
|
+
* page shows. The parties, their references, addresses and goods, the
|
|
24
|
+
* recipient's name on a delivery and the packages' own rows are not read.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseDsvSchenkerShipment(payload: unknown, rawNumber: string, id: string): CarrierResult;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { isValidSscc } from '../../core/detection/index.js';
|
|
3
|
+
import { returnedTrackingNumber } from '../../core/detection/returned.js';
|
|
4
|
+
import { IndeterminateError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
import { normalizeDsvNumber, PROVIDER } from './parser.js';
|
|
9
|
+
import { dsvStage } from './status.js';
|
|
10
|
+
const MAX_SHIPMENTS = 100;
|
|
11
|
+
const MAX_ROWS = 1000;
|
|
12
|
+
const MAX_EVENTS = 100;
|
|
13
|
+
const MAX_PACKAGES = 1000;
|
|
14
|
+
// "LandStt:<STT>:<data sources>:LAND", the id the tracker's own detail request takes.
|
|
15
|
+
const LAND_ID = /^[A-Za-z]{2,30}:[A-Z0-9]{3,40}:[A-Z]{2,10}(?:,[A-Z]{2,10}){0,5}:LAND$/;
|
|
16
|
+
// A former DB Schenker road STT: three letters and seven or eight digits, or five letters and nine digits.
|
|
17
|
+
const STT = /^(?:[A-Z]{3}\d{7,8}|[A-Z]{5}\d{9})$/;
|
|
18
|
+
const CODE = /^[A-Z0-9]{2,10}$/;
|
|
19
|
+
const CLOCK = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
|
20
|
+
// Postcodes and street numbers in a place name.
|
|
21
|
+
const DIGITS = /\S*\d{3,}\S*/g;
|
|
22
|
+
/**
|
|
23
|
+
* How the former DB Schenker tracker is asked for a number: an STT straight
|
|
24
|
+
* by its details, a package's SSCC through the search, any other number not
|
|
25
|
+
* at all.
|
|
26
|
+
*/
|
|
27
|
+
export function dsvSchenkerRoute(number) {
|
|
28
|
+
if (STT.test(number))
|
|
29
|
+
return 'stt';
|
|
30
|
+
return isValidSscc(/^\d{18}$/.test(number) ? `00${number}` : number) ? 'sscc' : null;
|
|
31
|
+
}
|
|
32
|
+
/** The details id of a road STT, without the data sources the tracker adds itself. */
|
|
33
|
+
export function dsvSchenkerSttId(number) {
|
|
34
|
+
return `LandStt:${number}:LAND`;
|
|
35
|
+
}
|
|
36
|
+
/** The reply's id is the one asked for, or a road id of the same kind for an id asked without its data sources. */
|
|
37
|
+
function answersId(asked, replied) {
|
|
38
|
+
if (typeof replied !== 'string' || replied === asked)
|
|
39
|
+
return true;
|
|
40
|
+
const [type, , sources] = asked.split(':');
|
|
41
|
+
return sources === 'LAND' && LAND_ID.test(replied) && replied.startsWith(`${type}:`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The former DB Schenker tracker's search: the shipments whose STT, package,
|
|
45
|
+
* waybill or a reference matches the number. Only a road shipment is read,
|
|
46
|
+
* and only its id; whether the number is the shipment's own is settled by
|
|
47
|
+
* its details.
|
|
48
|
+
*/
|
|
49
|
+
export function pickDsvSchenkerShipment(payload, rawNumber) {
|
|
50
|
+
const number = normalizeDsvNumber(rawNumber);
|
|
51
|
+
if (!isRecord(payload) || !Array.isArray(payload.result) || payload.result.length > MAX_SHIPMENTS
|
|
52
|
+
|| !payload.result.every(isRecord)) {
|
|
53
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker did not return a shipment list');
|
|
54
|
+
}
|
|
55
|
+
const entries = payload.result;
|
|
56
|
+
if (!entries.length)
|
|
57
|
+
notFound();
|
|
58
|
+
const own = entries.filter(entry => returnedTrackingNumber(entry.stt) === number);
|
|
59
|
+
const entry = own.length === 1 ? own[0] : entries.length === 1 ? entries[0] : null;
|
|
60
|
+
if (!entry)
|
|
61
|
+
throw new IndeterminateError(PROVIDER, 'DSV\'s former DB Schenker tracker found several shipments for the number', { reason: 'several_parcels' });
|
|
62
|
+
if (entry.transportMode !== 'LAND') {
|
|
63
|
+
throw new IndeterminateError(PROVIDER, 'DSV\'s former DB Schenker tracker found an air or ocean shipment, which the adapter does not read');
|
|
64
|
+
}
|
|
65
|
+
if (typeof entry.id !== 'string' || !LAND_ID.test(entry.id))
|
|
66
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned a shipment without its id');
|
|
67
|
+
return entry.id;
|
|
68
|
+
}
|
|
69
|
+
/** Neither of DSV's public trackers knows the number. */
|
|
70
|
+
export function notFound() {
|
|
71
|
+
throw new IndeterminateError(PROVIDER, 'Neither of DSV\'s public trackers knows the number', { reason: 'portal_not_found' });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The ids the shipment carries itself: its STT, the STT it was booked under
|
|
75
|
+
* and its packages' SSCCs, with or without the 00 that precedes an SSCC in
|
|
76
|
+
* its barcode. Waybills and the parties' references are not among them.
|
|
77
|
+
*/
|
|
78
|
+
function ownIds(payload) {
|
|
79
|
+
const ids = new Set();
|
|
80
|
+
const references = isRecord(payload.references) ? payload.references : {};
|
|
81
|
+
for (const value of [payload.sttNumber, references.originalStt]) {
|
|
82
|
+
const id = returnedTrackingNumber(value);
|
|
83
|
+
if (id)
|
|
84
|
+
ids.add(id);
|
|
85
|
+
}
|
|
86
|
+
const packages = Array.isArray(payload.packages) ? payload.packages.slice(0, MAX_PACKAGES) : [];
|
|
87
|
+
for (const entry of packages) {
|
|
88
|
+
const id = isRecord(entry) ? returnedTrackingNumber(entry.id, true) : '';
|
|
89
|
+
if (!id)
|
|
90
|
+
continue;
|
|
91
|
+
ids.add(id);
|
|
92
|
+
if (/^\d{18}$/.test(id))
|
|
93
|
+
ids.add(`00${id}`);
|
|
94
|
+
}
|
|
95
|
+
return ids;
|
|
96
|
+
}
|
|
97
|
+
/** The row's clock as the page prints it: the wall clock as written, whatever offset follows it. */
|
|
98
|
+
function clockOf(value) {
|
|
99
|
+
if (value === null || value === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
const match = typeof value === 'string' ? CLOCK.exec(value.trim()) : null;
|
|
102
|
+
if (!match)
|
|
103
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned an invalid date');
|
|
104
|
+
// UTC only checks the calendar fields. No offset is assigned: some rows label a local clock Z.
|
|
105
|
+
const parsed = DateTime.fromFormat(`${match[1]} ${match[2]}`, 'yyyy-MM-dd HH:mm:ss', { zone: 'UTC' });
|
|
106
|
+
if (!parsed.isValid)
|
|
107
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned an invalid date');
|
|
108
|
+
return parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss");
|
|
109
|
+
}
|
|
110
|
+
/** "Place, CC": the terminal's or the stop's town and its country, without postcodes. */
|
|
111
|
+
function locationOf(value) {
|
|
112
|
+
if (!isRecord(value))
|
|
113
|
+
return '';
|
|
114
|
+
const place = clean(clean(value.name, 120).replace(DIGITS, ' ')).replace(/^[\s,/-]+|[\s,/-]+$/g, '');
|
|
115
|
+
const country = clean(value.countryCode, 2).toUpperCase();
|
|
116
|
+
return [place, /^[A-Z]{2}$/.test(country) ? country : ''].filter(Boolean).join(', ');
|
|
117
|
+
}
|
|
118
|
+
/** The reasons the page prints beside a row, such as why a delivery failed. */
|
|
119
|
+
function reasonsOf(value) {
|
|
120
|
+
if (value === null || value === undefined)
|
|
121
|
+
return [];
|
|
122
|
+
if (!Array.isArray(value))
|
|
123
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned invalid reasons');
|
|
124
|
+
return value.slice(0, 10).map(reason => isRecord(reason) ? clean(reason.description, 200) || cleanScalar(reason.code, 10) : '')
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
/** The day of the agreed delivery, else of the estimated one. */
|
|
128
|
+
function estimateOf(value) {
|
|
129
|
+
if (!isRecord(value))
|
|
130
|
+
return null;
|
|
131
|
+
for (const date of [value.agreed, value.estimated]) {
|
|
132
|
+
const clock = date === null || date === undefined ? null : clockOf(date);
|
|
133
|
+
if (clock)
|
|
134
|
+
return clock.slice(0, 10);
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A road shipment's details from the former DB Schenker tracker. The number
|
|
140
|
+
* must be one of the shipment's own ids. The history is the shipment's status
|
|
141
|
+
* rows: a code, English wording, a wall clock, a place and the reasons the
|
|
142
|
+
* page shows. The parties, their references, addresses and goods, the
|
|
143
|
+
* recipient's name on a delivery and the packages' own rows are not read.
|
|
144
|
+
*/
|
|
145
|
+
export function parseDsvSchenkerShipment(payload, rawNumber, id) {
|
|
146
|
+
const number = normalizeDsvNumber(rawNumber);
|
|
147
|
+
if (!isRecord(payload) || !Array.isArray(payload.events) || typeof payload.sttNumber !== 'string'
|
|
148
|
+
|| !(Array.isArray(payload.packages) || payload.packages === null || payload.packages === undefined)) {
|
|
149
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker did not return the shipment');
|
|
150
|
+
}
|
|
151
|
+
if (!answersId(id, payload.shipmentId)) {
|
|
152
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned another shipment');
|
|
153
|
+
}
|
|
154
|
+
if (!ownIds(payload).has(number)) {
|
|
155
|
+
throw new IndeterminateError(PROVIDER, 'The number matched only a reference of a former DB Schenker shipment', { reason: 'reference_only' });
|
|
156
|
+
}
|
|
157
|
+
if (payload.events.length > MAX_ROWS)
|
|
158
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned an unexpectedly long history');
|
|
159
|
+
const scans = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
payload.events.forEach((row, index) => {
|
|
162
|
+
if (!isRecord(row))
|
|
163
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned an invalid history row');
|
|
164
|
+
const code = cleanScalar(row.code, 40).toUpperCase();
|
|
165
|
+
if (!CODE.test(code))
|
|
166
|
+
throw new SchemaError(PROVIDER, 'DSV\'s former DB Schenker tracker returned a row without a code');
|
|
167
|
+
const time = clockOf(row.date);
|
|
168
|
+
if (!time)
|
|
169
|
+
return;
|
|
170
|
+
const wording = clean(row.comment, 300) || code;
|
|
171
|
+
const reasons = reasonsOf(row.reasons);
|
|
172
|
+
const description = reasons.length ? `${wording}: ${reasons.join(', ')}` : wording;
|
|
173
|
+
const location = locationOf(row.location);
|
|
174
|
+
const event = { local_time: time, description, provider_code: code, ...(location ? { location } : {}) };
|
|
175
|
+
const classified = dsvStage(code, wording);
|
|
176
|
+
if (classified)
|
|
177
|
+
Object.assign(event, { stage: classified.stage, stage_source: classified.source });
|
|
178
|
+
const identity = JSON.stringify(event);
|
|
179
|
+
if (seen.has(identity))
|
|
180
|
+
return;
|
|
181
|
+
seen.add(identity);
|
|
182
|
+
scans.push({ event, index });
|
|
183
|
+
});
|
|
184
|
+
if (!scans.length)
|
|
185
|
+
throw new IndeterminateError(PROVIDER, 'DSV\'s former DB Schenker tracker returned the shipment without a history', { reason: 'no_history' });
|
|
186
|
+
// Newest first. The reply runs oldest first, so rows at the same clock keep its order reversed.
|
|
187
|
+
scans.sort((left, right) => {
|
|
188
|
+
const a = left.event.local_time;
|
|
189
|
+
const b = right.event.local_time;
|
|
190
|
+
return b > a ? 1 : b < a ? -1 : right.index - left.index;
|
|
191
|
+
});
|
|
192
|
+
const events = scans.map(scan => scan.event);
|
|
193
|
+
const latest = events[0];
|
|
194
|
+
const stage = latest.stage;
|
|
195
|
+
const expected = stage === 'delivered' ? null : estimateOf(payload.deliveryDate);
|
|
196
|
+
return {
|
|
197
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
198
|
+
...(stage ? { current_stage: stage, current_stage_source: latest.stage_source } : {}),
|
|
199
|
+
last_status_text: latest.description,
|
|
200
|
+
last_update: null,
|
|
201
|
+
last_update_local: latest.local_time,
|
|
202
|
+
expected_delivery: expected,
|
|
203
|
+
events: events.slice(0, MAX_EVENTS),
|
|
204
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -2,7 +2,9 @@ import { classifyWording } from '../../core/status/index.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* myDSV names each row of a shipment's history with a date type: the booking,
|
|
4
4
|
* the pickup, the departure and arrival at each port and the handover at the
|
|
5
|
-
* end. The same codes name the page's date filters.
|
|
5
|
+
* end. The same codes name the page's date filters. The former DB Schenker
|
|
6
|
+
* tracker names a road shipment's rows with three-letter event codes; the two
|
|
7
|
+
* sets share only DEP, a departure in both.
|
|
6
8
|
*/
|
|
7
9
|
const CODES = {
|
|
8
10
|
BOOKING: 'registered',
|
|
@@ -18,6 +20,36 @@ const CODES = {
|
|
|
18
20
|
Z70: 'delivered',
|
|
19
21
|
HANDOVER: 'delivered',
|
|
20
22
|
ACTUAL_DELIVERY: 'delivered',
|
|
23
|
+
// Former DB Schenker road shipments.
|
|
24
|
+
ENT: 'registered',
|
|
25
|
+
COT: 'registered',
|
|
26
|
+
COL: 'accepted',
|
|
27
|
+
DET: 'accepted',
|
|
28
|
+
// Not collected: the pickup failed.
|
|
29
|
+
NCL: 'exception',
|
|
30
|
+
ENM: 'in_transit',
|
|
31
|
+
MAN: 'in_transit',
|
|
32
|
+
ARR: 'in_transit',
|
|
33
|
+
TIN: 'in_transit',
|
|
34
|
+
VLO: 'in_transit',
|
|
35
|
+
VUL: 'in_transit',
|
|
36
|
+
TLO: 'in_transit',
|
|
37
|
+
TUL: 'in_transit',
|
|
38
|
+
VTL: 'in_transit',
|
|
39
|
+
VTU: 'in_transit',
|
|
40
|
+
// Held back from the load it was planned on; a reason says why.
|
|
41
|
+
NLO: 'exception',
|
|
42
|
+
CCL: 'customs',
|
|
43
|
+
CCF: 'in_transit',
|
|
44
|
+
DOT: 'out_for_delivery',
|
|
45
|
+
NDL: 'failed_attempt',
|
|
46
|
+
DIS: 'ready_for_pickup',
|
|
47
|
+
DIF: 'ready_for_pickup',
|
|
48
|
+
DLV: 'delivered',
|
|
49
|
+
PUP: 'delivered',
|
|
50
|
+
PUF: 'delivered',
|
|
51
|
+
// The electronic proof of delivery, which follows the delivery.
|
|
52
|
+
POD: 'delivered',
|
|
21
53
|
};
|
|
22
54
|
// Dates DSV plans or a customer asked for: not scans.
|
|
23
55
|
const ESTIMATES = new Map([
|
|
@@ -17,6 +17,33 @@
|
|
|
17
17
|
{ "code": "ESTIMATED_PICKUP", "wording": "Estimated pickup", "language": "en", "confirmedBy": "live", "note": "An estimate, not a scan: left out of the history." },
|
|
18
18
|
{ "code": "REQUESTED_PICKUP", "wording": "Requested pickup", "language": "en", "confirmedBy": "live", "note": "The pickup the booking asked for, not a scan: left out of the history." },
|
|
19
19
|
{ "code": "ESTIMATED_DELIVERY", "wording": "Estimated delivery", "language": "en", "confirmedBy": "bundle", "note": "Read as the delivery estimate, not as a scan." },
|
|
20
|
-
{ "code": "REQUESTED_DELIVERY", "wording": "Requested delivery", "language": "en", "confirmedBy": "bundle", "note": "The delivery the booking asked for, not a scan: left out of the history." }
|
|
20
|
+
{ "code": "REQUESTED_DELIVERY", "wording": "Requested delivery", "language": "en", "confirmedBy": "bundle", "note": "The delivery the booking asked for, not a scan: left out of the history." },
|
|
21
|
+
{ "code": "ENT", "wording": "Booked", "language": "en", "stage": "registered", "confirmedBy": "live", "note": "A former DB Schenker road shipment was booked." },
|
|
22
|
+
{ "code": "COT", "wording": "Out for collection", "language": "en", "stage": "registered", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
23
|
+
{ "code": "COL", "wording": "Collected", "language": "en", "stage": "accepted", "confirmedBy": "live", "note": "Picked up from the shipper." },
|
|
24
|
+
{ "code": "DET", "wording": "Delivered to terminal by shipper", "language": "en", "stage": "accepted", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
25
|
+
{ "code": "NCL", "wording": "Not Collected", "language": "en", "stage": "exception", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments. The pickup failed." },
|
|
26
|
+
{ "code": "ENM", "wording": "Arrived", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "Arrived at a terminal." },
|
|
27
|
+
{ "code": "MAN", "wording": "Departed", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "Left a terminal." },
|
|
28
|
+
{ "code": "DEP", "wording": "Linehaul departed", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
29
|
+
{ "code": "ARR", "wording": "Linehaul arrived", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
30
|
+
{ "code": "TIN", "wording": "Terminal inventory", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
31
|
+
{ "code": "VLO", "wording": "Vessel Loaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
32
|
+
{ "code": "VUL", "wording": "Vessel Unloaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
33
|
+
{ "code": "TLO", "wording": "Train Loaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
34
|
+
{ "code": "TUL", "wording": "Train Unloaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
35
|
+
{ "code": "VTL", "wording": "Vessel/Train Loaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
36
|
+
{ "code": "VTU", "wording": "Vessel/Train Unloaded", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
37
|
+
{ "code": "NLO", "wording": "Not loaded", "language": "en", "stage": "exception", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments. The shipment missed its planned load; a reason says why." },
|
|
38
|
+
{ "code": "CCL", "wording": "Customs clearance initiated", "language": "en", "stage": "customs", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
39
|
+
{ "code": "CCF", "wording": "Customs clearance finalised", "language": "en", "stage": "in_transit", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments. Cleared, and on its way again." },
|
|
40
|
+
{ "code": "DOT", "wording": "Out for Delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "live" },
|
|
41
|
+
{ "code": "NDL", "wording": "Not delivered", "language": "en", "stage": "failed_attempt", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
42
|
+
{ "code": "DIS", "wording": "To consignee's disposal", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments. The shipment waits for the consignee to collect it." },
|
|
43
|
+
{ "code": "DIF", "wording": "Ready for pick up", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
44
|
+
{ "code": "DLV", "wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "live" },
|
|
45
|
+
{ "code": "PUP", "wording": "Picked up by consignee", "language": "en", "stage": "delivered", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
46
|
+
{ "code": "PUF", "wording": "Picked up at agent", "language": "en", "stage": "delivered", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments." },
|
|
47
|
+
{ "code": "POD", "wording": "ePOD available", "language": "en", "stage": "delivered", "confirmedBy": "bundle", "note": "Named in the DSV Tracking app for former DB Schenker road shipments. The electronic proof of delivery follows the delivery." }
|
|
21
48
|
]
|
|
22
49
|
}
|
|
@@ -3,7 +3,8 @@ import type { CarrierResult } from '../../core/result/index.js';
|
|
|
3
3
|
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
4
|
import type { TrawlClient } from '../../core/transport/trawl.js';
|
|
5
5
|
export { magyarPostaTrackingUrl, normalizeMagyarPostaNumber, parseMagyarPosta } from './parser.js';
|
|
6
|
-
/** The search form's endpoint, and the result page
|
|
6
|
+
/** The tracker page, its search form's endpoint, and the result page the search redirects to. */
|
|
7
|
+
export declare const MAGYAR_POSTA_TRACKER = "https://www.posta.hu/ikrnyomkovproxy/";
|
|
7
8
|
export declare const MAGYAR_POSTA_SEARCH = "https://www.posta.hu/ikrnyomkovproxy/Search/Search";
|
|
8
9
|
export declare const MAGYAR_POSTA_RESULT = "https://www.posta.hu/ikrnyomkovproxy/Result/Index";
|
|
9
10
|
/**
|
|
@@ -13,6 +14,7 @@ export declare const MAGYAR_POSTA_RESULT = "https://www.posta.hu/ikrnyomkovproxy
|
|
|
13
14
|
*/
|
|
14
15
|
export declare function magyarPostaBrowser(trawl: TrawlClient, raw: string, signal: AbortSignal, remainingMs: number): Promise<CarrierResult>;
|
|
15
16
|
export declare class MagyarPostaTracker {
|
|
17
|
+
#private;
|
|
16
18
|
private readonly options;
|
|
17
19
|
constructor(options?: {
|
|
18
20
|
trawl?: TrawlClient | null;
|
|
@@ -3,9 +3,14 @@ import { runSteps } from '../../core/runner/index.js';
|
|
|
3
3
|
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
4
4
|
import { MAGYAR_POSTA_MAX_BYTES, magyarPostaTrackingUrl, normalizeMagyarPostaNumber, parseMagyarPosta, PROVIDER } from './parser.js';
|
|
5
5
|
export { magyarPostaTrackingUrl, normalizeMagyarPostaNumber, parseMagyarPosta } from './parser.js';
|
|
6
|
-
/** The search form's endpoint, and the result page
|
|
6
|
+
/** The tracker page, its search form's endpoint, and the result page the search redirects to. */
|
|
7
|
+
export const MAGYAR_POSTA_TRACKER = 'https://www.posta.hu/ikrnyomkovproxy/';
|
|
7
8
|
export const MAGYAR_POSTA_SEARCH = 'https://www.posta.hu/ikrnyomkovproxy/Search/Search';
|
|
8
9
|
export const MAGYAR_POSTA_RESULT = 'https://www.posta.hu/ikrnyomkovproxy/Result/Index';
|
|
10
|
+
/** The browser helper's record on the tracker page when Google refuses the audio challenge. */
|
|
11
|
+
const REFUSED = 'Google refused the audio challenge';
|
|
12
|
+
/** How long a refusal holds for this address, during which lookups do not open the browser. */
|
|
13
|
+
const REFUSAL_MEMORY_MS = 10 * 60_000;
|
|
9
14
|
/** A refused or failed page; a missing route is not a missing item. */
|
|
10
15
|
function httpFailure(status, retryAfter) {
|
|
11
16
|
if (status === 429)
|
|
@@ -31,6 +36,9 @@ export async function magyarPostaBrowser(trawl, raw, signal, remainingMs) {
|
|
|
31
36
|
signal.throwIfAborted();
|
|
32
37
|
if (page.capturedResponses.length > 10)
|
|
33
38
|
throw new ChallengeError(PROVIDER, 'Magyar Posta returned no matching verified search');
|
|
39
|
+
if (page.capturedResponses.some(row => row.url === MAGYAR_POSTA_TRACKER && row.error === REFUSED)) {
|
|
40
|
+
throw new ChallengeError(PROVIDER, 'Google refused Magyar Posta\'s audio challenge', { reason: 'verification_refused' });
|
|
41
|
+
}
|
|
34
42
|
const result = page.capturedResponses.filter(row => row.url === MAGYAR_POSTA_RESULT && row.trackingNumberSubmitted === number).at(-1);
|
|
35
43
|
const search = page.capturedResponses.filter(row => row.url === MAGYAR_POSTA_SEARCH).at(-1);
|
|
36
44
|
const response = result ?? search;
|
|
@@ -56,6 +64,7 @@ export async function magyarPostaBrowser(trawl, raw, signal, remainingMs) {
|
|
|
56
64
|
}
|
|
57
65
|
export class MagyarPostaTracker {
|
|
58
66
|
options;
|
|
67
|
+
#refusedUntil = 0;
|
|
59
68
|
constructor(options = {}) {
|
|
60
69
|
this.options = options;
|
|
61
70
|
}
|
|
@@ -67,7 +76,20 @@ export class MagyarPostaTracker {
|
|
|
67
76
|
throw new ChallengeError(PROVIDER, 'Magyar Posta requires the browser tracking service');
|
|
68
77
|
return runSteps({ carrier: 'magyar-posta', budgetMs: context.budgetMs ?? 45_000, signal: context.signal,
|
|
69
78
|
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
70
|
-
{ id: 'trawl', run: ({ signal, remainingMs }) =>
|
|
79
|
+
{ id: 'trawl', run: async ({ signal, remainingMs }) => {
|
|
80
|
+
// Another solve soon after a refusal would hold a browser for the same answer.
|
|
81
|
+
if (Date.now() < this.#refusedUntil) {
|
|
82
|
+
throw new ChallengeError(PROVIDER, 'Google refused Magyar Posta\'s audio challenge recently', { reason: 'verification_refused' });
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await magyarPostaBrowser(trawl, number, signal, remainingMs);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof ChallengeError && error.reason === 'verification_refused')
|
|
89
|
+
this.#refusedUntil = Date.now() + REFUSAL_MEMORY_MS;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
} },
|
|
71
93
|
]);
|
|
72
94
|
}
|
|
73
95
|
}
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED