universal-parcel-scraper 0.13.0-main.337 → 0.13.1

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.
@@ -2,6 +2,8 @@ import { type AdapterFactory, type AdapterEnvironment, type TrackingContext } fr
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
3
  export declare function normalizeNumber(raw: string): string;
4
4
  export declare function trackingUrl(raw: string): string;
5
+ /** Public DHL web tracking: bind both the waybill and the Express division. */
6
+ export declare function parseUnified(payload: unknown, raw: string): CarrierResult;
5
7
  /** A waybill can be reused. Multiple or duplicate shipments need recipient disambiguation. */
6
8
  export declare function parse(payload: unknown, raw: string): CarrierResult;
7
9
  export declare class DhlExpressTracker {
@@ -1,14 +1,19 @@
1
1
  import { DateTime } from 'luxon';
2
- import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { accepted, lookupBudget, recognizeFromBrowserLookup, recognizeFromLookup } from '../../core/adapter/index.js';
3
3
  import { isValidDhlExpressWaybill } from '../../core/detection/numericChecksums.js';
4
4
  import { BudgetExceededError, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
5
5
  import { runSteps } from '../../core/runner/index.js';
6
6
  import { classifyWording } from '../../core/status/index.js';
7
- import { clean, decodeText, fetchBounded, parseJsonBytes, TRAWL_TRANSPORT_ALLOWANCE_MS } from '../../core/transport/index.js';
7
+ import { explicitOffsetTime } from '../../core/time/index.js';
8
+ import { clean, decodeText, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
8
9
  import { isRecord } from '../../core/types.js';
9
10
  const PROVIDER = 'DHL Express';
10
11
  const API = 'https://mydhl.express.dhl/shipmentTracking';
12
+ const BROWSER_API = 'https://www.dhl.com/utapi';
11
13
  const MAX_BYTES = 1_000_000;
14
+ // The scoped browser runner bounds context cleanup to 1.5 seconds. Leave room
15
+ // for that and transport without consuming most of a short recognition budget.
16
+ const BROWSER_CLEANUP_ALLOWANCE_MS = 2_000;
12
17
  export function normalizeNumber(raw) {
13
18
  const number = raw.replace(/[\s.-]/g, '');
14
19
  if (!isValidDhlExpressWaybill(number)) {
@@ -46,6 +51,46 @@ function stage(description) {
46
51
  return { stage: 'customs', source: 'carrier_map' };
47
52
  return classifyWording(description, 'pending');
48
53
  }
54
+ /** Public DHL web tracking: bind both the waybill and the Express division. */
55
+ export function parseUnified(payload, raw) {
56
+ const number = normalizeNumber(raw);
57
+ if (!isRecord(payload) || !Array.isArray(payload.shipments) || payload.shipments.length > 20
58
+ || payload.shipments.some((shipment) => !isRecord(shipment)))
59
+ throw new SchemaError(PROVIDER, 'DHL returned invalid shipments');
60
+ const matching = payload.shipments.filter(isRecord).filter((shipment) => shipment.id === number);
61
+ if (!matching.length)
62
+ throw new SchemaError(PROVIDER, 'DHL returned a different waybill');
63
+ if (matching.length !== 1)
64
+ throw new IndeterminateError(PROVIDER, 'DHL returned a reused waybill');
65
+ const shipment = matching[0];
66
+ if (shipment.service !== 'express')
67
+ throw new IndeterminateError(PROVIDER, 'DHL returned a different division');
68
+ if (!Array.isArray(shipment.events) || shipment.events.length > 1000)
69
+ throw new SchemaError(PROVIDER, 'DHL returned invalid events');
70
+ if (!shipment.events.length)
71
+ throw new IndeterminateError(PROVIDER, 'DHL has no shipment activity');
72
+ const event = (row) => {
73
+ if (!isRecord(row) || typeof row.description !== 'string' || !row.description.trim())
74
+ throw new SchemaError(PROVIDER, 'DHL returned an invalid event');
75
+ const description = row.statusCode === 'delivered' ? 'Delivered' : clean(row.description).slice(0, 1000);
76
+ const mapped = stage(description);
77
+ const time = typeof row.timestamp === 'string' && /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/i.test(row.timestamp)
78
+ ? explicitOffsetTime(row.timestamp)?.iso : undefined;
79
+ const address = isRecord(row.location) && isRecord(row.location.address) ? row.location.address : {};
80
+ return { description, stage: mapped.stage, stage_source: mapped.source,
81
+ ...(time ? { time } : {}), ...(typeof address.addressLocality === 'string' ? { location: clean(address.addressLocality).slice(0, 300) } : {}),
82
+ ...(typeof row.status === 'string' ? { provider_code: row.status.slice(0, 100) } : {}) };
83
+ };
84
+ const events = shipment.events.map(event);
85
+ const current = event(shipment.status);
86
+ const status = current.stage === 'delivered' ? 'delivered' : current.stage === 'out_for_delivery' ? 'out_for_delivery'
87
+ : ['exception', 'failed_attempt', 'returned'].includes(current.stage) ? 'exception'
88
+ : ['pending', 'registered'].includes(current.stage) ? 'pending' : 'in_transit';
89
+ const destination = isRecord(shipment.destination) && isRecord(shipment.destination.address) ? shipment.destination.address : {};
90
+ return { status, current_stage: current.stage, current_stage_source: current.stage_source,
91
+ last_status_text: current.description, last_update: current.time ?? null, events,
92
+ ...(typeof destination.countryCode === 'string' && /^[A-Z]{2}$/.test(destination.countryCode) ? { destination_country: destination.countryCode } : {}) };
93
+ }
49
94
  /** A waybill can be reused. Multiple or duplicate shipments need recipient disambiguation. */
50
95
  export function parse(payload, raw) {
51
96
  const number = normalizeNumber(raw);
@@ -104,36 +149,48 @@ export class DhlExpressTracker {
104
149
  const budget = lookupBudget(context, 45_000, PROVIDER);
105
150
  if (!this.environment.trawl)
106
151
  throw new ChallengeError(PROVIDER, 'DHL Express requires the browser tracking service');
107
- const timeoutMs = budget.remainingMs() - TRAWL_TRANSPORT_ALLOWANCE_MS;
152
+ const timeoutMs = budget.remainingMs() - BROWSER_CLEANUP_ALLOWANCE_MS;
108
153
  if (timeoutMs < 1)
109
154
  throw new BudgetExceededError(PROVIDER, budget.budgetMs);
110
- const page = await this.environment.trawl.scrape({ url: trackingUrl(number), skipHttp: true, maxTier: 3,
111
- maxTimeout: timeoutMs, captureResponses: [API], settleTimeout: Math.min(timeoutMs, 10_000),
155
+ const page = await this.environment.trawl.scrape({ url: `https://www.dhl.com/global-en/home/tracking/tracking-parcel.html?submit=1&tracking-id=${normalizeNumber(number)}`, skipHttp: true, maxTier: 3,
156
+ maxTimeout: timeoutMs, captureResponses: [BROWSER_API], settleTimeout: timeoutMs,
112
157
  }, { provider: PROVIDER, timeoutMs, signal: budget.signal, maxBytes: 6_000_000, requireSolved: false });
113
158
  budget.signal.throwIfAborted();
114
159
  const captures = page.capturedResponses.filter((r) => {
115
160
  try {
116
161
  const url = new URL(r.url);
117
- return url.origin + url.pathname === API && !url.username && !url.password
118
- && url.searchParams.getAll('AWB').length === 1 && url.searchParams.get('AWB') === number && r.status !== 204;
162
+ return url.origin + url.pathname === BROWSER_API && !url.username && !url.password
163
+ && url.searchParams.getAll('trackingNumber').length === 1 && url.searchParams.get('trackingNumber') === number && r.status !== 204;
119
164
  }
120
165
  catch {
121
166
  return false;
122
167
  }
123
168
  });
124
169
  const response = captures.at(-1);
125
- if (!response || captures.length > 10 || response.error || response.truncated || response.base64Encoded || response.body === null) {
170
+ if (!response || captures.length > 10 || response.error || response.truncated || response.base64Encoded) {
126
171
  throw new TransportError(PROVIDER, 'DHL Express returned no complete matching browser response');
127
172
  }
128
173
  if (response.status === 429)
129
174
  throw new RateLimitedError(PROVIDER);
130
- if ([401, 403].includes(response.status) || /^\s*</.test(response.body))
175
+ if ([401, 403, 428].includes(response.status) || /^\s*</.test(response.body ?? ''))
131
176
  throw new ChallengeError(PROVIDER);
177
+ if (response.status === 404 && response.body) {
178
+ let missing;
179
+ try {
180
+ missing = JSON.parse(response.body);
181
+ }
182
+ catch { /* An arbitrary 404 is not a shipment answer. */ }
183
+ if (isRecord(missing) && missing.status === 404 && missing.title === 'No result found'
184
+ && missing.detail === 'No shipment with given tracking number found.')
185
+ throw new NotFoundError(PROVIDER);
186
+ }
132
187
  if (response.status >= 400)
133
188
  throw new UpstreamHttpError(PROVIDER, response.status);
189
+ if (response.body === null)
190
+ throw new TransportError(PROVIDER, 'DHL Express returned no complete matching browser response');
134
191
  if (response.status !== 200 || Buffer.byteLength(response.body) > MAX_BYTES)
135
192
  throw new SchemaError(PROVIDER, 'DHL Express returned invalid browser data');
136
- return this.decode(new TextEncoder().encode(response.body), number);
193
+ return parseUnified(parseJsonBytes(new TextEncoder().encode(response.body), PROVIDER), number);
137
194
  }
138
195
  decode(bytes, number) {
139
196
  let payload;
@@ -160,15 +217,6 @@ export const adapter = (environment) => {
160
217
  recognizeWithBrowser: async (number, context, previousError) => {
161
218
  if (previousError instanceof RateLimitedError || previousError instanceof SchemaError || previousError instanceof NotFoundError)
162
219
  throw previousError;
163
- try {
164
- const result = await tracker.browser(normalizeNumber(number), context);
165
- // Local facility clocks establish dated activity, but never an instant for ranking reuse.
166
- return result.events?.some((event) => event.time) ? { known: true, lastActivityAt: null, result } : { known: false };
167
- }
168
- catch (error) {
169
- if (error instanceof NotFoundError)
170
- return { known: false };
171
- throw error;
172
- }
220
+ return recognizeFromBrowserLookup(() => tracker.browser(normalizeNumber(number), context));
173
221
  } };
174
222
  };
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.13.0"
5
+ "version": "0.13.1"
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.13.0-main.337",
3
+ "version": "0.13.1",
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",