universal-parcel-scraper 0.3.4-main.317 → 0.4.0-main.319

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 CHANGED
@@ -149,7 +149,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
149
149
  <!-- GENERATED:stages -->
150
150
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
151
151
 
152
- The carrier folders record 1,673 such statuses, each filed under one stage.
152
+ The carrier folders record 1,675 such statuses, each filed under one stage.
153
153
  <!-- /GENERATED:stages -->
154
154
 
155
155
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -25,7 +25,7 @@ import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/
25
25
  import { isRecord } from '../../core/types.js';
26
26
  import { referenceConsignment } from '../swiss-post-cargo/reference.js';
27
27
  import { postlogisticsIdentifier } from './number.js';
28
- import { POSTLOGISTICS_IMAGE_CODE, postlogisticsStatus } from './status.js';
28
+ import { POSTLOGISTICS_IMAGE_CODE, postlogisticsStage, postlogisticsStatus } from './status.js';
29
29
  const PROVIDER = 'PostLogistics';
30
30
  const UPSTREAM = 'PostLogistics tracking';
31
31
  const TRACK_URL = 'https://eosapi.postlogistics.ch/api/trackandtrace/public?culture=fr-FR';
@@ -108,6 +108,7 @@ export function parsePostlogisticsTrackingResponse(value, trackingNumber, now =
108
108
  time: text(event.TimeStamp),
109
109
  location: text(event.City),
110
110
  description: text(event.Description),
111
+ stage: postlogisticsStage(text(event.Status)),
111
112
  }));
112
113
  const latest = orderedHistory[0]?.event ?? {};
113
114
  const latestStatus = text(latest.Status);
