universal-parcel-scraper 0.510.0-main.2413 → 0.511.0-main.2416

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
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **554 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **555 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>772 catalog entries · 134 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
package/data/catalog.json CHANGED
@@ -3123,7 +3123,7 @@
3123
3123
  "checksum": "luhn"
3124
3124
  },
3125
3125
  {
3126
- "pattern": "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3126
+ "pattern": "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3127
3127
  "confidence": "high"
3128
3128
  }
3129
3129
  ]
@@ -23849,7 +23849,20 @@
23849
23849
  "timezone": "America/Toronto",
23850
23850
  "tracking": {
23851
23851
  "mode": "automatic",
23852
- "adapter": "universal"
23852
+ "adapter": "postmedia-parcel-services",
23853
+ "requirements": [
23854
+ {
23855
+ "field": "postcode",
23856
+ "validator": "internationalPostcode",
23857
+ "label": "Delivery postal code",
23858
+ "type": "text",
23859
+ "help": "Postmedia Parcel Services shows a parcel only with the recipient's postal code.",
23860
+ "pattern": "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$",
23861
+ "maxLength": 7,
23862
+ "inputMode": "text",
23863
+ "autoComplete": "postal-code"
23864
+ }
23865
+ ]
23853
23866
  },
23854
23867
  "canaryUrl": "https://postmediaparcelservices.com/",
23855
23868
  "trackingSearchUrl": "https://postmediaparcelservices.com/",
@@ -23859,7 +23872,7 @@
23859
23872
  "postmediaparcelservices.com",
23860
23873
  "www.postmediaparcelservices.com"
23861
23874
  ],
23862
- "pathPattern": "^/?$",
23875
+ "pathPattern": "^/(?:fr/?)?(?:track-your-shipment/?)?$",
23863
23876
  "params": [
23864
23877
  "bniTrackingID"
23865
23878
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 772,
3
- "activeAdapters": 554,
3
+ "activeAdapters": 555,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -64316,6 +64316,17 @@
64316
64316
  "ekart"
64317
64317
  ]
64318
64318
  },
64319
+ {
64320
+ "input": "BNI000000001",
64321
+ "carrier": "unknown",
64322
+ "confidence": "low",
64323
+ "candidates": [
64324
+ "colis-prive"
64325
+ ],
64326
+ "asked": [
64327
+ "colis-prive"
64328
+ ]
64329
+ },
64319
64330
  {
64320
64331
  "input": "BOK2023192",
64321
64332
  "carrier": "bookurier",
@@ -2778,7 +2778,7 @@ var Ue = {
2778
2778
  checksum: "luhn"
2779
2779
  },
2780
2780
  {
2781
- pattern: "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
2781
+ pattern: "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
2782
2782
  confidence: "high"
2783
2783
  }
2784
2784
  ]
