universal-parcel-scraper 0.637.1 → 0.638.0-main.3012

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
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 16,380 such statuses, each filed under one stage.
158
+ The carrier folders record 16,387 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -29850,7 +29850,7 @@ function Hc(e) {
29850
29850
  if (sc(t) || /\b(?:en|em) (?:proceso de |processo de )?devolu(?:cion|cao)\b|^devolu(?:cion|cao)$|\bdevolu(?:cion|cao) (?:en curso|em curso|iniciada|solicitada|en transito|em transito)\b|\bdevolu(?:cion|cao) (?:al|ao|a la|a) (?:remitente|remetente|origen|origem)\b(?! (?:completad|finalizad|concluid|entregad|entregue|realizad|efectuad|efetuad))|\ben camino (?:de vuelta |de regreso )?(?:al|hacia el) remitente\b|\b(?:a caminho (?:de volta )?(?:do|ao)|em transito para o) remetente\b|\b(?:siendo|sendo|a ser) (?:(?:devuelt|devolvid)[oa]s?|entregad[oa]s? al remitente|entregues? ao remetente)\b|\b(?:no|nao) (?:(?:ha|han|se|fue|foi|foram|sido|ser|sera|puede|pode|pudo|podido|todavia|aun|ainda|ya|ja) )*(?:(?:devuelt|devolvid)[oa]s?|entregad[oa]s? al remitente|entregues? ao remetente)\b|\b(?:sera|seran|serao|va a ser|vai ser|puede ser|pode ser|debe ser|deve ser|devera ser) (?:(?:pronto|proximamente|en breve|em breve|brevemente) )?(?:(?:devuelt|devolvid)[oa]s?|entregad[oa]s? al remitente|entregues? ao remetente)\b|\bse (?:le )?devolvera\b/.test(t)) return "exception";
29851
29851
  if (/returned to (?:the )?sender|retour(?:ne)? a l'expediteur|zuruck an (?:den )?absender|an (?:den )?absender zuruck|retour a l'expediteur|reso al mittente|restituit[oa] al mittente|delivered (?:back )?to (?:the )?(?:sender|shipper)/.test(t) || /^(?:(?:envio|paquete|pedido|objeto|encomenda|pacote|remessa) )?(?:devuelt|devolvid)[oa]$|\b(?:devuelt|devolvid|retornad)[oa]s? (?:al|ao|a la|a) (?:remitente|remetente|origen|origem)\b|\bentregad[oa]s? al remitente\b|\bentregues? ao remetente\b|\bdevolu(?:cion|cao) (?:(?:al|ao|a la|a) (?:remitente|remetente|origen|origem) )?(?:completad|finalizad|concluid|entregad|entregue|realizad|efectuad|efetuad)/.test(t)) return "returned";
29852
29852
  if (/^(?:a )?(?:new|next|another|further) (?:delivery )?attempt\b/.test(t) && !/\b(?:not|unable|fail(?:ed|ure)?|unsuccessful|missed|absent|closed|refused|nobody|no one)\b|n't\b/.test(t)) return "in_transit";
29853
- if (/not (?:yet )?delivered|\bun ?delivered\b|(?:could not|couldn't).*deliver|\bcan(?: ?not|'t) be delivered\b|unable to deliver|delivery (?:attempt|failed)|non livre|n'(?:a|avons) (?:pas )?pu.*(?:remis|remettre)|n'a pas pu etre (?:distribue|livre)|\bne (?:peut|pourra|pourrait|pouvait) (?:pas )?etre (?:distribue|livre)|livraison (?:impossible|echouee)|\bechec (?:de (?:la )?)?livraison|tentative de livraison|nicht zugestellt|nicht zugestellt werden|zustellung.*(?:fehlgeschlagen|nicht moglich)|zustellversuch|non consegnat[oa]|\bmancata consegna\b|non e stato possibile consegnare|consegna (?:fallita|non riuscita)|tentativo di consegna/.test(t) || /\bno (?:(?:ha|han|hemos|se|le|lo|la|fue|sido|ser|es|era|pudo|puede|podido|posible|todavia|aun) )*(?:entregad[oa]s?|entregar(?:lo|la|le|se)?|repartid[oa]s?|realizar la entrega|efectuar la entrega)\b|\bsin entregar\b|\b(?:entrega|reparto) (?:fallid[oa]|imposible|no (?:(?:ha|han|se|fue|sido|ser|pudo|puede|podido) )*(?:realizad[oa]|efectuad[oa]|completad[oa]|posible|conseguid[oa]))\b|\bimposible (?:entregar|realizar la entrega|efectuar la entrega)\b|\bfallo (?:en la|de) entrega\b|\bintento fallido\b|\bintento de (?:entrega|reparto)\b(?! (?:programad|previst|para (?:hoy|manana|el)|manana|hoy))|\bnao (?:(?:foi|foram|ser|sera|pode|podemos|conseguimos|conseguiu|se|possivel|e|ainda|tem sido|esta|estava) )*(?:entregues?|entregar|efetuar a entrega|efectuar a entrega|realizar a entrega)\b|\bentrega (?:falhada|sem sucesso|impossivel|nao (?:(?:foi|pode|ser|sera|tem sido) )*(?:efetuada|efectuada|realizada|conseguida|concretizada|possivel))\b|\bfalha (?:na|de) entrega\b|\btentativa (?:de entrega|sem sucesso)\b(?! (?:agendada|prevista|programada|para (?:hoje|amanha)|amanha|hoje))|\bcarteiro nao atendido\b/.test(t) || !/\bpick ?up (?:attempt|was attempted|unsuccessful|failed)|attempted (?:to )?pick ?up/.test(t) && /(?:attempted|tried) to deliver|\bcard(?:ed| left)\b|\bwe(?: have|'ve)? missed (?:you|each other)\b(?![^.;!?]*\bready for (?:pick ?up|collection)\b)|\battempted delivery\b(?! (?:is )?(?:scheduled|planned|expected|tomorrow|will)\b)|unsuccessful delivery|delivery (?:was )?unsuccessful|unable to complete (?:the |your |this )?delivery|(?<!\bif (?:the |your )?)\b(?:recipient|addressee|consignee|customer|receiver|user)(?: was| is)? (?:not (?:at home|home|available|present)|unavailable|absent)\b|absence of (?:the )?(?:recipient|addressee|consignee)|\bbusiness (?:was |is )?closed\b|\bpremises (?:(?:was|were) )?closed\b|destinataire absent|(?<!\ben cas d')\babsence du destinataire|(?:entreprise|societe) (?:etait |est )?fermee?\b|nicht angetroffen|(?:firma|betrieb|geschaft) (?:war |ist )?geschlossen|destinatario assente|(?:attivita|azienda|ditta) (?:era |e )?chiusa/.test(t) || !/\bintento de recogida\b|\brecogida (?:fallida|no realizada|no efectuada)\b|\btentativa de (?:recolha|coleta)\b|\b(?:recolha|coleta) (?:falhada|sem sucesso|nao (?:realizada|efetuada|efectuada))\b/.test(t) && !/\b(?:si|caso)\b[^.;:]*\b(?:ausen|nadie|ninguem|cerrad|fechad|no (?:esta|se encuentra)|nao (?:esta|se encontra))|\bse (?:o |a )?(?:destinatari[oa] |cliente )?(?:estiver|for|nao estiver)\b/.test(t) && /\bausen(?:te|tes|cia)\b|\b(?:destinatari[oa]|cliente|consignatari[oa]) (?:no (?:esta|estaba|se encuentra|se encontraba|disponible|presente)|nao (?:esta|estava|se encontra|se encontrava|disponivel|presente))\b|\bnadie en (?:casa|el domicilio)\b|\bninguem em casa\b|\b(?:establecimiento|negocio|empresa|comercio|tienda) (?:esta |estaba |se encontraba )?cerrad[oa]\b|\b(?:estabelecimento|empresa|loja|comercio|negocio) (?:esta |estava |se encontrava )?fechad[oa]\b/.test(t)) return "failed_attempt";
29853
+ if (/not (?:yet )?delivered|\bun ?delivered\b|(?:could not|couldn't).*deliver|\bcan(?: ?not|'t) be delivered\b|unable to deliver|delivery (?:attempt|failed)|non livre|n'(?:a|avons) (?:pas )?pu.*(?:remis|remettre)|n'a pas pu etre (?:distribue|livre)|\bne (?:peut|pourra|pourrait|pouvait) (?:pas )?etre (?:distribue|livre)|livraison (?:impossible|echouee)|\bechec (?:de (?:la )?)?livraison|tentative de livraison|nicht zugestellt|nicht zugestellt werden|zustellung.*(?:fehlgeschlagen|nicht moglich)|zustellversuch|non consegnat[oa]|\bmancata consegna\b|non e stato possibile consegnare|consegna (?:fallita|non riuscita)|tentativo di consegna/.test(t) || /\bno (?:(?:ha|han|hemos|se|le|lo|la|fue|sido|ser|es|era|pudo|puede|podido|posible|todavia|aun) )*(?:entregad[oa]s?|entregar(?:lo|la|le|se)?|repartid[oa]s?|realizar la entrega|efectuar la entrega)\b|\bsin entregar\b|\b(?:entrega|reparto) (?:fallid[oa]|imposible|no (?:(?:ha|han|se|fue|sido|ser|pudo|puede|podido) )*(?:realizad[oa]|efectuad[oa]|completad[oa]|posible|conseguid[oa]))\b|\bimposible (?:entregar|realizar la entrega|efectuar la entrega)\b|\bfallo (?:en la|de) entrega\b|\bintento fallido\b|\bintento de (?:entrega|reparto)\b(?! (?:programad|previst|para (?:hoy|manana|el)|manana|hoy))|\bnao (?:(?:foi|foram|ser|sera|pode|podemos|conseguimos|conseguiu|se|possivel|e|ainda|tem sido|esta|estava) )*(?:entregues?|entregar|efetuar a entrega|efectuar a entrega|realizar a entrega)\b|\bentrega (?:falhada|sem sucesso|impossivel|nao (?:(?:foi|pode|ser|sera|tem sido) )*(?:efetuada|efectuada|realizada|conseguida|concretizada|possivel))\b|\bfalha (?:na|de) entrega\b|\btentativa (?:de entrega|sem sucesso)\b(?! (?:agendada|prevista|programada|para (?:hoje|amanha)|amanha|hoje))|\bcarteiro nao atendido\b/.test(t) || !/\bpick ?up (?:attempt|was attempted|unsuccessful|failed)|attempted (?:to )?pick ?up/.test(t) && /(?:attempted|tried) to deliver|\bcard(?:ed| left)\b|\bwe(?: have|'ve)? missed (?:you|each other)\b(?![^.;!?]*\bready for (?:pick ?up|collection)\b)|\battempted delivery\b(?! (?:is )?(?:scheduled|planned|expected|tomorrow|will)\b)|unsuccessful delivery|delivery (?:was )?(?:un|not )successful|unable to complete (?:the |your |this )?delivery|(?<!\bif (?:the |your )?)\b(?:recipient|addressee|consignee|customer|receiver|user)(?: was| is)? (?:not (?:at home|home|available|present)|unavailable|absent)\b|absence of (?:the )?(?:recipient|addressee|consignee)|\bbusiness (?:was |is )?closed\b|\bpremises (?:(?:was|were) )?closed\b|destinataire absent|(?<!\ben cas d')\babsence du destinataire|(?:entreprise|societe) (?:etait |est )?fermee?\b|nicht angetroffen|(?:firma|betrieb|geschaft) (?:war |ist )?geschlossen|destinatario assente|(?:attivita|azienda|ditta) (?:era |e )?chiusa/.test(t) || !/\bintento de recogida\b|\brecogida (?:fallida|no realizada|no efectuada)\b|\btentativa de (?:recolha|coleta)\b|\b(?:recolha|coleta) (?:falhada|sem sucesso|nao (?:realizada|efetuada|efectuada))\b/.test(t) && !/\b(?:si|caso)\b[^.;:]*\b(?:ausen|nadie|ninguem|cerrad|fechad|no (?:esta|se encuentra)|nao (?:esta|se encontra))|\bse (?:o |a )?(?:destinatari[oa] |cliente )?(?:estiver|for|nao estiver)\b/.test(t) && /\bausen(?:te|tes|cia)\b|\b(?:destinatari[oa]|cliente|consignatari[oa]) (?:no (?:esta|estaba|se encuentra|se encontraba|disponible|presente)|nao (?:esta|estava|se encontra|se encontrava|disponivel|presente))\b|\bnadie en (?:casa|el domicilio)\b|\bninguem em casa\b|\b(?:establecimiento|negocio|empresa|comercio|tienda) (?:esta |estaba |se encontraba )?cerrad[oa]\b|\b(?:estabelecimento|empresa|loja|comercio|negocio) (?:esta |estava |se encontrava )?fechad[oa]\b/.test(t)) return "failed_attempt";
29854
29854
  if (jc.test(t)) return Mc.test(t) ? "ready_for_pickup" : "in_transit";
29855
29855
  if (/\ba request was made to change (?:this|the|your) delivery date\b/.test(t) || /^(?:the )?delivery (?:details|information) (?:(?:has|have) been |were |was )?updated$/.test(t) || /^(?:package|parcel) shipment$/.test(t)) return "in_transit";
29856
29856
  if (/\b(?:damaged|broken in transit)\b|endommag|avarie|deterior|beschadigt|danneggiat|danad[oa]|danificad|uszkodzon/.test(t) || /\blost (?:in transit|package|parcel|shipment)?\b|colis perdu|envoi perdu|egare|verloren|verlust der sendung|smarrit|(?:paquete|envio) perdid|extraviad|zagubion|zaginion/.test(t) || /refused by (?:the )?(?:recipient|consignee)|\brefused\b|rejected by (?:the )?recipient|refus(?:e|ee)? par le destinataire|refus du destinataire|(?:colis|envoi|pli) refuse|annahme verweigert|verweigert|rifiutat|rechazad|recusad|odmowa przyjecia|odmowiono przyjecia/.test(t) || /address (?:incomplete|incorrect|invalid|insufficient|unknown)|(?:incorrect|incomplete|insufficient|wrong|invalid) address|(?:updated|correct(?:ed)?|complete) (?:delivery )?address (?:is )?(?:required|needed)|address(?:ee)? (?:unknown|cannot be located)|recipient unknown|adresse (?:incorrecte|incomplete|erronee|invalide|inconnue)|destinataire inconnu|(?:adresse|anschrift) (?:unvollstandig|falsch|unbekannt)|empfanger unbekannt|indirizzo (?:errato|incompleto|insufficiente|sconosciuto)|destinatario sconosciuto|direccion (?:incorrecta|incompleta|erronea|desconocida)|destinatario desconocido|endereco (?:incorreto|incompleto|errado|desconhecido)|destinatario desconhecido|adres (?:niepelny|nieprawidlowy|bledny)|nieznany adresat/.test(t)) return "exception";
@@ -1,2 +1,5 @@
1
1
  import type { ClassifiedStatus } from '../../core/status/index.js';
2
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
2
3
  export declare function evriStatus(wording: string): ClassifiedStatus | undefined;
4
+ /** What the map says about one scan: Evri's labels carry no code. */
5
+ export declare const statusMap: CarrierStatusMap;
@@ -10,6 +10,7 @@ const STATUSES = {
10
10
  'in transit': { status: 'in_transit', stage: 'in_transit' },
11
11
  'in customs': { status: 'in_transit', stage: 'customs' },
12
12
  'customs cleared': { status: 'in_transit', stage: 'in_transit' },
13
+ 'departed origin country': { status: 'in_transit', stage: 'in_transit' },
13
14
  'arrived in country': { status: 'in_transit', stage: 'in_transit' },
14
15
  'handed to partner carrier': { status: 'in_transit', stage: 'in_transit' },
15
16
  'received by carrier': { status: 'in_transit', stage: 'in_transit' },
@@ -28,3 +29,5 @@ export function evriStatus(wording) {
28
29
  const key = wording.trim().toLowerCase();
29
30
  return Object.hasOwn(STATUSES, key) ? STATUSES[key] : undefined;
30
31
  }
32
+ /** What the map says about one scan: Evri's labels carry no code. */
33
+ export const statusMap = { stage: (_code, wording) => evriStatus(wording)?.stage, gaps: [] };
@@ -129,6 +129,14 @@
129
129
  "firstSeen": "2026-09-26",
130
130
  "note": "Synthetic semantic boundary; not observed in the live reference."
131
131
  },
132
+ {
133
+ "wording": "Departed Origin Country",
134
+ "language": "en",
135
+ "stage": "in_transit",
136
+ "confirmedBy": "live",
137
+ "firstSeen": "2026-10-22",
138
+ "note": "After export customs and before the arrival in the destination country."
139
+ },
132
140
  {
133
141
  "wording": "Arrived In Country",
134
142
  "language": "en",
@@ -2,7 +2,7 @@ import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/e
2
2
  import { calendarDay, explicitOffsetTime } from '../../core/time/index.js';
3
3
  import { clean } from '../../core/transport/index.js';
4
4
  import { isRecord } from '../../core/types.js';
5
- import { classifySeurStatus } from './status.js';
5
+ import { classifySeurStatus, isSeurDelay } from './status.js';
6
6
  export function normalizeSeurNumber(raw) {
7
7
  const number = raw.replace(/\s/g, '');
8
8
  // Twenty-two digits is a sender's reference, which the lookup also finds, as
@@ -84,14 +84,25 @@ export function parseSeur(value, raw) {
84
84
  return { description: mapped?.status === 'delivered' ? 'Delivered' : label, provider_status: label,
85
85
  provider_group: group, provider_code: code, ...(mapped ? { stage: mapped.stage } : {}), ...scanTime(row.fecha) };
86
86
  });
87
+ const delay = (event) => isSeurDelay(event.provider_code, String(event.provider_group), String(event.provider_status));
88
+ // Oldest first: a delay notice keeps the stage of the scan before it.
89
+ let previous;
90
+ for (const event of [...events].reverse()) {
91
+ if (!delay(event))
92
+ previous = event.stage ?? previous;
93
+ else if (previous)
94
+ Object.assign(event, { stage: previous, stage_source: 'none' });
95
+ }
87
96
  // The official widget consumes situaciones[0]; unresolved clocks retain that position.
97
+ // Its status is the newest scan's other than a delay notice.
88
98
  const latest = events[0];
89
- const mapped = classifySeurStatus(latest.provider_code, String(latest.provider_group), String(latest.provider_status));
99
+ const current = events.find((event) => !delay(event));
100
+ const mapped = current && classifySeurStatus(current.provider_code, String(current.provider_group), String(current.provider_status));
90
101
  const weight = value.peso;
91
102
  return { status: mapped?.status ?? 'unknown', ...(mapped ? { current_stage: mapped.stage } : {}),
92
103
  last_status_text: latest.description, last_update: latest.time ?? null,
93
104
  ...(latest.local_time ? { last_update_local: latest.local_time } : {}),
94
- ...(mapped?.status === 'delivered' && latest.time ? { delivered_at: latest.time } : {}),
105
+ ...(mapped?.status === 'delivered' && current?.time ? { delivered_at: current.time } : {}),
95
106
  ...(typeof weight === 'number' && Number.isFinite(weight) && weight > 0 && weight <= 100_000 ? { weight_kg: weight } : {}),
96
107
  events: events.slice(0, 100) };
97
108
  }
@@ -1,4 +1,9 @@
1
1
  import type { ClassifiedStatus } from '../../core/status/index.js';
2
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
2
3
  export declare function classifySeurStatus(code: string, group: string, wording: string): ClassifiedStatus | undefined;
4
+ /** Whether the scan is one of SEUR's delay notices, which keep the stage the parcel had. */
5
+ export declare function isSeurDelay(code: string, group: string, wording: string): boolean;
3
6
  /** The status group SEUR sent with an observed code. */
4
7
  export declare function seurStatusGroup(code: string): string | undefined;
8
+ /** What the map says about one scan, read with the group SEUR sends with its code. */
9
+ export declare const statusMap: CarrierStatusMap;
@@ -1,18 +1,21 @@
1
1
  const delivered = { status: 'delivered', stage: 'delivered' };
2
2
  const inTransit = { status: 'in_transit', stage: 'in_transit' };
3
3
  const exception = { status: 'exception', stage: 'exception' };
4
+ // A null reading is a delay notice, not a movement: the scan keeps the stage of the one before it.
4
5
  const observed = {
5
6
  LL003: { group: 'ENTREGADO', wording: 'EL ENVÍO ESTÁ ENTREGADO.', mapped: delivered },
6
7
  LL010: { group: 'ENTREGADO', wording: 'EL DESTINATARIO HA RETIRADO EL ENVÍO DE LA TIENDA SEUR PICKUP SELECCIONADA.', mapped: delivered },
7
8
  LL020: { group: 'ENTREGADO', wording: 'EL ENVÍO HA SIDO ENTREGADO A UN VECINO.', mapped: delivered },
8
9
  LC003: { group: 'EN REPARTO', wording: 'EL ENVÍO ESTÁ EN REPARTO.', mapped: { status: 'out_for_delivery', stage: 'out_for_delivery' } },
9
- LI300: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO Y ES POSIBLE QUE SE DEMORE LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: exception },
10
- LI582: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO PROVOCANDO UNA POSIBLE DEMORA EN LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: exception },
10
+ LI300: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO Y ES POSIBLE QUE SE DEMORE LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: null },
11
+ LI582: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO PROVOCANDO UNA POSIBLE DEMORA EN LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: null },
11
12
  // Recorded after the day's delivery round, which starts again the next day.
