universal-parcel-scraper 0.44.0-main.412 → 0.44.0-main.414

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.
@@ -1,11 +1,12 @@
1
1
  import { load } from 'cheerio';
2
2
  import makeFetchCookie from 'fetch-cookie';
3
+ import { DateTime } from 'luxon';
3
4
  import { CookieJar } from 'tough-cookie';
4
5
  import { lookupBudget } from '../../core/adapter/index.js';
5
6
  import { ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
6
7
  import { eventPoint } from '../../core/result/index.js';
7
8
  import { isValidS10TrackingNumber } from '../../core/detection/index.js';
8
- import { isoTime, mislabeledLocalTime } from '../../core/time/index.js';
9
+ import { countryCode, countryTimeZone, isoTime, mislabeledLocalTime, timeZoneCountry, } from '../../core/time/index.js';
9
10
  import { cleanScalar, decodeText, fetchBounded, parseJsonBytes, UpstreamHttpError, } from '../../core/transport/index.js';
10
11
  import { isRecord } from '../../core/types.js';
11
12
  import { classifyIndiaPostEvent } from './status.js';
@@ -22,6 +23,8 @@ const DEFAULT_MAX_POLL_ATTEMPTS = 10;
22
23
  // kept an 11-day-old "Item Booked" while it reached export customs.
23
24
  const REFRESH_AFTER_MS = 30 * 60_000;
24
25
  const USER_TIMEZONE = 'Europe/Zurich';
26
+ // The zone of every Indian office, and the one MySpeedPost labels all clocks in.
27
+ const INDIA_ZONE = 'Asia/Kolkata';
25
28
  const MAX_RESPONSE_BYTES = 2_000_000;
26
29
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
27
30
  + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36';
@@ -119,12 +122,20 @@ const AIRPORT_PLACES = {
119
122
  function officeAirport(office) {
120
123
  return /^Office - ([A-Z]{3})\b/.exec(office)?.[1];
121
124
  }
122
- /** Flight remarks have a narrow format; unrelated remarks can contain recipient details. */
125
+ /**
126
+ * A flight's number and route, from a remark naming the office's airport as
127
+ * the origin. Flight remarks have a narrow format; unrelated remarks can
128
+ * contain recipient details.
129
+ */
130
+ function flightRoute(remarks, office) {
131
+ const match = /^Flight No:\s*([A-Z0-9]{2}\d{1,4}[A-Z]?)\s*\(From ([A-Z]{3}) To ([A-Z]{3})\)$/.exec(clean(remarks, 500));
132
+ return match && match[2] === officeAirport(office) ? match : null;
133
+ }
123
134
  function flightDetails(providerCode, remarks, office) {
124
135
  if (providerCode !== TAKE_OFF_CODE && providerCode !== 'MailArrived')
125
136
  return null;
126
- const match = /^Flight No:\s*([A-Z0-9]{2}\d{1,4}[A-Z]?)\s*\(From ([A-Z]{3}) To ([A-Z]{3})\)$/.exec(clean(remarks, 500));
127
- if (!match || match[2] !== officeAirport(office))
137
+ const match = flightRoute(remarks, office);
138
+ if (!match)
128
139
  return null;
129
140
  const airport = (code) => AIRPORT_PLACES[code] ? `${AIRPORT_PLACES[code].name} (${code})` : code;
130
141
  const departed = providerCode === TAKE_OFF_CODE;
@@ -166,6 +177,88 @@ function officePoint(rawEvent, office) {
166
177
  return null;
167
178
  return point;
168
179
  }
180
+ /**
181
+ * The country on MySpeedPost's Destination card: a heading, then label and
182
+ * value pairs, of which only "Country" is read. The card is absent when
183
+ * MySpeedPost has no booking details.
184
+ */
185
+ function destinationOf($) {
186
+ const heading = $('h3').filter((_, element) => clean($(element).text(), 40) === 'Destination').first();
187
+ for (const label of heading.closest('[x-data]').find('p').toArray()) {
188
+ if (clean($(label).text(), 40).toLowerCase() !== 'country')
189
+ continue;
190
+ // A longer value is not a country name, and a cut one would be wrong.
191
+ const name = clean($(label).next('p').text(), 81);
192
+ return name.length <= 80 && /^\p{L}[\p{L} .,'()&-]*$/u.test(name) ? { code: countryCode(name), name } : null;
193
+ }
194
+ return null;
195
+ }
196
+ // UPU's marks for a scan by the destination's post ("Item received at office
197
+ // of exchange (Inb)") and by the origin's ("Transfer to OOE (Otb)"). Another
198
+ // reply for the same item can carry bare codes ("ITEM_RECEIVE") instead.
199
+ const INBOUND = /\(Inb\)/i;
200
+ const OUTBOUND = /\(Otb\)/i;
201
+ // India Post's code for a transfer to the office of exchange, in either reply.
202
+ const TRANSFER_CODE = 'ItemTransfered';
203
+ // The codes of a bag's arrival, with the flight's route in the remark.
204
+ const ARRIVAL_CODES = new Set(['MailArrived', 'BagUnloaded']);
205
+ /** The airport a flight relayed on an arrival row lands at. */
206
+ function arrivalAirport(row) {
207
+ return ARRIVAL_CODES.has(clean(row.event_type, 100)) ? flightRoute(row.remarks, clean(row.office, 120))?.[3] ?? null : null;
208
+ }
209
+ function airportCountry(airport) {
210
+ const place = AIRPORT_PLACES[airport];
211
+ return place ? countryCode(place.country) : timeZoneCountry(AIRPORT_ZONES[airport] ?? '');
212
+ }
213
+ /**
214
+ * Rows the destination's post scanned abroad, which India Post relays with
215
+ * the office's wall clock labelled as India's. They start with an inbound
216
+ * mark or with a flight's arrival in the destination country, whichever is
217
+ * labelled first, so a reply in bare codes picks the same rows as one in
218
+ * prose. Flight rows keep their own reading. An outbound mark, a transfer to
219
+ * the office of exchange or an arrival in another country after that starts
220
+ * a way back, which may be in India again, so it and what follows keep
221
+ * India's clock. Take-offs are not compared: their labels are airport clocks.
222
+ */
223
+ function scannedAbroad(events, destination) {
224
+ const abroad = new Set();
225
+ if (!destination || destination.code === 'IN')
226
+ return abroad;
227
+ const marked = (row, mark) => mark.test(clean(row.event)) || mark.test(clean(row.remarks));
228
+ const code = (row) => clean(row.event_type, 100);
229
+ const labelled = events.map((row) => isoTime(row.tracked_at, INDIA_ZONE, 100)?.timestamp ?? Number.NaN);
230
+ // The country each arrival row lands in: '' where the airport's is unknown.
231
+ const landed = events.map((row) => {
232
+ const airport = arrivalAirport(row);
233
+ return airport === null ? null : airportCountry(airport) ?? '';
234
+ });
235
+ const entered = Math.min(...events.flatMap((row, index) => (marked(row, INBOUND) || (destination.code !== null && landed[index] === destination.code) ? [labelled[index]] : [])));
236
+ const wayBack = Math.min(...events.flatMap((row, index) => (labelled[index] > entered && (marked(row, OUTBOUND) || code(row) === TRANSFER_CODE || (landed[index] && landed[index] !== destination.code))
237
+ ? [labelled[index]] : [])));
238
+ events.forEach((row, index) => {
239
+ if (labelled[index] >= entered && labelled[index] < wayBack && landed[index] === null
240
+ && code(row) !== TAKE_OFF_CODE)
241
+ abroad.add(index);
242
+ });
243
+ return abroad;
244
+ }
245
+ /**
246
+ * A clock relayed from abroad, read in the destination's zone when the
247
+ * country keeps one. Elsewhere it stays an offset-less wall time: India's
248
+ * label would place it hours off, and the office's zone is unknown.
249
+ */
250
+ function foreignClock(trackedAt, zone) {
251
+ const labelled = isoTime(trackedAt, INDIA_ZONE, 100);
252
+ if (!labelled)
253
+ return null;
254
+ const wall = DateTime.fromMillis(labelled.timestamp, { zone: INDIA_ZONE })
255
+ .toISO({ includeOffset: false, suppressMilliseconds: true });
256
+ if (!wall)
257
+ return null;
258
+ const time = zone ? isoTime(wall, zone) : null;
259
+ // The local wall time still orders the row among India's labels.
260
+ return time ? { time } : zone ? null : { local: wall, timestamp: labelled.timestamp };
261
+ }
169
262
  export function parseIndiaPostTrackingHtml(html, trackingNumber) {
170
263
  const requested = normalizeIndiaPostTrackingNumber(trackingNumber);
171
264
  const $ = load(html);
@@ -188,19 +281,26 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
188
281
  }
189
282
  const parsed = [];
190
283
  const seen = new Set();
191
- events.slice(0, 500).forEach((rawEvent, index) => {
284
+ const destination = destinationOf($);
285
+ const rows = events.slice(0, 500);
286
+ const abroad = scannedAbroad(rows, destination);
287
+ const destinationZone = destination ? countryTimeZone(destination.code) : null;
288
+ rows.forEach((rawEvent, index) => {
192
289
  const office = clean(rawEvent.office, 120);
193
290
  const providerCode = clean(rawEvent.event_type, 100);
194
291
  // tracked_at is ISO; offset-less values are read as Asia/Kolkata, the zone
195
- // every India Post office stamps. A take-off is read on its airport's clock.
292
+ // every India Post office stamps. A take-off is read on its airport's clock,
293
+ // and a scan abroad on the destination's.
196
294
  const zone = takeOffZone(providerCode, office, clean(rawEvent.tracked_at, 100));
197
- const time = zone
295
+ const foreign = abroad.has(index) ? foreignClock(rawEvent.tracked_at, destinationZone) : null;
296
+ const time = foreign ? ('time' in foreign ? foreign.time : null) : zone
198
297
  ? mislabeledLocalTime(rawEvent.tracked_at, zone, 100)
199
- : isoTime(rawEvent.tracked_at, 'Asia/Kolkata', 100);
298
+ : isoTime(rawEvent.tracked_at, INDIA_ZONE, 100);
299
+ const local = foreign && 'local' in foreign ? foreign : null;
200
300
  const takeOff = providerCode === TAKE_OFF_CODE;
201
301
  const flight = flightDetails(providerCode, rawEvent.remarks, office);
202
302
  const description = flight?.description ?? (takeOff ? 'Aircraft Departure' : eventText(clean(rawEvent.event)));
203
- if (!time || !description)
303
+ if (!(time || local) || !description)
204
304
  return;
205
305
  const pincode = /^\d{6}$/.test(clean(rawEvent.pincode, 6))
206
306
  ? clean(rawEvent.pincode, 6)
@@ -208,7 +308,7 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
208
308
  const airportCode = flight?.airport ?? (takeOff ? officeAirport(office) : undefined);
209
309
  const point = airportCode ? null : officePoint(rawEvent, office);
210
310
  const airport = airportCode ? AIRPORT_PLACES[airportCode] : undefined;
211
- const identity = JSON.stringify([time.iso, description, office, pincode, providerCode]);
311
+ const identity = JSON.stringify([time?.iso ?? local?.local, description, office, pincode, providerCode]);
212
312
  if (seen.has(identity))
213
313
  return;
214
314
  seen.add(identity);
@@ -218,7 +318,7 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
218
318
  // contact numbers and address blocks travel on the row and are
219
319
  // deliberately never retained.
220
320
  event: {
221
- time: time.iso,
321
+ ...(time ? { time: time.iso } : { local_time: local.local }),
222
322
  location: airport ? `${airport.name} (${airportCode}), ${airport.country}`
223
323
  : flight && !takeOff ? `${flight.airport} Airport` : [office, pincode].filter(Boolean).join(' '),
224
324
  description,
@@ -227,7 +327,7 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
227
327
  ...(point ? { point } : {}),
228
328
  },
229
329
  classified,
230
- timestamp: time.timestamp,
330
+ timestamp: time?.timestamp ?? local.timestamp,
231
331
  index,
232
332
  });
233
333
  });
@@ -244,16 +344,36 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
244
344
  current_stage: classified.stage,
245
345
  last_status_text: latest.event.description,
246
346
  last_update: latest.event.time ?? null,
347
+ ...(typeof latest.event.local_time === 'string' ? { last_update_local: latest.event.local_time } : {}),
247
348
  expected_delivery: null,
248
- timezone: 'Asia/Kolkata',
349
+ timezone: INDIA_ZONE,
249
350
  // When MySpeedPost last asked India Post; an old value means stale history.
250
351
  ...(syncedAt ? { source_synced_at: syncedAt.iso } : {}),
352
+ // Only the country: a S10 item goes on to that country's post, which the
353
+ // host may confirm with its own lookup.
354
+ ...(destination?.code ? { destination_country: destination.code }
355
+ : destination ? { destination_country_name: destination.name } : {}),
251
356
  events: parsed.slice(0, 100).map((item) => item.event),
252
357
  };
253
358
  }
254
359
  function enumValue(value) {
255
360
  return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : '';
256
361
  }
362
+ /**
363
+ * A component state other than New, Processing or Completed. One lookup met
364
+ * one in October 2026 without its name being recorded. It says nothing about
365
+ * the shipment, so a completed history the HTML still carries is used, and
366
+ * otherwise the lookup is inconclusive.
367
+ */
368
+ function otherState(status, html, trackingNumber) {
369
+ try {
370
+ return parseIndiaPostTrackingHtml(html, trackingNumber);
371
+ }
372
+ catch {
373
+ const state = /^[A-Za-z_]{1,40}$/.test(status) ? status : 'unrecognised';
374
+ throw new IndeterminateError('India Post', `India Post tracking is in the ${state} state`);
375
+ }
376
+ }
257
377
  function parseTrackSnapshot(snapshot, trackingNumber) {
258
378
  let parsed;
259
379
  try {
@@ -447,9 +567,8 @@ export class IndiaPostTracker {
447
567
  return cached;
448
568
  }
449
569
  }
450
- if (!['New', 'Processing'].includes(initial.status)) {
451
- throw new SchemaError('India Post', 'India Post returned an unsupported tracking state');
452
- }
570
+ if (!['New', 'Processing'].includes(initial.status))
571
+ return otherState(initial.status, page.html, normalized);
453
572
  const token = csrfToken(page.html);
454
573
  return initial.status === 'Processing'
455
574
  ? this.complete(fetcher, budget, normalized, pageUrl, token, initial.snapshot, [{
@@ -474,15 +593,14 @@ export class IndiaPostTracker {
474
593
  const names = dispatchNames(update.effects);
475
594
  if (names.has('consignment_not_found'))
476
595
  throw new NotFoundError('India Post');
596
+ const html = typeof update.effects.html === 'string' ? update.effects.html : '';
477
597
  if (update.component.status === 'Completed') {
478
- const html = typeof update.effects.html === 'string' ? update.effects.html : '';
479
598
  if (!html)
480
599
  throw new SchemaError('India Post', 'India Post returned an empty completed response');
481
600
  return parseIndiaPostTrackingHtml(html, normalized);
482
601
  }
483
- if (update.component.status !== 'Processing') {
484
- throw new SchemaError('India Post', 'India Post returned an unsupported tracking state');
485
- }
602
+ if (update.component.status !== 'Processing')
603
+ return otherState(update.component.status, html, normalized);
486
604
  if (attempt === this.maxPollAttempts)
487
605
  break;
488
606
  await pause(this.pollIntervalMs, budget.signal);
@@ -1,4 +1,6 @@
1
1
  // MySpeedPost changes a scan's wording while its instant and event_type stay the same.
2
+ // Scans abroad were read on India's clock until October 2026 and now show the
3
+ // destination's offset, unless a reply lacks what places them abroad.
2
4
  export const sameInstantIdentityPolicy = {
3
- sourceCarrierId: 'india-post', storedSources: ['india-post'], requireProviderCode: true,
5
+ sourceCarrierId: 'india-post', storedSources: ['india-post'], requireProviderCode: true, relabelledFrom: 'Asia/Kolkata',
4
6
  };
@@ -13,6 +13,14 @@ export interface SameInstantIdentityPolicy {
13
13
  /** Match distinct scans sharing an instant by their evidence, with unique matches in both directions. */
14
14
  readonly matchEachScan?: boolean;
15
15
  readonly matches?: (incoming: SameInstantScan, stored: SameInstantScan) => boolean;
16
+ /**
17
+ * The zone this source once read every wall clock in. A scan showing a wall
18
+ * clock under another offset takes over the stored row whose instant shows
19
+ * that wall clock in this zone, and the reverse, when the provider code
20
+ * (required) and location agree, no event of the batch carries that row,
21
+ * and the match is unique both ways. Wording may differ.
22
+ */
23
+ readonly relabelledFrom?: string;
16
24
  }
17
25
  /** Unlisted sources cannot identify a reworded scan by its instant alone. */
18
26
  export declare function sameInstantIdentityPolicy(sourceCarrierId: string, options?: {
@@ -137,7 +137,7 @@ export function parse17TrackResponse(payload, trackingNumber, postcode = null) {
137
137
  const placed = wall && zone ? DateTime.fromISO(wall, { zone }) : null;
138
138
  let parsed;
139
139
  try {
140
- parsed = seventeenTrackEvent(placed?.isValid ? { ...raw, time_utc: placed.toUTC().toISO() } : raw, operator);
140
+ parsed = seventeenTrackEvent(raw, operator, placed?.isValid ? placed.toUTC().toISO() ?? undefined : undefined);
141
141
  }
142
142
  catch (cause) {
143
143
  throw new SchemaError(SOURCE, '17TRACK returned an invalid tracking event', { cause });
@@ -1,4 +1,7 @@
1
1
  import type { CarrierEvent } from '../../core/result/index.js';
2
2
  import { type JsonObject } from '../../core/types.js';
3
- /** Keep scan codes/operator and the origin of 17TRACK's converted timestamp. */
4
- export declare function seventeenTrackEvent(raw: JsonObject, operator: JsonObject): CarrierEvent | null;
3
+ /**
4
+ * Keep scan codes/operator and the origin of 17TRACK's converted timestamp.
5
+ * `placed` is the instant a caller read from the facility's own clock instead.
6
+ */
7
+ export declare function seventeenTrackEvent(raw: JsonObject, operator: JsonObject, placed?: string): CarrierEvent | null;
@@ -1,3 +1,5 @@
1
+ import { DateTime } from 'luxon';
2
+ import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
1
3
  import { isRecord } from '../../core/types.js';
2
4
  import { event, text } from '../shared/result.js';
3
5
  // Official v2 status vocabulary, plus TransportArrived/Departed observed in
@@ -18,11 +20,29 @@ const SUB_STAGES = {
18
20
  Exception_Rejected: 'exception', Exception_Delayed: 'exception', Exception_Lost: 'exception',
19
21
  Exception_Destroyed: 'exception', Exception_Cancel: 'exception',
20
22
  };
21
- /** Keep scan codes/operator and the origin of 17TRACK's converted timestamp. */
22
- export function seventeenTrackEvent(raw, operator) {
23
+ /**
24
+ * 17TRACK's two readings of a scan usually name one instant. When they
25
+ * disagree, `time_iso` is the clock and offset 17TRACK shows: on India Post's
26
+ * legs `time_utc` lands 30 minutes after it, on scans India Post itself dates.
27
+ */
28
+ function scanTime(raw) {
29
+ const { time_utc: utc, time_iso: iso } = raw;
30
+ if (typeof utc === 'string' && typeof iso === 'string' && EXPLICIT_OFFSET_PATTERN.test(iso)) {
31
+ const shown = DateTime.fromISO(iso, { setZone: true });
32
+ const converted = DateTime.fromISO(utc, { setZone: true });
33
+ if (shown.isValid && converted.isValid && shown.toMillis() !== converted.toMillis())
34
+ return iso;
35
+ }
36
+ return utc ?? iso;
37
+ }
38
+ /**
39
+ * Keep scan codes/operator and the origin of 17TRACK's converted timestamp.
40
+ * `placed` is the instant a caller read from the facility's own clock instead.
41
+ */
42
+ export function seventeenTrackEvent(raw, operator, placed) {
23
43
  // Some completed responses contain undated explanatory rows alongside real
24
44
  // scans. Skip only absent dates; malformed nonempty timestamps still fail.
25
- const time = raw.time_utc ?? raw.time_iso;
45
+ const time = placed ?? scanTime(raw);
26
46
  if (time === null || time === undefined)
27
47
  return null;
28
48
  const code = text(raw.sub_status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.44.0-main.412",
3
+ "version": "0.44.0-main.414",
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",