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.
Files changed (33) hide show
  1. package/dist/carriers/amazon-shipping/adapter.js +1 -1
  2. package/dist/carriers/ciblex/parser.js +1 -1
  3. package/dist/carriers/correos-spain/adapter.js +2 -1
  4. package/dist/carriers/ctt/adapter.js +11 -15
  5. package/dist/carriers/ctt-express/parser.js +2 -1
  6. package/dist/carriers/dachser/adapter.js +3 -3
  7. package/dist/carriers/dachser/status.js +1 -1
  8. package/dist/carriers/dhl-ecommerce/status.js +1 -1
  9. package/dist/carriers/dpd/adapter.js +1 -1
  10. package/dist/carriers/estafeta/parser.js +1 -1
  11. package/dist/carriers/fedex/adapter.js +2 -1
  12. package/dist/carriers/gls-de/adapter.js +2 -2
  13. package/dist/carriers/heppner/adapter.js +1 -1
  14. package/dist/carriers/hermes/adapter.js +15 -10
  15. package/dist/carriers/hermes/status.js +1 -1
  16. package/dist/carriers/hermes-de/adapter.js +1 -1
  17. package/dist/carriers/india-post/adapter.js +1 -1
  18. package/dist/carriers/inpost/adapter.js +33 -13
  19. package/dist/carriers/mondial-relay/adapter.js +1 -1
  20. package/dist/carriers/nacex/parser.js +1 -1
  21. package/dist/carriers/poste-italiane/adapter.js +1 -1
  22. package/dist/carriers/sunyou/adapter.js +2 -2
  23. package/dist/carriers/swiss-post/adapter.js +3 -2
  24. package/dist/carriers/the-courier-guy/parser.js +3 -2
  25. package/dist/carriers/ukrposhta/parser.js +1 -1
  26. package/dist/carriers/uniuni/parser.js +2 -1
  27. package/dist/carriers/ups/adapter.js +2 -2
  28. package/dist/carriers/yto/parser.js +2 -1
  29. package/dist/carriers/yunda/adapter.js +2 -2
  30. package/dist/core/transport/trawl.js +2 -2
  31. package/dist/server/index.js +11 -4
  32. package/dist/server/openapi.json +1 -1
  33. 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 && String(identity).toUpperCase() !== trackingNumber) {
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} .'\/-]{1,70}) (\d{2,3}) \(\2\)$/u.test(value) ? value : '';
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 == null) {
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, UpstreamHttpError, } from '../../core/transport/index.js';
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
- // - Live verification 2026-09-10, including a delivered parcel whose whole
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) || !record.Found) {
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
- allowHttpError: true,
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 { response, bytes } = await fetchBounded(MODULE_VERSION_URL, {
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 { response, bytes } = await fetchBounded(url, {
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 [...history.events].reverse()) {
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 String(raw ?? '').replace(/[\s.-]/g, '').toUpperCase();
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 = String(raw ?? '').trim();
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 });
@@ -1,6 +1,6 @@
1
1
  /** Lower-cased, accent-free text, for language-independent matching. */
2
2
  export function plainText(raw) {
3
- return String(raw ?? '')
3
+ return (typeof raw === 'string' ? raw : '')
4
4
  .toLocaleLowerCase()
5
5
  .normalize('NFKD')
6
6
  .replace(/\p{M}/gu, '');
@@ -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(String(event.description ?? ''));
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.type === 'text').map((_, node) => clean($(node).text(), 100)).get().filter(Boolean);
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 }) : 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(), String(owner?.code ?? ''), '4,5'), budget);
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 String(raw ?? '').replace(/[\s.-]/g, '').toUpperCase();
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.filter((event) => (String(event.sendungsstatus ?? '').trim()
47
- && String(event.sendungsstatusBuchungszeitpunkt ?? '').trim()));
48
- meaningfulEvents.sort((left, right) => String(right.sendungsstatusBuchungszeitpunkt ?? '')
49
- .localeCompare(String(left.sendungsstatusBuchungszeitpunkt ?? '')));
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: String(event.sendungsstatusBuchungszeitpunkt ?? ''),
56
+ time: event.time,
52
57
  location: '',
53
- description: String(event.sendungsstatus),
54
- stage: hermesEventStage(event.sendungsstatusId, event.sendungsstatus),
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].sendungsstatusId, meaningfulEvents[0].sendungsstatus)
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 String(raw ?? '').toLocaleLowerCase('de-DE').normalize('NFKD').replace(/\p{M}/gu, '');
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.some((entry) => !isRecord(entry))) {
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(String(cached.source_synced_at ?? ''));
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, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
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
- // - Live verification 2026-09-10: GET
16
- // https://inposteasy.com/api/tracking/000000000000000000000000 returns
17
- // HTTP 404 with a structured NOT_FOUND problem body in ~0.2s, no cookies,
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
- allowHttpError: true,
146
+ allowHttpStatuses: [404],
133
147
  fetcher: this.fetcher,
134
148
  });
135
- if (response.status === 404)
136
- throw new NotFoundError('InPost');
137
- if (!response.ok)
138
- throw new UpstreamHttpError('InPost tracking', response.status);
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.type === 'text').map((_, node) => $(node).text()).get().join(' '), 300);
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 (String(payload.stato ?? '') === '5') {
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 = String(item.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 String(value ?? '').trim().slice(0, limit);
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 String(value ?? '').replace(/[\s.-]/g, '').toUpperCase();
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 shipment.custom_tracking_reference === 'string' && /^DD-[A-Z0-9]{6}$/.test(shipment.custom_tracking_reference)
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 === `${shipment.custom_tracking_reference}/1`)))
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 [...item.spath_list].reverse()) {
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 (String(payload.statusCode ?? '') !== '200') {
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 [...item.waybillProcessInfo].reverse()) {
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 { clean } from './text.js';
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, clean(String(value.error), 200) || 'The browser service could not fetch the page');
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)
@@ -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
- for await (const chunk of request) {
57
- length += chunk.length;
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(Buffer.from(chunk));
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
- const server = createServer(async (request, response) => {
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;
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.3.0"
5
+ "version": "0.3.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.3.1-main.311",
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 --pass-on-unpruned-suppressions",
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",