@@ -18087,13 +18087,24 @@ var Ue = {
18087
18087
  timezone: "America/Toronto",
18088
18088
  tracking: {
18089
18089
  mode: "automatic",
18090
- adapter: "universal"
18090
+ adapter: "postmedia-parcel-services",
18091
+ requirements: [{
18092
+ field: "postcode",
18093
+ validator: "internationalPostcode",
18094
+ label: "Delivery postal code",
18095
+ type: "text",
18096
+ help: "Postmedia Parcel Services shows a parcel only with the recipient's postal code.",
18097
+ pattern: "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$",
18098
+ maxLength: 7,
18099
+ inputMode: "text",
18100
+ autoComplete: "postal-code"
18101
+ }]
18091
18102
  },
18092
18103
  canaryUrl: "https://postmediaparcelservices.com/",
18093
18104
  trackingSearchUrl: "https://postmediaparcelservices.com/",
18094
18105
  linkRules: [{
18095
18106
  domains: ["postmediaparcelservices.com", "www.postmediaparcelservices.com"],
18096
- pathPattern: "^/?$",
18107
+ pathPattern: "^/(?:fr/?)?(?:track-your-shipment/?)?$",
18097
18108
  params: ["bniTrackingID"]
18098
18109
  }],
18099
18110
  detectionRules: []
@@ -0,0 +1,18 @@
1
+ import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ /** The API the tracker on postmediaparcelservices.com asks for one number and postal code. */
5
+ export declare const POSTMEDIA_API = "https://pmparcelservicesapi.postmediaparcelservices.com";
6
+ /** The page's lookup: the number, a plus sign and the postal code, in the path. */
7
+ export declare function postmediaQueryUrl(number: string, postcode: string): string;
8
+ export declare class PostmediaTracker {
9
+ private readonly options;
10
+ constructor(options?: {
11
+ fetcher?: typeof fetch;
12
+ recorder?: StepRecorder;
13
+ userAgent?: string;
14
+ });
15
+ private read;
16
+ fetch(raw: string, rawPostcode: string | null | undefined, context?: TrackingContext): Promise<CarrierResult>;
17
+ }
18
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,68 @@
1
+ import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
2
+ import { runSteps } from '../../core/runner/index.js';
3
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
4
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
5
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
6
+ import { normalizePostmediaNumber, normalizePostmediaPostcode, parsePostmedia, POSTMEDIA_MAX_BYTES, PROVIDER } from './parser.js';
7
+ /** The API the tracker on postmediaparcelservices.com asks for one number and postal code. */
8
+ export const POSTMEDIA_API = 'https://pmparcelservicesapi.postmediaparcelservices.com';
9
+ const REDIRECTS = [301, 302, 303, 307, 308];
10
+ // Bot checks a CDN or firewall could put in place of the reply.
11
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*(?:Just a moment|Attention Required|Access Denied|Request blocked)|awswaf|captcha/i;
12
+ // API Gateway's answer for a route it does not have.
13
+ const NO_ROUTE = /"Missing Authentication Token"/;
14
+ const DEFAULT_BUDGET_MS = 15_000;
15
+ /** The page's lookup: the number, a plus sign and the postal code, in the path. */
16
+ export function postmediaQueryUrl(number, postcode) {
17
+ return `${POSTMEDIA_API}/query/${encodeURIComponent(number)}+${encodeURIComponent(postcode)}`;
18
+ }
19
+ function json(body) {
20
+ try {
21
+ return JSON.parse(body);
22
+ }
23
+ catch (cause) {
24
+ if (CHALLENGE.test(body.slice(0, 64_000)))
25
+ throw new ChallengeError(PROVIDER, 'Postmedia Parcel Services answered with a browser check', { cause });
26
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned a reply that is not JSON', { cause });
27
+ }
28
+ }
29
+ export class PostmediaTracker {
30
+ options;
31
+ constructor(options = {}) {
32
+ this.options = options;
33
+ }
34
+ async read(number, postcode, signal, timeoutMs) {
35
+ const { response, bytes } = await fetchBounded(postmediaQueryUrl(number, postcode), { method: 'GET', signal, headers: {
36
+ Accept: 'application/json, text/plain, */*', 'User-Agent': userAgentOf(this.options.userAgent),
37
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: POSTMEDIA_MAX_BYTES, redirect: 'manual',
38
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 403, 404, 410] });
39
+ // The page reads a missing parcel from a 200 reply; a redirect, a 404 or a missing route is a moved API.
40
+ const body = decodeText(bytes);
41
+ if (REDIRECTS.includes(response.status) || response.status === 404 || response.status === 410
42
+ || (response.status === 403 && NO_ROUTE.test(body.slice(0, 1_000)))) {
43
+ throw new TransportError(PROVIDER, 'Postmedia Parcel Services tracking lookup is unavailable', { status: response.status });
44
+ }
45
+ if (response.status === 403)
46
+ throw new ChallengeError(PROVIDER, 'Postmedia Parcel Services refused the lookup', { status: 403 });
47
+ return parsePostmedia(json(body), number);
48
+ }
49
+ async fetch(raw, rawPostcode, context = {}) {
50
+ const number = normalizePostmediaNumber(raw);
51
+ const postcode = normalizePostmediaPostcode(rawPostcode);
52
+ // A read that fails to reach the tracker, or hangs, is sent once more:
53
+ // the first try gets half of the budget.
54
+ return runSteps({ carrier: 'postmedia-parcel-services', budgetMs: context.budgetMs ?? DEFAULT_BUDGET_MS, signal: context.signal,
55
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
56
+ { id: 'direct', run: ({ signal, remainingMs }) => this.read(number, postcode, signal, firstAttemptMs(remainingMs)) },
57
+ networkRetryStep(({ signal, remainingMs }) => this.read(number, postcode, signal, remainingMs)),
58
+ ]);
59
+ }
60
+ }
61
+ export const adapter = (environment) => {
62
+ const tracker = new PostmediaTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
63
+ // The postal code is part of every lookup, so the adapter cannot recognize a number alone.
64
+ return {
65
+ id: 'postmedia-parcel-services', recordsSteps: true, steps: ['direct', 'retry'],
66
+ track: (input, context) => tracker.fetch(input.number, input.postcode, context),
67
+ };
68
+ };
@@ -19,17 +19,65 @@
19
19
  "timezone": "America/Toronto",
20
20
  "tracking": {
21
21
  "mode": "automatic",
22
- "adapter": "universal",
23
- "steps": []
22
+ "adapter": "postmedia-parcel-services",
23
+ "requirements": [
24
+ {
25
+ "field": "postcode",
26
+ "validator": "internationalPostcode",
27
+ "label": "Delivery postal code",
28
+ "type": "text",
29
+ "help": "Postmedia Parcel Services shows a parcel only with the recipient's postal code.",
30
+ "pattern": "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$",
31
+ "maxLength": 7,
32
+ "inputMode": "text",
33
+ "autoComplete": "postal-code"
34
+ }
35
+ ],
36
+ "steps": [
37
+ "direct",
38
+ "retry"
39
+ ]
24
40
  },
25
- "capabilities": [],
41
+ "capabilities": [
42
+ "history",
43
+ "location",
44
+ "delivered_at",
45
+ "eta"
46
+ ],
26
47
  "portal": {
27
48
  "searchUrl": "https://postmediaparcelservices.com/",
28
49
  "canaryUrl": "https://postmediaparcelservices.com/",
29
- "shows": [],
30
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
50
+ "shows": [
51
+ "status",
52
+ "history",
53
+ "location",
54
+ "eta",
55
+ "order_date",
56
+ "pickup_point"
57
+ ],
58
+ "scraped": [
59
+ "status",
60
+ "history",
61
+ "location",
62
+ "eta",
63
+ "delivered_at",
64
+ "offset_timestamps"
65
+ ],
66
+ "discarded": [
67
+ "order_date",
68
+ "pickup_point"
69
+ ],
70
+ "unavailable": [
71
+ "provider_code",
72
+ "weight",
73
+ "service_name",
74
+ "sender_name",
75
+ "recipient_name",
76
+ "recipient_address",
77
+ "signatory",
78
+ "proof_of_delivery",
79
+ "delivery_partner"
80
+ ]
33
81
  },
34
82
  "links": [
35
83
  {
@@ -37,7 +85,7 @@
37
85
  "postmediaparcelservices.com",
38
86
  "www.postmediaparcelservices.com"
39
87
  ],
40
- "pathPattern": "^/?$",
88
+ "pathPattern": "^/(?:fr/?)?(?:track-your-shipment/?)?$",
41
89
  "params": [
42
90
  "bniTrackingID"
43
91
  ]
@@ -1,5 +1,20 @@
1
1
  {
2
2
  "carrier": "postmedia-parcel-services",
3
- "gap": "Its tracker's archived lookups all carry the recipient's postal code, and no other public source shows a whole number.",
4
- "records": []
3
+ "records": [
4
+ {
5
+ "number": "BNI000000001",
6
+ "role": "shipment",
7
+ "evidence": "synthetic",
8
+ "derivedFrom": "invented",
9
+ "shapeConfirmed": "2026-10-19",
10
+ "expect": {
11
+ "carrier": "unknown",
12
+ "confidence": "low",
13
+ "candidates": [
14
+ "colis-prive"
15
+ ]
16
+ },
17
+ "note": "Invented placeholder for BNI and nine digits; every archived lookup on the tracker also carries the recipient's postal code, so none is kept."
18
+ }
19
+ ]
5
20
  }
@@ -0,0 +1,23 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Postmedia Parcel Services";
3
+ export declare const POSTMEDIA_MAX_BYTES: number;
4
+ /** A number for the tracker, in capitals as its page sends it; anything else is not one Postmedia Parcel Services issues. */
5
+ export declare function normalizePostmediaNumber(raw: string): string;
6
+ /**
7
+ * The recipient's postal code, which the tracker asks for with every number,
8
+ * in capitals as the page sends it. The page puts a space in the middle of a
9
+ * six-character code, so a Canadian one goes with a space after its first
10
+ * three characters, however it was typed. The tracker itself checks any
11
+ * other code.
12
+ */
13
+ export declare function normalizePostmediaPostcode(raw: string | null | undefined): string;
14
+ /**
15
+ * The tracker's reply for one number and postal code: the parcel's number,
16
+ * its expected delivery day and its scans, each with a town, a province and a
17
+ * message in English and French. A number and postal code it holds nothing
18
+ * under get `missing_error`, the page's "we don't have information for the
19
+ * package": a wrong number, a wrong postal code, or a parcel the sender has
20
+ * not announced yet. The parcel's order date, its record ids and the pickup
21
+ * point's address and description are never read.
22
+ */
23
+ export declare function parsePostmedia(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,159 @@
1
+ import { DateTime } from 'luxon';
2
+ import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
3
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
4
+ import { IndeterminateError, InputRequiredError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
5
+ import { languageStageStatus } from '../../core/status/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { postmediaStage } from './status.js';
9
+ export const PROVIDER = 'Postmedia Parcel Services';
10
+ // A delivered parcel's reply is about a kilobyte.
11
+ export const POSTMEDIA_MAX_BYTES = 256 * 1024;
12
+ // `BNI` and nine digits, as the tracker's lookups show them.
13
+ const NUMBER = /^BNI\d{9}$/;
14
+ // A Canadian postal code without its space.
15
+ const POSTCODE = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
16
+ const CLOCK = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/;
17
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
18
+ // The reply for a number and postal code the tracker holds no parcel under.
19
+ const NO_MATCH = /\bdid not match our records\b/i;
20
+ const MAX_ROWS = 200;
21
+ // The page writes a province's name for its code; a scan may give either.
22
+ const PROVINCES = {
23
+ AB: 'Alberta', BC: 'British Columbia', MB: 'Manitoba', NB: 'New Brunswick', NL: 'Newfoundland and Labrador',
24
+ NS: 'Nova Scotia', NT: 'Northwest Territories', NU: 'Nunavut', ON: 'Ontario', PE: 'Prince Edward Island',
25
+ QC: 'Quebec', SK: 'Saskatchewan', YT: 'Yukon',
26
+ };
27
+ /** A number for the tracker, in capitals as its page sends it; anything else is not one Postmedia Parcel Services issues. */
28
+ export function normalizePostmediaNumber(raw) {
29
+ const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
30
+ if (!NUMBER.test(number))
31
+ throw new InvalidInputError(PROVIDER, 'Postmedia Parcel Services numbers are BNI and nine digits');
32
+ return number;
33
+ }
34
+ /**
35
+ * The recipient's postal code, which the tracker asks for with every number,
36
+ * in capitals as the page sends it. The page puts a space in the middle of a
37
+ * six-character code, so a Canadian one goes with a space after its first
38
+ * three characters, however it was typed. The tracker itself checks any
39
+ * other code.
40
+ */
41
+ export function normalizePostmediaPostcode(raw) {
42
+ const typed = deliveryPostcodeText(raw ?? '');
43
+ if (!typed)
44
+ throw new InputRequiredError(PROVIDER, 'postcode', 'Postmedia Parcel Services shows a parcel only with the recipient\'s postal code');
45
+ if (!DELIVERY_POSTCODE.test(typed))
46
+ throw new InvalidInputError(PROVIDER, 'Postmedia Parcel Services requires a valid recipient postal code');
47
+ const compact = typed.replace(/[\s-]/g, '');
48
+ return POSTCODE.test(compact) ? `${compact.slice(0, 3)} ${compact.slice(3)}` : typed;
49
+ }
50
+ /** An optional text field; anything but a string is a changed reply. */
51
+ function text(row, key, maxLength = 200) {
52
+ const value = row[key];
53
+ if (value === null || value === undefined)
54
+ return '';
55
+ if (typeof value !== 'string')
56
+ throw new SchemaError(PROVIDER, `Postmedia Parcel Services returned an invalid ${key}`);
57
+ const cleaned = clean(value, maxLength);
58
+ // The page shows the text "null" as no message.
59
+ return cleaned.toLowerCase() === 'null' ? '' : cleaned;
60
+ }
61
+ /**
62
+ * A scan's `event_date_time`, written without an offset. The page says all
63
+ * its times are in GMT, and archived replies agree: read in any Canadian
64
+ * zone, their newest scans would come after the reply was captured.
65
+ */
66
+ function scanTime(value) {
67
+ const match = CLOCK.exec(value);
68
+ const instant = match ? `${match[1]}T${match[2]}` : '';
69
+ const parsed = DateTime.fromISO(instant, { zone: 'UTC' });
70
+ // Luxon rolls 24:00:00 over to the next day; only a clock that reads back unchanged is kept.
71
+ if (!match || !parsed.isValid || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== instant) {
72
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned an invalid scan time');
73
+ }
74
+ return { time: `${instant}Z`, at: parsed.toMillis() };
75
+ }
76
+ /** A scan's town and province, the province by name as the page writes it. */
77
+ function placeOf(row) {
78
+ const town = text(row, 'event_city', 80);
79
+ const province = text(row, 'event_province', 60);
80
+ return [town, PROVINCES[province.toUpperCase()] ?? province].filter(Boolean).join(', ');
81
+ }
82
+ /** A scan, or null when it has no message in either language: the page shows "N/A" for it. */
83
+ function projectRow(row, index) {
84
+ if (!isRecord(row))
85
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned an invalid scan');
86
+ const { time, at } = scanTime(text(row, 'event_date_time', 40));
87
+ const description = text(row, 'parcel_status_message') || text(row, 'parcel_status_message_french');
88
+ if (!description)
89
+ return null;
90
+ const location = placeOf(row);
91
+ const staged = postmediaStage(description);
92
+ const event = {
93
+ time,
94
+ ...(location ? { location } : {}),
95
+ description,
96
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
97
+ };
98
+ return { at, index, key: JSON.stringify([at, description, location]), event };
99
+ }
100
+ /** The expected delivery day, which the page shows as it is given. */
101
+ function expectedDay(payload) {
102
+ const day = text(payload, 'expected_delivery_date', 32);
103
+ return DAY.test(day) && DateTime.fromISO(day, { zone: 'UTC' }).isValid ? day : null;
104
+ }
105
+ /**
106
+ * The tracker's reply for one number and postal code: the parcel's number,
107
+ * its expected delivery day and its scans, each with a town, a province and a
108
+ * message in English and French. A number and postal code it holds nothing
109
+ * under get `missing_error`, the page's "we don't have information for the
110
+ * package": a wrong number, a wrong postal code, or a parcel the sender has
111
+ * not announced yet. The parcel's order date, its record ids and the pickup
112
+ * point's address and description are never read.
113
+ */
114
+ export function parsePostmedia(payload, raw) {
115
+ const number = normalizePostmediaNumber(raw);
116
+ if (!isRecord(payload))
117
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned an invalid reply');
118
+ if (Object.hasOwn(payload, 'missing_error')) {
119
+ if (typeof payload.missing_error === 'string' && NO_MATCH.test(payload.missing_error)) {
120
+ throw new NotFoundError(PROVIDER, 'Postmedia Parcel Services has no parcel under this number and postal code');
121
+ }
122
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services answered with an unknown notice');
123
+ }
124
+ if (typeof payload.tracking_id !== 'string')
125
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned a parcel without its number');
126
+ if (normalizeTrackingNumber(clean(payload.tracking_id, 64)) !== number) {
127
+ throw new IndeterminateError(PROVIDER, 'Postmedia Parcel Services answered for another parcel');
128
+ }
129
+ const rows = payload.statuses;
130
+ if (!Array.isArray(rows))
131
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned a parcel with invalid scans');
132
+ if (rows.length > MAX_ROWS)
133
+ throw new SchemaError(PROVIDER, 'Postmedia Parcel Services returned an oversized history');
134
+ const seen = new Set();
135
+ const scans = [];
136
+ rows.forEach((row, index) => {
137
+ const scan = projectRow(row, index);
138
+ if (!scan || seen.has(scan.key))
139
+ return;
140
+ seen.add(scan.key);
141
+ scans.push(scan);
142
+ });
143
+ if (!scans.length)
144
+ throw new NoHistoryError(PROVIDER);
145
+ // Newest first, as the page sorts them; the reply lists the oldest first, so a later row is newer on one clock.
146
+ const events = scans.sort((a, b) => b.at - a.at || b.index - a.index).map((scan) => scan.event);
147
+ const newest = events[0];
148
+ const current = events.find((event) => event.stage);
149
+ const stage = current?.stage;
150
+ return {
151
+ status: stage ? languageStageStatus(stage) : 'unknown',
152
+ ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
153
+ last_status_text: newest.description,
154
+ last_update: newest.time,
155
+ expected_delivery: stage === 'delivered' ? null : expectedDay(payload),
156
+ ...(stage === 'delivered' ? { delivered_at: current.time } : {}),
157
+ events,
158
+ };
159
+ }
@@ -0,0 +1,7 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The stage the map gives a scan's message. */
4
+ export declare function postmediaMappedStage(wording: string): Stage | undefined;
5
+ /** A scan's stage: its message in the map, else the shared wording rules, else nothing. */
6
+ export declare function postmediaStage(wording: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,35 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The tracker's scans carry no code, only a message in English and in French.
5
+ * The messages seen in replies are mapped, and the pickup message the page's
6
+ * script singles out; other wording falls to the shared rules.
7
+ */
8
+ const WORDINGS = new Map([
9
+ // The seller's or marketplace's own scan, before the parcel reaches a Postmedia hub.
10
+ ['Shipment has left seller facility and is in transit', 'in_transit'],
11
+ ['L\'envoi a quitté l\'établissement du vendeur et est en transit', 'in_transit'],
12
+ ['Arrival Scan/Received Hub', 'accepted'], // The first scan at a Postmedia hub.
13
+ ['Scan d\'arrivée/centre de réception', 'accepted'],
14
+ ['Out for delivery', 'out_for_delivery'],
15
+ ['En cours de livraison', 'out_for_delivery'],
16
+ ['Delivered', 'delivered'],
17
+ ['Livré', 'delivered'],
18
+ ['Available for pickup', 'ready_for_pickup'],
19
+ ].map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
20
+ /** The stage the map gives a scan's message. */
21
+ export function postmediaMappedStage(wording) {
22
+ return WORDINGS.get(normalizeStatusWording(wording));
23
+ }
24
+ /** A scan's stage: its message in the map, else the shared wording rules, else nothing. */
25
+ export function postmediaStage(wording) {
26
+ const mapped = postmediaMappedStage(wording);
27
+ if (mapped)
28
+ return { stage: mapped, source: 'carrier_map' };
29
+ const worded = classifyWording(wording, 'in_transit');
30
+ return worded.source === 'none' ? null : worded;
31
+ }
32
+ export const statusMap = {
33
+ stage: (_code, wording) => postmediaMappedStage(wording),
34
+ gaps: [],
35
+ };
@@ -82,7 +82,7 @@
82
82
  },
83
83
  {
84
84
  "id": "purolator-2",
85
- "pattern": "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
85
+ "pattern": "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
86
86
  "confidence": "high"
87
87
  }
88
88
  ]
@@ -341,6 +341,7 @@ import { statusMap as postaSlovenije } from '../../carriers/posta-slovenije/stat
341
341
  import { statusMap as postaSrbije } from '../../carriers/posta-srbije/status.js';
342
342
  import { statusMap as postex } from '../../carriers/postex/status.js';
343
343
  import { statusMap as postlogistics } from '../../carriers/postlogistics/status.js';
344
+ import { statusMap as postmediaParcelServices } from '../../carriers/postmedia-parcel-services/status.js';
344
345
  import { statusMap as posturinn } from '../../carriers/posturinn/status.js';
345
346
  import { statusMap as ppl } from '../../carriers/ppl/status.js';
346
347
  import { statusMap as precisionDeliveries } from '../../carriers/precision-deliveries/status.js';
@@ -535,7 +536,8 @@ export const STATUS_MAPS = {
535
536
  'pitney-bowes': pitneyBowes, 'pitt-ohio': pittOhio, 'pos-indonesia': posIndonesia,
536
537
  'post-luxembourg': postLuxembourg, 'posta-faroe': postaFaroe, 'posta-kenya': postaKenya, 'posta-north-macedonia': postaNorthMacedonia, 'posta-romana': postaRomana,
537
538
  'posta-shqiptare': postaShqiptare,
538
- 'posta-slovenije': postaSlovenije, 'posta-srbije': postaSrbije, postex, postlogistics, posturinn, ppl, 'precision-deliveries': precisionDeliveries,
539
+ 'posta-slovenije': postaSlovenije, 'posta-srbije': postaSrbije, postex, postlogistics, 'postmedia-parcel-services': postmediaParcelServices,
540
+ posturinn, ppl, 'precision-deliveries': precisionDeliveries,
539
541
  'pro-carrier': proCarrier, proships, 'pts-worldwide-express': ptsWorldwideExpress, ptt, 'pullman-cargo': pullmanCargo, 'quantium-solutions': quantiumSolutions,
540
542
  'quicksilver-sa': quicksilverSa, quiqup, qwintry, raben, 'ram-hand-to-hand': ramHandToHand, redpack, redur, redx, relaytech,
541
543
  'rl-carriers': rlCarriers, roadrunner, 'royal-express-mm': royalExpressMm,
@@ -3123,7 +3123,7 @@
3123
3123
  "checksum": "luhn"
3124
3124
  },
3125
3125
  {
3126
- "pattern": "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3126
+ "pattern": "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3127
3127
  "confidence": "high"
3128
3128
  }
3129
3129
  ]
@@ -23849,7 +23849,20 @@
23849
23849
  "timezone": "America/Toronto",
23850
23850
  "tracking": {
23851
23851
  "mode": "automatic",
23852
- "adapter": "universal"
23852
+ "adapter": "postmedia-parcel-services",
23853
+ "requirements": [
23854
+ {
23855
+ "field": "postcode",
23856
+ "validator": "internationalPostcode",
23857
+ "label": "Delivery postal code",
23858
+ "type": "text",
23859
+ "help": "Postmedia Parcel Services shows a parcel only with the recipient's postal code.",
23860
+ "pattern": "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$",
23861
+ "maxLength": 7,
23862
+ "inputMode": "text",
23863
+ "autoComplete": "postal-code"
23864
+ }
23865
+ ]
23853
23866
  },
23854
23867
  "canaryUrl": "https://postmediaparcelservices.com/",
23855
23868
  "trackingSearchUrl": "https://postmediaparcelservices.com/",
@@ -23859,7 +23872,7 @@
23859
23872
  "postmediaparcelservices.com",
23860
23873
  "www.postmediaparcelservices.com"
23861
23874
  ],
23862
- "pathPattern": "^/?$",
23875
+ "pathPattern": "^/(?:fr/?)?(?:track-your-shipment/?)?$",
23863
23876
  "params": [
23864
23877
  "bniTrackingID"
23865
23878
  ]
@@ -2110,7 +2110,7 @@ export declare const CARRIER_CATALOG: {
2110
2110
  readonly confidence: "low";
2111
2111
  readonly checksum: "luhn";
2112
2112
  }, {
2113
- readonly pattern: "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$";
2113
+ readonly pattern: "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$";
2114
2114
  readonly confidence: "high";
2115
2115
  }];
2116
2116
  };
