universal-parcel-scraper 0.25.3-main.367 → 0.26.1-main.369

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.
@@ -9,6 +9,7 @@ export declare function australiaPostApiUrl(raw: string): string;
9
9
  export declare function parse(payload: unknown, trackingNumber: string): CarrierResult;
10
10
  export interface AustraliaPostTrackerOptions {
11
11
  trawl: TrawlClient | null;
12
+ fetcher?: typeof fetch;
12
13
  timeoutMs?: number;
13
14
  recorder?: StepRecorder;
14
15
  }
@@ -1,7 +1,7 @@
1
- import { BudgetExceededError, ChallengeError, InvalidInputError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
- import { runSteps } from '../../core/runner/index.js';
1
+ import { BudgetExceededError, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
+ import { recoverableByDefault, runSteps } from '../../core/runner/index.js';
3
3
  import { explicitOffsetTime, epochMillisTime } from '../../core/time/index.js';
4
- import { clean } from '../../core/transport/index.js';
4
+ import { clean, fetchBounded } from '../../core/transport/index.js';
5
5
  import { isRecord } from '../../core/types.js';
6
6
  import { australiaPostStatus } from './status.js';
7
7
  const PROVIDER = 'Australia Post';
@@ -9,6 +9,14 @@ const DETAIL = 'https://auspost.com.au/mypost/track/details/';
9
9
  const API = 'https://digitalapi.auspost.com.au/shipments-gateway/v1/watchlist/shipments';
10
10
  const MAX_BYTES = 1_000_000;
11
11
  const TRANSPORT_ALLOWANCE_MS = 15_000;
12
+ const DIRECT_TIMEOUT_MS = 10_000;
13
+ // The gateway's bot protection passes the official Android app's HTTP client and
14
+ // channel; other clients get a captcha reply. So does fetch's default `Accept-Language: *`.
15
+ const APP_HEADERS = { Accept: 'application/json', 'Accept-Language': 'en-AU', AP_APP_ID: 'MYPOST', AP_CHANNEL_NAME: 'ANDROID',
16
+ 'User-Agent': 'okhttp/4.12.0' };
17
+ // fetch's `no-store` adds a browser reload's cache headers, which the gateway refuses
18
+ // from this client. `no-cache` still takes every reply from the gateway.
19
+ const asApp = (fetcher) => (input, init) => (fetcher ?? fetch)(input, { ...init, cache: 'no-cache' });
12
20
  export function normalizeAustraliaPostNumber(raw) {
13
21
  const number = raw.toUpperCase().replace(/[\s.-]/g, '');
14
22
  if (!/^(?=.*\d)[A-Z0-9]{10,34}$/.test(number)) {
@@ -32,6 +40,10 @@ export function parse(payload, trackingNumber) {
32
40
  if (entries.length !== 1)
33
41
  throw new SchemaError(PROVIDER, 'Australia Post returned a different or ambiguous lookup');
34
42
  const entry = entries[0];
43
+ // The gateway answers a reference it cannot process with an internal error, which says
44
+ // nothing about the parcel.
45
+ if (entry.status === 500 && entry.shipment === undefined)
46
+ throw new IndeterminateError(PROVIDER);
35
47
  if (entry.status === 400 && isRecord(entry.error) && entry.error.errorCode === 21
36
48
  && entry.error.error === 'Invalid Tracking ID' && entry.error.status === 'Failed'
37
49
  && entry.shipment === undefined)
@@ -122,12 +134,32 @@ export class AustraliaPostTracker {
122
134
  if (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > 60_000)
123
135
  throw new TypeError('Australia Post budget must be between 1 and 60000 ms');
124
136
  context.signal?.throwIfAborted();
125
- if (budgetMs <= TRANSPORT_ALLOWANCE_MS)
126
- throw new BudgetExceededError(PROVIDER, budgetMs);
127
137
  const trawl = this.options.trawl;
128
- if (!trawl)
129
- throw new ChallengeError(PROVIDER, 'Australia Post requires the browser tracking service');
130
138
  return runSteps({ carrier: 'australia-post', budgetMs, signal: context.signal, recorder: this.options.recorder }, [{
139
+ id: 'direct', run: async ({ signal, remainingMs }) => {
140
+ const { response, bytes } = await fetchBounded(australiaPostApiUrl(number), { signal, headers: APP_HEADERS }, {
141
+ provider: PROVIDER, timeoutMs: Math.max(1, Math.min(DIRECT_TIMEOUT_MS, Math.floor(remainingMs))),
142
+ maxBytes: MAX_BYTES, allowHttpStatuses: [401, 403, 429], fetcher: asApp(this.options.fetcher),
143
+ });
144
+ if (response.status === 429) {
145
+ const retry = response.headers.get('retry-after');
146
+ throw new RateLimitedError(PROVIDER, retry && /^\d+$/.test(retry) ? Number(retry) * 1000 : undefined);
147
+ }
148
+ if (response.status !== 200) {
149
+ throw new ChallengeError(PROVIDER, trawl ? undefined : 'Australia Post requires the browser tracking service');
150
+ }
151
+ let payload;
152
+ try {
153
+ payload = JSON.parse(new TextDecoder().decode(bytes));
154
+ }
155
+ catch {
156
+ throw new SchemaError(PROVIDER, 'Australia Post returned invalid tracking JSON');
157
+ }
158
+ return parse(payload, number);
159
+ },
160
+ }, {
161
+ // The browser reads the same gateway, so it cannot improve on an inconclusive entry.
162
+ enabled: trawl !== null, recovers: (error) => recoverableByDefault(error) && !(error instanceof IndeterminateError),
131
163
  id: 'trawl', run: async ({ signal, remainingMs }) => {
132
164
  const timeoutMs = Math.floor(remainingMs - TRANSPORT_ALLOWANCE_MS);
133
165
  if (timeoutMs < 1)
@@ -170,6 +202,6 @@ export class AustraliaPostTracker {
170
202
  }
171
203
  }
172
204
  export const adapter = (environment) => {
173
- const tracker = new AustraliaPostTracker({ trawl: environment.trawl, recorder: environment.recorder });
174
- return { id: 'australia-post', recordsSteps: true, steps: ['trawl'], track: (input, context) => tracker.fetch(input.number, context) };
205
+ const tracker = new AustraliaPostTracker({ trawl: environment.trawl, fetcher: environment.fetcher, recorder: environment.recorder });
206
+ return { id: 'australia-post', recordsSteps: true, steps: ['direct', 'trawl'], track: (input, context) => tracker.fetch(input.number, context) };
175
207
  };
@@ -16,6 +16,7 @@
16
16
  "mode": "automatic",
17
17
  "adapter": "australia-post",
18
18
  "steps": [
19
+ "direct",
19
20
  "trawl"
20
21
  ]
21
22
  },
@@ -1,11 +1,15 @@
1
1
  import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
2
2
  import { type StepRecorder } from '../../core/telemetry/index.js';
3
3
  export declare const UKRPOSHTA_API = "https://track.ukrposhta.ua/php/track_new.php";
4
+ export declare const UKRPOSHTA_STATUS_API = "https://www.ukrposhta.ua/status-tracking/0.0.1/statuses";
4
5
  export declare function ukrposhtaTrackingUrl(number: string): string;
5
6
  export declare class UkrposhtaTracker {
6
7
  private readonly options;
7
8
  constructor(options?: {
8
9
  executablePath?: string | null;
10
+ token?: string | null;
11
+ userAgent?: string;
12
+ fetcher?: typeof fetch;
9
13
  recorder?: StepRecorder;
10
14
  });
11
15
  fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
@@ -1,11 +1,51 @@
1
1
  import { withLocalBrowser } from '../../core/transport/localBrowser.js';
2
- import { BudgetExceededError, ChallengeError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
3
- import { runSteps } from '../../core/runner/index.js';
2
+ import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
3
+ import { BudgetExceededError, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
4
+ import { recoverableByDefault, runSteps } from '../../core/runner/index.js';
4
5
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { fetchBounded } from '../../core/transport/boundedFetch.js';
5
7
  import { isRecord } from '../../core/types.js';
6
- import { normalizeUkrposhtaNumber, parseUkrposhtaHistory, parseUkrposhtaOverview } from './parser.js';
8
+ import { normalizeUkrposhtaNumber, parseUkrposhtaHistory, parseUkrposhtaOverview, parseUkrposhtaStatuses } from './parser.js';
7
9
  export const UKRPOSHTA_API = 'https://track.ukrposhta.ua/php/track_new.php';
10
+ export const UKRPOSHTA_STATUS_API = 'https://www.ukrposhta.ua/status-tracking/0.0.1/statuses';
8
11
  const MAX_BYTES = 1_000_000;
12
+ const DIRECT_TIMEOUT_MS = 10_000;
13
+ // The Android app's shared application bearer is deliberately distributed with this adapter.
14
+ const APPLICATION_BEARER = 'c3e02b53-3b1d-386e-b676-141ffa054c57';
15
+ /** The API's bearer, or null when none is configured or the value could not be a header. */
16
+ function bearer(value) {
17
+ const token = value?.trim().replace(/^Bearer\s+/i, '') ?? '';
18
+ return /^[\w.~+/=-]{8,512}$/.test(token) ? token : null;
19
+ }
20
+ async function readStatuses(number, token, signal, timeoutMs, userAgent = 'Mozilla/5.0', fetcher) {
21
+ const url = new URL(UKRPOSHTA_STATUS_API);
22
+ url.searchParams.set('barcode', number);
23
+ url.searchParams.set('lang', 'en');
24
+ const { response, bytes } = await fetchBounded(url, { signal, headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': userAgent } }, {
25
+ provider: 'ukrposhta', timeoutMs, maxBytes: MAX_BYTES, allowHttpStatuses: [401, 403, 404, 429], fetcher,
26
+ });
27
+ // The refusal of a credential is an HTML page; it says nothing about the parcel.
28
+ if ([401, 403].includes(response.status))
29
+ throw new ChallengeError('ukrposhta', 'Ukrposhta refused the tracking API credential');
30
+ if (response.status === 429) {
31
+ const retryAfter = response.headers.get('retry-after');
32
+ throw new RateLimitedError('ukrposhta', retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1_000 : undefined);
33
+ }
34
+ let payload;
35
+ try {
36
+ payload = JSON.parse(new TextDecoder().decode(bytes));
37
+ }
38
+ catch (cause) {
39
+ throw new SchemaError('ukrposhta', 'Ukrposhta returned invalid tracking JSON', { cause });
40
+ }
41
+ if (response.status === 404) {
42
+ // The reply names no barcode, as on the portal: it cannot establish absence.
43
+ if (isRecord(payload) && payload.message === 'Shipment not found')
44
+ throw new IndeterminateError('ukrposhta');
45
+ throw new TransportError('ukrposhta', 'Ukrposhta tracking endpoint is unavailable', { status: 404 });
46
+ }
47
+ return parseUkrposhtaStatuses(payload, number);
48
+ }
9
49
  export function ukrposhtaTrackingUrl(number) {
10
50
  const url = new URL('https://track.ukrposhta.ua/en/');
11
51
  url.searchParams.set('barcode', number);
@@ -97,14 +137,23 @@ export class UkrposhtaTracker {
97
137
  const budgetMs = context.budgetMs ?? 45_000;
98
138
  if (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > 60_000)
99
139
  throw new TypeError('Ukrposhta budget must be between 1 and 60000 ms');
100
- if (!this.options.executablePath)
101
- throw new ChallengeError('ukrposhta', 'Ukrposhta requires a configured tracking browser');
140
+ const token = bearer(this.options.token);
141
+ const executablePath = this.options.executablePath;
142
+ if (!token && !executablePath)
143
+ throw new ChallengeError('ukrposhta', 'Ukrposhta requires a tracking API token or a configured tracking browser');
102
144
  return runSteps({ carrier: 'ukrposhta', budgetMs, signal: context.signal, recorder: this.options.recorder ?? NOOP_RECORDER }, [
103
- { id: 'browser', run: ({ signal, remainingMs }) => localBrowser(number, this.options.executablePath, signal, Math.max(1, Math.floor(remainingMs))) },
145
+ { id: 'direct', enabled: token !== null,
146
+ run: ({ signal, remainingMs }) => readStatuses(number, token, signal, Math.max(1, Math.min(DIRECT_TIMEOUT_MS, Math.floor(remainingMs))), this.options.userAgent, this.options.fetcher) },
147
+ // The portal reads the same domestic records, so it cannot improve on an unknown
148
+ // domestic barcode. An international reference still gets its second source.
149
+ { id: 'browser', enabled: Boolean(executablePath),
150
+ recovers: error => recoverableByDefault(error) && (!(error instanceof IndeterminateError) || isValidS10TrackingNumber(number)),
151
+ run: ({ signal, remainingMs }) => localBrowser(number, executablePath, signal, Math.max(1, Math.floor(remainingMs))) },
104
152
  ]);
105
153
  }
106
154
  }
107
155
  export const adapter = environment => {
108
- const tracker = new UkrposhtaTracker({ executablePath: environment.browserExecutablePath, recorder: environment.recorder });
109
- return { id: 'ukrposhta', recordsSteps: true, steps: ['browser'], track: (input, context) => tracker.fetch(input.number, context) };
156
+ const tracker = new UkrposhtaTracker({ executablePath: environment.browserExecutablePath, token: environment.env.UKRPOSHTA_TRACKING_TOKEN?.trim() || APPLICATION_BEARER,
157
+ userAgent: environment.userAgent, fetcher: environment.fetcher, recorder: environment.recorder });
158
+ return { id: 'ukrposhta', recordsSteps: true, steps: ['direct', 'browser'], track: (input, context) => tracker.fetch(input.number, context) };
110
159
  };
@@ -16,6 +16,7 @@
16
16
  "mode": "automatic",
17
17
  "adapter": "ukrposhta",
18
18
  "steps": [
19
+ "direct",
19
20
  "browser"
20
21
  ],
21
22
  "localClocks": true
@@ -12,4 +12,9 @@ export interface UkrposhtaOverview {
12
12
  country: string;
13
13
  }
14
14
  export declare function parseUkrposhtaOverview(payload: unknown, requested: string): UkrposhtaOverview;
15
+ /**
16
+ * The status-tracking API's reply to one barcode: a bare array of scans, each naming the
17
+ * barcode and its position in the history.
18
+ */
19
+ export declare function parseUkrposhtaStatuses(payload: unknown, requested: string): CarrierResult;
15
20
  export declare function parseUkrposhtaHistory(payload: unknown, overview: UkrposhtaOverview): CarrierResult;
@@ -1,7 +1,7 @@
1
1
  import { DateTime } from 'luxon';
2
2
  import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
3
3
  import { ChallengeError, IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
4
- import { clean } from '../../core/transport/text.js';
4
+ import { clean, cleanScalar } from '../../core/transport/text.js';
5
5
  import { isRecord } from '../../core/types.js';
6
6
  import { ukrposhtaReturnCue, ukrposhtaStatus } from './status.js';
7
7
  const PROVIDER = 'ukrposhta';
@@ -60,6 +60,73 @@ export function parseUkrposhtaOverview(payload, requested) {
60
60
  return { number, count: Number(row.step), date: text(row.date, 'overview clock'), code: text(row.event, 'overview event code', true),
61
61
  label: text(row.eventName, 'overview status', true), location: text(row.name, 'overview location'), country: text(row.country, 'overview country') };
62
62
  }
63
+ /** One result from current-first scans, following a return decision into its own leg. */
64
+ function project(rows) {
65
+ const latest = rows[0];
66
+ // Both native histories are current-first. Keep that order across foreign
67
+ // wall clocks or missing dates instead of sorting them as UTC instants.
68
+ let returnLeg = false;
69
+ const events = new Array(rows.length);
70
+ for (let index = rows.length - 1; index >= 0; index--) {
71
+ const row = rows[index];
72
+ if (ukrposhtaReturnCue(row.label))
73
+ returnLeg = true;
74
+ let classified = ukrposhtaStatus(row.code);
75
+ if (row.reason === '65' || row.reason === '66')
76
+ classified = { status: 'exception', stage: 'exception' };
77
+ if (classified?.stage === 'delivered' && returnLeg)
78
+ classified = { status: 'exception', stage: 'returned' };
79
+ const local = ukrposhtaWallClock(row.date);
80
+ events[index] = {
81
+ description: row.label, provider_code: row.code,
82
+ ...(row.location || row.country ? { location: [row.location, row.country].filter(Boolean).join(', ') } : {}),
83
+ ...(local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
84
+ ...(classified ? { stage: classified.stage } : {}), ...(returnLeg ? { provider_leg: 'return' } : {}),
85
+ };
86
+ }
87
+ const current = events[0];
88
+ const stage = current.stage;
89
+ const status = stage === 'returned' || stage === 'exception' ? 'exception' : ukrposhtaStatus(latest.code)?.status ?? 'unknown';
90
+ const seen = new Set();
91
+ const deduplicated = events.filter(event => { const key = JSON.stringify(event); if (seen.has(key))
92
+ return false; seen.add(key); return true; });
93
+ return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: null, events: deduplicated.slice(0, 100) };
94
+ }
95
+ /**
96
+ * The status-tracking API's reply to one barcode: a bare array of scans, each naming the
97
+ * barcode and its position in the history.
98
+ */
99
+ export function parseUkrposhtaStatuses(payload, requested) {
100
+ const number = normalizeUkrposhtaNumber(requested);
101
+ if (!Array.isArray(payload))
102
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned an invalid tracking response');
103
+ if (!payload.length)
104
+ throw new IndeterminateError(PROVIDER);
105
+ if (payload.length > MAX_SCANS)
106
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned excessive history');
107
+ const steps = new Set();
108
+ const rows = payload.map((raw) => {
109
+ if (!isRecord(raw))
110
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned an invalid history row');
111
+ if (text(raw.barcode, 'history identity', true).toUpperCase() !== number)
112
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned a different history identity');
113
+ if (!Number.isInteger(raw.step) || Number(raw.step) < 1 || steps.has(Number(raw.step)))
114
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned an invalid history position');
115
+ steps.add(Number(raw.step));
116
+ if (!['string', 'number'].includes(typeof raw.event))
117
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned an invalid scan code');
118
+ const reason = raw.eventReason_id == null ? '' : cleanScalar(raw.eventReason_id, 16);
119
+ if (raw.eventReason_id != null && !/^\d{1,9}$/.test(reason))
120
+ throw new SchemaError(PROVIDER, 'Ukrposhta returned an invalid scan reason code');
121
+ return { step: Number(raw.step), date: text(raw.date, 'scan clock'), code: cleanScalar(raw.event, 64),
122
+ label: text(raw.eventName, 'scan status', true), location: text(raw.name, 'scan location'), country: text(raw.country, 'scan country'),
123
+ reason };
124
+ });
125
+ if (rows.some(row => !row.code))
126
+ throw new SchemaError(PROVIDER, 'Ukrposhta omitted scan code');
127
+ // The API lists scans oldest first; the position, not the foreign wall clock, orders them.
128
+ return project(rows.sort((a, b) => b.step - a.step));
129
+ }
63
130
  export function parseUkrposhtaHistory(payload, overview) {
64
131
  const data = envelope(payload);
65
132
  if (!isRecord(data.from_to) || !Array.isArray(data.result))
@@ -99,32 +166,5 @@ export function parseUkrposhtaHistory(payload, overview) {
99
166
  if (!sameClock || latest.code !== overview.code || latest.label !== overview.label
100
167
  || latest.location !== overview.location || latest.country !== overview.country)
101
168
  throw new IndeterminateError(PROVIDER, 'Ukrposhta overview and current history disagree');
102
- // Both native histories are current-first. Keep that order across foreign
103
- // wall clocks or missing dates instead of sorting them as UTC instants.
104
- let returnLeg = false;
105
- const events = new Array(rows.length);
106
- for (let index = rows.length - 1; index >= 0; index--) {
107
- const row = rows[index];
108
- if (ukrposhtaReturnCue(row.label))
109
- returnLeg = true;
110
- let classified = ukrposhtaStatus(row.code);
111
- if (row.reason === '65' || row.reason === '66')
112
- classified = { status: 'exception', stage: 'exception' };
113
- if (classified?.stage === 'delivered' && returnLeg)
114
- classified = { status: 'exception', stage: 'returned' };
115
- const local = ukrposhtaWallClock(row.date);
116
- events[index] = {
117
- description: row.label, provider_code: row.code,
118
- ...(row.location || row.country ? { location: [row.location, row.country].filter(Boolean).join(', ') } : {}),
119
- ...(local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
120
- ...(classified ? { stage: classified.stage } : {}), ...(returnLeg ? { provider_leg: 'return' } : {}),
121
- };
122
- }
123
- const current = events[0];
124
- const stage = current.stage;
125
- const status = stage === 'returned' || stage === 'exception' ? 'exception' : ukrposhtaStatus(latest.code)?.status ?? 'unknown';
126
- const seen = new Set();
127
- const deduplicated = events.filter(event => { const key = JSON.stringify(event); if (seen.has(key))
128
- return false; seen.add(key); return true; });
129
- return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: null, events: deduplicated.slice(0, 100) };
169
+ return project(rows);
130
170
  }
@@ -30,5 +30,5 @@ export function ukrposhtaStatus(code) {
30
30
  // "Returned to Sender" is the decision to start the journey back. The
31
31
  // separate "Return: Delivered to Sender" scan confirms its completion.
32
32
  export function ukrposhtaReturnCue(label) {
33
- return /^Return: /i.test(label) || /^Returned to Sender(?:\s|\()/i.test(label);
33
+ return /^Return: /i.test(label) || /^Returned to Sender(?:\s|\()/i.test(label) || /^Повернення[.:]/i.test(label);
34
34
  }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.25.3"
5
+ "version": "0.26.1"
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.25.3-main.367",
3
+ "version": "0.26.1-main.369",
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",