universal-parcel-scraper 0.55.0-main.708 → 0.56.0-main.723
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 +1 -1
- package/data/catalog.json +42 -1
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +49 -2
- package/dist/browser/scraper.js +1048 -1005
- package/dist/carriers/chronopost/adapter.js +23 -16
- package/dist/carriers/dpd/adapter.d.ts +2 -2
- package/dist/carriers/dpd/adapter.js +29 -20
- package/dist/carriers/dpd-de/adapter.js +2 -3
- package/dist/carriers/dpd-de/app.d.ts +5 -2
- package/dist/carriers/dpd-de/app.js +10 -3
- package/dist/carriers/dpd-de/carrier.json +1 -1
- package/dist/carriers/dpd-de/numbers.json +26 -0
- package/dist/carriers/dpd-de/service.d.ts +42 -7
- package/dist/carriers/dpd-de/service.js +54 -26
- package/dist/carriers/dpd-fr/adapter.js +18 -16
- package/dist/carriers/dpd-pl/carrier.json +7 -0
- package/dist/carriers/emile/carrier.json +56 -0
- package/dist/carriers/emile/numbers.json +94 -0
- package/dist/carriers/emile/statuses.json +4 -0
- package/dist/carriers/four-px/numbers.json +5 -2
- package/dist/carriers/gls-ch/adapter.d.ts +4 -0
- package/dist/carriers/gls-ch/adapter.js +8 -3
- package/dist/carriers/gls-ch/status.js +2 -2
- package/dist/carriers/gls-ch/statuses.json +1 -1
- package/dist/carriers/gls-de/adapter.d.ts +4 -0
- package/dist/carriers/gls-de/adapter.js +8 -3
- package/dist/carriers/gls-de/statuses.json +1 -1
- package/dist/carriers/gls-fr/adapter.js +8 -4
- package/dist/carriers/la-poste/adapter.js +23 -11
- package/dist/carriers/poczta-polska/parser.js +22 -6
- package/dist/carriers/swiss-post/adapter.d.ts +1 -1
- package/dist/carriers/swiss-post/adapter.js +17 -8
- package/dist/carriers/ukrposhta/parser.js +19 -3
- package/dist/core/catalog/hints.js +8 -5
- package/dist/core/errors/index.d.ts +8 -1
- package/dist/core/errors/index.js +7 -0
- package/dist/core/runner/networkRetry.d.ts +15 -0
- package/dist/core/runner/networkRetry.js +51 -0
- package/dist/core/status/language.js +32 -6
- package/dist/data/catalog.json +42 -1
- package/dist/generated/catalog.d.ts +30 -2
- package/dist/generated/catalog.js +45 -2
- package/dist/generated/registry.js +1 -0
- package/dist/node.d.ts +1 -1
- package/dist/providers/parcelsapp/adapter.js +9 -3
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "emile",
|
|
3
|
+
"displayName": "Emile",
|
|
4
|
+
"aliases": [
|
|
5
|
+
"Emile Express"
|
|
6
|
+
],
|
|
7
|
+
"region": {
|
|
8
|
+
"countries": [
|
|
9
|
+
"CA"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"brand": {
|
|
13
|
+
"color": "#e42920"
|
|
14
|
+
},
|
|
15
|
+
"selectable": true,
|
|
16
|
+
"timezone": "UTC",
|
|
17
|
+
"tracking": {
|
|
18
|
+
"mode": "link-only",
|
|
19
|
+
"adapter": null,
|
|
20
|
+
"steps": []
|
|
21
|
+
},
|
|
22
|
+
"capabilities": [],
|
|
23
|
+
"portal": {
|
|
24
|
+
"url": "https://www.emileps.com/tracking?tracking={trackingNumber}",
|
|
25
|
+
"searchUrl": "https://www.emileps.com/tracking",
|
|
26
|
+
"canaryUrl": "https://www.emileps.com/",
|
|
27
|
+
"shows": [
|
|
28
|
+
"status",
|
|
29
|
+
"history",
|
|
30
|
+
"delivery_photo"
|
|
31
|
+
],
|
|
32
|
+
"scraped": [],
|
|
33
|
+
"discarded": [],
|
|
34
|
+
"unavailable": [
|
|
35
|
+
"status",
|
|
36
|
+
"history"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"links": [
|
|
40
|
+
{
|
|
41
|
+
"domains": [
|
|
42
|
+
"emileps.com"
|
|
43
|
+
],
|
|
44
|
+
"params": [
|
|
45
|
+
"tracking"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"detection": [
|
|
50
|
+
{
|
|
51
|
+
"id": "emile-1",
|
|
52
|
+
"pattern": "^EM\\d{12}CA$",
|
|
53
|
+
"confidence": "low"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"carrier": "emile",
|
|
3
|
+
"records": [
|
|
4
|
+
{
|
|
5
|
+
"number": "EM200006549049CA",
|
|
6
|
+
"role": "shipment",
|
|
7
|
+
"evidence": "public_shipment_report",
|
|
8
|
+
"source": {
|
|
9
|
+
"url": "https://web.archive.org/web/20260102183550/https://track.4px.com/query/EM200006549049CA",
|
|
10
|
+
"date": "2026-01-02",
|
|
11
|
+
"dateKind": "capture",
|
|
12
|
+
"access": "indexed_excerpt"
|
|
13
|
+
},
|
|
14
|
+
"context": {
|
|
15
|
+
"assessment": "candidate"
|
|
16
|
+
},
|
|
17
|
+
"expect": {
|
|
18
|
+
"carrier": "unknown",
|
|
19
|
+
"confidence": "low",
|
|
20
|
+
"candidates": [
|
|
21
|
+
"emile"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. 4PX's feed returns the parcel's history and names Emile as its last-mile provider."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"number": "EM100048497916CA",
|
|
28
|
+
"role": "shipment",
|
|
29
|
+
"evidence": "public_shipment_report",
|
|
30
|
+
"source": {
|
|
31
|
+
"url": "https://web.archive.org/web/20250306075727/https://global.cainiao.com/global/detail.json?mailNos=EM100048497916CA&lang=en-US&language=en-US",
|
|
32
|
+
"date": "2025-03-06",
|
|
33
|
+
"dateKind": "capture",
|
|
34
|
+
"access": "full_page"
|
|
35
|
+
},
|
|
36
|
+
"quarantine": true,
|
|
37
|
+
"context": {
|
|
38
|
+
"assessment": "review"
|
|
39
|
+
},
|
|
40
|
+
"expect": {
|
|
41
|
+
"carrier": "unknown",
|
|
42
|
+
"confidence": "low",
|
|
43
|
+
"candidates": [
|
|
44
|
+
"emile"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"note": "A lookup on Cainiao's global tracker archived by the Wayback Machine. The archived history of this AliExpress parcel ends with a Canadian local delivery company receiving it, but Cainiao names no company; only the shape points to Emile."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"number": "EM100047250023CA",
|
|
51
|
+
"role": "shipment",
|
|
52
|
+
"evidence": "public_shipment_report",
|
|
53
|
+
"source": {
|
|
54
|
+
"url": "https://web.archive.org/web/20250217144441/https://www.parcelpanel.com/couriers/emile/",
|
|
55
|
+
"date": "2025-02-17",
|
|
56
|
+
"dateKind": "capture",
|
|
57
|
+
"access": "full_page"
|
|
58
|
+
},
|
|
59
|
+
"context": {
|
|
60
|
+
"assessment": "candidate"
|
|
61
|
+
},
|
|
62
|
+
"expect": {
|
|
63
|
+
"carrier": "unknown",
|
|
64
|
+
"confidence": "low",
|
|
65
|
+
"candidates": [
|
|
66
|
+
"emile"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"note": "The number ParcelPanel's Emile tracking page offered to try, archived by the Wayback Machine. The page rotates its example."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"number": "EM200009946130CA",
|
|
73
|
+
"role": "shipment",
|
|
74
|
+
"evidence": "public_shipment_report",
|
|
75
|
+
"source": {
|
|
76
|
+
"url": "https://web.archive.org/web/20260410161436/https://www.parcelpanel.com/couriers/emile/",
|
|
77
|
+
"date": "2026-04-10",
|
|
78
|
+
"dateKind": "capture",
|
|
79
|
+
"access": "full_page"
|
|
80
|
+
},
|
|
81
|
+
"context": {
|
|
82
|
+
"assessment": "candidate"
|
|
83
|
+
},
|
|
84
|
+
"expect": {
|
|
85
|
+
"carrier": "unknown",
|
|
86
|
+
"confidence": "low",
|
|
87
|
+
"candidates": [
|
|
88
|
+
"emile"
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"note": "The number ParcelPanel's Emile tracking page offered to try, archived by the Wayback Machine. The page rotates its example."
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
@@ -216,9 +216,12 @@
|
|
|
216
216
|
},
|
|
217
217
|
"expect": {
|
|
218
218
|
"carrier": "unknown",
|
|
219
|
-
"confidence": "
|
|
219
|
+
"confidence": "low",
|
|
220
|
+
"candidates": [
|
|
221
|
+
"emile"
|
|
222
|
+
]
|
|
220
223
|
},
|
|
221
|
-
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. The number is the Canadian last-mile courier Emile's, which 4PX's feed also answers
|
|
224
|
+
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. The number is the Canadian last-mile courier Emile's, which 4PX's feed also answers and names as the provider."
|
|
222
225
|
},
|
|
223
226
|
{
|
|
224
227
|
"number": "OJ791502074GB",
|
|
@@ -39,6 +39,10 @@ export declare class GLSSwitzerlandTracker {
|
|
|
39
39
|
* a 400 or 403 can be a challenge, so it stays a failure.
|
|
40
40
|
*/
|
|
41
41
|
recognizes(rawTrackingNumber: string, context?: TrackingContext): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
|
|
44
|
+
* what is left, so the second fits in the rest.
|
|
45
|
+
*/
|
|
42
46
|
private request;
|
|
43
47
|
}
|
|
44
48
|
/** Keep the verified overview's delivery owner when the postcode detail omits it. */
|
|
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
|
|
|
3
3
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
4
4
|
import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
|
|
5
5
|
import { InputRequiredError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
6
|
+
import { withNetworkRetry } from '../../core/runner/networkRetry.js';
|
|
6
7
|
import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
7
8
|
import { isRecord } from '../../core/types.js';
|
|
8
9
|
import { classifyDescription, GLS_STATUSES, statusCode } from './status.js';
|
|
@@ -403,17 +404,21 @@ export class GLSSwitzerlandTracker {
|
|
|
403
404
|
throw error;
|
|
404
405
|
}
|
|
405
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
|
|
409
|
+
* what is left, so the second fits in the rest.
|
|
410
|
+
*/
|
|
406
411
|
async request(url, budget) {
|
|
407
|
-
const { response, bytes } = await fetchBounded(url, {
|
|
412
|
+
const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(url, {
|
|
408
413
|
signal: budget.signal,
|
|
409
414
|
headers: pageHeaders(this.#userAgent),
|
|
410
415
|
}, {
|
|
411
416
|
provider: 'GLS Switzerland tracking',
|
|
412
|
-
timeoutMs: Math.min(this.timeoutMs,
|
|
417
|
+
timeoutMs: Math.min(this.timeoutMs, timeoutMs),
|
|
413
418
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
414
419
|
allowHttpError: true,
|
|
415
420
|
...(this.#fetcher ? { fetcher: this.#fetcher } : {}),
|
|
416
|
-
});
|
|
421
|
+
}));
|
|
417
422
|
if ([400, 403, 404].includes(response.status))
|
|
418
423
|
throw new GLSSwitzerlandTrackingError();
|
|
419
424
|
if (!response.ok)
|
|
@@ -41,9 +41,9 @@ export function glsSwitzerlandStatus(value) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Classify a history row that carries no status code, from its wording alone. */
|
|
43
43
|
export function classifyDescription(description) {
|
|
44
|
-
//
|
|
44
|
+
// GLS knows of the parcel but does not have it yet: no acceptance.
|
|
45
45
|
if (/^(?:the )?parcel has not been handed over to gls[.!]?$/i.test(description.trim())) {
|
|
46
|
-
return { status: '
|
|
46
|
+
return { status: 'pending', stage: 'registered' };
|
|
47
47
|
}
|
|
48
48
|
const translated = trackingLanguageStage(description);
|
|
49
49
|
if (translated)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{ "code": "CANCELLED", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Both spellings appear." },
|
|
17
17
|
{ "code": "FINAL", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "" },
|
|
18
18
|
{ "code": "NORECORD", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Reported as status 'unknown'." },
|
|
19
|
-
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "
|
|
19
|
+
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule, checked before the handover to GLS: GLS knows of the parcel but does not have it." },
|
|
20
20
|
{ "wording": "The parcel was handed over to GLS.", "language": "en", "stage": "accepted", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule for rows with no status code." },
|
|
21
21
|
{ "wording": "The parcel data was entered into the GLS IT system…", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule; contains 'not yet handed over to GLS' and must not read as a handoff." },
|
|
22
22
|
{ "wording": "The parcel was released by customs.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Release is completion, not active clearance." },
|
|
@@ -24,6 +24,10 @@ export declare class GLSGermanyTracker {
|
|
|
24
24
|
* only after this returns true; a provider failure must stay a failure.
|
|
25
25
|
*/
|
|
26
26
|
recognizes(rawTrackingNumber: string, context?: TrackingContext): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
|
|
29
|
+
* what is left, so the second fits in the rest.
|
|
30
|
+
*/
|
|
27
31
|
private request;
|
|
28
32
|
}
|
|
29
33
|
export declare const adapter: AdapterFactory;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
2
2
|
import { NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
3
|
+
import { withNetworkRetry } from '../../core/runner/networkRetry.js';
|
|
3
4
|
import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
4
5
|
import { isRecord } from '../../core/types.js';
|
|
5
6
|
import { glsSwitzerlandDetailApiUrl, glsDeliveryReference, glsSwitzerlandOverviewApiUrl, GLSSwitzerlandTrackingError, normalizeGLSSwitzerlandPostcode, normalizeGLSSwitzerlandTrackingNumber, parseGLSSwitzerlandTrackingResponse, selectGLSParcel, } from '../gls-ch/adapter.js';
|
|
@@ -88,17 +89,21 @@ export class GLSGermanyTracker {
|
|
|
88
89
|
throw error;
|
|
89
90
|
}
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
|
|
94
|
+
* what is left, so the second fits in the rest.
|
|
95
|
+
*/
|
|
91
96
|
async request(url, budget) {
|
|
92
|
-
const { response, bytes } = await fetchBounded(url, {
|
|
97
|
+
const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(url, {
|
|
93
98
|
signal: budget.signal,
|
|
94
99
|
headers: { Accept: 'application/json', Referer: 'https://gls-group.eu/EU/en/parcel-tracking', 'User-Agent': this.#userAgent },
|
|
95
100
|
}, {
|
|
96
101
|
provider: PROVIDER,
|
|
97
|
-
timeoutMs: Math.min(this.timeoutMs,
|
|
102
|
+
timeoutMs: Math.min(this.timeoutMs, timeoutMs),
|
|
98
103
|
maxBytes: 1_000_000,
|
|
99
104
|
allowHttpError: true,
|
|
100
105
|
...(this.#fetcher ? { fetcher: this.#fetcher } : {}),
|
|
101
|
-
});
|
|
106
|
+
}));
|
|
102
107
|
// A challenge, invalid postcode, rate limit or outage must not become "not found".
|
|
103
108
|
if (response.status === 404) {
|
|
104
109
|
const payload = parseJsonBytes(bytes, PROVIDER);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{ "code": "CANCELLED", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Both spellings appear." },
|
|
17
17
|
{ "code": "FINAL", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "" },
|
|
18
18
|
{ "code": "NORECORD", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Reported as status 'unknown'." },
|
|
19
|
-
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "
|
|
19
|
+
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule, checked before the handover to GLS: GLS knows of the parcel but does not have it." },
|
|
20
20
|
{ "wording": "The parcel was handed over to GLS.", "language": "en", "stage": "accepted", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule for rows with no status code." },
|
|
21
21
|
{ "wording": "The parcel data was entered into the GLS IT system…", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule; contains 'not yet handed over to GLS' and must not read as a handoff." },
|
|
22
22
|
{ "wording": "The parcel was released by customs.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Release is completion, not active clearance." },
|
|
@@ -13,6 +13,7 @@ import { DateTime } from 'luxon';
|
|
|
13
13
|
import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
14
14
|
import { isValidGlsParcelNumber } from '../../core/detection/index.js';
|
|
15
15
|
import { InvalidInputError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
16
|
+
import { withNetworkRetry } from '../../core/runner/networkRetry.js';
|
|
16
17
|
import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
|
|
17
18
|
import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
18
19
|
import { isRecord } from '../../core/types.js';
|
|
@@ -280,7 +281,8 @@ export class GLSFranceTracker {
|
|
|
280
281
|
async fetch(trackingNumber, context = {}, { pickupPoint = true } = {}) {
|
|
281
282
|
const normalized = normalizeGLSFranceTrackingNumber(trackingNumber);
|
|
282
283
|
const printed = printedNumber(trackingNumber);
|
|
283
|
-
|
|
284
|
+
// Room for a request and its retry.
|
|
285
|
+
const budget = lookupBudget(context, 2 * this.timeoutMs);
|
|
284
286
|
let parsed;
|
|
285
287
|
try {
|
|
286
288
|
parsed = await this.lookup(normalized, normalized, budget);
|
|
@@ -335,16 +337,18 @@ export class GLSFranceTracker {
|
|
|
335
337
|
};
|
|
336
338
|
}
|
|
337
339
|
async lookup(code, normalized, budget) {
|
|
338
|
-
|
|
340
|
+
// A request that fails to reach GLS, or hangs, is sent once more: the first
|
|
341
|
+
// gets at most half of what is left, so the second fits in the rest.
|
|
342
|
+
const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(`${TRACKING_API}/${encodeURIComponent(code)}`, {
|
|
339
343
|
signal: budget.signal,
|
|
340
344
|
headers: this.#headers(),
|
|
341
345
|
}, {
|
|
342
346
|
provider: 'GLS France tracking',
|
|
343
|
-
timeoutMs: Math.min(this.timeoutMs,
|
|
347
|
+
timeoutMs: Math.min(this.timeoutMs, timeoutMs),
|
|
344
348
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
345
349
|
fetcher: this.#fetcher,
|
|
346
350
|
allowHttpStatuses: [404, 410],
|
|
347
|
-
});
|
|
351
|
+
}));
|
|
348
352
|
budget.signal.throwIfAborted();
|
|
349
353
|
if ([404, 410].includes(response.status)) {
|
|
350
354
|
// The native negative names the complete submitted code. An absent API
|
|
@@ -3,6 +3,7 @@ import { carrierIdFromPartner } from '../../core/catalog/hints.js';
|
|
|
3
3
|
import { dpdParcelNumber } from '../../core/detection/dpd.js';
|
|
4
4
|
import { CarrierError, InvalidInputError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
5
5
|
import { runSteps } from '../../core/runner/index.js';
|
|
6
|
+
import { firstAttemptMs, isNetworkFailure } from '../../core/runner/networkRetry.js';
|
|
6
7
|
import { isoTime } from '../../core/time/index.js';
|
|
7
8
|
import { clean, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
8
9
|
import { isRecord } from '../../core/types.js';
|
|
@@ -20,6 +21,9 @@ import { eventStage, eventStatus } from './status.js';
|
|
|
20
21
|
// - Production HTTP 403s carried La Poste's "Site indisponible - Incident en
|
|
21
22
|
// cours" page and immediately following checks succeeded, so a 403 is
|
|
22
23
|
// retried up to three times inside the original deadline (see README.md).
|
|
24
|
+
// - A request that hangs would otherwise take the whole budget, so a network
|
|
25
|
+
// failure or timeout is retried once too, and an attempt gets at most half
|
|
26
|
+
// of what is left while that retry remains.
|
|
23
27
|
// - The tracking page links the point holding a parcel to La Poste's locator,
|
|
24
28
|
// `localiser.laposte.fr/{idPoint}`, which redirects to the point's page. That
|
|
25
29
|
// page carries the point's record, address included, as JSON in
|
|
@@ -273,7 +277,7 @@ export class LaPosteTracker {
|
|
|
273
277
|
const normalized = normalizeLaPosteTrackingNumber(trackingNumber);
|
|
274
278
|
const budgetMs = context.budgetMs ?? this.timeoutMs;
|
|
275
279
|
const deadline = performance.now() + budgetMs;
|
|
276
|
-
const request = async (
|
|
280
|
+
const request = async (timeoutMs, signal) => {
|
|
277
281
|
const { bytes } = await fetchBounded(laPosteTrackingApiUrl(normalized), {
|
|
278
282
|
signal,
|
|
279
283
|
headers: {
|
|
@@ -284,7 +288,7 @@ export class LaPosteTracker {
|
|
|
284
288
|
},
|
|
285
289
|
}, {
|
|
286
290
|
provider: 'La Poste tracking',
|
|
287
|
-
timeoutMs: Math.max(1, Math.floor(Math.min(this.timeoutMs,
|
|
291
|
+
timeoutMs: Math.max(1, Math.floor(Math.min(this.timeoutMs, timeoutMs))),
|
|
288
292
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
289
293
|
fetcher: this.fetcher,
|
|
290
294
|
});
|
|
@@ -302,21 +306,28 @@ export class LaPosteTracker {
|
|
|
302
306
|
// later, is answered normally. It is a hiccup of that one request, not
|
|
303
307
|
// maintenance, so the page is retried like any other 403: three immediate
|
|
304
308
|
// retries sharing the original deadline. A real incident still fails every
|
|
305
|
-
// attempt within seconds and reaches the router.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
// attempt within seconds and reaches the router. A request that fails to
|
|
310
|
+
// reach La Poste, or hangs until its timeout, gets one retry as well. Until
|
|
311
|
+
// that retry is spent, an attempt gets at most half of what is left, so a
|
|
312
|
+
// hang leaves it time. Do not retry other HTTP or parsing failures, nor a
|
|
313
|
+
// not-found. Once the deadline is spent the retry is refused, so the caller
|
|
314
|
+
// still sees the provider's own rejection rather than a budget error.
|
|
315
|
+
let networkRetried = false;
|
|
316
|
+
const retriable = (error) => deadline - performance.now() >= 1
|
|
317
|
+
&& ((error instanceof UpstreamHttpError && error.status === 403) || (!networkRetried && isNetworkFailure(error)));
|
|
311
318
|
const retry = {
|
|
312
319
|
id: 'retry',
|
|
313
320
|
recovers: retriable,
|
|
314
|
-
run: ({ remainingMs, signal }) =>
|
|
321
|
+
run: ({ remainingMs, signal, previousError }) => {
|
|
322
|
+
if (isNetworkFailure(previousError))
|
|
323
|
+
networkRetried = true;
|
|
324
|
+
return request(networkRetried ? remainingMs : firstAttemptMs(remainingMs), signal);
|
|
325
|
+
},
|
|
315
326
|
};
|
|
316
327
|
return await runSteps({
|
|
317
328
|
carrier: 'la-poste', budgetMs, signal: context.signal, recorder: this.recorder,
|
|
318
329
|
}, [
|
|
319
|
-
{ id: 'direct', run: ({ remainingMs, signal }) => request(remainingMs, signal) },
|
|
330
|
+
{ id: 'direct', run: ({ remainingMs, signal }) => request(firstAttemptMs(remainingMs), signal) },
|
|
320
331
|
retry,
|
|
321
332
|
{ ...retry },
|
|
322
333
|
{ ...retry },
|
|
@@ -369,7 +380,8 @@ export const adapter = (environment) => {
|
|
|
369
380
|
return {
|
|
370
381
|
id: 'la-poste',
|
|
371
382
|
// One keyless request, then up to three immediate retries of the same
|
|
372
|
-
// request after a transient HTTP 403,
|
|
383
|
+
// request after a transient HTTP 403, and one after a network failure or
|
|
384
|
+
// timeout, inside the original deadline.
|
|
373
385
|
steps: ['direct', 'retry'],
|
|
374
386
|
track: (input, context) => tracker.fetch(input.number, context),
|
|
375
387
|
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeLaPosteTrackingNumber(number))),
|
|
@@ -17,7 +17,22 @@ export function normalizePocztaPolskaNumber(raw) {
|
|
|
17
17
|
const sum = [...number].reduce((total, digit, index) => total + Number(digit) * (index % 2 ? 1 : 3), 0);
|
|
18
18
|
return `${number}${(10 - sum % 10) % 10}`;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
// Poczta Polska's own offices carry an office type. Scans relayed from partners
|
|
21
|
+
// abroad carry none and keep the sending country's wall clock; an office code
|
|
22
|
+
// alone could be a foreign postcode.
|
|
23
|
+
function polishOffice(office) {
|
|
24
|
+
return isRecord(office) && clean(office.officeType, 40) !== '';
|
|
25
|
+
}
|
|
26
|
+
// A Polish office dates its scans on Polish time. A wall clock the zone skips
|
|
27
|
+
// or repeats stays local.
|
|
28
|
+
function warsawTime(local) {
|
|
29
|
+
const parsed = DateTime.fromISO(local, { zone: 'Europe/Warsaw' });
|
|
30
|
+
if (!parsed.isValid || parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) !== local
|
|
31
|
+
|| parsed.getPossibleOffsets().length !== 1)
|
|
32
|
+
return null;
|
|
33
|
+
return parsed.toISO({ suppressMilliseconds: true });
|
|
34
|
+
}
|
|
35
|
+
function eventClock(value, polish) {
|
|
21
36
|
if (value != null && typeof value !== 'string')
|
|
22
37
|
throw new SchemaError('poczta-polska', 'Poczta Polska returned an invalid clock field');
|
|
23
38
|
const raw = clean(value, 64);
|
|
@@ -30,6 +45,9 @@ function eventClock(value) {
|
|
|
30
45
|
// UTC validates the calendar only. It is never attached to local digits.
|
|
31
46
|
const parsed = DateTime.fromISO(raw, { zone: 'UTC' });
|
|
32
47
|
const local = parsed.isValid ? parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) : null;
|
|
48
|
+
const time = local && polish ? warsawTime(local) : null;
|
|
49
|
+
if (time)
|
|
50
|
+
return { time };
|
|
33
51
|
if (local)
|
|
34
52
|
return { local_time: local };
|
|
35
53
|
}
|
|
@@ -72,16 +90,14 @@ export function parsePocztaPolska(payload, number) {
|
|
|
72
90
|
if (startsPocztaPolskaReturn(code, isRecord(raw.state) ? clean(raw.state.code, 16) : ''))
|
|
73
91
|
returning = true;
|
|
74
92
|
const stage = returning && mapped?.stage === 'delivered' ? 'returned' : mapped?.stage;
|
|
75
|
-
return { ...eventClock(raw.time), description, provider_code: code,
|
|
93
|
+
return { ...eventClock(raw.time, polishOffice(raw.postOffice)), description, provider_code: code,
|
|
76
94
|
...(location ? { location } : {}), ...(stage ? { stage } : {}), ...(returning ? { provider_leg: 'return' } : {}) };
|
|
77
95
|
});
|
|
78
96
|
if (!projected.length)
|
|
79
97
|
throw new IndeterminateError('poczta-polska', 'Poczta Polska returned no parcel history');
|
|
80
|
-
// The widget's last source row is current
|
|
81
|
-
//
|
|
98
|
+
// The widget's last source row is current, so the provider's order stands even
|
|
99
|
+
// when every scan is dated: the clocks never reorder it.
|
|
82
100
|
projected.reverse();
|
|
83
|
-
if (projected.every(event => event.time))
|
|
84
|
-
projected.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
|
|
85
101
|
const seen = new Set();
|
|
86
102
|
const events = projected.filter(event => {
|
|
87
103
|
const key = JSON.stringify(event);
|
|
@@ -21,7 +21,7 @@ export declare function parseSwissPostShipment(item: JsonObject, rawEvents: unkn
|
|
|
21
21
|
export declare class SwissPostTracker {
|
|
22
22
|
#private;
|
|
23
23
|
constructor(options?: SwissPostOptions);
|
|
24
|
-
readJson(fetcher: typeof fetch, url: string, init?: RequestInit, budget?: LookupBudget): Promise<unknown>;
|
|
24
|
+
readJson(fetcher: typeof fetch, url: string, init?: RequestInit, budget?: LookupBudget, timeoutMs?: number): Promise<unknown>;
|
|
25
25
|
/** Cached for the process: the table is large, static, and shared by every lookup. */
|
|
26
26
|
loadTranslations(fetcher: typeof fetch, budget?: LookupBudget): Promise<Record<string, string>>;
|
|
27
27
|
fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
@@ -4,6 +4,7 @@ import { CookieJar } from 'tough-cookie';
|
|
|
4
4
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
5
5
|
import { NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
6
6
|
import { countryCode } from '../../core/time/index.js';
|
|
7
|
+
import { withNetworkRetry } from '../../core/runner/networkRetry.js';
|
|
7
8
|
import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
8
9
|
import { isRecord } from '../../core/types.js';
|
|
9
10
|
import { FALLBACK_EVENT_LABELS, GLOBAL_STATUS_STAGE, STAGE_STATUS, STATUS_MAP, swissPostEventStage, } from './status.js';
|
|
@@ -266,14 +267,22 @@ export class SwissPostTracker {
|
|
|
266
267
|
constructor(options = {}) {
|
|
267
268
|
this.#fetcher = options.fetcher;
|
|
268
269
|
}
|
|
269
|
-
async readJson(fetcher, url, init = {}, budget) {
|
|
270
|
+
async readJson(fetcher, url, init = {}, budget, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
270
271
|
const { bytes } = await fetchBounded(url, budget ? { ...init, signal: budget.signal } : init, {
|
|
271
272
|
provider: 'Swiss Post tracking',
|
|
272
|
-
timeoutMs: Math.min(REQUEST_TIMEOUT_MS, budget?.remainingMs() ?? REQUEST_TIMEOUT_MS),
|
|
273
|
+
timeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs, budget?.remainingMs() ?? REQUEST_TIMEOUT_MS),
|
|
273
274
|
fetcher,
|
|
274
275
|
});
|
|
275
276
|
return parseJsonBytes(bytes, PROVIDER);
|
|
276
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* A call of the lookup's chain, sent once more if it fails to reach Swiss Post or hangs: the
|
|
280
|
+
* first gets at most half of what is left, so the second fits in the rest. The search's POST
|
|
281
|
+
* only files the number under the throwaway user, so it is sent again like a read.
|
|
282
|
+
*/
|
|
283
|
+
#readRetrying(fetcher, url, init, budget) {
|
|
284
|
+
return withNetworkRetry(budget, (timeoutMs) => this.readJson(fetcher, url, init, budget, timeoutMs));
|
|
285
|
+
}
|
|
277
286
|
/** Cached for the process: the table is large, static, and shared by every lookup. */
|
|
278
287
|
async loadTranslations(fetcher, budget) {
|
|
279
288
|
if (this.#translations)
|
|
@@ -301,11 +310,11 @@ export class SwissPostTracker {
|
|
|
301
310
|
async fetch(trackingNumber, context = {}) {
|
|
302
311
|
const budget = lookupBudget(context, DEFAULT_BUDGET_MS);
|
|
303
312
|
const fetcher = makeFetchCookie(this.#fetcher ?? fetch, new CookieJar());
|
|
304
|
-
const userResult = await fetchBounded(`${API_BASE}/user`, { headers: HEADERS, signal: budget.signal }, {
|
|
313
|
+
const userResult = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(`${API_BASE}/user`, { headers: HEADERS, signal: budget.signal }, {
|
|
305
314
|
provider: 'Swiss Post tracking',
|
|
306
|
-
timeoutMs: Math.min(REQUEST_TIMEOUT_MS,
|
|
315
|
+
timeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs),
|
|
307
316
|
fetcher,
|
|
308
|
-
});
|
|
317
|
+
}));
|
|
309
318
|
const userPayload = parseJsonBytes(userResult.bytes, PROVIDER);
|
|
310
319
|
if (!isRecord(userPayload))
|
|
311
320
|
throw new SchemaError(PROVIDER, 'Swiss Post returned an invalid anonymous user response');
|
|
@@ -315,7 +324,7 @@ export class SwissPostTracker {
|
|
|
315
324
|
const csrf = userResult.response.headers.get('x-csrf-token') ?? '';
|
|
316
325
|
const headers = { ...HEADERS, 'x-csrf-token': csrf };
|
|
317
326
|
const query = new URLSearchParams({ userId });
|
|
318
|
-
const historyPayload = await this
|
|
327
|
+
const historyPayload = await this.#readRetrying(fetcher, `${API_BASE}/history?${query}`, {
|
|
319
328
|
method: 'POST',
|
|
320
329
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
321
330
|
body: JSON.stringify({ searchQuery: trackingNumber }),
|
|
@@ -323,7 +332,7 @@ export class SwissPostTracker {
|
|
|
323
332
|
const hash = isRecord(historyPayload) ? text(historyPayload.hash) : '';
|
|
324
333
|
if (!hash)
|
|
325
334
|
throw new SchemaError(PROVIDER, 'Swiss Post did not return a shipment search identifier');
|
|
326
|
-
const items = await this
|
|
335
|
+
const items = await this.#readRetrying(fetcher, `${API_BASE}/history/not-included/${encodeURIComponent(hash)}?${query}`, { headers }, budget);
|
|
327
336
|
if (!Array.isArray(items)) {
|
|
328
337
|
throw new SchemaError(PROVIDER, 'Swiss Post returned an invalid shipment response');
|
|
329
338
|
}
|
|
@@ -348,7 +357,7 @@ export class SwissPostTracker {
|
|
|
348
357
|
let events = [];
|
|
349
358
|
if (identity) {
|
|
350
359
|
try {
|
|
351
|
-
const payload = await this
|
|
360
|
+
const payload = await this.#readRetrying(fetcher, `${API_BASE}/shipment/id/${encodeURIComponent(identity)}/events`, { headers }, budget);
|
|
352
361
|
if (Array.isArray(payload))
|
|
353
362
|
events = payload;
|
|
354
363
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DateTime } from 'luxon';
|
|
2
2
|
import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
|
|
3
3
|
import { ChallengeError, IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { countryCode } from '../../core/time/index.js';
|
|
4
5
|
import { clean, cleanScalar } from '../../core/transport/text.js';
|
|
5
6
|
import { isRecord } from '../../core/types.js';
|
|
6
7
|
import { ukrposhtaReturnCue, ukrposhtaStatus } from './status.js';
|
|
@@ -37,6 +38,19 @@ export function ukrposhtaWallClock(value) {
|
|
|
37
38
|
const date = DateTime.fromFormat(raw, format, { zone: 'UTC' });
|
|
38
39
|
return date.isValid && date.toFormat(format) === raw ? date.toFormat("yyyy-MM-dd'T'HH:mm:ss") : null;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* A scan in Ukraine is dated on Kyiv time. The minute is the precision both
|
|
43
|
+
* sources share, so the status API and the portal date one scan alike. A wall
|
|
44
|
+
* clock the zone skips or repeats stays local.
|
|
45
|
+
*/
|
|
46
|
+
function kyivTime(local) {
|
|
47
|
+
const minute = `${local.slice(0, 16)}:00`;
|
|
48
|
+
const parsed = DateTime.fromISO(minute, { zone: 'Europe/Kyiv' });
|
|
49
|
+
if (!parsed.isValid || parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) !== minute
|
|
50
|
+
|| parsed.getPossibleOffsets().length !== 1)
|
|
51
|
+
return null;
|
|
52
|
+
return parsed.toISO({ suppressMilliseconds: true });
|
|
53
|
+
}
|
|
40
54
|
export function parseUkrposhtaOverview(payload, requested) {
|
|
41
55
|
const number = normalizeUkrposhtaNumber(requested);
|
|
42
56
|
const data = envelope(payload);
|
|
@@ -64,7 +78,8 @@ export function parseUkrposhtaOverview(payload, requested) {
|
|
|
64
78
|
function project(rows) {
|
|
65
79
|
const latest = rows[0];
|
|
66
80
|
// Both native histories are current-first. Keep that order across foreign
|
|
67
|
-
// wall clocks or missing dates instead of sorting them as
|
|
81
|
+
// wall clocks or missing dates instead of sorting them as instants. A row
|
|
82
|
+
// naming Ukraine is a Ukrposhta scan; others keep the clock they were given.
|
|
68
83
|
let returnLeg = false;
|
|
69
84
|
const events = new Array(rows.length);
|
|
70
85
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
@@ -77,10 +92,11 @@ function project(rows) {
|
|
|
77
92
|
if (classified?.stage === 'delivered' && returnLeg)
|
|
78
93
|
classified = { status: 'exception', stage: 'returned' };
|
|
79
94
|
const local = ukrposhtaWallClock(row.date);
|
|
95
|
+
const time = local && countryCode(row.country) === 'UA' ? kyivTime(local) : null;
|
|
80
96
|
events[index] = {
|
|
81
97
|
description: row.label, provider_code: row.code,
|
|
82
98
|
...(row.location || row.country ? { location: [row.location, row.country].filter(Boolean).join(', ') } : {}),
|
|
83
|
-
...(local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
|
|
99
|
+
...(time ? { time } : local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
|
|
84
100
|
...(classified ? { stage: classified.stage } : {}), ...(returnLeg ? { provider_leg: 'return' } : {}),
|
|
85
101
|
};
|
|
86
102
|
}
|
|
@@ -90,7 +106,7 @@ function project(rows) {
|
|
|
90
106
|
const seen = new Set();
|
|
91
107
|
const deduplicated = events.filter(event => { const key = JSON.stringify(event); if (seen.has(key))
|
|
92
108
|
return false; seen.add(key); return true; });
|
|
93
|
-
return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: null, events: deduplicated.slice(0, 100) };
|
|
109
|
+
return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: current.time ?? null, events: deduplicated.slice(0, 100) };
|
|
94
110
|
}
|
|
95
111
|
/**
|
|
96
112
|
* The status-tracking API's reply to one barcode: a bare array of scans, each naming the
|