universal-parcel-scraper 0.652.1-main.3103 → 0.652.3-main.3107

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
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <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">
157
157
 
158
- The carrier folders record 16,472 such statuses, each filed under one stage.
158
+ The carrier folders record 16,475 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "carrier": "dayone",
3
3
  "entries": [
4
+ {"wording": "Shipment has begun. In transit to destination city", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The single scan of a parcel the tracker lists with a YunExpress number, whose tracker carries the rest of its history."},
4
5
  {"wording": "Order processed by shipper", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Yanwen's record of the merchant's order data, the first scan."},
5
6
  {"wording": "SHIPPING INFORMATION RECEIVED", "stage": "registered", "language": "en", "confirmedBy": "live"},
6
7
  {"wording": "SHIPPING INFORMATION RECEIVED BY AUSTRALIA POST", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Australia Post's manifest, relayed: it comes after the parcel has moved and is no step back."},
@@ -14,6 +14,7 @@ const CODES = new Map([
14
14
  ['FPX_C_AAF', { status: 'in_transit', stage: 'in_transit' }],
15
15
  ['FPX_C_ADFF', { status: 'in_transit', stage: 'in_transit' }],
16
16
  ['FPX_F_ST', { status: 'in_transit', stage: 'in_transit' }],
17
+ ['FPX_M_PT', { status: 'in_transit', stage: 'in_transit' }], // Left the origin facility, awaiting the line haul.
17
18
  ['FPX_M_HA', { status: 'in_transit', stage: 'in_transit' }],
18
19
  ['FPX_M_DFOA', { status: 'in_transit', stage: 'in_transit' }],
19
20
  ['FPX_M_ATA', { status: 'in_transit', stage: 'in_transit' }],
@@ -23,6 +24,7 @@ const CODES = new Map([
23
24
  ['FPX_D_AOPC', { status: 'in_transit', stage: 'in_transit' }],
24
25
  ['FPX_D_STPP', { status: 'in_transit', stage: 'in_transit' }],
25
26
  ['FPX_D_HQ', { status: 'in_transit', stage: 'in_transit' }],
27
+ ['FPX_D_DFD', { status: 'in_transit', stage: 'in_transit' }], // Left the destination's national sorting centre.
26
28
  ['FPX_O_RR', { status: 'in_transit', stage: 'in_transit' }],
27
29
  ['FPX_M_IT', { status: 'in_transit', stage: 'in_transit' }],
28
30
  ]);
@@ -302,6 +302,24 @@
302
302
  "confirmedBy": "live",
303
303
  "firstSeen": "2026-10-19",
304
304
  "note": "The pickup shop took the parcel over; relayed from DPD."
305
+ },
306
+ {
307
+ "code": "FPX_M_PT",
308
+ "wording": "Pending transportation",
309
+ "language": "en",
310
+ "stage": "in_transit",
311
+ "confirmedBy": "live",
312
+ "firstSeen": "2026-10-22",
313
+ "note": "After the departure from the origin facility, before the hand-over to the airline."
314
+ },
315
+ {
316
+ "code": "FPX_D_DFD",
317
+ "wording": "Out from national sorting center",
318
+ "language": "en",
319
+ "stage": "in_transit",
320
+ "confirmedBy": "live",
321
+ "firstSeen": "2026-10-22",
322
+ "note": "After import clearance, the day before the last mile's out-for-delivery scan."
305
323
  }
306
324
  ]
307
325
  }
@@ -1,4 +1,5 @@
1
1
  import { load } from 'cheerio';
2
+ import { DateTime, FixedOffsetZone } from 'luxon';
2
3
  import makeFetchCookie from 'fetch-cookie';
3
4
  import { CookieJar } from 'tough-cookie';
4
5
  import { carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
@@ -295,30 +296,44 @@ export function parseUPSTrackingHtml(page, trackingNumber) {
295
296
  function text(value, maxLength) {
296
297
  return clean(typeof value === 'string' ? decodeEntities(value) : value, maxLength);
297
298
  }
299
+ /** UPS's offset of a scan's place, in minutes. */
300
+ function statedOffset(activity) {
301
+ const offset = /^([+-])(0\d|1[0-4]):([0-5]\d)$/.exec(cleanScalar(activity.gmtOffset));
302
+ return offset ? (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3])) : null;
303
+ }
298
304
  /**
299
305
  * A scan's clock. UPS sends the same scan twice: a UTC pair (`gmtDate` /
300
- * `gmtTime`) and a local pair with a `gmtOffset`. Either becomes an
301
- * offset-carrying ISO string, so no zone is ever guessed. A local pair without
302
- * an offset stays a `local_time`, a scan UPS dates by its day alone, as it does
303
- * some drop-offs, keeps that day, other text stays as UPS wrote it, and a scan
304
- * with no clock at all, like some label scans, gets none.
306
+ * `gmtTime`) and a local pair with a `gmtOffset`. The UTC pair's instant, to
307
+ * the second, is written on that offset when the local pair reads the same
308
+ * minute on it, so the scan keeps its place's clock; otherwise it stays in
309
+ * UTC. A zero offset is written as UTC was, `+00:00`. Without the UTC pair,
310
+ * the local pair and its offset become the time, and no zone is ever guessed.
311
+ * A local pair without an offset stays a `local_time`, a scan UPS dates by
312
+ * its day alone, as it does some drop-offs, keeps that day, other text stays
313
+ * as UPS wrote it, and a scan with no clock at all, like some label scans,
314
+ * gets none.
305
315
  */
306
316
  function activityClock(activity) {
307
- const gmtDate = cleanScalar(activity.gmtDate);
308
- const gmtTime = cleanScalar(activity.gmtTime);
309
- if (/^\d{8}$/.test(gmtDate) && /^\d{2}:\d{2}:\d{2}$/.test(gmtTime)) {
310
- return { time: `${gmtDate.slice(0, 4)}-${gmtDate.slice(4, 6)}-${gmtDate.slice(6, 8)}T${gmtTime}+00:00` };
311
- }
312
317
  const localDate = cleanScalar(activity.date);
313
318
  const localTime = cleanScalar(activity.time);
314
319
  const dateMatch = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(localDate);
315
320
  const timeMatch = /^(\d{1,2}):([0-5]\d)\s*([AP])\.?M\.?$/i.exec(localTime);
316
321
  const hour = Number(timeMatch?.[1]);
317
- if (dateMatch && timeMatch && hour >= 1 && hour <= 12) {
318
- const hours = String(hour % 12 + (timeMatch[3].toUpperCase() === 'P' ? 12 : 0)).padStart(2, '0');
319
- const local = `${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}T${hours}:${timeMatch[2]}:00`;
320
- const offset = cleanScalar(activity.gmtOffset);
321
- return /^[+-]\d{2}:\d{2}$/.test(offset) ? { time: `${local}${offset}` } : { local_time: local };
322
+ const local = dateMatch && timeMatch && hour >= 1 && hour <= 12
323
+ ? `${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}T${String(hour % 12 + (timeMatch[3].toUpperCase() === 'P' ? 12 : 0)).padStart(2, '0')}:${timeMatch[2]}:00`
324
+ : null;
325
+ const offset = statedOffset(activity);
326
+ const gmtDate = cleanScalar(activity.gmtDate);
327
+ const gmtTime = cleanScalar(activity.gmtTime);
328
+ if (/^\d{8}$/.test(gmtDate) && /^\d{2}:\d{2}:\d{2}$/.test(gmtTime)) {
329
+ const utc = `${gmtDate.slice(0, 4)}-${gmtDate.slice(4, 6)}-${gmtDate.slice(6, 8)}T${gmtTime}`;
330
+ const shown = offset ? DateTime.fromISO(utc, { zone: 'utc' }).setZone(FixedOffsetZone.instance(offset)) : null;
331
+ return { time: shown?.isValid && shown.toFormat("yyyy-MM-dd'T'HH:mm':00'") === local
332
+ ? shown.toISO({ suppressMilliseconds: true }) : `${utc}+00:00` };
333
+ }
334
+ if (local) {
335
+ const stated = cleanScalar(activity.gmtOffset);
336
+ return /^[+-]\d{2}:\d{2}$/.test(stated) ? { time: `${local}${stated}` } : { local_time: local };
322
337
  }
323
338
  const day = dateMatch && !localTime ? calendarDay(Number(dateMatch[3]), Number(dateMatch[1]), Number(dateMatch[2])) : null;
324
339
  if (day)
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.652.1"
5
+ "version": "0.652.3"
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.652.1-main.3103",
3
+ "version": "0.652.3-main.3107",
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",