@@ -116,6 +117,7 @@ export function parsePostlogisticsTrackingResponse(value, trackingNumber, now =
116
117
  .find(Boolean) ?? '';
117
118
  return {
118
119
  status: postlogisticsStatus(latestStatus),
120
+ current_stage: postlogisticsStage(latestStatus),
119
121
  last_status_text: text(latest.Description) || latestStatus,
120
122
  last_update: text(latest.TimeStamp) || null,
121
123
  expected_delivery: eta ? eta.slice(0, 10) : null,
@@ -2,21 +2,19 @@
2
2
  * PostLogistics status vocabulary.
3
3
  *
4
4
  * Every history entry carries a three-letter `Status` code next to its
5
- * free-text `Description`. Only the codes that decide the shipment's outcome
6
- * are mapped; everything else stays `in_transit` and the description is left
7
- * for the sync's wording classifier. The map is deliberately small: a wrong
8
- * "delivered" is worse than a missing nuance.
9
- *
10
- * The adapter classifies the shipment, not each event: the endpoint gives one
11
- * code per scan but no stage vocabulary, so events are returned without an
12
- * explicit stage and the sync records them for review.
5
+ * free-text `Description`. Confirmed codes classify each scan and the newest
6
+ * scan's summary. Unknown codes leave the stage to the wording classifier.
7
+ * An announcement must not become movement when its wording is unrecognized.
13
8
  */
14
9
  import type { CarrierStatus } from '../../core/result/index.js';
10
+ import type { Stage } from '../../generated/catalog.js';
15
11
  /** Codes that mean the parcel reached its recipient. */
16
12
  export declare const POSTLOGISTICS_DELIVERED_CODES: readonly ["DEL", "DLV", "POD", "SIG"];
17
13
  /** The code that means the shipment is announced but not yet moving. */
18
14
  export declare const POSTLOGISTICS_NOTIFIED_CODE = "NTF";
19
15
  /** The code of an entry that records a picture, not a movement. */
20
16
  export declare const POSTLOGISTICS_IMAGE_CODE = "IMG";
17
+ /** The milestone of a scan whose code has a confirmed meaning. */
18
+ export declare function postlogisticsStage(code: string): Stage | undefined;
21
19
  /** The shipment status the newest history code implies. */
22
20
  export declare function postlogisticsStatus(code: string): CarrierStatus;
@@ -4,11 +4,24 @@ export const POSTLOGISTICS_DELIVERED_CODES = ['DEL', 'DLV', 'POD', 'SIG'];
4
4
  export const POSTLOGISTICS_NOTIFIED_CODE = 'NTF';
5
5
  /** The code of an entry that records a picture, not a movement. */
6
6
  export const POSTLOGISTICS_IMAGE_CODE = 'IMG';
7
- /** The shipment status the newest history code implies. */
8
- export function postlogisticsStatus(code) {
7
+ /** The milestone of a scan whose code has a confirmed meaning. */
8
+ export function postlogisticsStage(code) {
9
9
  if (POSTLOGISTICS_DELIVERED_CODES.includes(code))
10
10
  return 'delivered';
11
11
  if (code === POSTLOGISTICS_NOTIFIED_CODE)
12
+ return 'registered';
13
+ if (code === 'RFS')
14
+ return 'accepted';
15
+ if (code === 'SCA')
16
+ return 'out_for_delivery';
17
+ return undefined;
18
+ }
19
+ /** The shipment status the newest history code implies. */
20
+ export function postlogisticsStatus(code) {
21
+ const stage = postlogisticsStage(code);
22
+ if (stage === 'delivered' || stage === 'out_for_delivery')
23
+ return stage;
24
+ if (stage === 'registered')
12
25
  return 'pending';
13
26
  return 'in_transit';
14
27
  }
@@ -34,7 +34,21 @@
34
34
  "stage": "registered",
35
35
  "confirmedBy": "fixture",
36
36
  "firstSeen": "2026-09-12",
37
- "note": "Shipment announced. The adapter reports the shipment as pending and stamps no event stage."
37
+ "note": "Shipment announced; the summary remains pending and the scan is registered."
38
+ },
39
+ {
40
+ "code": "RFS",
41
+ "stage": "accepted",
42
+ "confirmedBy": "fixture",
43
+ "firstSeen": "2026-10-04",
44
+ "note": "Goods received by the carrier."
45
+ },
46
+ {
47
+ "code": "SCA",
48
+ "stage": "out_for_delivery",
49
+ "confirmedBy": "fixture",
50
+ "firstSeen": "2026-10-04",
51
+ "note": "Loaded for delivery."
38
52
  }
39
53
  ]
40
54
  }
