universal-parcel-scraper 0.40.0-main.395 → 0.41.0-main.397

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 **103 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **104 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>124 catalog entries · 58 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -149,7 +149,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
149
149
  <!-- GENERATED:stages -->
150
150
  <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">
151
151
 
152
- The carrier folders record 2,204 such statuses, each filed under one stage.
152
+ The carrier folders record 2,211 such statuses, each filed under one stage.
153
153
  <!-- /GENERATED:stages -->
154
154
 
155
155
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -949,15 +949,14 @@
949
949
  "requirements": [
950
950
  {
951
951
  "field": "postcode",
952
- "whenTrackingNumber": "^(?![0-9]{26}$).*$",
953
- "validator": "francePostcode",
952
+ "whenTrackingNumber": "^[0-9]{8}$",
953
+ "validator": "internationalPostcode",
954
954
  "label": "Delivery postcode",
955
955
  "type": "text",
956
- "placeholder": "75001",
957
- "help": "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.",
958
- "pattern": "^[0-9]{5}$",
959
- "maxLength": 5,
960
- "inputMode": "numeric",
956
+ "help": "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.",
957
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
958
+ "maxLength": 12,
959
+ "inputMode": "text",
961
960
  "autoComplete": "postal-code"
962
961
  }
963
962
  ]
@@ -1109,8 +1108,9 @@
1109
1108
  "timezone": "Europe/Paris",
1110
1109
  "tracking": {
1111
1110
  "mode": "automatic",
1112
- "adapter": "la-poste",
1113
- "recognitionRank": 45
1111
+ "adapter": "chronopost",
1112
+ "recognitionRank": 45,
1113
+ "localClocks": true
1114
1114
  },
1115
1115
  "canaryUrl": "https://www.chronopost.fr/tracking-no-cms/suivi-page",
1116
1116
  "trackingUrlTemplate": "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}",
