universal-parcel-scraper 0.85.0-main.959 → 0.87.0-main.963
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 +2 -2
- package/data/catalog.json +21 -2
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +33 -15
- package/dist/carriers/gls-es/adapter.d.ts +22 -0
- package/dist/carriers/gls-es/adapter.js +81 -0
- package/dist/carriers/gls-es/carrier.json +84 -8
- package/dist/carriers/gls-es/parser.d.ts +20 -0
- package/dist/carriers/gls-es/parser.js +166 -0
- package/dist/carriers/gls-es/status.d.ts +16 -0
- package/dist/carriers/gls-es/status.js +67 -0
- package/dist/carriers/gls-es/statuses.json +172 -1
- package/dist/carriers/royal-mail/adapter.d.ts +2 -0
- package/dist/carriers/royal-mail/adapter.js +4 -2
- package/dist/carriers/royal-mail/browser.d.ts +1 -1
- package/dist/carriers/royal-mail/browser.js +11 -4
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/core/transport/localBrowser.d.ts +2 -0
- package/dist/core/transport/localBrowser.js +15 -1
- package/dist/data/catalog.json +21 -2
- package/dist/generated/catalog.d.ts +16 -2
- package/dist/generated/catalog.js +21 -2
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **143 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>208 catalog entries · 66 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 3,
|
|
158
|
+
The carrier folders record 3,978 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
|
@@ -6376,10 +6376,23 @@
|
|
|
6376
6376
|
"timezone": "Europe/Madrid",
|
|
6377
6377
|
"tracking": {
|
|
6378
6378
|
"mode": "automatic",
|
|
6379
|
-
"adapter": "
|
|
6379
|
+
"adapter": "gls-es",
|
|
6380
|
+
"requirements": [
|
|
6381
|
+
{
|
|
6382
|
+
"field": "postcode",
|
|
6383
|
+
"validator": "internationalPostcode",
|
|
6384
|
+
"label": "Delivery postcode",
|
|
6385
|
+
"type": "text",
|
|
6386
|
+
"help": "GLS Spain shows a shipment only with its delivery postcode.",
|
|
6387
|
+
"pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
|
|
6388
|
+
"maxLength": 12,
|
|
6389
|
+
"inputMode": "text",
|
|
6390
|
+
"autoComplete": "postal-code"
|
|
6391
|
+
}
|
|
6392
|
+
]
|
|
6380
6393
|
},
|
|
6381
6394
|
"canaryUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6382
|
-
"trackingUrlTemplate": "https://
|
|
6395
|
+
"trackingUrlTemplate": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6383
6396
|
"trackingSearchUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6384
6397
|
"linkRules": [
|
|
6385
6398
|
{
|
|
@@ -6391,6 +6404,12 @@
|
|
|
6391
6404
|
"codigo"
|
|
6392
6405
|
]
|
|
6393
6406
|
},
|
|
6407
|
+
{
|
|
6408
|
+
"domains": [
|
|
6409
|
+
"mygls.gls-spain.es"
|
|
6410
|
+
],
|
|
6411
|
+
"path": "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$"
|
|
6412
|
+
},
|
|
6394
6413
|
{
|
|
6395
6414
|
"domains": [
|
|
6396
6415
|
"gls-group.com",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -5116,24 +5116,42 @@ var be = {
|
|
|
5116
5116
|
timezone: "Europe/Madrid",
|
|
5117
5117
|
tracking: {
|
|
5118
5118
|
mode: "automatic",
|
|
5119
|
-
adapter: "
|
|
5119
|
+
adapter: "gls-es",
|
|
5120
|
+
requirements: [{
|
|
5121
|
+
field: "postcode",
|
|
5122
|
+
validator: "internationalPostcode",
|
|
5123
|
+
label: "Delivery postcode",
|
|
5124
|
+
type: "text",
|
|
5125
|
+
help: "GLS Spain shows a shipment only with its delivery postcode.",
|
|
5126
|
+
pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
|
|
5127
|
+
maxLength: 12,
|
|
5128
|
+
inputMode: "text",
|
|
5129
|
+
autoComplete: "postal-code"
|
|
5130
|
+
}]
|
|
5120
5131
|
},
|
|
5121
5132
|
canaryUrl: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
5122
|
-
trackingUrlTemplate: "https://
|
|
5133
|
+
trackingUrlTemplate: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
5123
5134
|
trackingSearchUrl: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
5124
|
-
linkRules: [
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
"
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5135
|
+
linkRules: [
|
|
5136
|
+
{
|
|
5137
|
+
domains: ["mygls.gls-spain.es"],
|
|
5138
|
+
pathPattern: "^/e/?$",
|
|
5139
|
+
params: ["codigo"]
|
|
5140
|
+
},
|
|
5141
|
+
{
|
|
5142
|
+
domains: ["mygls.gls-spain.es"],
|
|
5143
|
+
path: "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$"
|
|
5144
|
+
},
|
|
5145
|
+
{
|
|
5146
|
+
domains: ["gls-group.com", "gls-group.eu"],
|
|
5147
|
+
pathPattern: "^/ES/",
|
|
5148
|
+
params: [
|
|
5149
|
+
"match",
|
|
5150
|
+
"parcelNumber",
|
|
5151
|
+
"matchParcelNumber"
|
|
5152
|
+
]
|
|
5153
|
+
}
|
|
5154
|
+
],
|
|
5137
5155
|
detectionRules: []
|
|
5138
5156
|
},
|
|
5139
5157
|
"gls-it": {
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
export declare const GLS_SPAIN_ENDPOINT = "https://api.consignee.gls-spain.es/api/v5/expeditions/find";
|
|
5
|
+
export declare const GLS_SPAIN_SIGNING_SECRET = "gls";
|
|
6
|
+
/** The page's X-Signature: hex HMAC-SHA256 of the method, the path and the X-Timestamp, one per line. */
|
|
7
|
+
export declare function glsSpainSignature(timestamp: string, secret: string): string;
|
|
8
|
+
/** The lookup's body: the number and the postcode, as the page sends them. */
|
|
9
|
+
export declare function glsSpainLookupBody(number: string, postcode: string): string;
|
|
10
|
+
export declare class GLSSpainTracker {
|
|
11
|
+
private readonly options;
|
|
12
|
+
/** `secret` undefined uses the page's shared secret; null turns the lookup off. */
|
|
13
|
+
constructor(options?: {
|
|
14
|
+
secret?: string | null;
|
|
15
|
+
fetcher?: typeof fetch;
|
|
16
|
+
recorder?: StepRecorder;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
});
|
|
20
|
+
fetch(raw: string, rawPostcode: string | null | undefined, context?: TrackingContext): Promise<CarrierResult>;
|
|
21
|
+
}
|
|
22
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { ChallengeError, IndeterminateError, SchemaError } 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, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { normalizeGLSSpainNumber, normalizeGLSSpainPostcode, parseGLSSpainReply, PROVIDER } from './parser.js';
|
|
8
|
+
// The service mygls.gls-spain.es asks for a shipment by number and delivery postcode.
|
|
9
|
+
export const GLS_SPAIN_ENDPOINT = 'https://api.consignee.gls-spain.es/api/v5/expeditions/find';
|
|
10
|
+
// The path the signature covers, without the API's prefix.
|
|
11
|
+
const SIGNED_PATH = '/expeditions/find';
|
|
12
|
+
// Shared request-signing secret the tracking page receives from Remote Config, distributed with the maintainer's approval.
|
|
13
|
+
export const GLS_SPAIN_SIGNING_SECRET = 'gls';
|
|
14
|
+
// A shipment reply is a few kilobytes.
|
|
15
|
+
const MAX_BYTES = 512 * 1024;
|
|
16
|
+
// A bot check or a block page instead of the service's JSON.
|
|
17
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>[^<]*(?:just a moment|attention required|access denied)/i;
|
|
18
|
+
/** The page's X-Signature: hex HMAC-SHA256 of the method, the path and the X-Timestamp, one per line. */
|
|
19
|
+
export function glsSpainSignature(timestamp, secret) {
|
|
20
|
+
return createHmac('sha256', secret).update(`POST\n${SIGNED_PATH}\n${timestamp}`, 'utf8').digest('hex');
|
|
21
|
+
}
|
|
22
|
+
/** The lookup's body: the number and the postcode, as the page sends them. */
|
|
23
|
+
export function glsSpainLookupBody(number, postcode) {
|
|
24
|
+
return JSON.stringify({ find: { reference: number, destination: { address: { postalCode: postcode } } } });
|
|
25
|
+
}
|
|
26
|
+
export class GLSSpainTracker {
|
|
27
|
+
options;
|
|
28
|
+
/** `secret` undefined uses the page's shared secret; null turns the lookup off. */
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
}
|
|
32
|
+
async fetch(raw, rawPostcode, context = {}) {
|
|
33
|
+
const number = normalizeGLSSpainNumber(raw);
|
|
34
|
+
const postcode = normalizeGLSSpainPostcode(rawPostcode);
|
|
35
|
+
if (this.options.secret === null)
|
|
36
|
+
throw new ChallengeError(PROVIDER, 'GLS Spain tracking is turned off by configuration');
|
|
37
|
+
const secret = this.options.secret ?? GLS_SPAIN_SIGNING_SECRET;
|
|
38
|
+
const body = glsSpainLookupBody(number, postcode);
|
|
39
|
+
const read = async (signal, timeoutMs) => {
|
|
40
|
+
// The service refuses a timestamp minutes away from its clock, so each try signs anew.
|
|
41
|
+
const timestamp = (this.options.now?.() ?? new Date()).toISOString();
|
|
42
|
+
const { response, bytes } = await fetchBounded(GLS_SPAIN_ENDPOINT, { method: 'POST', body, signal, headers: {
|
|
43
|
+
Accept: 'application/json', 'Accept-Language': 'en_US', 'Content-Type': 'application/json; charset=utf-8',
|
|
44
|
+
'MyGls-Agent': 'pwa', 'X-Timestamp': timestamp, 'X-Signature': glsSpainSignature(timestamp, secret),
|
|
45
|
+
'User-Agent': userAgentOf(this.options.userAgent),
|
|
46
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: MAX_BYTES,
|
|
47
|
+
allowHttpStatuses: [404], fetcher: this.options.fetcher });
|
|
48
|
+
const head = decodeText(bytes.subarray(0, 4_096));
|
|
49
|
+
if (/^\s*</.test(head)) {
|
|
50
|
+
if (CHALLENGE.test(head))
|
|
51
|
+
throw new ChallengeError(PROVIDER, 'GLS Spain asked for a browser check');
|
|
52
|
+
throw new IndeterminateError(PROVIDER, 'GLS Spain answered with a web page instead of tracking data');
|
|
53
|
+
}
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = parseJsonBytes(bytes, PROVIDER);
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned invalid tracking JSON', { cause });
|
|
60
|
+
}
|
|
61
|
+
return parseGLSSpainReply(response.status, payload, number);
|
|
62
|
+
};
|
|
63
|
+
// A request that fails to reach GLS, or hangs, gets one more try: the
|
|
64
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
65
|
+
return runSteps({ carrier: 'gls-es', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
66
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
67
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
68
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export const adapter = (environment) => {
|
|
73
|
+
const secret = environment.env.GLS_ES_SIGNING_SECRET;
|
|
74
|
+
const tracker = new GLSSpainTracker({ secret: secret === undefined ? undefined : secret.trim() || null,
|
|
75
|
+
fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
76
|
+
// The postcode is part of every lookup, so the adapter cannot recognize a number alone.
|
|
77
|
+
return {
|
|
78
|
+
id: 'gls-es', recordsSteps: true, steps: ['direct', 'retry'],
|
|
79
|
+
track: (input, context) => tracker.fetch(input.number, input.postcode, context),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
@@ -19,18 +19,88 @@
|
|
|
19
19
|
"timezone": "Europe/Madrid",
|
|
20
20
|
"tracking": {
|
|
21
21
|
"mode": "automatic",
|
|
22
|
-
"adapter": "
|
|
23
|
-
"
|
|
22
|
+
"adapter": "gls-es",
|
|
23
|
+
"requirements": [
|
|
24
|
+
{
|
|
25
|
+
"field": "postcode",
|
|
26
|
+
"validator": "internationalPostcode",
|
|
27
|
+
"label": "Delivery postcode",
|
|
28
|
+
"type": "text",
|
|
29
|
+
"help": "GLS Spain shows a shipment only with its delivery postcode.",
|
|
30
|
+
"pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
|
|
31
|
+
"maxLength": 12,
|
|
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
|
+
"delivered_at",
|
|
44
|
+
"eta",
|
|
45
|
+
"pickup_point",
|
|
46
|
+
"weight",
|
|
47
|
+
"provider_code",
|
|
48
|
+
"canonical_tracking_number"
|
|
49
|
+
],
|
|
26
50
|
"portal": {
|
|
27
|
-
"url": "https://
|
|
51
|
+
"url": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
28
52
|
"searchUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
29
53
|
"canaryUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
30
|
-
"shows": [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
"shows": [
|
|
55
|
+
"status",
|
|
56
|
+
"history",
|
|
57
|
+
"eta",
|
|
58
|
+
"delivered_at",
|
|
59
|
+
"pickup_point",
|
|
60
|
+
"pickup_point_address",
|
|
61
|
+
"pickup_deadline",
|
|
62
|
+
"pickup_code",
|
|
63
|
+
"weight",
|
|
64
|
+
"parcel_count",
|
|
65
|
+
"progress_rail",
|
|
66
|
+
"sender_name",
|
|
67
|
+
"customer_reference",
|
|
68
|
+
"origin",
|
|
69
|
+
"destination",
|
|
70
|
+
"office_contact_details",
|
|
71
|
+
"recipient_name",
|
|
72
|
+
"recipient_verification",
|
|
73
|
+
"payment_details"
|
|
74
|
+
],
|
|
75
|
+
"scraped": [
|
|
76
|
+
"status",
|
|
77
|
+
"history",
|
|
78
|
+
"eta",
|
|
79
|
+
"delivered_at",
|
|
80
|
+
"pickup_point",
|
|
81
|
+
"weight",
|
|
82
|
+
"provider_code",
|
|
83
|
+
"offset_timestamps"
|
|
84
|
+
],
|
|
85
|
+
"discarded": [
|
|
86
|
+
"pickup_point_address",
|
|
87
|
+
"pickup_deadline",
|
|
88
|
+
"pickup_code",
|
|
89
|
+
"parcel_count",
|
|
90
|
+
"progress_rail",
|
|
91
|
+
"sender_name",
|
|
92
|
+
"customer_reference",
|
|
93
|
+
"origin",
|
|
94
|
+
"destination",
|
|
95
|
+
"office_contact_details",
|
|
96
|
+
"recipient_name",
|
|
97
|
+
"recipient_verification",
|
|
98
|
+
"payment_details"
|
|
99
|
+
],
|
|
100
|
+
"unavailable": [
|
|
101
|
+
"location",
|
|
102
|
+
"service_name"
|
|
103
|
+
]
|
|
34
104
|
},
|
|
35
105
|
"links": [
|
|
36
106
|
{
|
|
@@ -42,6 +112,12 @@
|
|
|
42
112
|
"codigo"
|
|
43
113
|
]
|
|
44
114
|
},
|
|
115
|
+
{
|
|
116
|
+
"domains": [
|
|
117
|
+
"mygls.gls-spain.es"
|
|
118
|
+
],
|
|
119
|
+
"path": "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$"
|
|
120
|
+
},
|
|
45
121
|
{
|
|
46
122
|
"domains": [
|
|
47
123
|
"gls-group.com",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "GLS Spain";
|
|
3
|
+
/** A number as the tracking service takes it: letters and digits, without spaces or dashes. */
|
|
4
|
+
export declare function normalizeGLSSpainNumber(raw: string): string;
|
|
5
|
+
/** The delivery postcode, which the service asks for with every number. */
|
|
6
|
+
export declare function normalizeGLSSpainPostcode(raw: string | null | undefined): string;
|
|
7
|
+
/**
|
|
8
|
+
* The `found` shipment of `POST /expeditions/find`. The service looked the
|
|
9
|
+
* whole number up with the postcode; the reply names the shipment it
|
|
10
|
+
* resolved, which may hold the number as a parcel barcode or reference it does
|
|
11
|
+
* not echo. Its addresses, the sender, the recipient and their document, the
|
|
12
|
+
* point's collection code and the scans' reasons are never read.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseGLSSpain(payload: unknown, raw: string): CarrierResult;
|
|
15
|
+
/**
|
|
16
|
+
* The service's reply to a lookup. A 404 with its own not-found error is the
|
|
17
|
+
* only missing shipment: no shipment has the number with that postcode. Any
|
|
18
|
+
* other error reply leaves the answer open.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseGLSSpainReply(status: number, payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,166 @@
|
|
|
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, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
7
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
8
|
+
import { isRecord } from '../../core/types.js';
|
|
9
|
+
import { glsSpainStage } from './status.js';
|
|
10
|
+
export const PROVIDER = 'GLS Spain';
|
|
11
|
+
// The service takes a shipment number, a parcel's barcode or the sender's
|
|
12
|
+
// reference, all letters and digits.
|
|
13
|
+
const NUMBER = /^[A-Z0-9]{5,40}$/;
|
|
14
|
+
// The scans that leave the parcel at a GLS point.
|
|
15
|
+
const AT_POINT = new Set(['receivedOnParcel', 'deliveredOnParcel']);
|
|
16
|
+
const MAX_SCANS = 500;
|
|
17
|
+
const MAX_EVENTS = 100;
|
|
18
|
+
/** A number as the tracking service takes it: letters and digits, without spaces or dashes. */
|
|
19
|
+
export function normalizeGLSSpainNumber(raw) {
|
|
20
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
21
|
+
if (!NUMBER.test(number)) {
|
|
22
|
+
throw new InvalidInputError(PROVIDER, 'GLS Spain looks up shipment numbers, parcel barcodes and sender references of letters and digits');
|
|
23
|
+
}
|
|
24
|
+
return number;
|
|
25
|
+
}
|
|
26
|
+
/** The delivery postcode, which the service asks for with every number. */
|
|
27
|
+
export function normalizeGLSSpainPostcode(raw) {
|
|
28
|
+
const postcode = deliveryPostcodeText(raw ?? '');
|
|
29
|
+
if (!postcode)
|
|
30
|
+
throw new InputRequiredError(PROVIDER, 'postcode', 'GLS Spain requires the delivery postcode');
|
|
31
|
+
if (!DELIVERY_POSTCODE.test(postcode))
|
|
32
|
+
throw new InvalidInputError(PROVIDER, 'GLS Spain requires a valid delivery postcode');
|
|
33
|
+
return postcode;
|
|
34
|
+
}
|
|
35
|
+
/** An optional text field; an object, array or other non-scalar is a changed reply. */
|
|
36
|
+
function text(row, key, maxLength = 500) {
|
|
37
|
+
const value = row[key];
|
|
38
|
+
if (value === null || value === undefined)
|
|
39
|
+
return '';
|
|
40
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
41
|
+
throw new SchemaError(PROVIDER, `GLS Spain returned an invalid ${key}`);
|
|
42
|
+
return typeof value === 'number' ? cleanScalar(value, maxLength) : clean(value, maxLength);
|
|
43
|
+
}
|
|
44
|
+
/** One scan: its state code, sentence and instant. Its reason is never read. */
|
|
45
|
+
function scan(row) {
|
|
46
|
+
if (!isRecord(row))
|
|
47
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned an invalid scan');
|
|
48
|
+
const code = text(row, 'code', 64);
|
|
49
|
+
const description = text(row, 'description');
|
|
50
|
+
if (!code || !description)
|
|
51
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a scan without a state or wording');
|
|
52
|
+
let time;
|
|
53
|
+
if (row.at !== null && row.at !== undefined) {
|
|
54
|
+
// The app reads the clock as an instant and shows it in the reader's zone.
|
|
55
|
+
const at = explicitOffsetTime(row.at);
|
|
56
|
+
if (!at)
|
|
57
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a scan without an offset time');
|
|
58
|
+
time = at.iso;
|
|
59
|
+
}
|
|
60
|
+
const stage = glsSpainStage(code, description);
|
|
61
|
+
return { ...(time ? { time } : {}), description, provider_code: code,
|
|
62
|
+
...(stage ? { stage: stage.stage, stage_source: stage.source } : {}) };
|
|
63
|
+
}
|
|
64
|
+
/** The shipment's estimated delivery day in Spain; the service sends a day or an instant. */
|
|
65
|
+
function estimatedDay(state) {
|
|
66
|
+
const raw = text(state, 'estimatedDeliveryDate', 64);
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? DateTime.fromISO(raw, { zone: 'Europe/Madrid' })
|
|
70
|
+
: explicitOffsetTime(raw) ? DateTime.fromISO(raw, { setZone: true }).setZone('Europe/Madrid') : null;
|
|
71
|
+
return day?.isValid ? day.toISODate() : null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The GLS point the parcel waits at or was collected from, by name, when the
|
|
75
|
+
* scan that left it there names the same point. The reply's point is the
|
|
76
|
+
* destination depot otherwise.
|
|
77
|
+
*/
|
|
78
|
+
function pickupPoint(found, events, stage) {
|
|
79
|
+
// The newest scan, or on a delivered parcel the one before its collection.
|
|
80
|
+
const left = stage === 'ready_for_pickup' ? events[0]
|
|
81
|
+
: stage === 'delivered' && events[0]?.stage === 'delivered' ? events[1] : undefined;
|
|
82
|
+
if (!left || !AT_POINT.has(left.provider_code ?? ''))
|
|
83
|
+
return '';
|
|
84
|
+
const name = isRecord(found.parcelShop) ? text(found.parcelShop, 'name', 120) : '';
|
|
85
|
+
return name && left.description.toLocaleLowerCase('es').includes(name.toLocaleLowerCase('es')) ? name : '';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The `found` shipment of `POST /expeditions/find`. The service looked the
|
|
89
|
+
* whole number up with the postcode; the reply names the shipment it
|
|
90
|
+
* resolved, which may hold the number as a parcel barcode or reference it does
|
|
91
|
+
* not echo. Its addresses, the sender, the recipient and their document, the
|
|
92
|
+
* point's collection code and the scans' reasons are never read.
|
|
93
|
+
*/
|
|
94
|
+
export function parseGLSSpain(payload, raw) {
|
|
95
|
+
const number = normalizeGLSSpainNumber(raw);
|
|
96
|
+
const found = isRecord(payload) && isRecord(payload.found) ? payload.found : null;
|
|
97
|
+
if (!found)
|
|
98
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned an unexpected reply');
|
|
99
|
+
const shipment = text(found, 'expeditionCode', 40);
|
|
100
|
+
if (!/^\d{1,20}$/.test(shipment))
|
|
101
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a shipment without its number');
|
|
102
|
+
if (!isRecord(found.state))
|
|
103
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a shipment without its state');
|
|
104
|
+
const state = found.state;
|
|
105
|
+
const stateCode = text(state, 'code', 64);
|
|
106
|
+
const stateText = text(state, 'description');
|
|
107
|
+
if (!stateCode)
|
|
108
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a shipment without its state');
|
|
109
|
+
const rows = found.tracking ?? [];
|
|
110
|
+
if (!Array.isArray(rows) || rows.length > MAX_SCANS)
|
|
111
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned an invalid scan list');
|
|
112
|
+
// The reply lists the scans newest first.
|
|
113
|
+
const events = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
const event = scan(row);
|
|
117
|
+
const key = JSON.stringify([event.time, event.provider_code, event.description]);
|
|
118
|
+
if (seen.has(key))
|
|
119
|
+
continue;
|
|
120
|
+
seen.add(key);
|
|
121
|
+
events.push(event);
|
|
122
|
+
}
|
|
123
|
+
const newest = events[0];
|
|
124
|
+
// The shipment's state is what the app heads its page with; the newest scan
|
|
125
|
+
// of the same state reads the same, with its sentence.
|
|
126
|
+
const current = newest?.stage && newest.provider_code === stateCode
|
|
127
|
+
? { stage: newest.stage, source: newest.stage_source }
|
|
128
|
+
: glsSpainStage(stateCode, stateText);
|
|
129
|
+
const stage = current?.stage;
|
|
130
|
+
const delivered = stage === 'delivered' ? events.find((event) => event.stage === 'delivered')?.time : undefined;
|
|
131
|
+
const point = pickupPoint(found, events, stage);
|
|
132
|
+
const weight = isRecord(found.attributes) ? found.attributes.weight : undefined;
|
|
133
|
+
const lastText = newest?.description ?? stateText;
|
|
134
|
+
if (!lastText)
|
|
135
|
+
throw new SchemaError(PROVIDER, 'GLS Spain returned a shipment without wording');
|
|
136
|
+
return {
|
|
137
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
138
|
+
...(current ? { current_stage: current.stage, current_stage_source: current.source } : {}),
|
|
139
|
+
last_status_text: lastText,
|
|
140
|
+
last_update: newest?.time ?? null,
|
|
141
|
+
expected_delivery: stage && !['delivered', 'returned'].includes(stage) ? estimatedDay(state) : null,
|
|
142
|
+
...(delivered ? { delivered_at: delivered } : {}),
|
|
143
|
+
...(point ? { pickup_point: point } : {}),
|
|
144
|
+
...(typeof weight === 'number' && Number.isFinite(weight) && weight > 0 && weight <= 10_000 ? { weight_kg: weight } : {}),
|
|
145
|
+
...(shipment !== number ? { canonical_tracking_number: shipment } : {}),
|
|
146
|
+
...(events.length ? {} : { provider_code: stateCode, summary_only: true }),
|
|
147
|
+
events: events.slice(0, MAX_EVENTS),
|
|
148
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The service's reply to a lookup. A 404 with its own not-found error is the
|
|
153
|
+
* only missing shipment: no shipment has the number with that postcode. Any
|
|
154
|
+
* other error reply leaves the answer open.
|
|
155
|
+
*/
|
|
156
|
+
export function parseGLSSpainReply(status, payload, raw) {
|
|
157
|
+
const error = isRecord(payload) && isRecord(payload.error) ? payload.error : null;
|
|
158
|
+
if (status === 404) {
|
|
159
|
+
if (error?.name === 'NotFoundError' && error.details === 'notFound' && !('found' in payload))
|
|
160
|
+
throw new NotFoundError(PROVIDER);
|
|
161
|
+
throw new IndeterminateError(PROVIDER, 'GLS Spain answered with an error instead of the shipment');
|
|
162
|
+
}
|
|
163
|
+
if (error)
|
|
164
|
+
throw new IndeterminateError(PROVIDER, 'GLS Spain answered with an error instead of the shipment');
|
|
165
|
+
return parseGLSSpain(payload, raw);
|
|
166
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tracking service's state codes, the same for the shipment's current
|
|
5
|
+
* state and each scan. `stored` is left out: the app gives it no milestone of
|
|
6
|
+
* its own, so its sentence decides.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GLS_SPAIN_STATES: Readonly<Record<string, Stage>>;
|
|
9
|
+
/**
|
|
10
|
+
* Incidence sentences in the service's English, mapped whole. An incidence
|
|
11
|
+
* covers a missed delivery as much as a refusal; others stay exceptions.
|
|
12
|
+
*/
|
|
13
|
+
export declare const GLS_SPAIN_INCIDENCES: Readonly<Record<string, Stage>>;
|
|
14
|
+
/** The stage of a state code and its sentence: the map first, then the shared wording rules. */
|
|
15
|
+
export declare function glsSpainStage(code: string, sentence: string): ClassifiedWording | null;
|
|
16
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tracking service's state codes, the same for the shipment's current
|
|
5
|
+
* state and each scan. `stored` is left out: the app gives it no milestone of
|
|
6
|
+
* its own, so its sentence decides.
|
|
7
|
+
*/
|
|
8
|
+
export const GLS_SPAIN_STATES = {
|
|
9
|
+
dataReceived: 'registered',
|
|
10
|
+
// Collected from the sender.
|
|
11
|
+
picked: 'accepted',
|
|
12
|
+
onTransit: 'in_transit',
|
|
13
|
+
onDelivery: 'out_for_delivery',
|
|
14
|
+
delivered: 'delivered',
|
|
15
|
+
// Part of a shipment of several parcels is still on its way.
|
|
16
|
+
partiallyDelivered: 'in_transit',
|
|
17
|
+
returned: 'returned',
|
|
18
|
+
// On its way back to the sender.
|
|
19
|
+
onReturn: 'returned',
|
|
20
|
+
destroyed: 'exception',
|
|
21
|
+
retained: 'exception',
|
|
22
|
+
broken: 'exception',
|
|
23
|
+
// Sent on to another address or depot.
|
|
24
|
+
rechannelized: 'in_transit',
|
|
25
|
+
// Left at a GLS point, then booked in by it with the collection code and deadline the app shows.
|
|
26
|
+
deliveredOnParcel: 'ready_for_pickup',
|
|
27
|
+
receivedOnParcel: 'ready_for_pickup',
|
|
28
|
+
canceled: 'exception',
|
|
29
|
+
incidence: 'exception',
|
|
30
|
+
// A collection from the recipient, such as a return, waiting for the driver.
|
|
31
|
+
pickupPending: 'registered',
|
|
32
|
+
pickupIncidence: 'exception',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Incidence sentences in the service's English, mapped whole. An incidence
|
|
36
|
+
* covers a missed delivery as much as a refusal; others stay exceptions.
|
|
37
|
+
*/
|
|
38
|
+
export const GLS_SPAIN_INCIDENCES = {
|
|
39
|
+
Absent: 'failed_attempt',
|
|
40
|
+
'Refused by the consignee': 'exception',
|
|
41
|
+
};
|
|
42
|
+
// The incidence that sends the parcel on to a GLS point, followed by the point's name.
|
|
43
|
+
const REDIRECTED = /^shipment redirected to gls point /;
|
|
44
|
+
const INCIDENCES = new Map(Object.entries(GLS_SPAIN_INCIDENCES).map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
|
|
45
|
+
/** The stage a state code gives, with the sentence an incidence needs. */
|
|
46
|
+
function mappedStage(code, wording) {
|
|
47
|
+
if (!code || !Object.hasOwn(GLS_SPAIN_STATES, code))
|
|
48
|
+
return undefined;
|
|
49
|
+
if (code !== 'incidence')
|
|
50
|
+
return GLS_SPAIN_STATES[code];
|
|
51
|
+
return INCIDENCES.get(wording) ?? (REDIRECTED.test(wording) ? 'in_transit' : GLS_SPAIN_STATES[code]);
|
|
52
|
+
}
|
|
53
|
+
/** The stage of a state code and its sentence: the map first, then the shared wording rules. */
|
|
54
|
+
export function glsSpainStage(code, sentence) {
|
|
55
|
+
const mapped = mappedStage(code, normalizeStatusWording(sentence));
|
|
56
|
+
if (mapped)
|
|
57
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
58
|
+
const worded = classifyWording(sentence, 'in_transit');
|
|
59
|
+
return worded.source === 'none' ? null : worded;
|
|
60
|
+
}
|
|
61
|
+
export const statusMap = {
|
|
62
|
+
stage: mappedStage,
|
|
63
|
+
gaps: [{
|
|
64
|
+
code: 'stored',
|
|
65
|
+
note: 'Held at a GLS depot. The app gives the state no milestone of its own, so the scan\'s sentence decides.',
|
|
66
|
+
}],
|
|
67
|
+
};
|
|
@@ -1,4 +1,175 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "gls-es",
|
|
3
|
-
"entries": [
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"code": "dataReceived",
|
|
6
|
+
"wording": "Label created. The shipment has not yet been delivered to GLS by the sender",
|
|
7
|
+
"language": "en",
|
|
8
|
+
"stage": "registered",
|
|
9
|
+
"confirmedBy": "live",
|
|
10
|
+
"firstSeen": "2026-10-11",
|
|
11
|
+
"note": "The sender created the label and has not handed the parcel over. The first scan."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"code": "picked",
|
|
15
|
+
"wording": "Parcel picked up by GLS. Pending to arrive to final destination",
|
|
16
|
+
"language": "en",
|
|
17
|
+
"stage": "accepted",
|
|
18
|
+
"confirmedBy": "live",
|
|
19
|
+
"firstSeen": "2026-10-11",
|
|
20
|
+
"note": "Collected from the sender."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"code": "onTransit",
|
|
24
|
+
"wording": "At delivery agency",
|
|
25
|
+
"language": "en",
|
|
26
|
+
"stage": "in_transit",
|
|
27
|
+
"confirmedBy": "live",
|
|
28
|
+
"firstSeen": "2026-10-11",
|
|
29
|
+
"note": "At the delivering depot; it can repeat."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"code": "onDelivery",
|
|
33
|
+
"wording": "Out for delivery",
|
|
34
|
+
"language": "en",
|
|
35
|
+
"stage": "out_for_delivery",
|
|
36
|
+
"confirmedBy": "live",
|
|
37
|
+
"firstSeen": "2026-10-11",
|
|
38
|
+
"note": "On the delivery round."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"code": "delivered",
|
|
42
|
+
"wording": "Delivered",
|
|
43
|
+
"language": "en",
|
|
44
|
+
"stage": "delivered",
|
|
45
|
+
"confirmedBy": "live",
|
|
46
|
+
"firstSeen": "2026-10-11",
|
|
47
|
+
"note": "Delivered, at the door or collected from a GLS point."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"code": "incidence",
|
|
51
|
+
"wording": "Absent",
|
|
52
|
+
"language": "en",
|
|
53
|
+
"stage": "failed_attempt",
|
|
54
|
+
"confirmedBy": "live",
|
|
55
|
+
"firstSeen": "2026-10-11",
|
|
56
|
+
"note": "A missed delivery: nobody was in."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"code": "incidence",
|
|
60
|
+
"wording": "Refused by the consignee",
|
|
61
|
+
"language": "en",
|
|
62
|
+
"stage": "exception",
|
|
63
|
+
"confirmedBy": "live",
|
|
64
|
+
"firstSeen": "2026-10-11",
|
|
65
|
+
"note": "A return to the sender follows."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"code": "incidence",
|
|
69
|
+
"wording": "Shipment redirected to GLS Point <point>",
|
|
70
|
+
"language": "en",
|
|
71
|
+
"stage": "in_transit",
|
|
72
|
+
"confirmedBy": "live",
|
|
73
|
+
"firstSeen": "2026-10-11",
|
|
74
|
+
"note": "After a missed delivery, sent on to a GLS point; the point's name follows."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"code": "receivedOnParcel",
|
|
78
|
+
"wording": "Received in <point>",
|
|
79
|
+
"language": "en",
|
|
80
|
+
"stage": "ready_for_pickup",
|
|
81
|
+
"confirmedBy": "live",
|
|
82
|
+
"firstSeen": "2026-10-11",
|
|
83
|
+
"note": "Booked in by the GLS point, which keeps it until a deadline; the point's name follows."
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"code": "onReturn",
|
|
87
|
+
"wording": "Return delivery process",
|
|
88
|
+
"language": "en",
|
|
89
|
+
"stage": "returned",
|
|
90
|
+
"confirmedBy": "live",
|
|
91
|
+
"firstSeen": "2026-10-11",
|
|
92
|
+
"note": "On its way back to the sender after a refusal."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"code": "returned",
|
|
96
|
+
"wording": "Returned to the shipper",
|
|
97
|
+
"language": "en",
|
|
98
|
+
"stage": "returned",
|
|
99
|
+
"confirmedBy": "live",
|
|
100
|
+
"firstSeen": "2026-10-11",
|
|
101
|
+
"note": "Back with the sender."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"code": "canceled",
|
|
105
|
+
"wording": "Cancelled",
|
|
106
|
+
"language": "en",
|
|
107
|
+
"stage": "exception",
|
|
108
|
+
"confirmedBy": "live",
|
|
109
|
+
"firstSeen": "2026-10-11",
|
|
110
|
+
"note": "The sender cancelled the label before handing the parcel over."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"code": "partiallyDelivered",
|
|
114
|
+
"stage": "in_transit",
|
|
115
|
+
"confirmedBy": "bundle",
|
|
116
|
+
"firstSeen": "2026-10-11",
|
|
117
|
+
"note": "Part of a shipment of several parcels is still on its way. A state code of the tracking page; no reply showed it."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"code": "stored",
|
|
121
|
+
"confirmedBy": "bundle",
|
|
122
|
+
"firstSeen": "2026-10-11",
|
|
123
|
+
"note": "Held at a GLS depot. The page gives the state no milestone of its own, so the scan's sentence decides. No reply showed it."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"code": "destroyed",
|
|
127
|
+
"stage": "exception",
|
|
128
|
+
"confirmedBy": "bundle",
|
|
129
|
+
"firstSeen": "2026-10-11",
|
|
130
|
+
"note": "A state code of the tracking page; no reply showed it."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"code": "retained",
|
|
134
|
+
"stage": "exception",
|
|
135
|
+
"confirmedBy": "bundle",
|
|
136
|
+
"firstSeen": "2026-10-11",
|
|
137
|
+
"note": "Held, the page shows a notice instead of a date. No reply showed it."
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"code": "broken",
|
|
141
|
+
"stage": "exception",
|
|
142
|
+
"confirmedBy": "bundle",
|
|
143
|
+
"firstSeen": "2026-10-11",
|
|
144
|
+
"note": "Damaged. A state code of the tracking page; no reply showed it."
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"code": "rechannelized",
|
|
148
|
+
"stage": "in_transit",
|
|
149
|
+
"confirmedBy": "bundle",
|
|
150
|
+
"firstSeen": "2026-10-11",
|
|
151
|
+
"note": "Sent on to another address or depot. A state code of the tracking page; no reply showed it."
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"code": "deliveredOnParcel",
|
|
155
|
+
"stage": "ready_for_pickup",
|
|
156
|
+
"confirmedBy": "bundle",
|
|
157
|
+
"firstSeen": "2026-10-11",
|
|
158
|
+
"note": "Left at a GLS point; the page shows it with the point's icon and a notice, not as a completed delivery. No reply showed it."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"code": "pickupPending",
|
|
162
|
+
"stage": "registered",
|
|
163
|
+
"confirmedBy": "bundle",
|
|
164
|
+
"firstSeen": "2026-10-11",
|
|
165
|
+
"note": "A collection from the recipient, such as a return, waiting for the driver. No reply showed it."
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"code": "pickupIncidence",
|
|
169
|
+
"stage": "exception",
|
|
170
|
+
"confirmedBy": "bundle",
|
|
171
|
+
"firstSeen": "2026-10-11",
|
|
172
|
+
"note": "A failed collection from the recipient. No reply showed it."
|
|
173
|
+
}
|
|
174
|
+
]
|
|
4
175
|
}
|
|
@@ -6,6 +6,8 @@ export { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTra
|
|
|
6
6
|
export interface RoyalMailTrackerOptions {
|
|
7
7
|
/** Fresh local Chromium; preferred when supplied. */
|
|
8
8
|
executablePath?: string | null;
|
|
9
|
+
/** An `http://` or `socks5://` proxy for the local Chromium's traffic. */
|
|
10
|
+
browserProxy?: string | null;
|
|
9
11
|
timeoutMs?: number;
|
|
10
12
|
/** Legacy configuration seam; `trawl` is preferred. */
|
|
11
13
|
trawlUrl?: string;
|
|
@@ -19,6 +19,7 @@ export class RoyalMailTracker {
|
|
|
19
19
|
#recorder;
|
|
20
20
|
#fullHistory;
|
|
21
21
|
#executablePath;
|
|
22
|
+
#browserProxy;
|
|
22
23
|
constructor(options = {}) {
|
|
23
24
|
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
|
24
25
|
throw new TypeError('Royal Mail timeout must be positive');
|
|
@@ -30,6 +31,7 @@ export class RoyalMailTracker {
|
|
|
30
31
|
this.#recorder = options.recorder ?? NOOP_RECORDER;
|
|
31
32
|
this.#fullHistory = options.fullHistory ?? false;
|
|
32
33
|
this.#executablePath = options.executablePath ?? null;
|
|
34
|
+
this.#browserProxy = options.browserProxy?.trim() || null;
|
|
33
35
|
}
|
|
34
36
|
/** The injected browser service, or one built from the configured URL. */
|
|
35
37
|
#browserService() {
|
|
@@ -41,7 +43,7 @@ export class RoyalMailTracker {
|
|
|
41
43
|
const number = normalizeRoyalMailNumber(trackingNumber);
|
|
42
44
|
if (this.#executablePath) {
|
|
43
45
|
return runSteps({ carrier: 'royal-mail', budgetMs: context.budgetMs ?? this.timeoutMs,
|
|
44
|
-
signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath, signal, Math.min(60_000, remainingMs), this.#fullHistory) }]);
|
|
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) }]);
|
|
45
47
|
}
|
|
46
48
|
// The legacy service remains available through explicit tracker instances.
|
|
47
49
|
const trawl = this.#browserService();
|
|
@@ -169,7 +171,7 @@ export class RoyalMailTracker {
|
|
|
169
171
|
}
|
|
170
172
|
export const adapter = environment => {
|
|
171
173
|
const tracker = new RoyalMailTracker({ executablePath: environment.browserExecutablePath,
|
|
172
|
-
recorder: environment.recorder, fullHistory: true });
|
|
174
|
+
browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
|
|
173
175
|
return {
|
|
174
176
|
id: 'royal-mail', recordsSteps: true, steps: ['browser'],
|
|
175
177
|
track: (input, context) => {
|
|
@@ -8,4 +8,4 @@ export declare function royalMailDestination(summary: unknown): {
|
|
|
8
8
|
};
|
|
9
9
|
/** Observe the application's request; its issued session stays in the browser. */
|
|
10
10
|
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): Promise<CarrierResult>;
|
|
11
|
+
export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean, proxy?: string | null): Promise<CarrierResult>;
|
|
@@ -24,8 +24,15 @@ export async function captureRoyalMailReply(page, url, number, phase, signal, ti
|
|
|
24
24
|
void reply.catch(() => { });
|
|
25
25
|
const matches = (request) => active && request.url() === url && request.method() === 'GET'
|
|
26
26
|
&& ['xhr', 'fetch'].includes(request.resourceType()) && request.frame() === page.mainFrame();
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// The edge refuses a session it scores as automated by resetting the request's
|
|
28
|
+
// stream, while the page and its scripts load normally.
|
|
29
|
+
const failed = (request) => {
|
|
30
|
+
if (!matches(request))
|
|
31
|
+
return;
|
|
32
|
+
reject(request.failure()?.errorText === 'net::ERR_HTTP2_PROTOCOL_ERROR'
|
|
33
|
+
? new ChallengeError(PROVIDER, "Royal Mail's edge refused the tracking request")
|
|
34
|
+
: new TransportError(PROVIDER, 'Royal Mail tracking request failed'));
|
|
35
|
+
};
|
|
29
36
|
const aborted = () => reject(signal.aborted ? signal.reason : new TransportError(PROVIDER, 'Royal Mail tracking response timed out'));
|
|
30
37
|
const received = (response) => {
|
|
31
38
|
if (!matches(response.request()))
|
|
@@ -112,8 +119,8 @@ export async function captureRoyalMailReply(page, url, number, phase, signal, ti
|
|
|
112
119
|
deadline.removeEventListener('abort', aborted);
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
|
-
export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory) {
|
|
116
|
-
return withLocalBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs,
|
|
122
|
+
export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory, proxy = null) {
|
|
123
|
+
return withLocalBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs, proxy,
|
|
117
124
|
args: ['--disable-blink-features=AutomationControlled'] }, async ({ browser, signal: session, remainingMs }) => {
|
|
118
125
|
try {
|
|
119
126
|
const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
|
|
@@ -31,6 +31,7 @@ import { statusMap as emile } from '../../carriers/emile/status.js';
|
|
|
31
31
|
import { statusMap as fanCourier } from '../../carriers/fan-courier/status.js';
|
|
32
32
|
import { statusMap as foxpost } from '../../carriers/foxpost/status.js';
|
|
33
33
|
import { statusMap as geniki } from '../../carriers/geniki-taxydromiki/status.js';
|
|
34
|
+
import { statusMap as glsEs } from '../../carriers/gls-es/status.js';
|
|
34
35
|
import { statusMap as glsIt } from '../../carriers/gls-it/status.js';
|
|
35
36
|
import { statusMap as gofoFr } from '../../carriers/gofo-fr/status.js';
|
|
36
37
|
import { statusMap as gofoIt } from '../../carriers/gofo-it/status.js';
|
|
@@ -66,7 +67,7 @@ import { CARRIER_DEFINITIONS } from './definitions.js';
|
|
|
66
67
|
export const STATUS_MAPS = {
|
|
67
68
|
'acs-courier': acsCourier, aliexpress, 'an-post': anPost, 'aras-kargo': arasKargo, 'box-now': boxNow, cacesa, 'china-post': chinaPost, chronopost,
|
|
68
69
|
'correos-spain': correosSpain, 'dhl-express': dhlExpress, dpd, 'dpd-de': dpdDe, 'dpd-fr': dpdFr, 'dpd-ie': dpdIe, 'dpd-pl': dpdPl, econt, elta,
|
|
69
|
-
emile, 'fan-courier': fanCourier, foxpost, 'geniki-taxydromiki': geniki, 'gls-it': glsIt, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, helthjem,
|
|
70
|
+
emile, 'fan-courier': fanCourier, foxpost, 'geniki-taxydromiki': geniki, 'gls-es': glsEs, 'gls-it': glsIt, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, helthjem,
|
|
70
71
|
'j-and-t': jAndT, 'j-and-t-cargo': jntCargo, 'la-poste': laPoste, matkahuolto, 'old-dominion': oldDominion, omgo, omniva,
|
|
71
72
|
'orlen-paczka': orlenPaczka, postlogistics, ppl, ptt, sagawa, sameday, speedx, speedy, sto, 'swiss-post': swissPost, 'thailand-post': thailandPost,
|
|
72
73
|
tnt, ups, venipak, 'vinted-go': vintedGo, yunexpress, 'yurtici-kargo': yurticiKargo,
|
|
@@ -13,6 +13,8 @@ export interface LocalBrowserOptions {
|
|
|
13
13
|
timeoutMs: number;
|
|
14
14
|
signal?: AbortSignal;
|
|
15
15
|
args?: readonly string[];
|
|
16
|
+
/** An `http://` or `socks5://` host and port that carries all of the browser's traffic. */
|
|
17
|
+
proxy?: string | null;
|
|
16
18
|
}
|
|
17
19
|
export interface LocalBrowserSession {
|
|
18
20
|
browser: Browser;
|
|
@@ -3,11 +3,25 @@ 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;
|
|
5
5
|
let tail = Promise.resolve();
|
|
6
|
+
/** The configured proxy as Chromium's server setting; the message never repeats the value, which may hold credentials. */
|
|
7
|
+
function proxyServer(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value.trim());
|
|
11
|
+
}
|
|
12
|
+
catch { /* Reported below. */ }
|
|
13
|
+
if (!url || !['http:', 'socks5:'].includes(url.protocol) || !url.hostname || url.username || url.password
|
|
14
|
+
|| !['', '/'].includes(url.pathname) || url.search || url.hash) {
|
|
15
|
+
throw new TypeError('Browser proxy must be an http:// or socks5:// host and port');
|
|
16
|
+
}
|
|
17
|
+
return `${url.protocol}//${url.host}`;
|
|
18
|
+
}
|
|
6
19
|
export async function withLocalBrowser(options, run) {
|
|
7
20
|
const { provider, timeoutMs } = options;
|
|
8
21
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
9
22
|
throw new TypeError('Browser tracking timeout must be between 1 and 60000 ms');
|
|
10
23
|
}
|
|
24
|
+
const proxy = options.proxy ? proxyServer(options.proxy) : null;
|
|
11
25
|
options.signal?.throwIfAborted();
|
|
12
26
|
const deadline = performance.now() + timeoutMs;
|
|
13
27
|
const expiry = AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs)));
|
|
@@ -56,7 +70,7 @@ export async function withLocalBrowser(options, run) {
|
|
|
56
70
|
throw stopped();
|
|
57
71
|
// Do not pass the application environment (database or API secrets) to Chromium.
|
|
58
72
|
launching = chromium.launch({ executablePath: options.executablePath, headless: true, args: [...(options.args ?? [])],
|
|
59
|
-
timeout: Math.min(remainingMs(), 10_000),
|
|
73
|
+
timeout: Math.min(remainingMs(), 10_000), ...(proxy ? { proxy: { server: proxy } } : {}),
|
|
60
74
|
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
|
|
61
75
|
browser = await unlessStopped(launching);
|
|
62
76
|
return await unlessStopped(run({ browser, signal, remainingMs }));
|
package/dist/data/catalog.json
CHANGED
|
@@ -6376,10 +6376,23 @@
|
|
|
6376
6376
|
"timezone": "Europe/Madrid",
|
|
6377
6377
|
"tracking": {
|
|
6378
6378
|
"mode": "automatic",
|
|
6379
|
-
"adapter": "
|
|
6379
|
+
"adapter": "gls-es",
|
|
6380
|
+
"requirements": [
|
|
6381
|
+
{
|
|
6382
|
+
"field": "postcode",
|
|
6383
|
+
"validator": "internationalPostcode",
|
|
6384
|
+
"label": "Delivery postcode",
|
|
6385
|
+
"type": "text",
|
|
6386
|
+
"help": "GLS Spain shows a shipment only with its delivery postcode.",
|
|
6387
|
+
"pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
|
|
6388
|
+
"maxLength": 12,
|
|
6389
|
+
"inputMode": "text",
|
|
6390
|
+
"autoComplete": "postal-code"
|
|
6391
|
+
}
|
|
6392
|
+
]
|
|
6380
6393
|
},
|
|
6381
6394
|
"canaryUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6382
|
-
"trackingUrlTemplate": "https://
|
|
6395
|
+
"trackingUrlTemplate": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6383
6396
|
"trackingSearchUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6384
6397
|
"linkRules": [
|
|
6385
6398
|
{
|
|
@@ -6391,6 +6404,12 @@
|
|
|
6391
6404
|
"codigo"
|
|
6392
6405
|
]
|
|
6393
6406
|
},
|
|
6407
|
+
{
|
|
6408
|
+
"domains": [
|
|
6409
|
+
"mygls.gls-spain.es"
|
|
6410
|
+
],
|
|
6411
|
+
"path": "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$"
|
|
6412
|
+
},
|
|
6394
6413
|
{
|
|
6395
6414
|
"domains": [
|
|
6396
6415
|
"gls-group.com",
|
|
@@ -4321,15 +4321,29 @@ export declare const CARRIER_CATALOG: {
|
|
|
4321
4321
|
readonly timezone: "Europe/Madrid";
|
|
4322
4322
|
readonly tracking: {
|
|
4323
4323
|
readonly mode: "automatic";
|
|
4324
|
-
readonly adapter: "
|
|
4324
|
+
readonly adapter: "gls-es";
|
|
4325
|
+
readonly requirements: readonly [{
|
|
4326
|
+
readonly field: "postcode";
|
|
4327
|
+
readonly validator: "internationalPostcode";
|
|
4328
|
+
readonly label: "Delivery postcode";
|
|
4329
|
+
readonly type: "text";
|
|
4330
|
+
readonly help: "GLS Spain shows a shipment only with its delivery postcode.";
|
|
4331
|
+
readonly pattern: "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$";
|
|
4332
|
+
readonly maxLength: 12;
|
|
4333
|
+
readonly inputMode: "text";
|
|
4334
|
+
readonly autoComplete: "postal-code";
|
|
4335
|
+
}];
|
|
4325
4336
|
};
|
|
4326
4337
|
readonly canaryUrl: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/";
|
|
4327
|
-
readonly trackingUrlTemplate: "https://
|
|
4338
|
+
readonly trackingUrlTemplate: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/";
|
|
4328
4339
|
readonly trackingSearchUrl: "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/";
|
|
4329
4340
|
readonly linkRules: readonly [{
|
|
4330
4341
|
readonly domains: readonly ["mygls.gls-spain.es"];
|
|
4331
4342
|
readonly pathPattern: "^/e/?$";
|
|
4332
4343
|
readonly params: readonly ["codigo"];
|
|
4344
|
+
}, {
|
|
4345
|
+
readonly domains: readonly ["mygls.gls-spain.es"];
|
|
4346
|
+
readonly path: "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$";
|
|
4333
4347
|
}, {
|
|
4334
4348
|
readonly domains: readonly ["gls-group.com", "gls-group.eu"];
|
|
4335
4349
|
readonly pathPattern: "^/ES/";
|
|
@@ -6377,10 +6377,23 @@ export const CARRIER_CATALOG = {
|
|
|
6377
6377
|
"timezone": "Europe/Madrid",
|
|
6378
6378
|
"tracking": {
|
|
6379
6379
|
"mode": "automatic",
|
|
6380
|
-
"adapter": "
|
|
6380
|
+
"adapter": "gls-es",
|
|
6381
|
+
"requirements": [
|
|
6382
|
+
{
|
|
6383
|
+
"field": "postcode",
|
|
6384
|
+
"validator": "internationalPostcode",
|
|
6385
|
+
"label": "Delivery postcode",
|
|
6386
|
+
"type": "text",
|
|
6387
|
+
"help": "GLS Spain shows a shipment only with its delivery postcode.",
|
|
6388
|
+
"pattern": "^(?=.{3,12}$)(?=.*[0-9])[A-Za-z0-9]+(?:[ \\-][A-Za-z0-9]+)*$",
|
|
6389
|
+
"maxLength": 12,
|
|
6390
|
+
"inputMode": "text",
|
|
6391
|
+
"autoComplete": "postal-code"
|
|
6392
|
+
}
|
|
6393
|
+
]
|
|
6381
6394
|
},
|
|
6382
6395
|
"canaryUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6383
|
-
"trackingUrlTemplate": "https://
|
|
6396
|
+
"trackingUrlTemplate": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6384
6397
|
"trackingSearchUrl": "https://gls-group.com/ES/es/recibir-paquetes/seguimiento-envio/",
|
|
6385
6398
|
"linkRules": [
|
|
6386
6399
|
{
|
|
@@ -6392,6 +6405,12 @@ export const CARRIER_CATALOG = {
|
|
|
6392
6405
|
"codigo"
|
|
6393
6406
|
]
|
|
6394
6407
|
},
|
|
6408
|
+
{
|
|
6409
|
+
"domains": [
|
|
6410
|
+
"mygls.gls-spain.es"
|
|
6411
|
+
],
|
|
6412
|
+
"path": "^/e/([A-Za-z0-9]{5,40})/[^/?#]+(?:/[a-z]{2})?/?$"
|
|
6413
|
+
},
|
|
6395
6414
|
{
|
|
6396
6415
|
"domains": [
|
|
6397
6416
|
"gls-group.com",
|
|
@@ -62,6 +62,7 @@ import { adapter as genikiTaxydromiki } from '../carriers/geniki-taxydromiki/ada
|
|
|
62
62
|
import { adapter as geodis } from '../carriers/geodis/adapter.js';
|
|
63
63
|
import { adapter as glsCh } from '../carriers/gls-ch/adapter.js';
|
|
64
64
|
import { adapter as glsDe } from '../carriers/gls-de/adapter.js';
|
|
65
|
+
import { adapter as glsEs } from '../carriers/gls-es/adapter.js';
|
|
65
66
|
import { adapter as glsFr } from '../carriers/gls-fr/adapter.js';
|
|
66
67
|
import { adapter as glsIt } from '../carriers/gls-it/adapter.js';
|
|
67
68
|
import { adapter as gofo } from '../carriers/gofo/adapter.js';
|
|
@@ -206,6 +207,7 @@ export const REGISTRY = {
|
|
|
206
207
|
"geodis": geodis,
|
|
207
208
|
"gls-ch": glsCh,
|
|
208
209
|
"gls-de": glsDe,
|
|
210
|
+
"gls-es": glsEs,
|
|
209
211
|
"gls-fr": glsFr,
|
|
210
212
|
"gls-it": glsIt,
|
|
211
213
|
"gofo": gofo,
|
|
@@ -375,7 +377,7 @@ export const REGISTRY = {
|
|
|
375
377
|
"geodis": "geodis",
|
|
376
378
|
"gls-ch": "gls-ch",
|
|
377
379
|
"gls-de": "gls-de",
|
|
378
|
-
"gls-es": "
|
|
380
|
+
"gls-es": "gls-es",
|
|
379
381
|
"gls-fr": "gls-fr",
|
|
380
382
|
"gls-it": "gls-it",
|
|
381
383
|
"go-express": "universal",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0-main.963",
|
|
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",
|