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 +3 -0
- package/dist/browser/scraper.js +3 -2
- package/dist/carriers/asendia/adapter.d.ts +2 -2
- package/dist/carriers/asendia/adapter.js +27 -5
- package/dist/carriers/asendia/browser.d.ts +5 -0
- package/dist/carriers/asendia/browser.js +102 -0
- package/dist/carriers/asendia/carrier.json +2 -1
- package/dist/carriers/uniuni/carrier.json +3 -1
- package/dist/core/catalog/handoff.js +11 -5
- package/dist/core/transport/trawl.d.ts +2 -0
- package/dist/core/transport/trawl.js +2 -0
- package/dist/data/catalog.json +3 -0
- package/dist/generated/catalog.d.ts +1 -0
- package/dist/generated/catalog.js +3 -0
- package/dist/providers/parcelsapp/adapter.js +4 -2
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/data/catalog.json
CHANGED
package/dist/browser/scraper.js
CHANGED
|
@@ -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)
|
|
12329
|
-
|
|
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,
|
|
32
|
-
*
|
|
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,
|
|
32
|
-
*
|
|
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
|
-
|
|
489
|
-
steps: ['direct'],
|
|
490
|
-
track: (input, 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
|
+
}
|
|
@@ -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.
|
|
60
|
-
//
|
|
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
|
|
64
|
+
const detected = detectCarrierMatch(number);
|
|
63
65
|
const country = countryCode(destination);
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
/**
|
package/dist/data/catalog.json
CHANGED
|
@@ -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";
|
|
@@ -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).
|
|
182
|
-
*
|
|
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;
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED