universal-parcel-scraper 0.55.0-main.706 → 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.
@@ -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'." },
@@ -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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.55.0-main.706",
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",