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.
@@ -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}",
@@ -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 ? "carrier_map" : n.source,
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) => Lo[e] === void 0).map((e) => Io(e)).filter(Boolean)), l = (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
- Lo[e] ?? 0
7475
+ c[e] ?? 0
7472
7476
  ];
7473
- return o.filter((e) => Lo[e] !== void 0 && we.has(e) && xe(e) !== "universal" && !c.has(Io(e)) && !t.skip?.(e)).map((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: l(e)
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 r = e.map((e) => ({
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
- })), i, a = new Promise((e) => {
7491
- i = setTimeout(e, n);
7492
- }), o = e.map(async (e, n) => {
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 i = await t(e.carrier);
7495
- r[n] = {
7505
+ let r = await t(e.carrier, o);
7506
+ if (a.signal.aborted) return;
7507
+ i[n] = {
7496
7508
  ...e,
7497
- status: i.known ? "known" : "unknown",
7498
- lastActivityAt: i.lastActivityAt ?? null
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(o), a]);
7515
+ await Promise.race([Promise.all(u), l]);
7504
7516
  } finally {
7505
- clearTimeout(i);
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: (error) => error instanceof ChallengeError || error instanceof UpstreamNetworkError
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
  };
@@ -26,6 +26,9 @@
26
26
  ],
27
27
  "recognition": {
28
28
  "rank": 23
29
+ },
30
+ "browserRecognition": {
31
+ "rank": 90
29
32
  }
30
33
  },
31
34
  "capabilities": [
@@ -1,4 +1,4 @@
1
- import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
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
  };
@@ -15,7 +15,10 @@
15
15
  "adapter": "fedex",
16
16
  "steps": [
17
17
  "trawl"
18
- ]
18
+ ],
19
+ "browserRecognition": {
20
+ "rank": 100
21
+ }
19
22
  },
20
23
  "capabilities": [
21
24
  "history",
@@ -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. Only carriers that declare `tracking.recognition`
15
- * qualify. A high-confidence dedicated carrier needs no recognition; the
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. Only carriers that declare `tracking.recognition`
21
- * qualify. A high-confidence dedicated carrier needs no recognition; the
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) => CARRIER_RECOGNITION_RANKS[carrier] === undefined)
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
- CARRIER_RECOGNITION_RANKS[carrier] ?? 0,
58
+ ranks[carrier] ?? 0,
56
59
  ];
57
60
  return candidates
58
- .filter((carrier) => CARRIER_RECOGNITION_RANKS[carrier] !== undefined && AUTOMATIC_CARRIER_IDS.has(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`; its lookup may still finish in the
12
- * background, but its answer is ignored.
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`; its lookup may still finish in the
18
- * background, but its answer is ignored.
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
- const deadline = new Promise((resolve) => { timer = setTimeout(resolve, budgetMs); });
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
- return stages.has(declaredStage) ? 'carrier_map' : classifyStage(description).source;
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 ? 'carrier_map' : classified.source,
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 };
@@ -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 { event, eventStage, hasPrivateDeliveryDetails, isNotice, numberOf, result, text } from '../shared/result.js';
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 stage = eventStage(description) ?? 'pending';
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, stage });
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. It is kept here unchanged while the classifiers are
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
- return sourceEventStage(description, false) ?? trackingLanguageStage(description) ?? sourceEventStage(description);
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 resolved = sourceEventStage(label, false) ?? declared ?? trackingLanguageStage(label) ?? sourceEventStage(label);
114
- if (resolved !== 'delivered' && hasPrivateDeliveryDetails(label))
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: resolved === 'delivered' ? 'Delivered' : label, stage: resolved ?? 'pending' };
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 current = unique.find((e) => e.stage && e.stage !== 'pending')?.stage;
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', last_status_text: unique[0].description,
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 { eventStage, hasPrivateDeliveryDetails, isNotice, numberOf, result, text } from '../shared/result.js';
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 stage = STAGES[raw.EventCd] ?? eventStage(description) ?? 'pending';
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, stage,
68
+ description: stage === 'delivered' ? 'Delivered' : description, ...classified,
67
69
  ...(location && !hasPrivateDeliveryDetails(location) ? { location } : {}),
68
70
  });
69
71
  }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.5.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.5.0-main.324",
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",