universal-parcel-scraper 0.49.1-main.549 → 0.49.2-main.551
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 +1 -1
- package/dist/browser/scraper.js +2 -2
- package/dist/carriers/aliexpress/adapter.js +12 -9
- package/dist/carriers/aliexpress/app.js +17 -2
- package/dist/carriers/aliexpress/status.d.ts +5 -11
- package/dist/carriers/aliexpress/status.js +19 -16
- package/dist/carriers/aliexpress/statuses.json +38 -12
- package/dist/carriers/paack/adapter.js +39 -6
- package/dist/carriers/paack/carrier.json +3 -1
- package/dist/core/status/language.js +2 -2
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
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,
|
|
152
|
+
The carrier folders record 2,758 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/dist/browser/scraper.js
CHANGED
|
@@ -4937,8 +4937,8 @@ function Yt(e) {
|
|
|
4937
4937
|
if (/will be transported|sera (?:transporte|achemine)|wird.*transportiert|sara trasportat/.test(t) || /will be available|sera disponible|(?:sera|va etre|doit etre) (?:distribue|remis|mis en (?:livraison|distribution))|wird.*verfugbar|sara disponibile|to be delivered|a livrer|noch zuzustellen|da consegnare|(?:once|when|as soon as) (?:the |your )?(?:shipment|parcel|package|item) is out for delivery/.test(t) || /\bestara disponi(?:ble|vel)\b|\bpor entregar\b|\bpendiente de (?:entrega|entregar|reparto)\b|\bpendente de entrega\b|\b(?:aguarda|aguardando|a aguardar) (?:a )?entrega\b|\bsera (?:enviad|transportad|expedid|encaminhad)[oa]s?\b/.test(t)) return "in_transit";
|
|
4938
4938
|
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";
|
|
4939
4939
|
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";
|
|
4940
|
-
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";
|
|
4941
|
-
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";
|
|
4940
|
+
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|(?:\bnot|n't|\bnever)(?: yet)?(?: been)? released (?:by |from )?(?:(?:import|export)(?:\/export)? )?customs|\b(?:be|being) released from (?:(?:import|export)(?:\/export)? )?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";
|
|
4941
|
+
if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released from (?:(?:import|export)(?:\/export)? )?customs|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";
|
|
4942
4942
|
if (/customs|clearance|douan|formalites (?:d')?(?:import|export)|zoll|dogan|government agency|autorite gouvernementale|staatliche behorde|autorita governativa/.test(t)) return "customs";
|
|
4943
4943
|
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";
|
|
4944
4944
|
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";
|
|
@@ -19,7 +19,7 @@ import { NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
|
19
19
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
20
20
|
import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
21
21
|
import { isRecord } from '../../core/types.js';
|
|
22
|
-
import { CAINIAO_ACTION_STATUS, CAINIAO_STATUS, cainiaoActionCode, cainiaoStageByStatus, } from './status.js';
|
|
22
|
+
import { CAINIAO_ACTION_STATUS, CAINIAO_STATUS, cainiaoActionCode, cainiaoActionStage, cainiaoStageByStatus, } from './status.js';
|
|
23
23
|
const PROVIDER = 'Cainiao';
|
|
24
24
|
const UPSTREAM = 'Cainiao tracking';
|
|
25
25
|
const DETAIL_URL = 'https://global.cainiao.com/global/detail.json';
|
|
@@ -86,14 +86,17 @@ function carrierNames(trackingModule, scans) {
|
|
|
86
86
|
* Cainiao has no place field: it writes a scan's town in front of its standard
|
|
87
87
|
* wording, "[Bordeaux] Out for delivery", in every language. A bracket that
|
|
88
88
|
* reads as a place name becomes the location and the wording keeps the rest.
|
|
89
|
-
* Codes, capitals-only names and the module's
|
|
89
|
+
* Codes, ambiguous capitals-only names and the module's carrier names stay in the text.
|
|
90
90
|
*/
|
|
91
91
|
function placedWording(wording, carriers) {
|
|
92
92
|
const match = /^\s*\[([^[\]]{1,80})\]\s*(\S[\s\S]*)$/.exec(wording);
|
|
93
93
|
const place = match?.[1].trim() ?? '';
|
|
94
|
+
// An explicit numbered city district is a place even in uppercase. Keep
|
|
95
|
+
// arbitrary numbers and facility codes excluded from the general rule.
|
|
96
|
+
const arrondissement = /^(?:Paris (?:[1-9]|1\d|20)|Lyon [1-9]|Marseille (?:[1-9]|1[0-6]))(?:er|e|eme|ème) arrondissement$/i.test(place);
|
|
94
97
|
const letters = place.match(/\p{L}/gu)?.length ?? 0;
|
|
95
|
-
if (!match || letters < 3 || !/^\p{L}[\p{L}\p{M} .'’-]*$/u.test(place)
|
|
96
|
-
|| (/\p{Lu}/u.test(place) && !/\p{Ll}/u.test(place)) || carriers.has(place.toLocaleLowerCase('en-US'))) {
|
|
98
|
+
if (!match || letters < 3 || (!arrondissement && (!/^\p{L}[\p{L}\p{M} .'’-]*$/u.test(place)
|
|
99
|
+
|| (/\p{Lu}/u.test(place) && !/\p{Ll}/u.test(place)))) || carriers.has(place.toLocaleLowerCase('en-US'))) {
|
|
97
100
|
return { description: wording, location: '' };
|
|
98
101
|
}
|
|
99
102
|
return { description: match[2].trim(), location: place };
|
|
@@ -160,11 +163,11 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
|
|
|
160
163
|
: (CAINIAO_STATUS.get(rawStatus)
|
|
161
164
|
?? (rawStatus ? 'in_transit' : details.length === 0 ? 'pending' : 'unknown'));
|
|
162
165
|
const latestAction = cainiaoActionCode(latest.actionCode);
|
|
163
|
-
const
|
|
166
|
+
const currentStage = cainiaoActionStage(latestAction) ?? cainiaoStageByStatus()[status];
|
|
164
167
|
const carriers = carrierNames(trackingModule, details);
|
|
165
168
|
const events = details.slice(0, MAX_EVENTS_TO_RETURN).map((event) => {
|
|
166
169
|
const code = cainiaoActionCode(event.actionCode);
|
|
167
|
-
const
|
|
170
|
+
const stage = cainiaoActionStage(code);
|
|
168
171
|
const { description, location } = placedWording(text(event.standerdDesc) || text(event.desc), carriers);
|
|
169
172
|
return {
|
|
170
173
|
time: scanTime(event),
|
|
@@ -172,7 +175,7 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
|
|
|
172
175
|
description,
|
|
173
176
|
// The code keys the app's review of a scan the map does not stage.
|
|
174
177
|
...(/^[A-Z0-9_]{1,64}$/.test(code) ? { provider_code: code } : {}),
|
|
175
|
-
...(
|
|
178
|
+
...(stage ? { stage } : {}),
|
|
176
179
|
};
|
|
177
180
|
});
|
|
178
181
|
const eta = record(trackingModule.globalEtaInfo);
|
|
@@ -187,7 +190,7 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
|
|
|
187
190
|
const deliveredAt = status === 'delivered' ? scanTime(latest) || null : null;
|
|
188
191
|
return {
|
|
189
192
|
status,
|
|
190
|
-
...(
|
|
193
|
+
...(currentStage ? { current_stage: currentStage } : {}),
|
|
191
194
|
last_status_text: placedWording(text(latest.standerdDesc) || text(latest.desc), carriers).description || rawStatus,
|
|
192
195
|
last_update: scanTime(latest) || null,
|
|
193
196
|
expected_delivery: status === 'delivered' ? null : expected,
|
|
@@ -195,7 +198,7 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
|
|
|
195
198
|
? {}
|
|
196
199
|
: { expected_delivery_from: expectedFrom }),
|
|
197
200
|
...(deliveredAt ? { delivered_at: deliveredAt } : {}),
|
|
198
|
-
...(handoff ? { delivery_tracking_number: handoff } : {}),
|
|
201
|
+
...(handoff && comparableIdentifier(handoff) !== requested ? { delivery_tracking_number: handoff } : {}),
|
|
199
202
|
...(destination ? { destination_country_name: destination } : {}),
|
|
200
203
|
events,
|
|
201
204
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cainiaoActionCode, cainiaoActionStage } from './status.js';
|
|
1
2
|
const normalized = (value) => value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
|
|
2
3
|
// A scan stored while its town was still part of the wording, "[Bordeaux] Out for
|
|
3
4
|
// delivery" without a location, is the same scan as "Out for delivery" in Bordeaux.
|
|
@@ -7,8 +8,22 @@ export const sameInstantIdentityPolicy = {
|
|
|
7
8
|
matches(incoming, stored) {
|
|
8
9
|
const wording = normalized(incoming.description);
|
|
9
10
|
const location = normalized(incoming.location);
|
|
10
|
-
|
|
11
|
-
&& incoming.stage === stored.stage && !normalized(stored.location)
|
|
11
|
+
const movedLocation = Boolean(location) && !normalized(stored.location)
|
|
12
12
|
&& normalized(stored.description) === `[${location}] ${wording}`;
|
|
13
|
+
if (!wording || ['', 'unknown'].includes(incoming.stage))
|
|
14
|
+
return false;
|
|
15
|
+
if (incoming.stage === stored.stage)
|
|
16
|
+
return movedLocation;
|
|
17
|
+
// Correct only the old adapter's coarse stages, with the same action,
|
|
18
|
+
// wording and place. The consumer also requires a unique same-time match.
|
|
19
|
+
const code = cainiaoActionCode(incoming.providerCode);
|
|
20
|
+
if (!code || code !== cainiaoActionCode(stored.providerCode)
|
|
21
|
+
|| incoming.stage !== cainiaoActionStage(code))
|
|
22
|
+
return false;
|
|
23
|
+
const correction = (['customs', 'accepted'].includes(incoming.stage) && stored.stage === 'in_transit')
|
|
24
|
+
|| (incoming.stage === 'ready_for_pickup' && stored.stage === 'out_for_delivery')
|
|
25
|
+
|| (incoming.stage === 'out_for_delivery' && stored.stage === 'ready_for_pickup');
|
|
26
|
+
return correction && (movedLocation || (wording === normalized(stored.description)
|
|
27
|
+
&& location === normalized(stored.location)));
|
|
13
28
|
},
|
|
14
29
|
};
|
|
@@ -24,15 +24,9 @@ export declare const CAINIAO_ACTION_STATUS: Map<string, CarrierStatus>;
|
|
|
24
24
|
export declare const CAINIAO_PICKUP_ACTIONS: Set<string>;
|
|
25
25
|
/** Normalize an action code: Cainiao has been seen spelling them with spaces and in lower case. */
|
|
26
26
|
export declare function cainiaoActionCode(value: unknown): string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*/
|
|
32
|
-
export declare function cainiaoStageByStatus(latestActionCode: string): Partial<Record<CarrierStatus, Stage>>;
|
|
33
|
-
/**
|
|
34
|
-
* What the map says about one scan, by its action code. An out-for-delivery
|
|
35
|
-
* code reads as the van or a pickup point by the newest code of the whole
|
|
36
|
-
* reply, so the code alone does not answer it.
|
|
37
|
-
*/
|
|
27
|
+
/** Coarse status fallback when Cainiao supplies no action code. */
|
|
28
|
+
export declare function cainiaoStageByStatus(): Partial<Record<CarrierStatus, Stage>>;
|
|
29
|
+
/** Each scan keeps its own milestone even after the parcel progresses. */
|
|
30
|
+
export declare function cainiaoActionStage(code: string): Stage | undefined;
|
|
31
|
+
/** The review queue uses the same per-scan mapping as the adapter. */
|
|
38
32
|
export declare const statusMap: CarrierStatusMap;
|
|
@@ -51,6 +51,8 @@ export const CAINIAO_ACTION_STATUS = new Map([
|
|
|
51
51
|
['CC_IM_FAILURE', 'exception'],
|
|
52
52
|
['CC_IM_EXCEPTION', 'exception'],
|
|
53
53
|
['GTMS_ACCEPT', 'in_transit'],
|
|
54
|
+
['GTMS_DO_ARRIVE', 'in_transit'],
|
|
55
|
+
['GTMS_STATION_OUT', 'in_transit'],
|
|
54
56
|
['GTMS_SC_DEPART', 'in_transit'],
|
|
55
57
|
['SC_ARRIVE', 'in_transit'],
|
|
56
58
|
['SC_DEPART', 'in_transit'],
|
|
@@ -71,19 +73,27 @@ export const CAINIAO_PICKUP_ACTIONS = new Set(['GSTA_INFORM_BUYER', 'GTMS_WAIT_S
|
|
|
71
73
|
export function cainiaoActionCode(value) {
|
|
72
74
|
return (typeof value === 'string' ? value : '').trim().toLocaleUpperCase('en-US').replace(/\s+/g, '_');
|
|
73
75
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
* action code, so the whole table is derived from it once and then applied to
|
|
77
|
-
* the shipment and to every historical event, exactly as before the move.
|
|
78
|
-
*/
|
|
79
|
-
export function cainiaoStageByStatus(latestActionCode) {
|
|
76
|
+
/** Coarse status fallback when Cainiao supplies no action code. */
|
|
77
|
+
export function cainiaoStageByStatus() {
|
|
80
78
|
return {
|
|
81
79
|
pending: 'registered',
|
|
82
|
-
|
|
80
|
+
in_transit: 'in_transit',
|
|
81
|
+
out_for_delivery: 'out_for_delivery',
|
|
83
82
|
delivered: 'delivered',
|
|
84
83
|
exception: 'exception',
|
|
85
84
|
};
|
|
86
85
|
}
|
|
86
|
+
/** Each scan keeps its own milestone even after the parcel progresses. */
|
|
87
|
+
export function cainiaoActionStage(code) {
|
|
88
|
+
if (CAINIAO_PICKUP_ACTIONS.has(code))
|
|
89
|
+
return 'ready_for_pickup';
|
|
90
|
+
if (['CC_EX_START', 'CC_IM_START', 'CC_HO_IN_SUCCESS'].includes(code))
|
|
91
|
+
return 'customs';
|
|
92
|
+
if (code === 'PU_PICKUP_SUCCESS')
|
|
93
|
+
return 'accepted';
|
|
94
|
+
const status = CAINIAO_ACTION_STATUS.get(code);
|
|
95
|
+
return status ? cainiaoStageByStatus()[status] : undefined;
|
|
96
|
+
}
|
|
87
97
|
/**
|
|
88
98
|
* The scans the review queue holds without their action code, from before
|
|
89
99
|
* events kept it, by `normalizeStatusWording` wording without the town
|
|
@@ -95,18 +105,11 @@ const UNCODED_SCANS = new Map([
|
|
|
95
105
|
['awaiting for transit to final delivery office', 'TD_TRANS_ARRIVE_DCP'],
|
|
96
106
|
['departed from destination country/region sorting center', 'GTMS_SC_DEPART'],
|
|
97
107
|
]);
|
|
98
|
-
/**
|
|
99
|
-
* What the map says about one scan, by its action code. An out-for-delivery
|
|
100
|
-
* code reads as the van or a pickup point by the newest code of the whole
|
|
101
|
-
* reply, so the code alone does not answer it.
|
|
102
|
-
*/
|
|
108
|
+
/** The review queue uses the same per-scan mapping as the adapter. */
|
|
103
109
|
export const statusMap = {
|
|
104
110
|
stage: (code, wording) => {
|
|
105
111
|
const action = code ? cainiaoActionCode(code) : UNCODED_SCANS.get(wording.replace(/^\[[^\]]*\]\s*/, ''));
|
|
106
|
-
|
|
107
|
-
if (!status || status === 'out_for_delivery')
|
|
108
|
-
return undefined;
|
|
109
|
-
return cainiaoStageByStatus('')[status] ?? 'in_transit';
|
|
112
|
+
return action ? cainiaoActionStage(action) : undefined;
|
|
110
113
|
},
|
|
111
114
|
gaps: [],
|
|
112
115
|
};
|
|
@@ -101,10 +101,12 @@
|
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"code": "PU_PICKUP_SUCCESS",
|
|
104
|
-
"stage": "
|
|
105
|
-
"confirmedBy": "
|
|
104
|
+
"stage": "accepted",
|
|
105
|
+
"confirmedBy": "live",
|
|
106
106
|
"firstSeen": "2026-09-12",
|
|
107
|
-
"note": ""
|
|
107
|
+
"note": "",
|
|
108
|
+
"wording": "Received by logistics company",
|
|
109
|
+
"language": "en"
|
|
108
110
|
},
|
|
109
111
|
{
|
|
110
112
|
"code": "GWMS_OUTBOUND",
|
|
@@ -143,10 +145,12 @@
|
|
|
143
145
|
},
|
|
144
146
|
{
|
|
145
147
|
"code": "CC_EX_START",
|
|
146
|
-
"stage": "
|
|
147
|
-
"confirmedBy": "
|
|
148
|
+
"stage": "customs",
|
|
149
|
+
"confirmedBy": "live",
|
|
148
150
|
"firstSeen": "2026-09-12",
|
|
149
|
-
"note": ""
|
|
151
|
+
"note": "",
|
|
152
|
+
"wording": "Export customs clearance started",
|
|
153
|
+
"language": "en"
|
|
150
154
|
},
|
|
151
155
|
{
|
|
152
156
|
"code": "CC_EX_SUCCESS",
|
|
@@ -254,17 +258,21 @@
|
|
|
254
258
|
},
|
|
255
259
|
{
|
|
256
260
|
"code": "CC_HO_IN_SUCCESS",
|
|
257
|
-
"stage": "
|
|
258
|
-
"confirmedBy": "
|
|
261
|
+
"stage": "customs",
|
|
262
|
+
"confirmedBy": "live",
|
|
259
263
|
"firstSeen": "2026-09-12",
|
|
260
|
-
"note": ""
|
|
264
|
+
"note": "",
|
|
265
|
+
"wording": "Arrived at customs",
|
|
266
|
+
"language": "en"
|
|
261
267
|
},
|
|
262
268
|
{
|
|
263
269
|
"code": "CC_IM_START",
|
|
264
|
-
"stage": "
|
|
265
|
-
"confirmedBy": "
|
|
270
|
+
"stage": "customs",
|
|
271
|
+
"confirmedBy": "live",
|
|
266
272
|
"firstSeen": "2026-09-12",
|
|
267
|
-
"note": ""
|
|
273
|
+
"note": "",
|
|
274
|
+
"wording": "Import customs clearance started",
|
|
275
|
+
"language": "en"
|
|
268
276
|
},
|
|
269
277
|
{
|
|
270
278
|
"code": "CC_IM_SUCCESS",
|
|
@@ -386,6 +394,24 @@
|
|
|
386
394
|
"confirmedBy": "prior-art",
|
|
387
395
|
"firstSeen": "2026-09-12",
|
|
388
396
|
"note": "Reclassified to exception 2026-09-12."
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"code": "GTMS_DO_ARRIVE",
|
|
400
|
+
"wording": "Arrived at local delivery center",
|
|
401
|
+
"language": "en",
|
|
402
|
+
"stage": "in_transit",
|
|
403
|
+
"confirmedBy": "live",
|
|
404
|
+
"firstSeen": "2026-10-08",
|
|
405
|
+
"note": ""
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"code": "GTMS_STATION_OUT",
|
|
409
|
+
"wording": "Delivery in process. Now left facility.",
|
|
410
|
+
"language": "en",
|
|
411
|
+
"stage": "in_transit",
|
|
412
|
+
"confirmedBy": "live",
|
|
413
|
+
"firstSeen": "2026-10-08",
|
|
414
|
+
"note": ""
|
|
389
415
|
}
|
|
390
416
|
]
|
|
391
417
|
}
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
* e-mail, phone and address, and per-event `variables` that repeat them.
|
|
19
19
|
* `parse()` copies nothing from those objects: each event is rebuilt from its
|
|
20
20
|
* timestamp, our own description and the mapped stage, and the only order-level
|
|
21
|
-
* fields read are the delivery postcode, compared and never copied,
|
|
22
|
-
* delivery window.
|
|
21
|
+
* fields read are the delivery postcode, compared and never copied, the
|
|
22
|
+
* delivery country, which picks the window's clock, and the delivery window.
|
|
23
23
|
*/
|
|
24
24
|
import { load } from 'cheerio';
|
|
25
|
+
import { DateTime } from 'luxon';
|
|
25
26
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
26
27
|
import { IndeterminateError, InputRequiredError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
28
|
+
import { countryCode, countryTimeZone, explicitOffsetTime } from '../../core/time/index.js';
|
|
27
29
|
import { decodeText, fetchBounded, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
|
|
28
30
|
import { isRecord } from '../../core/types.js';
|
|
29
31
|
import { classifyPaackEvent } from './status.js';
|
|
@@ -32,6 +34,7 @@ const DEFAULT_TIMEOUT_MS = 15_000;
|
|
|
32
34
|
const MAX_RESPONSE_BYTES = 2_000_000;
|
|
33
35
|
const MAX_EVENTS = 100;
|
|
34
36
|
const MAX_REDIRECTS = 1;
|
|
37
|
+
const DEFAULT_ZONE = 'Europe/Paris';
|
|
35
38
|
const PAACK_HOST = /(?:^|\.)paack\.(?:app|co)$/;
|
|
36
39
|
const NOT_FOUND_PATTERN = /order not found|incorrect order number|commande introuvable|pedido no encontrado/i;
|
|
37
40
|
const NOT_FOUND_PAGE_PATTERN = /order not found|incorrect order number or postal code|commande introuvable|pedido no encontrado/i;
|
|
@@ -68,6 +71,34 @@ function normalizedDate(value) {
|
|
|
68
71
|
}
|
|
69
72
|
return normalizedTimestamp(value)?.iso.slice(0, 10) ?? null;
|
|
70
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The clock the page shows the delivery window on: the delivery country's, and
|
|
76
|
+
* the Canary Islands' for Spanish postcodes 35 and 38.
|
|
77
|
+
*/
|
|
78
|
+
function deliveryZone(country, postcode) {
|
|
79
|
+
return countryCode(country) === 'ES' && /^3[58]\d{3}$/.test(postcode)
|
|
80
|
+
? 'Atlantic/Canary'
|
|
81
|
+
: countryTimeZone(country) ?? DEFAULT_ZONE;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `expected_delivery_ts` as the page shows it: `YYYY-MM-DD HH:MM–HH:MM` on the
|
|
85
|
+
* delivery clock when both ends are instants within one day, else the day
|
|
86
|
+
* alone. A window that ended before the newest scan is dropped.
|
|
87
|
+
*/
|
|
88
|
+
function expectedDelivery(window, zone, newest) {
|
|
89
|
+
const start = explicitOffsetTime(window.start);
|
|
90
|
+
const end = explicitOffsetTime(window.end) ?? start;
|
|
91
|
+
if (!end)
|
|
92
|
+
return normalizedDate(window.end ?? window.start);
|
|
93
|
+
if (end.timestamp < newest)
|
|
94
|
+
return null;
|
|
95
|
+
const clock = (time) => DateTime.fromMillis(time.timestamp, { zone }).toFormat('yyyy-MM-dd HH:mm');
|
|
96
|
+
const last = clock(end);
|
|
97
|
+
const first = start ? clock(start) : last;
|
|
98
|
+
return first.slice(0, 10) === last.slice(0, 10) && first < last
|
|
99
|
+
? `${first}–${last.slice(11)}`
|
|
100
|
+
: last.slice(0, 10);
|
|
101
|
+
}
|
|
71
102
|
function routeData(payload) {
|
|
72
103
|
if (!isRecord(payload))
|
|
73
104
|
throw new SchemaError('Paack', 'Paack returned an invalid tracking response');
|
|
@@ -222,16 +253,18 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
|
|
|
222
253
|
const activeTime = activeEvent
|
|
223
254
|
? normalizedTimestamp(activeEvent.timestamp ?? activeEvent.time)
|
|
224
255
|
: null;
|
|
225
|
-
const
|
|
226
|
-
|
|
256
|
+
const zone = deliveryZone(address?.country, postcode);
|
|
257
|
+
const newest = Math.max(activeTime?.timestamp ?? -Infinity, parsedEvents[0]?.timestamp ?? -Infinity);
|
|
258
|
+
const expected = isRecord(order.expected_delivery_ts) && !['delivered', 'exception'].includes(current.status)
|
|
259
|
+
? expectedDelivery(order.expected_delivery_ts, zone, newest)
|
|
227
260
|
: null;
|
|
228
261
|
return {
|
|
229
262
|
status: current.status,
|
|
230
263
|
current_stage: current.stage,
|
|
231
264
|
last_status_text: current.description,
|
|
232
265
|
last_update: activeTime?.iso ?? events[0]?.time ?? null,
|
|
233
|
-
expected_delivery:
|
|
234
|
-
timezone:
|
|
266
|
+
expected_delivery: expected,
|
|
267
|
+
timezone: zone,
|
|
235
268
|
events,
|
|
236
269
|
};
|
|
237
270
|
}
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"status",
|
|
50
50
|
"history",
|
|
51
51
|
"eta",
|
|
52
|
+
"eta_window",
|
|
52
53
|
"sender_name",
|
|
53
54
|
"recipient_name",
|
|
54
55
|
"recipient_address"
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
"scraped": [
|
|
57
58
|
"status",
|
|
58
59
|
"history",
|
|
59
|
-
"eta"
|
|
60
|
+
"eta",
|
|
61
|
+
"eta_window"
|
|
60
62
|
],
|
|
61
63
|
"discarded": [
|
|
62
64
|
"sender_name",
|
|
@@ -136,9 +136,9 @@ export function trackingLanguageStage(description) {
|
|
|
136
136
|
if (/^(?:reported|recorded|announced|item created|enregistre|annonce|erfasst|angekundigt|registrato|annunciato)$/.test(text))
|
|
137
137
|
return 'registered';
|
|
138
138
|
// Completion/release is distinct from pending or explicitly negated clearance.
|
|
139
|
-
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))
|
|
139
|
+
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|(?:\bnot|n't|\bnever)(?: yet)?(?: been)? released (?:by |from )?(?:(?:import|export)(?:\/export)? )?customs|\b(?:be|being) released from (?:(?:import|export)(?:\/export)? )?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))
|
|
140
140
|
return 'customs';
|
|
141
|
-
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))
|
|
141
|
+
if (/clearance.*(?:has been completed|completed)|clearance (?:complete|success)|(?:leaving|departed from|left) customs|completion of customs|formalites.*(?:terminee|achevee)|released from (?:(?:import|export)(?:\/export)? )?customs|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))
|
|
142
142
|
return 'in_transit';
|
|
143
143
|
if (/customs|clearance|douan|formalites (?:d')?(?:import|export)|zoll|dogan|government agency|autorite gouvernementale|staatliche behorde|autorita governativa/.test(text))
|
|
144
144
|
return 'customs';
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.2-main.551",
|
|
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",
|