universal-parcel-scraper 0.97.0-main.997 → 0.99.0-main.1001

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/README.md CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **156 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **157 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>208 catalog entries · 66 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 4,357 such statuses, each filed under one stage.
158
+ The carrier folders record 4,426 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -1983,7 +1983,11 @@
1983
1983
  "tracking": {
1984
1984
  "mode": "automatic",
1985
1985
  "adapter": "royal-mail",
1986
- "browserRecognitionRank": 900
1986
+ "browserRecognitionRank": 900,
1987
+ "refresh": {
1988
+ "minMinutes": 60
1989
+ },
1990
+ "localClocks": true
1987
1991
  },
1988
1992
  "canaryUrl": "https://www.royalmail.com/track-your-item",
1989
1993
  "trackingUrlTemplate": "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
@@ -8360,7 +8364,9 @@
8360
8364
  "color": "#fd4f00",
8361
8365
  "aliases": [
8362
8366
  "Whistl UK",
8363
- "TNT Post UK"
8367
+ "TNT Post UK",
8368
+ "Parcelhub",
8369
+ "Whistl Despatch"
8364
8370
  ],
8365
8371
  "countries": [
8366
8372
  "GB"
@@ -8369,7 +8375,9 @@
8369
8375
  "timezone": "Europe/London",
8370
8376
  "tracking": {
8371
8377
  "mode": "automatic",
8372
- "adapter": "universal"
8378
+ "adapter": "whistl",
8379
+ "recognitionRank": 880,
8380
+ "localClocks": true
8373
8381
  },
8374
8382
  "canaryUrl": "https://web.parcelhub.net/Tracking",
8375
8383
  "trackingUrlTemplate": "https://web.parcelhub.net/Tracking/reference/{trackingNumber}",
@@ -8377,9 +8385,10 @@
8377
8385
  "linkRules": [
8378
8386
  {
8379
8387
  "domains": [
8380
- "web.parcelhub.net"
8388
+ "web.parcelhub.net",
8389
+ "despatch.whistl.co.uk"
8381
8390
  ],
8382
- "path": "^/Tracking/reference/([^/?#]+)/?$"
8391
+ "path": "^/Tracking/reference/([^/?#]+)(?:/postcode/[^/?#]*)?/?$"
8383
8392
  },
8384
8393
  {
8385
8394
  "domains": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 208,
3
- "activeAdapters": 156,
3
+ "activeAdapters": 157,
4
4
  "countries": 66,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -1724,7 +1724,9 @@ var be = {
1724
1724
  tracking: {
1725
1725
  mode: "automatic",
1726
1726
  adapter: "royal-mail",
1727
- browserRecognitionRank: 900
1727
+ browserRecognitionRank: 900,
1728
+ refresh: { minMinutes: 60 },
1729
+ localClocks: !0
1728
1730
  },
1729
1731
  canaryUrl: "https://www.royalmail.com/track-your-item",
1730
1732
  trackingUrlTemplate: "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
@@ -6599,20 +6601,27 @@ var be = {
6599
6601
  whistl: {
6600
6602
  displayName: "Whistl",
6601
6603
  color: "#fd4f00",
6602
- aliases: ["Whistl UK", "TNT Post UK"],
6604
+ aliases: [
6605
+ "Whistl UK",
6606
+ "TNT Post UK",
6607
+ "Parcelhub",
6608
+ "Whistl Despatch"
6609
+ ],
6603
6610
  countries: ["GB"],
6604
6611
  selectable: !0,
6605
6612
  timezone: "Europe/London",
6606
6613
  tracking: {
6607
6614
  mode: "automatic",
6608
- adapter: "universal"
6615
+ adapter: "whistl",
6616
+ recognitionRank: 880,
6617
+ localClocks: !0
6609
6618
  },
6610
6619
  canaryUrl: "https://web.parcelhub.net/Tracking",
6611
6620
  trackingUrlTemplate: "https://web.parcelhub.net/Tracking/reference/{trackingNumber}",
6612
6621
  trackingSearchUrl: "https://web.parcelhub.net/Tracking",
6613
6622
  linkRules: [{
6614
- domains: ["web.parcelhub.net"],
6615
- path: "^/Tracking/reference/([^/?#]+)/?$"
6623
+ domains: ["web.parcelhub.net", "despatch.whistl.co.uk"],
6624
+ path: "^/Tracking/reference/([^/?#]+)(?:/postcode/[^/?#]*)?/?$"
6616
6625
  }, {
6617
6626
  domains: ["trackmyitem.whistl.co.uk"],
6618
6627
  path: "^/tracking/([^/?#]+)/?$"
@@ -12010,6 +12019,7 @@ var bc = {
12010
12019
  uniuni: 330,
12011
12020
  venipak: 630,
12012
12021
  "vinted-go": 720,
12022
+ whistl: 880,
12013
12023
  "yurtici-kargo": 660
12014
12024
  }, xc = 5;
12015
12025
  function Sc(e) {
@@ -20,7 +20,11 @@
20
20
  ],
21
21
  "browserRecognition": {
22
22
  "rank": 900
23
- }
23
+ },
24
+ "refresh": {
25
+ "minMinutes": 60
26
+ },
27
+ "localClocks": true
24
28
  },
25
29
  "capabilities": [
26
30
  "history",
@@ -0,0 +1,2 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,5 @@
1
+ // Scans abroad kept the UK offset Royal Mail labels them with until October
2
+ // 2026 and now show their country's, unless the country keeps several clocks.
3
+ export const sameInstantIdentityPolicy = {
4
+ sourceCarrierId: 'royal-mail', storedSources: ['royal-mail'], requireProviderCode: true, relabelledFrom: 'Europe/London',
5
+ };
@@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
2
2
  import { load } from 'cheerio';
3
3
  import { ChallengeError, IndeterminateError, InvalidInputError, RateLimitedError, SchemaError } from '../../core/errors/index.js';
4
4
  import { clean, cleanScalar } from '../../core/transport/index.js';
5
- import { explicitOffsetTime } from '../../core/time/index.js';
5
+ import { countryCode, countryTimeZone, explicitOffsetTime, isoTime } from '../../core/time/index.js';
6
6
  import { isRecord } from '../../core/types.js';
7
7
  import { isRoyalMailDomesticReference } from '../../core/detection/royalMail.js';
8
8
  import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
@@ -41,6 +41,22 @@ function eventTime(value) {
41
41
  return null;
42
42
  return explicitOffsetTime(raw)?.iso ?? null;
43
43
  }
44
+ /**
45
+ * A scan abroad names its country after the place, and Royal Mail labels that
46
+ * office's wall clock with the UK's offset. The clock is read in the country's
47
+ * zone, or kept as a wall clock when the country has several; null at home.
48
+ */
49
+ function abroadClock(raw, location) {
50
+ const country = countryCode(/,\s*([^,]+)$/.exec(location)?.[1]);
51
+ if (!country || country === 'GB' || !eventTime(raw))
52
+ return null;
53
+ const wall = DateTime.fromISO(raw, { setZone: true }).toISO({ includeOffset: false, suppressMilliseconds: true });
54
+ if (!wall)
55
+ return null;
56
+ const zone = countryTimeZone(country);
57
+ const placed = zone ? isoTime(wall, zone) : null;
58
+ return placed ? { time: placed.iso } : { local_time: wall };
59
+ }
44
60
  // Royal Mail appends a Post Office branch's postcode in brackets, and an
45
61
  // overseas partner can report a delivery office by postcode alone.
46
62
  const POSTCODE = /^(?:[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}|\d{3,10}(?:-\d{3,4})?)$/i;
@@ -78,13 +94,14 @@ export function parseRoyalMailTrackingResponse(payload, trackingNumber) {
78
94
  throw new SchemaError('Royal Mail', 'Royal Mail did not return the requested parcel');
79
95
  }
80
96
  const summary = mailpiece.summary;
81
- const summaryText = cleanScalar(summary.statusDescription);
97
+ const summaryText = cleanScalar(summary.statusDescription).replace(/\*\*/g, '').trim();
82
98
  if (mailpiece.events !== undefined && !Array.isArray(mailpiece.events)) {
83
99
  throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
84
100
  }
85
101
  const scans = Array.isArray(mailpiece.events) ? mailpiece.events : [];
86
102
  const events = [];
87
103
  const seen = new Set();
104
+ const abroad = new Map();
88
105
  for (const raw of scans.slice(0, MAX_EVENTS_TO_INSPECT)) {
89
106
  if (!isRecord(raw))
90
107
  throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
@@ -96,12 +113,15 @@ export function parseRoyalMailTrackingResponse(payload, trackingNumber) {
96
113
  const code = cleanScalar(raw.eventCode, 64);
97
114
  const mapped = royalMailEventStage(code);
98
115
  const stage = mapped ?? royalMailStage(description) ?? undefined;
99
- const time = eventTime(raw.eventDateTime);
100
116
  const timeText = cleanScalar(raw.eventDateTime, 64);
101
117
  const location = placeName(raw.locationName);
118
+ const clock = abroadClock(timeText, location);
119
+ if (clock && !abroad.has(timeText))
120
+ abroad.set(timeText, clock);
121
+ const time = clock ? ('time' in clock ? clock.time : null) : eventTime(raw.eventDateTime);
102
122
  const event = {
103
- ...(time ? { time } : {}),
104
- ...(!time && timeText ? { provider_time_text: timeText } : {}),
123
+ ...(clock ?? (time ? { time } : {})),
124
+ ...(!time && !clock && timeText ? { provider_time_text: timeText } : {}),
105
125
  ...(location ? { location } : {}),
106
126
  description: stage === 'delivered' ? 'Delivered' : description,
107
127
  ...(stage ? { stage } : {}),
@@ -127,15 +147,19 @@ export function parseRoyalMailTrackingResponse(payload, trackingNumber) {
127
147
  throw new SchemaError('Royal Mail', 'Royal Mail returned no usable tracking status');
128
148
  const stage = royalMailSummaryStage(summaryCategory, statusText) ?? undefined;
129
149
  const delivered = stage === 'delivered';
150
+ // The summary repeats the newest scan's label, which a scan abroad corrects.
151
+ const lastText = cleanScalar(summary.lastEventDateTime, 64);
152
+ const lastClock = abroad.get(lastText);
153
+ const lastTime = lastClock ? ('time' in lastClock ? lastClock.time : null) : eventTime(lastText);
130
154
  const deliveredAt = !delivered ? null : trimmed.find(event => event.stage === 'delivered' && event.time)?.time
131
- ?? (royalMailEventStage(cleanScalar(summary.lastEventCode, 64)) === 'delivered' ? eventTime(summary.lastEventDateTime) : null);
155
+ ?? (royalMailEventStage(cleanScalar(summary.lastEventCode, 64)) === 'delivered' ? lastTime : null);
132
156
  // Only the full-history reply names the destination.
133
157
  const destination = cleanScalar(summary.destinationCountryCode, 8);
134
158
  return {
135
159
  status: stage ? statusForStage(stage) : 'unknown',
136
160
  ...(stage ? { current_stage: stage } : {}),
137
161
  last_status_text: delivered ? 'Delivered' : statusText,
138
- last_update: eventTime(summary.lastEventDateTime) || trimmed[0]?.time || null,
162
+ last_update: lastClock ? lastTime : lastTime || trimmed[0]?.time || null,
139
163
  expected_delivery: delivered || !isRecord(mailpiece.estimatedDelivery)
140
164
  ? null : expectedDelivery(mailpiece.estimatedDelivery.date),
141
165
  ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
@@ -28,6 +28,8 @@ const EVENT_STAGES = new Map([
28
28
  ['EVHOE', 'in_transit'],
29
29
  ['EVIIS', 'in_transit'],
30
30
  ['EVBAH', 'in_transit'],
31
+ ['EVJOE', 'in_transit'],
32
+ ['EVJDU', 'in_transit'],
31
33
  ['EVCAD', 'accepted'],
32
34
  ['EVPPA', 'accepted'],
33
35
  ['EVAIP', 'registered'],
@@ -257,6 +257,24 @@
257
257
  "confirmedBy": "live",
258
258
  "firstSeen": "2026-10-08",
259
259
  "note": "Same notice as EVAIP."
260
+ },
261
+ {
262
+ "code": "EVJOE",
263
+ "wording": "Item Received",
264
+ "language": "en",
265
+ "stage": "in_transit",
266
+ "confirmedBy": "live",
267
+ "firstSeen": "2026-10-12",
268
+ "note": "Arrival at the destination country's office of exchange; the shared wording alone would read as acceptance."
269
+ },
270
+ {
271
+ "code": "EVJDU",
272
+ "wording": "Item has left the overseas International Processing Centre",
273
+ "language": "en",
274
+ "stage": "in_transit",
275
+ "confirmedBy": "live",
276
+ "firstSeen": "2026-10-12",
277
+ "note": "Departure from the destination country's office of exchange."
260
278
  }
261
279
  ]
262
280
  }
@@ -0,0 +1,14 @@
1
+ import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ export declare const WHISTL_ENDPOINT = "https://web.parcelhub.net/Tracking/reference/";
5
+ export declare class WhistlTracker {
6
+ private readonly options;
7
+ constructor(options?: {
8
+ fetcher?: typeof fetch;
9
+ recorder?: StepRecorder;
10
+ userAgent?: string;
11
+ });
12
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
13
+ }
14
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,54 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { IndeterminateError, TransportError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
7
+ import { normalizeWhistlNumber, parseWhistl, PROVIDER, WHISTL_MAX_BYTES } from './parser.js';
8
+ // Whistl's parcel tracker on its Parcelhub platform, which Whistl's site links
9
+ // and whose form opens a reference at this path. No postcode is sent: the
10
+ // tracker takes it only to narrow the search.
11
+ export const WHISTL_ENDPOINT = 'https://web.parcelhub.net/Tracking/reference/';
12
+ const REDIRECTS = [301, 302, 303, 307, 308];
13
+ // A missing route is not a missing parcel: the tracker answers unknown references with a heading.
14
+ const GONE = [404, 410];
15
+ export class WhistlTracker {
16
+ options;
17
+ constructor(options = {}) {
18
+ this.options = options;
19
+ }
20
+ async fetch(raw, context = {}) {
21
+ const number = normalizeWhistlNumber(raw);
22
+ const url = `${WHISTL_ENDPOINT}${encodeURIComponent(number)}`;
23
+ const read = async (signal, timeoutMs) => {
24
+ const { response, bytes } = await fetchBounded(url, { method: 'GET', signal, headers: {
25
+ Accept: 'text/html,application/xhtml+xml',
26
+ 'User-Agent': userAgentOf(this.options.userAgent),
27
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: WHISTL_MAX_BYTES,
28
+ redirect: 'manual', allowHttpStatuses: [...REDIRECTS, ...GONE], fetcher: this.options.fetcher });
29
+ if (REDIRECTS.includes(response.status))
30
+ throw new IndeterminateError(PROVIDER, 'Whistl sent the lookup to another page');
31
+ if (GONE.includes(response.status))
32
+ throw new TransportError(PROVIDER, 'Whistl tracking page is unavailable');
33
+ const html = decodeText(bytes);
34
+ if (!html.trim())
35
+ throw new IndeterminateError(PROVIDER, 'Whistl returned an empty page');
36
+ return parseWhistl(html, number);
37
+ };
38
+ // A request that fails to reach Whistl, or hangs, gets one more try: the
39
+ // first gets half of the budget, so the retry fits in the rest.
40
+ return runSteps({ carrier: 'whistl', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
41
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
42
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
43
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
44
+ ]);
45
+ }
46
+ }
47
+ export const adapter = (environment) => {
48
+ const tracker = new WhistlTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
49
+ return {
50
+ id: 'whistl', recordsSteps: true, steps: ['direct', 'retry'],
51
+ track: (input, context) => tracker.fetch(input.number, context),
52
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeWhistlNumber(number))),
53
+ };
54
+ };
@@ -3,7 +3,9 @@
3
3
  "displayName": "Whistl",
4
4
  "aliases": [
5
5
  "Whistl UK",
6
- "TNT Post UK"
6
+ "TNT Post UK",
7
+ "Parcelhub",
8
+ "Whistl Despatch"
7
9
  ],
8
10
  "region": {
9
11
  "countries": [
@@ -17,25 +19,66 @@
17
19
  "timezone": "Europe/London",
18
20
  "tracking": {
19
21
  "mode": "automatic",
20
- "adapter": "universal",
21
- "steps": []
22
+ "adapter": "whistl",
23
+ "steps": [
24
+ "direct",
25
+ "retry"
26
+ ],
27
+ "recognition": {
28
+ "rank": 880
29
+ },
30
+ "localClocks": true
22
31
  },
23
- "capabilities": [],
32
+ "capabilities": [
33
+ "history",
34
+ "location",
35
+ "service_name",
36
+ "delivery_partner",
37
+ "delivery_tracking_number",
38
+ "canonical_tracking_number"
39
+ ],
24
40
  "portal": {
25
41
  "url": "https://web.parcelhub.net/Tracking/reference/{trackingNumber}",
26
42
  "searchUrl": "https://web.parcelhub.net/Tracking",
27
43
  "canaryUrl": "https://web.parcelhub.net/Tracking",
28
- "shows": [],
29
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
44
+ "shows": [
45
+ "status",
46
+ "history",
47
+ "location",
48
+ "service_name",
49
+ "delivery_partner",
50
+ "delivery_tracking_number",
51
+ "sender_reference",
52
+ "recipient_reference"
53
+ ],
54
+ "scraped": [
55
+ "status",
56
+ "history",
57
+ "location",
58
+ "service_name",
59
+ "delivery_partner",
60
+ "delivery_tracking_number"
61
+ ],
62
+ "discarded": [
63
+ "sender_reference",
64
+ "recipient_reference",
65
+ "signatory"
66
+ ],
67
+ "unavailable": [
68
+ "eta",
69
+ "weight",
70
+ "sender_name",
71
+ "recipient_name",
72
+ "offset_timestamps"
73
+ ]
32
74
  },
33
75
  "links": [
34
76
  {
35
77
  "domains": [
36
- "web.parcelhub.net"
78
+ "web.parcelhub.net",
79
+ "despatch.whistl.co.uk"
37
80
  ],
38
- "path": "^/Tracking/reference/([^/?#]+)/?$"
81
+ "path": "^/Tracking/reference/([^/?#]+)(?:/postcode/[^/?#]*)?/?$"
39
82
  },
40
83
  {
41
84
  "domains": [
@@ -12,13 +12,13 @@
12
12
  "access": "full_page"
13
13
  },
14
14
  "context": {
15
- "assessment": "candidate"
15
+ "assessment": "confirmed"
16
16
  },
17
17
  "expect": {
18
18
  "carrier": "unknown",
19
19
  "confidence": "none"
20
20
  },
21
- "note": "A review on Whistl's Trustpilot page gives it as the tracking number."
21
+ "note": "A review on Whistl's Trustpilot page gives it as the tracking number. Whistl's Parcelhub tracker knows it as a shipment reference whose one parcel another carrier delivers under its own number."
22
22
  },
23
23
  {
24
24
  "number": "L1213411000055365001",
@@ -0,0 +1,14 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Whistl";
3
+ export declare const WHISTL_MAX_BYTES = 1000000;
4
+ /** A reference as Whistl's tracker takes it: letters and digits, at least one digit. */
5
+ export declare function normalizeWhistlNumber(raw: string): string;
6
+ /**
7
+ * Whistl's tracker page for one shipment reference or tracking number: the
8
+ * shipment's reference as its heading, the carrier and service, and for each
9
+ * parcel its tracking number and scans, newest first. The tracker finds a
10
+ * shipment by its reference or by a parcel's tracking number, so the page
11
+ * must name the requested number as one of them. Sender's and recipient's
12
+ * references, signatories and comments are never read.
13
+ */
14
+ export declare function parseWhistl(html: string, rawNumber: string): CarrierResult;