@@ -1644,14 +1644,13 @@
1644
1644
  "requirements": [
1645
1645
  {
1646
1646
  "field": "postcode",
1647
- "validator": "swissOrFrancePostcode",
1647
+ "validator": "internationalPostcode",
1648
1648
  "label": "Delivery postcode",
1649
1649
  "type": "text",
1650
- "placeholder": "10115",
1651
- "help": "GLS requires the four- or five-digit delivery postcode to show the detailed event history.",
1652
- "pattern": "^[0-9]{4,5}$",
1653
- "maxLength": 5,
1654
- "inputMode": "numeric",
1650
+ "help": "GLS requires the delivery postcode to show the detailed event history.",
1651
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
1652
+ "maxLength": 12,
1653
+ "inputMode": "text",
1655
1654
  "autoComplete": "postal-code"
1656
1655
  }
1657
1656
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 124,
3
- "activeAdapters": 103,
3
+ "activeAdapters": 104,
4
4
  "countries": 58,
5
5
  "referenceCarriers": 113,
6
6
  "comparison": {
@@ -852,15 +852,14 @@ var ve = {
852
852
  adapter: "mondial-relay",
853
853
  requirements: [{
854
854
  field: "postcode",
855
- whenTrackingNumber: "^(?![0-9]{26}$).*$",
856
- validator: "francePostcode",
855
+ whenTrackingNumber: "^[0-9]{8}$",
856
+ validator: "internationalPostcode",
857
857
  label: "Delivery postcode",
858
858
  type: "text",
859
- placeholder: "75001",
860
- help: "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.",
861
- pattern: "^[0-9]{5}$",
862
- maxLength: 5,
863
- inputMode: "numeric",
859
+ help: "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.",
860
+ pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
861
+ maxLength: 12,
862
+ inputMode: "text",
864
863
  autoComplete: "postal-code"
865
864
  }]
866
865
  },
@@ -984,8 +983,9 @@ var ve = {
984
983
  timezone: "Europe/Paris",
985
984
  tracking: {
986
985
  mode: "automatic",
987
- adapter: "la-poste",
988
- recognitionRank: 45
986
+ adapter: "chronopost",
987
+ recognitionRank: 45,
988
+ localClocks: !0
989
989
  },
990
990
  canaryUrl: "https://www.chronopost.fr/tracking-no-cms/suivi-page",
991
991
  trackingUrlTemplate: "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}",
@@ -1390,14 +1390,13 @@ var ve = {
1390
1390
  adapter: "gls-de",
1391
1391
  requirements: [{
1392
1392
  field: "postcode",
1393
- validator: "swissOrFrancePostcode",
1393
+ validator: "internationalPostcode",
1394
1394
  label: "Delivery postcode",
1395
1395
  type: "text",
1396
- placeholder: "10115",
1397
- help: "GLS requires the four- or five-digit delivery postcode to show the detailed event history.",
1398
- pattern: "^[0-9]{4,5}$",
1399
- maxLength: 5,
1400
- inputMode: "numeric",
1396
+ help: "GLS requires the delivery postcode to show the detailed event history.",
1397
+ pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
1398
+ maxLength: 12,
1399
+ inputMode: "text",
1401
1400
  autoComplete: "postal-code"
1402
1401
  }],
1403
1402
  recognitionRank: 50,
@@ -8939,17 +8938,23 @@ function Ks(e, t) {
8939
8938
  return r.protocol = "https:", r.hostname = Bs, r.port = "", r.toString();
8940
8939
  }
8941
8940
  //#endregion
8941
+ //#region core/catalog/postcode.ts
8942
+ var qs = /^(?=.{3,12}$)(?=.*\d)[A-Z0-9]+(?:[ -][A-Z0-9]+)*$/;
8943
+ function Js(e) {
8944
+ return e.trim().toUpperCase().replace(/\s+/g, " ");
8945
+ }
8946
+ //#endregion
8942
8947
  //#region core/catalog/inputs.ts
8943
- function qs(e, t, n, r) {
8948
+ function Ys(e, t, n, r) {
8944
8949
  let i = {
8945
8950
  trackingUrl: n.trim() || null,
8946
8951
  postcode: r.trim() || null
8947
8952
  }, a = e === "mondial-relay" && /^\d{26}$/.test(t);
8948
8953
  if (a && !he(t)) throw TypeError("Invalid Mondial Relay barcode");
8949
8954
  let o = new Map(je(e, t).map((e) => [e.field, e]));
8950
- a && i.postcode && o.set("postcode", {
8955
+ (a || e === "mondial-relay" && /^(?:\d{10}|\d{12})$/.test(t)) && i.postcode && o.set("postcode", {
8951
8956
  field: "postcode",
8952
- validator: "francePostcode"
8957
+ validator: "internationalPostcode"
8953
8958
  });
8954
8959
  for (let [e, t] of Object.entries(i)) if (t !== null && !o.has(e)) throw TypeError(e === "trackingUrl" ? "A tracking URL is not used for this carrier or tracking number" : "A delivery postcode is not used for this carrier");
8955
8960
  for (let [n, r] of o) {
@@ -8976,8 +8981,8 @@ function qs(e, t, n, r) {
8976
8981
  if (!/^\d{4,5}$/.test(a)) throw TypeError(`${y(e).displayName} requires a four- or five-digit delivery postcode`);
8977
8982
  break;
8978
8983
  case "internationalPostcode":
8979
- if (!Js.test(Ys(a))) throw TypeError(`${y(e).displayName} requires a valid delivery postcode`);
8980
- i[n] = Ys(a);
8984
+ if (!qs.test(Js(a))) throw TypeError(`${y(e).displayName} requires a valid delivery postcode`);
8985
+ i[n] = Js(a);
8981
8986
  break;
8982
8987
  case "paackPostcode": {
8983
8988
  let e = a.toLocaleUpperCase("en-US");
@@ -8990,13 +8995,9 @@ function qs(e, t, n, r) {
8990
8995
  }
8991
8996
  return i;
8992
8997
  }
8993
- var Js = /^(?=.{3,12}$)(?=.*\d)[A-Z0-9]+(?:[ -][A-Z0-9]+)*$/;
8994
- function Ys(e) {
8995
- return e.trim().toUpperCase().replace(/\s+/g, " ");
8996
- }
8997
8998
  function Xs(e) {
8998
- let t = Ys(e);
8999
- if (!Js.test(t)) throw TypeError("A valid delivery postcode is required");
8999
+ let t = Js(e);
9000
+ if (!qs.test(t)) throw TypeError("A valid delivery postcode is required");
9000
9001
  return t;
9001
9002
  }
9002
9003
  var Zs = { carriers: [
@@ -10778,4 +10779,4 @@ function pc(e = !1, t) {
10778
10779
  }).sources;
10779
10780
  }
10780
10781
  //#endregion
10781
- export { Se as AMAZON_NUMBER_PATTERN, Ne as AUTOMATIC_CARRIER_IDS, oc as BROWSER_SOURCES, f as BudgetExceededError, Ve as CARRIERS, ve as CARRIER_CATALOG, v as CARRIER_DEFINITIONS, ye as CARRIER_IDS, Pe as CARRIER_NAMES, t as CarrierError, a as ChallengeError, No as EXPLICIT_OFFSET_PATTERN, r as IndeterminateError, l as InputRequiredError, u as InvalidInputError, ys as MAX_RECOGNITIONS, s as MaintenanceError, i as NoHistoryError, n as NotFoundError, o as RateLimitedError, Ue as SELECTABLE_CARRIERS, xe as STAGES, c as SchemaError, St as TRACKING_CANDIDATE_PATTERNS, Fe as TRACKING_LINK_RULES, d as TransportError, ac as UNIVERSAL_SOURCES, m as UpstreamHttpError, ee as UpstreamNetworkError, je as activeRequirements, Te as amazonMarketplace, Ee as amazonOrdersUrl, De as amazonShippingOrigin, Oe as amazonShippingUrl, os as calendarDay, es as canadaProvinceTimeZone, Ae as carrierAdapter, y as carrierDefinition, h as carrierErrorKind, He as carrierInfo, Ie as carrierLinkRules, We as carrierRequirements, ke as carrierTimezone, _t as checksumRejections, us as classifyStage, Ut as classifyWording, Jo as countryCode, Yo as countryTimeZone, Is as deliveryHandoff, vt as detectCarrier, x as detectCarrierMatch, is as epochMillisTime, as as epochSecondsTime, ne as errorTypeOf, Rt as eventPoint, Fo as explicitOffsetTime, hs as failureHint, ms as failureKind, _e as formatTrackingNumber, Fs as hasDirectHandoffAdapter, ds as inferStage, Ce as isAmazonTrackingNumber, ge as isPlanzerSharedTrackingNumber, Xe as isValidCorreosSpainCheckLetter, ae as isValidDhlExpressWaybill, $e as isValidDpdParcelNumber, at as isValidGlsParcelNumber, ot as isValidHermesParcelNumber, he as isValidMondialRelayBarcode, pe as isValidPocztaPolskaBarcode, b as isValidS10TrackingNumber, me as isValidSscc, se as isValidTntConsignmentNumber, dt as isValidUspsPackageBarcode, Lo as isoTime, Et as keywordNumberInText, Ht as languageStageStatus, qe as localizedCarrierUrl, Le as matchesDomain, zo as mislabeledLocalTime, Bo as mislabeledWallTime, qs as normalizeCarrierInputs, zt as normalizeCarrierResult, Xs as normalizeDeliveryPostcode, _ as normalizeTrackingNumber, dc as orderUniversalSources, Mt as parseTrackingInput, cc as priorityUniversalSource, xs as recognitionCandidates, bs as recognitionNumberShape, Cs as recognizeAll, Ct as recognizedNumberInText, rs as regionHasTown, Me as requiredRequirements, Ge as requirementSatisfied, we as requiresAmazonAccount, ps as resolveResult, ls as resultHasUpdate, cs as resultStage, g as retryAfterMsOf, Uo as settleGuessedClocks, Ts as settleRecognition, Vo as sharedClockZone, fs as stageSource, st as supportsSwissPostHandoff, Xo as timeZoneCountry, ts as townKey, Vt as trackingLanguageStage, ze as trackingLink, Re as trackingNumberForLink, Ke as tracksAutomatically, fc as universalPlan, sc as universalSourceBudget, pc as universalSources, Qo as usStateTimeZone, bt as validTrackingNumber, Ks as validateDachserTrackingUrl, Gs as validatePlanzerSharedUrl, Wt as wordingStage, Io as zonedTime };
10782
+ export { Se as AMAZON_NUMBER_PATTERN, Ne as AUTOMATIC_CARRIER_IDS, oc as BROWSER_SOURCES, f as BudgetExceededError, Ve as CARRIERS, ve as CARRIER_CATALOG, v as CARRIER_DEFINITIONS, ye as CARRIER_IDS, Pe as CARRIER_NAMES, t as CarrierError, a as ChallengeError, No as EXPLICIT_OFFSET_PATTERN, r as IndeterminateError, l as InputRequiredError, u as InvalidInputError, ys as MAX_RECOGNITIONS, s as MaintenanceError, i as NoHistoryError, n as NotFoundError, o as RateLimitedError, Ue as SELECTABLE_CARRIERS, xe as STAGES, c as SchemaError, St as TRACKING_CANDIDATE_PATTERNS, Fe as TRACKING_LINK_RULES, d as TransportError, ac as UNIVERSAL_SOURCES, m as UpstreamHttpError, ee as UpstreamNetworkError, je as activeRequirements, Te as amazonMarketplace, Ee as amazonOrdersUrl, De as amazonShippingOrigin, Oe as amazonShippingUrl, os as calendarDay, es as canadaProvinceTimeZone, Ae as carrierAdapter, y as carrierDefinition, h as carrierErrorKind, He as carrierInfo, Ie as carrierLinkRules, We as carrierRequirements, ke as carrierTimezone, _t as checksumRejections, us as classifyStage, Ut as classifyWording, Jo as countryCode, Yo as countryTimeZone, Is as deliveryHandoff, vt as detectCarrier, x as detectCarrierMatch, is as epochMillisTime, as as epochSecondsTime, ne as errorTypeOf, Rt as eventPoint, Fo as explicitOffsetTime, hs as failureHint, ms as failureKind, _e as formatTrackingNumber, Fs as hasDirectHandoffAdapter, ds as inferStage, Ce as isAmazonTrackingNumber, ge as isPlanzerSharedTrackingNumber, Xe as isValidCorreosSpainCheckLetter, ae as isValidDhlExpressWaybill, $e as isValidDpdParcelNumber, at as isValidGlsParcelNumber, ot as isValidHermesParcelNumber, he as isValidMondialRelayBarcode, pe as isValidPocztaPolskaBarcode, b as isValidS10TrackingNumber, me as isValidSscc, se as isValidTntConsignmentNumber, dt as isValidUspsPackageBarcode, Lo as isoTime, Et as keywordNumberInText, Ht as languageStageStatus, qe as localizedCarrierUrl, Le as matchesDomain, zo as mislabeledLocalTime, Bo as mislabeledWallTime, Ys as normalizeCarrierInputs, zt as normalizeCarrierResult, Xs as normalizeDeliveryPostcode, _ as normalizeTrackingNumber, dc as orderUniversalSources, Mt as parseTrackingInput, cc as priorityUniversalSource, xs as recognitionCandidates, bs as recognitionNumberShape, Cs as recognizeAll, Ct as recognizedNumberInText, rs as regionHasTown, Me as requiredRequirements, Ge as requirementSatisfied, we as requiresAmazonAccount, ps as resolveResult, ls as resultHasUpdate, cs as resultStage, g as retryAfterMsOf, Uo as settleGuessedClocks, Ts as settleRecognition, Vo as sharedClockZone, fs as stageSource, st as supportsSwissPostHandoff, Xo as timeZoneCountry, ts as townKey, Vt as trackingLanguageStage, ze as trackingLink, Re as trackingNumberForLink, Ke as tracksAutomatically, fc as universalPlan, sc as universalSourceBudget, pc as universalSources, Qo as usStateTimeZone, bt as validTrackingNumber, Ks as validateDachserTrackingUrl, Gs as validatePlanzerSharedUrl, Wt as wordingStage, Io as zonedTime };
@@ -0,0 +1,13 @@
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 class ChronopostTracker {
5
+ private readonly options;
6
+ constructor(options?: {
7
+ fetcher?: typeof fetch;
8
+ recorder?: StepRecorder;
9
+ userAgent?: string;
10
+ });
11
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
12
+ }
13
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,40 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { IndeterminateError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
5
+ import { CHRONOPOST_MAX_BYTES, CHRONOPOST_NAMESPACE, normalizeChronopostNumber, parseChronopostTrackingXml } from './parser.js';
6
+ // The official WSDL declares this read-only operation without account credentials.
7
+ const ENDPOINT = 'https://ws.chronopost.fr/tracking-cxf/TrackingServiceWS';
8
+ export class ChronopostTracker {
9
+ options;
10
+ constructor(options = {}) {
11
+ this.options = options;
12
+ }
13
+ fetch(raw, context = {}) {
14
+ const number = normalizeChronopostNumber(raw);
15
+ return runSteps({ carrier: 'chronopost', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
16
+ recorder: this.options.recorder }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
17
+ const { response, bytes } = await fetchBounded(ENDPOINT, {
18
+ method: 'POST', signal,
19
+ headers: { Accept: 'text/xml', 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '""',
20
+ 'User-Agent': userAgentOf(this.options.userAgent) },
21
+ body: `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:trk="${CHRONOPOST_NAMESPACE}">`
22
+ + `<soap:Body><trk:trackSkybillV2><language>fr_FR</language><skybillNumber>${number}</skybillNumber>`
23
+ + '</trk:trackSkybillV2></soap:Body></soap:Envelope>',
24
+ }, { provider: 'Chronopost tracking', timeoutMs: Math.max(1, Math.floor(remainingMs)),
25
+ maxBytes: CHRONOPOST_MAX_BYTES, allowHttpStatuses: [404, 410], fetcher: this.options.fetcher });
26
+ // An HTTP route failure is not the service's positive unknown-parcel reply.
27
+ if ([404, 410].includes(response.status))
28
+ throw new IndeterminateError('Chronopost', 'Chronopost tracking endpoint is unavailable');
29
+ return parseChronopostTrackingXml(decodeText(bytes), number);
30
+ } }]);
31
+ }
32
+ }
33
+ export const adapter = environment => {
34
+ const tracker = new ChronopostTracker(environment);
35
+ return {
36
+ id: 'chronopost', recordsSteps: true, steps: ['direct'],
37
+ track: (input, context) => tracker.fetch(input.number, context),
38
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeChronopostNumber(number))),
39
+ };
40
+ };
@@ -16,20 +16,20 @@
16
16
  "timezone": "Europe/Paris",
17
17
  "tracking": {
18
18
  "mode": "automatic",
19
- "adapter": "la-poste",
19
+ "adapter": "chronopost",
20
20
  "steps": [
21
- "direct",
22
- "retry"
21
+ "direct"
23
22
  ],
24
23
  "recognition": {
25
24
  "rank": 45
26
- }
25
+ },
26
+ "localClocks": true
27
27
  },
28
28
  "capabilities": [
29
29
  "history",
30
30
  "location",
31
- "eta",
32
- "provider_code"
31
+ "provider_code",
32
+ "delivery_tracking_number"
33
33
  ],
34
34
  "portal": {
35
35
  "url": "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}",
@@ -43,10 +43,11 @@
43
43
  "scraped": [
44
44
  "status",
45
45
  "history",
46
- "location",
46
+ "location"
47
+ ],
48
+ "discarded": [
47
49
  "eta"
48
50
  ],
49
- "discarded": [],
50
51
  "unavailable": []
51
52
  },
52
53
  "links": [
@@ -0,0 +1,5 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const CHRONOPOST_MAX_BYTES = 2000000;
3
+ export declare const CHRONOPOST_NAMESPACE = "http://cxf.tracking.soap.chronopost.fr/";
4
+ export declare function normalizeChronopostNumber(raw: string): string;
5
+ export declare function parseChronopostTrackingXml(xml: string, rawNumber: string): CarrierResult;
@@ -0,0 +1,163 @@
1
+ import { DateTime } from 'luxon';
2
+ import { isValidDpdParcelNumber } from '../../core/detection/dpd.js';
3
+ import { ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { countryCode, explicitOffsetTime } from '../../core/time/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { xmlDocument } from '../../core/transport/xml.js';
8
+ import { chronopostStage, isChronopostNotification } from './status.js';
9
+ export const CHRONOPOST_MAX_BYTES = 2_000_000;
10
+ const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
11
+ export const CHRONOPOST_NAMESPACE = 'http://cxf.tracking.soap.chronopost.fr/';
12
+ const MAX_EVENTS = 500;
13
+ export function normalizeChronopostNumber(raw) {
14
+ const value = raw.toUpperCase().replace(/[\s.-]/g, '');
15
+ if (!/^[A-Z]{2}\d{9}[A-Z]{2}$/.test(value) && !isValidDpdParcelNumber(value)) {
16
+ throw new InvalidInputError('Chronopost', 'Chronopost requires a postal-shaped identifier or a checked 15-character parcel number');
17
+ }
18
+ return value;
19
+ }
20
+ function children(node, name, uri = '') {
21
+ return node.children.filter(child => child.name === name && child.uri === uri);
22
+ }
23
+ function one(node, name, uri = '') {
24
+ const found = children(node, name, uri);
25
+ if (found.length !== 1)
26
+ throw new SchemaError('Chronopost', 'Chronopost returned an invalid tracking structure');
27
+ return found[0];
28
+ }
29
+ function scalar(node, name, required = false) {
30
+ const found = children(node, name);
31
+ if (found.length > 1 || found[0]?.children.length || (required && found.length !== 1)) {
32
+ throw new SchemaError('Chronopost', 'Chronopost returned an invalid tracking field');
33
+ }
34
+ return found[0]?.text.trim() ?? '';
35
+ }
36
+ function clock(raw) {
37
+ // Reject impossible offsets that Luxon otherwise normalizes.
38
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(raw)) {
39
+ const parsed = explicitOffsetTime(raw);
40
+ if (parsed)
41
+ return { time: parsed.iso };
42
+ }
43
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw) && DateTime.fromISO(raw, { zone: 'UTC' }).isValid) {
44
+ return { local_time: raw };
45
+ }
46
+ return raw ? { provider_time_text: clean(raw, 64) } : {};
47
+ }
48
+ /** Keep operational places, never the delivery-point address or shop contact block. */
49
+ function location(office, place) {
50
+ if (/^[\p{L}\p{M} .,'’()-]{1,100} - [A-Z]{2}(?: \(depot \d{1,6}\))?$/u.test(place))
51
+ return place;
52
+ const shop = /^([\p{L}\p{M} .,'’-]{1,100}) - ([A-Z]{2}) - /u.exec(office);
53
+ if (shop)
54
+ return `${shop[1]}, ${shop[2]}`;
55
+ return /^[\p{L}\p{M} .,'’()/-]{1,160}$/u.test(office) ? office : '';
56
+ }
57
+ export function parseChronopostTrackingXml(xml, rawNumber) {
58
+ const requested = normalizeChronopostNumber(rawNumber);
59
+ const root = xmlDocument(xml, CHRONOPOST_MAX_BYTES);
60
+ if (!root || root.name !== 'Envelope' || root.uri !== SOAP) {
61
+ if (/<title>\s*Just a moment|cf-chl-|challenge-platform/i.test(xml))
62
+ throw new ChallengeError('Chronopost');
63
+ throw new SchemaError('Chronopost', 'Chronopost returned invalid tracking XML');
64
+ }
65
+ const body = one(root, 'Body', SOAP);
66
+ if (children(body, 'Fault', SOAP).length)
67
+ throw new IndeterminateError('Chronopost', 'Chronopost tracking returned a SOAP fault');
68
+ if (body.children.length !== 1)
69
+ throw new SchemaError('Chronopost', 'Chronopost returned ambiguous tracking results');
70
+ const response = one(body, 'trackSkybillV2Response', CHRONOPOST_NAMESPACE);
71
+ const result = one(response, 'return');
72
+ const errorCode = scalar(result, 'errorCode', true);
73
+ if (!/^\d+$/.test(errorCode))
74
+ throw new SchemaError('Chronopost', 'Chronopost returned an invalid tracking error code');
75
+ if (Number(errorCode) !== 0)
76
+ throw new IndeterminateError('Chronopost', 'Chronopost tracking is unavailable');
77
+ const history = one(result, 'listEventInfoComp');
78
+ // Compare the entire identity, without truncation or accepting a linked parcel as an alias.
79
+ const identity = scalar(history, 'skybillNumber', true).toUpperCase().replace(/[\s.-]/g, '');
80
+ if (identity !== requested) {
81
+ throw new SchemaError('Chronopost', 'Chronopost returned a different shipment');
82
+ }
83
+ const scans = children(history, 'events');
84
+ if (!scans.length)
85
+ throw new NotFoundError('Chronopost');
86
+ if (scans.length > MAX_EVENTS)
87
+ throw new SchemaError('Chronopost', 'Chronopost returned excessive tracking history');
88
+ const references = new Set();
89
+ const geoReferences = new Set();
90
+ const destinations = new Set();
91
+ const projected = scans.map((scan, index) => {
92
+ const description = clean(scalar(scan, 'eventLabel', true), 500);
93
+ const code = clean(scalar(scan, 'code'), 40);
94
+ if (!description)
95
+ throw new SchemaError('Chronopost', 'Chronopost returned an empty tracking event');
96
+ const extras = children(scan, 'infoCompList');
97
+ let place = '';
98
+ for (const extra of extras) {
99
+ const name = scalar(extra, 'name');
100
+ const value = scalar(extra, 'value');
101
+ if (name === 'Lieu')
102
+ place = value;
103
+ if (name === 'Numéro partenaire') {
104
+ const match = /^(?:GEO\/)?([A-Z0-9]{4,40})$/.exec(value.toUpperCase());
105
+ if (match) {
106
+ references.add(match[1]);
107
+ if (value.toUpperCase().startsWith('GEO/') && isValidDpdParcelNumber(match[1]))
108
+ geoReferences.add(match[1]);
109
+ }
110
+ }
111
+ if (name === 'Point de livraison') {
112
+ // Only the terminal country is projected; discard the address itself.
113
+ const country = countryCode(/ - ([A-Z]{2})$/.exec(value)?.[1]);
114
+ if (country)
115
+ destinations.add(country);
116
+ }
117
+ }
118
+ const event = {
119
+ ...clock(scalar(scan, 'eventDate')),
120
+ description,
121
+ location: location(scalar(scan, 'officeLabel'), place),
122
+ ...(code ? { provider_code: code } : {}),
123
+ };
124
+ const mapped = chronopostStage(code, description);
125
+ return { event, mapped, notification: isChronopostNotification(description), index };
126
+ });
127
+ // The operation returns oldest first. Reorder only when all scan clocks
128
+ // establish instants, so mixed local clocks cannot create a guessed order.
129
+ if (projected.every(scan => explicitOffsetTime(scan.event.time))) {
130
+ projected.sort((left, right) => explicitOffsetTime(left.event.time).timestamp
131
+ - explicitOffsetTime(right.event.time).timestamp || left.index - right.index);
132
+ }
133
+ let stage = 'pending';
134
+ let stageSource = 'none';
135
+ for (const scan of projected) {
136
+ if (!scan.notification && scan.mapped.source !== 'none') {
137
+ stage = scan.mapped.stage;
138
+ stageSource = scan.mapped.source;
139
+ }
140
+ // An alert records activity, but does not regress delivery or prove movement.
141
+ scan.event.stage = scan.notification ? stage : scan.mapped.stage;
142
+ scan.event.stage_source = scan.notification ? 'none' : scan.mapped.source;
143
+ }
144
+ const events = projected.reverse().slice(0, 100).map(scan => scan.event);
145
+ const latest = events[0];
146
+ const reference = references.size === 1 ? [...references][0] : undefined;
147
+ const destination = destinations.size === 1 ? [...destinations][0] : undefined;
148
+ // The typed Geopost reference and Germany destination propose a national
149
+ // lookup. The downstream adapter must still confirm ownership and activity.
150
+ const partner = reference && geoReferences.has(reference) && destination === 'DE' ? 'dpd-de' : undefined;
151
+ return {
152
+ status: languageStageStatus(stage),
153
+ current_stage: stage,
154
+ current_stage_source: stageSource,
155
+ last_status_text: latest.description,
156
+ last_update: latest.time ?? null,
157
+ expected_delivery: null,
158
+ ...(reference ? { delivery_tracking_number: reference } : {}),
159
+ ...(partner ? { delivery_carrier: partner } : {}),
160
+ ...(destination ? { destination_country: destination } : {}),
161
+ events,
162
+ };
163
+ }
@@ -0,0 +1,4 @@
1
+ import { type ClassifiedWording } from '../../core/status/index.js';
2
+ export declare function isChronopostNotification(label: string): boolean;
3
+ /** Partner events can reuse codes; the observed wording must agree with the code. */
4
+ export declare function chronopostStage(code: string, label: string): ClassifiedWording;
@@ -0,0 +1,19 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ const observed = new Map([
3
+ ['DC', { label: "Colis en cours de préparation chez l'expéditeur", stage: 'registered' }],
4
+ ['DB', { label: "Colis déposé par l'expéditeur", stage: 'accepted' }],
5
+ ['SC', { label: "Tri effectué dans l'agence de départ", stage: 'in_transit' }],
6
+ ['TS', { label: "Colis en cours d'acheminement", stage: 'in_transit' }],
7
+ ['O', { label: "Colis en cours d'acheminement", stage: 'in_transit' }],
8
+ ['TP', { label: "Colis en cours d'acheminement", stage: 'in_transit' }],
9
+ ]);
10
+ export function isChronopostNotification(label) {
11
+ return label === 'Destinataire informé par SMS ou mail';
12
+ }
13
+ /** Partner events can reuse codes; the observed wording must agree with the code. */
14
+ export function chronopostStage(code, label) {
15
+ const known = observed.get(code);
16
+ if (known?.label === label)
17
+ return { stage: known.stage, source: 'carrier_map' };
18
+ return classifyWording(label, 'pending');
19
+ }
@@ -42,6 +42,62 @@
42
42
  "confirmedBy": "fixture",
43
43
  "firstSeen": "2026-09-12",
44
44
  "note": "Observed on a Chronopost shipment with an empty group and the label 'Livraison effectuée'."
45
+ },
46
+ {
47
+ "code": "DC",
48
+ "stage": "registered",
49
+ "confirmedBy": "live",
50
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
51
+ "wording": "Colis en cours de préparation chez l'expéditeur",
52
+ "language": "fr"
53
+ },
54
+ {
55
+ "code": "DB",
56
+ "stage": "accepted",
57
+ "confirmedBy": "live",
58
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
59
+ "wording": "Colis déposé par l'expéditeur",
60
+ "language": "fr"
61
+ },
62
+ {
63
+ "code": "SC",
64
+ "stage": "in_transit",
65
+ "confirmedBy": "live",
66
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
67
+ "wording": "Tri effectué dans l'agence de départ",
68
+ "language": "fr"
69
+ },
70
+ {
71
+ "code": "TS",
72
+ "stage": "in_transit",
73
+ "confirmedBy": "live",
74
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
75
+ "wording": "Colis en cours d'acheminement",
76
+ "language": "fr"
77
+ },
78
+ {
79
+ "code": "O",
80
+ "stage": "in_transit",
81
+ "confirmedBy": "live",
82
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
83
+ "wording": "Colis en cours d'acheminement",
84
+ "language": "fr"
85
+ },
86
+ {
87
+ "code": "TP",
88
+ "stage": "in_transit",
89
+ "confirmedBy": "live",
90
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording.",
91
+ "wording": "Colis en cours d'acheminement",
92
+ "language": "fr"
93
+ },
94
+ {
95
+ "code": "SM",
96
+ "stage": "in_transit",
97
+ "confirmedBy": "live",
98
+ "note": "Notification after an in-transit scan; inherits the preceding shipment stage rather than establishing a milestone.",
99
+ "wording": "Destinataire informé par SMS ou mail",
100
+ "language": "fr"
45
101
  }
46
102
  ]
47
103
  }
@@ -2,6 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { load } from 'cheerio';
4
4
  import { lookupBudget } from '../../core/adapter/index.js';
5
+ import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
5
6
  import { dpdParcelNumber } from '../../core/detection/dpd.js';
6
7
  import { BudgetExceededError, carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
7
8
  import { runSteps } from '../../core/runner/index.js';
@@ -47,8 +48,6 @@ const TOKEN_FAILURE_MEMORY_MS = 30_000;
47
48
  /** The browser service may spend the whole request timeout plus its own transport allowance. */
48
49
  const SOLVER_ALLOWANCE_MS = 15_000;
49
50
  const MAX_BYTES = 10_000_000;
50
- /** Any country's postcode, as the catalog's `internationalPostcode` input takes it. */
51
- const DELIVERY_POSTCODE = /^(?=.{3,12}$)(?=.*\d)[A-Z0-9]+(?:[ -][A-Z0-9]+)*$/;
52
51
  /** Cloudflare interrupted the consignee page with an interactive challenge. */
53
52
  export class DPDChallengeError extends ChallengeError {
54
53
  constructor(message = 'DPD returned a Cloudflare browser challenge', options) {
@@ -537,7 +536,7 @@ export class DPDTracker {
537
536
  // DPD checks the postcode against the recipient's, and the service this
538
537
  // adapter asks also answers for parcels delivered in other countries: any
539
538
  // country's postcode goes through. The German unit reads German deliveries.
540
- const resolvedPostcode = postcode.trim().toUpperCase().replace(/\s+/g, ' ');
539
+ const resolvedPostcode = deliveryPostcodeText(postcode);
541
540
  if (resolvedPostcode && !(this.country === 'DE' ? /^\d{5}$/ : DELIVERY_POSTCODE).test(resolvedPostcode)) {
542
541
  throw new InvalidInputError('DPD', this.country === 'DE'
543
542
  ? 'DPD postcode must contain exactly 5 digits'
@@ -15,10 +15,16 @@ export declare class GLSSwitzerlandTrackingError extends NotFoundError {
15
15
  constructor();
16
16
  }
17
17
  export declare function normalizeGLSSwitzerlandTrackingNumber(raw: string): string;
18
- export declare function normalizeGLSSwitzerlandPostcode(raw: string, digits?: 4 | 5 | '4,5'): string;
18
+ export type GLSPostcodeShape = 4 | 'any';
19
+ /**
20
+ * The recipient postcode: four Swiss digits, or with `any` the postcode of any
21
+ * country GLS delivers in. GLS refuses only what no country writes, such as a
22
+ * symbol or fewer than three characters.
23
+ */
24
+ export declare function normalizeGLSSwitzerlandPostcode(raw: string, shape?: GLSPostcodeShape): string;
19
25
  export declare function glsSwitzerlandTrackingUrl(rawTrackingNumber: string): string;
20
26
  export declare function glsSwitzerlandOverviewApiUrl(rawTrackingNumber: string, millis?: number): string;
21
- export declare function glsSwitzerlandDetailApiUrl(rawParcelNumber: string, rawPostcode: string, millis?: number, ownerCode?: string, postcodeDigits?: 4 | 5 | '4,5'): string;
27
+ export declare function glsSwitzerlandDetailApiUrl(rawParcelNumber: string, rawPostcode: string, millis?: number, ownerCode?: string, postcodeShape?: GLSPostcodeShape): string;
22
28
  export declare function selectGLSParcel(payload: unknown, rawTrackingNumber: string): JsonObject;
23
29
  export declare function parseGLSSwitzerlandTrackingResponse(payload: unknown, rawTrackingNumber: string): CarrierResult;
24
30
  export declare class GLSSwitzerlandTracker {
@@ -1,6 +1,7 @@
1
1
  import { load } from 'cheerio';
2
2
  import { DateTime } from 'luxon';
3
3
  import { lookupBudget } from '../../core/adapter/index.js';
4
+ import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
4
5
  import { InputRequiredError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
5
6
  import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
6
7
  import { isRecord } from '../../core/types.js';
@@ -49,11 +50,16 @@ export function normalizeGLSSwitzerlandTrackingNumber(raw) {
49
50
  }
50
51
  return value;
51
52
  }
52
- export function normalizeGLSSwitzerlandPostcode(raw, digits = 4) {
53
- const value = raw.trim();
54
- if (!new RegExp(`^\\d{${digits}}$`).test(value)) {
55
- const shape = `${digits === '4,5' ? '4- or 5' : digits}-digit recipient postcode`;
56
- throw new InputRequiredError('GLS', `the ${shape}`, `GLS detailed tracking requires the ${shape}`);
53
+ /**
54
+ * The recipient postcode: four Swiss digits, or with `any` the postcode of any
55
+ * country GLS delivers in. GLS refuses only what no country writes, such as a
56
+ * symbol or fewer than three characters.
57
+ */
58
+ export function normalizeGLSSwitzerlandPostcode(raw, shape = 4) {
59
+ const value = shape === 'any' ? deliveryPostcodeText(raw) : raw.trim();
60
+ if (!(shape === 'any' ? DELIVERY_POSTCODE : /^\d{4}$/).test(value)) {
61
+ const wanted = shape === 'any' ? 'recipient postcode' : '4-digit recipient postcode';
62
+ throw new InputRequiredError('GLS', `the ${wanted}`, `GLS detailed tracking requires the ${wanted}`);
57
63
  }
58
64
  return value;
59
65
  }
@@ -70,7 +76,7 @@ export function glsSwitzerlandOverviewApiUrl(rawTrackingNumber, millis = Date.no
70
76
  url.searchParams.set('millis', String(millis));
71
77
  return url.toString();
72
78
  }
73
- export function glsSwitzerlandDetailApiUrl(rawParcelNumber, rawPostcode, millis = Date.now(), ownerCode = '', postcodeDigits = 4) {
79
+ export function glsSwitzerlandDetailApiUrl(rawParcelNumber, rawPostcode, millis = Date.now(), ownerCode = '', postcodeShape = 4) {
74
80
  const parcelNumber = normalizeGLSSwitzerlandTrackingNumber(rawParcelNumber);
75
81
  if (!/^\d{11,14}$/.test(parcelNumber)) {
76
82
  throw new InputRequiredError('GLS', 'the numeric parcel number', 'GLS details require the numeric parcel number');
@@ -78,7 +84,7 @@ export function glsSwitzerlandDetailApiUrl(rawParcelNumber, rawPostcode, millis
78
84
  const url = new URL(`${TRACKING_API}/rstt028/${encodeURIComponent(parcelNumber)}`);
79
85
  url.searchParams.set('caller', 'witt002');
80
86
  url.searchParams.set('millis', String(millis));
81
- url.searchParams.set('postalCode', normalizeGLSSwitzerlandPostcode(rawPostcode, postcodeDigits));
87
+ url.searchParams.set('postalCode', normalizeGLSSwitzerlandPostcode(rawPostcode, postcodeShape));
82
88
  const owner = text(ownerCode, 32);
83
89
  if (owner && /^[A-Z0-9_-]+$/i.test(owner))
84
90
  url.searchParams.set('tuOwnerCode', owner);
@@ -3,8 +3,9 @@ import { NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors
3
3
  import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
4
4
  import { isRecord } from '../../core/types.js';
5
5
  import { glsSwitzerlandDetailApiUrl, glsDeliveryReference, glsSwitzerlandOverviewApiUrl, GLSSwitzerlandTrackingError, normalizeGLSSwitzerlandPostcode, normalizeGLSSwitzerlandTrackingNumber, parseGLSSwitzerlandTrackingResponse, selectGLSParcel, } from '../gls-ch/adapter.js';
6
- // GLS's GROUP recipient service covers German and Swiss parcels. Keep the
7
- // shared response parser; the delivery postcode can be Swiss or German.
6
+ // GLS's GROUP recipient service covers the parcels GLS delivers across its
7
+ // network, so the delivery postcode can be any country's. Keep the shared
8
+ // response parser.
8
9
  const PROVIDER = 'GLS Germany tracking';
9
10
  const CARRIER = 'GLS Germany';
10
11
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -33,7 +34,7 @@ export class GLSGermanyTracker {
33
34
  }
34
35
  async fetch(rawTrackingNumber, rawPostcode, context = {}) {
35
36
  const number = normalizeGLSSwitzerlandTrackingNumber(rawTrackingNumber);
36
- const postcode = normalizeGLSSwitzerlandPostcode(rawPostcode, '4,5');
37
+ const postcode = normalizeGLSSwitzerlandPostcode(rawPostcode, 'any');
37
38
  // Two requests, each with the full request timeout.
38
39
  const budget = lookupBudget(context, 2 * this.timeoutMs);
39
40
  const overview = await this.request(glsSwitzerlandOverviewApiUrl(number, this.now()), budget);
@@ -55,7 +56,7 @@ export class GLSGermanyTracker {
55
56
  }
56
57
  const owners = Array.isArray(parcel.owners) ? parcel.owners.filter(isRecord) : [];
57
58
  const owner = owners.find((row) => row.type === 'REQUEST');
58
- const detail = await this.request(glsSwitzerlandDetailApiUrl(String(parcel.tuNo), postcode, this.now(), cleanScalar(owner?.code, 64), '4,5'), budget);
59
+ const detail = await this.request(glsSwitzerlandDetailApiUrl(String(parcel.tuNo), postcode, this.now(), cleanScalar(owner?.code, 64), 'any'), budget);
59
60
  try {
60
61
  const result = parseGLSSwitzerlandTrackingResponse(detail, String(parcel.tuNo));
61
62
  // Both services use CET/CEST; expose the regional timezone to clients.
@@ -21,14 +21,13 @@
21
21
  "requirements": [
22
22
  {
23
23
  "field": "postcode",
24
- "validator": "swissOrFrancePostcode",
24
+ "validator": "internationalPostcode",
25
25
  "label": "Delivery postcode",
26
26
  "type": "text",
27
- "placeholder": "10115",
28
- "help": "GLS requires the four- or five-digit delivery postcode to show the detailed event history.",
29
- "pattern": "^[0-9]{4,5}$",
30
- "maxLength": 5,
31
- "inputMode": "numeric",
27
+ "help": "GLS requires the delivery postcode to show the detailed event history.",
28
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
29
+ "maxLength": 12,
30
+ "inputMode": "text",
32
31
  "autoComplete": "postal-code"
33
32
  }
34
33
  ],
@@ -5,11 +5,19 @@ import { TrawlClient } from '../../core/transport/index.js';
5
5
  import { MondialRelayAppClient } from './app.js';
6
6
  interface MondialRelayCredential {
7
7
  shipment: string;
8
+ /** The recipient's, as typed; empty when the number needs none. */
8
9
  postcode: string;
9
10
  canonicalShipment?: string;
10
11
  /** Read from a checksummed label barcode, which needs no postcode. */
11
12
  barcode?: true;
12
13
  }
14
+ /**
15
+ * The shipment and the postcode Mondial Relay is asked with. The postcode is
16
+ * compared with the recipient's as typed, in any country's format: the country
17
+ * sent alongside only picks the reply's language. Only an 8-digit shipment
18
+ * needs it; the 10- and 12-digit forms carry the brand and are found without
19
+ * one, so a postcode typed for them is not sent.
20
+ */
13
21
  export declare function normalizeMondialRelayCredential(rawShipment: string, rawPostcode?: string): MondialRelayCredential;
14
22
  export declare function mondialRelayTrackingUrl(rawShipment: string, rawPostcode?: string): string;
15
23
  export declare function parseMondialRelayTrackingResponse(payload: unknown, rawShipment: string, rawPostcode?: string): CarrierResult;
@@ -1,5 +1,6 @@
1
1
  import { load } from 'cheerio';
2
2
  import { DateTime } from 'luxon';
3
+ import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
3
4
  import { isValidMondialRelayBarcode } from '../../core/detection/index.js';
4
5
  import { ChallengeError, InputRequiredError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
5
6
  import { runSteps, singleFlight, takeTurn } from '../../core/runner/index.js';
@@ -24,8 +25,8 @@ const DEFAULT_TIMEOUT_MS = 90_000;
24
25
  /** The app answers in about a second; past this the website gets the rest of the budget. */
25
26
  const DEFAULT_DIRECT_TIMEOUT_MS = 10_000;
26
27
  const ZONE = 'Europe/Paris';
27
- const CREDENTIAL_MESSAGE = 'Mondial Relay tracking requires an 8-, 10-, or 12-digit shipment number followed by '
28
- + 'the 5-digit recipient postcode';
28
+ const CREDENTIAL_MESSAGE = 'Mondial Relay tracking requires a 26-digit label barcode, a 10- or 12-digit shipment '
29
+ + 'number, or an 8-digit shipment number with the recipient postcode';
29
30
  /** Strip the markup Mondial Relay embeds in its labels, entities included. */
30
31
  function plainText(value, limit = 500) {
31
32
  if (typeof value !== 'string' && typeof value !== 'number')
@@ -36,32 +37,40 @@ function plainText(value, limit = 500) {
36
37
  function plausibleFrenchPostcode(value) {
37
38
  return /^(?:0[1-9]|[1-8]\d|9[0-5]|97|98)\d{3}$/.test(value);
38
39
  }
40
+ /**
41
+ * The shipment and the postcode Mondial Relay is asked with. The postcode is
42
+ * compared with the recipient's as typed, in any country's format: the country
43
+ * sent alongside only picks the reply's language. Only an 8-digit shipment
44
+ * needs it; the 10- and 12-digit forms carry the brand and are found without
45
+ * one, so a postcode typed for them is not sent.
46
+ */
39
47
  export function normalizeMondialRelayCredential(rawShipment, rawPostcode = '') {
40
48
  let shipment = rawShipment.trim().replace(/[\s.-]/g, '');
41
- let postcode = rawPostcode.trim();
49
+ let postcode = deliveryPostcodeText(rawPostcode);
42
50
  if (/^\d{26}$/.test(shipment)) {
43
- if (!isValidMondialRelayBarcode(shipment) || (postcode && !plausibleFrenchPostcode(postcode))) {
51
+ if (!isValidMondialRelayBarcode(shipment) || (postcode && !DELIVERY_POSTCODE.test(postcode))) {
44
52
  throw new InvalidInputError('Mondial Relay', 'Invalid Mondial Relay barcode or postcode');
45
53
  }
46
54
  // The public alias carries brand/shipment/parcel sequence, not a postcode.
47
55
  return { shipment: shipment.slice(0, 12), postcode, canonicalShipment: shipment.slice(2, 10), barcode: true };
48
56
  }
49
57
  if (!postcode && /^(?:\d{13}|\d{15}|\d{17})$/.test(shipment)) {
58
+ // A French label prints the shipment followed by the 5-digit postcode.
59
+ if (!plausibleFrenchPostcode(shipment.slice(-5)))
60
+ throw new InvalidInputError('Mondial Relay', CREDENTIAL_MESSAGE);
50
61
  postcode = shipment.slice(-5);
51
62
  shipment = shipment.slice(0, -5);
52
63
  }
53
- const shapedShipment = /^(?:\d{8}|\d{10}|\d{12})$/.test(shipment);
54
- if (!shapedShipment || !plausibleFrenchPostcode(postcode)) {
55
- // A well-shaped number without a usable postcode is a missing credential;
56
- // anything else is a number this carrier cannot address at all.
57
- throw shapedShipment
58
- ? new InputRequiredError('Mondial Relay', 'the recipient postcode', CREDENTIAL_MESSAGE)
59
- : new InvalidInputError('Mondial Relay', CREDENTIAL_MESSAGE);
64
+ if (!/^(?:\d{8}|\d{10}|\d{12})$/.test(shipment))
65
+ throw new InvalidInputError('Mondial Relay', CREDENTIAL_MESSAGE);
66
+ if (shipment.length === 8 && !DELIVERY_POSTCODE.test(postcode)) {
67
+ // A well-shaped number without a usable postcode is a missing credential.
68
+ throw new InputRequiredError('Mondial Relay', 'the recipient postcode', CREDENTIAL_MESSAGE);
60
69
  }
61
70
  // The longer forms put the 2-digit brand before the 8-digit shipment (the
62
71
  // 12-digit one adds the parcel sequence), and the API echoes the shipment.
63
72
  return shipment.length > 8
64
- ? { shipment, postcode, canonicalShipment: shipment.slice(2, 10) }
73
+ ? { shipment, postcode: '', canonicalShipment: shipment.slice(2, 10) }
65
74
  : { shipment, postcode };
66
75
  }
67
76
  export function mondialRelayTrackingUrl(rawShipment, rawPostcode = '') {
@@ -75,6 +84,8 @@ function trackingApiUrl(credential, brand = '') {
75
84
  url.searchParams.set('shipment', credential.shipment);
76
85
  url.searchParams.set('postcode', credential.postcode);
77
86
  url.searchParams.set('brand', brand);
87
+ // Only the reply's language: the postcode matches whatever country it is from.
88
+ // The status and event parsers read French.
78
89
  url.searchParams.set('codePays', 'fr');
79
90
  return url.toString();
80
91
  }
@@ -5,7 +5,7 @@ export declare const MONDIAL_RELAY_APP_API = "https://mobile-app-bff.mondialrela
5
5
  * HTTP/2, and answers the same request over plain HTTP/1.1.
6
6
  */
7
7
  export declare const http1Fetch: typeof fetch;
8
- /** A normalized credential: 8, 10 or 12 digits, or a barcode's 12-digit alias, and a postcode. */
8
+ /** A normalized credential: 8, 10 or 12 digits, or a barcode's 12-digit alias, and the postcode an 8-digit one needs. */
9
9
  export interface MondialRelayAppQuery {
10
10
  shipment: string;
11
11
  postcode: string;
@@ -22,15 +22,14 @@
22
22
  "requirements": [
23
23
  {
24
24
  "field": "postcode",
25
- "whenTrackingNumber": "^(?![0-9]{26}$).*$",
26
- "validator": "francePostcode",
25
+ "whenTrackingNumber": "^[0-9]{8}$",
26
+ "validator": "internationalPostcode",
27
27
  "label": "Delivery postcode",
28
28
  "type": "text",
29
- "placeholder": "75001",
30
- "help": "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.",
31
- "pattern": "^[0-9]{5}$",
32
- "maxLength": 5,
33
- "inputMode": "numeric",
29
+ "help": "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.",
30
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
31
+ "maxLength": 12,
32
+ "inputMode": "text",
34
33
  "autoComplete": "postal-code"
35
34
  }
36
35
  ],
@@ -3,6 +3,7 @@ import { activeRequirements, carrierDefinition } from './index.js';
3
3
  import { isValidMondialRelayBarcode } from '../detection/index.js';
4
4
  import { validateDachserTrackingUrl } from './urls.js';
5
5
  import { validatePlanzerSharedUrl } from './urls.js';
6
+ import { DELIVERY_POSTCODE, deliveryPostcodeText } from './postcode.js';
6
7
  export { AUTOMATIC_CARRIER_IDS, CARRIER_NAMES, activeRequirements, carrierAdapter, carrierDefinition, carrierTimezone, requiredRequirements, } from './index.js';
7
8
  export { isValidS10TrackingNumber, supportsSwissPostHandoff, } from '../detection/index.js';
8
9
  export function normalizeCarrierInputs(carrierId, trackingNumber, trackingUrl, postcode) {
@@ -15,10 +16,13 @@ export function normalizeCarrierInputs(carrierId, trackingNumber, trackingUrl, p
15
16
  throw new TypeError('Invalid Mondial Relay barcode');
16
17
  }
17
18
  const requirements = new Map(activeRequirements(carrierId, trackingNumber).map((item) => [item.field, item]));
18
- // Older clients may still supply a postcode for label barcodes. Validate it
19
- // when present, while allowing the public alias to work without one.
20
- if (mondialBarcode && supplied.postcode) {
21
- requirements.set('postcode', { field: 'postcode', validator: 'francePostcode' });
19
+ // Older clients may still supply a postcode for Mondial Relay numbers that
20
+ // need none: label barcodes and the 10- and 12-digit forms. Validate it when
21
+ // present, while allowing them to work without one.
22
+ const mondialWithoutPostcode = mondialBarcode
23
+ || (carrierId === 'mondial-relay' && /^(?:\d{10}|\d{12})$/.test(trackingNumber));
24
+ if (mondialWithoutPostcode && supplied.postcode) {
25
+ requirements.set('postcode', { field: 'postcode', validator: 'internationalPostcode' });
22
26
  }
23
27
  for (const [field, value] of Object.entries(supplied)) {
24
28
  if (value !== null && !requirements.has(field)) {
@@ -76,10 +80,10 @@ export function normalizeCarrierInputs(carrierId, trackingNumber, trackingUrl, p
76
80
  break;
77
81
  case 'internationalPostcode':
78
82
  // The carrier delivers abroad too: any country's postcode, checked by the carrier itself.
79
- if (!POSTCODE_SHAPE.test(postcodeText(value))) {
83
+ if (!DELIVERY_POSTCODE.test(deliveryPostcodeText(value))) {
80
84
  throw new TypeError(`${carrierDefinition(carrierId).displayName} requires a valid delivery postcode`);
81
85
  }
82
- supplied[field] = postcodeText(value);
86
+ supplied[field] = deliveryPostcodeText(value);
83
87
  break;
84
88
  case 'paackPostcode': {
85
89
  const rawPostcode = value.toLocaleUpperCase('en-US');
@@ -95,15 +99,10 @@ export function normalizeCarrierInputs(carrierId, trackingNumber, trackingUrl, p
95
99
  }
96
100
  return supplied;
97
101
  }
98
- /** Any country's postcode: 3 to 12 characters with a digit, in groups joined by one space or hyphen. */
99
- const POSTCODE_SHAPE = /^(?=.{3,12}$)(?=.*\d)[A-Z0-9]+(?:[ -][A-Z0-9]+)*$/;
100
- function postcodeText(value) {
101
- return value.trim().toUpperCase().replace(/\s+/g, ' ');
102
- }
103
102
  /** A recipient postcode for universal providers; independent of a carrier's own credentials. */
104
103
  export function normalizeDeliveryPostcode(value) {
105
- const normalized = postcodeText(value);
106
- if (!POSTCODE_SHAPE.test(normalized)) {
104
+ const normalized = deliveryPostcodeText(value);
105
+ if (!DELIVERY_POSTCODE.test(normalized)) {
107
106
  throw new TypeError('A valid delivery postcode is required');
108
107
  }
109
108
  return normalized;
@@ -0,0 +1,4 @@
1
+ /** Any country's postcode: 3 to 12 characters with a digit, in groups joined by one space or hyphen. */
2
+ export declare const DELIVERY_POSTCODE: RegExp;
3
+ /** A typed postcode as it is checked and sent: trimmed, in capitals, with single spaces. */
4
+ export declare function deliveryPostcodeText(value: string): string;
@@ -0,0 +1,6 @@
1
+ /** Any country's postcode: 3 to 12 characters with a digit, in groups joined by one space or hyphen. */
2
+ export const DELIVERY_POSTCODE = /^(?=.{3,12}$)(?=.*\d)[A-Z0-9]+(?:[ -][A-Z0-9]+)*$/;
3
+ /** A typed postcode as it is checked and sent: trimmed, in capitals, with single spaces. */
4
+ export function deliveryPostcodeText(value) {
5
+ return value.trim().toUpperCase().replace(/\s+/g, ' ');
6
+ }
@@ -949,15 +949,14 @@
949
949
  "requirements": [
950
950
  {
951
951
  "field": "postcode",
952
- "whenTrackingNumber": "^(?![0-9]{26}$).*$",
953
- "validator": "francePostcode",
952
+ "whenTrackingNumber": "^[0-9]{8}$",
953
+ "validator": "internationalPostcode",
954
954
  "label": "Delivery postcode",
955
955
  "type": "text",
956
- "placeholder": "75001",
957
- "help": "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.",
958
- "pattern": "^[0-9]{5}$",
959
- "maxLength": 5,
960
- "inputMode": "numeric",
956
+ "help": "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.",
957
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
958
+ "maxLength": 12,
959
+ "inputMode": "text",
961
960
  "autoComplete": "postal-code"
962
961
  }
963
962
  ]
@@ -1109,8 +1108,9 @@
1109
1108
  "timezone": "Europe/Paris",
1110
1109
  "tracking": {
1111
1110
  "mode": "automatic",
1112
- "adapter": "la-poste",
1113
- "recognitionRank": 45
1111
+ "adapter": "chronopost",
1112
+ "recognitionRank": 45,
1113
+ "localClocks": true
1114
1114
  },
1115
1115
  "canaryUrl": "https://www.chronopost.fr/tracking-no-cms/suivi-page",
1116
1116
  "trackingUrlTemplate": "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}",
@@ -1644,14 +1644,13 @@
1644
1644
  "requirements": [
1645
1645
  {
1646
1646
  "field": "postcode",
1647
- "validator": "swissOrFrancePostcode",
1647
+ "validator": "internationalPostcode",
1648
1648
  "label": "Delivery postcode",
1649
1649
  "type": "text",
1650
- "placeholder": "10115",
1651
- "help": "GLS requires the four- or five-digit delivery postcode to show the detailed event history.",
1652
- "pattern": "^[0-9]{4,5}$",
1653
- "maxLength": 5,
1654
- "inputMode": "numeric",
1650
+ "help": "GLS requires the delivery postcode to show the detailed event history.",
1651
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
1652
+ "maxLength": 12,
1653
+ "inputMode": "text",
1655
1654
  "autoComplete": "postal-code"
1656
1655
  }
1657
1656
  ],
@@ -584,15 +584,14 @@ export declare const CARRIER_CATALOG: {
584
584
  readonly adapter: "mondial-relay";
585
585
  readonly requirements: readonly [{
586
586
  readonly field: "postcode";
587
- readonly whenTrackingNumber: "^(?![0-9]{26}$).*$";
588
- readonly validator: "francePostcode";
587
+ readonly whenTrackingNumber: "^[0-9]{8}$";
588
+ readonly validator: "internationalPostcode";
589
589
  readonly label: "Delivery postcode";
590
590
  readonly type: "text";
591
- readonly placeholder: "75001";
592
- readonly help: "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.";
593
- readonly pattern: "^[0-9]{5}$";
594
- readonly maxLength: 5;
595
- readonly inputMode: "numeric";
591
+ readonly help: "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.";
592
+ readonly pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$";
593
+ readonly maxLength: 12;
594
+ readonly inputMode: "text";
596
595
  readonly autoComplete: "postal-code";
597
596
  }];
598
597
  };
@@ -691,8 +690,9 @@ export declare const CARRIER_CATALOG: {
691
690
  readonly timezone: "Europe/Paris";
692
691
  readonly tracking: {
693
692
  readonly mode: "automatic";
694
- readonly adapter: "la-poste";
693
+ readonly adapter: "chronopost";
695
694
  readonly recognitionRank: 45;
695
+ readonly localClocks: true;
696
696
  };
697
697
  readonly canaryUrl: "https://www.chronopost.fr/tracking-no-cms/suivi-page";
698
698
  readonly trackingUrlTemplate: "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}";
@@ -1045,14 +1045,13 @@ export declare const CARRIER_CATALOG: {
1045
1045
  readonly adapter: "gls-de";
1046
1046
  readonly requirements: readonly [{
1047
1047
  readonly field: "postcode";
1048
- readonly validator: "swissOrFrancePostcode";
1048
+ readonly validator: "internationalPostcode";
1049
1049
  readonly label: "Delivery postcode";
1050
1050
  readonly type: "text";
1051
- readonly placeholder: "10115";
1052
- readonly help: "GLS requires the four- or five-digit delivery postcode to show the detailed event history.";
1053
- readonly pattern: "^[0-9]{4,5}$";
1054
- readonly maxLength: 5;
1055
- readonly inputMode: "numeric";
1051
+ readonly help: "GLS requires the delivery postcode to show the detailed event history.";
1052
+ readonly pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$";
1053
+ readonly maxLength: 12;
1054
+ readonly inputMode: "text";
1056
1055
  readonly autoComplete: "postal-code";
1057
1056
  }];
1058
1057
  readonly recognitionRank: 50;
@@ -950,15 +950,14 @@ export const CARRIER_CATALOG = {
950
950
  "requirements": [
951
951
  {
952
952
  "field": "postcode",
953
- "whenTrackingNumber": "^(?![0-9]{26}$).*$",
954
- "validator": "francePostcode",
953
+ "whenTrackingNumber": "^[0-9]{8}$",
954
+ "validator": "internationalPostcode",
955
955
  "label": "Delivery postcode",
956
956
  "type": "text",
957
- "placeholder": "75001",
958
- "help": "Short Mondial Relay shipment numbers require the recipient postcode; validated 26-digit label barcodes do not.",
959
- "pattern": "^[0-9]{5}$",
960
- "maxLength": 5,
961
- "inputMode": "numeric",
957
+ "help": "8-digit Mondial Relay shipment numbers require the recipient postcode; longer numbers and label barcodes do not.",
958
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
959
+ "maxLength": 12,
960
+ "inputMode": "text",
962
961
  "autoComplete": "postal-code"
963
962
  }
964
963
  ]
@@ -1110,8 +1109,9 @@ export const CARRIER_CATALOG = {
1110
1109
  "timezone": "Europe/Paris",
1111
1110
  "tracking": {
1112
1111
  "mode": "automatic",
1113
- "adapter": "la-poste",
1114
- "recognitionRank": 45
1112
+ "adapter": "chronopost",
1113
+ "recognitionRank": 45,
1114
+ "localClocks": true
1115
1115
  },
1116
1116
  "canaryUrl": "https://www.chronopost.fr/tracking-no-cms/suivi-page",
1117
1117
  "trackingUrlTemplate": "https://www.chronopost.fr/tracking-no-cms/suivi-page?langue=fr&listeNumerosLT={trackingNumber}",
@@ -1645,14 +1645,13 @@ export const CARRIER_CATALOG = {
1645
1645
  "requirements": [
1646
1646
  {
1647
1647
  "field": "postcode",
1648
- "validator": "swissOrFrancePostcode",
1648
+ "validator": "internationalPostcode",
1649
1649
  "label": "Delivery postcode",
1650
1650
  "type": "text",
1651
- "placeholder": "10115",
1652
- "help": "GLS requires the four- or five-digit delivery postcode to show the detailed event history.",
1653
- "pattern": "^[0-9]{4,5}$",
1654
- "maxLength": 5,
1655
- "inputMode": "numeric",
1651
+ "help": "GLS requires the delivery postcode to show the detailed event history.",
1652
+ "pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
1653
+ "maxLength": 12,
1654
+ "inputMode": "text",
1656
1655
  "autoComplete": "postal-code"
1657
1656
  }
1658
1657
  ],
@@ -12,6 +12,7 @@ import { adapter as brt } from '../carriers/brt/adapter.js';
12
12
  import { adapter as cChezVous } from '../carriers/c-chez-vous/adapter.js';
13
13
  import { adapter as canadaPost } from '../carriers/canada-post/adapter.js';
14
14
  import { adapter as canpar } from '../carriers/canpar/adapter.js';
15
+ import { adapter as chronopost } from '../carriers/chronopost/adapter.js';
15
16
  import { adapter as ciblex } from '../carriers/ciblex/adapter.js';
16
17
  import { adapter as cne } from '../carriers/cne/adapter.js';
17
18
  import { adapter as colisPrive } from '../carriers/colis-prive/adapter.js';
@@ -117,6 +118,7 @@ export const REGISTRY = {
117
118
  "c-chez-vous": cChezVous,
118
119
  "canada-post": canadaPost,
119
120
  "canpar": canpar,
121
+ "chronopost": chronopost,
120
122
  "ciblex": ciblex,
121
123
  "cne": cne,
122
124
  "colis-prive": colisPrive,
@@ -225,7 +227,7 @@ export const REGISTRY = {
225
227
  "canada-post": "canada-post",
226
228
  "canpar": "canpar",
227
229
  "china-post": "universal",
228
- "chronopost": "la-poste",
230
+ "chronopost": "chronopost",
229
231
  "ciblex": "ciblex",
230
232
  "cne": "cne",
231
233
  "colis-prive": "colis-prive",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.40.0"
5
+ "version": "0.41.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.40.0-main.395",
3
+ "version": "0.41.0-main.397",
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",