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

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.
@@ -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
  },
@@ -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.666",
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",