universal-parcel-scraper 0.577.3-main.2713 → 0.579.0-main.2719

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.
@@ -185,12 +185,14 @@ export function parseCanadaPostTrackingResponse(payload, trackingNumber) {
185
185
  const country = isRecord(raw.locationAddr) ? textField(raw.locationAddr, 'countryCd', 8).toUpperCase() : '';
186
186
  const location = isRecord(raw.locationAddr)
187
187
  ? [textField(raw.locationAddr, 'city', 160), region].filter(Boolean).join(', ') : '';
188
- const stage = type === 'Signature' || code === SIGNATURE_CODE ? null : canadaPostScanStage(code, description) ?? canadaPostStage(description);
188
+ // The signature row can come without its type.
189
+ const signature = type === 'Signature' || code === SIGNATURE_CODE;
190
+ const stage = signature ? null : canadaPostScanStage(code, description) ?? canadaPostStage(description);
189
191
  const relayed = relayedClock(code, country, region);
190
192
  const clock = eventClock(raw.datetime, relayed);
191
193
  const event = { ...clock, description: stage === 'delivered' ? 'Delivered' : description,
192
194
  provider_code: code, ...(stage ? { stage } : {}), ...(location ? { location } : {}) };
193
- return { event, type, region, country, sourceDate: isRecord(raw.datetime) ? calendarDate(raw.datetime.date) : null,
195
+ return { event, type, signature, region, country, sourceDate: isRecord(raw.datetime) ? calendarDate(raw.datetime.date) : null,
194
196
  returnCue: returnStarted(description) || type === 'RtsLabelProc', guessed: relayed?.zone === null && 'time' in clock };
195
197
  });
196
198
  // Native detail history is newest first. Compare instants only when every
@@ -216,7 +218,7 @@ export function parseCanadaPostTrackingResponse(payload, trackingNumber) {
216
218
  const summaryStage = canadaPostPackageStage(summary);
217
219
  const delivery = rows.find(row => row.event.stage === (currentReturn ? 'returned' : 'delivered'));
218
220
  const matchingDelivery = delivery?.event.time && latest.event.time === delivery.event.time
219
- && (latest === delivery || latest.type === 'Signature') ? delivery : undefined;
221
+ && (latest === delivery || latest.signature) ? delivery : undefined;
220
222
  let current = latest.event.stage ?? summaryStage;
221
223
  let snapshot = null;
222
224
  if (currentReturn && !explicitReturn) {
@@ -1,13 +1,23 @@
1
1
  import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
3
  import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ /** A lookup of a DPD parcel number in DPD's group-wide guest service. */
5
+ export type DpdGroupLookup = (number: string, context: TrackingContext) => Promise<CarrierResult>;
4
6
  export declare class DpdIeTracker {
5
7
  private readonly options;
6
8
  constructor(options?: {
7
9
  fetcher?: typeof fetch;
8
10
  recorder?: StepRecorder;
9
11
  userAgent?: string;
12
+ group?: DpdGroupLookup;
10
13
  });
14
+ /**
15
+ * A parcel number's answer from DPD Ireland's tracker and DPD's group-wide
16
+ * guest service, asked together: the tracker's history when it holds more
17
+ * scans and is not behind the guest service's milestones, else those. Either
18
+ * answers when the other fails; not found needs both.
19
+ */
20
+ track(raw: string, context?: TrackingContext): Promise<CarrierResult>;
11
21
  fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
12
22
  private lookup;
13
23
  }
@@ -1,10 +1,12 @@
1
1
  import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
- import { BudgetExceededError, ChallengeError, IndeterminateError, SchemaError } from '../../core/errors/index.js';
2
+ import { BudgetExceededError, ChallengeError, IndeterminateError, NotFoundError, SchemaError } from '../../core/errors/index.js';
3
+ import { fillPartialAnswer } from '../../core/result/partial.js';
3
4
  import { runSteps } from '../../core/runner/index.js';
4
5
  import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
6
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
7
  import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
7
- import { isDpdIeChallenge, normalizeDpdIeNumber, parseDpdIe, parseDpdIeAbsence, PROVIDER } from './parser.js';
8
+ import { DPDTracker } from '../dpd/adapter.js';
9
+ import { isDpdIeChallenge, isDpdIeParcelNumber, normalizeDpdIeNumber, parseDpdIe, parseDpdIeAbsence, PROVIDER } from './parser.js';
8
10
  // The tracker www.dpd.ie/tracking/ shows in a frame, with the frame's device type.
9
11
  const ORIGIN = 'https://tracking.dpd.ie';
10
12
  const RESULT = '/Home/SearchResult?deviceType=5';
@@ -42,6 +44,27 @@ export class DpdIeTracker {
42
44
  constructor(options = {}) {
43
45
  this.options = options;
44
46
  }
47
+ /**
48
+ * A parcel number's answer from DPD Ireland's tracker and DPD's group-wide
49
+ * guest service, asked together: the tracker's history when it holds more
50
+ * scans and is not behind the guest service's milestones, else those. Either
51
+ * answers when the other fails; not found needs both.
52
+ */
53
+ async track(raw, context = {}) {
54
+ const number = normalizeDpdIeNumber(raw);
55
+ if (!isDpdIeParcelNumber(number) || !this.options.group)
56
+ return this.fetch(number, context);
57
+ const shared = { ...context, budgetMs: context.budgetMs ?? 15_000 };
58
+ const [ireland, group] = await Promise.allSettled([this.fetch(number, shared), this.options.group(number, shared)]);
59
+ if (ireland.status === 'fulfilled' && group.status === 'fulfilled') {
60
+ return fillPartialAnswer('dpd-ie', number, group.value, ireland.value) ?? group.value;
61
+ }
62
+ if (ireland.status === 'fulfilled')
63
+ return ireland.value;
64
+ if (group.status === 'fulfilled')
65
+ return group.value;
66
+ throw [group.reason, ireland.reason].find((error) => !(error instanceof NotFoundError)) ?? ireland.reason;
67
+ }
45
68
  async fetch(raw, context = {}) {
46
69
  const number = normalizeDpdIeNumber(raw);
47
70
  const budgetMs = context.budgetMs ?? 15_000;
@@ -91,10 +114,14 @@ export class DpdIeTracker {
91
114
  }
92
115
  }
93
116
  export const adapter = environment => {
94
- const tracker = new DpdIeTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
117
+ // DPD's guest service answers for the whole group; Belgium's unit asks it alone.
118
+ const group = new DPDTracker({ country: 'BE', fetcher: environment.fetcher, userAgent: environment.userAgent });
119
+ const tracker = new DpdIeTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent,
120
+ group: (number, context) => group.fetch(number, '', context) });
95
121
  return {
96
122
  id: 'dpd-ie', recordsSteps: true, steps: ['direct', 'retry'],
97
- track: (input, context) => tracker.fetch(input.number, context),
123
+ track: (input, context) => tracker.track(input.number, context),
124
+ // DPD's group-wide match does not make a parcel Irish: recognition reads DPD Ireland's tracker alone.
98
125
  recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeDpdIeNumber(number))),
99
126
  };