@@ -15256,13 +15256,24 @@ export declare const CARRIER_CATALOG: {
15256
15256
  readonly timezone: "America/Toronto";
15257
15257
  readonly tracking: {
15258
15258
  readonly mode: "automatic";
15259
- readonly adapter: "universal";
15259
+ readonly adapter: "postmedia-parcel-services";
15260
+ readonly requirements: readonly [{
15261
+ readonly field: "postcode";
15262
+ readonly validator: "internationalPostcode";
15263
+ readonly label: "Delivery postal code";
15264
+ readonly type: "text";
15265
+ readonly help: "Postmedia Parcel Services shows a parcel only with the recipient's postal code.";
15266
+ readonly pattern: "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$";
15267
+ readonly maxLength: 7;
15268
+ readonly inputMode: "text";
15269
+ readonly autoComplete: "postal-code";
15270
+ }];
15260
15271
  };
15261
15272
  readonly canaryUrl: "https://postmediaparcelservices.com/";
15262
15273
  readonly trackingSearchUrl: "https://postmediaparcelservices.com/";
15263
15274
  readonly linkRules: readonly [{
15264
15275
  readonly domains: readonly ["postmediaparcelservices.com", "www.postmediaparcelservices.com"];
15265
- readonly pathPattern: "^/?$";
15276
+ readonly pathPattern: "^/(?:fr/?)?(?:track-your-shipment/?)?$";
15266
15277
  readonly params: readonly ["bniTrackingID"];
15267
15278
  }];
15268
15279
  readonly detectionRules: readonly [];
@@ -3124,7 +3124,7 @@ export const CARRIER_CATALOG = {
3124
3124
  "checksum": "luhn"
3125
3125
  },
3126
3126
  {
3127
- "pattern": "^(?!(?:BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3127
+ "pattern": "^(?!(?:BNI|BYS|CUB|GWD|LTN|MPX|NZC|QSP|RWM|XLT))[A-Z]{3}\\d{9}$",
3128
3128
  "confidence": "high"
3129
3129
  }
3130
3130
  ]
