universal-parcel-scraper 0.133.3-main.1118 → 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/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/core/transport/trawl.d.ts +2 -0
- package/dist/core/transport/trawl.js +2 -0
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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/server/openapi.json
CHANGED
package/package.json
CHANGED