package/dist/cli/index.js CHANGED
@@ -9,7 +9,7 @@ const help = `Universal Parcel Scraper
9
9
 
10
10
  parcel-scraper detect <number, link or text>
11
11
  parcel-scraper recognize <number>
12
- parcel-scraper track <number> [--carrier <id>] [--postcode <value>] [--tracking-url <url>]
12
+ parcel-scraper track <number> [--carrier <id>] [--postcode <value>] [--tracking-url <url>] [--country-hint <code>]
13
13
  parcel-scraper carriers
14
14
  parcel-scraper serve [--host <address>] [--port <port>]
15
15
 
@@ -38,7 +38,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
38
38
  throw new TypeError('Unknown command; use --help');
39
39
  const values = {};
40
40
  const positional = [];
41
- const allowed = command === 'track' ? ['carrier', 'postcode', 'tracking-url'] : command === 'serve' ? ['host', 'port'] : [];
41
+ const allowed = command === 'track' ? ['carrier', 'postcode', 'tracking-url', 'country-hint'] : command === 'serve' ? ['host', 'port'] : [];
42
42
  for (let i = 0; i < args.length; i++) {
43
43
  const argument = args[i];
44
44
  if (!argument.startsWith('--')) {
@@ -91,7 +91,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
91
91
  if (command === 'recognize')
92
92
  print(await tracker.recognize(input));
93
93
  else
94
- print(await tracker.track({ number: input, carrier: values.carrier, postcode: values.postcode, trackingUrl: values['tracking-url'] }));
94
+ print(await tracker.track({ number: input, carrier: values.carrier, postcode: values.postcode, trackingUrl: values['tracking-url'], countryHint: values['country-hint'] }));
95
95
  return 0;
96
96
  }
97
97
  /** What a failed command prints: a rejected input, setting or listener explains itself; anything else stays generic. */
@@ -8,6 +8,8 @@ export interface TrackingInput {
8
8
  trackingUrl?: string | null;
9
9
  /** The delivery postcode for carriers that need one; part of the tracking credential. */
10
10
  postcode?: string | null;
11
+ /** ISO country code or English name used only to retry an empty universal lookup; never shipment evidence. */
12
+ countryHint?: string | null;
11
13
  /**
12
14
  * The zone of the carrier the parcel is filed under, for universal providers
13
15
  * whose scan times name no zone they can be trusted with. When that
@@ -28,7 +28,7 @@ export async function trackCarrier(carrier, input, options) {
28
28
  }
29
29
  }
30
30
  if (options.registry.adapterIdFor(carrier) === 'universal') {
31
- return normalizeCarrierResult(await options.universal.fetch(input.number, input.postcode, { signal: options.signal, budgetMs: options.budgetMs }));
31
+ return normalizeCarrierResult(await options.universal.fetch(input.number, input.postcode, { signal: options.signal, budgetMs: options.budgetMs }, ...(input.countryHint === undefined ? [] : [input.countryHint])));
32
32
  }
33
33
  throw new RangeError(`No tracking adapter is registered for ${carrier}`);
34
34
  }
@@ -24,6 +24,8 @@ export interface ParcelInput {
24
24
  number: string;
25
25
  carrier?: string;
26
26
  postcode?: string | null;
27
+ /** A destination or visitor country hint for an empty universal lookup; never shipment evidence. */
28
+ countryHint?: string | null;
27
29
  trackingUrl?: string | null;
28
30
  }
29
31
  export interface TrackingAttempt {
@@ -147,6 +147,8 @@ export function createTracker(options = {}) {
147
147
  throw new TrackingError([], failureHint(new BudgetExceededError('Tracking', ms)));
148
148
  }
149
149
  }
150
+ if (input.countryHint != null && typeof input.countryHint !== 'string')
151
+ throw new TypeError('Invalid country hint');
150
152
  if (input.postcode != null && typeof input.postcode !== 'string')
151
153
  throw new TypeError('Invalid postcode');
152
154
  if (input.trackingUrl != null && typeof input.trackingUrl !== 'string')
@@ -188,7 +190,7 @@ export function createTracker(options = {}) {
188
190
  for (const candidate of plan.sources.filter(source => enabled.includes(source))) {
189
191
  if (signal.aborted)
190
192
  break;
191
- result = await attempt(candidate, () => provider(candidate, async () => resolveResult(await universal.fetchSource(candidate, number, Math.min(remaining() + DEADLINE_SLACK_MS, universalSourceBudget(candidate)), fields.postcode, carrierTimezone(carrier) === 'UTC' ? null : carrierTimezone(carrier), signal)), signal));
193
+ result = await attempt(candidate, () => provider(candidate, async () => resolveResult(await universal.fetchSource(candidate, number, Math.min(remaining() + DEADLINE_SLACK_MS, universalSourceBudget(candidate)), fields.postcode, carrierTimezone(carrier) === 'UTC' ? null : carrierTimezone(carrier), signal, input.countryHint)), signal));
192
194
  if (result) {
193
195
  source = candidate;
194
196
  break;
@@ -18,6 +18,6 @@ export declare class ParcelsAppTracker {
18
18
  readonly options: ParcelsAppOptions;
19
19
  private readonly http;
20
20
  constructor(options?: ParcelsAppOptions);
21
- fetch(trackingNumber: string, budgetMs?: number, postcode?: string | null, timezone?: string | null, signal?: AbortSignal): Promise<CarrierResult>;
21
+ fetch(trackingNumber: string, budgetMs?: number, postcode?: string | null, timezone?: string | null, signal?: AbortSignal, countryHint?: string | null): Promise<CarrierResult>;
22
22
  }
23
23
  export declare const adapter: AdapterFactory;
@@ -20,7 +20,7 @@ import { capturedBodies, loadCapture } from '../shared/capture.js';
20
20
  import { universalCarrierHints } from '../shared/hints.js';
21
21
  import { event, isNotice, localEvent, numberOf, result } from '../shared/result.js';
22
22
  import { carrierScan, markReturnLeg } from '../shared/scans.js';
23
- import { PARCELSAPP_API, ParcelsAppHttpClient } from './http.js';
23
+ import { PARCELSAPP_API, parcelsAppCountry, ParcelsAppHttpClient } from './http.js';
24
24
  const SOURCE = 'ParcelsApp';
25
25
  const MAX_EVENTS = 1000;
26
26
  export const PARCELSAPP_BUDGET_MS = 45_000;
@@ -327,13 +327,14 @@ export class ParcelsAppTracker {
327
327
  this.options = options;
328
328
  this.http = options.httpClient === undefined ? new ParcelsAppHttpClient(options.fetcher) : options.httpClient;
329
329
  }
330
- async fetch(trackingNumber, budgetMs = this.options.timeoutMs ?? PARCELSAPP_BUDGET_MS, postcode, timezone = null, signal) {
330
+ async fetch(trackingNumber, budgetMs = this.options.timeoutMs ?? PARCELSAPP_BUDGET_MS, postcode, timezone = null, signal, countryHint) {
331
331
  const number = numberOf(trackingNumber);
332
332
  if (!Number.isFinite(budgetMs) || budgetMs < 1)
333
333
  throw new TypeError('ParcelsApp timeout must be positive');
334
334
  const deadline = performance.now() + budgetMs;
335
- const request = async (remainingMs, signal) => {
336
- const payload = await this.http.fetch(number, Math.max(1, Math.min(DIRECT_BUDGET_MS, Math.floor(remainingMs))), postcode, signal);
335
+ const country = parcelsAppCountry(countryHint);
336
+ const request = async (remainingMs, signal, manualCountry) => {
337
+ const payload = await this.http.fetch(number, Math.max(1, Math.min(DIRECT_BUDGET_MS, Math.floor(remainingMs))), postcode, signal, manualCountry);
337
338
  // This endpoint returns one shipment per POST, synchronously, with no
338
339
  // shared session or polling handle. Each retry keeps its own request
339
340
  // binding. Numberless browser captures never get this exemption.
@@ -350,13 +351,17 @@ export class ParcelsAppTracker {
350
351
  id: 'retry',
351
352
  enabled: this.http !== null,
352
353
  // Uncached carrier aggregation can outlive a timed-out HTTP request.
353
- // Retry that replayable read once; responses such as NO_DATA, input
354
- // gates, aliases, challenges and HTTP errors do not qualify.
355
- recovers: (error) => error instanceof UpstreamNetworkError && deadline - performance.now() > RETRY_DELAY_MS + 1,
356
- run: async ({ signal }) => {
357
- await timers.setTimeout(RETRY_DELAY_MS, undefined, { signal });
354
+ // Retry a replayable network failure with the same input. An empty
355
+ // answer can instead try the caller's country once, as the website's
356
+ // selector does. Both share this step and the original lookup budget.
357
+ recovers: (error) => (error instanceof UpstreamNetworkError && deadline - performance.now() > RETRY_DELAY_MS + 1)
358
+ || (country !== null && error instanceof NoHistoryError && deadline - performance.now() > 1),
359
+ run: async ({ signal, previousError }) => {
360
+ const empty = previousError instanceof NoHistoryError;
361
+ if (!empty)
362
+ await timers.setTimeout(RETRY_DELAY_MS, undefined, { signal });
358
363
  signal.throwIfAborted();
359
- return request(deadline - performance.now(), signal);
364
+ return request(deadline - performance.now(), signal, empty ? country : null);
360
365
  },
361
366
  }, {
362
367
  id: 'trawl',
@@ -389,6 +394,6 @@ export const adapter = (environment) => {
389
394
  return {
390
395
  id: SOURCE,
391
396
  steps: ['direct', 'retry', 'trawl'],
392
- track: (input, context) => tracker.fetch(input.number, context?.budgetMs, input.postcode, input.timezone ?? null, context?.signal),
397
+ track: (input, context) => tracker.fetch(input.number, context?.budgetMs, input.postcode, input.timezone ?? null, context?.signal, input.countryHint),
393
398
  };
394
399
  };
@@ -1,9 +1,11 @@
1
+ /** The country selector uses English names, not ISO codes. */
2
+ export declare function parcelsAppCountry(value: unknown): string | null;
1
3
  export declare const PARCELSAPP_API = "https://parcelsapp.com/api/v2/parcels";
2
4
  /** MurmurHash2 with the public website's seed, over ASCII protocol data. */
3
5
  export declare function parcelsAppChecksum(text: string): number;
4
- export declare function parcelsAppRequest(trackingNumber: string, postcode?: string | null): URLSearchParams;
6
+ export declare function parcelsAppRequest(trackingNumber: string, postcode?: string | null, countryHint?: string | null): URLSearchParams;
5
7
  export declare class ParcelsAppHttpClient {
6
8
  readonly fetcher?: typeof fetch | undefined;
7
9
  constructor(fetcher?: typeof fetch | undefined);
8
- fetch(trackingNumber: string, timeoutMs: number, postcode?: string | null, signal?: AbortSignal): Promise<unknown>;
10
+ fetch(trackingNumber: string, timeoutMs: number, postcode?: string | null, signal?: AbortSignal, countryHint?: string | null): Promise<unknown>;
9
11
  }
@@ -1,5 +1,12 @@
1
1
  import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
2
2
  import { numberOf } from '../shared/result.js';
3
+ import { countryCode } from '../../core/time/index.js';
4
+ const COUNTRY_NAMES = new Intl.DisplayNames(['en'], { type: 'region' });
5
+ /** The country selector uses English names, not ISO codes. */
6
+ export function parcelsAppCountry(value) {
7
+ const code = countryCode(value);
8
+ return code && code !== 'ZZ' ? COUNTRY_NAMES.of(code) ?? null : null;
9
+ }
3
10
  export const PARCELSAPP_API = 'https://parcelsapp.com/api/v2/parcels';
4
11
  // Public frontend protocol, verified 2026-09-12. No session or issued secret:
5
12
  // packs/js/application-aa19cda6a00923f7e330.js on dvow0vltefbxy.cloudfront.net.
@@ -29,7 +36,7 @@ export function parcelsAppChecksum(text) {
29
36
  hash = Math.imul(hash, 0x5bd1e995);
30
37
  return (hash ^ hash >>> 15) >>> 0;
31
38
  }
32
- export function parcelsAppRequest(trackingNumber, postcode) {
39
+ export function parcelsAppRequest(trackingNumber, postcode, countryHint) {
33
40
  const number = numberOf(trackingNumber);
34
41
  // The bundle shifts ASCII by 2 * sum([1,2,8,4,5,6,7,5]) modulo 126,
35
42
  // URI-encodes it, then lets jQuery form-encode it a second time.
@@ -42,6 +49,9 @@ export function parcelsAppRequest(trackingNumber, postcode) {
42
49
  const zipcode = postcode?.trim();
43
50
  if (zipcode)
44
51
  params.set('extra[zipcode]', zipcode);
52
+ const country = parcelsAppCountry(countryHint);
53
+ if (country)
54
+ params.set('extra[manualCountry]', country);
45
55
  return params;
46
56
  }
47
57
  export class ParcelsAppHttpClient {
@@ -49,7 +59,7 @@ export class ParcelsAppHttpClient {
49
59
  constructor(fetcher) {
50
60
  this.fetcher = fetcher;
51
61
  }
52
- async fetch(trackingNumber, timeoutMs, postcode, signal) {
62
+ async fetch(trackingNumber, timeoutMs, postcode, signal, countryHint) {
53
63
  const number = numberOf(trackingNumber);
54
64
  if (!Number.isFinite(timeoutMs) || timeoutMs < 1)
55
65
  throw new TypeError('ParcelsApp HTTP timeout must be positive');
@@ -60,7 +70,7 @@ export class ParcelsAppHttpClient {
60
70
  Origin: 'https://parcelsapp.com', Referer: `https://parcelsapp.com/en/tracking/${number}`,
