universal-parcel-scraper 0.54.0-main.702 → 0.55.0-main.708
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/dist/browser/scraper.js +973 -971
- package/dist/carriers/australia-post/adapter.js +2 -1
- package/dist/carriers/australia-post/numbers.json +1 -1
- package/dist/carriers/swiss-post-cargo/adapter.js +19 -3
- package/dist/carriers/swiss-post-cargo/statuses.json +2 -2
- package/dist/carriers/ukrposhta/adapter.js +3 -2
- package/dist/core/catalog/handoff.js +3 -0
- package/dist/core/catalog/hints.js +7 -6
- package/dist/core/status/language.js +32 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -267,7 +267,8 @@ export class AustraliaPostTracker {
|
|
|
267
267
|
export const adapter = (environment) => {
|
|
268
268
|
const tracker = new AustraliaPostTracker({ trawl: environment.trawl, fetcher: environment.fetcher, recorder: environment.recorder });
|
|
269
269
|
// Recognition is plain HTTP: it asks the gateway alone, and a refusal there fails instead of starting a browser.
|
|
270
|
+
// A recognition budget can be longer than the minute a lookup takes at most.
|
|
270
271
|
const gateway = new AustraliaPostTracker({ trawl: null, fetcher: environment.fetcher, recorder: environment.recorder });
|
|
271
272
|
return { id: 'australia-post', recordsSteps: true, steps: ['direct', 'trawl'], track: (input, context) => tracker.fetch(input.number, context),
|
|
272
|
-
recognize: (number, context) => recognizeFromLookup(() => gateway.fetch(number, context), () => accepted(() => normalizeAustraliaPostNumber(number))) };
|
|
273
|
+
recognize: (number, context) => recognizeFromLookup(() => gateway.fetch(number, { ...context, budgetMs: Math.min(context?.budgetMs ?? 45_000, 60_000) }), () => accepted(() => normalizeAustraliaPostNumber(number))) };
|
|
273
274
|
};
|
|
@@ -230,7 +230,7 @@
|
|
|
230
230
|
"australia-post"
|
|
231
231
|
]
|
|
232
232
|
},
|
|
233
|
-
"note": "Open-source project example, not a real shipment. The source attributes it to Australia Post,
|
|
233
|
+
"note": "Open-source project example, not a real shipment. The source attributes it to Australia Post. Other carriers' 22-digit rules match too, so detection only suggests Australia Post."
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
236
|
"number": "LH290032509AU",
|
|
@@ -15,6 +15,8 @@ const PROVIDER = 'Swiss Post Cargo';
|
|
|
15
15
|
const ZONE = 'Europe/Zurich';
|
|
16
16
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
17
17
|
const MAX_RESPONSE_BYTES = 2_000_000;
|
|
18
|
+
/** The delivery photo and signature rows: proof of a delivery, not scans of their own. */
|
|
19
|
+
const DELIVERY_PROOF_CODES = new Set(['IMG', 'SIG']);
|
|
18
20
|
/**
|
|
19
21
|
* The endpoint sends Swiss wall-clock ISO-8601 without an offset
|
|
20
22
|
* (`2026-08-30T12:30:00.777`). It is read in Europe/Zurich, never in the
|
|
@@ -99,6 +101,7 @@ export function parseSwissPostCargoResponse(payload, rawTrackingNumber, now = Da
|
|
|
99
101
|
for (const shipment of shipments.slice(0, 100)) {
|
|
100
102
|
if (!Array.isArray(shipment.History))
|
|
101
103
|
continue;
|
|
104
|
+
const rows = [];
|
|
102
105
|
for (const candidate of shipment.History.slice(0, 500)) {
|
|
103
106
|
if (!isRecord(candidate))
|
|
104
107
|
continue;
|
|
@@ -106,13 +109,22 @@ export function parseSwissPostCargoResponse(payload, rawTrackingNumber, now = Da
|
|
|
106
109
|
const description = cleanScalar(candidate.Description);
|
|
107
110
|
if (!time || !description)
|
|
108
111
|
continue;
|
|
109
|
-
const location = cleanScalar(candidate.City, 120);
|
|
110
112
|
const code = cleanScalar(candidate.Status, 32).toLocaleUpperCase('en-US');
|
|
113
|
+
rows.push({
|
|
114
|
+
time, description, code, location: cleanScalar(candidate.City, 120),
|
|
115
|
+
classified: statusFor(code, description), proof: DELIVERY_PROOF_CODES.has(code),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// A barcode's photo and signature are part of its delivery scan: folded
|
|
119
|
+
// into it, they never stand on top of it.
|
|
120
|
+
const deliveryScan = rows.some((row) => !row.proof && row.classified.stage === 'delivered');
|
|
121
|
+
for (const { time, description, code, location, classified, proof } of rows) {
|
|
122
|
+
if (proof && deliveryScan)
|
|
123
|
+
continue;
|
|
111
124
|
const identity = JSON.stringify([time.iso, location, description, code]);
|
|
112
125
|
if (seen.has(identity))
|
|
113
126
|
continue;
|
|
114
127
|
seen.add(identity);
|
|
115
|
-
const classified = statusFor(code, description);
|
|
116
128
|
parsedEvents.push({
|
|
117
129
|
event: {
|
|
118
130
|
time: time.iso,
|
|
@@ -123,12 +135,16 @@ export function parseSwissPostCargoResponse(payload, rawTrackingNumber, now = Da
|
|
|
123
135
|
},
|
|
124
136
|
status: classified.status,
|
|
125
137
|
timestamp: time.timestamp,
|
|
138
|
+
proof,
|
|
126
139
|
index,
|
|
127
140
|
});
|
|
128
141
|
index += 1;
|
|
129
142
|
}
|
|
130
143
|
}
|
|
131
|
-
|
|
144
|
+
// Ties keep the carrier's order, except that a photo or signature never
|
|
145
|
+
// passes a scan made at the same instant.
|
|
146
|
+
parsedEvents.sort((left, right) => right.timestamp - left.timestamp
|
|
147
|
+
|| Number(left.proof) - Number(right.proof) || left.index - right.index);
|
|
132
148
|
const events = parsedEvents.slice(0, 100);
|
|
133
149
|
if (events.length === 0) {
|
|
134
150
|
throw new SchemaError(PROVIDER, 'Swiss Post Cargo returned no usable tracking events');
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
{ "code": "DLV", "stage": "delivered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Delivery scan; appears with the wording 'Delivered'." },
|
|
5
5
|
{ "code": "POD", "wording": "DELIVERED SCANNED", "language": "en", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Proof-of-delivery scan; seen live with the delivery wording." },
|
|
6
6
|
{ "code": "P40", "stage": "delivered", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Proof-of-delivery family carried over from the adapter's original map; not re-confirmed against a capture." },
|
|
7
|
-
{ "code": "IMG", "wording": "IMAGE", "language": "en", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "A delivery picture: live histories show it just before the delivery scan.
|
|
8
|
-
{ "code": "SIG", "wording": "SIGNATURE", "language": "en", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Signed for, at the instant of the delivery scan; the signature itself is never retained." },
|
|
7
|
+
{ "code": "IMG", "wording": "IMAGE", "language": "en", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "A delivery picture: live histories show it at or just before the delivery scan's instant, listed ahead of it. Folded into the barcode's delivery scan; the picture itself is never retained." },
|
|
8
|
+
{ "code": "SIG", "wording": "SIGNATURE", "language": "en", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Signed for, at the instant of the delivery scan. Folded into the barcode's delivery scan; the signature itself is never retained." },
|
|
9
9
|
{ "code": "SCA", "wording": "Loaded for Delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Delivery-round scan." },
|
|
10
10
|
{ "code": "NTF", "wording": "Data entry POST", "language": "en", "stage": "registered", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Shipment announced." },
|
|
11
11
|
{ "code": "RFS", "wording": "Received warehouse POST", "language": "en", "stage": "accepted", "confirmedBy": "live", "firstSeen": "2026-08-30", "note": "Accompanies the wording 'Shipment accepted'." },
|
|
@@ -157,12 +157,13 @@ export const adapter = environment => {
|
|
|
157
157
|
const options = { token: environment.env.UKRPOSHTA_TRACKING_TOKEN?.trim() || APPLICATION_BEARER,
|
|
158
158
|
userAgent: environment.userAgent, fetcher: environment.fetcher, recorder: environment.recorder };
|
|
159
159
|
const tracker = new UkrposhtaTracker({ ...options, executablePath: environment.browserExecutablePath });
|
|
160
|
-
// Recognition is plain HTTP: the status API alone.
|
|
160
|
+
// Recognition is plain HTTP: the status API alone. A recognition budget can be
|
|
161
|
+
// longer than the minute a lookup takes at most.
|
|
161
162
|
const statusApi = new UkrposhtaTracker({ ...options, executablePath: null });
|
|
162
163
|
return { id: 'ukrposhta', recordsSteps: true, steps: ['direct', 'browser'], track: (input, context) => tracker.fetch(input.number, context),
|
|
163
164
|
recognize: (number, context) => recognizeFromLookup(async () => {
|
|
164
165
|
try {
|
|
165
|
-
return await statusApi.fetch(number, context);
|
|
166
|
+
return await statusApi.fetch(number, { ...context, budgetMs: Math.min(context?.budgetMs ?? 45_000, 60_000) });
|
|
166
167
|
}
|
|
167
168
|
catch (error) {
|
|
168
169
|
// The portal reads the same domestic records, so the status API's miss means Ukrposhta
|
|
@@ -28,8 +28,11 @@ export function deliveryHandoff(carrier, number, result) {
|
|
|
28
28
|
// S10 binds the postal identity across borders. Its issuer suffix does not
|
|
29
29
|
// identify the destination operator; try that country's post only when no
|
|
30
30
|
// partner is named, and let the shared progress/freshness checks confirm it.
|
|
31
|
+
// Japan Post does not track `U`-prefixed customs labels.
|
|
31
32
|
if (!target && isValidS10TrackingNumber(reference)) {
|
|
32
33
|
target = nationalPostCandidate(destination);
|
|
34
|
+
if (target === 'japan-post' && reference.startsWith('U'))
|
|
35
|
+
target = undefined;
|
|
33
36
|
if (target)
|
|
34
37
|
basis = 'destination';
|
|
35
38
|
}
|
|
@@ -22,14 +22,15 @@ const CATALOG_NAMES = new Set([
|
|
|
22
22
|
]);
|
|
23
23
|
// Postal lookup candidates, not proof of which operator delivers a shipment.
|
|
24
24
|
// Only operators whose dedicated adapter looks up mail from abroad by its S10
|
|
25
|
-
// number are useful here
|
|
26
|
-
//
|
|
25
|
+
// number are useful here: India Post's, Pos Malaysia's, CTT's and Poste
|
|
26
|
+
// Italiane's take only numbers of their own. Poczta Polska and Ukrposhta give
|
|
27
|
+
// wall clocks without an offset, so a host cannot tell their scans follow the
|
|
28
|
+
// origin's. The host also checks adapter availability and required inputs
|
|
29
|
+
// before making a request.
|
|
27
30
|
const NATIONAL_POSTS = new Map([
|
|
28
31
|
['AU', 'australia-post'], ['CA', 'canada-post'], ['CH', 'swiss-post'], ['DE', 'dhl'],
|
|
29
|
-
['ES', 'correos-spain'], ['FI', 'posti'], ['FR', 'la-poste'],
|
|
30
|
-
['
|
|
31
|
-
['JP', 'japan-post'], ['MY', 'pos-malaysia'], ['NL', 'spring-gds'], ['PL', 'poczta-polska'], ['PT', 'ctt'], ['SE', 'postnord'],
|
|
32
|
-
['UA', 'ukrposhta'], ['US', 'usps'],
|
|
32
|
+
['ES', 'correos-spain'], ['FI', 'posti'], ['FR', 'la-poste'], ['GB', 'royal-mail'],
|
|
33
|
+
['JP', 'japan-post'], ['NL', 'spring-gds'], ['SE', 'postnord'], ['US', 'usps'],
|
|
33
34
|
]);
|
|
34
35
|
const englishCountries = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
35
36
|
// Deprecated codes carry their successor's name, so fold them into it ("FX" is France).
|
|
@@ -107,6 +107,32 @@ const HOLD = String.raw `\b(?:on hold|held|hold|holding|retenu(?:e|s|es)?|en att
|
|
|
107
107
|
const RECIPIENT_HOLD = new RegExp(`${HOLD}${SENTENCE}*?${RECIPIENT_REQUEST}|${RECIPIENT_REQUEST}${SENTENCE}*?${HOLD}`);
|
|
108
108
|
/** Where a held parcel waits for the recipient to collect it. */
|
|
109
109
|
const COUNTER = /post office|postal outlet|pick ?up|collection|counter|retail (?:location|outlet)|bureau de poste|point (?:de )?retrait|point relais|filiale|abholung|ufficio postale|ritiro|oficina|recogida|levantamento|agencia/;
|
|
110
|
+
/** The recipient's side by its role, not a service desk or an agent acting for it. */
|
|
111
|
+
const RECIPIENT = String.raw `(?:recipient|addressee|consignee|customer|receiver)s?\b(?!'| (?:service|care|support)\b)`;
|
|
112
|
+
/**
|
|
113
|
+
* Where only a recipient collects a parcel. Parcel shops, service points and
|
|
114
|
+
* access points also take in the drop-offs a courier then collects ("Picked
|
|
115
|
+
* up at parcelshop", "collected from the ServicePoint"), so a collection there
|
|
116
|
+
* has to name the recipient.
|
|
117
|
+
*/
|
|
118
|
+
const COLLECTION_PLACE = String.raw `(?:pick ?up (?:point|station|parcel ?shop|locker)|collection point|(?:parcel )?lockers?|packstation|(?:post office |postal )?counter)\b`;
|
|
119
|
+
const COLLECTED = String.raw `(?:picked up|collected)`;
|
|
120
|
+
/**
|
|
121
|
+
* A parcel collected from its pickup point: by the recipient, the consignee or
|
|
122
|
+
* the customer, or from a pickup point, locker or counter, in each language.
|
|
123
|
+
*/
|
|
124
|
+
const RECIPIENT_COLLECTED = new RegExp([
|
|
125
|
+
String.raw `\b${COLLECTED}(?: [\w'-]+){0,6}? by (?:the |a |an |its |your )?${RECIPIENT}|\b${RECIPIENT} (?:has |have )?(?:already )?${COLLECTED}\b|\b${COLLECTED} (?:from|at|in) (?:the |a |an |its |your |our )?(?:[\w-]+ ){0,2}?${COLLECTION_PLACE}`,
|
|
126
|
+
String.raw `\bretire(?:e|s|es)?\b${SENTENCE}*? par (?:le |la |l'|son |sa )?(?:destinataire|cliente?)\b|\b(?:destinataire|cliente?) a retire\b|\bretire(?:e|s|es)? (?:au |en |dans (?:le |la |l'|un |une |votre |son )|a la |a l')(?:point (?:de )?retrait|point relais|relais|consigne|locker|bureau de poste)\b`,
|
|
127
|
+
String.raw `\b(?:vom|von (?:der|dem)|durch (?:den|die)) (?:empfanger(?:in)?|kunden|kundin|adressaten|adressatin)\b${SENTENCE}*?\babgeholt\b|\b(?:empfanger(?:in)?|kunde|kundin) hat\b${SENTENCE}*?\babgeholt\b|\b(?:in|aus) (?:der|dem|einer|einem|ihrer|ihrem) (?:[\w-]+ )?(?:filiale|postfiliale|packstation|paketstation|abholstation|postamt)\b${SENTENCE}*?\babgeholt\b`,
|
|
128
|
+
String.raw `\britirat[oaie]\b${SENTENCE}*? dal(?:la)? (?:destinatari[oa]|cliente)\b|\b(?:destinatari[oa]|cliente) ha ritirato\b|\britirat[oaie] (?:presso|in|al|nel|nella) (?:il |la |lo |l')?(?:punto (?:di )?ritiro|locker|ufficio postale)\b`,
|
|
129
|
+
String.raw `\b(?:recogid|retirad)[oa]s? por (?:el |la )?destinatari[oa]\b|\bdestinatari[oa] (?:ya )?ha (?:recogido|retirado)\b|\b(?:levantad|retirad)[oa]s? pel[oa] destinatari[oa]\b|\bodebran[aeoy] przez (?:odbiorce|adresata)\b`,
|
|
130
|
+
].join('|'));
|
|
131
|
+
const COLLECT_VERB = String.raw `(?:picked up|collected|retire|abgeholt|ritirat|recogid|retirad|levantad|odebran)`;
|
|
132
|
+
/** A collection negated, still to come or made a condition, in each language. */
|
|
133
|
+
const COLLECTION_PENDING = new RegExp(String.raw `(?:\b(?:not|never|nothing|non|pas|jamais|nicht|nao|nie)\b|n't)${SENTENCE}*?${COLLECT_VERB}|\bno (?:(?:ha|han|fue|sido|se) )*(?:recogid|retirad)|\b(?:be|being|once|when|until|till|if|unless|before|etre|sera|seront|serait|essere|sara|saranno|verra|verranno)(?: [\w']+){0,3}? ${COLLECT_VERB}|\babgeholt (?:werden|wird)\b|\b(?:wird|werden|kann|konnen|muss|soll)\b${SENTENCE}*?\babgeholt\b`);
|
|
134
|
+
/** A courier, a driver or the sender's side collecting it, in each language. */
|
|
135
|
+
const COLLECTED_BY_CARRIER = new RegExp(String.raw `\b(?:by|from|par|de|du|vom|von|beim|dal|dalla|dallo|por|del|pelo|przez) (?:the |a |an |our |your |le |la |l'|den |der |dem |il |lo |el |o )?(?:[\w-]+ )?(?:couriers?|drivers?|carriers?|senders?|shippers?|sellers?|merchants?|consignors?|chauffeur|livreur|transporteur|coursier|expediteur|\w*(?:fahrer|kurier|zusteller|boten?)|absender|corriere|autista|vettore|mittente|repartidor|mensajero|transportista|remitente|motorista|estafeta|remetente|kuriera|nadawcy)\b(?!')`);
|
|
110
136
|
/**
|
|
111
137
|
* A shipment, order or label cancelled, and nothing else in the scan, in each
|
|
112
138
|
* language and Turkish ("İptal Edildi"). A cancelled return, pickup or hold,
|
|
@@ -193,6 +219,11 @@ export function trackingLanguageStage(description) {
|
|
|
193
219
|
// admisión", a parcel the sender dropped in a CityPaq locker.
|
|
194
220
|
if (/\bpara (?:su )?(?:admision|admissao)\b|\b(?:deposited|dropped off)\b(?: [\w']+){0,8}? for (?:admission|acceptance)\b/.test(text))
|
|
195
221
|
return 'registered';
|
|
222
|
+
// The recipient collecting the parcel ends the delivery, ahead of the drop-off
|
|
223
|
+
// at the pickup point the scan may also name; the courier's or the sender's
|
|
224
|
+
// collection is an acceptance below.
|
|
225
|
+
if (RECIPIENT_COLLECTED.test(text) && !COLLECTION_PENDING.test(text) && !COLLECTED_BY_CARRIER.test(text))
|
|
226
|
+
return 'delivered';
|
|
196
227
|
// "Delivered to" a pickup point or an intermediary is not delivery to the
|
|
197
228
|
// recipient; a parcel already waiting at its pickup point outranks the
|
|
198
229
|
// "sera remis" (will be handed over) that follows in La Poste's sentence.
|
|
@@ -266,7 +297,7 @@ export function trackingLanguageStage(description) {
|
|
|
266
297
|
// A delivery still to come ("una vez entregado", "que se entregue", "quando
|
|
267
298
|
// for entregue") is not one; Spanish "entregue" is also a subjunctive.
|
|
268
299
|
if (!/\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(text)
|
|
269
|
-
&& /(?<!\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
|
|
300
|
+
&& /(?<!\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/.test(text))
|
|
270
301
|
return 'delivered';
|
|
271
302
|
// A handover to another network, a carrier or a courier set apart, takes the parcel on.
|
|
272
303
|
if (HANDED_TO_NETWORK.test(text))
|
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.
|
|
3
|
+
"version": "0.55.0-main.708",
|
|
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",
|