universal-parcel-scraper 0.133.0 → 0.133.1

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 (35) hide show
  1. package/dist/carriers/apc-overnight/parser.js +2 -1
  2. package/dist/carriers/asendia/probe.js +2 -1
  3. package/dist/carriers/cdek/parser.js +3 -2
  4. package/dist/carriers/correos-de-mexico/parser.js +2 -1
  5. package/dist/carriers/cyprus-post/parser.js +2 -1
  6. package/dist/carriers/dai-post/parser.js +2 -1
  7. package/dist/carriers/dao/parser.js +3 -2
  8. package/dist/carriers/dhl-ecommerce-pl/adapter.js +61 -39
  9. package/dist/carriers/dhl-ecommerce-pl/carrier.json +2 -1
  10. package/dist/carriers/dsv/parser.js +5 -4
  11. package/dist/carriers/dynalogic/parser.js +2 -1
  12. package/dist/carriers/express-one/parser.js +2 -1
  13. package/dist/carriers/fan-courier/parser.js +3 -2
  14. package/dist/carriers/foxpost/parser.js +2 -1
  15. package/dist/carriers/gebrueder-weiss/parser.js +2 -1
  16. package/dist/carriers/gls-it/parser.js +2 -1
  17. package/dist/carriers/go-express/parser.js +2 -1
  18. package/dist/carriers/hrvatska-posta/parser.js +2 -1
  19. package/dist/carriers/japan-post/adapter.js +2 -1
  20. package/dist/carriers/jd-logistics/parser.js +2 -1
  21. package/dist/carriers/kuehne-nagel/parser.js +3 -2
  22. package/dist/carriers/landmark-global/parser.js +2 -1
  23. package/dist/carriers/maltapost/parser.js +2 -1
  24. package/dist/carriers/matkahuolto/parser.js +2 -1
  25. package/dist/carriers/meest/parser.js +3 -2
  26. package/dist/carriers/nova-poshta/parser.js +2 -1
  27. package/dist/carriers/passport/parser.js +2 -1
  28. package/dist/carriers/speedy/parser.js +2 -1
  29. package/dist/carriers/xdp/parser.js +2 -1
  30. package/dist/carriers/yto/parser.js +3 -2
  31. package/dist/carriers/yunda/parser.js +2 -1
  32. package/dist/core/detection/returned.d.ts +2 -0
  33. package/dist/core/detection/returned.js +7 -0
  34. package/dist/server/openapi.json +1 -1
  35. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -54,7 +55,7 @@ function searchPage($, html, number) {
54
55
  const notice = notices.length === 1 ? clean(notices.text(), 200) : '';
55
56
  // The page puts the identifier it was asked for back into its search field.
56
57
  const fields = $('input#ConsignmentNumber');
57
- const echoed = fields.length === 1 ? normalizeTrackingNumber(clean(fields.attr('value'), 80)) : '';
58
+ const echoed = fields.length === 1 ? returnedTrackingNumber(fields.attr('value')) : '';
58
59
  if (notice === NOT_FOUND || notice === INVALID) {
59
60
  if (echoed !== number)
60
61
  throw new SchemaError(PROVIDER, 'APC Overnight answered for a different number');
@@ -21,6 +21,7 @@
21
21
  * event from an explicit allowlist (time, harmonized wording, scanning
22
22
  * location, harmonized code) and copies nothing else.
23
23
  */
24
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
24
25
  import { createHash } from 'node:crypto';
25
26
  import { load } from 'cheerio';
26
27
  import { DateTime } from 'luxon';
@@ -140,7 +141,7 @@ function parseTenantConfig(payload) {
140
141
  function responseIdentifier(parcel) {
141
142
  for (const value of [parcel.tracking_id, parcel.upper_tracking_id]) {
142
143
  try {
143
- return normalizeAsendiaTrackingNumber(cleanScalar(value, 64));
144
+ return normalizeAsendiaTrackingNumber(returnedTrackingNumber(value, true));
144
145
  }
145
146
  catch {
146
147
  // Try the alternate provider field.
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
3
4
  import { languageStageStatus } from '../../core/status/index.js';
@@ -71,8 +72,8 @@ export function parseCdek(payload, raw) {
71
72
  const data = payload.data;
72
73
  if (!isRecord(data))
73
74
  throw new SchemaError(PROVIDER, 'CDEK did not return an order');
74
- const order = normalizeTrackingNumber(clean(data.orderNumber, 40));
75
- const outer = payload.orderNumber === undefined ? order : normalizeTrackingNumber(clean(payload.orderNumber, 40));
75
+ const order = returnedTrackingNumber(data.orderNumber);
76
+ const outer = payload.orderNumber === undefined ? order : returnedTrackingNumber(payload.orderNumber);
76
77
  if (!ISSUED.test(order) || outer !== order)
77
78
  throw new SchemaError(PROVIDER, 'CDEK returned an order without its number');
78
79
  // CDEK finds an order by a number it links to it and answers with the order's own number.
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { ChallengeError, IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
@@ -125,7 +126,7 @@ export function readCorreosDeMexicoPage(html, rawNumber, askedYear) {
125
126
  const guia = form.find('input#Guia[name="Guia"]');
126
127
  if (guia.length !== 1)
127
128
  throw new SchemaError(PROVIDER, 'Correos de México tracking form changed');
128
- if (normalizeTrackingNumber(clean(guia.attr('value'), 80)) !== number) {
129
+ if (returnedTrackingNumber(guia.attr('value')) !== number) {
129
130
  throw new SchemaError(PROVIDER, 'Correos de México answered for a different number');
130
131
  }
131
132
  const options = form.find('select#Periodo[name="Periodo"] > option');
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -192,7 +193,7 @@ function itemFor($, html, number) {
192
193
  const panel = panels.first();
193
194
  const titles = panel.find('.panel-heading .panel-title a');
194
195
  const bodies = panel.children('.panel-collapse');
195
- if (titles.length !== 1 || normalizeTrackingNumber(clean(titles.text(), 80)) !== number
196
+ if (titles.length !== 1 || returnedTrackingNumber(titles.text()) !== number
196
197
  || bodies.length !== 1 || bodies.attr('id') !== `collapse_${number}`) {
197
198
  throw new SchemaError(PROVIDER, 'Cyprus Post answered for a different item');
198
199
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -133,7 +134,7 @@ function scanRows(rows) {
133
134
  if (props && node[1] === 'input' && props.name === 'tracknbr' && typeof props.defaultValue === 'string') {
134
135
  const value = flightText(props.defaultValue);
135
136
  if (value !== undefined)
136
- echoed.add(normalizeTrackingNumber(clean(value, 200)));
137
+ echoed.add(returnedTrackingNumber(value));
137
138
  }
138
139
  for (const child of node)
139
140
  stack.push(child);
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { carrierIdFromPartner } from '../../core/catalog/hints.js';
2
3
  import { isValidSscc, normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -30,7 +31,7 @@ export function normalizeDaoNumber(raw) {
30
31
  }
31
32
  /** The parcel number the reply names, in the forms DAO issues. */
32
33
  function issued(value) {
33
- const barcode = normalizeTrackingNumber(cleanScalar(value, 40));
34
+ const barcode = returnedTrackingNumber(value, true);
34
35
  return /^(?:00\d{18}|7\d{12}|\d{9})$/.test(barcode) ? barcode : '';
35
36
  }
36
37
  /** A clock as a wall time, validated. The reply gives no offset. */
@@ -76,7 +77,7 @@ function scanOf(row, index) {
76
77
  }
77
78
  /** The partner and number a parcel sent abroad is tracked under, as the page links them. */
78
79
  function abroad(parcel, number) {
79
- const partnerNumber = normalizeTrackingNumber(clean(parcel.dist_barcode, 64));
80
+ const partnerNumber = returnedTrackingNumber(parcel.dist_barcode);
80
81
  if (!/^[A-Z0-9]{4,40}$/.test(partnerNumber) || partnerNumber === number)
81
82
  return {};
82
83
  const partner = carrierIdFromPartner('', clean(parcel.external_tracking, 500));
@@ -1,9 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
3
- import { BudgetExceededError, IndeterminateError } from '../../core/errors/index.js';
3
+ import { BudgetExceededError, ChallengeError, IndeterminateError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
4
4
  import { runSteps } from '../../core/runner/index.js';
5
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
6
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
- import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
7
+ import { decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
7
8
  import { normalizeDhlEcommercePlNumber, parseDhlEcommercePl, parseDhlEcommercePlChallenge, parseDhlEcommercePlRejection } from './parser.js';
8
9
  const PROVIDER = 'DHL eCommerce Poland';
9
10
  // The guest routes behind mojdhl.pl. They return the current status and none
@@ -37,47 +38,68 @@ export class DhlEcommercePlTracker {
37
38
  fetch(raw, context = {}) {
38
39
  const number = normalizeDhlEcommercePlNumber(raw);
39
40
  const budgetMs = context.budgetMs ?? 15_000;
41
+ const read = async (signal, remainingMs) => {
42
+ signal.throwIfAborted();
43
+ // One deadline covers the challenge, its solution and the lookup.
44
+ const deadline = performance.now() + remainingMs;
45
+ const left = () => {
46
+ const ms = Math.floor(deadline - performance.now());
47
+ if (ms <= 0)
48
+ throw new BudgetExceededError(PROVIDER, budgetMs);
49
+ return ms;
50
+ };
51
+ // A page in place of JSON is a block, whatever its status says.
52
+ const json = (bytes) => {
53
+ try {
54
+ return parseJsonBytes(bytes, PROVIDER);
55
+ }
56
+ catch {
57
+ if (/Request Rejected|The requested URL was rejected|Access Denied|cf-chl-|_cf_chl_opt|cf-turnstile|Incapsula incident|<title>\s*(?:Just a moment|Attention Required)/i.test(decodeText(bytes.subarray(0, 64000)))) {
58
+ throw new ChallengeError(PROVIDER, 'DHL eCommerce Poland returned a browser challenge');
59
+ }
60
+ throw new IndeterminateError(PROVIDER, 'DHL eCommerce Poland returned a page instead of tracking JSON');
61
+ }
62
+ };
63
+ // The signed per-request proof stays out of failure diagnostics. Each
64
+ // retry obtains a fresh challenge rather than replaying a spent proof.
65
+ const request = async (url, init, options) => {
66
+ try {
67
+ return await fetchBounded(url, { ...init, signal }, options);
68
+ }
69
+ catch (error) {
70
+ if (error instanceof UpstreamHttpError)
71
+ throw new UpstreamHttpError(PROVIDER, error.status, error.retryAfterMs);
72
+ if (error instanceof UpstreamNetworkError)
73
+ throw new UpstreamNetworkError(PROVIDER, new Error('DHL eCommerce Poland tracking request failed'));
74
+ throw error;
75
+ }
76
+ };
77
+ const headers = { Accept: 'application/json', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.options.userAgent) };
78
+ const issued = await request(`${BASE}/auth/captcha/challenge`, { signal, headers }, {
79
+ provider: PROVIDER, timeoutMs: left(), maxBytes: 20_000, fetcher: this.options.fetcher,
80
+ });
81
+ const proof = await solveDhlEcommercePlChallenge(parseDhlEcommercePlChallenge(json(issued.bytes)), signal, deadline, budgetMs);
82
+ signal.throwIfAborted();
83
+ const { response, bytes } = await request(`${BASE}/shipment/status`, { method: 'POST', signal,
84
+ headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ number1: number, 'captcha-payload': proof }) }, {
85
+ provider: PROVIDER, timeoutMs: left(), maxBytes: 500_000, allowHttpStatuses: [400, 422], fetcher: this.options.fetcher,
86
+ });
87
+ if (response.status === 422)
88
+ return parseDhlEcommercePlRejection(json(bytes));
89
+ // A 400 is the portal refusing the proof: nothing was looked up.
90
+ if (response.status !== 200)
91
+ throw new IndeterminateError(PROVIDER, 'DHL eCommerce Poland returned an inconclusive tracking response');
92
+ return parseDhlEcommercePl(json(bytes), number);
93
+ };
40
94
  return runSteps({ carrier: 'dhl-ecommerce-pl', budgetMs, signal: context.signal,
41
- recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
42
- signal.throwIfAborted();
43
- // One deadline covers the challenge, its solution and the lookup.
44
- const deadline = performance.now() + remainingMs;
45
- const left = () => {
46
- const ms = Math.floor(deadline - performance.now());
47
- if (ms <= 0)
48
- throw new BudgetExceededError(PROVIDER, budgetMs);
49
- return ms;
50
- };
51
- // A page in place of JSON is a block, whatever its status says.
52
- const json = (bytes) => {
53
- try {
54
- return parseJsonBytes(bytes, PROVIDER);
55
- }
56
- catch (cause) {
57
- throw new IndeterminateError(PROVIDER, 'DHL eCommerce Poland returned a page instead of tracking JSON', { cause });
58
- }
59
- };
60
- const headers = { Accept: 'application/json', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.options.userAgent) };
61
- const issued = await fetchBounded(`${BASE}/auth/captcha/challenge`, { signal, headers }, {
62
- provider: PROVIDER, timeoutMs: left(), maxBytes: 20_000, fetcher: this.options.fetcher,
63
- });
64
- const proof = await solveDhlEcommercePlChallenge(parseDhlEcommercePlChallenge(json(issued.bytes)), signal, deadline, budgetMs);
65
- signal.throwIfAborted();
66
- const { response, bytes } = await fetchBounded(`${BASE}/shipment/status`, { method: 'POST', signal,
67
- headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ number1: number, 'captcha-payload': proof }) }, {
68
- provider: PROVIDER, timeoutMs: left(), maxBytes: 500_000, allowHttpStatuses: [400, 422], fetcher: this.options.fetcher,
69
- });
70
- if (response.status === 422)
71
- return parseDhlEcommercePlRejection(json(bytes));
72
- // A 400 is the portal refusing the proof: nothing was looked up.
73
- if (response.status !== 200)
74
- throw new IndeterminateError(PROVIDER, 'DHL eCommerce Poland returned an inconclusive tracking response');
75
- return parseDhlEcommercePl(json(bytes), number);
76
- } }]);
95
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
96
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
97
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
98
+ ]);
77
99
  }
78
100
  }
79
101
  export const adapter = (environment) => {
80
102
  const tracker = new DhlEcommercePlTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
81
- return { id: 'dhl-ecommerce-pl', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context),
103
+ return { id: 'dhl-ecommerce-pl', recordsSteps: true, steps: ['direct', 'retry'], track: (input, context) => tracker.fetch(input.number, context),
82
104
  recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeDhlEcommercePlNumber(number))) };
83
105
  };
@@ -20,7 +20,8 @@
20
20
  "mode": "automatic",
21
21
  "adapter": "dhl-ecommerce-pl",
22
22
  "steps": [
23
- "direct"
23
+ "direct",
24
+ "retry"
24
25
  ],
25
26
  "recognition": {
26
27
  "rank": 620
@@ -1,5 +1,6 @@
1
1
  import { DateTime } from 'luxon';
2
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
3
4
  import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
4
5
  import { languageStageStatus } from '../../core/status/index.js';
5
6
  import { clean, cleanScalar } from '../../core/transport/index.js';
@@ -31,14 +32,14 @@ function ownIds(shipment) {
31
32
  const ids = new Set();
32
33
  const calculated = Array.isArray(shipment.calculatedShipmentId) ? shipment.calculatedShipmentId : [];
33
34
  for (const value of [shipment.mainTmsShipmentId, shipment.shipmentId, shipment.bookingId, ...calculated]) {
34
- const id = normalizeTrackingNumber(cleanScalar(value, 80));
35
+ const id = returnedTrackingNumber(value, true);
35
36
  if (id)
36
37
  ids.add(id);
37
38
  }
38
39
  return ids;
39
40
  }
40
41
  function shipmentOf(entry, own) {
41
- const key = clean(entry.randomIdentifier, 80);
42
+ const key = typeof entry.randomIdentifier === 'string' ? entry.randomIdentifier : '';
42
43
  if (!KEY.test(key))
43
44
  throw new SchemaError(PROVIDER, 'myDSV returned a shipment without its key');
44
45
  return { key, mode: clean(entry.modeOfTransport, 40).toUpperCase(), own };
@@ -61,7 +62,7 @@ export function pickDsvShipment(payload, rawNumber) {
61
62
  const own = new Map();
62
63
  for (const entry of entries) {
63
64
  if (ownIds(entry).has(number))
64
- own.set(clean(entry.randomIdentifier, 80), entry);
65
+ own.set(shipmentOf(entry, true).key, entry);
65
66
  }
66
67
  if (own.size === 1)
67
68
  return shipmentOf([...own.values()][0], true);
@@ -82,7 +83,7 @@ export function confirmDsvShipment(payload, rawNumber) {
82
83
  const number = normalizeDsvNumber(rawNumber);
83
84
  if (!isRecord(payload))
84
85
  throw new SchemaError(PROVIDER, 'myDSV did not return the shipment');
85
- if (ownIds(payload).has(number) || normalizeTrackingNumber(cleanScalar(payload.houseBillNumber, 80)) === number)
86
+ if (ownIds(payload).has(number) || returnedTrackingNumber(payload.houseBillNumber, true) === number)
86
87
  return;
87
88
  throw new IndeterminateError(PROVIDER, 'The number matched only a reference of a DSV shipment', { reason: 'reference_only' });
88
89
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
3
4
  import { languageStageStatus } from '../../core/status/index.js';
@@ -50,7 +51,7 @@ export function parseDynalogic(payload, rawNumber) {
50
51
  throw new SchemaError(PROVIDER, 'Dynalogic returned an invalid order reply');
51
52
  }
52
53
  const order = payload.data;
53
- const answered = typeof order.TrackAndTraceNumber === 'string' ? normalizeTrackingNumber(clean(order.TrackAndTraceNumber, 64)) : '';
54
+ const answered = typeof order.TrackAndTraceNumber === 'string' ? returnedTrackingNumber(order.TrackAndTraceNumber) : '';
54
55
  if (!answered)
55
56
  throw new SchemaError(PROVIDER, 'Dynalogic returned an order without its number');
56
57
  // The service also finds an order by the merchant's order number or its end;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -120,7 +121,7 @@ export function parseExpressOne(html, raw, status = 200) {
120
121
  if (status === 404 || !blocks.length) {
121
122
  const notice = clean(notices.find('p b').first().text(), 200);
122
123
  if (status === 404 && !blocks.length && notices.length === 1 && ABSENT.has(notice)
123
- && normalizeTrackingNumber(clean($('input#plc_number').val(), 64)) === number)
124
+ && returnedTrackingNumber($('input#plc_number').val()) === number)
124
125
  throw new NotFoundError(PROVIDER);
125
126
  throw new IndeterminateError(PROVIDER, 'Express One did not return the parcel');
126
127
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -42,14 +43,14 @@ export function parseFanCourier(payload, raw) {
42
43
  const message = clean(payload.message, 200).toLowerCase().replace(/\.$/, '');
43
44
  if (!ABSENT.has(message))
44
45
  throw new IndeterminateError(PROVIDER, 'FAN Courier did not return the AWB');
45
- if (normalizeTrackingNumber(cleanScalar(payload.content, 64)) !== number) {
46
+ if (returnedTrackingNumber(payload.content, true) !== number) {
46
47
  throw new SchemaError(PROVIDER, 'FAN Courier answered for a different number');
47
48
  }
48
49
  throw new NotFoundError(PROVIDER);
49
50
  }
50
51
  if (typeof payload.awbNumber !== 'string' && typeof payload.awbNumber !== 'number')
51
52
  throw new SchemaError(PROVIDER);
52
- if (normalizeTrackingNumber(cleanScalar(payload.awbNumber, 64)) !== number) {
53
+ if (returnedTrackingNumber(payload.awbNumber, true) !== number) {
53
54
  throw new SchemaError(PROVIDER, 'FAN Courier answered for a different AWB');
54
55
  }
55
56
  const rows = payload.events;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -72,7 +73,7 @@ export function parseFoxpost(html, raw) {
72
73
  }
73
74
  if (!shown.length && !rows.length)
74
75
  throw new IndeterminateError(PROVIDER, 'Foxpost did not return a parcel');
75
- if (shown.length !== 1 || normalizeTrackingNumber(clean(shown.text(), 80)) !== number) {
76
+ if (shown.length !== 1 || returnedTrackingNumber(shown.text()) !== number) {
76
77
  throw new SchemaError(PROVIDER, 'Foxpost returned a different parcel');
77
78
  }
78
79
  if ($('ul.parcel-status-items__list').length !== 1)
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -53,7 +54,7 @@ export function normalizeGebruederWeissPostcode(raw) {
53
54
  function carries(order, number) {
54
55
  const references = Array.isArray(order.customer_reference) ? order.customer_reference : [];
55
56
  return [order.order_id, order.request_order_id, ...references]
56
- .some((value) => normalizeTrackingNumber(cleanScalar(value, 100)) === number);
57
+ .some((value) => returnedTrackingNumber(value, true) === number);
57
58
  }
58
59
  /** The branch a scan happened at, as "Town, AT", and its country. */
59
60
  function placeOf(location) {
@@ -1,5 +1,6 @@
1
1
  import { DateTime } from 'luxon';
2
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
3
4
  import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
5
  import { languageStageStatus } from '../../core/status/index.js';
5
6
  import { clean, textFromHtml } from '../../core/transport/index.js';
@@ -26,7 +27,7 @@ function records(value) {
26
27
  return Array.isArray(value) ? value.filter(isRecord) : [];
27
28
  }
28
29
  function shipmentNumber(row) {
29
- return typeof row.tuNo === 'string' ? normalizeTrackingNumber(clean(row.tuNo, 40)) : '';
30
+ return typeof row.tuNo === 'string' ? returnedTrackingNumber(row.tuNo) : '';
30
31
  }
31
32
  /** The row's own count of parcels: the first row of a several-row reply is the shipment the page shows above its parcels. */
32
33
  function isShipmentOverview(row) {
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -54,7 +55,7 @@ function linkedNumber($) {
54
55
  return null;
55
56
  try {
56
57
  const reference = new URL(links.attr('data-link') ?? '').searchParams.getAll('reference');
57
- return reference.length === 1 ? normalizeTrackingNumber(clean(reference[0], 80)) : null;
58
+ return reference.length === 1 ? returnedTrackingNumber(reference[0]) : null;
58
59
  }
59
60
  catch {
60
61
  return null;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -81,7 +82,7 @@ export function parseHrvatskaPosta(html, rawNumber) {
81
82
  if (header.length !== 1 || named.length !== 1 || label !== NUMBER_LABEL) {
82
83
  throw new SchemaError(PROVIDER, 'Hrvatska pošta did not answer in English');
83
84
  }
84
- if (normalizeTrackingNumber(clean(named.text(), 80)) !== number) {
85
+ if (returnedTrackingNumber(named.text()) !== number) {
85
86
  throw new SchemaError(PROVIDER, 'Hrvatska pošta answered for a different number');
86
87
  }
87
88
  const groups = root.find('div.track__n__trace-events-content > div.event__status__item');
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -68,7 +69,7 @@ export function parse(html, trackingNumber) {
68
69
  const rows = result.find('tr').filter((_, row) => $(row).children('td').length > 0);
69
70
  const cells = rows.children('td');
70
71
  if (!details.length && !history.length && result.length === 1 && rows.length === 1 && cells.length === 2
71
- && normalizeTrackingNumber(clean(cells.eq(0).text())) === number
72
+ && returnedTrackingNumber(cells.eq(0).text()) === number
72
73
  && clean(cells.eq(1).text()) === '** Your item was not found. Confirm your item number and ask at your local office.') {
73
74
  throw new NotFoundError(PROVIDER);
74
75
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
@@ -127,7 +128,7 @@ export function parseJdLogistics(payload, raw) {
127
128
  if (!payload.data.length)
128
129
  throw new IndeterminateError(PROVIDER, 'JD Logistics returned no international history');
129
130
  if (payload.data.length !== 1 || !isRecord(payload.data[0])
130
- || normalizeTrackingNumber(clean(payload.data[0].waybillNo, 64)) !== number) {
131
+ || returnedTrackingNumber(payload.data[0].waybillNo) !== number) {
131
132
  throw new SchemaError(PROVIDER, 'JD Logistics returned a different or ambiguous shipment');
132
133
  }
133
134
  const item = payload.data[0];
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -111,8 +112,8 @@ export function parseKuehneNagelSearch(payload, rawNumber) {
111
112
  const kind = clean(group.kind, 60);
112
113
  for (const shipment of group.shipments) {
113
114
  const id = isRecord(shipment) ? cleanScalar(shipment.id, 20) : '';
114
- const trackingNumber = isRecord(shipment) ? normalizeTrackingNumber(clean(shipment.trackingNumber, 40)) : '';
115
- if (!isRecord(shipment) || !ID.test(id) || !trackingNumber) {
115
+ const trackingNumber = isRecord(shipment) ? returnedTrackingNumber(shipment.trackingNumber) : '';
116
+ if (!isRecord(shipment) || !ID.test(id) || !trackingNumber || trackingNumber.length > 40) {
116
117
  throw new SchemaError(PROVIDER, 'Kuehne+Nagel returned a shipment without its id or tracking number');
117
118
  }
118
119
  const exact = kind === TRACKING_NUMBER && trackingNumber === number;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { carrierIdFromPartner } from '../../core/catalog/hints.js';
@@ -48,7 +49,7 @@ export function parseLandmark(html, rawNumber) {
48
49
  const canonical = identities.length === 1 ? clean(identities.next('div').text(), 64) : '';
49
50
  // A merchant reference must be the parcel's order or package reference; its LTN then names the parcel.
50
51
  const references = $('.shipment-details-col h6').filter((_, node) => ['Order Reference', 'Package Reference'].includes(clean($(node).text())))
51
- .map((_, node) => normalizeTrackingNumber(clean($(node).next('div').text(), 100))).get();
52
+ .map((_, node) => returnedTrackingNumber($(node).next('div').text())).get();
52
53
  if (identities.length !== 1 || (merchant ? !/^LTN\d{8,9}$/.test(canonical) || !references.includes(number)
53
54
  : canonical !== number.replace(/N1$/, '')))
54
55
  throw new SchemaError('Landmark Global', 'Landmark returned a different or ambiguous parcel');
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { DateTime } from 'luxon';
2
3
  import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -47,7 +48,7 @@ function barcodeOf(value) {
47
48
  return '';
48
49
  if (typeof value !== 'string')
49
50
  throw new SchemaError(PROVIDER, 'MaltaPost returned an invalid barcode');
50
- return normalizeTrackingNumber(clean(value, 80));
51
+ return returnedTrackingNumber(value);
51
52
  }
52
53
  /** The scan's wording, its reason and the action that follows, as the tracking page lists them. */
53
54
  function wordingOf(row) {
@@ -1,5 +1,6 @@
1
1
  import { DateTime } from 'luxon';
2
2
  import { isValidS10TrackingNumber, isValidSscc, normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
3
4
  import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
5
  import { lastMovement } from '../../core/result/pickup.js';
5
6
  import { languageStageStatus } from '../../core/status/index.js';
@@ -102,7 +103,7 @@ export function parseMatkahuolto(reply, raw) {
102
103
  throw new NotFoundError(PROVIDER);
103
104
  throw new IndeterminateError(PROVIDER, 'Matkahuolto returned an unexpected not-found reply');
104
105
  }
105
- if (typeof reply.parcelNumber !== 'string' || !sameNumber(normalizeTrackingNumber(clean(reply.parcelNumber, 60)), number)) {
106
+ if (typeof reply.parcelNumber !== 'string' || !sameNumber(returnedTrackingNumber(reply.parcelNumber), number)) {
106
107
  throw new SchemaError(PROVIDER, 'Matkahuolto answered for a different consignment');
107
108
  }
108
109
  const rows = reply.trackingEvents;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { carrierIdFromPartner } from '../../core/catalog/hints.js';
2
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
4
  import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
@@ -80,8 +81,8 @@ function rowOf(item, index) {
80
81
  code,
81
82
  description,
82
83
  location: [city, country].filter(Boolean).join(', '),
83
- own: ['ShipmentNumber', 'ShipmentNumberSender', 'Barcode'].map(name => normalizeTrackingNumber(clean(field(item, name), 64))),
84
- transit: normalizeTrackingNumber(clean(field(item, 'ShipmentNumberTransit'), 64)),
84
+ own: ['ShipmentNumber', 'ShipmentNumberSender', 'Barcode'].map(name => returnedTrackingNumber(field(item, name))),
85
+ transit: returnedTrackingNumber(field(item, 'ShipmentNumberTransit')),
85
86
  destination: clean(field(item, 'CountryDel'), 8),
86
87
  // The page hides these, and the Chinese consolidator's own registration before Meest's.
87
88
  hidden: code === HIDDEN_CODE || (clean(field(item, 'AgentCode'), 40) === 'MCHINA' && field(item, 'ActionMessages').trim() === 'Зареєстровано'),
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
3
4
  import { explicitOffsetTime, zonedTime } from '../../core/time/index.js';
@@ -185,7 +186,7 @@ function partnerOf(payload) {
185
186
  for (const reference of references) {
186
187
  if (!isRecord(reference) || typeof reference.name !== 'string' || !Object.hasOwn(PARTNERS, reference.name))
187
188
  continue;
188
- const number = normalizeTrackingNumber(clean(reference.number, 64));
189
+ const number = returnedTrackingNumber(reference.number);
189
190
  if (/^[A-Z0-9]{8,40}$/.test(number))
190
191
  return { delivery_carrier: PARTNERS[reference.name], delivery_tracking_number: number };
191
192
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { carrierIdFromPartner } from '../../core/catalog/hints.js';
3
4
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
@@ -52,7 +53,7 @@ function shipmentOf(html, number) {
52
53
  if (!isRecord(shipment) || typeof shipment.tracking_code !== 'string') {
53
54
  throw new SchemaError(PROVIDER, 'Passport returned a shipment without its tracking number');
54
55
  }
55
- if (normalizeTrackingNumber(clean(shipment.tracking_code, 64)) !== number) {
56
+ if (returnedTrackingNumber(shipment.tracking_code) !== number) {
56
57
  throw new SchemaError(PROVIDER, 'Passport returned another shipment');
57
58
  }
58
59
  const bought = shipment.time_bought;
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { ChallengeError, InvalidInputError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
3
4
  import { lastMovement } from '../../core/result/pickup.js';
@@ -66,7 +67,7 @@ function partnerOf(mappings, number) {
66
67
  if (!isRecord(mapping) || mapping.mappingFor !== 'RECEIVER')
67
68
  continue;
68
69
  const carrier = PARTNERS[clean(mapping.externalCarrier, 32)];
69
- const reference = normalizeTrackingNumber(cleanScalar(mapping.barcode, 64));
70
+ const reference = returnedTrackingNumber(mapping.barcode, true);
70
71
  if (carrier && /^[A-Z0-9]{4,40}$/.test(reference) && reference !== number) {
71
72
  return { delivery_carrier: carrier, delivery_tracking_number: reference };
72
73
  }
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { load } from 'cheerio';
2
3
  import { DateTime } from 'luxon';
3
4
  import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
@@ -103,7 +104,7 @@ export function parseXdp(html, raw) {
103
104
  const field = $('form#trackform input#c');
104
105
  if (field.length !== 1)
105
106
  throw new IndeterminateError(PROVIDER, 'XDP did not return its tracking page');
106
- if (normalizeTrackingNumber(clean(field.attr('value'), 64)) !== number) {
107
+ if (returnedTrackingNumber(field.attr('value')) !== number) {
107
108
  throw new SchemaError(PROVIDER, 'XDP answered for a different consignment');
108
109
  }
109
110
  const cells = $('#leftCol tr').toArray().map((tr) => $(tr).children('td'))
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
3
4
  import { zonedTime } from '../../core/time/index.js';
@@ -21,7 +22,7 @@ export function parseYto(payload, trackingNumber) {
21
22
  throw new SchemaError(PROVIDER, 'YTO returned an invalid shipment list');
22
23
  if (!payload.length)
23
24
  throw new IndeterminateError(PROVIDER, 'YTO returned no shipment details');
24
- if (payload.length !== 1 || !isRecord(payload[0]) || normalizeTrackingNumber(clean(payload[0].waybillNo, 64)) !== number) {
25
+ if (payload.length !== 1 || !isRecord(payload[0]) || returnedTrackingNumber(payload[0].waybillNo) !== number) {
25
26
  throw new SchemaError(PROVIDER, 'YTO returned missing or ambiguous shipment identity');
26
27
  }
27
28
  const item = payload[0];
@@ -39,7 +40,7 @@ export function parseYto(payload, trackingNumber) {
39
40
  // The official client uses the first row as current. Read oldest first to
40
41
  // preserve a return leg when later scans use ordinary movement/signature codes.
41
42
  for (const raw of [...history].reverse()) {
42
- if (!isRecord(raw) || normalizeTrackingNumber(clean(raw.waybillNo, 64)) !== number) {
43
+ if (!isRecord(raw) || returnedTrackingNumber(raw.waybillNo) !== number) {
43
44
  throw new SchemaError(PROVIDER, 'YTO returned mixed or incomplete shipment history');
44
45
  }
45
46
  const code = clean(raw.opCode, 32);
@@ -1,3 +1,4 @@
1
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
1
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
3
  import { ChallengeError, IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
3
4
  import { zonedTime } from '../../core/time/index.js';
@@ -58,7 +59,7 @@ export function parseYunda(payload, trackingNumber) {
58
59
  const events = [];
59
60
  let returning = false;
60
61
  for (const raw of history.gn) {
61
- if (!isRecord(raw) || normalizeTrackingNumber(clean(raw.mailNo, 64)) !== number) {
62
+ if (!isRecord(raw) || returnedTrackingNumber(raw.mailNo) !== number) {
62
63
  throw new SchemaError(PROVIDER, 'Yunda returned mixed or incomplete scan identities');
63
64
  }
64
65
  const label = clean(raw.status, 100);
@@ -0,0 +1,2 @@
1
+ /** Normalize a complete provider identifier; display-text limits must never hide a suffix. */
2
+ export declare function returnedTrackingNumber(value: unknown, allowNumeric?: boolean): string;
@@ -0,0 +1,7 @@
1
+ import { normalizeTrackingNumber } from './normalize.js';
2
+ /** Normalize a complete provider identifier; display-text limits must never hide a suffix. */
3
+ export function returnedTrackingNumber(value, allowNumeric = false) {
4
+ const text = typeof value === 'string' ? value
5
+ : allowNumeric && typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : '';
6
+ return text.length <= 4096 ? normalizeTrackingNumber(text) : '';
7
+ }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.133.0"
5
+ "version": "0.133.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.133.0",
3
+ "version": "0.133.1",
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",