61
71
  'X-Requested-With': 'XMLHttpRequest',
62
72
  },
63
- body: parcelsAppRequest(number, postcode),
73
+ body: parcelsAppRequest(number, postcode, countryHint),
64
74
  }, { provider: 'ParcelsApp', fetcher: this.fetcher, timeoutMs: Math.floor(timeoutMs), maxBytes: 2_000_000 });
65
75
  return parseJsonBytes(bytes, 'ParcelsApp');
66
76
  }
@@ -56,10 +56,11 @@ export declare class UniversalTracker {
56
56
  * stored delivery postcode, if the user supplied one: it is forwarded into
57
57
  * every provider's track input. ParcelsApp submits it as extra[zipcode] on
58
58
  * its direct API request; the other providers currently do not consume it.
59
+ * `countryHint` lets ParcelsApp retry an empty answer with that country.
59
60
  * The caller's budget covers the whole chain and its signal ends it.
60
61
  */
61
- fetch(trackingNumber: string, postcode?: string | null, context?: TrackingContext): Promise<CarrierResult>;
62
- fetchSource(source: Source, trackingNumber: string, timeoutMs?: number, postcode?: string | null, timezone?: string | null, signal?: AbortSignal): Promise<CarrierResult>;
62
+ fetch(trackingNumber: string, postcode?: string | null, context?: TrackingContext, countryHint?: string | null): Promise<CarrierResult>;
63
+ fetchSource(source: Source, trackingNumber: string, timeoutMs?: number, postcode?: string | null, timezone?: string | null, signal?: AbortSignal, countryHint?: string | null): Promise<CarrierResult>;
63
64
  /** One provider adapter, built from this tracker's environment. */
64
65
  private provider;
65
66
  private environment;
@@ -49,9 +49,10 @@ export class UniversalTracker {
49
49
  * stored delivery postcode, if the user supplied one: it is forwarded into
50
50
  * every provider's track input. ParcelsApp submits it as extra[zipcode] on
51
51
  * its direct API request; the other providers currently do not consume it.
52
+ * `countryHint` lets ParcelsApp retry an empty answer with that country.
52
53
  * The caller's budget covers the whole chain and its signal ends it.
53
54
  */
54
- async fetch(trackingNumber, postcode, context = {}) {
55
+ async fetch(trackingNumber, postcode, context = {}, countryHint) {
55
56
  numberOf(trackingNumber);
56
57
  context.signal?.throwIfAborted();
57
58
  const deadline = context.budgetMs === undefined ? Infinity : performance.now() + context.budgetMs;
@@ -64,7 +65,7 @@ export class UniversalTracker {
64
65
  continue;
65
66
  }
66
67
  try {
67
- return await this.fetchSource(source, trackingNumber, Math.min(remaining, this.options.timeoutMs ?? universalSourceBudget(source)), postcode, null, context.signal);
68
+ return await this.fetchSource(source, trackingNumber, Math.min(remaining, this.options.timeoutMs ?? universalSourceBudget(source)), postcode, null, context.signal, countryHint);
68
69
  }
69
70
  catch (error) {
70
71
  if (context.signal?.aborted)
@@ -74,12 +75,12 @@ export class UniversalTracker {
74
75
  }
75
76
  throw new UniversalTrackingError(failures);
76
77
  }
77
- async fetchSource(source, trackingNumber, timeoutMs = this.options.timeoutMs ?? universalSourceBudget(source), postcode, timezone, signal) {
78
+ async fetchSource(source, trackingNumber, timeoutMs = this.options.timeoutMs ?? universalSourceBudget(source), postcode, timezone, signal, countryHint) {
78
79
  const number = numberOf(trackingNumber);
79
80
  if (this.options.browserLookup && (source === 'Postal Ninja' || source === 'Ship24')) {
80
81
  return await this.options.browserLookup(source, number);
81
82
  }
82
- return await this.provider(source).track({ number, postcode: postcode ?? null, timezone: timezone ?? null }, { budgetMs: timeoutMs, signal });
83
+ return await this.provider(source).track({ number, postcode: postcode ?? null, timezone: timezone ?? null, countryHint: countryHint ?? null }, { budgetMs: timeoutMs, signal });
83
84
  }
84
85
  /** One provider adapter, built from this tracker's environment. */
85
86
  provider(source) {
@@ -142,7 +142,7 @@ export function createTrackingServer(options = {}) {
142
142
  const number = normalizeTrackingNumber(input.number);
143
143
  const detected = detectCarrierMatch(number);
144
144
  input = { ...input, number, carrier: input.carrier ?? (detected.confidence === 'high' ? detected.carrier : undefined) };
145
- const key = createHash('sha256').update(JSON.stringify([input.number, input.carrier, input.postcode, input.trackingUrl])).digest('hex');
145
+ const key = createHash('sha256').update(JSON.stringify([input.number, input.carrier, input.postcode, input.trackingUrl, input.countryHint])).digest('hex');
146
146
  const previous = cache.get(key);
147
147
  if (previous && previous.until > now()) {
148
148
  if (previous.error)
@@ -261,7 +261,7 @@ export function createTrackingServer(options = {}) {
261
261
  }
262
262
  const input = await body(request);
263
263
  const allowed = path === '/v1/detect' ? ['text'] : path === '/v1/recognize' ? ['number', 'budgetMs']
264
- : ['number', 'carrier', 'postcode', 'trackingUrl', 'budgetMs'];
264
+ : ['number', 'carrier', 'postcode', 'trackingUrl', 'countryHint', 'budgetMs'];
265
265
  if (Object.keys(input).some(key => !allowed.includes(key)))
266
266
  throw new HttpError(400, 'Unknown request field');
267
267
  if (path === '/v1/detect') {
@@ -275,7 +275,7 @@ export function createTrackingServer(options = {}) {
275
275
  return;
276
276
  }
277
277
  const result = await tracked({ number: input.number, carrier: input.carrier,
278
- postcode: input.postcode, trackingUrl: input.trackingUrl }, input.budgetMs);
278
+ postcode: input.postcode, trackingUrl: input.trackingUrl, countryHint: input.countryHint }, input.budgetMs);
279
279
  json(response, 200, result);
280
280
  }
281
281
  catch (error) {
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.3.4"
5
+ "version": "0.4.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
@@ -539,6 +539,13 @@
539
539
  "type": "integer",
540
540
  "minimum": 1,
541
541
  "maximum": 120000
542
+ },
543
+ "countryHint": {
544
+ "type": [
545
+ "string",
546
+ "null"
547
+ ],
548
+ "description": "Country code or English name used only to retry an empty universal lookup."
542
549
  }
543
550
  },
544
551
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.3.4-main.317",
3
+ "version": "0.4.0-main.319",
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",