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.
@@ -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. The timer does not keep the process alive.
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. The renewal timer does not keep the process alive.
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'>): void;
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 = 3 * 3_600_000;
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 === session)
120
- this.#session = null;
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. The timer does not keep the process alive.
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.#renew();
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 (!/^[A-Za-z0-9+/=]{16,512}$/.test(value))
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 IndeterminateError('DPD Germany', 'DPD Germany returned no confirmed parcel');
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. The renewal timer does not keep the process alive.
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.670",
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",