universal-parcel-scraper 0.133.2-main.1113 → 0.134.0

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/catalog.json CHANGED
@@ -3057,6 +3057,9 @@
3057
3057
  "uniuni": {
3058
3058
  "displayName": "UniUni",
3059
3059
  "color": "#f68841",
3060
+ "aliases": [
3061
+ "UNI Express"
3062
+ ],
3060
3063
  "countries": [
3061
3064
  "CA",
3062
3065
  "US"
@@ -2600,6 +2600,7 @@ var De = {
2600
2600
  uniuni: {
2601
2601
  displayName: "UniUni",
2602
2602
  color: "#f68841",
2603
+ aliases: ["UNI Express"],
2603
2604
  countries: ["CA", "US"],
2604
2605
  selectable: !0,
2605
2606
  timezone: "UTC",
@@ -12325,8 +12326,8 @@ function Mc(e, t, n) {
12325
12326
  e.confidence === "high" && (i = e.carrier, a = "reference");
12326
12327
  }
12327
12328
  if (!i && !n.delivery_tracking_number && ["aliexpress", "intl-post"].includes(e) && xt(t) && (!s || ["ch", "switzerland"].includes(s.trim().toLowerCase())) && (i = "swiss-post", a = "reference"), !i && !n.delivery_tracking_number && Ac(e)) {
12328
- let n = y(t).candidates, r = is(s), o = r && n.includes(e) ? n.filter((t) => t !== e && h[t]?.countries?.[0] === r) : [];
12329
- o.length === 1 && (i = o[0], a = "reference");
12329
+ let n = y(t), r = is(s), o = n.confidence === "high" && n.carrier !== e && !v(t) ? n.carrier : null, c = r ? o ? [o].filter((e) => h[e]?.countries?.includes(r)) : n.candidates.includes(e) ? n.candidates.filter((t) => t !== e && h[t]?.countries?.[0] === r) : [] : [];
12330
+ c.length === 1 && (i = c[0], a = "reference");
12330
12331
  }
12331
12332
  return !i || i === e || !/^[A-Z0-9]{4,40}$/.test(o) || !jc(i, o) ? null : {
12332
12333
  carrier: i,
@@ -28,8 +28,8 @@
28
28
  * then asks Asendia UK's tracker (uk.ts), which serves parcels Asendia UK
29
29
  * carries, when A1 does not know the number, fails, or holds its registration
30
30
  * alone. Asendia's global portal track.asendia.com, which serves the
31
- * other subsidiaries, needs a Cloudflare Turnstile token for every search and
32
- * is not used here (see probe.ts). A number neither tracker knows is a
31
+ * other subsidiaries, uses an optional browser recovery step (browser.ts).
32
+ * Without that service, a number neither HTTP tracker knows is a
33
33
  * not-found for this adapter only; routing then tries the universal providers
34
34
  * in the same check.
35
35
  *
@@ -28,8 +28,8 @@
28
28
  * then asks Asendia UK's tracker (uk.ts), which serves parcels Asendia UK
29
29
  * carries, when A1 does not know the number, fails, or holds its registration
30
30
  * alone. Asendia's global portal track.asendia.com, which serves the
31
- * other subsidiaries, needs a Cloudflare Turnstile token for every search and
32
- * is not used here (see probe.ts). A number neither tracker knows is a
31
+ * other subsidiaries, uses an optional browser recovery step (browser.ts).
32
+ * Without that service, a number neither HTTP tracker knows is a
33
33
  * not-found for this adapter only; routing then tries the universal providers
34
34
  * in the same check.
35
35
  *
@@ -47,6 +47,8 @@ import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, userAgent
47
47
  import { isRecord } from '../../core/types.js';
48
48
  import { classifyAsendiaA1Event } from './status.js';
49
49
  import { AsendiaUkTracker } from './uk.js';
50
+ import { asendiaBrowser } from './browser.js';
51
+ import { recoverableByDefault, runSteps } from '../../core/runner/index.js';
50
52
  const PROVIDER = 'Asendia';
51
53
  const PAGE_URL = 'https://a1.asendiausa.com/tracking/';
52
54
  const SCRIPT_URL = `${PAGE_URL}js/main.js`;
@@ -485,8 +487,28 @@ export const adapter = (environment) => {
485
487
  const uk = new AsendiaUkTracker({ fetcher: environment.fetcher, userAgent: environment.userAgent });
486
488
  return {
487
489
  id: 'asendia',
488
- // Plain HTTP against two public trackers' own requests; no second tier.
489
- steps: ['direct'],
490
- track: (input, context) => trackEverywhere(a1, uk, input.number, context),
490
+ recordsSteps: true,
491
+ steps: ['direct', 'trawl'],
492
+ track: (input, context) => {
493
+ const number = normalizeAsendiaA1TrackingNumber(input.number);
494
+ const browserEligible = !!environment.trawl && /^[A-Z0-9]{8,40}$/.test(number) && /\d/.test(number);
495
+ return runSteps({ carrier: 'asendia', signal: context?.signal,
496
+ budgetMs: context?.budgetMs ?? (browserEligible ? 65_000 : DEFAULT_BUDGET_MS), recorder: environment.recorder }, [
497
+ { id: 'direct', run: ({ signal, remainingMs }) => trackEverywhere(a1, uk, number, { signal, budgetMs: Math.min(DEFAULT_BUDGET_MS, browserEligible ? Math.max(1, Math.floor(remainingMs * 0.4)) : remainingMs) }) },
498
+ { id: 'trawl', enabled: browserEligible,
499
+ recovers: error => error instanceof NotFoundError || recoverableByDefault(error),
500
+ run: async ({ signal, remainingMs, previousError }) => {
501
+ try {
502
+ return await asendiaBrowser(environment.trawl, number, signal, remainingMs);
503
+ }
504
+ catch (error) {
505
+ // The global portal cannot erase a failure from either HTTP source.
506
+ if (error instanceof NotFoundError && !(previousError instanceof NotFoundError))
507
+ throw previousError;
508
+ throw error;
509
+ }
510
+ } },
511
+ ]);
512
+ },
491
513
  };
492
514
  };
@@ -0,0 +1,5 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ import type { TrawlClient } from '../../core/transport/trawl.js';
3
+ /** The portal may disclose a summary while requiring recipient verification for scans. */
4
+ export declare function parseAsendiaGlobalResponse(payload: unknown, raw: string): CarrierResult;
5
+ export declare function asendiaBrowser(trawl: TrawlClient, raw: string, signal: AbortSignal, remainingMs: number): Promise<CarrierResult>;
@@ -0,0 +1,102 @@
1
+ import { DateTime } from 'luxon';
2
+ import { BudgetExceededError, ChallengeError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
3
+ import { explicitOffsetTime } from '../../core/time/index.js';
4
+ import { clean } from '../../core/transport/index.js';
5
+ import { isRecord } from '../../core/types.js';
6
+ import { asendiaTrackingApiUrl, asendiaTrackingUrl, normalizeAsendiaTrackingNumber } from './probe.js';
7
+ import { classifyAsendiaStatus } from './status.js';
8
+ const PROVIDER = 'Asendia';
9
+ const MAX_BYTES = 1_000_000;
10
+ /** The portal may disclose a summary while requiring recipient verification for scans. */
11
+ export function parseAsendiaGlobalResponse(payload, raw) {
12
+ const number = normalizeAsendiaTrackingNumber(raw);
13
+ if (!isRecord(payload) || payload.context_code !== 1000 || !Array.isArray(payload.data) || payload.data.length > 5) {
14
+ throw new SchemaError(PROVIDER, 'Asendia returned an invalid global tracking response');
15
+ }
16
+ if (!payload.data.length)
17
+ throw new NotFoundError(PROVIDER);
18
+ const matches = payload.data.filter(row => isRecord(row) && [row.tracking_id, row.upper_tracking_id]
19
+ .some(value => typeof value === 'string' && value.toUpperCase() === number));
20
+ if (matches.length !== 1 || !isRecord(matches[0]))
21
+ throw new SchemaError(PROVIDER, 'Asendia returned a different or ambiguous shipment');
22
+ const parcel = matches[0];
23
+ const wording = clean(parcel.status, 300);
24
+ if (!wording)
25
+ throw new SchemaError(PROVIDER, 'Asendia returned no current parcel status');
26
+ if (parcel.events !== undefined && (!Array.isArray(parcel.events) || parcel.events.length > 500)) {
27
+ throw new SchemaError(PROVIDER, 'Asendia returned an invalid global history');
28
+ }
29
+ const scans = Array.isArray(parcel.events) ? parcel.events.slice().reverse() : [];
30
+ const events = [];
31
+ const seen = new Set();
32
+ for (const scan of scans) {
33
+ if (!isRecord(scan))
34
+ throw new SchemaError(PROVIDER, 'Asendia returned an invalid global scan');
35
+ const description = clean(scan.labelBC, 500) || clean(scan.harmonized_label, 500);
36
+ const stamp = clean(scan.event_time, 64);
37
+ if (!description || !stamp)
38
+ throw new SchemaError(PROVIDER, 'Asendia returned an incomplete global scan');
39
+ const instant = explicitOffsetTime(stamp);
40
+ const wall = instant ? null : DateTime.fromISO(stamp, { zone: 'UTC' });
41
+ if (!instant && (!wall?.isValid || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(stamp))) {
42
+ throw new SchemaError(PROVIDER, 'Asendia returned an invalid global scan clock');
43
+ }
44
+ const classified = classifyAsendiaStatus(description);
45
+ const event = {
46
+ ...(instant ? { time: instant.iso } : { local_time: wall.toFormat("yyyy-MM-dd'T'HH:mm:ss") }),
47
+ description, location: clean(scan.location_name, 200),
48
+ ...(classified.status !== 'unknown' ? { stage: classified.stage } : {}),
49
+ };
50
+ const key = JSON.stringify(event);
51
+ if (!seen.has(key)) {
52
+ seen.add(key);
53
+ events.push(event);
54
+ }
55
+ }
56
+ if (events.length && events.every(event => event.time))
57
+ events.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
58
+ const current = classifyAsendiaStatus(wording.replaceAll('_', ' '));
59
+ const country = clean(parcel.destination_country_name, 100);
60
+ return {
61
+ status: current.status, ...(current.status !== 'unknown' ? { current_stage: current.stage } : {}),
62
+ last_status_text: wording, last_update: events[0]?.time ?? null,
63
+ ...(events[0]?.local_time ? { last_update_local: events[0].local_time } : {}),
64
+ ...(country ? { destination_country_name: country } : {}),
65
+ ...(!events.length ? { summary_only: true } : {}),
66
+ ...(events.length > 100 ? { history_truncated: true } : {}),
67
+ events: events.slice(0, 100), tracking_url: asendiaTrackingUrl(number), tracking_source: 'structured-web-response',
68
+ };
69
+ }
70
+ export async function asendiaBrowser(trawl, raw, signal, remainingMs) {
71
+ const number = normalizeAsendiaTrackingNumber(raw);
72
+ const endpoint = asendiaTrackingApiUrl();
73
+ const timeoutMs = Math.floor(remainingMs - 2000);
74
+ if (timeoutMs < 1)
75
+ throw new BudgetExceededError(PROVIDER, remainingMs);
76
+ const page = await trawl.scrape({ url: asendiaTrackingUrl(number), skipHttp: true, maxTier: 3,
77
+ captureResponses: [endpoint], maxTimeout: timeoutMs, settleTimeout: timeoutMs }, { provider: PROVIDER, timeoutMs, signal, requireSolved: false, captureWindow: 'last', maxBytes: 6 * MAX_BYTES });
78
+ signal.throwIfAborted();
79
+ const responses = page.capturedResponses.filter(row => row.url === endpoint && row.trackingNumberSubmitted === number);
80
+ const response = responses.at(-1);
81
+ if (!response || responses.length > 10)
82
+ throw new ChallengeError(PROVIDER, 'Asendia returned no matching verified tracking response');
83
+ if (response.status === 429)
84
+ throw new RateLimitedError(PROVIDER);
85
+ if ([400, 401, 403].includes(response.status))
86
+ throw new ChallengeError(PROVIDER, 'Asendia rejected browser verification');
87
+ if (response.status >= 400)
88
+ throw new UpstreamHttpError(PROVIDER, response.status);
89
+ if (response.status !== 200 || response.error || response.truncated || response.base64Encoded || !response.body) {
90
+ throw new TransportError(PROVIDER, 'Asendia returned an incomplete browser response');
91
+ }
92
+ if (Buffer.byteLength(response.body) > MAX_BYTES)
93
+ throw new SchemaError(PROVIDER, 'Asendia returned excessive browser data');
94
+ let payload;
95
+ try {
96
+ payload = JSON.parse(response.body);
97
+ }
98
+ catch {
99
+ throw new SchemaError(PROVIDER, 'Asendia returned invalid browser JSON');
100
+ }
101
+ return parseAsendiaGlobalResponse(payload, number);
102
+ }
@@ -24,7 +24,8 @@
24
24
  "mode": "automatic",
25
25
  "adapter": "asendia",
26
26
  "steps": [
27
- "direct"
27
+ "direct",
28
+ "trawl"
28
29
  ],
29
30
  "localClocks": true
30
31
  },
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "id": "uniuni",
3
3
  "displayName": "UniUni",
4
- "aliases": [],
4
+ "aliases": [
5
+ "UNI Express"
6
+ ],
5
7
  "region": {
6
8
  "countries": [
7
9
  "CA",
@@ -56,13 +56,19 @@ export function deliveryHandoff(carrier, number, result) {
56
56
  basis = 'reference';
57
57
  }
58
58
  // An origin that follows the parcel under its delivery carrier's number has
59
- // no second reference to name. The catalog lists that carrier beside the
60
- // origin for the shape; it has to be the only one at home where the parcel goes.
59
+ // no second reference to name. That carrier delivers where the parcel goes:
60
+ // the one carrier that issues the shape (an S10's issuer does not deliver
61
+ // it), else the only one at home there that the catalog lists beside the
62
+ // origin for the shape.
61
63
  if (!target && !result.delivery_tracking_number && followsPartnerNumbers(carrier)) {
62
- const candidates = detectCarrierMatch(number).candidates;
64
+ const detected = detectCarrierMatch(number);
63
65
  const country = countryCode(destination);
64
- const local = country && candidates.includes(carrier) ? candidates.filter((candidate) => candidate !== carrier
65
- && CARRIER_DEFINITIONS[candidate]?.countries?.[0] === country) : [];
66
+ const issuer = detected.confidence === 'high' && detected.carrier !== carrier && !isValidS10TrackingNumber(number)
67
+ ? detected.carrier : null;
68
+ const local = !country ? []
69
+ : issuer ? [issuer].filter((candidate) => CARRIER_DEFINITIONS[candidate]?.countries?.includes(country))
70
+ : detected.candidates.includes(carrier) ? detected.candidates.filter((candidate) => candidate !== carrier
71
+ && CARRIER_DEFINITIONS[candidate]?.countries?.[0] === country) : [];
66
72
  if (local.length === 1) {
67
73
  target = local[0];
68
74
  basis = 'reference';
@@ -32,6 +32,8 @@ export interface TrawlCapturedResponse {
32
32
  error: string | null;
33
33
  /** 17TRACK's matching browser request included the supplied delivery postcode. */
34
34
  postcodeSubmitted?: boolean;
35
+ /** A carrier helper bound this response's request to one whole tracking number. */
36
+ trackingNumberSubmitted?: string;
35
37
  }
36
38
  export interface TrawlScrapeResponse {
37
39
  url: string;
@@ -79,6 +79,8 @@ function captured(value, window = 'first') {
79
79
  base64Encoded: entry.base64Encoded === true,
80
80
  error: typeof entry.error === 'string' ? entry.error : entry.error ? 'error' : null,
81
81
  ...(entry.postcodeSubmitted === true ? { postcodeSubmitted: true } : {}),
82
+ ...(typeof entry.trackingNumberSubmitted === 'string' && /^[A-Z0-9]{6,40}$/.test(entry.trackingNumberSubmitted)
83
+ ? { trackingNumberSubmitted: entry.trackingNumberSubmitted } : {}),
82
84
  }));
83
85
  }
84
86
  /**
@@ -3057,6 +3057,9 @@
3057
3057
  "uniuni": {
3058
3058
  "displayName": "UniUni",
3059
3059
  "color": "#f68841",
3060
+ "aliases": [
3061
+ "UNI Express"
3062
+ ],
3060
3063
  "countries": [
3061
3064
  "CA",
3062
3065
  "US"
@@ -2083,6 +2083,7 @@ export declare const CARRIER_CATALOG: {
2083
2083
  readonly uniuni: {
2084
2084
  readonly displayName: "UniUni";
2085
2085
  readonly color: "#f68841";
2086
+ readonly aliases: readonly ["UNI Express"];
2086
2087
  readonly countries: readonly ["CA", "US"];
2087
2088
  readonly selectable: true;
2088
2089
  readonly timezone: "UTC";
@@ -3058,6 +3058,9 @@ export const CARRIER_CATALOG = {
3058
3058
  "uniuni": {
3059
3059
  "displayName": "UniUni",
3060
3060
  "color": "#f68841",
3061
+ "aliases": [
3062
+ "UNI Express"
3063
+ ],
3061
3064
  "countries": [
3062
3065
  "CA",
3063
3066
  "US"
@@ -178,11 +178,13 @@ const HOME_CLOCKS = {
178
178
  * (2026-10-10): Korea Post's page, Bring's offsets (in Denmark too) and
179
179
  * Parcelforce's for Royal Mail; Japan Post's, which its portal gives as local
180
180
  * times, at 10:27 for a delivery in Japan that Cainiao dates 10:27 GMT+9 and
181
- * Ship24 01:27Z (2026-10-11). Their home clock is a guess, which a location
182
- * in another country rules out.
181
+ * Ship24 01:27Z (2026-10-11). Pekaes' are the offset-less digits its own
182
+ * history gives and its page labels Polish time (2026-10-13). Their home
183
+ * clock is a guess, which a location in another country rules out.
183
184
  */
184
185
  const HOME_COUNTRIES = {
185
186
  southkoreapost: 'Asia/Seoul', postennorgenorwaypost: 'Europe/Oslo', royalmail: 'Europe/London', japanpost: 'Asia/Tokyo',
187
+ pekaes: 'Europe/Warsaw',
186
188
  };
187
189
  const UNITED_STATES = /^(?:us|usa|united states(?: of america)?)$/i;
188
190
  const POSTAL_CODE = /\s*\b(?:\d{5}(?:-\d{4})?|[A-Z]\d[A-Z] ?\d[A-Z]\d)$/i;
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.133.1"
5
+ "version": "0.134.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.133.2-main.1113",
3
+ "version": "0.134.0",
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",