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.
@@ -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, which no rule claims."
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
- parsedEvents.sort((left, right) => right.timestamp - left.timestamp || left.index - right.index);
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. 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; 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; Poste Italiane's takes only its own identifiers. The
26
- // host also checks adapter availability and required inputs before making a request.
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
- ['GB', 'royal-mail'], ['IN', 'india-post'],
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|\brecogid[oa] por (?:el |la )?destinatari[oa]\b|\b(?:levantad|retirad)[oa] pelo destinatario\b/.test(text))
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))
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.54.0"
5
+ "version": "0.55.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.54.0-main.702",
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",