universal-parcel-scraper 0.3.1-main.311 → 0.3.1-main.313
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/dist/carriers/amazon-shipping/adapter.js +1 -1
- package/dist/carriers/ciblex/parser.js +1 -1
- package/dist/carriers/correos-spain/adapter.js +2 -1
- package/dist/carriers/ctt/adapter.js +11 -15
- package/dist/carriers/ctt-express/parser.js +2 -1
- package/dist/carriers/dachser/adapter.js +3 -3
- package/dist/carriers/dachser/status.js +1 -1
- package/dist/carriers/dhl-ecommerce/status.js +1 -1
- package/dist/carriers/dpd/adapter.js +1 -1
- package/dist/carriers/estafeta/parser.js +1 -1
- package/dist/carriers/fedex/adapter.js +2 -1
- package/dist/carriers/gls-de/adapter.js +2 -2
- package/dist/carriers/heppner/adapter.js +1 -1
- package/dist/carriers/hermes/adapter.js +15 -10
- package/dist/carriers/hermes/status.js +1 -1
- package/dist/carriers/hermes-de/adapter.js +1 -1
- package/dist/carriers/india-post/adapter.js +1 -1
- package/dist/carriers/inpost/adapter.js +33 -13
- package/dist/carriers/mondial-relay/adapter.js +1 -1
- package/dist/carriers/nacex/parser.js +1 -1
- package/dist/carriers/poste-italiane/adapter.js +1 -1
- package/dist/carriers/sunyou/adapter.js +2 -2
- package/dist/carriers/swiss-post/adapter.js +3 -2
- package/dist/carriers/the-courier-guy/parser.js +3 -2
- package/dist/carriers/ukrposhta/parser.js +1 -1
- package/dist/carriers/uniuni/parser.js +2 -1
- package/dist/carriers/ups/adapter.js +2 -2
- package/dist/carriers/yto/parser.js +2 -1
- package/dist/carriers/yunda/adapter.js +2 -2
- package/dist/core/transport/trawl.js +2 -2
- package/dist/server/index.js +11 -4
- package/dist/server/openapi.json +1 -1
- package/package.json +2 -2
|
@@ -181,7 +181,7 @@ export function parseAmazonShippingTrackingResponse(payload, zone = 'Europe/Pari
|
|
|
181
181
|
const identitySummary = isRecord(progress.summary) ? progress.summary : {};
|
|
182
182
|
const identityMetadata = isRecord(identitySummary.metadata) ? identitySummary.metadata : {};
|
|
183
183
|
for (const identity of [payload.trackingId, payload.trackingID, progress.trackingId, metadataValue(identityMetadata, 'trackingId')]) {
|
|
184
|
-
if (trackingNumber && identity != null &&
|
|
184
|
+
if (trackingNumber && identity != null && (typeof identity !== 'string' || identity.toUpperCase() !== trackingNumber)) {
|
|
185
185
|
throw new SchemaError(PROVIDER, 'Amazon Shipping returned a different tracking number');
|
|
186
186
|
}
|
|
187
187
|
}
|
|
@@ -33,7 +33,7 @@ function safeLocation(raw) {
|
|
|
33
33
|
const value = clean(raw, 100);
|
|
34
34
|
// Free-form places can contain the recipient address. Retain only the
|
|
35
35
|
// established operational-depot label with a repeated department code.
|
|
36
|
-
return /^([\p{Letter}\p{Mark} .'
|
|
36
|
+
return /^([\p{Letter}\p{Mark} .'/-]{1,70}) (\d{2,3}) \(\2\)$/u.test(value) ? value : '';
|
|
37
37
|
}
|
|
38
38
|
export function parseCiblexTrackingHtml(html, raw) {
|
|
39
39
|
const number = normalizeCiblexTrackingNumber(raw);
|
|
@@ -58,7 +58,8 @@ export function parseCorreosSpainTrackingResponse(payload, trackingNumber) {
|
|
|
58
58
|
if (returned !== requested)
|
|
59
59
|
throw new SchemaError('Correos', 'Correos returned a different shipment');
|
|
60
60
|
const error = envelope.error;
|
|
61
|
-
if (!isRecord(error) || error.codError
|
|
61
|
+
if (!isRecord(error) || (typeof error.codError !== 'string'
|
|
62
|
+
&& (typeof error.codError !== 'number' || !Number.isFinite(error.codError)))) {
|
|
62
63
|
throw new SchemaError('Correos', 'Correos returned a response without a result envelope');
|
|
63
64
|
}
|
|
64
65
|
// Any non-zero codError (e.g. "3" Sin Trazabilidad) means unknown or
|
|
@@ -2,7 +2,7 @@ import { lookupBudget } from '../../core/adapter/index.js';
|
|
|
2
2
|
import { IndeterminateError, InvalidInputError, MaintenanceError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
3
|
import { isValidS10TrackingNumber } from '../../core/detection/index.js';
|
|
4
4
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
5
|
-
import { clean, decodeText, escapeRegExp, fetchBounded, parseJsonBytes,
|
|
5
|
+
import { clean, decodeText, escapeRegExp, fetchBounded, parseJsonBytes, } from '../../core/transport/index.js';
|
|
6
6
|
import { isRecord } from '../../core/types.js';
|
|
7
7
|
import { classifyCttStatus } from './status.js';
|
|
8
8
|
// Protocol provenance:
|
|
@@ -17,8 +17,7 @@ import { classifyCttStatus } from './status.js';
|
|
|
17
17
|
// rotates on every frontend deploy (derived at runtime from keyless version
|
|
18
18
|
// endpoints and the screen bundle — never pinned). A browser User-Agent is
|
|
19
19
|
// mandatory (Cloudflare error 1010 otherwise).
|
|
20
|
-
// -
|
|
21
|
-
// history mapped: Found:true carries ObjectEventsFromQuery; Found:false is
|
|
20
|
+
// - Found:true carries ObjectEventsFromQuery; Found:false is
|
|
22
21
|
// BOTH genuine unknown and backend outage, told apart only via the sibling
|
|
23
22
|
// DataActionCheckIPLocked call (made solely on Found:false — a found parcel
|
|
24
23
|
// already proves health).
|
|
@@ -178,7 +177,10 @@ export class CttTracker {
|
|
|
178
177
|
IPClient: '',
|
|
179
178
|
}, null, true, true, budget);
|
|
180
179
|
const record = isRecord(payload.data) ? payload.data.ObjectEventsFromQuery : undefined;
|
|
181
|
-
if (!isRecord(record) ||
|
|
180
|
+
if (!isRecord(record) || typeof record.Found !== 'boolean') {
|
|
181
|
+
throw new CttApiError('missing shipment Found flag');
|
|
182
|
+
}
|
|
183
|
+
if (!record.Found) {
|
|
182
184
|
// Found:false is both genuine unknown and backend outage: a found parcel
|
|
183
185
|
// already proves health, so the sibling check runs solely on negatives.
|
|
184
186
|
if (await this.isMaintenance(budget))
|
|
@@ -190,6 +192,8 @@ export class CttTracker {
|
|
|
190
192
|
async isMaintenance(budget) {
|
|
191
193
|
const payload = await this.callAction(MAINTENANCE_ACTION, {}, null, true, true, budget);
|
|
192
194
|
const data = isRecord(payload.data) ? payload.data : {};
|
|
195
|
+
if (typeof data.IsMaintenance !== 'boolean')
|
|
196
|
+
throw new CttApiError('missing maintenance flag');
|
|
193
197
|
return data.IsMaintenance === true;
|
|
194
198
|
}
|
|
195
199
|
async callAction(action, variables, session, retrySession, retryVersion, budget) {
|
|
@@ -217,7 +221,7 @@ export class CttTracker {
|
|
|
217
221
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
218
222
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
219
223
|
retryTransient: true,
|
|
220
|
-
|
|
224
|
+
allowHttpStatuses: [403],
|
|
221
225
|
fetcher: this.fetcher,
|
|
222
226
|
});
|
|
223
227
|
if (response.status === 403) {
|
|
@@ -227,8 +231,6 @@ export class CttTracker {
|
|
|
227
231
|
}
|
|
228
232
|
throw new CttApiError('anonymous session bootstrap failed');
|
|
229
233
|
}
|
|
230
|
-
if (!response.ok)
|
|
231
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
232
234
|
const payload = parseJsonBytes(bytes, 'CTT tracking');
|
|
233
235
|
if (!isRecord(payload))
|
|
234
236
|
throw new CttApiError('unexpected body (not a JSON object)');
|
|
@@ -256,7 +258,7 @@ export class CttTracker {
|
|
|
256
258
|
async ensureModuleVersion(budget) {
|
|
257
259
|
if (this.moduleVersion !== null)
|
|
258
260
|
return;
|
|
259
|
-
const {
|
|
261
|
+
const { bytes } = await fetchBounded(MODULE_VERSION_URL, {
|
|
260
262
|
signal: budget.signal,
|
|
261
263
|
headers: { Accept: 'application/json', 'User-Agent': BROWSER_USER_AGENT },
|
|
262
264
|
}, {
|
|
@@ -264,11 +266,8 @@ export class CttTracker {
|
|
|
264
266
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
265
267
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
266
268
|
retryTransient: true,
|
|
267
|
-
allowHttpError: true,
|
|
268
269
|
fetcher: this.fetcher,
|
|
269
270
|
});
|
|
270
|
-
if (!response.ok)
|
|
271
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
272
271
|
const payload = parseJsonBytes(bytes, 'CTT tracking');
|
|
273
272
|
const token = isRecord(payload) && typeof payload.versionToken === 'string' ? payload.versionToken : '';
|
|
274
273
|
if (!token)
|
|
@@ -297,7 +296,7 @@ export class CttTracker {
|
|
|
297
296
|
return parseJsonBytes((await this.fetchBytes(url, budget)).bytes, 'CTT tracking');
|
|
298
297
|
}
|
|
299
298
|
async fetchBytes(url, budget) {
|
|
300
|
-
const {
|
|
299
|
+
const { bytes } = await fetchBounded(url, {
|
|
301
300
|
signal: budget.signal,
|
|
302
301
|
headers: { Accept: 'application/json, text/plain, */*', 'User-Agent': BROWSER_USER_AGENT },
|
|
303
302
|
}, {
|
|
@@ -305,11 +304,8 @@ export class CttTracker {
|
|
|
305
304
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
306
305
|
maxBytes: MAX_SCRIPT_BYTES,
|
|
307
306
|
retryTransient: true,
|
|
308
|
-
allowHttpError: true,
|
|
309
307
|
fetcher: this.fetcher,
|
|
310
308
|
});
|
|
311
|
-
if (!response.ok)
|
|
312
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
313
309
|
return { bytes };
|
|
314
310
|
}
|
|
315
311
|
}
|
|
@@ -49,9 +49,10 @@ export function parseCttExpress(payload, number) {
|
|
|
49
49
|
if (history.item_code !== `${requested}001`)
|
|
50
50
|
throw new SchemaError('CTT Express', 'CTT Express returned a different package');
|
|
51
51
|
const events = [];
|
|
52
|
+
const rows = history.events;
|
|
52
53
|
// Newest provider positions win equal-clock ties. Preserve duplicates until
|
|
53
54
|
// their chronological leg is known so an earlier return start is not lost.
|
|
54
|
-
for (const row of [...
|
|
55
|
+
for (const row of [...rows].reverse()) {
|
|
55
56
|
if (!isRecord(row) || row.type !== 'STATUS' || !['ITEM_STATUS_V2', 'ITEM_STATUS_CHANGE_V1'].includes(clean(row.source, 64))) {
|
|
56
57
|
throw new SchemaError('CTT Express', 'CTT Express returned an unsupported scan source');
|
|
57
58
|
}
|
|
@@ -3,7 +3,7 @@ export { validateDachserTrackingUrl } from '../../core/catalog/urls.js';
|
|
|
3
3
|
import { DateTime } from 'luxon';
|
|
4
4
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
5
5
|
import { InputRequiredError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
6
|
-
import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
6
|
+
import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
7
7
|
import { isRecord } from '../../core/types.js';
|
|
8
8
|
import { eventLabel, plainText, shipmentStatus } from './status.js';
|
|
9
9
|
const DACHSER_HOST = 'customeriberia.dachser.com';
|
|
@@ -20,7 +20,7 @@ const DATE_FORMATS = [
|
|
|
20
20
|
'yyyy-MM-dd',
|
|
21
21
|
];
|
|
22
22
|
function normalizeTrackingNumber(raw) {
|
|
23
|
-
return
|
|
23
|
+
return cleanScalar(raw, 64).replace(/[\s.-]/g, '').toUpperCase();
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
26
|
* Validate and canonicalize a pasted Dachser capability URL.
|
|
@@ -51,7 +51,7 @@ export function dachserApiUrl(trackingUrl, trackingNumber) {
|
|
|
51
51
|
* that do carry an offset keep it; everything else is read in Europe/Madrid.
|
|
52
52
|
*/
|
|
53
53
|
function parseDateTime(raw) {
|
|
54
|
-
const value =
|
|
54
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
55
55
|
if (!value)
|
|
56
56
|
return null;
|
|
57
57
|
let parsed = DateTime.fromISO(value, { zone: 'Europe/Madrid', setZone: true });
|
|
@@ -34,7 +34,7 @@ export function stageFor(event) {
|
|
|
34
34
|
// A terminal provider code outranks an intuitive translated label.
|
|
35
35
|
if (event.statusCode === 'delivered')
|
|
36
36
|
return 'delivered';
|
|
37
|
-
const translated = trackingLanguageStage(
|
|
37
|
+
const translated = trackingLanguageStage(typeof event.description === 'string' ? event.description : '');
|
|
38
38
|
if (translated)
|
|
39
39
|
return translated;
|
|
40
40
|
switch (event.statusCode) {
|
|
@@ -442,7 +442,7 @@ export function parseDPDTrackingHtml(html, trackingNumber) {
|
|
|
442
442
|
};
|
|
443
443
|
}
|
|
444
444
|
function durationSeconds(value, fallback) {
|
|
445
|
-
const match = /^(\d+)s?$/.exec(String(value
|
|
445
|
+
const match = /^(\d+)s?$/.exec(typeof value === 'string' || typeof value === 'number' ? String(value) : '');
|
|
446
446
|
return match ? Number(match[1]) : fallback;
|
|
447
447
|
}
|
|
448
448
|
/** `shared`, for as long as the lookup waiting on it is live: its own signal ends the wait, not the shared work. */
|
|
@@ -50,7 +50,7 @@ export function parseEstafetaLookup(html, rawNumber) {
|
|
|
50
50
|
throw new SchemaError('Estafeta', 'Estafeta returned a different history target');
|
|
51
51
|
const state = cards.find('.stateDescription.fontColorCurrentProcess');
|
|
52
52
|
const messages = cards.find('.fontColorCurrentProcessMessage');
|
|
53
|
-
const parts = messages.contents().filter((_, node) => node.
|
|
53
|
+
const parts = messages.contents().filter((_, node) => node.nodeType === 3).map((_, node) => clean($(node).text(), 100)).get().filter(Boolean);
|
|
54
54
|
if (state.length !== 1 || messages.length !== 1 || parts.length < 2)
|
|
55
55
|
throw new SchemaError('Estafeta', 'Estafeta returned incomplete latest activity');
|
|
56
56
|
return { number, guide, state: clean(state.text(), 200), latestClock: parts[0], latestDate: parts[1] };
|
|
@@ -279,7 +279,8 @@ export class FedExTracker {
|
|
|
279
279
|
throw error;
|
|
280
280
|
// An unreadable or unrelated reply; the rendered page may still name a challenge.
|
|
281
281
|
captureError ??= error instanceof SyntaxError
|
|
282
|
-
? new SchemaError('FedEx', 'FedEx returned unreadable tracking JSON', { cause: error })
|
|
282
|
+
? new SchemaError('FedEx', 'FedEx returned unreadable tracking JSON', { cause: error })
|
|
283
|
+
: error instanceof Error ? error : new SchemaError('FedEx', 'FedEx returned invalid tracking data', { cause: error });
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
if (parseFedExTrackingHtml(page.html) === 'challenged') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
2
2
|
import { NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
3
|
-
import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
3
|
+
import { cleanScalar, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
4
4
|
import { isRecord } from '../../core/types.js';
|
|
5
5
|
import { glsSwitzerlandDetailApiUrl, glsDeliveryReference, glsSwitzerlandOverviewApiUrl, GLSSwitzerlandTrackingError, normalizeGLSSwitzerlandPostcode, normalizeGLSSwitzerlandTrackingNumber, parseGLSSwitzerlandTrackingResponse, selectGLSParcel, } from '../gls-ch/adapter.js';
|
|
6
6
|
// GLS's GROUP recipient service covers German and Swiss parcels. Keep the
|
|
@@ -53,7 +53,7 @@ export class GLSGermanyTracker {
|
|
|
53
53
|
}
|
|
54
54
|
const owners = Array.isArray(parcel.owners) ? parcel.owners.filter(isRecord) : [];
|
|
55
55
|
const owner = owners.find((row) => row.type === 'REQUEST');
|
|
56
|
-
const detail = await this.request(glsSwitzerlandDetailApiUrl(String(parcel.tuNo), postcode, this.now(),
|
|
56
|
+
const detail = await this.request(glsSwitzerlandDetailApiUrl(String(parcel.tuNo), postcode, this.now(), cleanScalar(owner?.code, 64), '4,5'), budget);
|
|
57
57
|
try {
|
|
58
58
|
const result = parseGLSSwitzerlandTrackingResponse(detail, String(parcel.tuNo));
|
|
59
59
|
// Both services use CET/CEST; expose the regional timezone to clients.
|
|
@@ -80,7 +80,7 @@ export function parseHeppnerCapability(rawCapability, rawTrackingNumber, rawPost
|
|
|
80
80
|
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(capability)) {
|
|
81
81
|
throw new SchemaError('Heppner', 'Heppner returned an invalid tracking capability');
|
|
82
82
|
}
|
|
83
|
-
let decoded
|
|
83
|
+
let decoded;
|
|
84
84
|
try {
|
|
85
85
|
decoded = Buffer.from(capability, 'base64').toString('utf8');
|
|
86
86
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
2
2
|
import { NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
|
-
import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
3
|
+
import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
4
4
|
import { isRecord } from '../../core/types.js';
|
|
5
5
|
import { hermesEventStage, hermesStatus } from './status.js';
|
|
6
6
|
export { hermesStatus };
|
|
@@ -11,7 +11,7 @@ const HERMES_API = 'https://myhes.de/api/request/auftragsdaten';
|
|
|
11
11
|
const PROVIDER = 'Hermes';
|
|
12
12
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
13
13
|
function normalizeHermesTrackingNumber(raw) {
|
|
14
|
-
return
|
|
14
|
+
return cleanScalar(raw, 64).replace(/[\s.-]/g, '').toUpperCase();
|
|
15
15
|
}
|
|
16
16
|
export function parseHermesTrackingResponse(payload, trackingNumber) {
|
|
17
17
|
if (!isRecord(payload))
|
|
@@ -43,19 +43,24 @@ export function parseHermesTrackingResponse(payload, trackingNumber) {
|
|
|
43
43
|
&& order.auftragsart == null
|
|
44
44
|
&& order.statusjourneyDto == null)
|
|
45
45
|
throw new NotFoundError(PROVIDER);
|
|
46
|
-
const meaningfulEvents = rawEvents.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const meaningfulEvents = rawEvents.flatMap((event) => {
|
|
47
|
+
const description = event.sendungsstatus;
|
|
48
|
+
const time = event.sendungsstatusBuchungszeitpunkt;
|
|
49
|
+
if ((description != null && typeof description !== 'string') || (time != null && typeof time !== 'string')) {
|
|
50
|
+
throw new SchemaError(PROVIDER, 'Hermes returned invalid tracking history');
|
|
51
|
+
}
|
|
52
|
+
return description?.trim() && time?.trim() ? [{ description, time, statusId: event.sendungsstatusId }] : [];
|
|
53
|
+
});
|
|
54
|
+
meaningfulEvents.sort((left, right) => right.time.localeCompare(left.time));
|
|
50
55
|
const events = meaningfulEvents.map((event) => ({
|
|
51
|
-
time:
|
|
56
|
+
time: event.time,
|
|
52
57
|
location: '',
|
|
53
|
-
description:
|
|
54
|
-
stage: hermesEventStage(event.
|
|
58
|
+
description: event.description,
|
|
59
|
+
stage: hermesEventStage(event.statusId, event.description),
|
|
55
60
|
}));
|
|
56
61
|
return {
|
|
57
62
|
status: meaningfulEvents[0]
|
|
58
|
-
? hermesStatus(meaningfulEvents[0].
|
|
63
|
+
? hermesStatus(meaningfulEvents[0].statusId, meaningfulEvents[0].description)
|
|
59
64
|
: 'pending',
|
|
60
65
|
last_status_text: events[0]?.description ?? '',
|
|
61
66
|
last_update: events[0]?.time || null,
|
|
@@ -25,7 +25,7 @@ export const HERMES_STATUS = new Map([
|
|
|
25
25
|
[430, 'out_for_delivery'],
|
|
26
26
|
]);
|
|
27
27
|
function normalizedText(raw) {
|
|
28
|
-
return
|
|
28
|
+
return (typeof raw === 'string' ? raw : '').toLocaleLowerCase('de-DE').normalize('NFKD').replace(/\p{M}/gu, '');
|
|
29
29
|
}
|
|
30
30
|
export function hermesStatus(rawStatusId, rawDescription = '') {
|
|
31
31
|
if (!['string', 'number'].includes(typeof rawStatusId))
|
|
@@ -42,7 +42,7 @@ export function normalizeHermesGermanyNumber(raw) {
|
|
|
42
42
|
}
|
|
43
43
|
export function parseHermesGermanyResponse(payload, trackingNumber) {
|
|
44
44
|
const requested = normalizeHermesGermanyNumber(trackingNumber);
|
|
45
|
-
if (!Array.isArray(payload) || payload.
|
|
45
|
+
if (!Array.isArray(payload) || !payload.every(isRecord)) {
|
|
46
46
|
throw new SchemaError(CARRIER, 'Hermes Germany returned an invalid tracking response');
|
|
47
47
|
}
|
|
48
48
|
if (!payload.length)
|
|
@@ -417,7 +417,7 @@ export class IndiaPostTracker {
|
|
|
417
417
|
const initial = initialTrackComponent(page.html, normalized);
|
|
418
418
|
if (initial.status === 'Completed') {
|
|
419
419
|
const cached = parseIndiaPostTrackingHtml(page.html, normalized);
|
|
420
|
-
const syncedAt = Date.parse(
|
|
420
|
+
const syncedAt = typeof cached.source_synced_at === 'string' ? Date.parse(cached.source_synced_at) : NaN;
|
|
421
421
|
if (Number.isFinite(syncedAt) && this.now().getTime() - syncedAt < REFRESH_AFTER_MS)
|
|
422
422
|
return cached;
|
|
423
423
|
// What the page's Refresh button sends. A failed refresh still leaves
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
-
import { InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
2
|
+
import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
3
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
4
|
-
import { clean, fetchBounded, parseJsonBytes,
|
|
4
|
+
import { clean, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
5
5
|
import { isRecord } from '../../core/types.js';
|
|
6
6
|
import { classifyInpostStatus } from './status.js';
|
|
7
7
|
// Protocol provenance:
|
|
@@ -12,12 +12,9 @@ import { classifyInpostStatus } from './status.js';
|
|
|
12
12
|
// (api-shipx-pl.easypack24.net, keyless, but its success shape is
|
|
13
13
|
// unconfirmed) and the inposteasy.com per-country hubs below. Only the
|
|
14
14
|
// inposteasy hub is implemented here; ShipX remains a future lead.
|
|
15
|
-
// -
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// headers or account. Long-expired corpus numbers return the same 404.
|
|
19
|
-
// - Public cross-border status vocabulary live-confirmed on IT/PT/GB
|
|
20
|
-
// consignments 2026-08-31 by the prior-art client; the map lives in status.ts.
|
|
15
|
+
// - Unknown or expired numbers return an identity-bound NOT_FOUND problem,
|
|
16
|
+
// optionally JSON-encoded inside the tracking-error wrapper's detail field.
|
|
17
|
+
// - The public cross-border status vocabulary lives in status.ts.
|
|
21
18
|
const TRACKING_ENDPOINT = 'https://inposteasy.com/api/tracking';
|
|
22
19
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
23
20
|
/** The pause `fetchBounded` takes before its one retry of a failed request. */
|
|
@@ -34,6 +31,23 @@ export function normalizeInpostTrackingNumber(raw) {
|
|
|
34
31
|
}
|
|
35
32
|
return value;
|
|
36
33
|
}
|
|
34
|
+
function isNotFoundProblem(payload, trackingNumber) {
|
|
35
|
+
if (!isRecord(payload) || payload.status !== 404
|
|
36
|
+
|| payload.instance !== `/api/tracking/${trackingNumber}`)
|
|
37
|
+
return false;
|
|
38
|
+
if (payload.type === '/errors/external/not-found' && payload.title === 'NOT_FOUND')
|
|
39
|
+
return true;
|
|
40
|
+
if (payload.type !== '/errors/external/tracking-error' || typeof payload.detail !== 'string')
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
const nested = JSON.parse(payload.detail.replace(/^Tracking client failed\. /, ''));
|
|
44
|
+
return isRecord(nested) && nested.type === '/errors/external/not-found'
|
|
45
|
+
&& nested.title === 'NOT_FOUND' && nested.status === 404 && nested.instance === payload.instance;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
37
51
|
export function parseInpostTrackingResponse(payload, trackingNumber) {
|
|
38
52
|
const requested = normalizeInpostTrackingNumber(trackingNumber);
|
|
39
53
|
if (!isRecord(payload))
|
|
@@ -129,13 +143,19 @@ export class InpostTracker {
|
|
|
129
143
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
130
144
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
131
145
|
retryTransient: true,
|
|
132
|
-
|
|
146
|
+
allowHttpStatuses: [404],
|
|
133
147
|
fetcher: this.fetcher,
|
|
134
148
|
});
|
|
135
|
-
if (response.status === 404)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
if (response.status === 404) {
|
|
150
|
+
let problem;
|
|
151
|
+
try {
|
|
152
|
+
problem = parseJsonBytes(bytes, 'InPost tracking');
|
|
153
|
+
}
|
|
154
|
+
catch { /* An HTML error page proves no shipment outcome. */ }
|
|
155
|
+
if (isNotFoundProblem(problem, trackingNumber))
|
|
156
|
+
throw new NotFoundError('InPost');
|
|
157
|
+
throw new IndeterminateError('InPost', 'InPost returned an unrecognized not-found response');
|
|
158
|
+
}
|
|
139
159
|
return parseInpostTrackingResponse(parseJsonBytes(bytes, 'InPost tracking'), trackingNumber);
|
|
140
160
|
}
|
|
141
161
|
}
|
|
@@ -227,7 +227,7 @@ function trawlJson(response) {
|
|
|
227
227
|
const $ = load(response.html);
|
|
228
228
|
candidates.push($('pre').first().text(), $('body').text(), response.html);
|
|
229
229
|
for (const candidate of candidates) {
|
|
230
|
-
const cleaned = candidate.trim().replace(
|
|
230
|
+
const cleaned = candidate.trim().replace(/^\uFEFF/, '');
|
|
231
231
|
if (!cleaned)
|
|
232
232
|
continue;
|
|
233
233
|
try {
|
|
@@ -96,7 +96,7 @@ export function parseNacex(html, raw) {
|
|
|
96
96
|
}
|
|
97
97
|
const mapped = classifyNacexStatus(label);
|
|
98
98
|
const movement = mapped && ['accepted', 'in_transit', 'out_for_delivery', 'ready_for_pickup'].includes(mapped.stage);
|
|
99
|
-
const trailing = clean(cell.contents().filter((_, node) => node.
|
|
99
|
+
const trailing = clean(cell.contents().filter((_, node) => node.nodeType === 3).map((_, node) => $(node).text()).get().join(' '), 300);
|
|
100
100
|
// The same trailing column holds a recipient after delivery. Only the
|
|
101
101
|
// observed depot/locality shape of a mapped movement scan is a location.
|
|
102
102
|
const locality = movement && /^\d{4}(?:-\d{1,3})?\s*-\s*([\p{L}][\p{L}\s.()-]{1,99})$/u.exec(trailing)?.[1];
|
|
@@ -124,7 +124,7 @@ export function parsePosteItalianeTrackingResponse(payload, trackingNumber) {
|
|
|
124
124
|
const events = parsed.slice(0, MAX_EVENTS_TO_RETURN).map(({ event }) => event);
|
|
125
125
|
// Envelope stato "5" forces delivered; otherwise the newest mapped event wins
|
|
126
126
|
// and unmapped wording stays unknown with its raw text preserved.
|
|
127
|
-
if (
|
|
127
|
+
if (payload.stato === '5' || payload.stato === 5) {
|
|
128
128
|
return {
|
|
129
129
|
status: 'delivered',
|
|
130
130
|
current_stage: 'delivered',
|
|
@@ -14,7 +14,7 @@ import { randomInt } from 'node:crypto';
|
|
|
14
14
|
import { DateTime } from 'luxon';
|
|
15
15
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
16
16
|
import { NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
17
|
-
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
17
|
+
import { cleanScalar, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
18
18
|
import { isRecord } from '../../core/types.js';
|
|
19
19
|
import { sunYouStatus } from './status.js';
|
|
20
20
|
const PROVIDER = 'SunYou';
|
|
@@ -99,7 +99,7 @@ export function parseSunYouTrackingResponse(value, trackingNumber) {
|
|
|
99
99
|
const item = identified.find((candidate) => comparableIdentifier(candidate.orderNo) === requested);
|
|
100
100
|
if (!item)
|
|
101
101
|
throw new SchemaError(PROVIDER, 'SunYou returned a different shipment');
|
|
102
|
-
const displayStatus =
|
|
102
|
+
const displayStatus = cleanScalar(item.displayStatus, 16);
|
|
103
103
|
if (displayStatus === '0')
|
|
104
104
|
throw new NotFoundError(PROVIDER);
|
|
105
105
|
if (item.has !== true)
|
|
@@ -29,14 +29,15 @@ const HEADERS = {
|
|
|
29
29
|
* deliberately not used here.
|
|
30
30
|
*/
|
|
31
31
|
function text(value, limit = 500) {
|
|
32
|
-
return
|
|
32
|
+
return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))
|
|
33
|
+
? String(value).trim().slice(0, limit) : '';
|
|
33
34
|
}
|
|
34
35
|
/** Whether the lookup was cancelled or has spent its budget, rather than a request failing on its own. */
|
|
35
36
|
function ended(budget) {
|
|
36
37
|
return budget.signal.aborted || budget.remainingMs() <= BUDGET_SLACK_MS;
|
|
37
38
|
}
|
|
38
39
|
function comparableShipmentNumber(value) {
|
|
39
|
-
return
|
|
40
|
+
return text(value, 64).replace(/[\s.-]/g, '').toUpperCase();
|
|
40
41
|
}
|
|
41
42
|
function datePart(value) {
|
|
42
43
|
return /(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/.exec(text(value))?.[1] ?? null;
|
|
@@ -43,8 +43,9 @@ export function parseCourierGuy(payload, rawNumber) {
|
|
|
43
43
|
throw new SchemaError('The Courier Guy');
|
|
44
44
|
// Cancelled, never-collected DD bookings omit their count. This bounded
|
|
45
45
|
// precollection timeline cannot complete a delivered or moving shipment.
|
|
46
|
+
const customReference = shipment.custom_tracking_reference;
|
|
46
47
|
const cancelledCollection = shipment.parcel_count == null && shipment.status === 'cancelled'
|
|
47
|
-
&& typeof
|
|
48
|
+
&& typeof customReference === 'string' && /^DD-[A-Z0-9]{6}$/.test(customReference)
|
|
48
49
|
&& Array.isArray(shipment.parcel_tracking_references)
|
|
49
50
|
&& shipment.parcel_tracking_references.length === 1
|
|
50
51
|
&& shipment.tracking_events.every(row => isRecord(row) && row.parcel_id === 0
|
|
@@ -53,7 +54,7 @@ export function parseCourierGuy(payload, rawNumber) {
|
|
|
53
54
|
|| !shipment.parcel_tracking_references.length
|
|
54
55
|
|| shipment.parcel_tracking_references.length > 500
|
|
55
56
|
|| shipment.parcel_tracking_references.some(ref => typeof ref !== 'string'
|
|
56
|
-
|| (!/^[A-Z0-9]{5,40}$/.test(ref) && !(cancelledCollection && ref === `${
|
|
57
|
+
|| (!/^[A-Z0-9]{5,40}$/.test(ref) && !(cancelledCollection && ref === `${customReference}/1`)))
|
|
57
58
|
|| new Set(shipment.parcel_tracking_references).size !== shipment.parcel_tracking_references.length)
|
|
58
59
|
throw new SchemaError('The Courier Guy', 'Invalid shipment pieces');
|
|
59
60
|
if (!cancelledCollection && (!Number.isSafeInteger(shipment.parcel_count) || shipment.parcel_count < 1
|
|
@@ -102,7 +102,7 @@ export function parseUkrposhtaHistory(payload, overview) {
|
|
|
102
102
|
// Both native histories are current-first. Keep that order across foreign
|
|
103
103
|
// wall clocks or missing dates instead of sorting them as UTC instants.
|
|
104
104
|
let returnLeg = false;
|
|
105
|
-
const events = Array(rows.length);
|
|
105
|
+
const events = new Array(rows.length);
|
|
106
106
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
107
107
|
const row = rows[index];
|
|
108
108
|
if (ukrposhtaReturnCue(row.label))
|
|
@@ -82,9 +82,10 @@ export function parseUniuni(payload, rawNumber) {
|
|
|
82
82
|
throw new SchemaError(PROVIDER);
|
|
83
83
|
const events = [];
|
|
84
84
|
const seen = new Set();
|
|
85
|
+
const scans = item.spath_list;
|
|
85
86
|
// The public client displays this oldest-first list in reverse. Keep that
|
|
86
87
|
// sequence when the newest clock is incomplete, including equal-time scans.
|
|
87
|
-
for (const scan of [...
|
|
88
|
+
for (const scan of [...scans].reverse()) {
|
|
88
89
|
if (!isRecord(scan))
|
|
89
90
|
throw new SchemaError(PROVIDER, 'UniUni returned an incomplete scan');
|
|
90
91
|
const description = clean(scan.description_en, 500);
|
|
@@ -245,7 +245,7 @@ function expectedDelivery(detail, today) {
|
|
|
245
245
|
export function parseUPSTrackingResponse(payload, trackingNumber, today = new Date()) {
|
|
246
246
|
if (!isRecord(payload))
|
|
247
247
|
throw new SchemaError('UPS');
|
|
248
|
-
if (
|
|
248
|
+
if (payload.statusCode !== '200' && payload.statusCode !== 200) {
|
|
249
249
|
throw new IndeterminateError('UPS', clean(payload.statusText) || 'UPS tracking is unavailable');
|
|
250
250
|
}
|
|
251
251
|
if (!Array.isArray(payload.trackDetails) || payload.trackDetails.length === 0)
|
|
@@ -437,7 +437,7 @@ export class UPSTracker {
|
|
|
437
437
|
}
|
|
438
438
|
catch (error) {
|
|
439
439
|
// An unreadable or unrelated reply; the rendered page may still answer.
|
|
440
|
-
captureError = error;
|
|
440
|
+
captureError = error instanceof Error ? error : new SchemaError('UPS', 'UPS returned invalid tracking data', { cause: error });
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
try {
|
|
@@ -34,10 +34,11 @@ export function parseYto(payload, trackingNumber) {
|
|
|
34
34
|
if (!item.waybillProcessInfo.length)
|
|
35
35
|
throw new IndeterminateError(PROVIDER, 'YTO returned no shipment history');
|
|
36
36
|
const events = [];
|
|
37
|
+
const history = item.waybillProcessInfo;
|
|
37
38
|
let returning = false;
|
|
38
39
|
// The official client uses the first row as current. Read oldest first to
|
|
39
40
|
// preserve a return leg when later scans use ordinary movement/signature codes.
|
|
40
|
-
for (const raw of [...
|
|
41
|
+
for (const raw of [...history].reverse()) {
|
|
41
42
|
if (!isRecord(raw) || normalizeTrackingNumber(clean(raw.waybillNo, 64)) !== number) {
|
|
42
43
|
throw new SchemaError(PROVIDER, 'YTO returned mixed or incomplete shipment history');
|
|
43
44
|
}
|
|
@@ -42,10 +42,10 @@ export class YundaTracker {
|
|
|
42
42
|
throw error;
|
|
43
43
|
}
|
|
44
44
|
};
|
|
45
|
-
const type = yundaEnvelope(await request('/index.php/api/order.record/captcha_type?' + params));
|
|
45
|
+
const type = yundaEnvelope(await request('/index.php/api/order.record/captcha_type?' + params.toString()));
|
|
46
46
|
if (type.data !== 1)
|
|
47
47
|
throw new ChallengeError('Yunda Express', 'Yunda requires an unsupported verification type');
|
|
48
|
-
const captcha = yundaEnvelope(await request('/index.php/api/order.record/captcha?' + params));
|
|
48
|
+
const captcha = yundaEnvelope(await request('/index.php/api/order.record/captcha?' + params.toString()));
|
|
49
49
|
if (!isRecord(captcha.data))
|
|
50
50
|
throw new SchemaError('Yunda Express');
|
|
51
51
|
const coordinate = await solveYundaSlider(captcha.data, signal);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { CarrierError, UpstreamHttpError } from '../errors/index.js';
|
|
12
12
|
import { decodeText, fetchBounded, parseJsonBytes } from './boundedFetch.js';
|
|
13
|
-
import {
|
|
13
|
+
import { cleanScalar } from './text.js';
|
|
14
14
|
import { isRecord } from '../types.js';
|
|
15
15
|
export class TrawlError extends CarrierError {
|
|
16
16
|
constructor(provider, message, options) {
|
|
@@ -186,7 +186,7 @@ export class TrawlClient {
|
|
|
186
186
|
if (!isRecord(value))
|
|
187
187
|
throw new TrawlError(options.provider, 'The browser service returned an invalid response');
|
|
188
188
|
if (value.error) {
|
|
189
|
-
throw new TrawlError(options.provider,
|
|
189
|
+
throw new TrawlError(options.provider, cleanScalar(value.error, 200) || 'The browser service could not fetch the page');
|
|
190
190
|
}
|
|
191
191
|
const statusCode = Number(value.statusCode);
|
|
192
192
|
if (Number.isInteger(statusCode) && statusCode >= 400)
|
package/dist/server/index.js
CHANGED
|
@@ -53,11 +53,15 @@ async function body(request) {
|
|
|
53
53
|
throw new HttpError(413, 'Request body is too large');
|
|
54
54
|
const chunks = [];
|
|
55
55
|
let length = 0;
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const stream = request;
|
|
57
|
+
for await (const chunk of stream) {
|
|
58
|
+
if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk))
|
|
59
|
+
throw new HttpError(400, 'Invalid request body');
|
|
60
|
+
const bytes = Buffer.from(chunk);
|
|
61
|
+
length += bytes.length;
|
|
58
62
|
if (length > limit)
|
|
59
63
|
throw new HttpError(413, 'Request body is too large');
|
|
60
|
-
chunks.push(
|
|
64
|
+
chunks.push(bytes);
|
|
61
65
|
}
|
|
62
66
|
let parsed;
|
|
63
67
|
try {
|
|
@@ -204,7 +208,7 @@ export function createTrackingServer(options = {}) {
|
|
|
204
208
|
pending.delete(key);
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
|
-
|
|
211
|
+
async function handleRequest(request, response) {
|
|
208
212
|
const started = performance.now();
|
|
209
213
|
let route = 'unknown';
|
|
210
214
|
response.on('finish', () => {
|
|
@@ -297,6 +301,9 @@ export function createTrackingServer(options = {}) {
|
|
|
297
301
|
json(response, 502, { error: 'Tracking is temporarily unavailable' });
|
|
298
302
|
}
|
|
299
303
|
}
|
|
304
|
+
}
|
|
305
|
+
const server = createServer((request, response) => {
|
|
306
|
+
void handleRequest(request, response).catch(() => response.destroy());
|
|
300
307
|
});
|
|
301
308
|
server.requestTimeout = 15_000;
|
|
302
309
|
server.headersTimeout = 10_000;
|
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.1-main.
|
|
3
|
+
"version": "0.3.1-main.313",
|
|
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",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"build": "node scripts/build.mjs",
|
|
59
59
|
"prepare": "npm run build",
|
|
60
60
|
"typecheck": "tsc --noEmit",
|
|
61
|
-
"lint": "eslint . --max-warnings=0
|
|
61
|
+
"lint": "eslint . --max-warnings=0",
|
|
62
62
|
"test": "vitest run",
|
|
63
63
|
"test:scripts": "node --test trawl/*.test.mjs scripts/*.test.mjs",
|
|
64
64
|
"generate": "node scripts/generate-catalog.mjs && node scripts/generate-registry.mjs --strict && node scripts/generate-region-towns.mjs && node scripts/coverage-tables.mjs && node scripts/generate-readme.mjs",
|