100
127
  };
@@ -1,8 +1,13 @@
1
1
  import type { CarrierResult } from '../../core/result/index.js';
2
2
  export declare const PROVIDER = "DPD Ireland";
3
3
  export declare const DPD_IE_ZONE = "Europe/Dublin";
4
- /** A DPD Ireland consignment number: nine or ten digits. */
4
+ /**
5
+ * A DPD Ireland consignment number, nine or ten digits, or a DPD parcel
6
+ * number, fourteen digits without the check character labels print after them.
7
+ */
5
8
  export declare function normalizeDpdIeNumber(raw: string): string;
9
+ /** Whether a normalized number is a DPD parcel number rather than a consignment number. */
10
+ export declare function isDpdIeParcelNumber(number: string): boolean;
6
11
  /** Cloudflare's challenge page in place of the tracker. Its passive script on ordinary pages is not one. */
7
12
  export declare function isDpdIeChallenge(html: string): boolean;
8
13
  /**
@@ -1,6 +1,7 @@
1
1
  import { load } from 'cheerio';
2
2
  import { DateTime } from 'luxon';
3
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
4
+ import { dpdParcelNumber } from '../../core/detection/dpd.js';
4
5
  import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
5
6
  import { languageStageStatus } from '../../core/status/index.js';
6
7
  import { normalizeStatusWording } from '../../core/status/statusMap.js';
@@ -12,13 +13,15 @@ export const DPD_IE_ZONE = 'Europe/Dublin';
12
13
  // The consignment numbers DPD Ireland's tracker shows have nine digits;
13
14
  // lookups archived from it also use ten.
14
15
  const NUMBER = /^\d{9,10}$/;
16
+ // The fourteen-digit parcel number DPD networks print, which the tracker also finds.
17
+ const PARCEL = /^\d{14}$/;
15
18
  const HEADERS = ['Status', 'Location', 'Date & Time'];
16
19
  // "07/9/2026 at 1:41": day, month without its zero, year, then the hour without its zero.
17
20
  const CLOCK = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) at (\d{1,2}):(\d{2})$/;
18
21
  // DPD Ireland's own depots, in the Republic and in Northern Ireland, and its
19
22
  // head office and website. Their clocks are Irish wall time. Another
20
23
  // network's scan names its town and country, as "<town> - <country>".
21
- const IRISH_PLACE = /^(?:Dpd [A-Za-z][A-Za-z .'-]{0,60}|Head Office|Web)$/i;
24
+ const IRISH_PLACE = /^(?:Dpd [A-Za-z][A-Za-z .&'-]{0,60}|Head Office|Web)$/i;
22
25
  // Labels of DPD Ireland's systems rather than places.
23
26
  const NOT_PLACES = new Set(['head office', 'web']);
24
27
  const VAN = 'Your parcel is on a van and due for delivery';
@@ -29,12 +32,22 @@ const VAN_HEADLINE = new RegExp(`^${VAN}\\. Expected delivery time (\\d{2}):(\\d
29
32
  const MAX_ROWS = 500;
30
33
  const same = (left, right) => normalizeStatusWording(left) === normalizeStatusWording(right);
31
34
  const MAX_EVENTS = 100;
32
- /** A DPD Ireland consignment number: nine or ten digits. */
35
+ /**
36
+ * A DPD Ireland consignment number, nine or ten digits, or a DPD parcel
37
+ * number, fourteen digits without the check character labels print after them.
38
+ */
33
39
  export function normalizeDpdIeNumber(raw) {
34
40
  const number = normalizeTrackingNumber(raw);
35
- if (!NUMBER.test(number))
36
- throw new InvalidInputError(PROVIDER, 'DPD Ireland tracks nine- or ten-digit consignment numbers');
37
- return number;
41
+ if (NUMBER.test(number))
42
+ return number;
43
+ const parcel = dpdParcelNumber(number);
44
+ if (!parcel)
45
+ throw new InvalidInputError(PROVIDER, 'DPD Ireland tracks nine- or ten-digit consignment numbers and fourteen-digit parcel numbers');
46
+ return parcel;
47
+ }
48
+ /** Whether a normalized number is a DPD parcel number rather than a consignment number. */
49
+ export function isDpdIeParcelNumber(number) {
50
+ return PARCEL.test(number);
38
51
  }
