universal-parcel-scraper 0.5.0-main.321 → 0.5.0-main.323

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 1,675 such statuses, each filed under one stage.
152
+ The carrier folders record 1,689 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.
@@ -3526,10 +3526,12 @@ function st(e) {
3526
3526
  if (/ready for (?:pickup|collection)|ready to collect|available for (?:pickup|collection)|awaiting (?:customer |recipient |your )?(?:pickup|collection)|pret.*(?:retrait|retire)|disponible.*(?:retrait|collecte)|vous attend (?:au|en|dans|chez|a)\b|mise? a disposition (?:au|en|dans|a)\b.*(?:point (?:de )?retrait|relais|consigne|locker|bureau de poste|agence)|jours? restants? pour (?:le )?retirer|abholbereit|zur abholung bereit|pront[oa] per il ritiro|disponibile per il ritiro|(?:deposited|depose|hinterlegt|depositat[oa]).*mypost24/.test(t) || /(?<!\b(?:no|nao|ya no|ja nao) (?:(?:esta|estara|se encuentra|se encontra|e|era|foi) )?)\bdisponi(?:ble|vel) (?:para (?:su |ser )?(?:recoger|recogida|recogid[oa]|retirar|retiro|retirada|retirad[oa]|levantamento|levantar|levantad[oa]|recolha|recolher)|(?:en|no|na|num|numa|em) (?:el |la |un |una |su |tu |nuestr[oa] )?(?:punto|oficina|tienda|locker|taquilla|consigna|citypaq|parcel ?shop|buzon inteligente|agencia|delegacion|sucursal|ponto|loja|cacifo|estacao|balcao|payshop|posto|unidade))\b|\blist[oa] para (?:su |ser )?(?:recoger|recogida|recogid[oa]|retirar|retiro)\b|\bpront[oa] (?:para|a) (?:ser )?(?:levantamento|levantar|levantad[oa]|recolha|recolher|retirada|retirar)\b|\b(?:ya )?(?:puede|puedes|podra|podras) (?:pasar a )?recoger(?:lo|la)?\b|\b(?:aguarda|aguardando|a aguardar) (?:o |a )?(?:levantamento|retirada)\b|\besperando (?:su|tu) recogida\b|\b(?:pendiente de|esperando|a la espera de) (?:ser )?recogid[oa] por (?:el |la )?destinatari[oa]\b/.test(t)) return "ready_for_pickup";
3527
3527
  if (/label (?:created|printed)|(?:created|generated) a (?:new )?(?:shipment |shipping )?label|etiquette.*(?:cree|imprime)|cree une etiquette|versandetikett.*(?:erstellt|gedruckt)|etikett erstellt|etichetta.*(?:creata|stampata)|creato un'etichetta|elektronisch|electroni(?:c|que|sch)|elettronic|pre.?advi[cs]|preannunciat|vorangemeldet|preannonce|data.*entered|donnees.*saisies|daten.*erfasst|dati.*inseriti/.test(t) || /shipment information (?:received|sent to)|\bdata received\b|(?:package|parcel|shipment) data (?:was |has been )?sent to|\bpre ?notification\b|informations d'expedition recues|sendungsinformationen erhalten|informazioni.*spedizione ricevute|(?:consignment|shipment|parcel|package) recorded by|envoi enregistre par|sendung.*absender.*erfasst|spedizione registrata dal/.test(t) || /preparation chez.*expediteur|preparation.*expediteur|preparazione.*mittente|being prepared.*sender|(?:shipper|sender)(?: that)? (?:they are|is|are) preparing|beim absender.*vorbereitet|warehouse of the sender|shippers warehouse|entrepot de l'expediteur|lager des absenders|magazzino del mittente|demande d'envoi.*prise en compte|collection request.*(?:received|recorded)|abholauftrag.*erfasst|richiesta.*ritiro.*registrata/.test(t) || /^(?:reported|recorded|announced|item created|enregistre|annonce|erfasst|angekundigt|registrato|annunciato)$/.test(t)) return "registered";
3528
3528
  if (/attend d'etre libere|attente de liberation|wartet auf.*freigabe|attesa di svincolo|(?:customs|clearance).*\bnot\b.*(?:clear|complet|releas)|dedouanement.*pas termine|customs (?:not cleared|clearance not completed)|not.*released by customs|dedouanement.*(?:non termine|pas termine)|(?:\bnot|n't|\bnever)(?: yet)?(?: been)? cleared (?:through |by )?customs|\bbe(?:ing)? cleared (?:through |by )?customs|non dedouane|zollabfertigung.*nicht abgeschlossen|nicht.*zoll.*freigegeben|sdoganamento.*non completato|non sdoganat/.test(t)) return "customs";
