universal-parcel-scraper 0.764.1-main.4049 → 0.764.2-main.4053

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
@@ -161,7 +161,7 @@ the command line's `track --source ParcelsApp` does the same.
161
161
  <!-- GENERATED:stages -->
162
162
  <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">
163
163
 
164
- The carrier folders record 16,883 such statuses, each filed under one stage.
164
+ The carrier folders record 16,891 such statuses, each filed under one stage.
165
165
  <!-- /GENERATED:stages -->
166
166
 
167
167
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -31136,7 +31136,7 @@ function Ic(e) {
31136
31136
  if (n != null && typeof n != "string") throw TypeError("The carrier adapter returned an invalid tracking event");
31137
31137
  }
31138
31138
  let t = { ...e };
31139
- if (t.point !== void 0) {
31139
+ if (typeof t.location == "string" && !t.location.trim() && delete t.location, t.point !== void 0) {
31140
31140
  let e = Xn(t.point) ? Nc(t.point.latitude, t.point.longitude) : null;
31141
31141
  e ? t.point = e : delete t.point;
31142
31142
  }
@@ -41,6 +41,8 @@ const OBSERVED = [
41
41
  ['TA', 'Colis en cours de livraison par le livreur', 'out_for_delivery'],
42
42
  ['RB', 'Colis en cours de livraison au point de retrait', 'in_transit'],
43
43
  ['AB', 'Colis mis à disposition au point de retrait', 'ready_for_pickup'],
44
+ // The courier left the parcel at another pickup point than the one planned, where it waits.
45
+ ['AB', 'Colis mis à disposition dans un point de retrait différent de celui prévu', 'ready_for_pickup'],
44
46
  // The same arrival at the pickup point, also written under SD and under MD.
45
47
  ['SD', 'Colis mis à disposition au point de retrait', 'ready_for_pickup'],
46
48
  ['MD', 'Colis mis à disposition au point de retrait', 'ready_for_pickup'],
@@ -312,6 +312,15 @@
312
312
  "wording": "Colis mis à disposition au point de retrait",
313
313
  "language": "fr"
314
314
  },
315
+ {
316
+ "code": "AB",
317
+ "stage": "ready_for_pickup",
318
+ "confirmedBy": "live",
319
+ "firstSeen": "2026-10-27",
320
+ "note": "Chronopost trackSkybillV2; code mapping requires matching wording. The parcel waits at another pickup point than the one planned.",
321
+ "wording": "Colis mis à disposition dans un point de retrait différent de celui prévu",
322
+ "language": "fr"
323
+ },
315
324
  {
316
325
  "code": "SD",
317
326
  "stage": "ready_for_pickup",
@@ -6,7 +6,7 @@ import { zonedTime } from '../../core/time/index.js';
6
6
  import { clean, cleanScalar, fetchBounded, parseJsonBytes, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
7
7
  import { isRecord } from '../../core/types.js';
8
8
  import { isCorreosExpressCode } from '../correos-express/status.js';
9
- import { CORREOS_HOME_ZONE, correosOriginZone } from './clock.js';
9
+ import { CORREOS_HOME_ZONE, correosOriginZone, correosUnitZone } from './clock.js';
10
10
  import { classifyCorreosSpainStatus, correosSpainEnglishSummary, NOTICE_CODES } from './status.js';
11
11
  // Protocol provenance:
12
12
  // - Prior art (structure + vocabulary, verified independently below, MIT):
@@ -41,12 +41,11 @@ const STAMP_FORMAT = 'dd/MM/yyyy HH:mm:ss';
41
41
  /**
42
42
  * When a scan happened. Correos splits it into fecEvento (DD/MM/YYYY) and
43
43
  * horEvento (HH:MM:SS, midnight when absent), a wall clock, read in Spanish
44
- * time: mainland Spain is Europe/Madrid, and Canary Islands scans stamped the
45
- * same way can be off by one hour, as events carry no locality. An origin
46
- * post's scan on an item from abroad has that post's wall clock (clock.ts):
47
- * it is read in `origin`, that post's zone, or kept as a wall clock when
48
- * `origin` is null, as the post's country keeps several. `timestamp` orders
49
- * the scans, on Madrid's reading for a wall clock.
44
+ * time: Europe/Madrid. A Canary Islands delivery unit's scans, and an origin
45
+ * post's scans on an item from abroad, have their own wall clock (clock.ts):
46
+ * they are read in `origin`, that zone, or kept as a wall clock when `origin`
47
+ * is null, as the post's country keeps several. `timestamp` orders the scans,
48
+ * on Madrid's reading for a wall clock.
50
49
  */
51
50
  function parsedTime(dateValue, timeValue, origin) {
52
51
  const date = clean(dateValue, 16);
@@ -154,14 +153,17 @@ function parseTracking(payload, trackingNumber, expeditionCode) {
154
153
  }
155
154
  const parsed = [];
156
155
  const seen = new Set();
157
- const codes = [];
158
- (Array.isArray(rawEvents) ? rawEvents : []).filter(isRecord).slice(0, 500).forEach((rawEvent, index) => {
156
+ const rows = (Array.isArray(rawEvents) ? rawEvents : []).filter(isRecord).slice(0, 500);
157
+ const codes = rows.map((rawEvent) => clean(rawEvent.codEvento, 32).toLocaleUpperCase('en-US')).filter(Boolean);
158
+ // codired is the unit holding the parcel, whose address the office locator gives.
159
+ const officeCode = cleanScalar(envelope.codired, 16);
160
+ const unitZone = correosUnitZone(officeCode, codes);
161
+ rows.forEach((rawEvent, index) => {
159
162
  const code = clean(rawEvent.codEvento, 32).toLocaleUpperCase('en-US');
160
- if (code)
161
- codes.push(code);
162
163
  // desTextoResumen is worded in the language codIdioma names.
163
164
  const description = clean(rawEvent.desTextoResumen, 500) || correosSpainEnglishSummary(code) || code;
164
- const time = parsedTime(rawEvent.fecEvento, rawEvent.horEvento, correosOriginZone(requested, { code }));
165
+ const origin = correosOriginZone(requested, { code });
166
+ const time = parsedTime(rawEvent.fecEvento, rawEvent.horEvento, origin === undefined ? unitZone(code) : origin);
165
167
  if (!time || !description)
166
168
  return;
167
169
  const identity = `${time.time ?? time.local_time}\u0000${description}\u0000${code}`;
@@ -207,7 +209,6 @@ function parseTracking(payload, trackingNumber, expeditionCode) {
207
209
  // the envelope. The office is only a pickup signal when the parcel is
208
210
  // actually awaiting collection; weight/dims are operational parcel data.
209
211
  const office = clean(envelope.nom_codired, 300) || null;
210
- const officeCode = cleanScalar(envelope.codired, 16);
211
212
  const grams = Number(envelope.peso);
212
213
  const weightKg = Number.isFinite(grams) && grams > 0 ? Math.round((grams / 1000) * 1000) / 1000 : null;
213
214
  const dims = [envelope.largo, envelope.ancho, envelope.alto].map((value) => Number(value));
@@ -222,7 +223,6 @@ function parseTracking(payload, trackingNumber, expeditionCode) {
222
223
  ...(weightKg != null ? { weight_kg: weightKg } : {}),
223
224
  ...(dimensionsText ? { dimensions_text: dimensionsText } : {}),
224
225
  };
225
- // codired is the unit holding the parcel, whose address the office locator gives.
226
226
  const waiting = latest?.event.stage === 'ready_for_pickup' && office;
227
227
  const pickupExtras = waiting ? { pickup_point: office } : {};
228
228
  const pickupOffice = waiting && /^\d{1,16}$/.test(officeCode) ? officeCode : '';
@@ -12,3 +12,9 @@ export declare function correosOriginZone(number: string, scan: {
12
12
  } | {
13
13
  wording: string;
14
14
  }): string | null | undefined;
15
+ /**
16
+ * The clock of the delivery unit's scans when the unit holding the parcel is
17
+ * on the Canary Islands, which stamp their own wall clock; undefined for any
18
+ * other unit or scan, on Madrid's clock.
19
+ */
20
+ export declare function correosUnitZone(unit: string, codes: readonly string[]): (code: string) => string | undefined;
@@ -35,3 +35,27 @@ export function correosOriginZone(number, scan) {
35
35
  const country = origin ? foreignOrigin(number) : null;
36
36
  return country ? countryTimeZone(country) : undefined;
37
37
  }
38
+ /**
39
+ * A unit code (`codired`) opens with its province's two digits, as the office
40
+ * locator lists them: 35 (Las Palmas) and 38 (Santa Cruz de Tenerife) are the
41
+ * Canary Islands, an hour behind Madrid.
42
+ */
43
+ const CANARY_UNIT = /^3[58]\d{5}$/;
44
+ /**
45
+ * The delivery unit's scans: its registration of the parcel (G), the rounds,
46
+ * attempts and holds (H, not the collection from a sender's address,
47
+ * HI10002V), the end of a hold (L0), its hold for a return (O13) and the
48
+ * delivery.
49
+ */
50
+ const DELIVERY_UNIT_CODE = /^(?:[GH]\d|L0|O13|I01H210V$|I010000V$)/;
51
+ /** Handed back to the sender, whose unit the envelope may name from then on. */
52
+ const BACK_TO_SENDER = new Set(['I020000V', 'I01H230V']);
53
+ /**
54
+ * The clock of the delivery unit's scans when the unit holding the parcel is
55
+ * on the Canary Islands, which stamp their own wall clock; undefined for any
56
+ * other unit or scan, on Madrid's clock.
57
+ */
58
+ export function correosUnitZone(unit, codes) {
59
+ const canary = CANARY_UNIT.test(unit) && !codes.some((code) => BACK_TO_SENDER.has(code));
60
+ return (code) => canary && DELIVERY_UNIT_CODE.test(code) ? 'Atlantic/Canary' : undefined;
61
+ }
@@ -1,7 +1,8 @@
1
1
  // Scans stored while the localizador answered in Spanish take its English
2
2
  // wording in place: at one instant, the event code names the same scan. An
3
- // origin post's scans on an item from abroad kept the Madrid reading until
4
- // October 2026 and now show that post's clock, unless its country keeps several.
3
+ // origin post's scans on an item from abroad, and a Canary Islands delivery
4
+ // unit's, kept the Madrid reading until October 2026 and now show their own
5
+ // clock, unless the origin's country keeps several.
5
6
  export const sameInstantIdentityPolicy = {
6
7
  sourceCarrierId: 'correos-spain', storedSources: ['correos-spain'], requireProviderCode: true, relabelledFrom: 'Europe/Madrid',
7
8
  };
@@ -48,6 +48,10 @@ const WORDINGS = new Map([
48
48
  ['package received & scanned at warehouse', 'accepted'],
49
49
  ['package received and scanned at warehouse', 'accepted'],
50
50
  ['parcel handed over to carrier,waiting for transportation', 'accepted'],
51
+ // A line's collection point and its domestic warehouse, before export.
52
+ ['the goods arrive at the receiving point', 'accepted'],
53
+ ["package received & scanned at carrier's domestic warehouse.", 'accepted'],
54
+ ["package depart carrier's domestic warehouse.", 'in_transit'],
51
55
  // China Post's receipt (中国邮政 已收取邮件).
52
56
  ['package received', 'accepted'],
53
57
  ['the package has sent from the warehouse', 'in_transit'],
@@ -115,12 +119,17 @@ const PATTERNS = [
115
119
  [/^离开【[^】]*】处理中心$/, 'in_transit'],
116
120
  [/^送交出口海关$/, 'customs'],
117
121
  // A line's own rows in Chinese, most with its English after them: the collection point and the
118
- // operation centre, the warehouse's intake and customs' release before the flight, then the
119
- // destination warehouse and the hand-over to the local carrier.
122
+ // operation centre, the warehouse's intake and customs' release before the flight, the flight,
123
+ // import clearance once the dispatch is unpacked, then the destination warehouse and the
124
+ // hand-over to the local carrier.
120
125
  [/^到达收货点$/, 'accepted'],
121
126
  [/^货物离开操作中心$/, 'in_transit'],
122
127
  [/^进仓时间[::]/, 'in_transit'],
123
128
  [/^通关放行[;;]/, 'in_transit'],
129
+ [/^航班起飞[,,]/, 'in_transit'],
130
+ [/^航班已落地[;;]/, 'in_transit'],
131
+ [/^拆板清关中[;;]/, 'customs'],
132
+ [/^清关完成[,,]交仓中/, 'in_transit'],
124
133
  [/^货物已交仓[,,]/, 'in_transit'],
125
134
  [/^包裹交接给本地物流商处理/, 'in_transit'],
126
135
  [/安排投递$/, 'out_for_delivery'],
@@ -68,6 +68,13 @@
68
68
  {"wording": "货物已交仓,留意扫描; Pay attention to scanning", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "After import clearance, handed to the destination warehouse."},
69
69
  {"wording": "包裹交接给本地物流商处理,Received by local delivery company", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "Handed over to the local carrier."},
70
70
  {"wording": "预计配载航班时间:10-23;Estimated loading flight time:10-23", "unmapped": true, "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The flight the parcel is booked on, as another line words it, any date: no scan, so the adapter drops the row."},
71
+ {"wording": "THE GOODS ARRIVE AT THE RECEIVING POINT", "stage": "accepted", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The goods reached a line's collection point, beside 到达收货点."},
72
+ {"wording": "Package received & scanned at carrier's domestic warehouse.", "stage": "accepted", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The line's domestic warehouse took the parcel in."},
73
+ {"wording": "Package depart carrier's domestic warehouse.", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The parcel left the line's domestic warehouse."},
74
+ {"wording": "航班起飞,预计落地时间:10-25 Flight departure, estimated landing time:10-25", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The flight took off, with the day it lands; any day."},
75
+ {"wording": "航班已落地;The flight has Arrived", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The flight landed."},
76
+ {"wording": "拆板清关中;Dismantle the board and clear the gate", "stage": "customs", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "Import clearance once the dispatch is unpacked from its pallet."},
77
+ {"wording": "清关完成,交仓中;Customs clearance completed,In transit", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "Import clearance done; on the way to the destination warehouse."},
71
78
  {"wording": "预计配载航班1月2日,Expected flight on January 2nd", "unmapped": true, "language": "zh", "confirmedBy": "live", "firstSeen": "2026-10-25", "note": "The flight the parcel is booked on, in Chinese and English, any date, written before the flight: no scan, so the adapter drops the row."},
72
79
  {"wording": "到达寄达地", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Arrived in the destination country."},
73
80
  {"wording": "目的港已提货 Delivery has been made at the destination port", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Collected at the destination port after import clearance, before the destination's own scans; the English says a delivery, which it is not."},
@@ -141,16 +141,17 @@ export function parseHelthjem(payload, rawNumber) {
141
141
  const events = scans.map(scan => scan.event);
142
142
  const newest = events[0];
143
143
  // The page headlines the parcel's state; the newest scan stands in without
144
- // one, and for it when the scan's event code says more than their state.
145
- const stateStage = !state ? undefined
146
- : scans[0]?.state === state && newest?.stage ? newest.stage : helthjemStage(state);
144
+ // one, and the newest scan in that state for it, since its event code can
145
+ // say more than the state.
146
+ const own = state ? scans.find(scan => scan.state === state)?.event.stage : undefined;
147
+ const stateStage = !state ? undefined : own ?? helthjemStage(state);
147
148
  if (!newest && !state)
148
149
  throw new IndeterminateError(PROVIDER, 'Helthjem returned a parcel without a state or scans');
149
150
  const stage = stateStage ?? newest?.stage;
150
151
  const delivered = stage === 'delivered' ? deliveredAt(events) : undefined;
151
152
  // The night a booking names, while no scan but a terminal's has followed it.
152
153
  const booking = scans.find(scan => scan.state !== 'IN_TRANSIT')?.event;
153
- const night = booking && stage === 'out_for_delivery' ? bookedNight(booking) : null;
154
+ const night = booking && stage === 'in_transit' ? bookedNight(booking) : null;
154
155
  // The point a parcel waits at, or was collected from, by its name alone.
155
156
  const point = state === 'READY_FOR_PICKUP' || state === 'PICKED_UP'
156
157
  ? (isRecord(parcel.servicePoint) ? clean(parcel.servicePoint.name, 160) : '') : '';
@@ -7,8 +7,8 @@ const STATES = new Map([
7
7
  // The shop announced the parcel; Helthjem does not have it yet.
8
8
  ['REGISTERED', { stage: 'registered', label: 'Pakken er registrert' }],
9
9
  ['IN_TRANSIT', { stage: 'in_transit', label: 'På vei til mottaker' }],
10
- // Booked on the coming night's round, which the scan announces. A night
11
- // missed is filed here too; its event code tells it apart.
10
+ // On the round. A booking on the coming night's round and a night missed
11
+ // are filed here too; their event codes tell them apart.
12
12
  ['DELIVERING', { stage: 'out_for_delivery', label: 'Leveres' }],
13
13
  ['DELIVERED', { stage: 'delivered', label: 'Levert' }],
14
14
  // A home delivery sent on to a pickup point instead.
@@ -26,8 +26,11 @@ const STATES = new Map([
26
26
  ]);
27
27
  /** Scans whose event code says more than their state. */
28
28
  const EVENTS = new Map([
29
+ // "Planlagt levering natt til 24. oktober!": the parcel is booked on the
30
+ // coming night's round, announced the day before. No courier has it yet.
31
+ ['DELIVERING/113', 'in_transit'],
29
32
  // "Budet fikk ikke med seg pakken din ut i natt": the night's courier did
30
- // not take the parcel out, so it waits for a later round. Filed as delivering.
33
+ // not take the parcel out, so it waits for a later round.
31
34
  ['DELIVERING/057', 'in_transit'],
32
35
  ]);
33
36
  /** The state is a word of capitals and underscores. */
@@ -4,7 +4,7 @@
4
4
  { "code": "REGISTERED/001", "wording": "Nettbutikken har meldt at en pakke er på vei!", "language": "nb", "stage": "registered", "confirmedBy": "live", "note": "The shop announced the parcel; Helthjem does not have it yet." },
5
5
  { "code": "IN_TRANSIT/002", "wording": "Vi har mottatt pakken din, og gjør den klar for veien videre.", "language": "nb", "stage": "in_transit", "confirmedBy": "live", "note": "Received at a Helthjem terminal, which the scan names." },
6
6
  { "code": "IN_TRANSIT/003", "wording": "Pakken er under transport.", "language": "nb", "stage": "in_transit", "confirmedBy": "live", "note": "A terminal or sorter scan. The page hides the rows of its sorter, which it names VestbySorter; the adapter keeps them." },
7
- { "code": "DELIVERING/113", "wording": "Planlagt levering natt til 8. oktober!", "language": "nb", "stage": "out_for_delivery", "confirmedBy": "live", "note": "Booked on the coming night's round; the wording names that night's date, which the adapter reads as the expected delivery day. Helthjem files the scan as delivering." },
7
+ { "code": "DELIVERING/113", "wording": "Planlagt levering natt til 8. oktober!", "language": "nb", "stage": "in_transit", "confirmedBy": "live", "note": "Booked on the coming night's round, the day before it: no courier has the parcel yet. The wording names that night's date, which the adapter reads as the expected delivery day. Helthjem files the scan as delivering; its code overrides that state, for the parcel too while it is the newest scan in that state." },
8
8
  { "code": "DELIVERING/057", "wording": "Budet fikk ikke med seg pakken din ut i natt.", "language": "nb", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-25", "note": "The night's courier did not take the parcel out, so it waits for a later round. Helthjem files the scan as delivering; its code overrides that state, for the parcel too while it is the newest scan." },
9
9
  { "code": "DELIVERED/013", "wording": "Pakken er levert på dørmatten din!", "language": "nb", "stage": "delivered", "confirmedBy": "live", "note": "Left at the door." },
10
10
  { "code": "DELIVERED/013", "wording": "Pakken er levert i postkassen din!", "language": "nb", "stage": "delivered", "confirmedBy": "live", "note": "Left in the letterbox." },
@@ -150,6 +150,9 @@ export function normalizeCarrierResult(value) {
150
150
  }
151
151
  }
152
152
  const event = { ...rawEvent };
153
+ // A blank place is no place.
154
+ if (typeof event.location === 'string' && !event.location.trim())
155
+ delete event.location;
153
156
  if (event.point !== undefined) {
154
157
  const point = isRecord(event.point) ? eventPoint(event.point.latitude, event.point.longitude) : null;
155
158
  if (point)
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.764.1-main.4049"
5
+ "version": "0.764.2-main.4053"
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.764.1-main.4049",
3
+ "version": "0.764.2-main.4053",
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",