39
52
  /** Cloudflare's challenge page in place of the tracker. Its passive script on ordinary pages is not one. */
40
53
  export function isDpdIeChallenge(html) {
@@ -100,7 +113,9 @@ export function parseDpdIe(html, raw) {
100
113
  throw new IndeterminateError(PROVIDER, 'DPD Ireland did not return a consignment');
101
114
  }
102
115
  const info = root.find('.tracking-status-info p').filter((_, node) => /^Consignment\b/.test(clean($(node).text(), 80)));
103
- if (info.length !== 1 || info.find('strong').length !== 1 || clean(info.find('strong').text(), 40) !== number) {
116
+ // A parcel number finds its consignment, which the page names instead.
117
+ const consignment = clean(info.find('strong').text(), 40);
118
+ if (info.length !== 1 || info.find('strong').length !== 1 || (isDpdIeParcelNumber(number) ? !NUMBER.test(consignment) : consignment !== number)) {
104
119
  throw new SchemaError(PROVIDER, 'DPD Ireland returned a different consignment');
105
120
  }
106
121
  const lists = root.find('ul.history-list');
@@ -18,6 +18,9 @@ const NUMBER = /^1\d{15}$/;
18
18
  // `VGS` and fourteen digits, the other number the service answers. They hold
19
19
  // no timestamp and no Luhn check digit.
20
20
  const VGS_NUMBER = /^VGS\d{14}$/;
21
+ // Sixteen capital letters and digits, letters among them, which the service
22
+ // answers like the others. A VGS number with a digit missing is not one.
23
+ const CODE = /^(?!VGS?\d+$)(?=\d*[A-Z])[A-Z\d]{16}$/;
21
24
  // A sorting centre's town and country, the only place a scan's wording names
22
25
  // as the parcel's own. "In transit to" names where it is going.
23
26
  const SORTING_CENTER = /^(?:Return: )?At sorting center in ([^,]{2,80}), ([A-Z]{2})$/;
@@ -32,8 +35,8 @@ const MAX_EVENTS = 100;
32
35
  /** A Vinted Go parcel number; anything else is a number Vinted Go does not issue. */
33
36
  export function normalizeVintedGoNumber(raw) {
34
37
  const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
35
- if (!VGS_NUMBER.test(number) && !(NUMBER.test(number) && hasLuhnCheckDigit(number))) {
36
- throw new InvalidInputError(PROVIDER, 'Vinted Go numbers are sixteen digits starting 1 whose last digit passes the Luhn check, or VGS and fourteen digits');
38
+ if (!VGS_NUMBER.test(number) && !CODE.test(number) && !(NUMBER.test(number) && hasLuhnCheckDigit(number))) {
39
+ throw new InvalidInputError(PROVIDER, 'Vinted Go numbers are sixteen digits starting 1 whose last digit passes the Luhn check, VGS and fourteen digits, or sixteen capital letters and digits');
37
40
  }
38
41
  return number;
39
42
  }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.577.3"
5
+ "version": "0.579.0"
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.577.3-main.2713",
3
+ "version": "0.579.0-main.2719",
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",