universal-parcel-scraper 0.49.2-main.558 → 0.49.2-main.561
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/dist/carriers/dpd/adapter.d.ts +13 -0
- package/dist/carriers/dpd/adapter.js +45 -3
- package/dist/carriers/dpd-de/app.d.ts +3 -22
- package/dist/carriers/dpd-de/app.js +13 -178
- package/dist/carriers/dpd-de/service.d.ts +59 -0
- package/dist/carriers/dpd-de/service.js +200 -0
- package/dist/carriers/posti/adapter.js +29 -5
- package/dist/carriers/posti/identity.d.ts +8 -0
- package/dist/carriers/posti/identity.js +15 -0
- package/dist/core/catalog/eventIdentity.js +2 -1
- package/dist/providers/parcelsapp/adapter.js +4 -2
- package/dist/providers/shared/app.js +5 -2
- package/dist/providers/shared/result.d.ts +4 -1
- package/dist/providers/shared/result.js +6 -2
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { ChallengeError, IndeterminateError, NotFoundError, type CarrierErrorOpt
|
|
|
3
3
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
4
4
|
import type { StepRecorder } from '../../core/telemetry/index.js';
|
|
5
5
|
import { TrawlClient } from '../../core/transport/index.js';
|
|
6
|
+
import { type DpdParcelShop } from '../dpd-de/service.js';
|
|
6
7
|
/** Cloudflare interrupted the consignee page with an interactive challenge. */
|
|
7
8
|
export declare class DPDChallengeError extends ChallengeError {
|
|
8
9
|
constructor(message?: string, options?: CarrierErrorOptions);
|
|
@@ -37,6 +38,15 @@ export interface DPDTrackerOptions {
|
|
|
37
38
|
userAgent?: string;
|
|
38
39
|
/** A further German tier, ahead of the guest tier when no postcode is given. */
|
|
39
40
|
app?: DPDAppTier;
|
|
41
|
+
/** Finds the address of the Pickup shop a parcel waits at, by its PUDO id. */
|
|
42
|
+
shops?: DPDParcelShops;
|
|
43
|
+
}
|
|
44
|
+
export interface DPDParcelShops {
|
|
45
|
+
/** The shop, or nothing when it is not found in time; only the signal ends it with an error. */
|
|
46
|
+
parcelShop(id: string, options: {
|
|
47
|
+
signal: AbortSignal;
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
}): Promise<DpdParcelShop | undefined>;
|
|
40
50
|
}
|
|
41
51
|
export interface DPDAppTier {
|
|
42
52
|
/** `leads` when the guest tier is still to come and needs time left. `postcode` is empty when none was given. */
|
|
@@ -61,6 +71,7 @@ export declare class DPDTracker {
|
|
|
61
71
|
private readonly recorder?;
|
|
62
72
|
private readonly userAgent;
|
|
63
73
|
private readonly app?;
|
|
74
|
+
private readonly shops?;
|
|
64
75
|
constructor(options?: DPDTrackerOptions);
|
|
65
76
|
/** The browser service, resolved late so a malformed URL fails the page step, not construction. */
|
|
66
77
|
private browserService;
|
|
@@ -77,6 +88,8 @@ export declare class DPDTracker {
|
|
|
77
88
|
*/
|
|
78
89
|
recognizes(raw: string, context?: TrackingContext): Promise<boolean>;
|
|
79
90
|
private apiFetch;
|
|
91
|
+
/** The shop's record, within the budget less a moment for the lookup to answer without it. */
|
|
92
|
+
private pickupShop;
|
|
80
93
|
private detailsWithFreshToken;
|
|
81
94
|
private parcelDetails;
|
|
82
95
|
private accessToken;
|
|
@@ -9,6 +9,7 @@ import { runSteps } from '../../core/runner/index.js';
|
|
|
9
9
|
import { calendarDay, isoTime, explicitOffsetTime, zonedTime } from '../../core/time/index.js';
|
|
10
10
|
import { TrawlClient, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
11
11
|
import { isRecord } from '../../core/types.js';
|
|
12
|
+
import { DpdAppService } from '../dpd-de/service.js';
|
|
12
13
|
import { API_LABELS, PROOF_OF_DELIVERY_SCAN, apiStage, apiStatus, scanStage, wordingStatus, } from './status.js';
|
|
13
14
|
// Protocol provenance:
|
|
14
15
|
// - The myDPD Android application talks to a guest JSON API: a Firebase
|
|
@@ -48,6 +49,8 @@ const TOKEN_FAILURE_MEMORY_MS = 30_000;
|
|
|
48
49
|
/** The browser service may spend the whole request timeout plus its own transport allowance. */
|
|
49
50
|
const SOLVER_ALLOWANCE_MS = 15_000;
|
|
50
51
|
const MAX_BYTES = 10_000_000;
|
|
52
|
+
/** Left to a lookup that waited for its pickup shop's address, to answer without it. */
|
|
53
|
+
const SHOP_RESERVE_MS = 5_000;
|
|
51
54
|
/** Cloudflare interrupted the consignee page with an interactive challenge. */
|
|
52
55
|
export class DPDChallengeError extends ChallengeError {
|
|
53
56
|
constructor(message = 'DPD returned a Cloudflare browser challenge', options) {
|
|
@@ -175,11 +178,31 @@ function apiPickupPoint(payload, current, stage) {
|
|
|
175
178
|
if (stage !== 'ready_for_pickup')
|
|
176
179
|
return null;
|
|
177
180
|
// ParcelShop collection points are operational locations, not private
|
|
178
|
-
// addresses.
|
|
181
|
+
// addresses. The receiver's name is not one: it names the recipient.
|
|
179
182
|
const value = firstText(...[current.pickupPoint, current.parcelShop, payload.pickupPoint, payload.parcelShop]
|
|
180
|
-
.map((candidate) => (isRecord(candidate) ? firstText(candidate.name, candidate.shopName) : candidate))
|
|
183
|
+
.map((candidate) => (isRecord(candidate) ? firstText(candidate.name, candidate.shopName) : candidate)));
|
|
181
184
|
return value ? value.slice(0, 200) : null;
|
|
182
185
|
}
|
|
186
|
+
/** Scans at DPD's depots and hubs. */
|
|
187
|
+
const DEPOT_SCANS = new Set(['ORI', 'SPL', 'HUI', 'HUS', 'DLI', 'DLS', 'DLQ', 'DLO']);
|
|
188
|
+
/**
|
|
189
|
+
* The PUDO id of the shop the parcel waits at: that of the newest scan naming
|
|
190
|
+
* one, unless a depot scan came after it, as after the sender's drop-off at a
|
|
191
|
+
* shop. Verified replies only: `parcelHistory` names no shop.
|
|
192
|
+
*/
|
|
193
|
+
function pickupShopId(payload) {
|
|
194
|
+
const scans = (Array.isArray(payload.parcelEvents) ? payload.parcelEvents.filter(isRecord) : [])
|
|
195
|
+
.map((raw, index) => ({ raw, index, wallClock: `${clean(raw.date)}T${clean(raw.time)}` }))
|
|
196
|
+
.sort((left, right) => right.wallClock.localeCompare(left.wallClock) || left.index - right.index);
|
|
197
|
+
for (const { raw } of scans) {
|
|
198
|
+
const id = clean(raw.pudoId);
|
|
199
|
+
if (/^[A-Z]{2}\d{1,12}$/.test(id))
|
|
200
|
+
return id;
|
|
201
|
+
if (DEPOT_SCANS.has(scanCode(raw.eventType)))
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
183
206
|
function apiLocation(event) {
|
|
184
207
|
const city = known(event.city);
|
|
185
208
|
// A scan's own `country` wins even when it is a placeholder: `depotCountry`
|
|
@@ -329,7 +352,8 @@ export function parseDPDTrackingApi(payload, trackingNumber, postcodeVerified) {
|
|
|
329
352
|
}
|
|
330
353
|
};
|
|
331
354
|
// Only the fields below leave a scan. `podUrl` embeds the parcel number, and
|
|
332
|
-
// `pudoId`, `buShortName` and `depotCountry` are DPD's own routing data
|
|
355
|
+
// `pudoId`, `buShortName` and `depotCountry` are DPD's own routing data: a
|
|
356
|
+
// scan's `pudoId` only finds the address of the shop holding the parcel.
|
|
333
357
|
for (const raw of parcelEvents) {
|
|
334
358
|
const code = scanCode(raw.eventType);
|
|
335
359
|
const wallClock = scanWallClock(raw);
|
|
@@ -538,6 +562,7 @@ export class DPDTracker {
|
|
|
538
562
|
recorder;
|
|
539
563
|
userAgent;
|
|
540
564
|
app;
|
|
565
|
+
shops;
|
|
541
566
|
#accessToken = '';
|
|
542
567
|
#accessTokenExpiresAt = 0;
|
|
543
568
|
#basicToken = '';
|
|
@@ -557,6 +582,7 @@ export class DPDTracker {
|
|
|
557
582
|
this.recorder = options.recorder;
|
|
558
583
|
this.userAgent = userAgentOf(options.userAgent);
|
|
559
584
|
this.app = options.app;
|
|
585
|
+
this.shops = options.shops;
|
|
560
586
|
}
|
|
561
587
|
/** The browser service, resolved late so a malformed URL fails the page step, not construction. */
|
|
562
588
|
browserService() {
|
|
@@ -680,6 +706,15 @@ export class DPDTracker {
|
|
|
680
706
|
postcodeVerified = false;
|
|
681
707
|
}
|
|
682
708
|
const result = parseDPDTrackingApi(payload, trackingNumber, postcodeVerified);
|
|
709
|
+
const shopId = this.shops && result.current_stage === 'ready_for_pickup' ? pickupShopId(payload) : '';
|
|
710
|
+
if (shopId) {
|
|
711
|
+
// The reply names the shop, if at all, without its address: the shop's own
|
|
712
|
+
// record adds it. Without the record the result stays as it was.
|
|
713
|
+
const shop = await this.pickupShop(shopId, lookup);
|
|
714
|
+
const name = result.pickup_point || shop?.name.slice(0, 200);
|
|
715
|
+
if (shop && name)
|
|
716
|
+
result.pickup_point = `${name}\n${shop.address}`;
|
|
717
|
+
}
|
|
683
718
|
// The guest service can answer for another business unit. A selected
|
|
684
719
|
// German adapter must not project explicit evidence for another country.
|
|
685
720
|
if (this.country === 'DE' && isRecord(payload.status)) {
|
|
@@ -692,6 +727,11 @@ export class DPDTracker {
|
|
|
692
727
|
requireGuestActivity(payload, result);
|
|
693
728
|
return result;
|
|
694
729
|
}
|
|
730
|
+
/** The shop's record, within the budget less a moment for the lookup to answer without it. */
|
|
731
|
+
async pickupShop(id, lookup) {
|
|
732
|
+
const timeoutMs = Math.min(this.timeoutMs, lookup.remainingMs()) - SHOP_RESERVE_MS;
|
|
733
|
+
return timeoutMs > 0 ? this.shops.parcelShop(id, { signal: lookup.signal, timeoutMs }) : undefined;
|
|
734
|
+
}
|
|
695
735
|
async detailsWithFreshToken(trackingNumber, postcode, lookup) {
|
|
696
736
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
697
737
|
const token = await this.accessToken(lookup);
|
|
@@ -984,6 +1024,8 @@ export const adapter = (environment) => {
|
|
|
984
1024
|
userAgent: environment.userAgent,
|
|
985
1025
|
// A host can follow a rotated key without waiting for a release.
|
|
986
1026
|
firebaseApiKey: environment.env.DPD_FIREBASE_API_KEY?.trim() || undefined,
|
|
1027
|
+
// The German DPD app's service holds the group's Pickup shops, Swiss ones included.
|
|
1028
|
+
shops: new DpdAppService({ fetcher: environment.fetcher, userAgent: environment.userAgent }),
|
|
987
1029
|
});
|
|
988
1030
|
return {
|
|
989
1031
|
id: 'dpd',
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
2
|
import type { ClassifiedStatus } from '../../core/status/index.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import type { XmlNode } from '../../core/transport/xml.js';
|
|
4
|
+
import { type DpdDePartner } from './service.js';
|
|
5
5
|
/** Activity the service places outside Germany; the app tier would read the same parcel. */
|
|
6
6
|
export declare const DPD_DE_OTHER_COUNTRY = "other_country";
|
|
7
|
-
/** The lookup stopped waiting for the session, which keeps opening for the next one. */
|
|
8
|
-
export declare const DPD_DE_SESSION_OPENING = "session_opening";
|
|
9
|
-
export interface DpdDePartner {
|
|
10
|
-
name: string;
|
|
11
|
-
token: string;
|
|
12
|
-
password: string;
|
|
13
|
-
}
|
|
14
7
|
/** The app's progress rail: one coarse state for the whole parcel. */
|
|
15
8
|
export declare const DPD_DE_APP_RAIL: Readonly<Record<string, ClassifiedStatus>>;
|
|
16
9
|
/**
|
|
@@ -21,11 +14,7 @@ export declare function parseDpdDeApp(data: XmlNode, scans: XmlNode, number: str
|
|
|
21
14
|
result: CarrierResult;
|
|
22
15
|
shop: string;
|
|
23
16
|
};
|
|
24
|
-
/**
|
|
25
|
-
* The German app's SOAP service. Tracking needs an anonymous device session, which the
|
|
26
|
-
* service takes tens of seconds to open and then accepts for hours: one is kept per client.
|
|
27
|
-
* The opening runs on its own clock, so a lookup that stops waiting leaves it to the next.
|
|
28
|
-
*/
|
|
17
|
+
/** DPD Germany's tracking through the German app's service, which keeps one session per client. */
|
|
29
18
|
export declare class DpdDeAppClient {
|
|
30
19
|
#private;
|
|
31
20
|
constructor(options?: {
|
|
@@ -45,12 +34,4 @@ export declare class DpdDeAppClient {
|
|
|
45
34
|
sessionWaitMs?: number;
|
|
46
35
|
postcode?: string;
|
|
47
36
|
}): Promise<CarrierResult>;
|
|
48
|
-
/** The shop's street and town, or nothing: the parcel is found, and its pickup point keeps its name without them. */
|
|
49
|
-
private shopAddress;
|
|
50
|
-
/**
|
|
51
|
-
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
52
|
-
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
53
|
-
*/
|
|
54
|
-
private session;
|
|
55
|
-
private call;
|
|
56
37
|
}
|
|
@@ -1,22 +1,10 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
1
|
import { DateTime } from 'luxon';
|
|
3
|
-
import { CarrierError,
|
|
2
|
+
import { CarrierError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
4
3
|
import { calendarDay, isoTime, zonedTime } from '../../core/time/index.js';
|
|
5
|
-
import {
|
|
6
|
-
import { xmlDocument } from '../../core/transport/xml.js';
|
|
4
|
+
import { children, DpdAppService, invalid, one, optional, PostcodeRejected, scalar, SessionExpired, } from './service.js';
|
|
7
5
|
import { DPD_DE_APP_SCANS, dpdDeScanKey } from './status.js';
|
|
8
|
-
export const DPD_DE_APP_API = 'https://api.paketnavigator.de/services/v1/Navigator3Service.asmx';
|
|
9
6
|
/** Activity the service places outside Germany; the app tier would read the same parcel. */
|
|
10
7
|
export const DPD_DE_OTHER_COUNTRY = 'other_country';
|
|
11
|
-
/** The lookup stopped waiting for the session, which keeps opening for the next one. */
|
|
12
|
-
export const DPD_DE_SESSION_OPENING = 'session_opening';
|
|
13
|
-
const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
|
|
14
|
-
const SERVICE = 'https://cloud.dpd.com/';
|
|
15
|
-
const MAX_BYTES = 2_000_000;
|
|
16
|
-
/** Opening a session has taken up to 42 seconds. */
|
|
17
|
-
const SESSION_OPEN_MS = 75_000;
|
|
18
|
-
// Shared partner credentials of the public app, distributed with the maintainer's approval.
|
|
19
|
-
const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
|
|
20
8
|
/** The app's progress rail: one coarse state for the whole parcel. */
|
|
21
9
|
export const DPD_DE_APP_RAIL = {
|
|
22
10
|
ACCEPTED: { status: 'pending', stage: 'registered' },
|
|
@@ -27,26 +15,6 @@ export const DPD_DE_APP_RAIL = {
|
|
|
27
15
|
// Set while the return travels and after it reaches the sender: its own scans tell them apart.
|
|
28
16
|
RETURN_TO_SENDER: { status: 'exception', stage: 'exception' },
|
|
29
17
|
};
|
|
30
|
-
function invalid() { throw new SchemaError('DPD Germany', 'DPD Germany returned invalid tracking XML'); }
|
|
31
|
-
function children(node, name, uri = SERVICE) {
|
|
32
|
-
return node.children.filter(child => child.name === name && child.uri === uri);
|
|
33
|
-
}
|
|
34
|
-
function one(node, name, uri = SERVICE) {
|
|
35
|
-
const matches = children(node, name, uri);
|
|
36
|
-
return matches.length === 1 ? matches[0] : invalid();
|
|
37
|
-
}
|
|
38
|
-
function scalar(node, name, max = 200) {
|
|
39
|
-
const matches = children(node, name);
|
|
40
|
-
if (matches.length > 1 || matches[0]?.children.length)
|
|
41
|
-
invalid();
|
|
42
|
-
return clean(matches[0]?.text ?? '', max);
|
|
43
|
-
}
|
|
44
|
-
function optional(node, name) {
|
|
45
|
-
const matches = children(node, name);
|
|
46
|
-
if (matches.length > 1)
|
|
47
|
-
invalid();
|
|
48
|
-
return matches[0];
|
|
49
|
-
}
|
|
50
18
|
const affirmative = (value) => value === 'true' || value === '1';
|
|
51
19
|
const placeholderDay = (day) => day === '2000-01-01' || day === '0001-01-01';
|
|
52
20
|
/** Validate source digits without assigning a zone to an offset-less estimate. */
|
|
@@ -98,13 +66,6 @@ function deliveryEstimate(tracking, announced) {
|
|
|
98
66
|
// DeliveryDateTime is display text and can omit the year: never invent it.
|
|
99
67
|
return day ?? announced ?? estimateDay(scalar(tracking, 'DeliveryDateTime', 64));
|
|
100
68
|
}
|
|
101
|
-
const escaped = (value) => value.replace(/[<>&"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
102
|
-
const elements = (fields) => Object.entries(fields)
|
|
103
|
-
.map(([name, value]) => `<${name}>${typeof value === 'string' ? escaped(value) : elements(value)}</${name}>`).join('');
|
|
104
|
-
class SessionExpired extends Error {
|
|
105
|
-
}
|
|
106
|
-
class PostcodeRejected extends Error {
|
|
107
|
-
}
|
|
108
69
|
/** A two-letter country, where the service puts a placeholder three-letter one for an unknown depot. */
|
|
109
70
|
function countryOf(node) {
|
|
110
71
|
const country = node ? scalar(node, 'Country', 8) : '';
|
|
@@ -131,18 +92,6 @@ function shopOf(row) {
|
|
|
131
92
|
}
|
|
132
93
|
return undefined;
|
|
133
94
|
}
|
|
134
|
-
/** The street and town of the shop that `getParcelShopByID` returns, a line each, if it is the requested one. */
|
|
135
|
-
function addressOf(result, id) {
|
|
136
|
-
const shop = one(result, 'ParcelShop');
|
|
137
|
-
if (scalar(shop, 'PUDOID', 40) !== id)
|
|
138
|
-
return '';
|
|
139
|
-
const address = one(shop, 'ShopAddress');
|
|
140
|
-
const street = scalar(address, 'Street', 120);
|
|
141
|
-
const city = scalar(address, 'City', 80);
|
|
142
|
-
if (!street || !city)
|
|
143
|
-
return '';
|
|
144
|
-
return `${[street, scalar(address, 'HouseNo', 20)].filter(Boolean).join(' ')}\n${[scalar(address, 'ZipCode', 16), city].filter(Boolean).join(' ')}`;
|
|
145
|
-
}
|
|
146
95
|
/** The measured length, width and height, in millimetres; the shipper's own figures are not read. */
|
|
147
96
|
function dimensionsOf(order) {
|
|
148
97
|
const sides = ['Length', 'Width', 'Height'].map(name => scalar(order, name, 16));
|
|
@@ -239,24 +188,11 @@ export function parseDpdDeApp(data, scans, number) {
|
|
|
239
188
|
};
|
|
240
189
|
return { result, shop: pickup?.id ?? '' };
|
|
241
190
|
}
|
|
242
|
-
/**
|
|
243
|
-
* The German app's SOAP service. Tracking needs an anonymous device session, which the
|
|
244
|
-
* service takes tens of seconds to open and then accepts for hours: one is kept per client.
|
|
245
|
-
* The opening runs on its own clock, so a lookup that stops waiting leaves it to the next.
|
|
246
|
-
*/
|
|
191
|
+
/** DPD Germany's tracking through the German app's service, which keeps one session per client. */
|
|
247
192
|
export class DpdDeAppClient {
|
|
248
|
-
#
|
|
249
|
-
#fetcher;
|
|
250
|
-
#userAgent;
|
|
251
|
-
#now;
|
|
252
|
-
#device = randomBytes(8).toString('hex');
|
|
253
|
-
#session = '';
|
|
254
|
-
#opening = null;
|
|
193
|
+
#service;
|
|
255
194
|
constructor(options = {}) {
|
|
256
|
-
this.#
|
|
257
|
-
this.#fetcher = options.fetcher;
|
|
258
|
-
this.#userAgent = userAgentOf(options.userAgent);
|
|
259
|
-
this.#now = options.now ?? Date.now;
|
|
195
|
+
this.#service = new DpdAppService(options);
|
|
260
196
|
}
|
|
261
197
|
/**
|
|
262
198
|
* `sessionWaitMs` bounds the wait for a session still opening; the whole `timeoutMs` by default.
|
|
@@ -273,9 +209,9 @@ export class DpdDeAppClient {
|
|
|
273
209
|
const left = () => Math.max(1, Math.floor(deadline - performance.now()));
|
|
274
210
|
try {
|
|
275
211
|
for (let attempt = 0;; attempt += 1) {
|
|
276
|
-
const session = await this.session(options.signal, Math.min(left(), options.sessionWaitMs ?? Infinity));
|
|
212
|
+
const session = await this.#service.session(options.signal, Math.min(left(), options.sessionWaitMs ?? Infinity));
|
|
277
213
|
// Read-only: the parcel is neither added to the session nor redirected.
|
|
278
|
-
const tracking = (zip) => this.call('getTrackingData', { SessionToken: session, ParcelNo: number, DeliveryZipCode: zip,
|
|
214
|
+
const tracking = (zip) => this.#service.call('getTrackingData', { SessionToken: session, ParcelNo: number, DeliveryZipCode: zip,
|
|
279
215
|
UpdateNewDeliveryData: 'false', addParcelIfNoTrackingdataAvailable: 'false', ParcelFlowTypeID: 'receiving' }, options.signal, left());
|
|
280
216
|
try {
|
|
281
217
|
let data;
|
|
@@ -292,15 +228,14 @@ export class DpdDeAppClient {
|
|
|
292
228
|
data = await tracking('');
|
|
293
229
|
verified = false;
|
|
294
230
|
}
|
|
295
|
-
const scans = await this.call('getTrackingScanList', { SessionToken: session, ParcelNo: number,
|
|
231
|
+
const scans = await this.#service.call('getTrackingScanList', { SessionToken: session, ParcelNo: number,
|
|
296
232
|
DeliveryZipCode: verified ? postcode : '' }, options.signal, left());
|
|
297
233
|
const { result, shop } = parseDpdDeApp(data, scans, number);
|
|
298
234
|
// The scans name the shop without its address, which the shop's own record adds.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
235
|
+
// Without it, the parcel is found all the same and its pickup point keeps the name alone.
|
|
236
|
+
const address = shop ? (await this.#service.parcelShop(shop, { signal: options.signal, timeoutMs: left() }))?.address : undefined;
|
|
237
|
+
if (address)
|
|
238
|
+
result.pickup_point = `${result.pickup_point}\n${address}`;
|
|
304
239
|
if (verified !== undefined)
|
|
305
240
|
result.dpd_postcode_verified = verified;
|
|
306
241
|
return result;
|
|
@@ -308,8 +243,7 @@ export class DpdDeAppClient {
|
|
|
308
243
|
catch (error) {
|
|
309
244
|
if (!(error instanceof SessionExpired) || attempt)
|
|
310
245
|
throw error;
|
|
311
|
-
|
|
312
|
-
this.#session = '';
|
|
246
|
+
this.#service.expire(session);
|
|
313
247
|
}
|
|
314
248
|
}
|
|
315
249
|
}
|
|
@@ -324,103 +258,4 @@ export class DpdDeAppClient {
|
|
|
324
258
|
throw new TransportError('DPD Germany', 'DPD Germany app request failed');
|
|
325
259
|
}
|
|
326
260
|
}
|
|
327
|
-
/** The shop's street and town, or nothing: the parcel is found, and its pickup point keeps its name without them. */
|
|
328
|
-
async shopAddress(session, id, signal, timeoutMs) {
|
|
329
|
-
try {
|
|
330
|
-
return addressOf(await this.call('getParcelShopByID', { SessionToken: session, ParcelShopID: '0', PudoID: id,
|
|
331
|
-
ParcelShopOnly: 'false' }, signal, timeoutMs), id);
|
|
332
|
-
}
|
|
333
|
-
catch {
|
|
334
|
-
signal.throwIfAborted();
|
|
335
|
-
return '';
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
340
|
-
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
341
|
-
*/
|
|
342
|
-
async session(signal, waitMs) {
|
|
343
|
-
if (this.#session)
|
|
344
|
-
return this.#session;
|
|
345
|
-
signal.throwIfAborted();
|
|
346
|
-
if (!this.#opening) {
|
|
347
|
-
const controller = new AbortController();
|
|
348
|
-
const holders = new Set();
|
|
349
|
-
const token = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
|
|
350
|
-
HardwareID: this.#device, BootSystemID: 'Android_Phone', Version: '15', AppVersion: '4.2.0',
|
|
351
|
-
} }, controller.signal, SESSION_OPEN_MS).then(result => {
|
|
352
|
-
const value = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
|
|
353
|
-
if (!/^[A-Za-z0-9+/=]{16,512}$/.test(value))
|
|
354
|
-
invalid();
|
|
355
|
-
return this.#session = value;
|
|
356
|
-
});
|
|
357
|
-
const release = () => {
|
|
358
|
-
for (const holder of holders)
|
|
359
|
-
if (holder.aborted)
|
|
360
|
-
holders.delete(holder);
|
|
361
|
-
if (!holders.size)
|
|
362
|
-
controller.abort(new Error('No lookup is waiting for the DPD Germany session'));
|
|
363
|
-
};
|
|
364
|
-
const opening = { token, holders, release };
|
|
365
|
-
this.#opening = opening;
|
|
366
|
-
void token.catch(() => undefined).finally(() => {
|
|
367
|
-
if (this.#opening === opening)
|
|
368
|
-
this.#opening = null;
|
|
369
|
-
for (const holder of holders)
|
|
370
|
-
holder.removeEventListener('abort', release);
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
const { token, holders, release } = this.#opening;
|
|
374
|
-
if (!holders.has(signal)) {
|
|
375
|
-
holders.add(signal);
|
|
376
|
-
signal.addEventListener('abort', release, { once: true });
|
|
377
|
-
}
|
|
378
|
-
let leave;
|
|
379
|
-
let timer;
|
|
380
|
-
const left = new Promise((_resolve, reject) => {
|
|
381
|
-
leave = () => reject(signal.reason);
|
|
382
|
-
timer = setTimeout(() => reject(new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', {
|
|
383
|
-
reason: DPD_DE_SESSION_OPENING,
|
|
384
|
-
})), Math.max(0, Math.min(waitMs, SESSION_OPEN_MS)));
|
|
385
|
-
});
|
|
386
|
-
signal.addEventListener('abort', leave, { once: true });
|
|
387
|
-
try {
|
|
388
|
-
return await Promise.race([token, left]);
|
|
389
|
-
}
|
|
390
|
-
finally {
|
|
391
|
-
clearTimeout(timer);
|
|
392
|
-
signal.removeEventListener('abort', leave);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
async call(operation, fields, signal, timeoutMs) {
|
|
396
|
-
// The service checks a key derived from the minute of the UTC day.
|
|
397
|
-
const now = new Date(this.#now());
|
|
398
|
-
const phase = String((now.getUTCHours() * 60 + now.getUTCMinutes() + 1000) * 3);
|
|
399
|
-
const key = phase + createHash('md5').update(`${phase}${this.#partner.name}0${operation}${this.#partner.password}`).digest('base64').slice(0, 16);
|
|
400
|
-
const body = `<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:soap="${SOAP}"><soap:Body><${operation} xmlns="${SERVICE}"><${operation}Request>${elements({
|
|
401
|
-
Version: '100', Language: 'de_EN', PartnerCredentials: { Name: this.#partner.name, Token: this.#partner.token, KeyPhase: key }, ...fields,
|
|
402
|
-
})}</${operation}Request></${operation}></soap:Body></soap:Envelope>`;
|
|
403
|
-
const { response, bytes } = await fetchBounded(DPD_DE_APP_API, { method: 'POST', signal, body,
|
|
404
|
-
headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: `"${SERVICE}${operation}"`, Accept: 'text/xml', 'User-Agent': this.#userAgent } }, {
|
|
405
|
-
provider: 'DPD Germany', maxBytes: MAX_BYTES, timeoutMs, fetcher: this.#fetcher, allowHttpStatuses: [404, 410],
|
|
406
|
-
});
|
|
407
|
-
if (response.status !== 200)
|
|
408
|
-
throw new TransportError('DPD Germany', 'DPD Germany app service is unavailable', { status: response.status });
|
|
409
|
-
const root = xmlDocument(decodeText(bytes), MAX_BYTES) ?? invalid();
|
|
410
|
-
if (root.name !== 'Envelope' || root.uri !== SOAP)
|
|
411
|
-
invalid();
|
|
412
|
-
const result = one(one(one(root, 'Body', SOAP), `${operation}Response`), `${operation}Result`);
|
|
413
|
-
const codes = children(result, 'ErrorDataList').flatMap(list => children(list, 'ErrorData')).map(error => scalar(error, 'ErrorCode', 80));
|
|
414
|
-
if (codes.some(code => code === 'ERROR_PARTNER' || code === 'ERROR_KEYPHASE')) {
|
|
415
|
-
throw new ChallengeError('DPD Germany', 'DPD Germany refused the app credential');
|
|
416
|
-
}
|
|
417
|
-
if (codes.includes('ERROR_SESSION_NOT_VALID'))
|
|
418
|
-
throw new SessionExpired();
|
|
419
|
-
if (codes.includes('ERROR_TRACKING_DELIVERYZIPCODE_NOT_VALID'))
|
|
420
|
-
throw new PostcodeRejected();
|
|
421
|
-
// "No tracking data" also answers for parcels the scan list still knows: it proves no absence.
|
|
422
|
-
if (scalar(result, 'Ack', 8) !== 'true')
|
|
423
|
-
throw new IndeterminateError('DPD Germany', 'DPD Germany returned no confirmed parcel');
|
|
424
|
-
return result;
|
|
425
|
-
}
|
|
426
261
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type XmlNode } from '../../core/transport/xml.js';
|
|
2
|
+
export declare const DPD_DE_APP_API = "https://api.paketnavigator.de/services/v1/Navigator3Service.asmx";
|
|
3
|
+
/** The lookup stopped waiting for the session, which keeps opening for the next one. */
|
|
4
|
+
export declare const DPD_DE_SESSION_OPENING = "session_opening";
|
|
5
|
+
export interface DpdDePartner {
|
|
6
|
+
name: string;
|
|
7
|
+
token: string;
|
|
8
|
+
password: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function invalid(): never;
|
|
11
|
+
export declare function children(node: XmlNode, name: string, uri?: string): XmlNode[];
|
|
12
|
+
export declare function one(node: XmlNode, name: string, uri?: string): XmlNode;
|
|
13
|
+
export declare function scalar(node: XmlNode, name: string, max?: number): string;
|
|
14
|
+
export declare function optional(node: XmlNode, name: string): XmlNode | undefined;
|
|
15
|
+
type Fields = {
|
|
16
|
+
readonly [name: string]: string | Fields;
|
|
17
|
+
};
|
|
18
|
+
/** The service no longer accepts the session. */
|
|
19
|
+
export declare class SessionExpired extends Error {
|
|
20
|
+
}
|
|
21
|
+
/** DPD does not hold the postcode to be the recipient's. */
|
|
22
|
+
export declare class PostcodeRejected extends Error {
|
|
23
|
+
}
|
|
24
|
+
/** A Pickup shop as its own record names it: its name, then its street and its town on a line each. */
|
|
25
|
+
export interface DpdParcelShop {
|
|
26
|
+
name: string;
|
|
27
|
+
address: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The service needs an anonymous device session, which it takes tens of seconds to open and then
|
|
31
|
+
* accepts for hours: one is kept per instance. The opening runs on its own clock, so a lookup that
|
|
32
|
+
* stops waiting leaves it to the next.
|
|
33
|
+
*/
|
|
34
|
+
export declare class DpdAppService {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(options?: {
|
|
37
|
+
partner?: DpdDePartner;
|
|
38
|
+
fetcher?: typeof fetch;
|
|
39
|
+
userAgent?: string;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* The Pickup shop with this PUDO id, or nothing when the service does not answer it within
|
|
44
|
+
* `timeoutMs`, including the wait for a session. Only the signal ends it with an error.
|
|
45
|
+
*/
|
|
46
|
+
parcelShop(id: string, options: {
|
|
47
|
+
signal: AbortSignal;
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
}): Promise<DpdParcelShop | undefined>;
|
|
50
|
+
/** Forgets a session the service refused, unless another lookup already replaced it. */
|
|
51
|
+
expire(session: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
54
|
+
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
55
|
+
*/
|
|
56
|
+
session(signal: AbortSignal, waitMs: number): Promise<string>;
|
|
57
|
+
call(operation: string, fields: Fields, signal: AbortSignal, timeoutMs: number): Promise<XmlNode>;
|
|
58
|
+
}
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
4
|
+
import { xmlDocument } from '../../core/transport/xml.js';
|
|
5
|
+
// The German DPD app's SOAP service, shared between carriers. DPD Germany tracks parcels
|
|
6
|
+
// through it, and DPD Switzerland reads the address of the Pickup shop holding a parcel
|
|
7
|
+
// from it: `getParcelShopByID` answers for the group's shops, Swiss ones included.
|
|
8
|
+
export const DPD_DE_APP_API = 'https://api.paketnavigator.de/services/v1/Navigator3Service.asmx';
|
|
9
|
+
/** The lookup stopped waiting for the session, which keeps opening for the next one. */
|
|
10
|
+
export const DPD_DE_SESSION_OPENING = 'session_opening';
|
|
11
|
+
const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
|
|
12
|
+
const SERVICE = 'https://cloud.dpd.com/';
|
|
13
|
+
const MAX_BYTES = 2_000_000;
|
|
14
|
+
/** Opening a session has taken up to 42 seconds. */
|
|
15
|
+
const SESSION_OPEN_MS = 75_000;
|
|
16
|
+
// Shared partner credentials of the public app, distributed with the maintainer's approval.
|
|
17
|
+
const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
|
|
18
|
+
export function invalid() { throw new SchemaError('DPD Germany', 'DPD Germany returned invalid tracking XML'); }
|
|
19
|
+
export function children(node, name, uri = SERVICE) {
|
|
20
|
+
return node.children.filter(child => child.name === name && child.uri === uri);
|
|
21
|
+
}
|
|
22
|
+
export function one(node, name, uri = SERVICE) {
|
|
23
|
+
const matches = children(node, name, uri);
|
|
24
|
+
return matches.length === 1 ? matches[0] : invalid();
|
|
25
|
+
}
|
|
26
|
+
export function scalar(node, name, max = 200) {
|
|
27
|
+
const matches = children(node, name);
|
|
28
|
+
if (matches.length > 1 || matches[0]?.children.length)
|
|
29
|
+
invalid();
|
|
30
|
+
return clean(matches[0]?.text ?? '', max);
|
|
31
|
+
}
|
|
32
|
+
export function optional(node, name) {
|
|
33
|
+
const matches = children(node, name);
|
|
34
|
+
if (matches.length > 1)
|
|
35
|
+
invalid();
|
|
36
|
+
return matches[0];
|
|
37
|
+
}
|
|
38
|
+
const escaped = (value) => value.replace(/[<>&"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
39
|
+
const elements = (fields) => Object.entries(fields)
|
|
40
|
+
.map(([name, value]) => `<${name}>${typeof value === 'string' ? escaped(value) : elements(value)}</${name}>`).join('');
|
|
41
|
+
/** The service no longer accepts the session. */
|
|
42
|
+
export class SessionExpired extends Error {
|
|
43
|
+
}
|
|
44
|
+
/** DPD does not hold the postcode to be the recipient's. */
|
|
45
|
+
export class PostcodeRejected extends Error {
|
|
46
|
+
}
|
|
47
|
+
/** The shop that `getParcelShopByID` returns, if it is the requested one and has a street and a town. */
|
|
48
|
+
function shopOf(result, id) {
|
|
49
|
+
const shop = one(result, 'ParcelShop');
|
|
50
|
+
if (scalar(shop, 'PUDOID', 40) !== id)
|
|
51
|
+
return undefined;
|
|
52
|
+
const address = one(shop, 'ShopAddress');
|
|
53
|
+
const street = scalar(address, 'Street', 120);
|
|
54
|
+
const city = scalar(address, 'City', 80);
|
|
55
|
+
if (!street || !city)
|
|
56
|
+
return undefined;
|
|
57
|
+
return {
|
|
58
|
+
name: scalar(address, 'Company', 120),
|
|
59
|
+
address: `${[street, scalar(address, 'HouseNo', 20)].filter(Boolean).join(' ')}\n${[scalar(address, 'ZipCode', 16), city].filter(Boolean).join(' ')}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The service needs an anonymous device session, which it takes tens of seconds to open and then
|
|
64
|
+
* accepts for hours: one is kept per instance. The opening runs on its own clock, so a lookup that
|
|
65
|
+
* stops waiting leaves it to the next.
|
|
66
|
+
*/
|
|
67
|
+
export class DpdAppService {
|
|
68
|
+
#partner;
|
|
69
|
+
#fetcher;
|
|
70
|
+
#userAgent;
|
|
71
|
+
#now;
|
|
72
|
+
#device = randomBytes(8).toString('hex');
|
|
73
|
+
#session = '';
|
|
74
|
+
#opening = null;
|
|
75
|
+
constructor(options = {}) {
|
|
76
|
+
this.#partner = options.partner ?? PARTNER;
|
|
77
|
+
this.#fetcher = options.fetcher;
|
|
78
|
+
this.#userAgent = userAgentOf(options.userAgent);
|
|
79
|
+
this.#now = options.now ?? Date.now;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The Pickup shop with this PUDO id, or nothing when the service does not answer it within
|
|
83
|
+
* `timeoutMs`, including the wait for a session. Only the signal ends it with an error.
|
|
84
|
+
*/
|
|
85
|
+
async parcelShop(id, options) {
|
|
86
|
+
const deadline = performance.now() + options.timeoutMs;
|
|
87
|
+
const left = () => Math.max(1, Math.floor(deadline - performance.now()));
|
|
88
|
+
try {
|
|
89
|
+
for (let attempt = 0;; attempt += 1) {
|
|
90
|
+
const session = await this.session(options.signal, left());
|
|
91
|
+
try {
|
|
92
|
+
return shopOf(await this.call('getParcelShopByID', { SessionToken: session, ParcelShopID: '0', PudoID: id,
|
|
93
|
+
ParcelShopOnly: 'false' }, options.signal, left()), id);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!(error instanceof SessionExpired) || attempt)
|
|
97
|
+
throw error;
|
|
98
|
+
this.expire(session);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
options.signal.throwIfAborted();
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Forgets a session the service refused, unless another lookup already replaced it. */
|
|
108
|
+
expire(session) {
|
|
109
|
+
if (this.#session === session)
|
|
110
|
+
this.#session = '';
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
114
|
+
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
115
|
+
*/
|
|
116
|
+
async session(signal, waitMs) {
|
|
117
|
+
if (this.#session)
|
|
118
|
+
return this.#session;
|
|
119
|
+
signal.throwIfAborted();
|
|
120
|
+
if (!this.#opening) {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const holders = new Set();
|
|
123
|
+
const token = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
|
|
124
|
+
HardwareID: this.#device, BootSystemID: 'Android_Phone', Version: '15', AppVersion: '4.2.0',
|
|
125
|
+
} }, controller.signal, SESSION_OPEN_MS).then(result => {
|
|
126
|
+
const value = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
|
|
127
|
+
if (!/^[A-Za-z0-9+/=]{16,512}$/.test(value))
|
|
128
|
+
invalid();
|
|
129
|
+
return this.#session = value;
|
|
130
|
+
});
|
|
131
|
+
const release = () => {
|
|
132
|
+
for (const holder of holders)
|
|
133
|
+
if (holder.aborted)
|
|
134
|
+
holders.delete(holder);
|
|
135
|
+
if (!holders.size)
|
|
136
|
+
controller.abort(new Error('No lookup is waiting for the DPD Germany session'));
|
|
137
|
+
};
|
|
138
|
+
const opening = { token, holders, release };
|
|
139
|
+
this.#opening = opening;
|
|
140
|
+
void token.catch(() => undefined).finally(() => {
|
|
141
|
+
if (this.#opening === opening)
|
|
142
|
+
this.#opening = null;
|
|
143
|
+
for (const holder of holders)
|
|
144
|
+
holder.removeEventListener('abort', release);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const { token, holders, release } = this.#opening;
|
|
148
|
+
if (!holders.has(signal)) {
|
|
149
|
+
holders.add(signal);
|
|
150
|
+
signal.addEventListener('abort', release, { once: true });
|
|
151
|
+
}
|
|
152
|
+
let leave;
|
|
153
|
+
let timer;
|
|
154
|
+
const left = new Promise((_resolve, reject) => {
|
|
155
|
+
leave = () => reject(signal.reason);
|
|
156
|
+
timer = setTimeout(() => reject(new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', {
|
|
157
|
+
reason: DPD_DE_SESSION_OPENING,
|
|
158
|
+
})), Math.max(0, Math.min(waitMs, SESSION_OPEN_MS)));
|
|
159
|
+
});
|
|
160
|
+
signal.addEventListener('abort', leave, { once: true });
|
|
161
|
+
try {
|
|
162
|
+
return await Promise.race([token, left]);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
signal.removeEventListener('abort', leave);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async call(operation, fields, signal, timeoutMs) {
|
|
170
|
+
// The service checks a key derived from the minute of the UTC day.
|
|
171
|
+
const now = new Date(this.#now());
|
|
172
|
+
const phase = String((now.getUTCHours() * 60 + now.getUTCMinutes() + 1000) * 3);
|
|
173
|
+
const key = phase + createHash('md5').update(`${phase}${this.#partner.name}0${operation}${this.#partner.password}`).digest('base64').slice(0, 16);
|
|
174
|
+
const body = `<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:soap="${SOAP}"><soap:Body><${operation} xmlns="${SERVICE}"><${operation}Request>${elements({
|
|
175
|
+
Version: '100', Language: 'de_EN', PartnerCredentials: { Name: this.#partner.name, Token: this.#partner.token, KeyPhase: key }, ...fields,
|
|
176
|
+
})}</${operation}Request></${operation}></soap:Body></soap:Envelope>`;
|
|
177
|
+
const { response, bytes } = await fetchBounded(DPD_DE_APP_API, { method: 'POST', signal, body,
|
|
178
|
+
headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: `"${SERVICE}${operation}"`, Accept: 'text/xml', 'User-Agent': this.#userAgent } }, {
|
|
179
|
+
provider: 'DPD Germany', maxBytes: MAX_BYTES, timeoutMs, fetcher: this.#fetcher, allowHttpStatuses: [404, 410],
|
|
180
|
+
});
|
|
181
|
+
if (response.status !== 200)
|
|
182
|
+
throw new TransportError('DPD Germany', 'DPD Germany app service is unavailable', { status: response.status });
|
|
183
|
+
const root = xmlDocument(decodeText(bytes), MAX_BYTES) ?? invalid();
|
|
184
|
+
if (root.name !== 'Envelope' || root.uri !== SOAP)
|
|
185
|
+
invalid();
|
|
186
|
+
const result = one(one(one(root, 'Body', SOAP), `${operation}Response`), `${operation}Result`);
|
|
187
|
+
const codes = children(result, 'ErrorDataList').flatMap(list => children(list, 'ErrorData')).map(error => scalar(error, 'ErrorCode', 80));
|
|
188
|
+
if (codes.some(code => code === 'ERROR_PARTNER' || code === 'ERROR_KEYPHASE')) {
|
|
189
|
+
throw new ChallengeError('DPD Germany', 'DPD Germany refused the app credential');
|
|
190
|
+
}
|
|
191
|
+
if (codes.includes('ERROR_SESSION_NOT_VALID'))
|
|
192
|
+
throw new SessionExpired();
|
|
193
|
+
if (codes.includes('ERROR_TRACKING_DELIVERYZIPCODE_NOT_VALID'))
|
|
194
|
+
throw new PostcodeRejected();
|
|
195
|
+
// "No tracking data" also answers for parcels the scan list still knows: it proves no absence.
|
|
196
|
+
if (scalar(result, 'Ack', 8) !== 'true')
|
|
197
|
+
throw new IndeterminateError('DPD Germany', 'DPD Germany returned no confirmed parcel');
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -4,12 +4,14 @@ import { runSteps } from '../../core/runner/index.js';
|
|
|
4
4
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
5
5
|
import { clean, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
6
6
|
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { isPostiAbroadLabel } from './identity.js';
|
|
7
8
|
import { postiEventStage, postiStatus } from './status.js';
|
|
8
9
|
const TOKEN_URL = 'https://auth-service.posti.fi/api/v1/anonymous_token';
|
|
9
10
|
const GRAPHQL_URL = 'https://graphql.posti.fi/graphql';
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
11
12
|
// The public tracking page uses this operation with PUBLIC_SHIPMENTS. Select
|
|
12
|
-
// only tracking fields
|
|
13
|
+
// only tracking fields and the pickup point's own address; no account,
|
|
14
|
+
// recipient address, payment or pickup code.
|
|
13
15
|
const QUERY = `query SearchShipments($searchTerms: [String!]!, $locale: String) {
|
|
14
16
|
consumerSearchShipments(page: 1, pageSize: 20, type: PUBLIC_SHIPMENTS, searchTerms: $searchTerms, locale: $locale) {
|
|
15
17
|
totalHits
|
|
@@ -17,7 +19,7 @@ const QUERY = `query SearchShipments($searchTerms: [String!]!, $locale: String)
|
|
|
17
19
|
displayId
|
|
18
20
|
status { main subStatus }
|
|
19
21
|
measurements { weight { unit value } height { unit value } width { unit value } length { unit value } }
|
|
20
|
-
pickupPoint { address { publicName city } }
|
|
22
|
+
pickupPoint { type address { publicName streetAddress postcode city } }
|
|
21
23
|
events { city eventDescription reasonDescription timestamp }
|
|
22
24
|
packages { trackingNumber }
|
|
23
25
|
}
|
|
@@ -47,6 +49,27 @@ function assertGraphql(payload) {
|
|
|
47
49
|
if (payload.errors != null && !Array.isArray(payload.errors))
|
|
48
50
|
throw new SchemaError('Posti');
|
|
49
51
|
}
|
|
52
|
+
// Pickup point types whose address is a public business's: Posti's lockers and
|
|
53
|
+
// service points. A private locker stands in the recipient's building.
|
|
54
|
+
const PUBLIC_POINTS = new Set(['LOCKER', 'LOCKER_OUTDOOR', 'POSTI_SERVICE_POINT']);
|
|
55
|
+
/**
|
|
56
|
+
* The pickup point's name, then its street and "postcode city", as Posti's
|
|
57
|
+
* tracker lays them out. A private locker, or a point of unknown type, keeps
|
|
58
|
+
* only its town after the name: its street could be the recipient's.
|
|
59
|
+
*/
|
|
60
|
+
function pickupPoint(raw) {
|
|
61
|
+
const point = isRecord(raw) ? raw : {};
|
|
62
|
+
const address = isRecord(point.address) ? point.address : {};
|
|
63
|
+
const name = clean(address.publicName, 160);
|
|
64
|
+
if (!name)
|
|
65
|
+
return null;
|
|
66
|
+
const street = clean(address.streetAddress, 120);
|
|
67
|
+
const city = clean(address.city, 80);
|
|
68
|
+
if (street && city && typeof point.type === 'string' && PUBLIC_POINTS.has(point.type)) {
|
|
69
|
+
return [name, street, [clean(address.postcode, 16), city].filter(Boolean).join(' ')].join('\n');
|
|
70
|
+
}
|
|
71
|
+
return [name, city].filter(Boolean).join('\n');
|
|
72
|
+
}
|
|
50
73
|
function measurement(raw, units) {
|
|
51
74
|
if (!isRecord(raw) || typeof raw.unit !== 'string' || !Object.hasOwn(units, raw.unit.toLowerCase()))
|
|
52
75
|
return null;
|
|
@@ -87,17 +110,17 @@ export function parse(payload, trackingNumber) {
|
|
|
87
110
|
if (!isRecord(event) || typeof event.eventDescription !== 'string')
|
|
88
111
|
throw new SchemaError('Posti');
|
|
89
112
|
const description = clean(event.eventDescription);
|
|
113
|
+
const city = clean(event.city, 160);
|
|
90
114
|
return {
|
|
91
115
|
description: [description, clean(event.reasonDescription)].filter(Boolean).join(' '),
|
|
92
116
|
time: explicitOffsetTime(event.timestamp)?.iso ?? '',
|
|
93
|
-
location:
|
|
117
|
+
location: isPostiAbroadLabel(city) ? '' : city,
|
|
94
118
|
stage: postiEventStage(description),
|
|
95
119
|
};
|
|
96
120
|
}).sort((left, right) => (Date.parse(right.time || '') || 0) - (Date.parse(left.time || '') || 0)).slice(0, 100);
|
|
97
121
|
const current = postiStatus(hit.status.main, hit.status.subStatus);
|
|
98
122
|
const measurements = isRecord(hit.measurements) ? hit.measurements : {};
|
|
99
123
|
const dimensions = ['length', 'width', 'height'].map((field) => measurement(measurements[field], { cm: 1, mm: 0.1, m: 100 }));
|
|
100
|
-
const pickup = isRecord(hit.pickupPoint) && isRecord(hit.pickupPoint.address) ? hit.pickupPoint.address : {};
|
|
101
124
|
return {
|
|
102
125
|
status: current?.status ?? 'unknown',
|
|
103
126
|
...(current ? { current_stage: current.stage } : {}),
|
|
@@ -106,7 +129,8 @@ export function parse(payload, trackingNumber) {
|
|
|
106
129
|
provider_status: hit.status.main,
|
|
107
130
|
weight_kg: measurement(measurements.weight, { kg: 1, g: 0.001 }),
|
|
108
131
|
dimensions_text: dimensions.every((value) => value !== null) ? `${dimensions.join(' × ')} cm` : null,
|
|
109
|
-
|
|
132
|
+
// Posti's tracker shows the point at any status, a planned one in transit included.
|
|
133
|
+
pickup_point: current?.stage === 'ready_for_pickup' ? pickupPoint(hit.pickupPoint) : null,
|
|
110
134
|
timezone: 'Europe/Helsinki',
|
|
111
135
|
events,
|
|
112
136
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a scan's town is Posti's "ULKOMAILLA" ("abroad"), which it gives,
|
|
4
|
+
* untranslated, to scans outside Finland. It names no place, and the public
|
|
5
|
+
* reply carries no country to put in its stead.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isPostiAbroadLabel(location: string): boolean;
|
|
8
|
+
export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a scan's town is Posti's "ULKOMAILLA" ("abroad"), which it gives,
|
|
4
|
+
* untranslated, to scans outside Finland. It names no place, and the public
|
|
5
|
+
* reply carries no country to put in its stead.
|
|
6
|
+
*/
|
|
7
|
+
export function isPostiAbroadLabel(location) {
|
|
8
|
+
return location.replace(/\s+/g, ' ').trim().toLocaleUpperCase('fi-FI') === 'ULKOMAILLA';
|
|
9
|
+
}
|
|
10
|
+
// Scans stored with that label as their location lose it and keep their row.
|
|
11
|
+
export const sameInstantIdentityPolicy = {
|
|
12
|
+
sourceCarrierId: 'posti', storedSources: ['posti'], requireProviderCode: false,
|
|
13
|
+
matchEachScan: true,
|
|
14
|
+
matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isPostiAbroadLabel),
|
|
15
|
+
};
|
|
@@ -5,10 +5,11 @@ import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
|
|
|
5
5
|
import { sameInstantIdentityPolicy as dpdFrance } from '../../carriers/dpd-fr/identity.js';
|
|
6
6
|
import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
|
|
7
7
|
import { sameInstantIdentityPolicy as mondialRelay } from '../../carriers/mondial-relay/identity.js';
|
|
8
|
+
import { sameInstantIdentityPolicy as posti } from '../../carriers/posti/identity.js';
|
|
8
9
|
import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
|
|
9
10
|
import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
|
|
10
11
|
import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
|
|
11
|
-
const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
12
|
+
const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, posti, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
12
13
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
13
14
|
export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
|
|
14
15
|
const policy = policies.get(sourceCarrierId);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { load } from 'cheerio';
|
|
11
11
|
import { DateTime } from 'luxon';
|
|
12
12
|
import timers from 'node:timers/promises';
|
|
13
|
+
import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
|
|
13
14
|
import { carrierTimezone } from '../../core/catalog/index.js';
|
|
14
15
|
import { brandTimeZones, carrierIdFromName, carrierNameCountryZone } from '../../core/catalog/hints.js';
|
|
15
16
|
import { CarrierError, carrierErrorKind, ChallengeError, IndeterminateError, InputRequiredError, NoHistoryError, SchemaError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
|
|
@@ -236,8 +237,9 @@ function parseHistory(payload, trackingNumber, timezone = null) {
|
|
|
236
237
|
}
|
|
237
238
|
if (parsed) {
|
|
238
239
|
const location = typeof raw.location === 'string' ? raw.location.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
|
239
|
-
// Some Chronopost rows put the service label in the location field
|
|
240
|
-
|
|
240
|
+
// Some Chronopost rows put the service label in the location field, and
|
|
241
|
+
// Posti names scans outside Finland "ULKOMAILLA" (abroad).
|
|
242
|
+
if (location && !/^Type de livraison\s*:/i.test(location) && !isPostiAbroadLabel(location))
|
|
241
243
|
parsed.location = location;
|
|
242
244
|
events.push(parsed);
|
|
243
245
|
scanCarriers.add(name);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
|
|
1
|
+
import { matchesDroppedLocation, matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
|
|
3
|
+
// A copy may add a place, or drop Posti's "abroad" label it was once stored with.
|
|
2
4
|
export const sameInstantIdentityPolicy = {
|
|
3
5
|
sourceCarrierId: 'unknown', storedSources: ['unknown'], requireProviderCode: false,
|
|
4
6
|
matchEachScan: true,
|
|
5
|
-
matches: matchesLocationEnrichment,
|
|
7
|
+
matches: (incoming, stored) => matchesLocationEnrichment(incoming, stored)
|
|
8
|
+
|| matchesDroppedLocation(incoming, stored, isPostiAbroadLabel),
|
|
6
9
|
};
|
|
@@ -5,7 +5,10 @@ export declare function numberOf(raw: string): string;
|
|
|
5
5
|
export declare function text(value: unknown): string;
|
|
6
6
|
export declare function isNotice(description: string): boolean;
|
|
7
7
|
export declare function hasPrivateDeliveryDetails(description: string): boolean;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* The place a provider reports for a scan, unless it holds private delivery
|
|
10
|
+
* details or is Posti's "abroad" label.
|
|
11
|
+
*/
|
|
9
12
|
export declare function place(value: unknown): string;
|
|
10
13
|
export declare function eventStage(description: string): Stage | undefined;
|
|
11
14
|
/** Retain the decision before delivery wording is redacted or a stage is projected. */
|
|
@@ -12,6 +12,7 @@ import { DateTime } from 'luxon';
|
|
|
12
12
|
import { trackingLanguageStage } from '../../core/status/index.js';
|
|
13
13
|
import { deliveryForecastRemainder, nonterminalEnglishReturn } from '../../core/status/language.js';
|
|
14
14
|
import { IndeterminateError, InvalidInputError } from '../../core/errors/index.js';
|
|
15
|
+
import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
|
|
15
16
|
const STAGES = {
|
|
16
17
|
InfoReceived: 'registered', InTransit: 'in_transit', AvailableForPickup: 'ready_for_pickup',
|
|
17
18
|
OutForDelivery: 'out_for_delivery', DeliveryFailure: 'failed_attempt', Delivered: 'delivered',
|
|
@@ -35,10 +36,13 @@ export function isNotice(description) {
|
|
|
35
36
|
export function hasPrivateDeliveryDetails(description) {
|
|
36
37
|
return /\bpin\s*:|(?:access|security|pickup|collection) code|(?:door|house) (?:no\b|number)|signed (?:for )?by|signature|numero civico|firmato da|signe par|signé par|code (?:de retrait|d['’]acc[eè]s)|num[eé]ro de (?:rue|maison)|abholcode|zugangscode|hausnummer|unterschrieben von|codice (?:di ritiro|di accesso)|c[oó]digo (?:pin|secreto|de (?:recogida|retirada|retiro|acceso|acesso|entrega|seguridad|seguran[cç]a|levantamento|recolha|desbloqueo|desbloqueio|apertura|abertura))|firmad[oa] por|firma del destinatari|assinad[oa] por|assinatura|n[uú]mero (?:de (?:portal|puerta|casa|piso|vivienda|pol[ií]cia)|da (?:porta|casa))|(?:porta|puerta) n\.?\s?[º°]|n\.?\s?[º°] (?:de |da )?porta/i.test(description);
|
|
37
38
|
}
|
|
38
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The place a provider reports for a scan, unless it holds private delivery
|
|
41
|
+
* details or is Posti's "abroad" label.
|
|
42
|
+
*/
|
|
39
43
|
export function place(value) {
|
|
40
44
|
const location = text(value).slice(0, 200);
|
|
41
|
-
return location && !hasPrivateDeliveryDetails(location) ? location : '';
|
|
45
|
+
return location && !hasPrivateDeliveryDetails(location) && !isPostiAbroadLabel(location) ? location : '';
|
|
42
46
|
}
|
|
43
47
|
function sourceEventStage(description, includeBroadMovement = true) {
|
|
44
48
|
// Public aggregators retain the carrier's French wording even in English.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.49.2-main.
|
|
3
|
+
"version": "0.49.2-main.561",
|
|
4
4
|
"description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|