universal-parcel-scraper 0.53.0-main.670 → 0.53.0-main.672
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-de/service.d.ts +30 -4
- package/dist/carriers/dpd-de/service.js +152 -11
- package/dist/node.d.ts +1 -1
- package/package.json +1 -1
|
@@ -27,6 +27,26 @@ export interface DpdParcelShop {
|
|
|
27
27
|
name: string;
|
|
28
28
|
address: string;
|
|
29
29
|
}
|
|
30
|
+
/** A session as a host keeps it between processes. Times are milliseconds since the epoch. */
|
|
31
|
+
export interface DpdSession {
|
|
32
|
+
token: string;
|
|
33
|
+
openedAt: number;
|
|
34
|
+
/** The last time a check found the service accepting it, once lookups no longer used it. */
|
|
35
|
+
checkedAt?: number;
|
|
36
|
+
/** When the service began refusing it, once a second check confirmed it. */
|
|
37
|
+
refusedAt?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Where a long-lived host keeps the sessions, so that a restart takes the current one up again
|
|
41
|
+
* instead of opening another, and so that how long the service accepts one can be read back
|
|
42
|
+
* from `refusedAt - openedAt`. The service carries on without a store that fails.
|
|
43
|
+
*/
|
|
44
|
+
export interface DpdSessionStore {
|
|
45
|
+
/** The sessions saved that the service has not refused, in any order. */
|
|
46
|
+
load(): Promise<readonly DpdSession[]>;
|
|
47
|
+
/** Saves a session, replacing what was saved for the same token. */
|
|
48
|
+
save(session: DpdSession): Promise<void>;
|
|
49
|
+
}
|
|
30
50
|
/**
|
|
31
51
|
* The service needs an anonymous device session, which it takes tens of seconds to open and then
|
|
32
52
|
* accepts for hours. One is kept per instance, and `sharedDpdAppService` gives the process one
|
|
@@ -54,9 +74,11 @@ export declare class DpdAppService {
|
|
|
54
74
|
/**
|
|
55
75
|
* Keeps a session open from now on, for a long-lived host: one opens at once if there is none,
|
|
56
76
|
* and the next before the current one lapses. Openings then run whether or not a lookup waits
|
|
57
|
-
* for them.
|
|
77
|
+
* for them. With a store, the current session saved there is taken up again instead, and the
|
|
78
|
+
* sessions lookups no longer use are checked until the service refuses them. The timers do not
|
|
79
|
+
* keep the process alive.
|
|
58
80
|
*/
|
|
59
|
-
keep(): void;
|
|
81
|
+
keep(store?: DpdSessionStore): void;
|
|
60
82
|
/**
|
|
61
83
|
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
62
84
|
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
@@ -74,7 +96,11 @@ export declare function sharedDpdAppService(options?: Pick<AdapterEnvironment, '
|
|
|
74
96
|
* Opens the session DPD Germany and DPD Switzerland read the German DPD app's service with, in the
|
|
75
97
|
* background, and keeps one open for the life of the process, so that no lookup waits the tens of
|
|
76
98
|
* seconds an opening takes. For a long-lived host, with the `fetcher` and `userAgent` its adapter
|
|
77
|
-
* environment has.
|
|
99
|
+
* environment has. With a `store`, a restart takes the saved session up again, and the sessions
|
|
100
|
+
* no longer used are checked every hour until the service refuses them, which the store records.
|
|
101
|
+
* The timers do not keep the process alive.
|
|
78
102
|
*/
|
|
79
|
-
export declare function warmDpdSession(environment?: Pick<AdapterEnvironment, 'fetcher' | 'userAgent'
|
|
103
|
+
export declare function warmDpdSession(environment?: Pick<AdapterEnvironment, 'fetcher' | 'userAgent'>, options?: {
|
|
104
|
+
store?: DpdSessionStore;
|
|
105
|
+
}): void;
|
|
80
106
|
export {};
|
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
3
|
import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
4
4
|
import { xmlDocument } from '../../core/transport/xml.js';
|
|
5
|
+
import { isRecord } from '../../core/types.js';
|
|
5
6
|
// The German DPD app's SOAP service, shared between carriers. DPD Germany tracks parcels
|
|
6
7
|
// through it, and DPD Switzerland reads the address of the Pickup shop holding a parcel
|
|
7
8
|
// from it: `getParcelShopByID` answers for the group's shops, Swiss ones included.
|
|
@@ -17,9 +18,24 @@ const SESSION_OPEN_MS = 120_000;
|
|
|
17
18
|
* The service accepts a session for at least eight hours, one left idle for four too. From this
|
|
18
19
|
* age the next one opens beside it, and replaces it once open.
|
|
19
20
|
*/
|
|
20
|
-
const SESSION_RENEW_MS =
|
|
21
|
+
const SESSION_RENEW_MS = 8 * 3_600_000;
|
|
21
22
|
/** A kept session that failed to open is tried again after this long. */
|
|
22
23
|
const SESSION_RETRY_MS = 15 * 60_000;
|
|
24
|
+
/** A host's store has this long to give back its sessions before the service opens one. */
|
|
25
|
+
const STORE_LOAD_MS = 5_000;
|
|
26
|
+
/**
|
|
27
|
+
* With a store, a session lookups no longer use is checked this often, until the service refuses
|
|
28
|
+
* it twice in a row: while slow, it can refuse a session it accepts again later.
|
|
29
|
+
*/
|
|
30
|
+
const CHECK_EVERY_MS = 3_600_000;
|
|
31
|
+
/** And for at most this long after it opened. */
|
|
32
|
+
const CHECK_FOR_MS = 7 * 24 * 3_600_000;
|
|
33
|
+
/** A check takes a fraction of a second, and checks of several sessions are this far apart. */
|
|
34
|
+
const CHECK_TIMEOUT_MS = 30_000;
|
|
35
|
+
const CHECK_GAP_MS = 5_000;
|
|
36
|
+
/** A shop id no shop has: the service answers it without a shop, or refuses the session. */
|
|
37
|
+
const CHECK_PUDO_ID = 'XX0000';
|
|
38
|
+
const TOKEN = /^[A-Za-z0-9+/=]{16,512}$/;
|
|
23
39
|
// Shared partner credentials of the public app, distributed with the maintainer's approval.
|
|
24
40
|
const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
|
|
25
41
|
export function invalid() { throw new SchemaError('DPD Germany', 'DPD Germany returned invalid tracking XML'); }
|
|
@@ -51,6 +67,9 @@ export class SessionExpired extends Error {
|
|
|
51
67
|
/** DPD does not hold the postcode to be the recipient's. */
|
|
52
68
|
export class PostcodeRejected extends Error {
|
|
53
69
|
}
|
|
70
|
+
/** The service took the session, but confirmed nothing. */
|
|
71
|
+
class Unconfirmed extends IndeterminateError {
|
|
72
|
+
}
|
|
54
73
|
/** The shop that `getParcelShopByID` returns, if it is the requested one and has a street and a town. */
|
|
55
74
|
function shopOf(result, id) {
|
|
56
75
|
const shop = one(result, 'ParcelShop');
|
|
@@ -66,6 +85,19 @@ function shopOf(result, id) {
|
|
|
66
85
|
address: `${[street, scalar(address, 'HouseNo', 20)].filter(Boolean).join(' ')}\n${[scalar(address, 'ZipCode', 16), city].filter(Boolean).join(' ')}`,
|
|
67
86
|
};
|
|
68
87
|
}
|
|
88
|
+
/** The promise's outcome, or the signal's reason once it aborts first. */
|
|
89
|
+
async function until(promise, signal) {
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
let leave;
|
|
92
|
+
const left = new Promise((_resolve, reject) => { leave = () => reject(signal.reason); });
|
|
93
|
+
signal.addEventListener('abort', leave, { once: true });
|
|
94
|
+
try {
|
|
95
|
+
return await Promise.race([promise, left]);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
signal.removeEventListener('abort', leave);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
69
101
|
/**
|
|
70
102
|
* The service needs an anonymous device session, which it takes tens of seconds to open and then
|
|
71
103
|
* accepts for hours. One is kept per instance, and `sharedDpdAppService` gives the process one
|
|
@@ -82,6 +114,9 @@ export class DpdAppService {
|
|
|
82
114
|
#opening = null;
|
|
83
115
|
#kept = false;
|
|
84
116
|
#timer;
|
|
117
|
+
#store;
|
|
118
|
+
#loading = null;
|
|
119
|
+
#retired = new Map();
|
|
85
120
|
constructor(options = {}) {
|
|
86
121
|
this.#partner = options.partner ?? PARTNER;
|
|
87
122
|
this.#fetcher = options.fetcher;
|
|
@@ -116,19 +151,118 @@ export class DpdAppService {
|
|
|
116
151
|
}
|
|
117
152
|
/** Forgets a session the service refused, unless another lookup already replaced it. */
|
|
118
153
|
expire(session) {
|
|
119
|
-
if (this.#session?.token
|
|
120
|
-
|
|
154
|
+
if (this.#session?.token !== session)
|
|
155
|
+
return;
|
|
156
|
+
this.#retire(this.#session, this.#now());
|
|
157
|
+
this.#session = null;
|
|
121
158
|
}
|
|
122
159
|
/**
|
|
123
160
|
* Keeps a session open from now on, for a long-lived host: one opens at once if there is none,
|
|
124
161
|
* and the next before the current one lapses. Openings then run whether or not a lookup waits
|
|
125
|
-
* for them.
|
|
162
|
+
* for them. With a store, the current session saved there is taken up again instead, and the
|
|
163
|
+
* sessions lookups no longer use are checked until the service refuses them. The timers do not
|
|
164
|
+
* keep the process alive.
|
|
126
165
|
*/
|
|
127
|
-
keep() {
|
|
166
|
+
keep(store) {
|
|
128
167
|
if (this.#kept)
|
|
129
168
|
return;
|
|
130
169
|
this.#kept = true;
|
|
131
|
-
this.#
|
|
170
|
+
this.#store = store;
|
|
171
|
+
if (!store)
|
|
172
|
+
return this.#renew();
|
|
173
|
+
this.#loading = this.#load(store).finally(() => {
|
|
174
|
+
this.#loading = null;
|
|
175
|
+
this.#renew();
|
|
176
|
+
this.#follow();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Takes up the newest saved session while it is younger than the renewal age, and follows the others. */
|
|
180
|
+
async #load(store) {
|
|
181
|
+
let timer;
|
|
182
|
+
const late = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('The store did not answer')), STORE_LOAD_MS); });
|
|
183
|
+
let saved;
|
|
184
|
+
try {
|
|
185
|
+
saved = await Promise.race([Promise.resolve().then(() => store.load()), late]);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
const now = this.#now();
|
|
194
|
+
const sessions = (Array.isArray(saved) ? saved : [])
|
|
195
|
+
.filter((session) => isRecord(session) && typeof session.token === 'string' && TOKEN.test(session.token)
|
|
196
|
+
&& typeof session.openedAt === 'number' && session.openedAt <= now && now - session.openedAt < CHECK_FOR_MS
|
|
197
|
+
&& session.refusedAt == null)
|
|
198
|
+
.sort((left, right) => right.openedAt - left.openedAt);
|
|
199
|
+
const [newest, ...older] = sessions;
|
|
200
|
+
if (newest && !this.#session && now - newest.openedAt < SESSION_RENEW_MS)
|
|
201
|
+
this.#session = { token: newest.token, openedAt: newest.openedAt };
|
|
202
|
+
else if (newest)
|
|
203
|
+
older.unshift(newest);
|
|
204
|
+
for (const session of older)
|
|
205
|
+
this.#retired.set(session.token, { session: { ...session } });
|
|
206
|
+
}
|
|
207
|
+
/** Saves a session's state in the host's store, if there is one, without waiting for it. */
|
|
208
|
+
#save(session) {
|
|
209
|
+
const store = this.#store;
|
|
210
|
+
if (store)
|
|
211
|
+
void Promise.resolve().then(() => store.save({ ...session })).catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
/** Follows a session lookups no longer use, when a store keeps what its checks find. */
|
|
214
|
+
#retire(session, refusedAt) {
|
|
215
|
+
if (this.#store && !this.#retired.has(session.token))
|
|
216
|
+
this.#retired.set(session.token, { session: { ...session }, refusedSince: refusedAt });
|
|
217
|
+
}
|
|
218
|
+
/** Checks the sessions lookups no longer use, every hour. */
|
|
219
|
+
#follow() {
|
|
220
|
+
const timer = setTimeout(() => void this.#check().finally(() => this.#follow()), CHECK_EVERY_MS);
|
|
221
|
+
timer.unref?.();
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Asks the service once for each followed session whether it still accepts it. A refusal
|
|
225
|
+
* confirmed by the next check is saved as the time it began; a reply that is not about the
|
|
226
|
+
* session says nothing.
|
|
227
|
+
*/
|
|
228
|
+
async #check() {
|
|
229
|
+
let first = true;
|
|
230
|
+
for (const [token, retired] of [...this.#retired]) {
|
|
231
|
+
const now = this.#now();
|
|
232
|
+
if (now - retired.session.openedAt >= CHECK_FOR_MS) {
|
|
233
|
+
this.#retired.delete(token);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!first)
|
|
237
|
+
await new Promise((resolve) => { setTimeout(resolve, CHECK_GAP_MS).unref?.(); });
|
|
238
|
+
first = false;
|
|
239
|
+
let accepted;
|
|
240
|
+
try {
|
|
241
|
+
await this.call('getParcelShopByID', { SessionToken: token, ParcelShopID: '0', PudoID: CHECK_PUDO_ID, ParcelShopOnly: 'false' }, AbortSignal.timeout(CHECK_TIMEOUT_MS), CHECK_TIMEOUT_MS);
|
|
242
|
+
accepted = true;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof SessionExpired)
|
|
246
|
+
accepted = false;
|
|
247
|
+
else if (error instanceof Unconfirmed)
|
|
248
|
+
accepted = true;
|
|
249
|
+
else
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (accepted) {
|
|
253
|
+
retired.refusedSince = undefined;
|
|
254
|
+
retired.session.checkedAt = this.#now();
|
|
255
|
+
this.#save(retired.session);
|
|
256
|
+
}
|
|
257
|
+
else if (retired.refusedSince === undefined) {
|
|
258
|
+
retired.refusedSince = this.#now();
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
retired.session.refusedAt = retired.refusedSince;
|
|
262
|
+
this.#retired.delete(token);
|
|
263
|
+
this.#save(retired.session);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
132
266
|
}
|
|
133
267
|
/** Opens the next session if the current one is due, or waits until it is. */
|
|
134
268
|
#renew() {
|
|
@@ -155,9 +289,12 @@ export class DpdAppService {
|
|
|
155
289
|
HardwareID: this.#device, BootSystemID: 'Android_Phone', Version: '15', AppVersion: '4.2.0',
|
|
156
290
|
} }, controller.signal, SESSION_OPEN_MS).then(result => {
|
|
157
291
|
const value = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
|
|
158
|
-
if (
|
|
292
|
+
if (!TOKEN.test(value))
|
|
159
293
|
invalid();
|
|
294
|
+
if (this.#session)
|
|
295
|
+
this.#retire(this.#session);
|
|
160
296
|
this.#session = { token: value, openedAt: this.#now() };
|
|
297
|
+
this.#save(this.#session);
|
|
161
298
|
return value;
|
|
162
299
|
});
|
|
163
300
|
const release = () => {
|
|
@@ -182,6 +319,8 @@ export class DpdAppService {
|
|
|
182
319
|
* A session past its renewal age answers at once while the next one opens.
|
|
183
320
|
*/
|
|
184
321
|
async session(signal, waitMs) {
|
|
322
|
+
if (this.#loading)
|
|
323
|
+
await until(this.#loading, signal);
|
|
185
324
|
const current = this.#session;
|
|
186
325
|
const due = !current || this.#now() - current.openedAt >= SESSION_RENEW_MS;
|
|
187
326
|
if (current && !due)
|
|
@@ -240,7 +379,7 @@ export class DpdAppService {
|
|
|
240
379
|
throw new PostcodeRejected();
|
|
241
380
|
// "No tracking data" also answers for parcels the scan list still knows: it proves no absence.
|
|
242
381
|
if (scalar(result, 'Ack', 8) !== 'true')
|
|
243
|
-
throw new
|
|
382
|
+
throw new Unconfirmed('DPD Germany', 'DPD Germany returned no confirmed parcel');
|
|
244
383
|
return result;
|
|
245
384
|
}
|
|
246
385
|
}
|
|
@@ -265,8 +404,10 @@ export function sharedDpdAppService(options = {}) {
|
|
|
265
404
|
* Opens the session DPD Germany and DPD Switzerland read the German DPD app's service with, in the
|
|
266
405
|
* background, and keeps one open for the life of the process, so that no lookup waits the tens of
|
|
267
406
|
* seconds an opening takes. For a long-lived host, with the `fetcher` and `userAgent` its adapter
|
|
268
|
-
* environment has.
|
|
407
|
+
* environment has. With a `store`, a restart takes the saved session up again, and the sessions
|
|
408
|
+
* no longer used are checked every hour until the service refuses them, which the store records.
|
|
409
|
+
* The timers do not keep the process alive.
|
|
269
410
|
*/
|
|
270
|
-
export function warmDpdSession(environment = {}) {
|
|
271
|
-
sharedDpdAppService(environment).keep();
|
|
411
|
+
export function warmDpdSession(environment = {}, options = {}) {
|
|
412
|
+
sharedDpdAppService(environment).keep(options.store);
|
|
272
413
|
}
|
package/dist/node.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ export { REGISTRY } from './generated/registry.js';
|
|
|
8
8
|
export { UniversalTracker, UniversalTrackingError } from './providers/universal.js';
|
|
9
9
|
export { TrackingCaptureError, SeventeenTrackLookupError, SeventeenTrackNoHistoryError, SeventeenTrackVerificationError } from './providers/universal.js';
|
|
10
10
|
export { amazonShippingEligibility } from './carriers/amazon-shipping/eligibility.js';
|
|
11
|
-
export { warmDpdSession } from './carriers/dpd-de/service.js';
|
|
11
|
+
export { warmDpdSession, type DpdSession, type DpdSessionStore } from './carriers/dpd-de/service.js';
|
|
12
12
|
export { createTrackingServer, type TrackingServerOptions } from './server/index.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.53.0-main.
|
|
3
|
+
"version": "0.53.0-main.672",
|
|
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",
|