universal-parcel-scraper 0.49.2-main.550 → 0.49.2-main.552

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
@@ -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,758 such statuses, each filed under one stage.
152
+ The carrier folders record 2,766 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.
@@ -15,16 +15,20 @@
15
15
  * does echo the delivery postcode, which must agree with the requested one.
16
16
  *
17
17
  * Privacy: the loader payload carries the retailer, the recipient's name,
18
- * e-mail, phone and address, and per-event `variables` that repeat them.
19
- * `parse()` copies nothing from those objects: each event is rebuilt from its
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, and the
22
- * delivery window.
18
+ * e-mail, phone and address, the PaackGo Point pickup code and QR link, and
19
+ * per-event `variables` that repeat them. `parse()` copies nothing from those:
20
+ * each event is rebuilt from its timestamp, our own description and the mapped
21
+ * stage, and the only order-level fields read are the delivery postcode,
22
+ * compared and never copied, the delivery country, which picks the window's
23
+ * clock, the delivery window, and the PaackGo Point's name and address while
24
+ * the parcel waits there.
23
25
  */
24
26
  import { load } from 'cheerio';
27
+ import { DateTime } from 'luxon';
25
28
  import { lookupBudget } from '../../core/adapter/index.js';
26
29
  import { IndeterminateError, InputRequiredError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
27
- import { decodeText, fetchBounded, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
30
+ import { countryCode, countryTimeZone, explicitOffsetTime } from '../../core/time/index.js';
31
+ import { clean, decodeText, fetchBounded, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
28
32
  import { isRecord } from '../../core/types.js';
29
33
  import { classifyPaackEvent } from './status.js';
30
34
  const TRACKING_ENDPOINT = 'https://mydeliveries.paack.app/tracking/order';
@@ -32,6 +36,7 @@ const DEFAULT_TIMEOUT_MS = 15_000;
32
36
  const MAX_RESPONSE_BYTES = 2_000_000;
33
37
  const MAX_EVENTS = 100;
34
38
  const MAX_REDIRECTS = 1;
39
+ const DEFAULT_ZONE = 'Europe/Paris';
35
40
  const PAACK_HOST = /(?:^|\.)paack\.(?:app|co)$/;
36
41
  const NOT_FOUND_PATTERN = /order not found|incorrect order number|commande introuvable|pedido no encontrado/i;
37
42
  const NOT_FOUND_PAGE_PATTERN = /order not found|incorrect order number or postal code|commande introuvable|pedido no encontrado/i;
@@ -68,6 +73,34 @@ function normalizedDate(value) {
68
73
  }
69
74
  return normalizedTimestamp(value)?.iso.slice(0, 10) ?? null;
70
75
  }
76
+ /**
77
+ * The clock the page shows the delivery window on: the delivery country's, and
78
+ * the Canary Islands' for Spanish postcodes 35 and 38.
79
+ */
80
+ function deliveryZone(country, postcode) {
81
+ return countryCode(country) === 'ES' && /^3[58]\d{3}$/.test(postcode)
82
+ ? 'Atlantic/Canary'
83
+ : countryTimeZone(country) ?? DEFAULT_ZONE;
84
+ }
85
+ /**
86
+ * `expected_delivery_ts` as the page shows it: `YYYY-MM-DD HH:MM–HH:MM` on the
87
+ * delivery clock when both ends are instants within one day, else the day
88
+ * alone. A window that ended before the newest scan is dropped.
89
+ */
90
+ function expectedDelivery(window, zone, newest) {
91
+ const start = explicitOffsetTime(window.start);
92
+ const end = explicitOffsetTime(window.end) ?? start;
93
+ if (!end)
94
+ return normalizedDate(window.end ?? window.start);
95
+ if (end.timestamp < newest)
96
+ return null;
97
+ const clock = (time) => DateTime.fromMillis(time.timestamp, { zone }).toFormat('yyyy-MM-dd HH:mm');
98
+ const last = clock(end);
99
+ const first = start ? clock(start) : last;
100
+ return first.slice(0, 10) === last.slice(0, 10) && first < last
101
+ ? `${first}–${last.slice(11)}`
102
+ : last.slice(0, 10);
103
+ }
71
104
  function routeData(payload) {
72
105
  if (!isRecord(payload))
73
106
  throw new SchemaError('Paack', 'Paack returned an invalid tracking response');
@@ -165,6 +198,22 @@ function redirectTarget(location, from, trackingNumber, postcode) {
165
198
  return null;
166
199
  return { url: trackingOrderUrl(`${target.origin}${target.pathname}`, trackingNumber, postcode) };
167
200
  }
201
+ /**
202
+ * The PaackGo Point the page names: `pudo_name`, then `pudo_address` on the
203
+ * following lines. The address is one string Paack formats itself; the page
204
+ * prints it as it comes and breaks it only where it holds a line break. The
205
+ * pickup code (`pudo_passcode`), QR link and collection deadline beside them
206
+ * are never read.
207
+ */
208
+ function pickupPoint(order) {
209
+ const name = clean(order.pudo_name, 120);
210
+ if (!name)
211
+ return '';
212
+ const address = typeof order.pudo_address === 'string'
213
+ ? order.pudo_address.split('\n').map((line) => clean(line, 200)).filter(Boolean)
214
+ : [];
215
+ return [name, ...address].join('\n');
216
+ }
168
217
  /** Letters and digits only, so "SW1A 1AA" and "4445-027" compare by content. */
169
218
  function postcodeKey(value) {
170
219
  return value.toLocaleUpperCase('en-US').replace(/[^A-Z0-9]/g, '');
@@ -222,16 +271,20 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
222
271
  const activeTime = activeEvent
223
272
  ? normalizedTimestamp(activeEvent.timestamp ?? activeEvent.time)
224
273
  : null;
225
- const expected = isRecord(order.expected_delivery_ts)
226
- ? normalizedDate(order.expected_delivery_ts.end ?? order.expected_delivery_ts.start)
274
+ const zone = deliveryZone(address?.country, postcode);
275
+ const newest = Math.max(activeTime?.timestamp ?? -Infinity, parsedEvents[0]?.timestamp ?? -Infinity);
276
+ const expected = isRecord(order.expected_delivery_ts) && !['delivered', 'exception'].includes(current.status)
277
+ ? expectedDelivery(order.expected_delivery_ts, zone, newest)
227
278
  : null;
279
+ const pickup = current.stage === 'ready_for_pickup' ? pickupPoint(order) : '';
228
280
  return {
229
281
  status: current.status,
230
282
  current_stage: current.stage,
231
283
  last_status_text: current.description,
232
284
  last_update: activeTime?.iso ?? events[0]?.time ?? null,
233
- expected_delivery: ['delivered', 'exception'].includes(current.status) ? null : expected,
234
- timezone: 'Europe/Paris',
285
+ expected_delivery: expected,
286
+ ...(pickup ? { pickup_point: pickup } : {}),
287
+ timezone: zone,
235
288
  events,
236
289
  };
237
290
  }
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "capabilities": [
42
42
  "history",
43
- "eta"
43
+ "eta",
44
+ "pickup_point"
44
45
  ],
45
46
  "portal": {
46
47
  "url": "https://mydeliveries.paack.app/tracking?tracking_number={trackingNumber}",
@@ -49,6 +50,9 @@
49
50
  "status",
50
51
  "history",
51
52
  "eta",
53
+ "eta_window",
54
+ "pickup_point",
55
+ "pickup_code",
52
56
  "sender_name",
53
57
  "recipient_name",
54
58
  "recipient_address"
@@ -56,9 +60,12 @@
56
60
  "scraped": [
57
61
  "status",
58
62
  "history",
59
- "eta"
63
+ "eta",
64
+ "eta_window",
65
+ "pickup_point"
60
66
  ],
61
67
  "discarded": [
68
+ "pickup_code",
62
69
  "sender_name",
63
70
  "recipient_name",
64
71
  "recipient_address"
@@ -10,6 +10,10 @@
10
10
  *
11
11
  * Order matters: return and failure identifiers are tested before the broader
12
12
  * delivery ones, so "notDelivered" can never match the "delivered" substring.
13
+ * PaackGo Point identifiers come from the page's own vocabulary and are tested
14
+ * before the generic words they contain: "rejectedByPudo" is a retried
15
+ * delivery, not a rejection, and "collectedByCustomer" is the recipient
16
+ * collecting the parcel, not a transit collection.
13
17
  * The provider's own wording is never returned; each mapped entry supplies the
14
18
  * English description we display.
15
19
  *
@@ -15,6 +15,7 @@ const OUT_FOR_DELIVERY = {
15
15
  description: 'Out for delivery',
16
16
  };
17
17
  const DELIVERED = { status: 'delivered', stage: 'delivered', description: 'Delivered' };
18
+ const DELIVERY_ISSUE = { status: 'exception', stage: 'failed_attempt', description: 'Delivery issue' };
18
19
  /** Paack's English timeline labels, keyed like `statusKey`, with the identifier each one renders. */
19
20
  const RELAYED = new Map([
20
21
  ['orderdetailsreceived', REGISTERED], // manifested
@@ -36,6 +37,18 @@ export function classifyPaackEvent(value) {
36
37
  ])) {
37
38
  return { status: 'exception', stage: 'returned', description: 'Shipment returned' };
38
39
  }
40
+ // Expired, refused or unpaid at the PaackGo Point: on its way back to the retailer.
41
+ if (includesAny(key, [
42
+ 'inpudotoreturnexpired',
43
+ 'inpudotoreturnrejected',
44
+ 'inpudotoreturncodnotaccepted',
45
+ ])) {
46
+ return { status: 'exception', stage: 'returned', description: 'Returning to sender' };
47
+ }
48
+ // The point was closed, full or refused the parcel; Paack tries again.
49
+ if (includesAny(key, ['pudoclosed', 'pudofull', 'rejectedbypudo'])) {
50
+ return { ...DELIVERY_ISSUE };
51
+ }
39
52
  if (includesAny(key, [
40
53
  'incorrectaddress',
41
54
  'notaccepted',
@@ -59,10 +72,10 @@ export function classifyPaackEvent(value) {
59
72
  'notdelivered',
60
73
  'undelivered',
61
74
  ]))
62
- return { status: 'exception', stage: 'failed_attempt', description: 'Delivery issue' };
63
- if (includesAny(key, ['delivered', 'deliverycompleted']))
75
+ return { ...DELIVERY_ISSUE };
76
+ if (includesAny(key, ['delivered', 'deliverycompleted', 'collectedbycustomer']))
64
77
  return { ...DELIVERED };
65
- if (includesAny(key, ['readyforpickup', 'atpickuppoint'])) {
78
+ if (includesAny(key, ['droppedinpudo'])) {
66
79
  return { status: 'out_for_delivery', stage: 'ready_for_pickup', description: 'Ready for pickup' };
67
80
  }
68
81
  if (includesAny(key, ['outfordelivery', 'indelivery', 'driverassigned', 'inprogress']))
@@ -25,6 +25,54 @@
25
25
  "firstSeen": "2026-09-12",
26
26
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
27
27
  },
28
+ {
29
+ "code": "inpudotoreturnexpired",
30
+ "language": "en",
31
+ "stage": "returned",
32
+ "confirmedBy": "bundle",
33
+ "firstSeen": "2026-10-08",
34
+ "note": "Page label inPudoToReturnExpired, \"Expired at PaackGo Point\": not collected in time, returned to the retailer."
35
+ },
36
+ {
37
+ "code": "inpudotoreturnrejected",
38
+ "language": "en",
39
+ "stage": "returned",
40
+ "confirmedBy": "bundle",
41
+ "firstSeen": "2026-10-08",
42
+ "note": "Page label inPudoToReturnRejected: refused at the PaackGo Point, returned to the retailer. Tested before the rejected exception rule."
43
+ },
44
+ {
45
+ "code": "inpudotoreturncodnotaccepted",
46
+ "language": "en",
47
+ "stage": "returned",
48
+ "confirmedBy": "bundle",
49
+ "firstSeen": "2026-10-08",
50
+ "note": "Page label inPudoToReturnCODNotAccepted: cash on delivery not paid at the PaackGo Point, returned to the retailer."
51
+ },
52
+ {
53
+ "code": "pudoclosed",
54
+ "language": "en",
55
+ "stage": "failed_attempt",
56
+ "confirmedBy": "bundle",
57
+ "firstSeen": "2026-10-08",
58
+ "note": "Page label pudoClosed, \"Has been rescheduled\": the PaackGo Point was closed; Paack tries again."
59
+ },
60
+ {
61
+ "code": "pudofull",
62
+ "language": "en",
63
+ "stage": "failed_attempt",
64
+ "confirmedBy": "bundle",
65
+ "firstSeen": "2026-10-08",
66
+ "note": "Page label pudoFull, \"Has been rescheduled\": the PaackGo Point was full; Paack tries again."
67
+ },
68
+ {
69
+ "code": "rejectedbypudo",
70
+ "language": "en",
71
+ "stage": "failed_attempt",
72
+ "confirmedBy": "bundle",
73
+ "firstSeen": "2026-10-08",
74
+ "note": "Page label rejectedByPudo, \"Has been rescheduled\": the PaackGo Point refused the parcel; Paack tries again. Tested before the rejected exception rule."
75
+ },
28
76
  {
29
77
  "code": "returntosenderscheduled",
30
78
  "language": "en",
@@ -145,6 +193,22 @@
145
193
  "firstSeen": "2026-09-12",
146
194
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded. Reclassified to exception 2026-09-12."
147
195
  },
196
+ {
197
+ "code": "inpudotoreturndamaged",
198
+ "language": "en",
199
+ "stage": "exception",
200
+ "confirmedBy": "bundle",
201
+ "firstSeen": "2026-10-08",
202
+ "note": "Page label inPudoToReturnDamaged, \"Logistic issue\": no longer available at the PaackGo Point. Reached through damaged."
203
+ },
204
+ {
205
+ "code": "lostbypudo",
206
+ "language": "en",
207
+ "stage": "exception",
208
+ "confirmedBy": "bundle",
209
+ "firstSeen": "2026-10-08",
210
+ "note": "Page label lostByPudo, \"Logistic issue\". Reached through lost."
211
+ },
148
212
  {
149
213
  "code": "integrationerror",
150
214
  "language": "en",
@@ -186,20 +250,20 @@
186
250
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
187
251
  },
188
252
  {
189
- "code": "readyforpickup",
253
+ "code": "collectedbycustomer",
190
254
  "language": "en",
191
- "stage": "ready_for_pickup",
192
- "confirmedBy": "prior-art",
193
- "firstSeen": "2026-09-12",
194
- "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
255
+ "stage": "delivered",
256
+ "confirmedBy": "bundle",
257
+ "firstSeen": "2026-10-08",
258
+ "note": "Page label collectedByCustomer, \"Collected from PaackGo Point\". Tested before the collected transit rule."
195
259
  },
196
260
  {
197
- "code": "atpickuppoint",
261
+ "code": "droppedinpudo",
198
262
  "language": "en",
199
263
  "stage": "ready_for_pickup",
200
- "confirmedBy": "prior-art",
201
- "firstSeen": "2026-09-12",
202
- "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
264
+ "confirmedBy": "bundle",
265
+ "firstSeen": "2026-10-08",
266
+ "note": "Page label droppedInPudo, \"At PaackGo Point\": the parcel waits at the point named by pudo_name and pudo_address."
203
267
  },
204
268
  {
205
269
  "code": "outfordelivery",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.2-main.550",
3
+ "version": "0.49.2-main.552",
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",