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.
@@ -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
  },
@@ -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
  /**
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.133.3"
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.3-main.1118",
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",