12
13
  LI521: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO PORQUE LA DIRECCIÓN DE ENTREGA ESTÁ INCOMPLETA O ERRÓNEA.', mapped: { status: 'exception', stage: 'failed_attempt' } },
13
14
  LI523: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO POR AUSENCIA O CIERRE. SE PUEDE REPROGRAMAR UNA NUEVA ENTREGA.', mapped: { status: 'exception', stage: 'failed_attempt' } },
14
15
  LI524: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO PORQUE NO ES LA DIRECCIÓN ACTUAL DEL DESTINATARIO.', mapped: exception },
15
16
  LI530: { group: 'ENTREGA EN TIENDA', wording: 'EL ENVÍO ESTÁ DISPONIBLE PARA RECOGER EN EL PUNTO SEUR PICKUP.', mapped: { status: 'in_transit', stage: 'ready_for_pickup' } },
17
+ // Pending arrival at the destination's facility: in the delay group, but it says where the parcel is.
18
+ LI511: { group: 'EN DEMORA', wording: 'EL ENVÍO ESTÁ PENDIENTE DE LLEGAR A DESTINO. EN CUANTO SE RECIBA, ACTUALIZAREMOS LA SITUACIÓN.', mapped: inTransit },
16
19
  LI567: { group: 'EN TRÁNSITO', wording: 'EL ENVÍO ESTÁ EN TRÁNSITO. PRÓXIMAMENTE LLEGARÁ A LAS INSTALACIONES DE DESTINO.', mapped: inTransit },
