universal-parcel-scraper 0.49.2-main.556 → 0.49.2-main.558

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
@@ -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,771 such statuses, each filed under one stage.
152
+ The carrier folders record 2,772 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.
@@ -64,6 +64,8 @@ export const CAINIAO_ACTION_STATUS = new Map([
64
64
  // Station signed, not the recipient — must never read as delivered.
65
65
  ['GTMS_STA_SIGNED', 'out_for_delivery'],
66
66
  ['GTMS_SIGNED', 'delivered'],
67
+ // The courier could not deliver and tries again: a failed attempt, below.
68
+ ['GTMS_DEL_FAILURE', 'exception'],
67
69
  ['GTMS_STA_SIGN_FAILURE', 'exception'],
68
70
  ['EXCEPTION', 'exception'],
69
71
  ]);
@@ -91,6 +93,10 @@ export function cainiaoActionStage(code) {
91
93
  return 'customs';
92
94
  if (code === 'PU_PICKUP_SUCCESS')
93
95
  return 'accepted';
96
+ // Cainiao's last-mile gateway words it "Delivery Attempt Failure" in its
97
+ // `delivery_failed` group (seen live through Ecoscooting, another client).
98
+ if (code === 'GTMS_DEL_FAILURE')
99
+ return 'failed_attempt';
94
100
  const status = CAINIAO_ACTION_STATUS.get(code);
95
101
  return status ? cainiaoStageByStatus()[status] : undefined;
96
102
  }
@@ -381,6 +381,13 @@
381
381
  "firstSeen": "2026-09-12",
382
382
  "note": "The only code that means the recipient has the parcel."
383
383
  },
384
+ {
385
+ "code": "GTMS_DEL_FAILURE",
386
+ "stage": "failed_attempt",
387
+ "confirmedBy": "live",
388
+ "firstSeen": "2026-10-08",
389
+ "note": "Seen live through Ecoscooting, a client of the same Cainiao last-mile gateway, as \"Delivery Attempt Failure\" with a reason such as the recipient not at home. No AliExpress reply has carried it yet."
390
+ },
384
391
  {
385
392
  "code": "GTMS_STA_SIGN_FAILURE",
386
393
  "stage": "exception",
@@ -0,0 +1,4 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ /** Whether an office label names one of Chronopost's services rather than a place. */
3
+ export declare function isChronopostService(label: string): boolean;
4
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,15 @@
1
+ import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
2
+ // Office labels that name a Chronopost service, not where the scan happened:
3
+ // the shipper's labelling system on preparation scans, the notification
4
+ // service on SMS and e-mail notices, and the partner network abroad.
5
+ const SERVICES = new Set(['web services', "service d'avisage", 'chronopost networks']);
6
+ /** Whether an office label names one of Chronopost's services rather than a place. */
7
+ export function isChronopostService(label) {
8
+ return SERVICES.has(label.replace(/\s+/g, ' ').trim().replace(/’/g, "'").toLocaleLowerCase('fr-FR'));
9
+ }
10
+ // Scans stored with a service as their location lose it and keep their row.
11
+ export const sameInstantIdentityPolicy = {
12
+ sourceCarrierId: 'chronopost', storedSources: ['chronopost'], requireProviderCode: false,
13
+ matchEachScan: true,
14
+ matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isChronopostService),
15
+ };
@@ -5,6 +5,7 @@ import { languageStageStatus } from '../../core/status/index.js';
5
5
  import { calendarDay, countryCode, explicitOffsetTime } from '../../core/time/index.js';
6
6
  import { clean } from '../../core/transport/index.js';
7
7
  import { xmlDocument } from '../../core/transport/xml.js';
8
+ import { isChronopostService } from './identity.js';
8
9
  import { chronopostStage, isChronopostNotification, isPickupDropOff } from './status.js';
9
10
  export const CHRONOPOST_MAX_BYTES = 2_000_000;
10
11
  const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
@@ -78,14 +79,17 @@ const clockDay = (event) => {
78
79
  };
79
80
  /** Stages on the way to a planned delivery. */
80
81
  const ONGOING = new Set(['pending', 'registered', 'accepted', 'in_transit', 'customs', 'out_for_delivery']);
