universal-parcel-scraper 0.190.0-main.1346 → 0.191.0

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 **271 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **272 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>495 catalog entries · 113 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -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 9,873 such statuses, each filed under one stage.
158
+ The carrier folders record 9,886 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.
package/data/catalog.json CHANGED
@@ -9295,7 +9295,9 @@
9295
9295
  "timezone": "Africa/Cairo",
9296
9296
  "tracking": {
9297
9297
  "mode": "automatic",
9298
- "adapter": "universal"
9298
+ "adapter": "egypt-post",
9299
+ "recognitionRank": 944,
9300
+ "localClocks": true
9299
9301
  },
9300
9302
  "canaryUrl": "https://egyptpost.gov.eg/en-us/home/",
9301
9303
  "trackingSearchUrl": "https://egyptpost.gov.eg/en-us/home/",
@@ -9315,6 +9317,12 @@
9315
9317
  {
9316
9318
  "pattern": "^(?:ENO|EXP|RCC)\\d{8}EG$",
9317
9319
  "confidence": "low"
9320
+ },
9321
+ {
9322
+ "pattern": "^[A-Z]{2}\\d{9}EG$",
9323
+ "confidence": "low",
9324
+ "checksum": "s10",
9325
+ "preferred": true
9318
9326
  }
9319
9327
  ]
9320
9328
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 495,
3
- "activeAdapters": 271,
3
+ "activeAdapters": 272,
4
4
  "countries": 113,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -51503,6 +51503,7 @@
51503
51503
  "intl-post"
51504
51504
  ],
51505
51505
  "asked": [
51506
+ "egypt-post",
51506
51507
  "chronopost"
51507
51508
  ]
51508
51509
  },
@@ -52086,6 +52087,20 @@
52086
52087
  "confidence": "low",
52087
52088
  "candidates": [
52088
52089
  "egypt-post"
52090
+ ],
52091
+ "asked": [
52092
+ "egypt-post"
52093
+ ]
52094
+ },
52095
+ {
52096
+ "input": "ENO30052041EG",
52097
+ "carrier": "unknown",
52098
+ "confidence": "low",
52099
+ "candidates": [
52100
+ "egypt-post"
52101
+ ],
52102
+ "asked": [
52103
+ "egypt-post"
52089
52104
  ]
52090
52105
  },
52091
52106
  {
@@ -52669,6 +52684,9 @@
52669
52684
  "confidence": "low",
52670
52685
  "candidates": [
52671
52686
  "egypt-post"
52687
+ ],
52688
+ "asked": [
52689
+ "egypt-post"
52672
52690
  ]
52673
52691
  },
52674
52692
  {
@@ -61675,6 +61693,14 @@
61675
61693
  "chronopost"
61676
61694
  ]
61677
61695
  },
61696
+ {
61697
+ "input": "RB536527177SG",
61698
+ "carrier": "singapore-post",
61699
+ "confidence": "high",
61700
+ "candidates": [
61701
+ "singapore-post"
61702
+ ]
61703
+ },
61678
61704
  {
61679
61705
  "input": "RB584629627SG",
61680
61706
  "carrier": "singapore-post",
@@ -61874,6 +61900,14 @@
61874
61900
  "correios-br"
61875
61901
  ]
61876
61902
  },
61903
+ {
61904
+ "input": "RE272304639SE",
61905
+ "carrier": "postnord",
61906
+ "confidence": "high",
61907
+ "candidates": [
61908
+ "postnord"
61909
+ ]
61910
+ },
61877
61911
  {
61878
61912
  "input": "RE924000013UY",
61879
61913
  "carrier": "intl-post",
@@ -63327,6 +63361,14 @@
63327
63361
  "spring-gds"
63328
63362
  ]
63329
63363
  },
