universal-parcel-scraper 0.5.0-main.324 → 0.7.0-main.326
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/data/carrier.schema.json +15 -0
- package/data/catalog.json +4 -2
- package/dist/browser/scraper.js +35 -23
- package/dist/carriers/canada-post/adapter.d.ts +1 -0
- package/dist/carriers/dhl-ecommerce/adapter.d.ts +1 -0
- package/dist/carriers/dhl-ecommerce/adapter.js +15 -4
- package/dist/carriers/dhl-ecommerce/carrier.json +3 -0
- package/dist/carriers/fedex/adapter.d.ts +1 -1
- package/dist/carriers/fedex/adapter.js +2 -0
- package/dist/carriers/fedex/carrier.json +4 -1
- package/dist/core/adapter/index.d.ts +8 -0
- package/dist/core/adapter/index.js +8 -0
- package/dist/core/catalog/recognition.d.ts +3 -2
- package/dist/core/catalog/recognition.js +9 -6
- package/dist/core/catalog/types.d.ts +2 -0
- package/dist/core/recognition/index.d.ts +4 -4
- package/dist/core/recognition/index.js +19 -5
- package/dist/core/result/index.d.ts +4 -0
- package/dist/core/result/index.js +2 -1
- package/dist/core/result/resolve.d.ts +1 -1
- package/dist/core/result/resolve.js +8 -3
- package/dist/data/catalog.json +4 -2
- package/dist/generated/catalog.d.ts +2 -0
- package/dist/generated/catalog.js +4 -2
- package/dist/providers/parcelsapp/adapter.js +2 -2
- package/dist/providers/postal-ninja/adapter.js +4 -3
- package/dist/providers/shared/result.d.ts +5 -0
- package/dist/providers/shared/result.js +23 -8
- package/dist/providers/upu/adapter.js +5 -3
- package/dist/server/openapi.json +4 -1
- package/package.json +1 -1
package/data/carrier.schema.json
CHANGED
|
@@ -172,6 +172,21 @@
|
|
|
172
172
|
},
|
|
173
173
|
"localClocks": {
|
|
174
174
|
"type": "boolean"
|
|
175
|
+
},
|
|
176
|
+
"browserRecognition": {
|
|
177
|
+
"description": "Present when the adapter implements recognizeWithBrowser(): opt-in browser confirmation without recipient inputs, requiring dated shipment activity. Higher rank is asked first after HTTP checks are inconclusive.",
|
|
178
|
+
"type": "object",
|
|
179
|
+
"additionalProperties": false,
|
|
180
|
+
"required": [
|
|
181
|
+
"rank"
|
|
182
|
+
],
|
|
183
|
+
"properties": {
|
|
184
|
+
"rank": {
|
|
185
|
+
"type": "integer",
|
|
186
|
+
"minimum": 1,
|
|
187
|
+
"maximum": 100
|
|
188
|
+
}
|
|
189
|
+
}
|
|
175
190
|
}
|
|
176
191
|
}
|
|
177
192
|
},
|
package/data/catalog.json
CHANGED
|
@@ -469,7 +469,8 @@
|
|
|
469
469
|
"tracking": {
|
|
470
470
|
"mode": "automatic",
|
|
471
471
|
"adapter": "dhl-ecommerce",
|
|
472
|
-
"recognitionRank": 23
|
|
472
|
+
"recognitionRank": 23,
|
|
473
|
+
"browserRecognitionRank": 90
|
|
473
474
|
},
|
|
474
475
|
"canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
|
|
475
476
|
"trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
|
|
@@ -675,7 +676,8 @@
|
|
|
675
676
|
"timezone": "UTC",
|
|
676
677
|
"tracking": {
|
|
677
678
|
"mode": "automatic",
|
|
678
|
-
"adapter": "fedex"
|
|
679
|
+
"adapter": "fedex",
|
|
680
|
+
"browserRecognitionRank": 100
|
|
679
681
|
},
|
|
680
682
|
"canaryUrl": "https://www.fedex.com/fedextrack/",
|
|
681
683
|
"trackingUrlTemplate": "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -480,7 +480,8 @@ var ue = {
|
|
|
480
480
|
tracking: {
|
|
481
481
|
mode: "automatic",
|
|
482
482
|
adapter: "dhl-ecommerce",
|
|
483
|
-
recognitionRank: 23
|
|
483
|
+
recognitionRank: 23,
|
|
484
|
+
browserRecognitionRank: 90
|
|
484
485
|
},
|
|
485
486
|
canaryUrl: "https://www.dhl.com/ch-en/home/tracking.html",
|
|
486
487
|
trackingUrlTemplate: "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
|
|
@@ -580,7 +581,8 @@ var ue = {
|
|
|
580
581
|
timezone: "UTC",
|
|
581
582
|
tracking: {
|
|
582
583
|
mode: "automatic",
|
|
583
|
-
adapter: "fedex"
|
|
584
|
+
adapter: "fedex",
|
|
585
|
+
browserRecognitionRank: 100
|
|
584
586
|
},
|
|
585
587
|
canaryUrl: "https://www.fedex.com/fedextrack/",
|
|
586
588
|
trackingUrlTemplate: "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}",
|
|
@@ -3449,6 +3451,7 @@ var et = /* @__PURE__ */ new Set([
|
|
|
3449
3451
|
"exception",
|
|
3450
3452
|
"unknown"
|
|
3451
3453
|
]), tt = new Set(fe), nt = [
|
|
3454
|
+
"current_stage_source",
|
|
3452
3455
|
"last_status_text",
|
|
3453
3456
|
"last_update",
|
|
3454
3457
|
"expected_delivery",
|
|
@@ -3465,7 +3468,8 @@ var et = /* @__PURE__ */ new Set([
|
|
|
3465
3468
|
"time",
|
|
3466
3469
|
"location",
|
|
3467
3470
|
"description",
|
|
3468
|
-
"stage"
|
|
3471
|
+
"stage",
|
|
3472
|
+
"stage_source"
|
|
3469
3473
|
];
|
|
3470
3474
|
function it(e, t) {
|
|
3471
3475
|
let n = (e) => typeof e == "string" && e.trim() ? Number(e) : e, r = n(e), i = n(t);
|
|
@@ -7381,8 +7385,8 @@ function ko(e, t = "in_transit") {
|
|
|
7381
7385
|
function Ao(e, t = "in_transit") {
|
|
7382
7386
|
return ko(e, t).stage;
|
|
7383
7387
|
}
|
|
7384
|
-
function jo(e, t) {
|
|
7385
|
-
return Eo.has(e) ? "carrier_map" : ko(t).source;
|
|
7388
|
+
function jo(e, t, n) {
|
|
7389
|
+
return Eo.has(e) ? typeof n == "string" && n.length <= 100 && (n === "none" || n === "carrier_map" || /^wording:[a-z0-9_]+$/.test(n)) ? n : "carrier_map" : ko(t).source;
|
|
7386
7390
|
}
|
|
7387
7391
|
function Mo(e) {
|
|
7388
7392
|
let t = at(e), n = Do(t), r = (t.events ?? []).map((e) => {
|
|
@@ -7390,7 +7394,7 @@ function Mo(e) {
|
|
|
7390
7394
|
return {
|
|
7391
7395
|
...e,
|
|
7392
7396
|
stage: t ?? n.stage,
|
|
7393
|
-
stage_source: t ? "
|
|
7397
|
+
stage_source: t ? jo(t, e.description ?? "", e.stage_source) : n.source,
|
|
7394
7398
|
instant: $a(e.time)?.iso ?? null
|
|
7395
7399
|
};
|
|
7396
7400
|
});
|
|
@@ -7465,46 +7469,54 @@ function zo(e, t = {}) {
|
|
|
7465
7469
|
carrier: t,
|
|
7466
7470
|
preferred: r.preferred === !0
|
|
7467
7471
|
}] : [];
|
|
7468
|
-
}) : [], o = r ? a.map(({ carrier: e }) => e) : n.candidates, s = r ? a.filter((e) => e.preferred).map(({ carrier: e }) => e) : n.preferred, c = new Set(s.filter((e) =>
|
|
7472
|
+
}) : [], o = r ? a.map(({ carrier: e }) => e) : n.candidates, s = r ? a.filter((e) => e.preferred).map(({ carrier: e }) => e) : n.preferred, c = t.phase === "browser" ? Object.fromEntries(Object.entries(_).map(([e, t]) => [e, t.tracking.browserRecognitionRank])) : Lo, l = new Set(s.filter((e) => c[e] === void 0).map((e) => Io(e)).filter(Boolean)), u = (e) => [
|
|
7469
7473
|
+(e === t.hint),
|
|
7470
7474
|
+!!s.includes(e),
|
|
7471
|
-
|
|
7475
|
+
c[e] ?? 0
|
|
7472
7476
|
];
|
|
7473
|
-
return o.filter((e) =>
|
|
7477
|
+
return o.filter((e) => c[e] !== void 0 && we.has(e) && xe(e) !== "universal" && !l.has(Io(e)) && !t.skip?.(e)).map((e) => ({
|
|
7474
7478
|
carrier: e,
|
|
7475
|
-
score:
|
|
7479
|
+
score: u(e)
|
|
7476
7480
|
})).sort((e, t) => t.score[0] - e.score[0] || t.score[1] - e.score[1] || t.score[2] - e.score[2]).map(({ carrier: t }) => ({
|
|
7477
7481
|
carrier: t,
|
|
7478
7482
|
needsInput: Ce(t, e)[0]?.field ?? null,
|
|
7479
7483
|
preferred: s.includes(t)
|
|
7480
|
-
}));
|
|
7484
|
+
})).filter((e) => t.phase !== "browser" || !e.needsInput);
|
|
7481
7485
|
}
|
|
7482
7486
|
//#endregion
|
|
7483
7487
|
//#region core/recognition/index.ts
|
|
7484
7488
|
var Bo = 5184e6;
|
|
7485
|
-
async function Vo(e, t, n) {
|
|
7486
|
-
let
|
|
7489
|
+
async function Vo(e, t, n, r) {
|
|
7490
|
+
let i = e.map((e) => ({
|
|
7487
7491
|
...e,
|
|
7488
7492
|
status: "failed",
|
|
7489
7493
|
lastActivityAt: null
|
|
7490
|
-
}))
|
|
7491
|
-
|
|
7492
|
-
|
|
7494
|
+
}));
|
|
7495
|
+
if (r?.throwIfAborted(), n <= 0 || e.length === 0) return i;
|
|
7496
|
+
let a = new AbortController(), o = {
|
|
7497
|
+
signal: a.signal,
|
|
7498
|
+
budgetMs: n
|
|
7499
|
+
}, s, c = () => void 0, l = new Promise((e) => {
|
|
7500
|
+
c = () => {
|
|
7501
|
+
a.abort(), e();
|
|
7502
|
+
}, s = setTimeout(c, n), r?.addEventListener("abort", c, { once: !0 });
|
|
7503
|
+
}), u = e.map(async (e, n) => {
|
|
7493
7504
|
try {
|
|
7494
|
-
let
|
|
7495
|
-
|
|
7505
|
+
let r = await t(e.carrier, o);
|
|
7506
|
+
if (a.signal.aborted) return;
|
|
7507
|
+
i[n] = {
|
|
7496
7508
|
...e,
|
|
7497
|
-
status:
|
|
7498
|
-
lastActivityAt:
|
|
7509
|
+
status: r.known ? "known" : "unknown",
|
|
7510
|
+
lastActivityAt: r.lastActivityAt ?? null
|
|
7499
7511
|
};
|
|
7500
7512
|
} catch {}
|
|
7501
7513
|
});
|
|
7502
7514
|
try {
|
|
7503
|
-
await Promise.race([Promise.all(
|
|
7515
|
+
await Promise.race([Promise.all(u), l]);
|
|
7504
7516
|
} finally {
|
|
7505
|
-
clearTimeout(
|
|
7517
|
+
clearTimeout(s), r?.removeEventListener("abort", c);
|
|
7506
7518
|
}
|
|
7507
|
-
return r.map((e) => ({ ...e }));
|
|
7519
|
+
return r?.throwIfAborted(), i.map((e) => ({ ...e }));
|
|
7508
7520
|
}
|
|
7509
7521
|
function Ho(e, t) {
|
|
7510
7522
|
let n = Date.parse(e.lastActivityAt ?? "");
|
|
@@ -18,6 +18,7 @@ export declare class CanadaPostTracker {
|
|
|
18
18
|
canonical_tracking_number?: string;
|
|
19
19
|
status?: import("../../index.js").CarrierStatus;
|
|
20
20
|
current_stage?: string;
|
|
21
|
+
current_stage_source?: string;
|
|
21
22
|
last_status_text?: string | null;
|
|
22
23
|
last_update?: string | null;
|
|
23
24
|
expected_delivery?: string | null;
|
|
@@ -48,5 +48,6 @@ export declare class DHLEcommerceTracker {
|
|
|
48
48
|
recognize(trackingNumber: string, context?: TrackingContext): Promise<Recognition>;
|
|
49
49
|
private direct;
|
|
50
50
|
private browser;
|
|
51
|
+
recognizeWithBrowser(number: string, context?: TrackingContext, previousError?: unknown): Promise<import("../../core/adapter/index.js").BrowserRecognition>;
|
|
51
52
|
}
|
|
52
53
|
export declare const adapter: AdapterFactory;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* accepted, and the id itself is never retained.
|
|
8
8
|
*/
|
|
9
9
|
import { DateTime } from 'luxon';
|
|
10
|
-
import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
10
|
+
import { accepted, lookupBudget, recognizeFromBrowserLookup, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
11
11
|
import { ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
12
12
|
import { runSteps, singleFlight, takeTurn } from '../../core/runner/index.js';
|
|
13
13
|
import { countryTimeZone } from '../../core/time/index.js';
|
|
@@ -22,6 +22,11 @@ const WEBTRACK_API = 'https://api.dhlecs.com/webtrack/v4/tracking';
|
|
|
22
22
|
const DIRECT_TIMEOUT_MS = 15_000;
|
|
23
23
|
/** Statuses DHL answers with while its challenge is unsolved. */
|
|
24
24
|
const CHALLENGE_STATUSES = [401, 403, 419, 428];
|
|
25
|
+
function browserRecoveryAllowed(error) {
|
|
26
|
+
return error instanceof ChallengeError || error instanceof UpstreamNetworkError
|
|
27
|
+
|| error instanceof UpstreamHttpError && error.status >= 500
|
|
28
|
+
|| error instanceof IndeterminateError && ['webtrack_not_found', 'webtrack_no_history'].includes(error.reason ?? '');
|
|
29
|
+
}
|
|
25
30
|
/**
|
|
26
31
|
* UTAPI strings occasionally carry markup. Tags are dropped without a
|
|
27
32
|
* separator, as this adapter has always done, before the shared cleaner
|
|
@@ -222,9 +227,7 @@ export class DHLEcommerceTracker {
|
|
|
222
227
|
const timeoutMs = this.options.timeoutMs ?? 45_000;
|
|
223
228
|
return takeTurn(this.serialize, PROVIDER, context, ({ signal, budgetMs }) => runSteps({ carrier: 'dhl-ecommerce', budgetMs: budgetMs ?? this.options.budgetMs ?? timeoutMs + 15_000, signal, recorder: this.recorder }, [
|
|
224
229
|
{ id: 'direct', run: (step) => this.direct(number, Math.min(DIRECT_TIMEOUT_MS, step.remainingMs), step.signal) },
|
|
225
|
-
{ id: 'browser', recovers:
|
|
226
|
-
|| error instanceof UpstreamHttpError && error.status >= 500
|
|
227
|
-
|| error instanceof IndeterminateError && ['webtrack_not_found', 'webtrack_no_history'].includes(error.reason ?? ''),
|
|
230
|
+
{ id: 'browser', recovers: browserRecoveryAllowed,
|
|
228
231
|
run: (step) => this.browser(number, Math.max(1, Math.floor(Math.min(timeoutMs, step.remainingMs))), step.signal) },
|
|
229
232
|
]));
|
|
230
233
|
}
|
|
@@ -277,6 +280,13 @@ export class DHLEcommerceTracker {
|
|
|
277
280
|
throw error;
|
|
278
281
|
}
|
|
279
282
|
}
|
|
283
|
+
async recognizeWithBrowser(number, context = {}, previousError) {
|
|
284
|
+
if (previousError !== undefined && !browserRecoveryAllowed(previousError)) {
|
|
285
|
+
throw previousError instanceof Error ? previousError : new Error('DHL eCommerce HTTP recognition cannot recover through a browser');
|
|
286
|
+
}
|
|
287
|
+
const normalized = normalizeDHLEcommerceNumber(number);
|
|
288
|
+
return takeTurn(this.serialize, PROVIDER, context, (step) => recognizeFromBrowserLookup(() => runSteps({ carrier: 'dhl-ecommerce', budgetMs: step.budgetMs ?? 20_000, signal: step.signal, recorder: this.recorder }, [{ id: 'browser', run: ({ signal, remainingMs }) => this.browser(normalized, remainingMs, signal) }])));
|
|
289
|
+
}
|
|
280
290
|
}
|
|
281
291
|
export const adapter = (environment) => {
|
|
282
292
|
const tracker = new DHLEcommerceTracker({
|
|
@@ -288,5 +298,6 @@ export const adapter = (environment) => {
|
|
|
288
298
|
steps: ['direct', 'browser'],
|
|
289
299
|
track: (input, context) => tracker.fetch(input.number, context),
|
|
290
300
|
recognize: (number, context) => tracker.recognize(number, context),
|
|
301
|
+
recognizeWithBrowser: (number, context, previousError) => tracker.recognizeWithBrowser(number, context, previousError),
|
|
291
302
|
};
|
|
292
303
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
2
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
3
|
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
4
|
import { TrawlClient } from '../../core/transport/index.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { load } from 'cheerio';
|
|
2
|
+
import { recognizeFromBrowserLookup } from '../../core/adapter/index.js';
|
|
2
3
|
import { ChallengeError, InputRequiredError, InvalidInputError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError, } from '../../core/errors/index.js';
|
|
3
4
|
import { runSteps } from '../../core/runner/index.js';
|
|
4
5
|
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
@@ -310,5 +311,6 @@ export const adapter = (environment) => {
|
|
|
310
311
|
// Browser-backed direct tracking; universal recovery belongs to the caller.
|
|
311
312
|
steps: ['trawl'],
|
|
312
313
|
track: (input, context) => tracker.fetch(input.number, context),
|
|
314
|
+
recognizeWithBrowser: (number, context) => recognizeFromBrowserLookup(() => tracker.fetch(number, context)),
|
|
313
315
|
};
|
|
314
316
|
};
|
|
@@ -57,6 +57,10 @@ export interface Recognition {
|
|
|
57
57
|
/** The newest activity the check saw, when it reports one; old parcels can share a reused number. */
|
|
58
58
|
lastActivityAt?: string | null;
|
|
59
59
|
}
|
|
60
|
+
/** Browser confirmation retains the lookup so a consumer can reuse its history. */
|
|
61
|
+
export interface BrowserRecognition extends Recognition {
|
|
62
|
+
result?: CarrierResult;
|
|
63
|
+
}
|
|
60
64
|
export interface CarrierAdapter {
|
|
61
65
|
readonly id: string;
|
|
62
66
|
/** The tiers this adapter can go through, in order; telemetry labels use these ids. */
|
|
@@ -71,6 +75,8 @@ export interface CarrierAdapter {
|
|
|
71
75
|
* other failure throws.
|
|
72
76
|
*/
|
|
73
77
|
recognize?(number: string, context?: TrackingContext): Promise<Recognition>;
|
|
78
|
+
/** Opt-in confirmation through the adapter's browser path, without recipient inputs. */
|
|
79
|
+
recognizeWithBrowser?(number: string, context?: TrackingContext, previousError?: unknown): Promise<BrowserRecognition>;
|
|
74
80
|
}
|
|
75
81
|
export type AdapterFactory = (environment: AdapterEnvironment) => CarrierAdapter;
|
|
76
82
|
/**
|
|
@@ -83,6 +89,8 @@ export type AdapterFactory = (environment: AdapterEnvironment) => CarrierAdapter
|
|
|
83
89
|
/** Whether a number check passes: false when it rejects the number. */
|
|
84
90
|
export declare function accepted(check: () => unknown): boolean;
|
|
85
91
|
export declare function recognizeFromLookup(lookup: () => Promise<CarrierResult>, accepts?: () => boolean): Promise<Recognition>;
|
|
92
|
+
/** Browser shells and undated default statuses do not establish a shipment. */
|
|
93
|
+
export declare function recognizeFromBrowserLookup(lookup: () => Promise<CarrierResult>): Promise<BrowserRecognition>;
|
|
86
94
|
/** Registered adapter factories plus the carrier → adapter mapping, as generated. */
|
|
87
95
|
export interface RegistryDefinition {
|
|
88
96
|
factories: Readonly<Record<string, AdapterFactory>>;
|
|
@@ -84,6 +84,14 @@ export async function recognizeFromLookup(lookup, accepts = () => true) {
|
|
|
84
84
|
: Number.isFinite(updated) ? new Date(updated).toISOString() : null,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/** Browser shells and undated default statuses do not establish a shipment. */
|
|
88
|
+
export async function recognizeFromBrowserLookup(lookup) {
|
|
89
|
+
let result;
|
|
90
|
+
const answer = await recognizeFromLookup(async () => (result = await lookup()));
|
|
91
|
+
if (!answer.known || !answer.lastActivityAt || !result)
|
|
92
|
+
return { known: false, lastActivityAt: null };
|
|
93
|
+
return { ...answer, result };
|
|
94
|
+
}
|
|
87
95
|
/**
|
|
88
96
|
* One adapter instance per adapter id for the process lifetime, created on
|
|
89
97
|
* first use so a broken provider module cannot prevent unrelated carriers
|
|
@@ -11,13 +11,14 @@ export interface RecognitionCandidate {
|
|
|
11
11
|
/**
|
|
12
12
|
* The low-confidence candidates worth asking, best first: the carrier a
|
|
13
13
|
* universal provider named, then the ones number evidence backs, then the
|
|
14
|
-
* catalog's popularity rank.
|
|
15
|
-
*
|
|
14
|
+
* catalog's popularity rank. HTTP is the default; `phase: 'browser'` selects
|
|
15
|
+
* the separate opt-in browser catalog. A high-confidence dedicated carrier needs no recognition; the
|
|
16
16
|
* unknown postal carrier still needs a direct carrier to confirm it.
|
|
17
17
|
*/
|
|
18
18
|
export declare function recognitionCandidates(number: string, options?: {
|
|
19
19
|
hint?: string;
|
|
20
20
|
skip?: (carrier: string) => boolean;
|
|
21
|
+
phase?: 'http' | 'browser';
|
|
21
22
|
}): RecognitionCandidate[];
|
|
22
23
|
/** The carriers the detect route asks about a number, best first; empty when none can answer. */
|
|
23
24
|
export declare function recognitionAskedCarriers(number: string): string[];
|
|
@@ -17,8 +17,8 @@ export const MAX_RECOGNITIONS = 5;
|
|
|
17
17
|
/**
|
|
18
18
|
* The low-confidence candidates worth asking, best first: the carrier a
|
|
19
19
|
* universal provider named, then the ones number evidence backs, then the
|
|
20
|
-
* catalog's popularity rank.
|
|
21
|
-
*
|
|
20
|
+
* catalog's popularity rank. HTTP is the default; `phase: 'browser'` selects
|
|
21
|
+
* the separate opt-in browser catalog. A high-confidence dedicated carrier needs no recognition; the
|
|
22
22
|
* unknown postal carrier still needs a direct carrier to confirm it.
|
|
23
23
|
*/
|
|
24
24
|
export function recognitionCandidates(number, options = {}) {
|
|
@@ -43,19 +43,22 @@ export function recognitionCandidates(number, options = {}) {
|
|
|
43
43
|
const candidates = unknownPostalCarrier ? postalMatches.map(({ carrier }) => carrier) : detected.candidates;
|
|
44
44
|
const preferred = unknownPostalCarrier
|
|
45
45
|
? postalMatches.filter((match) => match.preferred).map(({ carrier }) => carrier) : detected.preferred;
|
|
46
|
+
const ranks = options.phase === 'browser'
|
|
47
|
+
? Object.fromEntries(Object.entries(CARRIER_DEFINITIONS).map(([id, definition]) => [id, definition.tracking.browserRecognitionRank]))
|
|
48
|
+
: CARRIER_RECOGNITION_RANKS;
|
|
46
49
|
// A carrier the number points to but that cannot be asked (DPD France) keeps
|
|
47
50
|
// its brand's other networks out: DPD's guest API also answers for DPD
|
|
48
51
|
// France parcels, and would file one under DPD Switzerland.
|
|
49
52
|
const shadowed = new Set(preferred
|
|
50
|
-
.filter((carrier) =>
|
|
53
|
+
.filter((carrier) => ranks[carrier] === undefined)
|
|
51
54
|
.map((carrier) => carrierBrand(carrier)).filter(Boolean));
|
|
52
55
|
const score = (carrier) => [
|
|
53
56
|
carrier === options.hint ? 1 : 0,
|
|
54
57
|
preferred.includes(carrier) ? 1 : 0,
|
|
55
|
-
|
|
58
|
+
ranks[carrier] ?? 0,
|
|
56
59
|
];
|
|
57
60
|
return candidates
|
|
58
|
-
.filter((carrier) =>
|
|
61
|
+
.filter((carrier) => ranks[carrier] !== undefined && AUTOMATIC_CARRIER_IDS.has(carrier)
|
|
59
62
|
&& carrierAdapter(carrier) !== 'universal' && !shadowed.has(carrierBrand(carrier)) && !options.skip?.(carrier))
|
|
60
63
|
.map((carrier) => ({ carrier, score: score(carrier) }))
|
|
61
64
|
// Array#sort is stable: equal scores keep the catalog order.
|
|
@@ -64,7 +67,7 @@ export function recognitionCandidates(number, options = {}) {
|
|
|
64
67
|
carrier,
|
|
65
68
|
needsInput: requiredRequirements(carrier, number)[0]?.field ?? null,
|
|
66
69
|
preferred: preferred.includes(carrier),
|
|
67
|
-
}));
|
|
70
|
+
})).filter((candidate) => options.phase !== 'browser' || !candidate.needsInput);
|
|
68
71
|
}
|
|
69
72
|
/** The carriers the detect route asks about a number, best first; empty when none can answer. */
|
|
70
73
|
export function recognitionAskedCarriers(number) {
|
|
@@ -95,6 +95,8 @@ export interface CarrierDefinition {
|
|
|
95
95
|
requirements?: readonly CarrierCatalogRequirement[];
|
|
96
96
|
/** Present when the adapter can recognize a number; higher is asked first. */
|
|
97
97
|
recognitionRank?: number;
|
|
98
|
+
/** Opt-in browser confirmation; higher is asked first after HTTP is inconclusive. */
|
|
99
|
+
browserRecognitionRank?: number;
|
|
98
100
|
refresh?: {
|
|
99
101
|
minMinutes: number;
|
|
100
102
|
afterFailureMinutes?: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Recognition } from '../adapter/index.js';
|
|
1
|
+
import type { Recognition, TrackingContext } from '../adapter/index.js';
|
|
2
2
|
import type { RecognitionCandidate } from '../catalog/recognition.js';
|
|
3
3
|
export { MAX_RECOGNITIONS, recognitionCandidates, type RecognitionCandidate, } from '../catalog/recognition.js';
|
|
4
4
|
export type RecognitionStatus = 'known' | 'unknown' | 'failed';
|
|
@@ -8,10 +8,10 @@ export interface RecognitionOutcome extends RecognitionCandidate {
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* Ask every candidate at once. A candidate that throws, or has not answered
|
|
11
|
-
* when the budget runs out, is `failed
|
|
12
|
-
*
|
|
11
|
+
* when the budget runs out, is `failed`. The callback receives the deadline
|
|
12
|
+
* and cancellation signal; answers after either are ignored.
|
|
13
13
|
*/
|
|
14
|
-
export declare function recognizeAll(candidates: readonly RecognitionCandidate[], recognize: (carrier: string) => Promise<Recognition>, budgetMs: number): Promise<RecognitionOutcome[]>;
|
|
14
|
+
export declare function recognizeAll(candidates: readonly RecognitionCandidate[], recognize: (carrier: string, context: TrackingContext) => Promise<Recognition>, budgetMs: number, signal?: AbortSignal): Promise<RecognitionOutcome[]>;
|
|
15
15
|
/**
|
|
16
16
|
* The carrier the answers settle on: the only recent one, else the only one
|
|
17
17
|
* number evidence backs, else, when every answer comes from one brand's
|
|
@@ -14,16 +14,28 @@ export { MAX_RECOGNITIONS, recognitionCandidates, } from '../catalog/recognition
|
|
|
14
14
|
const RECENT_ACTIVITY_MS = 60 * 24 * 3_600_000;
|
|
15
15
|
/**
|
|
16
16
|
* Ask every candidate at once. A candidate that throws, or has not answered
|
|
17
|
-
* when the budget runs out, is `failed
|
|
18
|
-
*
|
|
17
|
+
* when the budget runs out, is `failed`. The callback receives the deadline
|
|
18
|
+
* and cancellation signal; answers after either are ignored.
|
|
19
19
|
*/
|
|
20
|
-
export async function recognizeAll(candidates, recognize, budgetMs) {
|
|
20
|
+
export async function recognizeAll(candidates, recognize, budgetMs, signal) {
|
|
21
21
|
const outcomes = candidates.map((candidate) => ({ ...candidate, status: 'failed', lastActivityAt: null }));
|
|
22
|
+
signal?.throwIfAborted();
|
|
23
|
+
if (budgetMs <= 0 || candidates.length === 0)
|
|
24
|
+
return outcomes;
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const context = { signal: controller.signal, budgetMs };
|
|
22
27
|
let timer;
|
|
23
|
-
|
|
28
|
+
let stop = () => undefined;
|
|
29
|
+
const deadline = new Promise((resolve) => {
|
|
30
|
+
stop = () => { controller.abort(); resolve(); };
|
|
31
|
+
timer = setTimeout(stop, budgetMs);
|
|
32
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
33
|
+
});
|
|
24
34
|
const asked = candidates.map(async (candidate, index) => {
|
|
25
35
|
try {
|
|
26
|
-
const answer = await recognize(candidate.carrier);
|
|
36
|
+
const answer = await recognize(candidate.carrier, context);
|
|
37
|
+
if (controller.signal.aborted)
|
|
38
|
+
return;
|
|
27
39
|
outcomes[index] = { ...candidate, status: answer.known ? 'known' : 'unknown', lastActivityAt: answer.lastActivityAt ?? null };
|
|
28
40
|
}
|
|
29
41
|
catch {
|
|
@@ -35,7 +47,9 @@ export async function recognizeAll(candidates, recognize, budgetMs) {
|
|
|
35
47
|
}
|
|
36
48
|
finally {
|
|
37
49
|
clearTimeout(timer);
|
|
50
|
+
signal?.removeEventListener('abort', stop);
|
|
38
51
|
}
|
|
52
|
+
signal?.throwIfAborted();
|
|
39
53
|
return outcomes.map((outcome) => ({ ...outcome }));
|
|
40
54
|
}
|
|
41
55
|
function recent(outcome, now) {
|
|
@@ -15,6 +15,8 @@ export interface CarrierEvent extends JsonObject {
|
|
|
15
15
|
location?: string;
|
|
16
16
|
description?: string;
|
|
17
17
|
stage?: string;
|
|
18
|
+
/** Explicit map, wording rule, or unresolved fallback used by the adapter. */
|
|
19
|
+
stage_source?: string;
|
|
18
20
|
provider_code?: string;
|
|
19
21
|
/**
|
|
20
22
|
* Where the carrier itself puts the scanning facility, when it says so. The
|
|
@@ -29,6 +31,8 @@ export interface EventPoint extends JsonObject {
|
|
|
29
31
|
export interface CarrierResult extends JsonObject {
|
|
30
32
|
status?: CarrierStatus;
|
|
31
33
|
current_stage?: string;
|
|
34
|
+
/** How the adapter chose current_stage, when it records that decision. */
|
|
35
|
+
current_stage_source?: string;
|
|
32
36
|
last_status_text?: string | null;
|
|
33
37
|
last_update?: string | null;
|
|
34
38
|
expected_delivery?: string | null;
|
|
@@ -20,6 +20,7 @@ const STATUSES = new Set([
|
|
|
20
20
|
]);
|
|
21
21
|
const CURRENT_STAGES = new Set(STAGES);
|
|
22
22
|
const OPTIONAL_TEXT_FIELDS = [
|
|
23
|
+
'current_stage_source',
|
|
23
24
|
'last_status_text',
|
|
24
25
|
'last_update',
|
|
25
26
|
'expected_delivery',
|
|
@@ -33,7 +34,7 @@ const OPTIONAL_TEXT_FIELDS = [
|
|
|
33
34
|
'international_tracking_number',
|
|
34
35
|
'timezone',
|
|
35
36
|
];
|
|
36
|
-
const EVENT_TEXT_FIELDS = ['time', 'location', 'description', 'stage'];
|
|
37
|
+
const EVENT_TEXT_FIELDS = ['time', 'location', 'description', 'stage', 'stage_source'];
|
|
37
38
|
/** A carrier's coordinates for a scan, or null when they are not a usable point. */
|
|
38
39
|
export function eventPoint(latitude, longitude) {
|
|
39
40
|
const number = (value) => typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
@@ -4,7 +4,7 @@ export declare function resultStage(result: CarrierResult): Stage | null;
|
|
|
4
4
|
export declare function resultHasUpdate(result: CarrierResult): boolean;
|
|
5
5
|
export declare function classifyStage(text: string, fallback?: string): import("../status/wording.js").ClassifiedWording;
|
|
6
6
|
export declare function inferStage(text: string, fallback?: string): string;
|
|
7
|
-
export declare function stageSource(declaredStage: string, description: string): string;
|
|
7
|
+
export declare function stageSource(declaredStage: string, description: string, source?: unknown): string;
|
|
8
8
|
export interface ResolvedEvent extends CarrierEvent {
|
|
9
9
|
stage: Stage;
|
|
10
10
|
stage_source: string;
|
|
@@ -31,8 +31,13 @@ export function classifyStage(text, fallback = 'in_transit') {
|
|
|
31
31
|
export function inferStage(text, fallback = 'in_transit') {
|
|
32
32
|
return classifyStage(text, fallback).stage;
|
|
33
33
|
}
|
|
34
|
-
export function stageSource(declaredStage, description) {
|
|
35
|
-
|
|
34
|
+
export function stageSource(declaredStage, description, source) {
|
|
35
|
+
if (!stages.has(declaredStage))
|
|
36
|
+
return classifyStage(description).source;
|
|
37
|
+
if (typeof source === 'string' && source.length <= 100
|
|
38
|
+
&& (source === 'none' || source === 'carrier_map' || /^wording:[a-z0-9_]+$/.test(source)))
|
|
39
|
+
return source;
|
|
40
|
+
return 'carrier_map';
|
|
36
41
|
}
|
|
37
42
|
/** Classify wording and expose verified instants while retaining the feed's clocks. */
|
|
38
43
|
export function resolveResult(input) {
|
|
@@ -42,7 +47,7 @@ export function resolveResult(input) {
|
|
|
42
47
|
const declared = stages.has(event.stage ?? '') ? event.stage : undefined;
|
|
43
48
|
const classified = classifyWording(event.description ?? '', 'in_transit');
|
|
44
49
|
return { ...event, stage: declared ?? classified.stage,
|
|
45
|
-
stage_source: declared ? '
|
|
50
|
+
stage_source: declared ? stageSource(declared, event.description ?? '', event.stage_source) : classified.source,
|
|
46
51
|
instant: explicitOffsetTime(event.time)?.iso ?? null };
|
|
47
52
|
});
|
|
48
53
|
return { ...result, ...(current ? { current_stage: current } : {}), events };
|
package/dist/data/catalog.json
CHANGED
|
@@ -469,7 +469,8 @@
|
|
|
469
469
|
"tracking": {
|
|
470
470
|
"mode": "automatic",
|
|
471
471
|
"adapter": "dhl-ecommerce",
|
|
472
|
-
"recognitionRank": 23
|
|
472
|
+
"recognitionRank": 23,
|
|
473
|
+
"browserRecognitionRank": 90
|
|
473
474
|
},
|
|
474
475
|
"canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
|
|
475
476
|
"trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
|
|
@@ -675,7 +676,8 @@
|
|
|
675
676
|
"timezone": "UTC",
|
|
676
677
|
"tracking": {
|
|
677
678
|
"mode": "automatic",
|
|
678
|
-
"adapter": "fedex"
|
|
679
|
+
"adapter": "fedex",
|
|
680
|
+
"browserRecognitionRank": 100
|
|
679
681
|
},
|
|
680
682
|
"canaryUrl": "https://www.fedex.com/fedextrack/",
|
|
681
683
|
"trackingUrlTemplate": "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}",
|
|
@@ -309,6 +309,7 @@ export declare const CARRIER_CATALOG: {
|
|
|
309
309
|
readonly mode: "automatic";
|
|
310
310
|
readonly adapter: "dhl-ecommerce";
|
|
311
311
|
readonly recognitionRank: 23;
|
|
312
|
+
readonly browserRecognitionRank: 90;
|
|
312
313
|
};
|
|
313
314
|
readonly canaryUrl: "https://www.dhl.com/ch-en/home/tracking.html";
|
|
314
315
|
readonly trackingUrlTemplate: "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1";
|
|
@@ -398,6 +399,7 @@ export declare const CARRIER_CATALOG: {
|
|
|
398
399
|
readonly tracking: {
|
|
399
400
|
readonly mode: "automatic";
|
|
400
401
|
readonly adapter: "fedex";
|
|
402
|
+
readonly browserRecognitionRank: 100;
|
|
401
403
|
};
|
|
402
404
|
readonly canaryUrl: "https://www.fedex.com/fedextrack/";
|
|
403
405
|
readonly trackingUrlTemplate: "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}";
|
|
@@ -470,7 +470,8 @@ export const CARRIER_CATALOG = {
|
|
|
470
470
|
"tracking": {
|
|
471
471
|
"mode": "automatic",
|
|
472
472
|
"adapter": "dhl-ecommerce",
|
|
473
|
-
"recognitionRank": 23
|
|
473
|
+
"recognitionRank": 23,
|
|
474
|
+
"browserRecognitionRank": 90
|
|
474
475
|
},
|
|
475
476
|
"canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
|
|
476
477
|
"trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
|
|
@@ -676,7 +677,8 @@ export const CARRIER_CATALOG = {
|
|
|
676
677
|
"timezone": "UTC",
|
|
677
678
|
"tracking": {
|
|
678
679
|
"mode": "automatic",
|
|
679
|
-
"adapter": "fedex"
|
|
680
|
+
"adapter": "fedex",
|
|
681
|
+
"browserRecognitionRank": 100
|
|
680
682
|
},
|
|
681
683
|
"canaryUrl": "https://www.fedex.com/fedextrack/",
|
|
682
684
|
"trackingUrlTemplate": "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}",
|
|
@@ -225,7 +225,7 @@ function parseHistory(payload, trackingNumber, timezone = null) {
|
|
|
225
225
|
scanCarriers.add(name);
|
|
226
226
|
}
|
|
227
227
|
if (parsed && scan)
|
|
228
|
-
scans.push({ event: Object.assign(parsed, { stage: scan.stage }), scan });
|
|
228
|
+
scans.push({ event: Object.assign(parsed, { stage: scan.stage, stage_source: 'carrier_map' }), scan });
|
|
229
229
|
}
|
|
230
230
|
markReturnLeg(scans);
|
|
231
231
|
if (!events.length) {
|
|
@@ -314,7 +314,7 @@ export function parseParcelsAppHtml(html, trackingNumber, timezone = null) {
|
|
|
314
314
|
if (parsed)
|
|
315
315
|
events.push(parsed);
|
|
316
316
|
if (parsed && scan)
|
|
317
|
-
scans.push({ event: Object.assign(parsed, { stage: scan.stage }), scan });
|
|
317
|
+
scans.push({ event: Object.assign(parsed, { stage: scan.stage, stage_source: 'carrier_map' }), scan });
|
|
318
318
|
});
|
|
319
319
|
markReturnLeg(scans);
|
|
320
320
|
return { ...result(events, SOURCE), ...(undated ? { undated_event_count: undated } : {}),
|
|
@@ -14,7 +14,7 @@ import { runSteps } from '../../core/runner/index.js';
|
|
|
14
14
|
import { scrapeUniversalPage } from '../../core/transport/browser.js';
|
|
15
15
|
import { isRecord } from '../../core/types.js';
|
|
16
16
|
import { capturedBodies, captureFailure, loadCapture } from '../shared/capture.js';
|
|
17
|
-
import {
|
|
17
|
+
import { classifyEvent, event, hasPrivateDeliveryDetails, isNotice, numberOf, result, text } from '../shared/result.js';
|
|
18
18
|
const SOURCE = 'Postal Ninja';
|
|
19
19
|
const GET_API = 'https://postal.ninja/track/get';
|
|
20
20
|
const CHECK_API = 'https://postal.ninja/track/check';
|
|
@@ -73,10 +73,11 @@ export function parsePostalNinjaResponse(payload, trackingNumber) {
|
|
|
73
73
|
events.push(parsed);
|
|
74
74
|
}
|
|
75
75
|
else {
|
|
76
|
-
const
|
|
76
|
+
const classified = classifyEvent(description);
|
|
77
|
+
const { stage } = classified;
|
|
77
78
|
if (stage !== 'delivered' && hasPrivateDeliveryDetails(description))
|
|
78
79
|
continue;
|
|
79
|
-
events.push({ local_time: raw.dt, description: stage === 'delivered' ? 'Delivered' : description,
|
|
80
|
+
events.push({ local_time: raw.dt, description: stage === 'delivered' ? 'Delivered' : description, ...classified });
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
return result(events, SOURCE, true);
|
|
@@ -6,6 +6,11 @@ export declare function text(value: unknown): string;
|
|
|
6
6
|
export declare function isNotice(description: string): boolean;
|
|
7
7
|
export declare function hasPrivateDeliveryDetails(description: string): boolean;
|
|
8
8
|
export declare function eventStage(description: string): Stage | undefined;
|
|
9
|
+
/** Retain the decision before delivery wording is redacted or a stage is projected. */
|
|
10
|
+
export declare function classifyEvent(description: string, declared?: Stage): {
|
|
11
|
+
stage: Stage;
|
|
12
|
+
stage_source: string;
|
|
13
|
+
};
|
|
9
14
|
export declare function event(time: unknown, description: unknown, stage?: unknown): CarrierEvent | null;
|
|
10
15
|
/**
|
|
11
16
|
* Like event(), but a scan without an offset keeps its wall time as local_time
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The wording -> stage logic below is provider vocabulary (Ship24, ParcelsApp,
|
|
8
8
|
* 17TRACK and Postal Ninja all render the same aggregated scans), not a
|
|
9
|
-
* carrier status map.
|
|
10
|
-
* merged in a later step.
|
|
9
|
+
* carrier status map. Its decisions retain their wording-rule provenance.
|
|
11
10
|
*/
|
|
12
11
|
import { DateTime } from 'luxon';
|
|
13
12
|
import { trackingLanguageStage } from '../../core/status/index.js';
|
|
@@ -99,7 +98,21 @@ function sourceEventStage(description, includeBroadMovement = true) {
|
|
|
99
98
|
return undefined;
|
|
100
99
|
}
|
|
101
100
|
export function eventStage(description) {
|
|
102
|
-
|
|
101
|
+
const classified = classifyEvent(description);
|
|
102
|
+
return classified.stage_source === 'none' ? undefined : classified.stage;
|
|
103
|
+
}
|
|
104
|
+
/** Retain the decision before delivery wording is redacted or a stage is projected. */
|
|
105
|
+
export function classifyEvent(description, declared) {
|
|
106
|
+
const specific = sourceEventStage(description, false);
|
|
107
|
+
if (specific)
|
|
108
|
+
return { stage: specific, stage_source: 'wording:provider' };
|
|
109
|
+
if (declared)
|
|
110
|
+
return { stage: declared, stage_source: 'carrier_map' };
|
|
111
|
+
const translated = trackingLanguageStage(description);
|
|
112
|
+
if (translated)
|
|
113
|
+
return { stage: translated, stage_source: 'wording:language' };
|
|
114
|
+
const broad = sourceEventStage(description);
|
|
115
|
+
return { stage: broad ?? 'pending', stage_source: broad ? 'wording:provider' : 'none' };
|
|
103
116
|
}
|
|
104
117
|
const EXPLICIT_OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
105
118
|
const LOCAL_WALL_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
|
|
@@ -110,11 +123,11 @@ function describe(description, stage) {
|
|
|
110
123
|
const declared = typeof stage === 'string' && Object.hasOwn(STAGES, stage) ? STAGES[stage] : undefined;
|
|
111
124
|
// Established source semantics (e.g. handoff/negation) remain first. A real
|
|
112
125
|
// provider stage outranks the new intuitive translation fallback.
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
126
|
+
const classified = classifyEvent(label, declared);
|
|
127
|
+
if (classified.stage !== 'delivered' && hasPrivateDeliveryDetails(label))
|
|
115
128
|
return null;
|
|
116
129
|
// Delivery descriptions can include signatures, access codes or door numbers.
|
|
117
|
-
return { description:
|
|
130
|
+
return { description: classified.stage === 'delivered' ? 'Delivered' : label, ...classified };
|
|
118
131
|
}
|
|
119
132
|
export function event(time, description, stage) {
|
|
120
133
|
const described = describe(description, stage);
|
|
@@ -155,14 +168,16 @@ export function result(events, source, preserveOrder = false) {
|
|
|
155
168
|
// A well-formed reply with no scan left after notices and private details proves nothing about the parcel.
|
|
156
169
|
if (!unique.length)
|
|
157
170
|
throw new IndeterminateError(source, 'No usable tracking events');
|
|
158
|
-
const
|
|
171
|
+
const currentEvent = unique.find((e) => e.stage && e.stage !== 'pending');
|
|
172
|
+
const current = currentEvent?.stage;
|
|
159
173
|
// Unknown wording can be displayed, but must not imply movement.
|
|
160
174
|
const status = current === 'delivered' ? 'delivered'
|
|
161
175
|
: current === 'registered' || !current ? 'pending'
|
|
162
176
|
: current === 'out_for_delivery' || current === 'ready_for_pickup' ? 'out_for_delivery'
|
|
163
177
|
: ['returned', 'failed_attempt', 'exception'].includes(current) ? 'exception' : 'in_transit';
|
|
164
178
|
return {
|
|
165
|
-
status, current_stage: current ?? 'pending',
|
|
179
|
+
status, current_stage: current ?? 'pending', current_stage_source: currentEvent?.stage_source ?? 'none',
|
|
180
|
+
last_status_text: unique[0].description,
|
|
166
181
|
last_update: unique[0].time ?? null, expected_delivery: null, timezone: 'UTC',
|
|
167
182
|
tracking_provider: source, events: unique,
|
|
168
183
|
};
|
|
@@ -4,7 +4,7 @@ import { InvalidInputError, NotFoundError, SchemaError } from '../../core/errors
|
|
|
4
4
|
import { runSteps } from '../../core/runner/index.js';
|
|
5
5
|
import { decodeText, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
6
6
|
import { isRecord } from '../../core/types.js';
|
|
7
|
-
import {
|
|
7
|
+
import { classifyEvent, hasPrivateDeliveryDetails, isNotice, numberOf, result, text } from '../shared/result.js';
|
|
8
8
|
const SOURCE = 'UPU';
|
|
9
9
|
export const UPU_BUDGET_MS = 8_000;
|
|
10
10
|
const MAX_EVENTS = 1_000;
|
|
@@ -58,12 +58,14 @@ export function parseUpuResponse(payload, trackingNumber) {
|
|
|
58
58
|
continue;
|
|
59
59
|
if (!description || isNotice(description))
|
|
60
60
|
throw new SchemaError(SOURCE, 'UPU returned no event label');
|
|
61
|
-
const
|
|
61
|
+
const mapped = STAGES[raw.EventCd];
|
|
62
|
+
const classified = mapped ? { stage: mapped, stage_source: 'carrier_map' } : classifyEvent(description);
|
|
63
|
+
const { stage } = classified;
|
|
62
64
|
if (stage !== 'delivered' && hasPrivateDeliveryDetails(description))
|
|
63
65
|
continue;
|
|
64
66
|
const location = text(raw.EventLocation);
|
|
65
67
|
events.push({ local_time: localTime(raw.EventDT), provider_code: raw.EventCd,
|
|
66
|
-
description: stage === 'delivered' ? 'Delivered' : description,
|
|
68
|
+
description: stage === 'delivered' ? 'Delivered' : description, ...classified,
|
|
67
69
|
...(location && !hasPrivateDeliveryDetails(location) ? { location } : {}),
|
|
68
70
|
});
|
|
69
71
|
}
|
package/dist/server/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Universal Parcel Scraper",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.7.0"
|
|
6
6
|
},
|
|
7
7
|
"paths": {
|
|
8
8
|
"/health": {
|
|
@@ -691,6 +691,9 @@
|
|
|
691
691
|
"current_stage": {
|
|
692
692
|
"type": "string"
|
|
693
693
|
},
|
|
694
|
+
"current_stage_source": {
|
|
695
|
+
"type": "string"
|
|
696
|
+
},
|
|
694
697
|
"last_status_text": {
|
|
695
698
|
"type": [
|
|
696
699
|
"string",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-main.326",
|
|
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",
|