17
20
  LO001: { group: 'EN TRÁNSITO', wording: 'EL ENVÍO ESTÁ EN TRÁNSITO. PRÓXIMAMENTE LLEGARÁ A LAS INSTALACIONES DE DESTINO.', mapped: inTransit },
18
21
  SW189: { group: 'EN TRÁNSITO', wording: 'EL ENVÍO ESTÁ EN TRÁNSITO. PRÓXIMAMENTE LLEGARÁ A LAS INSTALACIONES DE DESTINO.', mapped: inTransit },
@@ -25,13 +28,25 @@ const observed = {
25
28
  SX001: { group: 'EN TRÁNSITO', wording: 'EL ENVÍO HA SIDO RECEPCIONADO EN LAS INSTALACIONES DE ORIGEN DE SEUR. COMIENZA EL TRÁNSITO A DESTINO', mapped: { status: 'in_transit', stage: 'accepted' } },
26
29
  SX010: { group: 'NOTIFICADO', wording: 'EL ENVÍO HA SIDO REGISTRADO.', mapped: { status: 'pending', stage: 'registered' } },
27
30
  };
28
- export function classifySeurStatus(code, group, wording) {
31
+ function entry(code, group, wording) {
29
32
  if (!Object.hasOwn(observed, code))
30
33
  return undefined;
31
- const entry = observed[code];
32
- return group.toUpperCase() === entry.group && wording.toUpperCase() === entry.wording ? entry.mapped : undefined;
34
+ const found = observed[code];
35
+ return group.toUpperCase() === found.group && wording.toUpperCase() === found.wording ? found : undefined;
36
+ }
37
+ export function classifySeurStatus(code, group, wording) {
38
+ return entry(code, group, wording)?.mapped ?? undefined;
39
+ }
40
+ /** Whether the scan is one of SEUR's delay notices, which keep the stage the parcel had. */
41
+ export function isSeurDelay(code, group, wording) {
42
+ return entry(code, group, wording)?.mapped === null;
33
43
  }
34
44
  /** The status group SEUR sent with an observed code. */
35
45
  export function seurStatusGroup(code) {
36
46
  return Object.hasOwn(observed, code) ? observed[code].group : undefined;
37
47
  }
48
+ /** What the map says about one scan, read with the group SEUR sends with its code. */
49
+ export const statusMap = {
50
+ stage: (code, wording) => (code ? classifySeurStatus(code, seurStatusGroup(code) ?? '', wording)?.stage : undefined),
51
+ gaps: ['LI300', 'LI582'].map((code) => ({ code, note: 'A delay notice, not a movement: the parcel keeps the stage it had.' })),
52
+ };
@@ -71,16 +71,26 @@
71
71
  "code": "LI300",
72
72
  "wording": "EL ENVÍO HA SUFRIDO UN RETRASO Y ES POSIBLE QUE SE DEMORE LA ENTREGA. DISCULPA LAS MOLESTIAS.",
73
73
  "language": "es",
74
- "stage": "exception",
75
- "confirmedBy": "live"
74
+ "confirmedBy": "live",
75
+ "unmapped": true,
76
+ "note": "A delay notice in the EN DEMORA group, not a movement: the row keeps the stage of the one before it."
76
77
  },