63364
+ {
63365
+ "input": "RU860584495CN",
63366
+ "carrier": "china-post",
63367
+ "confidence": "high",
63368
+ "candidates": [
63369
+ "china-post"
63370
+ ]
63371
+ },
63330
63372
  {
63331
63373
  "input": "RUTD000239675UL",
63332
63374
  "carrier": "unknown",
@@ -7247,7 +7247,9 @@ var Ae = {
7247
7247
  timezone: "Africa/Cairo",
7248
7248
  tracking: {
7249
7249
  mode: "automatic",
7250
- adapter: "universal"
7250
+ adapter: "egypt-post",
7251
+ recognitionRank: 944,
7252
+ localClocks: !0
7251
7253
  },
7252
7254
  canaryUrl: "https://egyptpost.gov.eg/en-us/home/",
7253
7255
  trackingSearchUrl: "https://egyptpost.gov.eg/en-us/home/",
@@ -7259,6 +7261,11 @@ var Ae = {
7259
7261
  detectionRules: [{
7260
7262
  pattern: "^(?:ENO|EXP|RCC)\\d{8}EG$",
7261
7263
  confidence: "low"
7264
+ }, {
7265
+ pattern: "^[A-Z]{2}\\d{9}EG$",
7266
+ confidence: "low",
7267
+ checksum: "s10",
7268
+ preferred: !0
7262
7269
  }]
7263
7270
  },
7264
7271
  ekart: {
@@ -15721,7 +15728,7 @@ var Ae = {
15721
15728
  "ecms-express": ["ecms-express-1"],
15722
15729
  econt: [],
15723
15730
  ecourier: ["ecourier-1"],
15724
- "egypt-post": ["egypt-post-1"],
15731
+ "egypt-post": ["egypt-post-1", "egypt-post-s10"],
15725
15732
  ekart: ["ekart-ecommerce"],
15726
15733
  elta: ["elta-s10"],
15727
15734
  emile: ["emile-1"],
@@ -20887,6 +20894,7 @@ var Dc = /* @__PURE__ */ new Map([
20887
20894
  ["CA", "canada-post"],
20888
20895
  ["CH", "swiss-post"],
20889
20896
  ["DE", "dhl"],
20897
+ ["EG", "egypt-post"],
20890
20898
  ["ES", "correos-spain"],
20891
20899
  ["FI", "posti"],
20892
20900
  ["FR", "la-poste"],
@@ -21154,6 +21162,7 @@ var nl = {
21154
21162
  "early-bird": 350,
21155
21163
  econt: 290,
21156
21164
  ecoscooting: 655,
21165
+ "egypt-post": 944,
21157
21166
  elta: 150,
21158
21167
  emile: 480,
21159
21168
  "ems-vietnam": 962,
@@ -0,0 +1,15 @@
1
+ import { type AdapterFactory, type 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 lookup Egypt Post's tracking page and Android app make for a code, with English wording. */
5
+ export declare const EGYPT_POST_ENDPOINT = "https://egyptpost.gov.eg/en-us/TrackTrace/GetShipmentDetails";
6
+ export declare const EGYPT_POST_USER_AGENT = "Dart/3.9 (dart:io)";
7
+ export declare class EgyptPostTracker {
8
+ private readonly options;
9
+ constructor(options?: {
10
+ fetcher?: typeof fetch;
11
+ recorder?: StepRecorder;
12
+ });
13
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
14
+ }
15
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,65 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { decodeText, fetchBounded } from '../../core/transport/index.js';
7
+ import { EGYPT_POST_MAX_BYTES, normalizeEgyptPostNumber, parseEgyptPost, PROVIDER } from './parser.js';
8
+ /** The lookup Egypt Post's tracking page and Android app make for a code, with English wording. */
9
+ export const EGYPT_POST_ENDPOINT = 'https://egyptpost.gov.eg/en-us/TrackTrace/GetShipmentDetails';
10
+ // The Android app's HTTP client, as Dart names it. The site's firewall blocks
11
+ // or challenges other clients that are not browsers, and lets the app's through.
12
+ export const EGYPT_POST_USER_AGENT = 'Dart/3.9 (dart:io)';
13
+ const REDIRECTS = [301, 302, 303, 307, 308];
14
+ const GONE = [404, 405, 410];
15
+ // Cloudflare's block page and browser check.
16
+ const CHALLENGE = /<title>\s*(?:Just a moment|Attention Required)|_cf_chl_opt|cf-turnstile|cf-error-details/i;
17
+ export class EgyptPostTracker {
18
+ options;
19
+ constructor(options = {}) {
20
+ this.options = options;
21
+ }
22
+ async fetch(raw, context = {}) {
23
+ const number = normalizeEgyptPostNumber(raw);
24
+ const read = async (signal, timeoutMs) => {
25
+ const { response, bytes } = await fetchBounded(`${EGYPT_POST_ENDPOINT}?barcode=${encodeURIComponent(number)}`, { signal, headers: {
26
+ Accept: 'application/json', 'User-Agent': EGYPT_POST_USER_AGENT,
27
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: EGYPT_POST_MAX_BYTES, redirect: 'manual',
28
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, ...GONE, 403] });
29
+ // The tracker answers an unknown code in its reply: a missing route is not a missing item.
30
+ if (REDIRECTS.includes(response.status) || GONE.includes(response.status)) {
31
+ throw new TransportError(PROVIDER, 'Egypt Post tracking lookup is unavailable', { status: response.status });
32
+ }
33
+ if (response.status === 403)
34
+ throw new ChallengeError(PROVIDER, 'Egypt Post refused the lookup at its firewall', { status: 403 });
35
+ const text = decodeText(bytes).replace(/^\uFEFF/, '').trim();
36
+ if (!text)
37
+ throw new IndeterminateError(PROVIDER, 'Egypt Post returned an empty reply');
38
+ let payload;
39
+ try {
40
+ payload = JSON.parse(text);
41
+ }
42
+ catch (cause) {
43
+ if (CHALLENGE.test(text.slice(0, 64_000)))
44
+ throw new ChallengeError(PROVIDER, 'Egypt Post answered with a browser check', { cause });
45
+ throw new SchemaError(PROVIDER, 'Egypt Post returned a reply that is not JSON', { cause });
46
+ }
47
+ return parseEgyptPost(payload, number);
48
+ };
49
+ // A lookup that fails to reach Egypt Post, or hangs, gets one more try:
50
+ // the first gets half of the budget, so the retry fits in the rest.
51
+ return runSteps({ carrier: 'egypt-post', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
52
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
53
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
54
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
55
+ ]);
56
+ }
57
+ }
58
+ export const adapter = (environment) => {
59
+ const tracker = new EgyptPostTracker({ fetcher: environment.fetcher, recorder: environment.recorder });
60
+ return {
61
+ id: 'egypt-post', recordsSteps: true, steps: ['direct', 'retry'],
62
+ track: (input, context) => tracker.fetch(input.number, context),
63
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeEgyptPostNumber(number))),
64
+ };
65
+ };
@@ -18,17 +18,47 @@
18
18
  "timezone": "Africa/Cairo",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "egypt-post",
22
+ "steps": [
23
+ "direct",
24
+ "retry"
25
+ ],
26
+ "recognition": {
27
+ "rank": 944
28
+ },
29
+ "localClocks": true
23
30
  },
24
- "capabilities": [],
31
+ "capabilities": [
32
+ "history",
33
+ "location"
34
+ ],
25
35
  "portal": {
26
36
  "searchUrl": "https://egyptpost.gov.eg/en-us/home/",
27
37
  "canaryUrl": "https://egyptpost.gov.eg/en-us/home/",
28
- "shows": [],
29
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
38
+ "shows": [
39
+ "status",
40
+ "history",
41
+ "location",
42
+ "progress_rail"
43
+ ],
44
+ "scraped": [
45
+ "status",
46
+ "history",
47
+ "location"
48
+ ],
49
+ "discarded": [
50
+ "progress_rail"
51
+ ],
52
+ "unavailable": [
53
+ "eta",
54
+ "weight",
55
+ "service_name",
56
+ "delivered_at",
57
+ "offset_timestamps",
58
+ "sender_name",
59
+ "recipient_name",
60
+ "signatory"
61
+ ]
32
62
  },
33
63
  "links": [
34
64
  {
@@ -47,6 +77,13 @@
47
77
  "id": "egypt-post-1",
48
78
  "pattern": "^(?:ENO|EXP|RCC)\\d{8}EG$",
49
79
  "confidence": "low"
80
+ },
81
+ {
82
+ "id": "egypt-post-s10",
83
+ "pattern": "^[A-Z]{2}\\d{9}EG$",
84
+ "confidence": "low",
85
+ "preferred": true,
86
+ "checksum": "s10"
50
87
  }
51
88
  ]
52
89
  }
@@ -63,6 +63,83 @@
63
63
  "confidence": "high"
64
64
  },
65
65
  "note": "A review on TrackingMore's Emirates Post page gives it for an item sent from Egypt to the UAE; the EG suffix names Egypt Post as the issuing post."
66
+ },
67
+ {
68
+ "number": "ENO30052041EG",
69
+ "role": "shipment",
70
+ "evidence": "open_source_example",
71
+ "source": {
72
+ "url": "https://github.com/MohamedSalamaMousa/shipment/blob/main/resources/js/trackShipment.js",
73
+ "access": "full_page"
74
+ },
75
+ "context": {
76
+ "assessment": "candidate"
77
+ },
78
+ "expect": {
79
+ "carrier": "unknown",
80
+ "confidence": "low",
81
+ "candidates": [
82
+ "egypt-post"
83
+ ]
84
+ },
85
+ "note": "A public Egypt Post tracking project uses it as the default item number of its lookup."
86
+ },
87
+ {
88
+ "number": "RU860584495CN",
89
+ "role": "shipment",
90
+ "evidence": "public_shipment_report",
91
+ "source": {
92
+ "url": "https://web.archive.org/web/20251023074401/https://www.egyptpost.org/track-details?itemReference=RU860584495CN",
93
+ "date": "2025-10-23",
94
+ "dateKind": "capture",
95
+ "access": "indexed_excerpt"
96
+ },
97
+ "context": {
98
+ "assessment": "candidate"
99
+ },
100
+ "expect": {
101
+ "carrier": "china-post",
102
+ "confidence": "high"
103
+ },
104
+ "note": "Item from China looked up on Egypt Post's earlier tracker, as an archived address shows; the capture holds only a redirect."
105
+ },
106
+ {
107
+ "number": "RB536527177SG",
108
+ "role": "shipment",
109
+ "evidence": "public_shipment_report",
110
+ "source": {
111
+ "url": "https://web.archive.org/web/20251017125119/https://www.egyptpost.org/track-details?itemReference=RB536527177SG",
112
+ "date": "2025-10-17",
113
+ "dateKind": "capture",
114
+ "access": "indexed_excerpt"
115
+ },
116
+ "context": {
117
+ "assessment": "candidate"
118
+ },
119
+ "expect": {
120
+ "carrier": "singapore-post",
121
+ "confidence": "high"
122
+ },
123
+ "note": "Item from Singapore looked up on Egypt Post's earlier tracker, as an archived address shows; the capture holds only a redirect."
124
+ },
125
+ {
126
+ "number": "RE272304639SE",
127
+ "role": "shipment",
128
+ "evidence": "public_shipment_report",
129
+ "source": {
130
+ "url": "https://web.archive.org/web/20250510121901/https://www.egyptpost.org/track-details?itemReference=RB597759784SG,RE272304639SE",
131
+ "date": "2025-05-10",
132
+ "dateKind": "capture",
133
+ "access": "indexed_excerpt"
134
+ },
135
+ "context": {
136
+ "assessment": "candidate"
137
+ },
138
+ "expect": {
139
+ "carrier": "postnord",
140
+ "confidence": "high"
141
+ },
142
+ "note": "Item from Sweden looked up with another item on Egypt Post's earlier tracker, as an archived address shows; the capture holds only a redirect."
66
143
  }
67
144
  ]
68
145
  }
