universal-parcel-scraper 0.11.0 → 0.13.0-main.337
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 +4 -4
- package/data/catalog.json +6 -5
- package/data/coverage-summary.json +3 -3
- package/data/detection-golden.json +188 -0
- package/dist/browser/scraper.js +30 -26
- package/dist/carriers/cne/adapter.d.ts +12 -0
- package/dist/carriers/cne/adapter.js +43 -0
- package/dist/carriers/cne/carrier.json +17 -5
- package/dist/carriers/cne/numbers.json +28 -2
- package/dist/carriers/cne/parser.d.ts +3 -0
- package/dist/carriers/cne/parser.js +74 -0
- package/dist/carriers/cne/statuses.json +63 -1
- package/dist/carriers/dpd-uk/adapter.d.ts +11 -0
- package/dist/carriers/dpd-uk/adapter.js +58 -0
- package/dist/carriers/dpd-uk/carrier.json +18 -6
- package/dist/carriers/dpd-uk/numbers.json +30 -2
- package/dist/carriers/dpd-uk/parser.d.ts +9 -0
- package/dist/carriers/dpd-uk/parser.js +127 -0
- package/dist/carriers/evri-uk/adapter.d.ts +11 -0
- package/dist/carriers/evri-uk/adapter.js +80 -0
- package/dist/carriers/evri-uk/browser.d.ts +56 -0
- package/dist/carriers/evri-uk/browser.js +137 -0
- package/dist/carriers/evri-uk/carrier.json +21 -8
- package/dist/carriers/evri-uk/numbers.json +19 -2
- package/dist/carriers/evri-uk/parser.d.ts +5 -0
- package/dist/carriers/evri-uk/parser.js +89 -0
- package/dist/carriers/evri-uk/statuses.json +8 -1
- package/dist/carriers/intelcom/numbers.json +26 -0
- package/dist/carriers/intelcom/parser.js +9 -2
- package/dist/carriers/intelcom/statuses.json +9 -1
- package/dist/carriers/lbc-express/adapter.d.ts +13 -0
- package/dist/carriers/lbc-express/adapter.js +150 -0
- package/dist/carriers/lbc-express/carrier.json +20 -7
- package/dist/carriers/lbc-express/numbers.json +52 -0
- package/dist/carriers/lbc-express/parser.d.ts +3 -0
- package/dist/carriers/lbc-express/parser.js +68 -0
- package/dist/carriers/lbc-express/statuses.json +20 -1
- package/dist/carriers/nova-poshta/adapter.js +22 -5
- package/dist/carriers/nova-poshta/carrier.json +18 -6
- package/dist/carriers/nova-poshta/numbers.json +49 -0
- package/dist/carriers/nova-poshta/parser.d.ts +2 -0
- package/dist/carriers/nova-poshta/parser.js +98 -1
- package/dist/carriers/nova-poshta/statuses.json +93 -1
- package/dist/carriers/royal-mail/adapter.d.ts +3 -0
- package/dist/carriers/royal-mail/adapter.js +94 -47
- package/dist/carriers/royal-mail/numbers.json +13 -0
- package/dist/carriers/royal-mail/status.d.ts +1 -0
- package/dist/carriers/royal-mail/status.js +14 -0
- package/dist/carriers/royal-mail/statuses.json +151 -11
- package/dist/carriers/spx-ph/numbers.json +16 -0
- package/dist/carriers/spx-ph/parser.js +10 -4
- package/dist/carriers/spx-ph/statuses.json +56 -1
- package/dist/data/catalog.json +6 -5
- package/dist/generated/catalog.d.ts +6 -5
- package/dist/generated/catalog.js +6 -5
- package/dist/generated/registry.js +12 -4
- package/dist/providers/coverage.json +24 -20
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
2
|
+
import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
|
+
import { classifyWording, languageStageStatus } from '../../core/status/index.js';
|
|
4
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
5
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
const PROVIDER = 'CNE Express';
|
|
8
|
+
const WORDING = {
|
|
9
|
+
'Parcel Infomation Received': 'registered', 'Picked Up by Courier': 'accepted',
|
|
10
|
+
'Shipment Loaded': 'in_transit', 'Departed CNE Facility': 'in_transit',
|
|
11
|
+
'Arrived at CNE Facility': 'in_transit', 'Inbound scan': 'in_transit',
|
|
12
|
+
'Shipment Processed': 'in_transit', 'Sorting failed []': 'exception',
|
|
13
|
+
"It was returned to customer's address by CNE": 'returned',
|
|
14
|
+
};
|
|
15
|
+
export function normalizeCneNumber(raw) {
|
|
16
|
+
const number = normalizeTrackingNumber(raw);
|
|
17
|
+
if (!/^[A-Z0-9]{6,40}$/.test(number))
|
|
18
|
+
throw new InvalidInputError(PROVIDER, 'CNE requires an alphanumeric parcel reference');
|
|
19
|
+
return number;
|
|
20
|
+
}
|
|
21
|
+
function scanTime(value) {
|
|
22
|
+
const match = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-](\d{2}):?(\d{2}))$/i.exec(value);
|
|
23
|
+
if (!match || (match[1] !== undefined && (Number(match[1]) > 14 || Number(match[2]) > 59
|
|
24
|
+
|| (Number(match[1]) === 14 && Number(match[2]) !== 0))))
|
|
25
|
+
return null;
|
|
26
|
+
return explicitOffsetTime(value);
|
|
27
|
+
}
|
|
28
|
+
function scan(raw) {
|
|
29
|
+
if (!isRecord(raw) || !clean(raw.details, 1000))
|
|
30
|
+
throw new SchemaError(PROVIDER, 'CNE returned an invalid movement');
|
|
31
|
+
const description = clean(raw.details, 1000), clock = clean(raw.date, 64);
|
|
32
|
+
const location = clean(raw.place, 200), stage = WORDING[description];
|
|
33
|
+
const mapped = stage ? { stage, source: 'carrier_map' } : classifyWording(description, 'pending');
|
|
34
|
+
const time = scanTime(clock);
|
|
35
|
+
return { description, ...(location ? { location } : {}),
|
|
36
|
+
...(time ? { time: time.iso } : clock ? { provider_time_text: clock } : {}),
|
|
37
|
+
...(mapped.source !== 'none' ? { stage: mapped.stage, stage_source: mapped.source } : {}),
|
|
38
|
+
...(cleanScalar(raw.state) ? { provider_code: cleanScalar(raw.state) } : {}) };
|
|
39
|
+
}
|
|
40
|
+
export function parseCne(payload, raw) {
|
|
41
|
+
const number = normalizeCneNumber(raw);
|
|
42
|
+
if (!isRecord(payload) || typeof payload.ReturnValue !== 'number')
|
|
43
|
+
throw new SchemaError(PROVIDER, 'CNE returned an invalid envelope');
|
|
44
|
+
if (payload.ReturnValue !== 1) {
|
|
45
|
+
if (payload.ReturnValue === 0 && payload.cMess === '订单不存在'
|
|
46
|
+
&& payload.Response_Info === undefined && payload.trackingEventList === undefined)
|
|
47
|
+
throw new NotFoundError(PROVIDER);
|
|
48
|
+
throw new IndeterminateError(PROVIDER, 'CNE did not complete the lookup');
|
|
49
|
+
}
|
|
50
|
+
const identity = payload.Response_Info;
|
|
51
|
+
if (!isRecord(identity) || clean(identity.trackingNbr) !== number)
|
|
52
|
+
throw new SchemaError(PROVIDER, 'CNE returned a different shipment');
|
|
53
|
+
// These are echoed source identifiers, unlike referNbr and transNbr, which
|
|
54
|
+
// may be merchant and downstream references. Do not follow those aliases.
|
|
55
|
+
if (identity.Number_t !== undefined && clean(identity.Number_t) !== number)
|
|
56
|
+
throw new SchemaError(PROVIDER, 'CNE returned conflicting shipment identifiers');
|
|
57
|
+
if (!Array.isArray(payload.trackingEventList) || payload.trackingEventList.length > 1000)
|
|
58
|
+
throw new SchemaError(PROVIDER, 'CNE returned an invalid history');
|
|
59
|
+
if (!payload.trackingEventList.length)
|
|
60
|
+
throw new IndeterminateError(PROVIDER, 'CNE returned no shipment movements');
|
|
61
|
+
// The official client reverses the oldest-first array. Preserve that order
|
|
62
|
+
// even when its unqualified cross-border clocks cannot resolve to instants.
|
|
63
|
+
const events = payload.trackingEventList.map(scan).reverse();
|
|
64
|
+
const latest = events[0];
|
|
65
|
+
const summaryStage = cleanScalar(identity.status) === '8' ? 'returned' : undefined;
|
|
66
|
+
const stage = summaryStage ?? latest.stage;
|
|
67
|
+
const source = summaryStage ? 'carrier_map' : latest.stage_source;
|
|
68
|
+
const country = clean(identity.Destination, 64).toUpperCase();
|
|
69
|
+
return { status: stage ? languageStageStatus(stage) : 'unknown',
|
|
70
|
+
...(stage ? { current_stage: stage, current_stage_source: source } : {}),
|
|
71
|
+
last_status_text: latest.description, last_update: latest.time ?? null,
|
|
72
|
+
...(stage === 'delivered' && latest.time ? { delivered_at: latest.time } : {}),
|
|
73
|
+
...(/^[A-Z]{2}$/.test(country) ? { destination_country: country } : {}), events: events.slice(0, 100) };
|
|
74
|
+
}
|
|
@@ -1,4 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "cne",
|
|
3
|
-
"entries": [
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"wording": "Parcel Infomation Received",
|
|
6
|
+
"language": "en",
|
|
7
|
+
"stage": "registered",
|
|
8
|
+
"confirmedBy": "live"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"wording": "Picked Up by Courier",
|
|
12
|
+
"language": "en",
|
|
13
|
+
"stage": "accepted",
|
|
14
|
+
"confirmedBy": "live"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"wording": "Shipment Loaded",
|
|
18
|
+
"language": "en",
|
|
19
|
+
"stage": "in_transit",
|
|
20
|
+
"confirmedBy": "live"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"wording": "Departed CNE Facility",
|
|
24
|
+
"language": "en",
|
|
25
|
+
"stage": "in_transit",
|
|
26
|
+
"confirmedBy": "live"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"wording": "Arrived at CNE Facility",
|
|
30
|
+
"language": "en",
|
|
31
|
+
"stage": "in_transit",
|
|
32
|
+
"confirmedBy": "live"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"wording": "Inbound scan",
|
|
36
|
+
"language": "en",
|
|
37
|
+
"stage": "in_transit",
|
|
38
|
+
"confirmedBy": "live"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"wording": "Shipment Processed",
|
|
42
|
+
"language": "en",
|
|
43
|
+
"stage": "in_transit",
|
|
44
|
+
"confirmedBy": "live"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"wording": "Sorting failed []",
|
|
48
|
+
"language": "en",
|
|
49
|
+
"stage": "exception",
|
|
50
|
+
"confirmedBy": "live"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"wording": "It was returned to customer's address by CNE",
|
|
54
|
+
"language": "en",
|
|
55
|
+
"stage": "returned",
|
|
56
|
+
"confirmedBy": "live"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"code": "8",
|
|
60
|
+
"wording": "退件",
|
|
61
|
+
"language": "zh",
|
|
62
|
+
"stage": "returned",
|
|
63
|
+
"confirmedBy": "live"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
4
66
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
export declare class DpdUkTracker {
|
|
4
|
+
private readonly options;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
fetcher?: typeof fetch;
|
|
7
|
+
recorder?: StepRecorder;
|
|
8
|
+
});
|
|
9
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
10
|
+
}
|
|
11
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CarrierError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
2
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
3
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
4
|
+
import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
5
|
+
import { isDpdUkReferenceAbsent, normalizeDpdUkNumber, parseDpdUkHistory, parseDpdUkReference, PROVIDER, validateDpdUkParcel } from './parser.js';
|
|
6
|
+
const API = 'https://apis.track.dpd.co.uk/v1';
|
|
7
|
+
export class DpdUkTracker {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async fetch(raw, context = {}) {
|
|
13
|
+
const number = normalizeDpdUkNumber(raw);
|
|
14
|
+
return runSteps({ carrier: 'dpd-uk', signal: context.signal, budgetMs: context.budgetMs ?? 15_000,
|
|
15
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
|
|
16
|
+
const deadline = performance.now() + remainingMs;
|
|
17
|
+
const read = async (path, reference = false) => {
|
|
18
|
+
try {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const { bytes, response } = await fetchBounded(`${API}${path}`, { signal, headers: { Accept: 'application/json' } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(deadline - performance.now())), maxBytes: 1_000_000,
|
|
21
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [404, 410] });
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
payload = parseJsonBytes(bytes, PROVIDER);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw response.ok ? new SchemaError(PROVIDER) : new TransportError(PROVIDER, 'DPD UK tracking endpoint is unavailable');
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (reference && response.status === 404 && isDpdUkReferenceAbsent(payload))
|
|
31
|
+
throw new NotFoundError(PROVIDER);
|
|
32
|
+
throw new TransportError(PROVIDER, 'DPD UK tracking endpoint is unavailable');
|
|
33
|
+
}
|
|
34
|
+
return payload;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
// Request diagnostics and malformed bodies can contain an issued parcel
|
|
39
|
+
// handle or personal details. Keep the classification, never its cause.
|
|
40
|
+
if (error instanceof CarrierError)
|
|
41
|
+
throw new CarrierError(error.kind, PROVIDER, `${PROVIDER} tracking failed (${error.kind})`, { status: error.status, retryAfterMs: error.retryAfterMs, reason: error.reason });
|
|
42
|
+
throw new TransportError(PROVIDER);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const query = new URLSearchParams({ origin: 'PRTK', postcode: '', referenceNumber: number });
|
|
46
|
+
const code = parseDpdUkReference(await read(`/reference?${query}`, true), number);
|
|
47
|
+
const parcelPath = `/parcels/${encodeURIComponent(code)}`;
|
|
48
|
+
const detail = await read(parcelPath);
|
|
49
|
+
validateDpdUkParcel(detail, number, code);
|
|
50
|
+
const history = await read(`${parcelPath}/parcelevents`);
|
|
51
|
+
return parseDpdUkHistory(detail, history, number, code);
|
|
52
|
+
} }]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const adapter = environment => {
|
|
56
|
+
const tracker = new DpdUkTracker({ fetcher: environment.fetcher, recorder: environment.recorder });
|
|
57
|
+
return { id: 'dpd-uk', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context) };
|
|
58
|
+
};
|
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
"timezone": "Europe/London",
|
|
17
17
|
"tracking": {
|
|
18
18
|
"mode": "automatic",
|
|
19
|
-
"adapter": "
|
|
20
|
-
"steps": [
|
|
19
|
+
"adapter": "dpd-uk",
|
|
20
|
+
"steps": [
|
|
21
|
+
"direct"
|
|
22
|
+
],
|
|
23
|
+
"localClocks": true
|
|
21
24
|
},
|
|
22
25
|
"capabilities": [
|
|
23
26
|
"history",
|
|
24
|
-
"location"
|
|
25
|
-
"eta"
|
|
27
|
+
"location"
|
|
26
28
|
],
|
|
27
29
|
"portal": {
|
|
28
30
|
"url": "https://track.dpd.co.uk/?reference={trackingNumber}",
|
|
@@ -33,8 +35,18 @@
|
|
|
33
35
|
"location",
|
|
34
36
|
"eta"
|
|
35
37
|
],
|
|
36
|
-
"scraped": [
|
|
37
|
-
|
|
38
|
+
"scraped": [
|
|
39
|
+
"status",
|
|
40
|
+
"history",
|
|
41
|
+
"location"
|
|
42
|
+
],
|
|
43
|
+
"discarded": [
|
|
44
|
+
"parcel_handle",
|
|
45
|
+
"merchant_reference",
|
|
46
|
+
"delivery_partner_reference",
|
|
47
|
+
"recipient_details",
|
|
48
|
+
"proof_of_delivery_images"
|
|
49
|
+
],
|
|
38
50
|
"unavailable": []
|
|
39
51
|
},
|
|
40
52
|
"links": [
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "dpd-uk",
|
|
3
|
-
"
|
|
4
|
-
|
|
3
|
+
"records": [
|
|
4
|
+
{
|
|
5
|
+
"number": "15504366056765",
|
|
6
|
+
"role": "shipment",
|
|
7
|
+
"evidence": "public_shipment_report",
|
|
8
|
+
"source": {
|
|
9
|
+
"url": "https://uk.trustpilot.com/review/primepaint.co.uk?page=3"
|
|
10
|
+
},
|
|
11
|
+
"expect": {
|
|
12
|
+
"carrier": "unknown",
|
|
13
|
+
"confidence": "low",
|
|
14
|
+
"candidates": ["dpd", "dpd-fr", "relais-colis", "ciblex", "seur", "brt", "delhivery", "dpd-de", "dpd-uk", "xpressbees"]
|
|
15
|
+
},
|
|
16
|
+
"note": "Prime Paint's public reply identifies this as DPD UK. A numeric shape alone does not identify the service. Tracking numbers can be reused; current history need not belong to the original report."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"number": "15504366057635",
|
|
20
|
+
"role": "shipment",
|
|
21
|
+
"evidence": "public_shipment_report",
|
|
22
|
+
"source": {
|
|
23
|
+
"url": "https://uk.trustpilot.com/review/primepaint.co.uk?page=3"
|
|
24
|
+
},
|
|
25
|
+
"expect": {
|
|
26
|
+
"carrier": "unknown",
|
|
27
|
+
"confidence": "low",
|
|
28
|
+
"candidates": ["dpd", "dpd-fr", "relais-colis", "ciblex", "seur", "brt", "delhivery", "dpd-de", "dpd-uk", "xpressbees"]
|
|
29
|
+
},
|
|
30
|
+
"note": "Prime Paint's public reply identifies this as DPD UK. A numeric shape alone does not identify the service. Tracking numbers can be reused; current history need not belong to the original report."
|
|
31
|
+
}
|
|
32
|
+
]
|
|
5
33
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "DPD UK";
|
|
3
|
+
export declare function normalizeDpdUkNumber(raw: string): string;
|
|
4
|
+
/** Resolve one exact parcel, keeping its issued code opaque and lookup-local. */
|
|
5
|
+
export declare function parseDpdUkReference(payload: unknown, number: string): string;
|
|
6
|
+
export declare function isDpdUkReferenceAbsent(payload: unknown): boolean;
|
|
7
|
+
export declare function validateDpdUkParcel(payload: unknown, number: string, code: string): Record<string, unknown>;
|
|
8
|
+
/** Events have no identity field; the adapter reads them under the exact validated detail handle. */
|
|
9
|
+
export declare function parseDpdUkHistory(detailPayload: unknown, historyPayload: unknown, raw: string, code: string): CarrierResult;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { classifyWording, languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
export const PROVIDER = 'DPD UK';
|
|
9
|
+
export function normalizeDpdUkNumber(raw) {
|
|
10
|
+
const number = normalizeTrackingNumber(raw);
|
|
11
|
+
if (!/^\d{14}$/.test(number))
|
|
12
|
+
throw new InvalidInputError(PROVIDER, 'DPD UK requires a fourteen-digit parcel number');
|
|
13
|
+
return number;
|
|
14
|
+
}
|
|
15
|
+
// The public client displays spaced digits followed by a printed check letter.
|
|
16
|
+
function parcelNumber(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
18
|
+
return null;
|
|
19
|
+
const match = /^(\d{14})[A-Z]?$/.exec(value.replace(/\s/g, '').toUpperCase());
|
|
20
|
+
return match?.[1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** Resolve one exact parcel, keeping its issued code opaque and lookup-local. */
|
|
23
|
+
export function parseDpdUkReference(payload, number) {
|
|
24
|
+
if (!isRecord(payload) || !Array.isArray(payload.data) || payload.data.length > 100)
|
|
25
|
+
throw new SchemaError(PROVIDER);
|
|
26
|
+
if (!payload.data.length)
|
|
27
|
+
throw new IndeterminateError(PROVIDER, 'DPD UK returned no matching parcel');
|
|
28
|
+
if (payload.data.length !== 1 || !isRecord(payload.data[0]) || parcelNumber(payload.data[0].parcelNumber) !== number) {
|
|
29
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different or ambiguous parcel');
|
|
30
|
+
}
|
|
31
|
+
const code = payload.data[0].parcelCode;
|
|
32
|
+
if (typeof code !== 'string' || !/^[A-Za-z0-9*_-]{1,128}$/.test(code))
|
|
33
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned an invalid parcel handle');
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
export function isDpdUkReferenceAbsent(payload) {
|
|
37
|
+
return isRecord(payload) && !Object.hasOwn(payload, 'data') && isRecord(payload.error)
|
|
38
|
+
&& payload.error.statusCode === 404 && payload.error.error === 'Not Found'
|
|
39
|
+
&& payload.error.message === 'Your reference number could not be found';
|
|
40
|
+
}
|
|
41
|
+
export function validateDpdUkParcel(payload, number, code) {
|
|
42
|
+
if (!isRecord(payload) || !isRecord(payload.data) || parcelNumber(payload.data.parcelNumber) !== number
|
|
43
|
+
|| payload.data.parcelCode !== code)
|
|
44
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different parcel');
|
|
45
|
+
return payload.data;
|
|
46
|
+
}
|
|
47
|
+
const VERIFIED_WORDING = {
|
|
48
|
+
'your parcel has been delivered': 'delivered',
|
|
49
|
+
'the parcel has been delivered': 'delivered',
|
|
50
|
+
'your parcel will be with you today': 'out_for_delivery',
|
|
51
|
+
'the parcel is on the vehicle for delivery': 'out_for_delivery',
|
|
52
|
+
'your parcel is at our depot': 'in_transit',
|
|
53
|
+
"we have your parcel and it's on its way to our depot": 'in_transit',
|
|
54
|
+
"we have your parcel and it's on its way to you": 'in_transit',
|
|
55
|
+
'we have x-rayed your parcel as part of our normal security procedures': 'in_transit',
|
|
56
|
+
"we've received your order details, but have not yet received your parcel": 'registered',
|
|
57
|
+
};
|
|
58
|
+
function classify(description) {
|
|
59
|
+
const wording = description.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim().replace(/[.!]+$/, '');
|
|
60
|
+
const stage = VERIFIED_WORDING[wording];
|
|
61
|
+
return stage ? { stage, source: 'carrier_map' } : classifyWording(description);
|
|
62
|
+
}
|
|
63
|
+
function publicDescription(value) {
|
|
64
|
+
const text = clean(value, 1000);
|
|
65
|
+
// A sender return is a shipment stage, rather than a recipient's identity.
|
|
66
|
+
if (/\bdelivered (?:back )?to (?:the )?(?:sender|shipper)\b/i.test(text))
|
|
67
|
+
return text;
|
|
68
|
+
return text.replace(/(\bdelivered)\s+(?:to\s+.+|and\s+signed\s+(?:for\s+)?by\s+.+|signed\s+(?:for\s+)?by\s+.+)$/i, '$1');
|
|
69
|
+
}
|
|
70
|
+
function clockFields(clock) {
|
|
71
|
+
const shape = /^(\d{4}-\d{2}-\d{2})[ T]((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(Z|[+-]\d{2}:?\d{2})?$/.exec(clock);
|
|
72
|
+
if (!shape)
|
|
73
|
+
return { provider_time_text: clock };
|
|
74
|
+
const iso = `${shape[1]}T${shape[2]}${shape[3] ?? ''}`;
|
|
75
|
+
if (!DateTime.fromISO(iso, { zone: 'UTC', setZone: true }).isValid)
|
|
76
|
+
return { provider_time_text: clock };
|
|
77
|
+
if (!shape[3])
|
|
78
|
+
return { local_time: `${shape[1]}T${shape[2]}` };
|
|
79
|
+
const offset = /[+-](\d{2}):?(\d{2})$/.exec(iso);
|
|
80
|
+
if (offset && (Number(offset[1]) > 14 || Number(offset[2]) > 59 || (Number(offset[1]) === 14 && Number(offset[2]) !== 0))) {
|
|
81
|
+
return { provider_time_text: clock };
|
|
82
|
+
}
|
|
83
|
+
const instant = explicitOffsetTime(iso);
|
|
84
|
+
return instant ? { time: instant.iso } : { provider_time_text: clock };
|
|
85
|
+
}
|
|
86
|
+
/** Events have no identity field; the adapter reads them under the exact validated detail handle. */
|
|
87
|
+
export function parseDpdUkHistory(detailPayload, historyPayload, raw, code) {
|
|
88
|
+
const number = normalizeDpdUkNumber(raw);
|
|
89
|
+
const detail = validateDpdUkParcel(detailPayload, number, code);
|
|
90
|
+
if (!isRecord(historyPayload) || !Array.isArray(historyPayload.data) || historyPayload.data.length > 1000)
|
|
91
|
+
throw new SchemaError(PROVIDER);
|
|
92
|
+
if (!historyPayload.data.length)
|
|
93
|
+
throw new IndeterminateError(PROVIDER, 'DPD UK returned no parcel history');
|
|
94
|
+
// The official page renders these rows newest first. Do not reorder uncertain clocks.
|
|
95
|
+
const events = historyPayload.data.map(row => {
|
|
96
|
+
if (!isRecord(row))
|
|
97
|
+
throw new SchemaError(PROVIDER);
|
|
98
|
+
if ((row.parcelNumber !== undefined && parcelNumber(row.parcelNumber) !== number)
|
|
99
|
+
|| (row.parcelCode !== undefined && row.parcelCode !== code))
|
|
100
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different scan identity');
|
|
101
|
+
const description = publicDescription(row.eventText);
|
|
102
|
+
const clock = clean(row.eventDate, 64);
|
|
103
|
+
if (!description || !clock || typeof row.eventDate !== 'string' || row.eventDate.length > 64)
|
|
104
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned an incomplete scan');
|
|
105
|
+
const classification = classify(description);
|
|
106
|
+
const location = clean(row.eventLocation, 200);
|
|
107
|
+
return { description, ...clockFields(clock), ...(location ? { location } : {}),
|
|
108
|
+
...(classification.source !== 'none' ? { stage: classification.stage, stage_source: classification.source } : {}) };
|
|
109
|
+
});
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const retained = events.filter(event => {
|
|
112
|
+
const key = JSON.stringify(event);
|
|
113
|
+
if (seen.has(key))
|
|
114
|
+
return false;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
return true;
|
|
117
|
+
}).slice(0, 100);
|
|
118
|
+
const latest = retained[0];
|
|
119
|
+
const summary = publicDescription(detail.trackingStatusCurrent);
|
|
120
|
+
const summaryClassification = classify(summary);
|
|
121
|
+
const current = summary ? summaryClassification : classify(latest.description);
|
|
122
|
+
return { status: current.source !== 'none' ? languageStageStatus(current.stage) : 'unknown',
|
|
123
|
+
...(current.source !== 'none' ? { current_stage: current.stage, current_stage_source: current.source } : {}),
|
|
124
|
+
last_status_text: summary || latest.description, last_update: latest.time ?? null,
|
|
125
|
+
...(latest.local_time ? { last_update_local: latest.local_time } : {}),
|
|
126
|
+
...(current.stage === 'delivered' && latest.stage === 'delivered' && latest.time ? { delivered_at: latest.time } : {}), events: retained };
|
|
127
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
export declare class EvriUkTracker {
|
|
4
|
+
private readonly options;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
executablePath?: string | null;
|
|
7
|
+
recorder?: StepRecorder;
|
|
8
|
+
});
|
|
9
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
10
|
+
}
|
|
11
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
2
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
3
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
4
|
+
import { withLocalBrowser } from '../../core/transport/localBrowser.js';
|
|
5
|
+
import { requestEvriUkInPage } from './browser.js';
|
|
6
|
+
import { normalizeEvriUkNumber, parseEvriUk } from './parser.js';
|
|
7
|
+
export class EvriUkTracker {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
fetch(raw, context = {}) {
|
|
13
|
+
const number = normalizeEvriUkNumber(raw);
|
|
14
|
+
const budgetMs = context.budgetMs ?? 45_000;
|
|
15
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > 60_000)
|
|
16
|
+
throw new TypeError('Evri UK budget must be between 1 and 60000 ms');
|
|
17
|
+
if (!this.options.executablePath)
|
|
18
|
+
throw new ChallengeError('Evri UK', 'Evri UK requires TRACKING_CHROMIUM_PATH');
|
|
19
|
+
return runSteps({ carrier: 'evri-uk', budgetMs, signal: context.signal, recorder: this.options.recorder ?? NOOP_RECORDER }, [{
|
|
20
|
+
id: 'browser', run: ({ signal, remainingMs: remaining }) => withLocalBrowser({ provider: 'Evri UK',
|
|
21
|
+
executablePath: this.options.executablePath, signal, timeoutMs: Math.max(1, Math.floor(remaining)),
|
|
22
|
+
args: ['--disable-blink-features=AutomationControlled'], }, async ({ browser, signal: session, remainingMs }) => {
|
|
23
|
+
try {
|
|
24
|
+
const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
|
|
25
|
+
: process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
|
|
26
|
+
const major = browser.version().split('.')[0];
|
|
27
|
+
const userAgent = `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
28
|
+
const browserContext = await browser.newContext({ locale: 'en-GB', userAgent, acceptDownloads: false, serviceWorkers: 'block' });
|
|
29
|
+
session.throwIfAborted();
|
|
30
|
+
await browserContext.route('**/*', async (route) => {
|
|
31
|
+
const url = new URL(route.request().url());
|
|
32
|
+
const allowed = url.protocol === 'https:' && (['www.evri.com', 'api.evri.com', 'tracking.platform-apis.evri.com'].includes(url.hostname)
|
|
33
|
+
|| url.hostname.endsWith('.edge.sdk.awswaf.com'));
|
|
34
|
+
await (allowed ? route.continue() : route.abort());
|
|
35
|
+
});
|
|
36
|
+
const page = await browserContext.newPage();
|
|
37
|
+
const response = await page.goto('https://www.evri.com/track-a-parcel', { waitUntil: 'domcontentloaded', timeout: Math.min(15_000, remainingMs()) });
|
|
38
|
+
session.throwIfAborted();
|
|
39
|
+
if (!response)
|
|
40
|
+
throw new TransportError('Evri UK', 'Evri UK tracking page is unavailable');
|
|
41
|
+
if ([401, 403].includes(response.status()))
|
|
42
|
+
throw new ChallengeError('Evri UK');
|
|
43
|
+
if (response.status() !== 200)
|
|
44
|
+
throw new UpstreamHttpError('Evri UK', response.status());
|
|
45
|
+
await page.waitForFunction(() => typeof globalThis.AwsWafIntegration?.getToken === 'function', undefined, { timeout: Math.min(10_000, remainingMs()) });
|
|
46
|
+
session.throwIfAborted();
|
|
47
|
+
const reply = await page.evaluate(requestEvriUkInPage, { number, budgetMs: remainingMs() });
|
|
48
|
+
session.throwIfAborted();
|
|
49
|
+
if (reply.kind === 'ok')
|
|
50
|
+
return parseEvriUk(reply.payload, number, reply.urn);
|
|
51
|
+
if (reply.kind === 'http') {
|
|
52
|
+
if ([401, 403, 405].includes(reply.status ?? 0))
|
|
53
|
+
throw new ChallengeError('Evri UK');
|
|
54
|
+
if (reply.status === 429)
|
|
55
|
+
throw new RateLimitedError('Evri UK', reply.retryAfter && /^\d+$/.test(reply.retryAfter) ? Number(reply.retryAfter) * 1_000 : undefined);
|
|
56
|
+
if ([404, 410].includes(reply.status ?? 0))
|
|
57
|
+
throw new TransportError('Evri UK', 'Evri UK tracking endpoint is unavailable');
|
|
58
|
+
throw new UpstreamHttpError('Evri UK', reply.status ?? 0);
|
|
59
|
+
}
|
|
60
|
+
if (['schema', 'identity', 'context'].includes(reply.kind))
|
|
61
|
+
throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking data');
|
|
62
|
+
if (['indeterminate', 'scope'].includes(reply.kind))
|
|
63
|
+
throw new IndeterminateError('Evri UK', 'Evri UK could not confirm a domestic parcel');
|
|
64
|
+
throw new TransportError('Evri UK', 'Evri UK browser tracking failed');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
session.throwIfAborted();
|
|
68
|
+
if (carrierErrorKind(error))
|
|
69
|
+
throw error;
|
|
70
|
+
// Browser failures can include private tracking or issued URLs.
|
|
71
|
+
throw new TransportError('Evri UK', 'Evri UK browser tracking failed');
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
}]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export const adapter = environment => {
|
|
78
|
+
const tracker = new EvriUkTracker({ executablePath: environment.browserExecutablePath, recorder: environment.recorder });
|
|
79
|
+
return { id: 'evri-uk', recordsSteps: true, steps: ['browser'], track: (input, context) => tracker.fetch(input.number, context) };
|
|
80
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Runs in the official page. No issued key or browser token leaves this context. */
|
|
2
|
+
export declare function requestEvriUkInPage({ number, budgetMs }: {
|
|
3
|
+
number: string;
|
|
4
|
+
budgetMs: number;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
kind: "context";
|
|
7
|
+
phase?: undefined;
|
|
8
|
+
urn?: undefined;
|
|
9
|
+
payload?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "indeterminate";
|
|
12
|
+
phase: string;
|
|
13
|
+
urn?: undefined;
|
|
14
|
+
payload?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "scope";
|
|
17
|
+
phase: string;
|
|
18
|
+
urn?: undefined;
|
|
19
|
+
payload?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "identity";
|
|
22
|
+
phase: string;
|
|
23
|
+
urn?: undefined;
|
|
24
|
+
payload?: undefined;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "ok";
|
|
27
|
+
urn: string;
|
|
28
|
+
payload: {
|
|
29
|
+
failures: never[];
|
|
30
|
+
results: {
|
|
31
|
+
uniqueId: string;
|
|
32
|
+
parcelIdentifiers: {
|
|
33
|
+
type: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}[];
|
|
36
|
+
trackingEvents: {
|
|
37
|
+
dateTime: string;
|
|
38
|
+
trackingPoint: {
|
|
39
|
+
trackingPointCode: string;
|
|
40
|
+
description: string;
|
|
41
|
+
};
|
|
42
|
+
trackingStage: {
|
|
43
|
+
trackingStageCode: string;
|
|
44
|
+
};
|
|
45
|
+
}[];
|
|
46
|
+
}[];
|
|
47
|
+
};
|
|
48
|
+
phase?: undefined;
|
|
49
|
+
} | {
|
|
50
|
+
retryAfter?: string | undefined;
|
|
51
|
+
status?: number | undefined;
|
|
52
|
+
kind: "http" | "schema" | "transport";
|
|
53
|
+
phase: string;
|
|
54
|
+
urn?: undefined;
|
|
55
|
+
payload?: undefined;
|
|
56
|
+
}>;
|