universal-parcel-scraper 0.52.0-main.665 → 0.53.0-main.667

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/README.md CHANGED
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 2,992 such statuses, each filed under one stage.
158
+ The carrier folders record 2,993 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -1,6 +1,7 @@
1
1
  import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
3
  import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ import type { TrawlClient } from '../../core/transport/trawl.js';
4
5
  export declare function speeDeeProgressUrl(raw: string): string;
5
6
  /**
6
7
  * Whether a lookup failed because the host never answered. A reply that
@@ -13,6 +14,7 @@ export declare class SpeeDeeTracker {
13
14
  fetcher?: typeof fetch;
14
15
  recorder?: StepRecorder;
15
16
  userAgent?: string;
17
+ trawl?: TrawlClient | null;
16
18
  });
17
19
  fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
18
20
  }
@@ -46,7 +46,7 @@ export class SpeeDeeTracker {
46
46
  }
47
47
  async fetch(raw, context = {}) {
48
48
  const number = normalizeSpeeDeeNumber(raw);
49
- return runSteps({ carrier: 'spee-dee', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
49
+ return runSteps({ carrier: 'spee-dee', budgetMs: context.budgetMs ?? (this.options.trawl ? 30_000 : 15_000), signal: context.signal,
50
50
  recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
51
51
  let fetched;
52
52
  // Set once the status and headers arrive. A body that then breaks or
@@ -78,10 +78,38 @@ export class SpeeDeeTracker {
78
78
  if (REDIRECTS.includes(fetched.response.status))
79
79
  throw new SchemaError(PROVIDER, 'Spee-Dee tracking page moved');
80
80
  return parseSpeeDee(decodeText(fetched.bytes), number);
81
- } }]);
81
+ } }, {
82
+ id: 'trawl', enabled: Boolean(this.options.trawl), recovers: speeDeeUnanswered,
83
+ run: async ({ signal, remainingMs }) => {
84
+ const url = speeDeeProgressUrl(number);
85
+ // The remote network is the recovery; this page needs no browser or solver.
86
+ const timeoutMs = Math.max(1, Math.min(REQUEST_TIMEOUT_MS, Math.floor(remainingMs)));
87
+ let reply;
88
+ try {
89
+ reply = await this.options.trawl.scrape({ url, maxTier: 1, maxTimeout: timeoutMs }, {
90
+ provider: PROVIDER, timeoutMs, signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),
91
+ fetcher: this.options.fetcher,
92
+ maxBytes: 4_000_000, requireSolved: false,
93
+ });
94
+ }
95
+ catch (error) {
96
+ if (error instanceof UpstreamHttpError && [404, 410].includes(error.status)) {
97
+ throw new TransportError(PROVIDER, 'Spee-Dee remote tracking page is unavailable', { cause: error });
98
+ }
99
+ throw error;
100
+ }
101
+ if (reply.url !== url || reply.statusCode !== 200 || reply.tier !== 1) {
102
+ throw new SchemaError(PROVIDER, 'Spee-Dee remote tracking page changed');
103
+ }
104
+ if (Buffer.byteLength(reply.html) > MAX_BYTES)
105
+ throw new SchemaError(PROVIDER, 'Spee-Dee returned excessive tracking data');
106
+ return parseSpeeDee(reply.html, number);
107
+ },
108
+ }]);
82
109
  }
83
110
  }
84
111
  export const adapter = (environment) => {
85
- const tracker = new SpeeDeeTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
86
- return { id: 'spee-dee', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context) };
112
+ const tracker = new SpeeDeeTracker({ fetcher: environment.fetcher, recorder: environment.recorder,
113
+ userAgent: environment.userAgent, trawl: environment.trawl });
114
+ return { id: 'spee-dee', recordsSteps: true, steps: ['direct', 'trawl'], track: (input, context) => tracker.fetch(input.number, context) };
87
115
  };
@@ -16,7 +16,8 @@
16
16
  "mode": "automatic",
17
17
  "adapter": "spee-dee",
18
18
  "steps": [
19
- "direct"
19
+ "direct",
20
+ "trawl"
20
21
  ],
21
22
  "localClocks": true
22
23
  },
@@ -67,6 +67,7 @@ export const EVENT_STAGE_BY_CODE = {
67
67
  '1100': 'in_transit',
68
68
  '1201': 'in_transit',
69
69
  '1202': 'in_transit',
70
+ '1204': 'in_transit',
70
71
  '1213': 'in_transit',
71
72
  '1218': 'in_transit',
72
73
  '2102': 'ready_for_pickup',
@@ -424,6 +424,15 @@
424
424
  "firstSeen": "2026-09-12",
425
425
  "note": "Also carries a hard-coded fallback wording."
426
426
  },
427
+ {
428
+ "code": "1204",
429
+ "wording": "Consignment is being processed",
430
+ "language": "en",
431
+ "stage": "in_transit",
432
+ "confirmedBy": "live",
433
+ "firstSeen": "2026-10-09",
434
+ "note": "Scanned at a parcel centre, before it sorts the parcel for delivery."
435
+ },
427
436
  {
428
437
  "code": "1213",
429
438
  "wording": "Sorted for delivery",
@@ -8,7 +8,7 @@
8
8
  * response capture, cookies, user agent) and the legacy `/v1` command API
9
9
  * (`request.get`). Both are bounded like every other provider call.
10
10
  */