@@ -0,0 +1,16 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Egypt Post";
3
+ export declare const EGYPT_POST_MAX_BYTES = 64000;
4
+ /**
5
+ * Egypt Post's tracker looks up its own S10 item codes and those of other
6
+ * posts while the item is in Egypt, and its three-letter domestic codes. An
7
+ * S10 code needs a valid check digit.
8
+ */
9
+ export declare function normalizeEgyptPostNumber(raw: string): string;
10
+ /**
11
+ * The reply of the tracker's `GetShipmentDetails` for one code. `case` 1 holds
12
+ * the item's five steps, each with the newest scan the item had in it, or
13
+ * nothing; 2 is the tracker's "no tracking data" and 3 its "incorrect number".
14
+ * The reply must name the whole requested code.
15
+ */
16
+ export declare function parseEgyptPost(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,125 @@
1
+ import { DateTime } from 'luxon';
2
+ import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { clean } from '../../core/transport/index.js';
6
+ import { isRecord } from '../../core/types.js';
7
+ import { egyptPostStage } from './status.js';
8
+ export const PROVIDER = 'Egypt Post';
9
+ // A reply holds five short rows, under 2 kB.
10
+ export const EGYPT_POST_MAX_BYTES = 64_000;
11
+ // Egypt Post's domestic layout: three letters, eight digits and EG.
12
+ const DOMESTIC = /^[A-Z]{3}\d{8}EG$/;
13
+ // The tracker's five steps: registration, shipping, transport and processing, delivery, completion.
14
+ const STEPS = 5;
15
+ const DATE = /^\d{1,2} [A-Za-z]+ \d{4}$/;
16
+ const CLOCK = /^(?:0?[1-9]|1[0-2]):[0-5]\d [AP]M$/i;
17
+ /**
18
+ * Egypt Post's tracker looks up its own S10 item codes and those of other
19
+ * posts while the item is in Egypt, and its three-letter domestic codes. An
20
+ * S10 code needs a valid check digit.
21
+ */
22
+ export function normalizeEgyptPostNumber(raw) {
23
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
24
+ if (DOMESTIC.test(number) || isValidS10TrackingNumber(number))
25
+ return number;
26
+ throw new InvalidInputError(PROVIDER, 'Egypt Post tracks S10 item codes and three letters, eight digits and EG');
27
+ }
28
+ /** An optional text field; anything but a string is a changed reply. */
29
+ function text(row, key, maxLength = 160) {
30
+ const value = row[key];
31
+ if (value === null || value === undefined)
32
+ return '';
33
+ if (typeof value !== 'string')
34
+ throw new SchemaError(PROVIDER, `Egypt Post returned an invalid ${key}`);
35
+ return clean(value, maxLength);
36
+ }
37
+ /**
38
+ * A step's clock, "05 March 2026" and "02:41 PM": the wall clock of the
39
+ * office that scanned the item, which can be abroad, with no zone.
40
+ */
41
+ function stepClock(date, time) {
42
+ const parsed = DATE.test(date) && CLOCK.test(time)
43
+ ? DateTime.fromFormat(`${date} ${time.toUpperCase()}`, 'd MMMM yyyy h:mm a', { zone: 'UTC', locale: 'en-US' }) : null;
44
+ if (!parsed?.isValid)
45
+ throw new SchemaError(PROVIDER, 'Egypt Post returned an invalid scan time');
46
+ return parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss");
47
+ }
48
+ /** The office, its town and its country, each once: a scan abroad can name its country alone. */
49
+ function place(step) {
50
+ const parts = [];
51
+ for (const part of [text(step, 'location'), text(step, 'city', 80), text(step, 'country', 80)]) {
52
+ if (part && !parts.some((kept) => kept.toLowerCase() === part.toLowerCase()))
53
+ parts.push(part);
54
+ }
55
+ return parts.join(', ');
56
+ }
57
+ function projectStep(step, index) {
58
+ if (!isRecord(step) || step.status !== index + 1)
59
+ throw new SchemaError(PROVIDER, 'Egypt Post returned an invalid tracking step');
60
+ const date = text(step, 'date', 40);
61
+ const time = text(step, 'time', 16);
62
+ const wording = text(step, 'itemStatus', 200);
63
+ const summary = text(step, 'mainStatus', 80);
64
+ // A step the item has not reached is empty.
65
+ if (!date && !time && !wording && !summary)
66
+ return null;
67
+ if (!wording && !summary)
68
+ throw new SchemaError(PROVIDER, 'Egypt Post returned a scan without wording');
69
+ const local = stepClock(date, time);
70
+ const description = wording || summary;
71
+ const location = place(step);
72
+ const staged = egyptPostStage(wording, summary);
73
+ const event = {
74
+ local_time: local,
75
+ ...(location ? { location } : {}),
76
+ description,
77
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
78
+ };
79
+ return { event, step: index + 1 };
80
+ }
81
+ /**
82
+ * The reply of the tracker's `GetShipmentDetails` for one code. `case` 1 holds
83
+ * the item's five steps, each with the newest scan the item had in it, or
84
+ * nothing; 2 is the tracker's "no tracking data" and 3 its "incorrect number".
85
+ * The reply must name the whole requested code.
86
+ */
87
+ export function parseEgyptPost(payload, raw) {
88
+ const number = normalizeEgyptPostNumber(raw);
89
+ if (!isRecord(payload) || typeof payload.success !== 'boolean')
90
+ throw new SchemaError(PROVIDER, 'Egypt Post returned an invalid reply');
91
+ if (!payload.success)
92
+ throw new IndeterminateError(PROVIDER, 'Egypt Post did not complete the lookup');
93
+ const data = payload.data;
94
+ if (!isRecord(data) || typeof data.barcode !== 'string')
95
+ throw new SchemaError(PROVIDER, 'Egypt Post returned an item without its code');
96
+ if (normalizeTrackingNumber(clean(data.barcode, 64)).toUpperCase() !== number) {
97
+ throw new SchemaError(PROVIDER, 'Egypt Post answered for a different item');
98
+ }
99
+ if (data.case === 2)
100
+ throw new NotFoundError(PROVIDER);
101
+ if (data.case === 3)
102
+ throw new InvalidInputError(PROVIDER, 'Egypt Post says the number is incorrect');
103
+ if (data.case !== 1 || !Array.isArray(data.data) || data.data.length !== STEPS) {
104
+ throw new SchemaError(PROVIDER, 'Egypt Post returned invalid tracking steps');
105
+ }
106
+ const scans = data.data.map(projectStep).filter((scan) => scan !== null);
107
+ if (!scans.length)
108
+ throw new NoHistoryError(PROVIDER);
109
+ // Newest first by wall clock; at the same minute the later step comes first.
110
+ scans.sort((left, right) => String(right.event.local_time).localeCompare(String(left.event.local_time)) || right.step - left.step);
111
+ const events = scans.map(({ event }) => event);
112
+ const newest = events[0];
113
+ const stage = newest.stage;
114
+ return {
115
+ status: stage ? languageStageStatus(stage) : 'unknown',
116
+ ...(stage ? { current_stage: stage, current_stage_source: newest.stage_source } : {}),
117
+ last_status_text: newest.description,
118
+ last_update: null,
119
+ last_update_local: newest.local_time,
120
+ expected_delivery: null,
121
+ events,
122
+ // Each step keeps only its newest scan, so the item's other scans are missing.
123
+ history_truncated: true,
124
+ };
125
+ }
@@ -0,0 +1,20 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The tracker's scan wording in English, mapped whole. */
4
+ export declare const EGYPT_POST_SCANS: Readonly<Record<string, Stage>>;
5
+ /**
6
+ * The step summary each scan comes with. The page does not show it; it stages
7
+ * a scan whose own wording neither the map nor the shared rules know.
8
+ */
9
+ export declare const EGYPT_POST_SUMMARIES: Readonly<Record<string, Stage>>;
10
+ /** The stage Egypt Post's own scan wording gives a scan, if the map knows it. */
11
+ export declare function egyptPostScanStage(wording: string): Stage | undefined;
12
+ /** The stage a step summary gives, if the map knows it. */
13
+ export declare function egyptPostSummaryStage(summary: string): Stage | undefined;
14
+ /**
15
+ * A scan's stage from its wording, else the shared wording rules, else its
16
+ * step summary. A scan none of them stages stays visible for review.
17
+ */
18
+ export declare function egyptPostStage(wording: string, summary: string): ClassifiedWording | null;
19
+ /** The scans carry wording only; a scan without its own wording shows its step summary. */
20
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,65 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /** The tracker's scan wording in English, mapped whole. */
4
+ export const EGYPT_POST_SCANS = {
5
+ // A pickup the sender booked, before the item is collected.
6
+ 'REQUEST PICKUP CREATED': 'registered',
7
+ 'Shipment is collected from sender': 'accepted',
8
+ 'Shipment was collected': 'accepted',
9
+ 'Shipment is collected': 'accepted',
10
+ 'Shipment Arrived at office': 'in_transit',
11
+ 'Current Office Changed on Demand': 'in_transit',
12
+ // At the international office of exchange of Egypt or of the country abroad.
13
+ 'Shipment Arrived at Office Of Exchange': 'in_transit',
14
+ 'Shipment Dispatched from Inward Office Of Exchange': 'in_transit',
15
+ 'Shipment dispatched to international transporter': 'in_transit',
16
+ 'Shipment is Out for Delivery': 'out_for_delivery',
17
+ // The courier brought the item back to the office: a failed attempt, not a round still under way.
18
+ 'Shipment Out for Delivery and Return to Office': 'failed_attempt',
19
+ 'Shipment Delivered': 'delivered',
20
+ };
21
+ /**
22
+ * The step summary each scan comes with. The page does not show it; it stages
23
+ * a scan whose own wording neither the map nor the shared rules know.
24
+ */
25
+ export const EGYPT_POST_SUMMARIES = {
26
+ 'In PICKING': 'registered',
27
+ Collected: 'accepted',
28
+ 'In Process': 'in_transit',
29
+ 'In international Process': 'in_transit',
30
+ 'Out for Delivery': 'out_for_delivery',
31
+ 'Attempt of Delivery': 'failed_attempt',
32
+ Delivered: 'delivered',
33
+ };
34
+ const keyed = (map) => new Map(Object.entries(map).map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
35
+ const SCANS = keyed(EGYPT_POST_SCANS);
36
+ const SUMMARIES = keyed(EGYPT_POST_SUMMARIES);
37
+ /** The stage Egypt Post's own scan wording gives a scan, if the map knows it. */
38
+ export function egyptPostScanStage(wording) {
39
+ return SCANS.get(normalizeStatusWording(wording));
40
+ }
41
+ /** The stage a step summary gives, if the map knows it. */
42
+ export function egyptPostSummaryStage(summary) {
43
+ return SUMMARIES.get(normalizeStatusWording(summary));
44
+ }
45
+ /**
46
+ * A scan's stage from its wording, else the shared wording rules, else its
47
+ * step summary. A scan none of them stages stays visible for review.
48
+ */
49
+ export function egyptPostStage(wording, summary) {
50
+ const mapped = wording ? egyptPostScanStage(wording) : undefined;
51
+ if (mapped)
52
+ return { stage: mapped, source: 'carrier_map' };
53
+ if (wording) {
54
+ const worded = classifyWording(wording, 'in_transit');
55
+ if (worded.source !== 'none')
56
+ return worded;
57
+ }
58
+ const summed = summary ? egyptPostSummaryStage(summary) : undefined;
59
+ return summed ? { stage: summed, source: 'carrier_map' } : null;
60
+ }
61
+ /** The scans carry wording only; a scan without its own wording shows its step summary. */
62
+ export const statusMap = {
63
+ stage: (code, wording) => (code ? undefined : egyptPostScanStage(wording) ?? egyptPostSummaryStage(wording)),
64
+ gaps: [],
65
+ };
@@ -5,10 +5,23 @@
5
5
  {"wording": "Current Office Changed on Demand", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of Egypt Post's tracker on egyptpost.org. The item was moved to another office on request."},
6
6
  {"wording": "Processed through Facility", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of Egypt Post's tracker on egyptpost.org."},
7
7
  {"wording": "Shipment in transport", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of Egypt Post's tracker on egyptpost.org."},
8
- {"wording": "Collected", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "A step of the progress rail on Egypt Post's earlier tracker, seen in an archived capture."},
9
- {"wording": "In process", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "A step of the progress rail on Egypt Post's earlier tracker, seen in an archived capture."},
10
8
  {"wording": "Shipped", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "A step of the progress rail on Egypt Post's earlier tracker, seen in an archived capture."},
11
- {"wording": "Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "A step of the progress rail on Egypt Post's earlier tracker, seen in an archived capture."},
12
- {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "A step of the progress rail on Egypt Post's earlier tracker, seen in an archived capture."}
9
+ {"wording": "Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it. Seen as the progress rail step of the earlier tracker too."},
10
+ {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it. Seen as the progress rail step of the earlier tracker too."},
11
+ {"wording": "Shipment is collected from sender", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply of the tracker's lookup."},
12
+ {"wording": "Shipment Arrived at office", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply of the tracker's lookup."},
13
+ {"wording": "Shipment Arrived at Office Of Exchange", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen in live replies, at an office of exchange abroad for an item sent from Egypt."},
14
+ {"wording": "Shipment Dispatched from Inward Office Of Exchange", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply, abroad for an item sent from Egypt."},
15
+ {"wording": "Shipment dispatched to international transporter", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply of the tracker's lookup."},
16
+ {"wording": "Shipment Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "Seen in live replies of the tracker's lookup."},
17
+ {"wording": "Collected", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it. Seen as the progress rail step of the earlier tracker too."},
18
+ {"wording": "In Process", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it. Seen as the progress rail step \"In process\" of the earlier tracker too."},
19
+ {"wording": "In international Process", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it."},
20
+ {"wording": "REQUEST PICKUP CREATED", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply for a domestic item, in the registration step before collection."},
21
+ {"wording": "Shipment was collected", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply for a domestic item."},
22
+ {"wording": "Shipment is Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Seen in a live reply of the tracker's lookup."},
23
+ {"wording": "Shipment Out for Delivery and Return to Office", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "Seen in live replies under the step summary \"Attempt of Delivery\": the courier brought the item back to the office."},
24
+ {"wording": "In PICKING", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it."},
25
+ {"wording": "Attempt of Delivery", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "A step summary in the tracker's live replies; the page does not show it."}
13
26
  ]
14
27
  }
@@ -4,7 +4,9 @@ import { type StepRecorder } from '../../core/telemetry/index.js';
4
4
  import { TrawlClient } from '../../core/transport/index.js';
5
5
  export { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
6
6
  export interface RoyalMailTrackerOptions {
7
- /** Fresh local Chromium; preferred when supplied. */
7
+ /** Private Playwright WebSocket service; preferred when supplied. Keep its capability URL outside logs and Git. */
8
+ browserWsEndpoint?: string | null;
9
+ /** Fresh local Chromium, used when no private browser endpoint is supplied. */
8
10
  executablePath?: string | null;
9
11
  /** An `http://` or `socks5://` proxy for the local Chromium's traffic. */
10
12
  browserProxy?: string | null;
@@ -20,6 +20,7 @@ export class RoyalMailTracker {
20
20
  #fullHistory;
21
21
  #executablePath;
22
22
  #browserProxy;
23
+ #browserWsEndpoint;
23
24
  constructor(options = {}) {
24
25
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
25
26
  throw new TypeError('Royal Mail timeout must be positive');
@@ -32,6 +33,7 @@ export class RoyalMailTracker {
32
33
  this.#fullHistory = options.fullHistory ?? false;
33
34
  this.#executablePath = options.executablePath ?? null;
34
35
  this.#browserProxy = options.browserProxy?.trim() || null;
36
+ this.#browserWsEndpoint = options.browserWsEndpoint?.trim() || null;
35
37
  }
36
38
  /** The injected browser service, or one built from the configured URL. */
37
39
  #browserService() {
@@ -41,9 +43,9 @@ export class RoyalMailTracker {
41
43
  }
42
44
  async fetch(trackingNumber, context = {}) {
43
45
  const number = normalizeRoyalMailNumber(trackingNumber);
44
- if (this.#executablePath) {
46
+ if (this.#browserWsEndpoint || this.#executablePath) {
45
47
  return runSteps({ carrier: 'royal-mail', budgetMs: context.budgetMs ?? this.timeoutMs,
46
- signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath, signal, Math.min(60_000, remainingMs), this.#fullHistory, this.#browserProxy) }]);
48
+ signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath ?? '', signal, Math.min(60_000, remainingMs), this.#fullHistory, this.#browserProxy, this.#browserWsEndpoint) }]);
47
49
  }
48
50
  // The legacy service remains available through explicit tracker instances.
49
51
  const trawl = this.#browserService();
@@ -170,20 +172,21 @@ export class RoyalMailTracker {
170
172
  }
171
173
  }
172
174
  export const adapter = environment => {
175
+ const endpoint = environment.env.ROYAL_MAIL_BROWSER_WS_ENDPOINT?.trim() || null;
173
176
  const tracker = new RoyalMailTracker({ executablePath: environment.browserExecutablePath,
174
- browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
177
+ browserWsEndpoint: endpoint, browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
175
178
  return {
176
179
  id: 'royal-mail', recordsSteps: true, steps: ['browser'],
177
180
  track: (input, context) => {
178
181
  normalizeRoyalMailNumber(input.number);
179
- if (!environment.browserExecutablePath)
180
- throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH');
182
+ if (!endpoint && !environment.browserExecutablePath)
183
+ throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH or ROYAL_MAIL_BROWSER_WS_ENDPOINT');
181
184
  return tracker.fetch(input.number, context);
182
185
  },
183
186
  recognizeWithBrowser: (number, context) => recognizeFromBrowserLookup(() => {
184
187
  normalizeRoyalMailNumber(number);
185
- if (!environment.browserExecutablePath)
186
- throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH');
188
+ if (!endpoint && !environment.browserExecutablePath)
189
+ throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH or ROYAL_MAIL_BROWSER_WS_ENDPOINT');
187
190
  return tracker.fetch(number, context);
188
191
  }),
189
192
  };
@@ -0,0 +1,3 @@
1
+ import type { BrowserServer } from 'playwright-core';
2
+ /** A private Playwright service. Its capability path and process environment never enter browser pages. */
3
+ export declare function startRoyalMailBrowserService(env: Readonly<Record<string, string | undefined>>): Promise<BrowserServer>;
@@ -0,0 +1,19 @@
1
+ import { loadChromium } from '../../core/transport/optional.js';
2
+ /** A private Playwright service. Its capability path and process environment never enter browser pages. */
3
+ export async function startRoyalMailBrowserService(env) {
4
+ const executablePath = env.TRACKING_CHROMIUM_PATH?.trim();
5
+ if (!executablePath)
6
+ throw new TypeError('Royal Mail browser service requires TRACKING_CHROMIUM_PATH');
7
+ const wsPath = env.ROYAL_MAIL_BROWSER_WS_PATH ?? '';
8
+ if (!/^\/[A-Za-z0-9_-]{32,128}$/.test(wsPath)) {
9
+ throw new TypeError('ROYAL_MAIL_BROWSER_WS_PATH must be an unguessable path of 32 to 128 URL-safe characters');
10
+ }
11
+ const rawPort = env.ROYAL_MAIL_BROWSER_PORT ?? '3001';
12
+ const port = /^\d{1,5}$/.test(rawPort) ? Number(rawPort) : 0;
13
+ if (port < 1 || port > 65535)
14
+ throw new TypeError('Invalid Royal Mail browser service port');
15
+ const chromium = await loadChromium('Royal Mail');
16
+ return chromium.launchServer({ executablePath, headless: true, host: '127.0.0.1', port, wsPath,
17
+ args: ['--disable-blink-features=AutomationControlled'], timeout: 20_000,
18
+ env: { PATH: env.PATH ?? '', HOME: env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
19
+ }
@@ -1,5 +1,6 @@
1
- import type { Page } from 'playwright-core';
1
+ import type { Browser, Page } from 'playwright-core';
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
+ export declare function royalMailBrowserUserAgent(browser: Browser, remote: boolean): Promise<string>;
3
4
  /** The destination the full-history reply adds to the summary, and nothing else from it. */
4
5
  export declare function royalMailDestination(summary: unknown): {
5
6
  summary?: {
@@ -8,4 +9,4 @@ export declare function royalMailDestination(summary: unknown): {
8
9
  };
9
10
  /** Observe the application's request; its issued session stays in the browser. */
10
11
  export declare function captureRoyalMailReply(page: Page, url: string, number: string, phase: 'summary' | 'events', signal: AbortSignal, timeoutMs: number, trigger: () => Promise<void>): Promise<Record<string, unknown>>;
11
- export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean, proxy?: string | null): Promise<CarrierResult>;
12
+ export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean, proxy?: string | null, endpoint?: string | null): Promise<CarrierResult>;
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
3
- import { withLocalBrowser } from '../../core/transport/localBrowser.js';
3
+ import { withLocalBrowser, withRemoteBrowser } from '../../core/transport/localBrowser.js';
4
4
  import { isRecord } from '../../core/types.js';
5
5
  import { parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
6
6
  const PROVIDER = 'Royal Mail';
@@ -8,6 +8,25 @@ const ORIGIN = 'https://www.royalmail.com';
8
8
  const MAX_BYTES = 2_000_000;
9
9
  // The page keeps this frame hidden off-screen until hCaptcha wants a person to solve it.
10
10
  const CAPTCHA_CHALLENGE = 'iframe[src*="hcaptcha.com"][src*="frame=challenge"]:visible';
11
+ export async function royalMailBrowserUserAgent(browser, remote) {
12
+ const major = browser.version().split('.')[0];
13
+ if (remote) {
14
+ // Use the browser's OS, rather than the calling server's platform.
15
+ const cdp = await browser.newBrowserCDPSession();
16
+ try {
17
+ const version = await cdp.send('Browser.getVersion');
18
+ if (!/^Mozilla\/5\.0 [\x20-\x7E]{20,300}$/.test(version.userAgent))
19
+ throw new SchemaError(PROVIDER);
20
+ return version.userAgent.replace(/HeadlessChrome\/[\d.]+/, `Chrome/${major}.0.0.0`);
21
+ }
22
+ finally {
23
+ await cdp.detach();
24
+ }
25
+ }
26
+ const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
27
+ : process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
28
+ return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
29
+ }
11
30
  // The consent manager parks the address of a frame it blocks in `data-ta-src`.
12
31
  const CAPTCHA_WITHHELD = 'iframe[data-hcaptcha-widget-id][data-ta-src]';
13
32
  /** The destination the full-history reply adds to the summary, and nothing else from it. */
@@ -142,15 +161,14 @@ export async function captureRoyalMailReply(page, url, number, phase, signal, ti
142
161
  deadline.removeEventListener('abort', aborted);
143
162
  }
144
163
  }
145
- export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory, proxy = null) {
146
- return withLocalBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs, proxy,
164
+ export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory, proxy = null, endpoint = null) {
165
+ const withBrowser = endpoint ? withRemoteBrowser : withLocalBrowser;
166
+ return withBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs, proxy, endpoint: endpoint ?? '',
147
167
  args: ['--disable-blink-features=AutomationControlled'] }, async ({ browser, signal: session, remainingMs }) => {
148
168
  try {
149
- const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
150
- : process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
151
- const major = browser.version().split('.')[0];
169
+ const userAgent = await royalMailBrowserUserAgent(browser, endpoint !== null);
152
170
  const context = await browser.newContext({ locale: 'en-GB', acceptDownloads: false, serviceWorkers: 'block',
153
- userAgent: `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36` });
171
+ userAgent });
154
172
  session.throwIfAborted();
155
173
  const page = await context.newPage();
156
174
  const timeout = () => Math.min(15_000, remainingMs());
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { InputRequiredError } from '../core/errors/index.js';
5
5
  import { createTracker, TrackingError } from '../facade/index.js';
6
6
  import { CARRIER_CATALOG } from '../generated/catalog.js';
7
7
  import { createTrackingServer } from '../server/index.js';
8
+ import { startRoyalMailBrowserService } from '../carriers/royal-mail/browser-service.js';
8
9
  const help = `Universal Parcel Scraper
9
10
 
10
11
  parcel-scraper detect <number, link or text>
@@ -12,6 +13,7 @@ const help = `Universal Parcel Scraper
12
13
  parcel-scraper track <number> [--carrier <id>] [--postcode <value>] [--tracking-url <url>]
13
14
  parcel-scraper carriers
14
15
  parcel-scraper serve [--host <address>] [--port <port>]
16
+ parcel-scraper royal-mail-browser
15
17
 
16
18
  SCRAPER_PROVIDERS selects comma-separated providers; default: UPU.
17
19
  FLARESOLVERR_URL and TRACKING_CHROMIUM_PATH enable browser transports.
@@ -34,6 +36,15 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
34
36
  console.log(help);
35
37
  return 0;
36
38
  }
39
+ if (command === 'royal-mail-browser') {
40
+ if (args.length)
41
+ throw new TypeError('Unexpected argument; configure the Royal Mail browser service through its environment');
42
+ const server = await startRoyalMailBrowserService(env);
43
+ console.error(JSON.stringify({ service: 'royal-mail-browser', state: 'ready' }));
44
+ for (const signal of ['SIGINT', 'SIGTERM'])
45
+ process.once(signal, () => { void server.close(); });
46
+ return 0;
47
+ }
37
48
  if (!['detect', 'recognize', 'track', 'carriers', 'serve'].includes(command))
38
49
  throw new TypeError('Unknown command; use --help');
39
50
  const values = {};
@@ -36,7 +36,7 @@ const CATALOG_NAMES = new Set([
36
36
  // request.
37
37
  const NATIONAL_POSTS = new Map([
38
38
  ['AR', 'correo-argentino'], ['AU', 'australia-post'], ['CA', 'canada-post'], ['CH', 'swiss-post'],
39
- ['DE', 'dhl'], ['ES', 'correos-spain'], ['FI', 'posti'], ['FR', 'la-poste'],
39
+ ['DE', 'dhl'], ['EG', 'egypt-post'], ['ES', 'correos-spain'], ['FI', 'posti'], ['FR', 'la-poste'],
40
40
  ['GB', 'royal-mail'], ['JP', 'japan-post'], ['NL', 'spring-gds'], ['PL', 'poczta-polska'], ['PT', 'ctt'],
41
41
  ['RO', 'posta-romana'], ['SA', 'saudi-post'], ['SE', 'postnord'], ['UA', 'ukrposhta'], ['US', 'usps'],
42
42
  ]);
@@ -64,6 +64,7 @@ import { statusMap as dsv } from '../../carriers/dsv/status.js';
64
64
  import { statusMap as dynalogic } from '../../carriers/dynalogic/status.js';
65
65
  import { statusMap as earlyBird } from '../../carriers/early-bird/status.js';
66
66
  import { statusMap as econt } from '../../carriers/econt/status.js';
67
+ import { statusMap as egyptPost } from '../../carriers/egypt-post/status.js';
67
68
  import { statusMap as elta } from '../../carriers/elta/status.js';
68
69
  import { statusMap as emile } from '../../carriers/emile/status.js';
69
70
  import { statusMap as emsVietnam } from '../../carriers/ems-vietnam/status.js';
@@ -203,7 +204,7 @@ export const STATUS_MAPS = {
203
204
  couriersplease: couriersPlease, 'cyprus-post': cyprusPost,
204
205
  'dai-post': daiPost, dao, 'day-ross': dayRoss,
205
206
  dellin, deppon, 'dhl-express': dhlExpress, 'dhl-freight': dhlFreight, dhlink, dpd, 'dpd-de': dpdDe, 'dpd-fr': dpdFr, 'dpd-ie': dpdIe, 'dpd-pl': dpdPl, dsv, dynalogic,
206
- 'early-bird': earlyBird, econt, elta, emile, 'ems-vietnam': emsVietnam, envialia, 'epost-global': epostGlobal, estes,
207
+ 'early-bird': earlyBird, econt, 'egypt-post': egyptPost, elta, emile, 'ems-vietnam': emsVietnam, envialia, 'epost-global': epostGlobal, estes,
207
208
  'express-one': expressOne, 'fan-courier': fanCourier,
208
209
  foxpost, gati, gdex, 'gebrueder-weiss': gebruederWeiss, geis, 'geniki-taxydromiki': geniki, 'geodis-pl': geodisPl, 'gls-es': glsEs, 'gls-it': glsIt,
209
210
  'go-express': goExpress, gofo, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, gzyl, hanjin, 'hct-logistics': hctLogistics, helthjem, hepsijet, 'hrvatska-posta': hrvatskaPosta, hywl, imile,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The one local Chromium a process runs. A carrier outage can send a whole
2
+ * The one browser session a process runs. A carrier outage can send a whole
3
3
  * batch to the browser tier, so lookups take turns instead of each spawning a
4
4
  * browser: a lookup waits for the browser inside its own deadline and fails
5
5
  * with a budget error when that deadline passes first.
@@ -24,3 +24,7 @@ export interface LocalBrowserSession {
24
24
  remainingMs: () => number;
25
25
  }
26
26
  export declare function withLocalBrowser<T>(options: LocalBrowserOptions, run: (session: LocalBrowserSession) => Promise<T>): Promise<T>;
27
+ /** Connects to a private Playwright browser; closing the client disposes its contexts, not the service. */
28
+ export declare function withRemoteBrowser<T>(options: Pick<LocalBrowserOptions, 'provider' | 'signal' | 'timeoutMs'> & {
29
+ endpoint: string;
30
+ }, run: (session: LocalBrowserSession) => Promise<T>): Promise<T>;
@@ -1,4 +1,4 @@
1
- import { BudgetExceededError, carrierErrorKind } from '../errors/index.js';
1
+ import { BudgetExceededError, carrierErrorKind, TransportError } from '../errors/index.js';
2
2
  import { loadChromium } from './optional.js';
3
3
  /** Covers the rounding of the timeouts handed to Playwright and the granularity of timers. */
4
4
  const DEADLINE_SLACK_MS = 5;
@@ -17,6 +17,33 @@ function proxyServer(value) {
17
17
  return `${url.protocol}//${url.host}`;
18
18
  }
19
19
  export async function withLocalBrowser(options, run) {
20
+ return withBrowser(options, run);
21
+ }
22
+ /** Connects to a private Playwright browser; closing the client disposes its contexts, not the service. */
23
+ export async function withRemoteBrowser(options, run) {
24
+ let url;
25
+ try {
26
+ url = new URL(options.endpoint);
27
+ }
28
+ catch {
29
+ throw new TypeError('Browser endpoint must be a WebSocket URL');
30
+ }
31
+ if (!['ws:', 'wss:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.hash) {
32
+ throw new TypeError('Browser endpoint must be a WebSocket URL');
33
+ }
34
+ try {
35
+ return await withBrowser({ provider: options.provider, timeoutMs: options.timeoutMs, signal: options.signal, executablePath: '' }, run, url.href);
36
+ }
37
+ catch (error) {
38
+ if (options.signal?.aborted)
39
+ throw options.signal.reason;
40
+ if (carrierErrorKind(error))
41
+ throw error;
42
+ // Connection diagnostics can contain the endpoint's capability path.
43
+ throw new TransportError(options.provider, `${options.provider} browser service is unavailable`);
44
+ }
45
+ }
46
+ async function withBrowser(options, run, endpoint) {
20
47
  const { provider, timeoutMs } = options;
21
48
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
22
49
  throw new TypeError('Browser tracking timeout must be between 1 and 60000 ms');
@@ -69,9 +96,11 @@ export async function withLocalBrowser(options, run) {
69
96
  if (signal.aborted)
70
97
  throw stopped();
71
98
  // Do not pass the application environment (database or API secrets) to Chromium.
72
- launching = chromium.launch({ executablePath: options.executablePath, headless: true, args: [...(options.args ?? [])],
73
- timeout: Math.min(remainingMs(), 10_000), ...(proxy ? { proxy: { server: proxy } } : {}),
74
- env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
99
+ // The private service binds to loopback; its Host check must survive a tunnel's different address.
100
+ launching = endpoint ? chromium.connect(endpoint, { timeout: Math.min(remainingMs(), 10_000), headers: { Host: 'localhost' } })
101
+ : chromium.launch({ executablePath: options.executablePath, headless: true, args: [...(options.args ?? [])],
102
+ timeout: Math.min(remainingMs(), 10_000), ...(proxy ? { proxy: { server: proxy } } : {}),
103
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
75
104
  browser = await unlessStopped(launching);
76
105
  return await unlessStopped(run({ browser, signal, remainingMs }));
77
106
  }
@@ -9295,7 +9295,9 @@
9295
9295
  "timezone": "Africa/Cairo",
9296
9296
  "tracking": {
9297
9297
  "mode": "automatic",
9298
- "adapter": "universal"
9298
+ "adapter": "egypt-post",
9299
+ "recognitionRank": 944,
9300
+ "localClocks": true
9299
9301
  },
9300
9302
  "canaryUrl": "https://egyptpost.gov.eg/en-us/home/",
9301
9303
  "trackingSearchUrl": "https://egyptpost.gov.eg/en-us/home/",
@@ -9315,6 +9317,12 @@
9315
9317
  {
9316
9318
  "pattern": "^(?:ENO|EXP|RCC)\\d{8}EG$",
9317
9319
  "confidence": "low"
9320
+ },
9321
+ {
9322
+ "pattern": "^[A-Z]{2}\\d{9}EG$",
9323
+ "confidence": "low",
9324
+ "checksum": "s10",
9325
+ "preferred": true
9318
9326
  }
9319
9327
  ]
9320
9328
  },
@@ -6057,7 +6057,9 @@ export declare const CARRIER_CATALOG: {
6057
6057
  readonly timezone: "Africa/Cairo";
6058
6058
  readonly tracking: {
6059
6059
  readonly mode: "automatic";
6060
- readonly adapter: "universal";
6060
+ readonly adapter: "egypt-post";
6061
+ readonly recognitionRank: 944;
6062
+ readonly localClocks: true;
6061
6063
  };
6062
6064
  readonly canaryUrl: "https://egyptpost.gov.eg/en-us/home/";
6063
6065
  readonly trackingSearchUrl: "https://egyptpost.gov.eg/en-us/home/";
@@ -6069,6 +6071,11 @@ export declare const CARRIER_CATALOG: {
6069
6071
  readonly detectionRules: readonly [{
6070
6072
  readonly pattern: "^(?:ENO|EXP|RCC)\\d{8}EG$";
6071
6073
  readonly confidence: "low";
6074
+ }, {
6075
+ readonly pattern: "^[A-Z]{2}\\d{9}EG$";
6076
+ readonly confidence: "low";
6077
+ readonly checksum: "s10";
6078
+ readonly preferred: true;
6072
6079
  }];
6073
6080
  };
6074
6081
  readonly ekart: {
@@ -9296,7 +9296,9 @@ export const CARRIER_CATALOG = {
9296
9296
  "timezone": "Africa/Cairo",
9297
9297
  "tracking": {
9298
9298
  "mode": "automatic",
9299
- "adapter": "universal"
9299
+ "adapter": "egypt-post",
9300
+ "recognitionRank": 944,
9301
+ "localClocks": true
9300
9302
  },
9301
9303
  "canaryUrl": "https://egyptpost.gov.eg/en-us/home/",
9302
9304
  "trackingSearchUrl": "https://egyptpost.gov.eg/en-us/home/",
@@ -9316,6 +9318,12 @@ export const CARRIER_CATALOG = {
9316
9318
  {
9317
9319
  "pattern": "^(?:ENO|EXP|RCC)\\d{8}EG$",
9318
9320
  "confidence": "low"
9321
+ },
9322
+ {
9323
+ "pattern": "^[A-Z]{2}\\d{9}EG$",
9324
+ "confidence": "low",
9325
+ "checksum": "s10",
9326
+ "preferred": true
9319
9327
  }
9320
9328
  ]
9321
9329
  },
@@ -20875,7 +20883,7 @@ export const DETECTION_RULE_IDS = {
20875
20883
  "ecms-express": ["ecms-express-1"],
20876
20884
  "econt": [],
20877
20885
  "ecourier": ["ecourier-1"],
20878
- "egypt-post": ["egypt-post-1"],
20886
+ "egypt-post": ["egypt-post-1", "egypt-post-s10"],
20879
20887
  "ekart": ["ekart-ecommerce"],
20880
20888
  "elta": ["elta-s10"],
20881
20889
  "emile": ["emile-1"],
@@ -62,6 +62,7 @@ export const CARRIER_RECOGNITION_RANKS = {
62
62
  "early-bird": 350,
63
63
  "econt": 290,
64
64
  "ecoscooting": 655,
65
+ "egypt-post": 944,
65
66
  "elta": 150,
66
67
  "emile": 480,
67
68
  "ems-vietnam": 962,
@@ -85,6 +85,7 @@ import { adapter as dynalogic } from '../carriers/dynalogic/adapter.js';
85
85
  import { adapter as earlyBird } from '../carriers/early-bird/adapter.js';
86
86
  import { adapter as econt } from '../carriers/econt/adapter.js';
87
87
  import { adapter as ecoscooting } from '../carriers/ecoscooting/adapter.js';
88
+ import { adapter as egyptPost } from '../carriers/egypt-post/adapter.js';
88
89
  import { adapter as ekart } from '../carriers/ekart/adapter.js';
89
90
  import { adapter as elta } from '../carriers/elta/adapter.js';
90
91
  import { adapter as emile } from '../carriers/emile/adapter.js';
@@ -358,6 +359,7 @@ export const REGISTRY = {
358
359
  "early-bird": earlyBird,
359
360
  "econt": econt,
360
361
  "ecoscooting": ecoscooting,
362
+ "egypt-post": egyptPost,
361
363
  "ekart": ekart,
362
364
  "elta": elta,
363
365
  "emile": emile,
@@ -691,7 +693,7 @@ export const REGISTRY = {
691
693
  "econt": "econt",
692
694
  "ecoscooting": "ecoscooting",
693
695
  "ecourier": "universal",
694
- "egypt-post": "universal",
696
+ "egypt-post": "egypt-post",
695
697
  "ekart": "ekart",
696
698
  "elta": "elta",
697
699
  "emile": "emile",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.190.0"
5
+ "version": "0.191.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.190.0-main.1346",
3
+ "version": "0.191.0",
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",