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 +1 -1
- package/dist/carriers/spee-dee/adapter.d.ts +2 -0
- package/dist/carriers/spee-dee/adapter.js +32 -4
- package/dist/carriers/spee-dee/carrier.json +2 -1
- package/dist/carriers/swiss-post/status.js +1 -0
- package/dist/carriers/swiss-post/statuses.json +9 -0
- package/dist/core/transport/trawl.js +38 -5
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
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,
|
|
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,
|
|
86
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
|
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');
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
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",
|