universal-parcel-scraper 0.49.2-main.567 → 0.50.0-main.568

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.
@@ -9,7 +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
+ import { sharedDpdAppService } from '../dpd-de/service.js';
13
13
  import { API_LABELS, PROOF_OF_DELIVERY_SCAN, apiStage, apiStatus, scanStage, wordingStatus, } from './status.js';
14
14
  // Protocol provenance:
15
15
  // - The myDPD Android application talks to a guest JSON API: a Firebase
@@ -1025,7 +1025,8 @@ export const adapter = (environment) => {
1025
1025
  // A host can follow a rotated key without waiting for a release.
1026
1026
  firebaseApiKey: environment.env.DPD_FIREBASE_API_KEY?.trim() || undefined,
1027
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 }),
1028
+ // Its session is the process's, which DPD Germany shares.
1029
+ shops: sharedDpdAppService(environment),
1029
1030
  });
1030
1031
  return {
1031
1032
  id: 'dpd',
@@ -1,11 +1,13 @@
1
1
  import { CarrierError, carrierErrorKind } from '../../core/errors/index.js';
2
2
  import { DPDTracker } from '../dpd/adapter.js';
3
3
  import { DPD_DE_OTHER_COUNTRY, DpdDeAppClient } from './app.js';
4
+ import { sharedDpdAppService } from './service.js';
4
5
  /** Left to the guest API when the app's session is still opening ahead of it. */
5
6
  const GUEST_RESERVE_MS = 20_000;
6
7
  /** Germany reads the German app's service, then the guest protocol with its own business-unit selector. */
7
8
  export const adapter = (environment) => {
8
- const app = new DpdDeAppClient({ fetcher: environment.fetcher, userAgent: environment.userAgent });
9
+ // The process's session, which DPD Switzerland shares.
10
+ const app = new DpdDeAppClient({ service: sharedDpdAppService(environment) });
9
11
  const tracker = new DPDTracker({
10
12
  country: 'DE',
11
13
  fetcher: environment.fetcher,
@@ -1,7 +1,7 @@
1
1
  import type { CarrierResult } from '../../core/result/index.js';
2
2
  import type { ClassifiedStatus } from '../../core/status/index.js';
3
3
  import type { XmlNode } from '../../core/transport/xml.js';
4
- import { type DpdDePartner } from './service.js';
4
+ import { DpdAppService, 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
7
  /** The app's progress rail: one coarse state for the whole parcel. */
@@ -14,10 +14,11 @@ export declare function parseDpdDeApp(data: XmlNode, scans: XmlNode, number: str
14
14
  result: CarrierResult;
15
15
  shop: string;
16
16
  };
17
- /** DPD Germany's tracking through the German app's service, which keeps one session per client. */
17
+ /** DPD Germany's tracking through the German app's service, over the given one or a new one. */
18
18
  export declare class DpdDeAppClient {
19
19
  #private;
20
20
  constructor(options?: {
21
+ service?: DpdAppService;
21
22
  partner?: DpdDePartner;
22
23
  fetcher?: typeof fetch;
23
24
  userAgent?: string;
@@ -188,11 +188,11 @@ export function parseDpdDeApp(data, scans, number) {
188
188
  };
189
189
  return { result, shop: pickup?.id ?? '' };
190
190
  }
191
- /** DPD Germany's tracking through the German app's service, which keeps one session per client. */
191
+ /** DPD Germany's tracking through the German app's service, over the given one or a new one. */
192
192
  export class DpdDeAppClient {
193
193
  #service;
194
194
  constructor(options = {}) {
195
- this.#service = new DpdAppService(options);
195
+ this.#service = options.service ?? new DpdAppService(options);
196
196
  }
197
197
  /**
198
198
  * `sessionWaitMs` bounds the wait for a session still opening; the whole `timeoutMs` by default.
@@ -1,3 +1,4 @@
1
+ import type { AdapterEnvironment } from '../../core/adapter/index.js';
1
2
  import { type XmlNode } from '../../core/transport/xml.js';
2
3
  export declare const DPD_DE_APP_API = "https://api.paketnavigator.de/services/v1/Navigator3Service.asmx";
3
4
  /** The lookup stopped waiting for the session, which keeps opening for the next one. */
@@ -28,8 +29,9 @@ export interface DpdParcelShop {
28
29
  }
29
30
  /**
30
31
  * 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.
32
+ * accepts for hours. One is kept per instance, and `sharedDpdAppService` gives the process one
33
+ * instance per transport. The opening runs on its own clock, so a lookup that stops waiting
34
+ * leaves it to the next. A session past its renewal age serves while the next one opens.
33
35
  */
34
36
  export declare class DpdAppService {
35
37
  #private;
@@ -49,11 +51,30 @@ export declare class DpdAppService {
49
51
  }): Promise<DpdParcelShop | undefined>;
50
52
  /** Forgets a session the service refused, unless another lookup already replaced it. */
51
53
  expire(session: string): void;
54
+ /**
55
+ * Keeps a session open from now on, for a long-lived host: one opens at once if there is none,
56
+ * 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.
58
+ */
59
+ keep(): void;
52
60
  /**
53
61
  * One session for every lookup. The opening runs while a lookup that asked for it is still
54
62
  * running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
63
+ * A session past its renewal age answers at once while the next one opens.
55
64
  */
56
65
  session(signal: AbortSignal, waitMs: number): Promise<string>;
57
66
  call(operation: string, fields: Fields, signal: AbortSignal, timeoutMs: number): Promise<XmlNode>;
58
67
  }
68
+ /**
69
+ * The process's service for a transport and user agent. DPD Germany and DPD Switzerland share it,
70
+ * and so do the adapters of every registry, so a session opens once for all of them.
71
+ */
72
+ export declare function sharedDpdAppService(options?: Pick<AdapterEnvironment, 'fetcher' | 'userAgent'>): DpdAppService;
73
+ /**
74
+ * Opens the session DPD Germany and DPD Switzerland read the German DPD app's service with, in the
75
+ * background, and keeps one open for the life of the process, so that no lookup waits the tens of
76
+ * 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.
78
+ */
79
+ export declare function warmDpdSession(environment?: Pick<AdapterEnvironment, 'fetcher' | 'userAgent'>): void;
59
80
  export {};
@@ -11,8 +11,15 @@ export const DPD_DE_SESSION_OPENING = 'session_opening';
11
11
  const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
12
12
  const SERVICE = 'https://cloud.dpd.com/';
13
13
  const MAX_BYTES = 2_000_000;
14
- /** Opening a session has taken up to 42 seconds. */
15
- const SESSION_OPEN_MS = 75_000;
14
+ /** Opening a session has taken up to 75 seconds. */
15
+ const SESSION_OPEN_MS = 120_000;
16
+ /**
17
+ * The service accepts a session for at least four hours, an idle one too. From this age the next
18
+ * one opens beside it, and replaces it once open.
19
+ */
20
+ const SESSION_RENEW_MS = 3 * 3_600_000;
21
+ /** A kept session that failed to open is tried again after this long. */
22
+ const SESSION_RETRY_MS = 15 * 60_000;
16
23
  // Shared partner credentials of the public app, distributed with the maintainer's approval.
17
24
  const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
18
25
  export function invalid() { throw new SchemaError('DPD Germany', 'DPD Germany returned invalid tracking XML'); }
@@ -61,8 +68,9 @@ function shopOf(result, id) {
61
68
  }
62
69
  /**
63
70
  * 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.
71
+ * accepts for hours. One is kept per instance, and `sharedDpdAppService` gives the process one
72
+ * instance per transport. The opening runs on its own clock, so a lookup that stops waiting
73
+ * leaves it to the next. A session past its renewal age serves while the next one opens.
66
74
  */
67
75
  export class DpdAppService {
68
76
  #partner;
@@ -70,8 +78,10 @@ export class DpdAppService {
70
78
  #userAgent;
71
79
  #now;
72
80
  #device = randomBytes(8).toString('hex');
73
- #session = '';
81
+ #session = null;
74
82
  #opening = null;
83
+ #kept = false;
84
+ #timer;
75
85
  constructor(options = {}) {
76
86
  this.#partner = options.partner ?? PARTNER;
77
87
  this.#fetcher = options.fetcher;
@@ -106,49 +116,85 @@ export class DpdAppService {
106
116
  }
107
117
  /** Forgets a session the service refused, unless another lookup already replaced it. */
108
118
  expire(session) {
109
- if (this.#session === session)
110
- this.#session = '';
119
+ if (this.#session?.token === session)
120
+ this.#session = null;
121
+ }
122
+ /**
123
+ * Keeps a session open from now on, for a long-lived host: one opens at once if there is none,
124
+ * 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.
126
+ */
127
+ keep() {
128
+ if (this.#kept)
129
+ return;
130
+ this.#kept = true;
131
+ this.#renew();
132
+ }
133
+ /** Opens the next session if the current one is due, or waits until it is. */
134
+ #renew() {
135
+ if (this.#opening)
136
+ return;
137
+ const age = this.#session ? this.#now() - this.#session.openedAt : Infinity;
138
+ if (age < SESSION_RENEW_MS)
139
+ this.#schedule(SESSION_RENEW_MS - age);
140
+ else
141
+ this.#opening = this.#open();
142
+ }
143
+ #schedule(ms) {
144
+ if (!this.#kept)
145
+ return;
146
+ clearTimeout(this.#timer);
147
+ this.#timer = setTimeout(() => this.#renew(), ms);
148
+ this.#timer.unref?.();
149
+ }
150
+ /** An opening, which a kept service finishes and otherwise ends when no lookup holds it. */
151
+ #open() {
152
+ const controller = new AbortController();
153
+ const holders = new Set();
154
+ const token = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
155
+ HardwareID: this.#device, BootSystemID: 'Android_Phone', Version: '15', AppVersion: '4.2.0',
156
+ } }, controller.signal, SESSION_OPEN_MS).then(result => {
157
+ const value = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
158
+ if (!/^[A-Za-z0-9+/=]{16,512}$/.test(value))
159
+ invalid();
160
+ this.#session = { token: value, openedAt: this.#now() };
161
+ return value;
162
+ });
163
+ const release = () => {
164
+ for (const holder of holders)
165
+ if (holder.aborted)
166
+ holders.delete(holder);
167
+ if (!holders.size && !this.#kept)
168
+ controller.abort(new Error('No lookup is waiting for the DPD Germany session'));
169
+ };
170
+ const opening = { token, holders, release };
171
+ void token.then(() => this.#schedule(SESSION_RENEW_MS), () => this.#schedule(SESSION_RETRY_MS)).finally(() => {
172
+ if (this.#opening === opening)
173
+ this.#opening = null;
174
+ for (const holder of holders)
175
+ holder.removeEventListener('abort', release);
176
+ });
177
+ return opening;
111
178
  }
112
179
  /**
113
180
  * One session for every lookup. The opening runs while a lookup that asked for it is still
114
181
  * running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
182
+ * A session past its renewal age answers at once while the next one opens.
115
183
  */
116
184
  async session(signal, waitMs) {
117
- if (this.#session)
118
- return this.#session;
185
+ const current = this.#session;
186
+ const due = !current || this.#now() - current.openedAt >= SESSION_RENEW_MS;
187
+ if (current && !due)
188
+ return current.token;
119
189
  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;
190
+ const opening = this.#opening ??= this.#open();
191
+ const { token, holders, release } = opening;
148
192
  if (!holders.has(signal)) {
149
193
  holders.add(signal);
150
194
  signal.addEventListener('abort', release, { once: true });
151
195
  }
196
+ if (current)
197
+ return current.token;
152
198
  let leave;
153
199
  let timer;
154
200
  const left = new Promise((_resolve, reject) => {
@@ -198,3 +244,29 @@ export class DpdAppService {
198
244
  return result;
199
245
  }
200
246
  }
247
+ const services = new WeakMap();
248
+ const GLOBAL_FETCH = {};
249
+ /**
250
+ * The process's service for a transport and user agent. DPD Germany and DPD Switzerland share it,
251
+ * and so do the adapters of every registry, so a session opens once for all of them.
252
+ */
253
+ export function sharedDpdAppService(options = {}) {
254
+ const userAgent = userAgentOf(options.userAgent);
255
+ const key = options.fetcher ?? GLOBAL_FETCH;
256
+ let byAgent = services.get(key);
257
+ if (!byAgent)
258
+ services.set(key, byAgent = new Map());
259
+ let service = byAgent.get(userAgent);
260
+ if (!service)
261
+ byAgent.set(userAgent, service = new DpdAppService({ fetcher: options.fetcher, userAgent }));
262
+ return service;
263
+ }
264
+ /**
265
+ * Opens the session DPD Germany and DPD Switzerland read the German DPD app's service with, in the
266
+ * background, and keeps one open for the life of the process, so that no lookup waits the tens of
267
+ * 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.
269
+ */
270
+ export function warmDpdSession(environment = {}) {
271
+ sharedDpdAppService(environment).keep();
272
+ }
package/dist/node.d.ts CHANGED
@@ -8,4 +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
12
  export { createTrackingServer, type TrackingServerOptions } from './server/index.js';
package/dist/node.js CHANGED
@@ -8,4 +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
12
  export { createTrackingServer } from './server/index.js';
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.49.2"
5
+ "version": "0.50.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.2-main.567",
3
+ "version": "0.50.0-main.568",
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",