77
78
  {
78
79
  "code": "LI582",
79
80
  "wording": "EL ENVÍO HA SUFRIDO UN RETRASO PROVOCANDO UNA POSIBLE DEMORA EN LA ENTREGA. DISCULPA LAS MOLESTIAS.",
80
81
  "language": "es",
81
- "stage": "exception",
82
82
  "confirmedBy": "live",
83
- "note": "A delay notice in the EN DEMORA group, read like LI300."
83
+ "unmapped": true,
84
+ "note": "A delay notice in the EN DEMORA group, read like LI300. The parcel seen with it moved on to an agreed delivery day."
85
+ },
86
+ {
87
+ "code": "LI511",
88
+ "wording": "EL ENVÍO ESTÁ PENDIENTE DE LLEGAR A DESTINO. EN CUANTO SE RECIBA, ACTUALIZAREMOS LA SITUACIÓN.",
89
+ "language": "es",
90
+ "stage": "in_transit",
91
+ "confirmedBy": "live",
92
+ "firstSeen": "2026-10-22",
93
+ "note": "In the EN DEMORA group, but it says where the parcel is: between the origin's receipt and the transit to the destination's facility."
84
94
  },
85
95
  {
86
96
  "code": "LC003",
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type { CarrierStatus } from '../../core/result/index.js';
15
15
  import type { Stage } from '../../core/status/index.js';
16
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
16
17
  /**
17
18
  * Classify USPS status prose. Returns null when nothing matches, so callers
18
19
  * leave an event unstaged rather than invent a stage. `hasEvents` reports an
@@ -23,3 +24,5 @@ export declare function uspsStage(text: string): Stage | null;
23
24
  export declare function statusForStage(stage: Stage): CarrierStatus;
24
25
  /** The result-level status for banner and history wording. */
25
26
  export declare function uspsStatus(text: string, hasEvents?: boolean): CarrierStatus;
27
+ /** What the map says about one scan: USPS's scans carry no code, and their wording alone stages them. */
28
+ export declare const statusMap: CarrierStatusMap;
@@ -74,3 +74,5 @@ export function uspsStatus(text, hasEvents = false) {
74
74
  // Unrecognized wording only means "moving" once the shipment has scans.
75
75
  return hasEvents ? 'in_transit' : 'unknown';
76
76
  }
77
+ /** What the map says about one scan: USPS's scans carry no code, and their wording alone stages them. */
78
+ export const statusMap = { stage: (_code, wording) => uspsStage(wording) ?? undefined, gaps: [] };
@@ -1,8 +1,9 @@
1
1
  import { classifyWording } from '../../core/status/index.js';
2
2
  /**
3
- * The stage of each scan code ZTO International's tracker sends. Codes below
4
- * 100 are ZTO's own network, at home and abroad; codes from 150 are ZTO
5
- * International's warehouses, line hauls and customs.
3
+ * The stage of each scan code ZTO International's tracker sends. Codes up to
4
+ * 16 are ZTO's own network, at home and abroad; codes from 150 are ZTO
5
+ * International's warehouses, line hauls and customs. On an export to Europe,
6
+ * codes 100, 375 and 415 copy the last-mile carrier's scans.
6
7
  */
7
8
  const CODES = new Map([
8
9
  ['1', 'accepted'],
@@ -15,6 +16,10 @@ const CODES = new Map([
15
16
  // Taken in by a parcel station or a neighbour for the recipient; the page
16
17
  // calls it delivered, but the collection follows as code 15.
17
18
  ['16', 'ready_for_pickup'],
19
+ // The order checked before the overseas warehouse receives the parcel.
20
+ ['30', 'registered'],
21
+ // Copied from the last-mile carrier abroad, as an arrival.
22
+ ['100', 'in_transit'],
18
23
  // The overseas warehouse received the parcel: the first scan of an import.
19
24
  ['150', 'accepted'],
20
25
  ['180', 'in_transit'],
@@ -33,6 +38,11 @@ const CODES = new Map([
33
38
  ['370', 'in_transit'],
34
39
  ['371', 'in_transit'],
35
40
  ['373', 'customs'],
41
+ // The destination's import clearance, finished.
42
+ ['375', 'in_transit'],
43
+ // A hand-over in the warehouse or to the last-mile carrier abroad.
44
+ ['415', 'in_transit'],
45
+ ['-581', 'exception'],
36
46
  ]);
37
47
  /** A scan's stage by its code, or by its wording when the code is new. */
38
48
  export function ztoInternationalStage(code, wording) {
@@ -13,6 +13,8 @@
13
13
  {"code": "5", "wording": "Dispatch Signed for", "language": "en", "stage": "delivered", "confirmedBy": "live", "note": "A delivery in ZTO's network in Cambodia, without the station the line names."},
14
14
  {"code": "15", "wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "live", "note": "Signed for with the pickup code a parcel station gave the recipient."},
15
15
  {"code": "16", "wording": "Received on the recipient's behalf", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "live", "note": "The adapter's wording: the English line says Delivered, and the Chinese line names who took the parcel in, a parcel station in the histories seen. The collection follows as code 15."},
16
+ {"code": "30", "wording": "The order has been reviewed", "language": "en", "stage": "registered", "confirmedBy": "live", "firstSeen": "2026-10-22", "note": "The first row of an export to Europe, two days before the warehouse received the parcel."},
17
+ {"code": "100", "wording": "Arrival at inward office of exchange.", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-22", "note": "A row copied from the last-mile carrier abroad; the copy seen matched its out-for-delivery scan, read on ZTO's words alone."},
16
18
  {"code": "150", "wording": "Parcel entered the warehouse", "language": "en", "stage": "accepted", "confirmedBy": "live", "note": "The overseas warehouse, in the United States, Japan, Korea or Hong Kong, received the parcel."},
17
19
  {"code": "180", "wording": "Packed into shipping bag", "language": "en", "stage": "in_transit", "confirmedBy": "live"},
18
20
  {"code": "190", "wording": "Parcel left the warehouse", "language": "en", "stage": "in_transit", "confirmedBy": "live"},
@@ -28,6 +30,9 @@
28
30
  {"code": "360", "wording": "Import customs in progress.", "language": "en", "stage": "customs", "confirmedBy": "live"},
29
31
  {"code": "370", "wording": "clearance finish", "language": "en", "stage": "in_transit", "confirmedBy": "live"},
30
32
  {"code": "371", "wording": "clearance finish", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "The Chinese line adds that the parcel enters domestic transport."},
31
- {"code": "373", "wording": "Customs inspection", "language": "en", "stage": "customs", "confirmedBy": "live", "note": "Chinese customs picked the parcel for inspection."}
33
+ {"code": "373", "wording": "Customs inspection", "language": "en", "stage": "customs", "confirmedBy": "live", "note": "Chinese customs picked the parcel for inspection."},
34
+ {"code": "375", "wording": "clearance completed, customs", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-22", "note": "The destination's import clearance, finished, as the last-mile carrier abroad reports it."},
35
+ {"code": "415", "wording": "Express shipment / transshipment", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-22", "note": "After the warehouse's receipt and when the last-mile carrier abroad passes the parcel on."},
36
+ {"code": "-581", "wording": "Parcel with exceptions", "language": "en", "stage": "exception", "confirmedBy": "live", "firstSeen": "2026-10-22", "note": "A problem the row does not name, after the last mile's delivery round."}
32
37
  ]
33
38
  }
@@ -169,6 +169,7 @@ import { statusMap as estesForwarding } from '../../carriers/estes-forwarding/st
169
169
  import { statusMap as eurodis } from '../../carriers/eurodis/status.js';
170
170
  import { statusMap as euroexpressBa } from '../../carriers/euroexpress-ba/status.js';
171
171
  import { statusMap as evdemoLojistik } from '../../carriers/evdemo-lojistik/status.js';
172
+ import { statusMap as evri } from '../../carriers/evri/status.js';
172
173
  import { statusMap as evropat } from '../../carriers/evropat/status.js';
173
174
  import { statusMap as ewe } from '../../carriers/ewe/status.js';
174
175
  import { statusMap as exelot } from '../../carriers/exelot/status.js';
@@ -425,6 +426,7 @@ import { statusMap as sendex } from '../../carriers/sendex/status.js';
425
426
  import { statusMap as sending } from '../../carriers/sending/status.js';
426
427
  import { statusMap as serpost } from '../../carriers/serpost/status.js';
427
428
  import { statusMap as servientrega } from '../../carriers/servientrega/status.js';
429
+ import { statusMap as seur } from '../../carriers/seur/status.js';
428
430
  import { statusMap as sfExpress } from '../../carriers/sf-express/status.js';
429
431
  import { statusMap as sfc } from '../../carriers/sfc/status.js';
430
432
  import { statusMap as shadowfax } from '../../carriers/shadowfax/status.js';
@@ -509,6 +511,7 @@ import { statusMap as upaya } from '../../carriers/upaya/status.js';
509
511
  import { statusMap as ups } from '../../carriers/ups/status.js';
510
512
  import { statusMap as urbano } from '../../carriers/urbano/status.js';
511
513
  import { statusMap as urvaam } from '../../carriers/urvaam/status.js';
514
+ import { statusMap as usps } from '../../carriers/usps/status.js';
512
515
  import { statusMap as uzbekistanPost } from '../../carriers/uzbekistan-post/status.js';
513
516
  import { statusMap as valmo } from '../../carriers/valmo/status.js';
514
517
  import { statusMap as vaspExpresso } from '../../carriers/vasp-expresso/status.js';
@@ -567,7 +570,7 @@ export const STATUS_MAPS = {
567
570
  'early-bird': earlyBird, 'easy-mail': easyMail,
568
571
  econt, ecourier, 'egypt-post': egyptPost, elta, emile, 'ems-vietnam': emsVietnam, 'energia-tk': energiaTk, 'envia-colombia': enviaColombia,
569
572
  envialia, 'eparcel-kr': eparcelKr, 'epost-global': epostGlobal, equick, estes, 'estes-forwarding': estesForwarding, eurodis, 'euroexpress-ba': euroexpressBa,
570
- 'evdemo-lojistik': evdemoLojistik, evropat, ewe, exelot,
573
+ 'evdemo-lojistik': evdemoLojistik, evri, evropat, ewe, exelot,
571
574
  'exline-kz': exlineKz, exmate, 'express-one': expressOne, 'fan-courier': fanCourier, 'fargo-courier': fargoCourier, 'fargo-uz': fargoUz, fastdrop,
572
575
  fastfrate, 'fedex-freight': fedexFreight, 'fedex-pl': fedexPl, fermopoint, 'fg-delivery': fgDelivery, 'first-line': firstLine,
573
576
  'flash-express': flashExpress,
@@ -608,7 +611,7 @@ export const STATUS_MAPS = {
608
611
  'quicksilver-sa': quicksilverSa, quiqup, qwintry, raben, 'ram-hand-to-hand': ramHandToHand, redpack, redur, redx, relaytech,
609
612
  rivigo, 'rl-carriers': rlCarriers, roadrunner, 'royal-express-mm': royalExpressMm, 'royal-mail': royalMail,
610
613
  'rpx-indonesia': rpxIndonesia, sagawa, 'sagawa-global': sagawaGlobal, saia, sailpost, sameday, 'sapx-express': sapxExpress,
611
- 'sat-ukraine': satUkraine, 'saudi-post': saudiPost, seino, seko, sendex, sending, serpost, servientrega, 'sf-express': sfExpress, sfc, shadowfax, shipa, shipblu, shipentegra, shiprocket,
614
+ 'sat-ukraine': satUkraine, 'saudi-post': saudiPost, seino, seko, sendex, sending, serpost, servientrega, seur, 'sf-express': sfExpress, sfc, shadowfax, shipa, shipblu, shipentegra, shiprocket,
612
615
  'shree-anjani': shreeAnjani, 'shree-maruti': shreeMaruti, 'skroutz-last-mile': skroutzLastMile,
613
616
  skyking, 'skynet-sa': skynetSa, 'skynet-worldwide': skynetWorldwide, skypostal, slgtrax,
614
617
  'slovak-parcel-service': slovakParcelService,
@@ -620,7 +623,7 @@ export const STATUS_MAPS = {
620
623
  'thailand-post': thailandPost, thhy, tiki, tipsa, 'tirupati-courier': tirupatiCourier, 'tmm-express': tmmExpress, tnt, 'tnt-it': tntIt, 'today-kr': todayKr, toptrans, torrestir, 'total-express': totalExpress,
621
624
  trackon, 'tracx-logis': tracxLogis, transaher, transcorp, transmission, tsjy, 'tst-cf-express': tstCfExpress, tufesa, 'tws-express': twsExpress,
622
625
  'ubi-smart-parcel': ubiSmartParcel,
623
- 'united-delivery-service': unitedDeliveryService, upaya, ups, urbano, urvaam,
626
+ 'united-delivery-service': unitedDeliveryService, upaya, ups, urbano, urvaam, usps,
624
627
  'uzbekistan-post': uzbekistanPost, valmo, 'vasp-expresso': vaspExpresso, veloces, venipak, 'via-cargo': viaCargo, 'viettel-post': viettelPost,
625
628
  vimenpaq, 'vinted-go': vintedGo, vozovoz, 'vrl-logistics': vrlLogistics, wanbexpress, whistl, winit, 'xde-logistics': xdeLogistics, xdp, xlcourier, 'xpo-ltl': xpoLtl,
626
629
  yanwen, 'ycs-logistics': ycsLogistics, 'yilong-logistics': yilongLogistics, yto, yunexpress, zto, 'zto-international': ztoInternational,
@@ -230,7 +230,7 @@ export function trackingLanguageStage(description) {
230
230
  // absent recipient or a closed business. A scheduled attempt is not a missed
231
231
  // one, nor is a sender pickup.
232
232
  if (!/\bpick ?up (?:attempt|was attempted|unsuccessful|failed)|attempted (?:to )?pick ?up/.test(text)
233
- && /(?:attempted|tried) to deliver|\bcard(?:ed| left)\b|\bwe(?: have|'ve)? missed (?:you|each other)\b(?![^.;!?]*\bready for (?:pick ?up|collection)\b)|\battempted delivery\b(?! (?:is )?(?:scheduled|planned|expected|tomorrow|will)\b)|unsuccessful delivery|delivery (?:was )?unsuccessful|unable to complete (?:the |your |this )?delivery|(?<!\bif (?:the |your )?)\b(?:recipient|addressee|consignee|customer|receiver|user)(?: was| is)? (?:not (?:at home|home|available|present)|unavailable|absent)\b|absence of (?:the )?(?:recipient|addressee|consignee)|\bbusiness (?:was |is )?closed\b|\bpremises (?:(?:was|were) )?closed\b|destinataire absent|(?<!\ben cas d')\babsence du destinataire|(?:entreprise|societe) (?:etait |est )?fermee?\b|nicht angetroffen|(?:firma|betrieb|geschaft) (?:war |ist )?geschlossen|destinatario assente|(?:attivita|azienda|ditta) (?:era |e )?chiusa/.test(text))
233
+ && /(?:attempted|tried) to deliver|\bcard(?:ed| left)\b|\bwe(?: have|'ve)? missed (?:you|each other)\b(?![^.;!?]*\bready for (?:pick ?up|collection)\b)|\battempted delivery\b(?! (?:is )?(?:scheduled|planned|expected|tomorrow|will)\b)|unsuccessful delivery|delivery (?:was )?(?:un|not )successful|unable to complete (?:the |your |this )?delivery|(?<!\bif (?:the |your )?)\b(?:recipient|addressee|consignee|customer|receiver|user)(?: was| is)? (?:not (?:at home|home|available|present)|unavailable|absent)\b|absence of (?:the )?(?:recipient|addressee|consignee)|\bbusiness (?:was |is )?closed\b|\bpremises (?:(?:was|were) )?closed\b|destinataire absent|(?<!\ben cas d')\babsence du destinataire|(?:entreprise|societe) (?:etait |est )?fermee?\b|nicht angetroffen|(?:firma|betrieb|geschaft) (?:war |ist )?geschlossen|destinatario assente|(?:attivita|azienda|ditta) (?:era |e )?chiusa/.test(text))
234
234
  return 'failed_attempt';
235
235
  // The same in Spanish and Portuguese ("Ausente"), where "recogida" and
236
236
  // "recolha" name the sender pickup and "si"/"caso" make absence a condition.
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.637.1"
5
+ "version": "0.638.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.637.1",
3
+ "version": "0.638.0-main.3012",
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",