@@ -23850,7 +23850,20 @@ export const CARRIER_CATALOG = {
23850
23850
  "timezone": "America/Toronto",
23851
23851
  "tracking": {
23852
23852
  "mode": "automatic",
23853
- "adapter": "universal"
23853
+ "adapter": "postmedia-parcel-services",
23854
+ "requirements": [
23855
+ {
23856
+ "field": "postcode",
23857
+ "validator": "internationalPostcode",
23858
+ "label": "Delivery postal code",
23859
+ "type": "text",
23860
+ "help": "Postmedia Parcel Services shows a parcel only with the recipient's postal code.",
23861
+ "pattern": "^[A-Za-z][0-9][A-Za-z] ?[0-9][A-Za-z][0-9]$",
23862
+ "maxLength": 7,
23863
+ "inputMode": "text",
23864
+ "autoComplete": "postal-code"
23865
+ }
23866
+ ]
23854
23867
  },
23855
23868
  "canaryUrl": "https://postmediaparcelservices.com/",
23856
23869
  "trackingSearchUrl": "https://postmediaparcelservices.com/",
@@ -23860,7 +23873,7 @@ export const CARRIER_CATALOG = {
23860
23873
  "postmediaparcelservices.com",
23861
23874
  "www.postmediaparcelservices.com"
23862
23875
  ],
23863
- "pathPattern": "^/?$",
23876
+ "pathPattern": "^/(?:fr/?)?(?:track-your-shipment/?)?$",
23864
23877
  "params": [
23865
23878
  "bniTrackingID"
23866
23879
  ]
@@ -396,6 +396,7 @@ import { adapter as posteItaliane } from '../carriers/poste-italiane/adapter.js'
396
396
  import { adapter as postex } from '../carriers/postex/adapter.js';
397
397
  import { adapter as posti } from '../carriers/posti/adapter.js';
398
398
  import { adapter as postlogistics } from '../carriers/postlogistics/adapter.js';
399
+ import { adapter as postmediaParcelServices } from '../carriers/postmedia-parcel-services/adapter.js';
399
400
  import { adapter as postnord } from '../carriers/postnord/adapter.js';
400
401
  import { adapter as posturinn } from '../carriers/posturinn/adapter.js';
401
402
  import { adapter as ppl } from '../carriers/ppl/adapter.js';
@@ -952,6 +953,7 @@ export const REGISTRY = {
952
953
  "postex": postex,
953
954
  "posti": posti,
954
955
  "postlogistics": postlogistics,
956
+ "postmedia-parcel-services": postmediaParcelServices,
955
957
  "postnord": postnord,
956
958
  "posturinn": posturinn,
957
959
  "ppl": ppl,
@@ -1666,7 +1668,7 @@ export const REGISTRY = {
1666
1668
  "postex": "postex",
1667
1669
  "posti": "posti",
1668
1670
  "postlogistics": "postlogistics",
1669
- "postmedia-parcel-services": "universal",
1671
+ "postmedia-parcel-services": "postmedia-parcel-services",
1670
1672
  "postnord": "postnord",
1671
1673
  "postplus": "universal",
1672
1674
  "posturinn": "posturinn",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.510.0"
5
+ "version": "0.511.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.510.0-main.2413",
3
+ "version": "0.511.0-main.2416",
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",