81
- /** Keep operational places, never the delivery-point address or shop contact block. */
82
+ /**
83
+ * Keep operational places, never the delivery-point address or shop contact block,
84
+ * nor an office label that names a service ("Web Services", "Service d'avisage").
85
+ */
82
86
  function location(office, place) {
83
87
  if (/^[\p{L}\p{M} .,'’()-]{1,100} - [A-Z]{2}(?: \(depot \d{1,6}\))?$/u.test(place))
84
88
  return place;
85
89
  const shop = /^([\p{L}\p{M} .,'’-]{1,100}) - ([A-Z]{2}) - /u.exec(office);
86
90
  if (shop)
87
91
  return `${shop[1]}, ${shop[2]}`;
88
- return /^[\p{L}\p{M} .,'’()/-]{1,160}$/u.test(office) ? office : '';
92
+ return /^[\p{L}\p{M} .,'’()/-]{1,160}$/u.test(office) && !isChronopostService(office) ? office : '';
89
93
  }
90
94
  export function parseChronopostTrackingXml(xml, rawNumber) {
91
95
  const requested = normalizeChronopostNumber(rawNumber);
@@ -5,6 +5,7 @@ import { eventPoint } from '../../core/result/index.js';
5
5
  import { runSteps } from '../../core/runner/index.js';
6
6
  import { zonedTime } from '../../core/time/index.js';
7
7
  import { TrawlClient, clean, decodeText, fetchBounded } from '../../core/transport/index.js';
8
+ import { isDeliveryStatusCell } from './identity.js';
8
9
  import { classifyStatus, comparableText, includesAny } from './status.js';
9
10
  // Protocol provenance:
10
11
  // - DPD France publishes a server-rendered recipient page rather than a
@@ -146,7 +147,9 @@ export function parseDPDFranceTrackingHtml(html, rawTrackingNumber) {
146
147
  const date = clean(cells.eq(0).text(), 32);
147
148
  const clock = clean(cells.eq(1).text(), 32);
148
149
  const description = clean(cells.eq(2).text());
149
- const location = clean(cells.eq(3).text());
150
+ const cell = clean(cells.eq(3).text());
151
+ // The delivered row's cell repeats the delivery, which is no place.
152
+ const location = isDeliveryStatusCell(cell) ? '' : cell;
150
153
  const time = parsedEventTime(date, clock);
151
154
  if (!time || !description)
152
155
  return;
@@ -0,0 +1,4 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ /** Whether a row's location cell states the delivery instead of a depot: "Livré au destinataire". */
3
+ export declare function isDeliveryStatusCell(location: string): boolean;
4
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,12 @@
1
+ import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
2
+ import { comparableText } from './status.js';
3
+ /** Whether a row's location cell states the delivery instead of a depot: "Livré au destinataire". */
4
+ export function isDeliveryStatusCell(location) {
5
+ return comparableText(location) === 'livre au destinataire';
6
+ }
7
+ // Scans stored with that status as their location lose it and keep their row.
8
+ export const sameInstantIdentityPolicy = {
9
+ sourceCarrierId: 'dpd-fr', storedSources: ['dpd-fr'], requireProviderCode: false,
10
+ matchEachScan: true,
11
+ matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isDeliveryStatusCell),
12
+ };
@@ -1,12 +1,14 @@
1
1
  import { sameInstantIdentityPolicy as aliexpress } from '../../carriers/aliexpress/app.js';
2
2
  import { sameInstantIdentityPolicy as australiaPost } from '../../carriers/australia-post/app.js';
3
+ import { sameInstantIdentityPolicy as chronopost } from '../../carriers/chronopost/identity.js';
3
4
  import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
5
+ import { sameInstantIdentityPolicy as dpdFrance } from '../../carriers/dpd-fr/identity.js';
4
6
  import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
5
7
  import { sameInstantIdentityPolicy as mondialRelay } from '../../carriers/mondial-relay/identity.js';
6
8
  import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
7
9
  import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
8
10
  import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
9
- const policies = new Map([aliexpress, australiaPost, dpd, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
11
+ const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
10
12
  /** Unlisted sources cannot identify a reworded scan by its instant alone. */
11
13
  export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
12
14
  const policy = policies.get(sourceCarrierId);
@@ -1,3 +1,9 @@
1
1
  import type { SameInstantScan } from './eventIdentity.js';
2
2
  /** Adding a place must keep the scan's wording and known stage; conflicting places do not match. */
3
3
  export declare function matchesLocationEnrichment(incoming: SameInstantScan, stored: SameInstantScan): boolean;
4
+ /**
5
+ * A scan whose adapter now drops the text it once gave as the location, a
6
+ * service's name or a status that is no place, must keep its wording and
7
+ * known stage. Only a stored location `dropped` names matches.
8
+ */
9
+ export declare function matchesDroppedLocation(incoming: SameInstantScan, stored: SameInstantScan, dropped: (location: string) => boolean): boolean;
@@ -1,10 +1,22 @@
1
1
  const normalized = (value) => value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
2
+ /** The same wording and the same known stage. */
3
+ function sameScan(incoming, stored) {
4
+ const wording = normalized(incoming.description);
5
+ return Boolean(wording) && !['', 'unknown'].includes(incoming.stage)
6
+ && incoming.stage === stored.stage && wording === normalized(stored.description);
7
+ }
2
8
  /** Adding a place must keep the scan's wording and known stage; conflicting places do not match. */
3
9
  export function matchesLocationEnrichment(incoming, stored) {
4
- const wording = normalized(incoming.description);
5
10
  const location = normalized(incoming.location);
6
11
  const savedLocation = normalized(stored.location);
7
- return Boolean(location && wording) && !['', 'unknown'].includes(incoming.stage)
8
- && incoming.stage === stored.stage && wording === normalized(stored.description)
9
- && (!savedLocation || location === savedLocation);
12
+ return Boolean(location) && sameScan(incoming, stored) && (!savedLocation || location === savedLocation);
13
+ }
14
+ /**
15
+ * A scan whose adapter now drops the text it once gave as the location, a
16
+ * service's name or a status that is no place, must keep its wording and
17
+ * known stage. Only a stored location `dropped` names matches.
18
+ */
19
+ export function matchesDroppedLocation(incoming, stored, dropped) {
20
+ const savedLocation = stored.location.trim();
21
+ return !normalized(incoming.location) && Boolean(savedLocation) && dropped(savedLocation) && sameScan(incoming, stored);
10
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.2-main.556",
3
+ "version": "0.49.2-main.558",
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",