11
- import { CarrierError, UpstreamHttpError } from '../errors/index.js';
11
+ import { CarrierError, ChallengeError, UpstreamHttpError } from '../errors/index.js';
12
12
  import { decodeText, fetchBounded, parseJsonBytes } from './boundedFetch.js';
13
13
  import { cleanScalar } from './text.js';
14
14
  import { isRecord } from '../types.js';
@@ -26,6 +26,13 @@ const DEFAULT_MAX_BYTES = 10_000_000;
26
26
  */
27
27
  export const TRAWL_TRANSPORT_ALLOWANCE_MS = 15_000;
28
28
  const TRANSPORT_ALLOWANCE_MS = TRAWL_TRANSPORT_ALLOWANCE_MS;
29
+ function pageHttpError(provider, status, headers) {
30
+ const retryHeader = isRecord(headers)
31
+ ? Object.entries(headers).find(([key]) => key.toLowerCase() === 'retry-after')?.[1] : undefined;
32
+ const retryAfterMs = typeof retryHeader !== 'string' ? undefined : /^\d+$/.test(retryHeader.trim())
33
+ ? Number(retryHeader) * 1_000 : Date.parse(retryHeader) - Date.now();
34
+ return new UpstreamHttpError(provider, status, Number.isFinite(retryAfterMs) ? Math.max(0, retryAfterMs) : undefined);
35
+ }
29
36
  /**
30
37
  * End the call when its signal has aborted. A cancellation is rethrown as the
31
38
  * caller gave it. A deadline is a browser service that did not answer in
@@ -172,7 +179,7 @@ export class TrawlClient {
172
179
  }
173
180
  async scrapeOnce(request, options) {
174
181
  throwIfEnded(options);
175
- const { bytes } = await fetchBounded(this.scrapeUrl(), {
182
+ const { bytes, response } = await fetchBounded(this.scrapeUrl(), {
176
183
  method: 'POST',
177
184
  headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
178
185
  body: JSON.stringify(request),
@@ -181,17 +188,43 @@ export class TrawlClient {
181
188
  timeoutMs: options.timeoutMs + TRANSPORT_ALLOWANCE_MS,
182
189
  maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
183
190
  fetcher: this.requestFetcher(options),
191
+ // An HTTP-only lookup has exactly one upstream outcome. TRAWL wraps its
192
+ // blocked responses in HTTP 500, including upstream 429 and HTTP 200 walls.
193
+ allowHttpStatuses: request.maxTier === 1 ? [500] : undefined,
184
194
  });
185
195
  throwIfEnded(options);
186
- const value = parseJsonBytes(bytes, options.provider);
196
+ let value;
197
+ try {
198
+ value = parseJsonBytes(bytes, options.provider);
199
+ }
200
+ catch (error) {
201
+ if (response.status === 500)
202
+ throw new UpstreamHttpError(options.provider, 500);
203
+ throw error;
204
+ }
187
205
  if (!isRecord(value))
188
206
  throw new TrawlError(options.provider, 'The browser service returned an invalid response');
207
+ if (request.maxTier === 1 && value.error === 'Max tier reached without success'
208
+ && Array.isArray(value.timings) && value.timings.length === 1) {
209
+ const attempt = value.timings[0];
210
+ if (isRecord(attempt) && attempt.tier === 1 && ['blocked', 'needs-js', 'error'].includes(String(attempt.status))) {
211
+ if (Number.isInteger(attempt.statusCode) && Number(attempt.statusCode) >= 400) {
212
+ throw pageHttpError(options.provider, Number(attempt.statusCode), attempt.responseHeaders);
213
+ }
214
+ if (attempt.statusCode === 200 && ['blocked', 'needs-js'].includes(String(attempt.status))) {
215
+ throw new ChallengeError(options.provider, 'The tracking page requires browser verification');
216
+ }
217
+ }
218
+ }
219
+ if (response.status === 500)
220
+ throw new UpstreamHttpError(options.provider, 500);
189
221
  if (value.error) {
190
222
  throw new TrawlError(options.provider, cleanScalar(value.error, 200) || 'The browser service could not fetch the page');
191
223
  }
192
224
  const statusCode = Number(value.statusCode);
193
- if (Number.isInteger(statusCode) && statusCode >= 400)
194
- throw new UpstreamHttpError(options.provider, statusCode);
225
+ if (Number.isInteger(statusCode) && statusCode >= 400) {
226
+ throw pageHttpError(options.provider, statusCode, value.responseHeaders);
227
+ }
195
228
  const tier = Number(value.tier);
196
229
  if ((options.requireSolved ?? true) && (![2, 3].includes(tier) || statusCode !== 200)) {
197
230
  throw new TrawlError(options.provider, 'The browser service did not solve the page');
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.52.0"
5
+ "version": "0.53.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.52.0-main.665",
3
+ "version": "0.53.0-main.667",
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",