3529
- if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released by (?:customs|a government agency)|customs (?:cleared|released)|\bcleared (?:through |by )?customs|dedouanement.*(?:termine|acheve)|fin du dedouanement|libere.*(?:douane|autorite)|douane.*libere|zollabfertigung.*abgeschlossen|(?:zoll|behorde).*freigegeben|sdoganamento.*completato|completamento.*sdoganamento|svincolat.*(?:dogana|autorita)/.test(t)) return "in_transit";
3529
+ if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released by (?:customs|a government agency)|customs (?:cleared|(?:has |have )?released)|\bcleared (?:through |by )?customs|dedouanement.*(?:termine|acheve)|fin du dedouanement|libere.*(?:douane|autorite)|douane.*libere|zollabfertigung.*abgeschlossen|(?:zoll|behorde).*freigegeben|sdoganamento.*completato|completamento.*sdoganamento|svincolat.*(?:dogana|autorita)/.test(t)) return "in_transit";
3530
3530
  if (/customs|clearance|douan|formalites (?:d')?(?:import|export)|zoll|dogan|government agency|autorite gouvernementale|staatliche behorde|autorita governativa/.test(t)) return "customs";
3531
3531
  if (/out for (?:physical )?delivery|being delivered|in delivery|(?:loading|loaded).*delivery vehicle|on (?:\w+ )?vehicle for delivery|(?:courier|driver|delivery champion) has (?:the|your) (?:shipment|parcel|package|item)|en cours de livraison|en livraison|de la livraison de (?:son|votre) colis ce jour|charg(?:e|ement).*vehicule de livraison|in zustellung|zustellfahrzeug.*(?:geladen|verladen)|(?:beladen|verladung).*zustellfahrzeug|in consegna|caric(?:at|amento).*veicolo.*consegna/.test(t) || /^(?:en )?reparto$|\ben reparto\b|\b(?:siendo|sendo|a ser) (?:entregad[oa]s?|entregues?|repartid[oa]s?|distribuid[oa]s?)\b|\b(?:salida|salio|sale|ha salido) (?:a|para) (?:el )?reparto\b|\bruta de (?:reparto|entrega)\b|\ben proceso de entrega\b|\bvehiculo de reparto\b|\b(?:repartidor|mensajero|cartero|conductor) (?:ya )?(?:tiene|lleva) (?:tu|su|el|la) (?:paquete|envio|pedido|encomienda)\b|\bem distribuicao\b|\bsaiu para (?:a )?(?:entrega|distribuicao)\b|\bem (?:rota|processo|curso) de entrega\b|\bem entrega\b|\bveiculo de (?:entrega|distribuicao)\b|\b(?:carteiro|estafeta|motorista|entregador) (?:ja )?(?:tem|leva) (?:a|o) (?:sua |tua )?(?:encomenda|envio|objeto|pacote|volume)\b/.test(t)) return "out_for_delivery";
3532
3532
  if (/\bdelivered\b|^final delivery$|delivery complete(?:d)?\b|delivery (?:was )?successful|(?:a ete|est) distribue|^(?:livre|livree)(?:$|[ ,])|(?:colis|envoi|est|a ete) livre|zugestellt|\bconsegnat[oa]\b|livraison effectuee|consegna completata/.test(t) || !/\b(?:una vez|cuando|hasta que|antes de que|para que|en cuanto|tan pronto como|uma vez|quando|assim que|ate que|logo que)\b[^.;:]*\bentreg/.test(t) && /(?<!\b(?:sea|sean|fuera|fuese|ser|seja|sejam|fosse|for|forem|que|se) )\b(?:entregad[oa]s?|entregues?)\b|\bentrega (?:realizada|efectuada|efetuada|completada|concluida|finalizada)\b|\brecogid[oa] por (?:el |la )?destinatari[oa]\b|\b(?:levantad|retirad)[oa] pelo destinatario\b/.test(t)) return "delivered";
3533
+ if (/^(?:the |your )?(?:item|package|parcel|shipment) (?:has been |was |is )?(?:received for transport|dropped off by (?:the )?sender at (?:our |the )?postal partner)\b/.test(t)) return "accepted";
3534
+ if (/^(?:load vehicle|scan ok gateway)$/.test(t) || /^(?:the )?item in process in office of exchange\b/.test(t)) return "in_transit";
3533
3535
  if (/^(?:posting\/collection|posted|origin scan)$|received at (?:our |the )?(?:[a-z]+ )?origin (?:office|facility|hub)|dropped off (?:after|before) (?:the )?latest drop ?off time/.test(t) || /accepted|picked up|pick up was successful|^drop off$|pick ?up scan|prepared the package for return to ups or pickup by ups|handed (?:over )?to (?:dpd|gls)|package received at dhl|item booked|consignment was mailed|posted at a postal point|pris en charge|prise en charge|collecte.*(?:reussi|effectue)|recupere.*boite aux lettres|depose.*point postal|remis a (?:dpd|gls)|envoi.*depose|abholung.*erfolgreich|eingeliefert|an (?:dpd|gls) ubergeben|paket.*(?:angenommen|ubernommen)|ritiro.*(?:riuscito|effettuato)|pres[oa] in carico|affidat[oa] a (?:dpd|gls)|depositat.*punto postale|spedizione.*impostata/.test(t)) return "accepted";
3534
3536
  if (/transit|en route|on (?:its|the) way|\bfacility\b|airport|\bleaving\b|\bdispatched\b|^delay(?:ed)?$|received by (?:the )?(?:local )?(?:delivery|logistics) company|received by postnl|notification.*sent to the recipient|delivery option requested|instruction de livraison recue|expedie (?:depuis|du|de)\b|acheminement|unterwegs|transport|trasport|arriv|depart|processed|processing completed|sorted|sorting|\btri\b|trie|traitement|traite|sortier|bearbeitet|bearbeitung|elaborat|elaborazion|smistat|parcel cent(?:er|re)|delivery cent(?:er|re)|centre.*(?:colis|distribution)|paketzentrum|verteilzentrum|centro.*(?:pacchi|distribuzione)|depot|import scan|scan.*import|importscan|scansione.*import|delivery.*delayed|livraison.*retard|zustellung.*verzogert|consegna.*ritard|transferred|transfere|weitergeleitet|inoltrato|left.*(?:point|center)|quitte|verlassen|raggiunto|close bag|fermeture du sac|sack.*geschlossen|chiusura.*sacco|scanned into|scanne dans|eingescannt|scansionat|\bloaded\b|^(?:item|parcel|shipment) distributed$|charg.*vehicule de transport|transportfahrzeug.*geladen|caric.*veicolo.*trasporto/.test(t) || /^enviad[oa]$|\bclasificad[oa]s?\b|\bclasificacion\b|\ben ruta\b|\ben camino\b|\bllegad[oa]s? (?:a|al|en)\b|\bllegada (?:a|al|en|de)\b|\bha llegado\b|\bllego (?:a|al)\b|\bsalida (?:de|del|desde)\b|\b(?:ha )?salido (?:de|del)\b|\bsalio (?:de|del)\b|\ben destino\b|\b(?:agencia|delegacion|plataforma|oficina|franquicia) (?:de )?destino\b|\b(?:unidad|centro|oficina|delegacion|base|agencia|plataforma) de reparto\b|\bcentro (?:de )?(?:clasificacion|distribucion|tratamiento|logistico|operaciones|operativo)\b|\blectura\b|\bleid[oa]s?\b|\brecepcionad[oa]s?\b|\bprocesad[oa]s?\b|\bexpedid[oa]s?\b|\btriagem\b|\btriad[oa]s?\b|\bem tratamento\b|\btratad[oa]s?\b|\bcentro (?:de )?(?:distribuicao|tratamento|triagem|operacional)\b|\bchegad[ao] (?:a|ao|no|na|em)\b|\bchegou (?:a|ao|no|na|em)\b|\bsaida (?:do|da|de|dos|das)\b|\bsaiu (?:do|da|de|dos|das)\b|\bem rota\b|\ba caminho\b|\bencaminhad[oa]s?\b|\b(?:em )?transferencia\b|\btransferid[oa]s?\b|\bprocessad[oa]s?\b|\brecebid[oa] (?:no|na|em) (?:centro|armazem|unidade|plataforma)\b|\brecibid[oa] en (?:el |la |nuestr[oa] )?(?:almacen|centro|delegacion|plataforma|hub|agencia)\b/.test(t)) return "in_transit";
3535
3537
  }
@@ -10,7 +10,7 @@ export declare class DPDChallengeError extends ChallengeError {
10
10
  /**
11
11
  * The guest API answered, but the answer proves nothing about the shipment
12
12
  * (unreachable, malformed, unauthenticated, or an HTTP status the guest flow
13
- * handles itself). The rendered page is allowed to recover from it.
13
+ * handles itself, except HTTP 503). The rendered page is allowed to recover from it.
14
14
  */
15
15
  export declare class DPDAPIError extends IndeterminateError {
16
16
  constructor(message: string, options?: CarrierErrorOptions);
@@ -2,7 +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 { BudgetExceededError, carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
5
+ import { BudgetExceededError, carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
6
6
  import { runSteps } from '../../core/runner/index.js';
7
7
  import { isoTime, explicitOffsetTime, zonedTime } from '../../core/time/index.js';
8
8
  import { TrawlClient, decodeText, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
@@ -19,7 +19,8 @@ import { API_LABELS, PROOF_OF_DELIVERY_SCAN, apiStage, apiStatus, scanStage, wor
19
19
  // `continueWithoutVerification=true`. A rejected postcode (HTTP 400) is
20
20
  // retried once without verification and reported as unverified rather than
21
21
  // failing the lookup.
22
- // - The consignee web page is the fallback when the guest API is inconclusive.
22
+ // - The consignee web page is the fallback when the guest API is inconclusive,
23
+ // except HTTP 503: the service is down, irrespective of the parcel number.
23
24
  // It sits behind Cloudflare, so it is fetched through the browser service's
24
25
  // legacy command API when one is configured and directly otherwise.
25
26
  const TRACKING_BASE = 'https://www.dpdgroup.com/ch/mydpd/my-parcels/incoming';
@@ -54,7 +55,7 @@ export class DPDChallengeError extends ChallengeError {
54
55
  /**
55
56
  * The guest API answered, but the answer proves nothing about the shipment
56
57
  * (unreachable, malformed, unauthenticated, or an HTTP status the guest flow
57
- * handles itself). The rendered page is allowed to recover from it.
58
+ * handles itself, except HTTP 503). The rendered page is allowed to recover from it.
58
59
  */
59
60
  export class DPDAPIError extends IndeterminateError {
60
61
  constructor(message, options) {
@@ -778,6 +779,10 @@ export class DPDTracker {
778
779
  catch (error) {
779
780
  throw new DPDAPIError('DPD guest API is unreachable', { cause: error });
780
781
  }
782
+ // A 503 describes DPD's availability, so the page must not turn it into
783
+ // an answer about the parcel or hold up the caller's other lookups.
784
+ if (result.response.status === 503)
785
+ throw new UpstreamHttpError('DPD guest API', 503);
781
786
  if (!result.response.ok)
782
787
  throw new DPDAPIHttpError(result.response.status);
783
788
  let payload;
@@ -62,6 +62,7 @@ export function classifyIndiaPostEvent(...values) {
62
62
  'returnedfromcustoms',
63
63
  'releasedbyexportcustoms',
64
64
  'releasedbycustoms',
65
+ 'outofexportcustoms',
65
66
  ]))
66
67
  return { status: 'in_transit', stage: 'in_transit' };
67
68
  if (includesAny(key, ['customs', 'customclearance', 'customreceive'])) {
@@ -14,6 +14,7 @@
14
14
  { "code": "ItemDelivered", "wording": "Item Delivered", "language": "en", "stage": "delivered", "confirmedBy": "fixture", "firstSeen": "2026-09-01", "note": "Substring keys `itemdelivered`, `deliveredtorecipient`, `delivereddelivery`." },
15
15
  { "code": "DeliveryAttempted", "wording": "Delivery Attempted", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-01", "note": "Substring keys `deliveryattempted`, `deliveryfailed`, `notdelivered`, `undelivered`." },
16
16
  { "code": "InsufficientAddress", "wording": "Insufficient Address", "language": "en", "stage": "exception", "confirmedBy": "prior-art", "firstSeen": "2026-09-01", "note": "Substring keys `insufficientaddress`, `addresseecannotbelocated`, `damaged`, `refused`, `lost`. Reclassified to exception 2026-09-12." },
17
- { "code": "ReturnToSender", "wording": "Return To Sender", "language": "en", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-09-01", "note": "Substring keys `returntosender`, `returnedtocustomer`, `returnedtobookingoffice`, `returnitem`." }
17
+ { "code": "ReturnToSender", "wording": "Return To Sender", "language": "en", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-09-01", "note": "Substring keys `returntosender`, `returnedtocustomer`, `returnedtobookingoffice`, `returnitem`." },
18
+ {"wording": "Out of Export Customs", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04", "code": "ExportCustoms", "note": "ExportCustoms also covers entry; the exit label ends that step."}
18
19
  ]
19
20
  }
@@ -69,6 +69,10 @@ export function eventStatus(group, code, label, hasEvents) {
69
69
  const described = labelStatus(label, false);
70
70
  if (described === 'exception')
71
71
  return described;
72
+ if (code.toUpperCase() === 'MD1'
73
+ && comparable(label) === 'votre envoi est sur son site de distribution. nous le preparons pour le mettre en livraison.') {
74
+ return 'in_transit';
75
+ }
72
76
  return CODE_STATUSES.get(code.toLocaleUpperCase('en-US'))
73
77
  ?? GROUP_STATUSES.get(group.toLocaleUpperCase('en-US'))
74
78
  ?? (described !== 'unknown' ? described : labelStatus(label, hasEvents));
@@ -158,6 +158,15 @@
158
158
  "confirmedBy": "live",
159
159
  "firstSeen": "2026-09-20",
160
160
  "note": "Resolved by the shared classifier; \"sont terminees\" is the release and stays in_transit."
161
+ },
162
+ {
163
+ "wording": "Votre envoi est sur son site de distribution. Nous le préparons pour le mettre en livraison.",
164
+ "language": "fr",
165
+ "stage": "in_transit",
166
+ "confirmedBy": "fixture",
167
+ "firstSeen": "2026-10-04",
168
+ "code": "MD1",
169
+ "note": "DISTOU/MD1 also covers preparation before the delivery round."
161
170
  }
162
171
  ]
163
172
  }
@@ -22,7 +22,7 @@ export function postiStatus(main, subStatus) {
22
22
  /** Map each scan's own label, never the current parcel state or explanatory reason text. */
23
23
  export function postiEventStage(description) {
24
24
  const text = description.trim().toLowerCase();
25
- if (/^(?:the )?item has been delivered\.?$/.test(text))
25
+ if (/^(?:the )?item (?:has been delivered|delivered to the recipient)\.?$/.test(text))
26
26
  return 'delivered';
27
27
  if (/^(?:the )?item is ready for (?:a )?pick ?up\.?$/.test(text))
28
28
  return 'ready_for_pickup';
@@ -32,10 +32,14 @@ export function postiEventStage(description) {
32
32
  return 'returned';
33
33
  if (/^declare the item|^the item has been declared|^handling fee for item/.test(text))
34
34
  return 'customs';
35
+ if (/^(?:the )?item (?:has been registered|has arrived to destination country|is on the way to the recipient|is ready for delivery in destination country|in process in office of exchange|is on its way to the destination country)\.?$/.test(text))
36
+ return 'in_transit';
35
37
  if (/^(?:the )?item (?:is in (?:sorting|transport)|accepted from transport|has been released for delivery|arrived in the destination country|has departed from country of origin)/.test(text))
36
38
  return 'in_transit';
37
39
  if (/^(?:the )?item (?:has been received|has been accepted)/.test(text))
38
40
  return 'accepted';
41
+ if (/^(?:the )?item received for transport\.?$/.test(text))
42
+ return 'accepted';
39
43
  // Notifications, electronic pre-advice and unmapped wording prove no new movement.
40
44
  return 'pending';
41
45
  }
@@ -13,6 +13,14 @@
13
13
  { "wording": "Item is ready for a pick up", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "live", "firstSeen": "2026-09-21" },
14
14
  { "wording": "Item accepted from transport", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-09-21" },
15
15
  { "wording": "Item has been released for delivery.", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-09-21" },
16
- { "wording": "We sent the recipient an email about the item.", "language": "en", "stage": "pending", "confirmedBy": "live", "firstSeen": "2026-09-21", "note": "Notification alone proves no new movement." }
16
+ { "wording": "We sent the recipient an email about the item.", "language": "en", "stage": "pending", "confirmedBy": "live", "firstSeen": "2026-09-21", "note": "Notification alone proves no new movement." },
17
+ {"wording": "Item delivered to the recipient.", "language": "en", "stage": "delivered", "confirmedBy": "fixture", "firstSeen": "2026-10-04"},
18
+ {"wording": "Item has been registered", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04", "note": "Posti describes registration as repeated handling during transport, not electronic pre-advice."},
19
+ {"wording": "Item has arrived to destination country", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04"},
20
+ {"wording": "Item is on the way to the recipient", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04"},
21
+ {"wording": "Item is ready for delivery in destination country", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04", "note": "Readiness in the country does not establish the delivery round."},
22
+ {"wording": "Item in process in office of exchange.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04"},
23
+ {"wording": "The item is on its way to the destination country.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-10-04"},
24
+ {"wording": "Item received for transport", "language": "en", "stage": "accepted", "confirmedBy": "fixture", "firstSeen": "2026-10-04"}
17
25
  ]
18
26
  }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Every event carries a `category` token next to its localized
5
5
  * `status_description`. Categories are the primary signal. The English
6
- * "The item is out for delivery" label refines the overloaded Processing
7
- * category, which also covers sorting and acceptance.
6
+ * labels refine the overloaded Processing and Customs categories, which also
7
+ * cover sorting, acceptance and customs release.
8
8
  * Categories are matched case-insensitively after trimming, because the
9
9
  * international tracker has been seen returning them capitalized
10
10
  * ("Pre-advised") and lower case in the same history.
@@ -20,6 +20,14 @@ export { CATEGORY_STATUS as POSTNL_STATUS };
20
20
  /** The status and stage for one PostNL event category, or undefined when unmapped. */
21
21
  export function postNLStatus(category, description) {
22
22
  const code = (typeof category === 'string' ? category : '').trim().toLocaleLowerCase('en-US');
23
+ const label = (typeof description === 'string' ? description : '').trim().toLocaleLowerCase('en-US').replace(/[.!]+$/, '');
24
+ if (code === 'customs' && label === 'the item is released by customs') {
25
+ return { status: 'in_transit', stage: 'in_transit' };
26
+ }
27
+ if (code === 'processing' && ['the item has arrived at the domestic sorting centre',
28
+ 'the item is at the local sorting centre'].includes(label)) {
29
+ return { status: 'in_transit', stage: 'in_transit' };
30
+ }
23
31
  if (code === 'processing' && typeof description === 'string'
24
32
  && description.trim().toLocaleLowerCase('en-US') === 'the item is out for delivery') {
25
33
  return { status: 'out_for_delivery', stage: 'out_for_delivery' };
@@ -135,6 +135,30 @@
135
135
  "stage": "out_for_delivery",
136
136
  "confirmedBy": "live",
137
137
  "note": "Exact English description refines the overloaded Processing category."
138
+ },
139
+ {
140
+ "wording": "The item is released by customs",
141
+ "language": "en",
142
+ "stage": "in_transit",
143
+ "confirmedBy": "fixture",
144
+ "firstSeen": "2026-10-04",
145
+ "note": "The Customs category includes release; this exact label ends the customs step."
146
+ },
147
+ {
148
+ "wording": "The item has arrived at the domestic sorting centre",
149
+ "language": "en",
150
+ "stage": "in_transit",
151
+ "confirmedBy": "fixture",
152
+ "firstSeen": "2026-10-04",
153
+ "note": "The Processing category also covers handling after acceptance."
154
+ },
155
+ {
156
+ "wording": "The item is at the local sorting centre",
157
+ "language": "en",
158
+ "stage": "in_transit",
159
+ "confirmedBy": "fixture",
160
+ "firstSeen": "2026-10-04",
161
+ "note": "The Processing category also covers handling after acceptance."
138
162
  }
139
163
  ]
140
164
  }
@@ -23,6 +23,7 @@ const EXPRESS_CODES = {
23
23
  HW: { status: 'in_transit', stage: 'in_transit' },
24
24
  AS: { status: 'in_transit', stage: 'in_transit' },
25
25
  IR: { status: 'in_transit', stage: 'in_transit' },
26
+ RC: { status: 'in_transit', stage: 'in_transit' },
26
27
  OD: { status: 'out_for_delivery', stage: 'out_for_delivery' },
27
28
  OK: { status: 'delivered', stage: 'delivered' },
28
29
  LP: { status: 'exception', stage: 'exception' },
@@ -121,6 +121,14 @@
121
121
  "stage": "returned",
122
122
  "confirmedBy": "live",
123
123
  "firstSeen": "2026-09-28"
124
+ },
125
+ {
126
+ "code": "RC",
127
+ "wording": "Customs has released the goods",
128
+ "language": "en",
129
+ "stage": "in_transit",
130
+ "confirmedBy": "fixture",
131
+ "firstSeen": "2026-10-04"
124
132
  }
125
133
  ]
126
134
  }
@@ -108,7 +108,7 @@ export function trackingLanguageStage(description) {
108
108
  // Completion/release is distinct from pending or explicitly negated clearance.
109
109
  if (/attend d'etre libere|attente de liberation|wartet auf.*freigabe|attesa di svincolo|(?:customs|clearance).*\bnot\b.*(?:clear|complet|releas)|dedouanement.*pas termine|customs (?:not cleared|clearance not completed)|not.*released by customs|dedouanement.*(?:non termine|pas termine)|(?:\bnot|n't|\bnever)(?: yet)?(?: been)? cleared (?:through |by )?customs|\bbe(?:ing)? cleared (?:through |by )?customs|non dedouane|zollabfertigung.*nicht abgeschlossen|nicht.*zoll.*freigegeben|sdoganamento.*non completato|non sdoganat/.test(text))
110
110
  return 'customs';
111
- if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released by (?:customs|a government agency)|customs (?:cleared|released)|\bcleared (?:through |by )?customs|dedouanement.*(?:termine|acheve)|fin du dedouanement|libere.*(?:douane|autorite)|douane.*libere|zollabfertigung.*abgeschlossen|(?:zoll|behorde).*freigegeben|sdoganamento.*completato|completamento.*sdoganamento|svincolat.*(?:dogana|autorita)/.test(text))
111
+ if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released by (?:customs|a government agency)|customs (?:cleared|(?:has |have )?released)|\bcleared (?:through |by )?customs|dedouanement.*(?:termine|acheve)|fin du dedouanement|libere.*(?:douane|autorite)|douane.*libere|zollabfertigung.*abgeschlossen|(?:zoll|behorde).*freigegeben|sdoganamento.*completato|completamento.*sdoganamento|svincolat.*(?:dogana|autorita)/.test(text))
112
112
  return 'in_transit';
113
113
  if (/customs|clearance|douan|formalites (?:d')?(?:import|export)|zoll|dogan|government agency|autorite gouvernementale|staatliche behorde|autorita governativa/.test(text))
114
114
  return 'customs';
@@ -126,6 +126,12 @@ export function trackingLanguageStage(description) {
126
126
  && /(?<!\b(?:sea|sean|fuera|fuese|ser|seja|sejam|fosse|for|forem|que|se) )\b(?:entregad[oa]s?|entregues?)\b|\bentrega (?:realizada|efectuada|efetuada|completada|concluida|finalizada)\b|\brecogid[oa] por (?:el |la )?destinatari[oa]\b|\b(?:levantad|retirad)[oa] pelo destinatario\b/.test(text))
127
127
  return 'delivered';
128
128
  // Posting/collection by the carrier, not recipient pickup or data submission.
129
+ if (/^(?:the |your )?(?:item|package|parcel|shipment) (?:has been |was |is )?(?:received for transport|dropped off by (?:the )?sender at (?:our |the )?postal partner)\b/.test(text))
130
+ return 'accepted';
131
+ // A vehicle load and a gateway scan prove handling, but do not name the delivery round.
132
+ if (/^(?:load vehicle|scan ok gateway)$/.test(text)
133
+ || /^(?:the )?item in process in office of exchange\b/.test(text))
134
+ return 'in_transit';
129
135
  if (/^(?:posting\/collection|posted|origin scan)$|received at (?:our |the )?(?:[a-z]+ )?origin (?:office|facility|hub)|dropped off (?:after|before) (?:the )?latest drop ?off time/.test(text))
130
136
  return 'accepted';
131
137
  if (/accepted|picked up|pick up was successful|^drop off$|pick ?up scan|prepared the package for return to ups or pickup by ups|handed (?:over )?to (?:dpd|gls)|package received at dhl|item booked|consignment was mailed|posted at a postal point|pris en charge|prise en charge|collecte.*(?:reussi|effectue)|recupere.*boite aux lettres|depose.*point postal|remis a (?:dpd|gls)|envoi.*depose|abholung.*erfolgreich|eingeliefert|an (?:dpd|gls) ubergeben|paket.*(?:angenommen|ubernommen)|ritiro.*(?:riuscito|effettuato)|pres[oa] in carico|affidat[oa] a (?:dpd|gls)|depositat.*punto postale|spedizione.*impostata/.test(text))
@@ -43,6 +43,12 @@ function sourceEventStage(description, includeBroadMovement = true) {
43
43
  return 'registered';
44
44
  if (/prise en charge de votre colis sur notre site logistique/.test(french))
45
45
  return 'accepted';
46
+ // Posti's handling labels survive in aggregator histories. Registration is
47
+ // a repeated physical scan here, and the reason can describe future customs
48
+ // or delivery without making either the event's stage.
49
+ if (/^(?:the )?item (?:accepted from transport(?:[.!]?$|[.!]? this is a transportation marking\b)|has arrived to destination country(?:[.!]?$|[.!]? the item has reached (?:the target country|finland)\b)|is ready for delivery in destination country[.!]?$|in process in office of exchange(?:[.!]?$|[.!]? the item is being processed\b))/i.test(description)
50
+ || /^(?:the )?item has been registered(?:[.!]?$| the item can be registered several times during delivery\b)/i.test(description))
51
+ return 'in_transit';
46
52
  if (nonterminalEnglishReturn(description))
47
53
  return 'exception';
48
54
  if (/returned to (?:the )?sender/i.test(description))
@@ -76,7 +82,7 @@ function sourceEventStage(description, includeBroadMovement = true) {
76
82
  if (/out for delivery/i.test(description)
77
83
  && !/(?:once|when|as soon as) (?:the |your )?(?:shipment|parcel|package|item) is out for delivery/i.test(description))
78
84
  return 'out_for_delivery';
79
- if (/clearance (?:processing )?completed|customs (?:cleared|released)/i.test(description))
85
+ if (/clearance (?:processing )?completed|customs (?:cleared|released)|customs (?:has |have )released (?:the )?(?:goods|shipment|parcel|package|item)\b/i.test(description))
80
86
  return 'in_transit';
81
87
  if (/customs|clearance/i.test(description))
82
88
  return 'customs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.5.0-main.321",
3
+ "version": "0.5.0-main.323",
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",