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 {
|
|
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() -
|
|
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:
|
|
111
|
-
maxTimeout: timeoutMs, captureResponses: [
|
|
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 ===
|
|
118
|
-
&& url.searchParams.getAll('
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED