universal-parcel-scraper 0.636.0-main.2999 → 0.637.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.
package/README.md CHANGED
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 16,379 such statuses, each filed under one stage.
158
+ The carrier folders record 16,380 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -1,10 +1,14 @@
1
1
  import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
2
  import { IndeterminateError, SchemaError } from '../../core/errors/index.js';
3
3
  import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, isNetworkFailure } from '../../core/runner/networkRetry.js';
4
5
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
5
6
  import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
6
- import { normalizeCttExpressNumber, parseCttExpress } from './parser.js';
7
+ import { CttExpressErrorReply, normalizeCttExpressNumber, parseCttExpress } from './parser.js';
7
8
  const ENDPOINT = 'https://wct.cttexpress.com/p_track_redis_v2.php';
9
+ /** A body that is not JSON. */
10
+ class UnreadableReply extends SchemaError {
11
+ }
8
12
  export class CttExpressTracker {
9
13
  options;
10
14
  constructor(options = {}) {
@@ -12,30 +16,38 @@ export class CttExpressTracker {
12
16
  }
13
17
  fetch(raw, context = {}) {
14
18
  const number = normalizeCttExpressNumber(raw);
19
+ const read = async (signal, timeoutMs) => {
20
+ signal.throwIfAborted();
21
+ const url = new URL(ENDPOINT);
22
+ url.searchParams.set('sc', number);
23
+ const { response, bytes } = await fetchBounded(url, { signal, headers: { Accept: 'application/json',
24
+ Origin: 'https://shipping-tracking.production.cloud2.cttexpress.com', 'User-Agent': userAgentOf(this.options.userAgent),
25
+ } }, { provider: 'CTT Express', timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: 1_000_000,
26
+ allowHttpStatuses: [404, 410], fetcher: this.options.fetcher });
27
+ if (response.status !== 200)
28
+ throw new IndeterminateError('CTT Express', 'CTT Express tracking endpoint is unavailable');
29
+ let payload;
30
+ try {
31
+ payload = parseJsonBytes(bytes, 'CTT Express');
32
+ }
33
+ catch (cause) {
34
+ throw new UnreadableReply('CTT Express', 'CTT Express returned invalid tracking JSON', { cause });
35
+ }
36
+ return parseCttExpress(payload, number);
37
+ };
38
+ // A few percent of lookups get an error reply or a body that is not JSON, and the same
39
+ // parcel reads normally on the next poll. Those, like a request that fails to reach the
40
+ // service, are read once more.
15
41
  return runSteps({ carrier: 'ctt-express', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
16
- recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
17
- signal.throwIfAborted();
18
- const url = new URL(ENDPOINT);
19
- url.searchParams.set('sc', number);
20
- const { response, bytes } = await fetchBounded(url, { signal, headers: { Accept: 'application/json',
21
- Origin: 'https://shipping-tracking.production.cloud2.cttexpress.com', 'User-Agent': userAgentOf(this.options.userAgent),
22
- } }, { provider: 'CTT Express', timeoutMs: Math.max(1, Math.floor(remainingMs)), maxBytes: 1_000_000,
23
- allowHttpStatuses: [404, 410], fetcher: this.options.fetcher });
24
- if (response.status !== 200)
25
- throw new IndeterminateError('CTT Express', 'CTT Express tracking endpoint is unavailable');
26
- let payload;
27
- try {
28
- payload = parseJsonBytes(bytes, 'CTT Express');
29
- }
30
- catch (cause) {
31
- throw new SchemaError('CTT Express', 'CTT Express returned invalid tracking JSON', { cause });
32
- }
33
- return parseCttExpress(payload, number);
34
- } }]);
42
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
43
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
44
+ { id: 'retry', recovers: (error) => error instanceof CttExpressErrorReply || error instanceof UnreadableReply || isNetworkFailure(error),
45
+ run: ({ signal, remainingMs }) => read(signal, remainingMs) },
46
+ ]);
35
47
  }
36
48
  }
37
49
  export const adapter = (environment) => {
38
50
  const tracker = new CttExpressTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
39
- return { id: 'ctt-express', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context),
51
+ return { id: 'ctt-express', recordsSteps: true, steps: ['direct', 'retry'], track: (input, context) => tracker.fetch(input.number, context),
40
52
  recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeCttExpressNumber(number))) };
41
53
  };
@@ -1,3 +1,7 @@
1
+ import { IndeterminateError } from '../../core/errors/index.js';
1
2
  import type { CarrierResult } from '../../core/result/index.js';
3
+ /** A reply that carries an error in place of the shipment. */
4
+ export declare class CttExpressErrorReply extends IndeterminateError {
5
+ }
2
6
  export declare function normalizeCttExpressNumber(raw: string): string;
3
7
  export declare function parseCttExpress(payload: unknown, number: string): CarrierResult;
@@ -6,6 +6,9 @@ import { calendarDay, explicitOffsetTime } from '../../core/time/index.js';
6
6
  import { clean } from '../../core/transport/index.js';
7
7
  import { isRecord } from '../../core/types.js';
8
8
  import { classifyCttExpressStatus } from './status.js';
9
+ /** A reply that carries an error in place of the shipment. */
10
+ export class CttExpressErrorReply extends IndeterminateError {
11
+ }
9
12
  export function normalizeCttExpressNumber(raw) {
10
13
  const number = raw.replace(/[\s.-]/g, '');
11
14
  if (!isCttExpressTrackingNumber(number))
@@ -33,7 +36,7 @@ export function parseCttExpress(payload, number) {
33
36
  // An upstream Invalid token reply can precede successful anonymous retrieval.
34
37
  // No error signature has established a reliable parcel-negative response.
35
38
  if (payload.error != null)
36
- throw new IndeterminateError('CTT Express', 'CTT Express returned an inconclusive tracking response');
39
+ throw new CttExpressErrorReply('CTT Express', 'CTT Express returned an inconclusive tracking response');
37
40
  const data = payload.data;
38
41
  if (!isRecord(data) || data.shipping_code !== requested || !isRecord(data.shipping_history)) {
39
42
  throw new SchemaError('CTT Express', 'CTT Express returned a different or incomplete shipment');
@@ -18,7 +18,8 @@
18
18
  "mode": "automatic",
19
19
  "adapter": "royal-mail",
20
20
  "steps": [
21
- "browser"
21
+ "browser",
22
+ "retry"
22
23
  ],
23
24
  "browser": true
24
25
  },
@@ -13,6 +13,8 @@ const SETTLE_TIMEOUT_MS = 15_000;
13
13
  // A healthy lookup needs a fraction of this. A browser that is down fails every
14
14
  // parcel the same way, and each failure must not hold a consumer's run for a minute.
15
15
  const BROWSER_TIMEOUT_MS = 30_000;
16
+ // A fresh consent flow and the separate history request need time to complete.
17
+ const BROWSER_RETRY_MIN_MS = 15_000;
16
18
  const MAX_CAPTURED = 20;
17
19
  export class RoyalMailTracker {
18
20
  timeoutMs;
@@ -39,8 +41,17 @@ export class RoyalMailTracker {
39
41
  async fetch(trackingNumber, context = {}) {
40
42
  const number = normalizeRoyalMailNumber(trackingNumber);
41
43
  if (this.#browserWsEndpoint || this.#executablePath) {
42
- return runSteps({ carrier: 'royal-mail', budgetMs: context.budgetMs ?? this.timeoutMs,
43
- signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath ?? '', signal, Math.min(BROWSER_TIMEOUT_MS, remainingMs), this.#fullHistory, this.#browserProxy, this.#browserWsEndpoint) }]);
44
+ const budgetMs = Math.min(BROWSER_TIMEOUT_MS, context.budgetMs ?? this.timeoutMs);
45
+ const started = performance.now();
46
+ const run = ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath ?? '', signal, remainingMs, this.#fullHistory, this.#browserProxy, this.#browserWsEndpoint);
47
+ return runSteps({ carrier: 'royal-mail', budgetMs,
48
+ signal: context.signal, recorder: this.#recorder }, [
49
+ { id: 'browser', run },
50
+ // Each call disposes its context before returning. A fresh session can
51
+ // recover an intermittent edge refusal without retaining issued tokens.
52
+ { id: 'retry', run, recovers: error => error instanceof ChallengeError
53
+ && budgetMs - (performance.now() - started) >= BROWSER_RETRY_MIN_MS },
54
+ ]);
44
55
  }
45
56
  // The legacy service remains available through explicit tracker instances.
46
57
  const trawl = this.#trawl;
@@ -171,7 +182,7 @@ export const adapter = environment => {
171
182
  const tracker = new RoyalMailTracker({ executablePath: environment.browserExecutablePath,
172
183
  browserWsEndpoint: endpoint, browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
173
184
  return {
174
- id: 'royal-mail', recordsSteps: true, steps: ['browser'],
185
+ id: 'royal-mail', recordsSteps: true, steps: ['browser', 'retry'],
175
186
  track: (input, context) => {
176
187
  normalizeRoyalMailNumber(input.number);
177
188
  if (!endpoint && !environment.browserExecutablePath)
@@ -16,7 +16,8 @@
16
16
  "mode": "automatic",
17
17
  "adapter": "royal-mail",
18
18
  "steps": [
19
- "browser"
19
+ "browser",
20
+ "retry"
20
21
  ],
21
22
  "browserRecognition": {
22
23
  "rank": 910
@@ -5,9 +5,10 @@ import { isRecord } from '../../core/types.js';
5
5
  import { classifySeurStatus } from './status.js';
6
6
  export function normalizeSeurNumber(raw) {
7
7
  const number = raw.replace(/\s/g, '');
8
- // Twenty-two digits is a sender's reference, which the lookup also finds.
9
- if (!/^(?:\d{7}|\d{14}|\d{21}|\d{22})$/.test(number))
10
- throw new InvalidInputError('SEUR', 'SEUR requires a seven, fourteen, twenty-one or twenty-two digit identifier');
8
+ // Twenty-two digits is a sender's reference, which the lookup also finds, as
9
+ // it finds twelve digits a provider names SEUR for.
10
+ if (!/^(?:\d{7}|\d{12}|\d{14}|\d{21}|\d{22})$/.test(number))
11
+ throw new InvalidInputError('SEUR', 'SEUR requires a seven, twelve, fourteen, twenty-one or twenty-two digit identifier');
11
12
  return number;
12
13
  }
13
14
  /**
@@ -8,6 +8,8 @@ const observed = {
8
8
  LC003: { group: 'EN REPARTO', wording: 'EL ENVÍO ESTÁ EN REPARTO.', mapped: { status: 'out_for_delivery', stage: 'out_for_delivery' } },
9
9
  LI300: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO Y ES POSIBLE QUE SE DEMORE LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: exception },
10
10
  LI582: { group: 'EN DEMORA', wording: 'EL ENVÍO HA SUFRIDO UN RETRASO PROVOCANDO UNA POSIBLE DEMORA EN LA ENTREGA. DISCULPA LAS MOLESTIAS.', mapped: exception },
11
+ // Recorded after the day's delivery round, which starts again the next day.
12
+ LI521: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO PORQUE LA DIRECCIÓN DE ENTREGA ESTÁ INCOMPLETA O ERRÓNEA.', mapped: { status: 'exception', stage: 'failed_attempt' } },
11
13
  LI523: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO POR AUSENCIA O CIERRE. SE PUEDE REPROGRAMAR UNA NUEVA ENTREGA.', mapped: { status: 'exception', stage: 'failed_attempt' } },
12
14
  LI524: { group: 'EN INCIDENCIA', wording: 'EL ENVÍO NO SE HA ENTREGADO PORQUE NO ES LA DIRECCIÓN ACTUAL DEL DESTINATARIO.', mapped: exception },
13
15
  LI530: { group: 'ENTREGA EN TIENDA', wording: 'EL ENVÍO ESTÁ DISPONIBLE PARA RECOGER EN EL PUNTO SEUR PICKUP.', mapped: { status: 'in_transit', stage: 'ready_for_pickup' } },
@@ -89,6 +89,15 @@
89
89
  "stage": "out_for_delivery",
90
90
  "confirmedBy": "live"
91
91
  },
92
+ {
93
+ "code": "LI521",
94
+ "wording": "EL ENVÍO NO SE HA ENTREGADO PORQUE LA DIRECCIÓN DE ENTREGA ESTÁ INCOMPLETA O ERRÓNEA.",
95
+ "language": "es",
96
+ "stage": "failed_attempt",
97
+ "confirmedBy": "live",
98
+ "firstSeen": "2026-10-22",
99
+ "note": "Recorded hours after each day's delivery round, which starts again the next day: an attempt the address held up."
100
+ },
92
101
  {
93
102
  "code": "LI523",
94
103
  "wording": "EL ENVÍO NO SE HA ENTREGADO POR AUSENCIA O CIERRE. SE PUEDE REPROGRAMAR UNA NUEVA ENTREGA.",
@@ -24,7 +24,7 @@ export declare class UPSUnavailableError extends IndeterminateError {
24
24
  }
25
25
  /**
26
26
  * The browser step, skipped while UPS's status call would likely answer HTTP 500
27
- * again: inconclusive, so another source may answer.
27
+ * again, or Akamai block the page again: inconclusive, so another source may answer.
28
28
  */
29
29
  export declare class UPSStatusHeldError extends IndeterminateError {
30
30
  readonly heldMs: number;
@@ -27,8 +27,10 @@ const SETTLE_TIMEOUT_MS = 15_000;
27
27
  const MAX_CAPTURED = 20;
28
28
  // UPS's status call answers HTTP 500 in bursts. During one, the browser's call failed again
29
29
  // for every lookup that followed within a minute and nearly every one within five, while
30
- // lookups after a longer pause were answered. After a 500 the browser rests that long, and
31
- // the caller's next source answers meanwhile.
30
+ // lookups after a longer pause were answered. Akamai's blocks, which keep the browser on its
31
+ // interstitial, come in bursts too: 84% of the lookups within five minutes of one were
32
+ // blocked, against 3% otherwise. After either the browser rests that long, and the caller's
33
+ // next source answers meanwhile.
32
34
  export const STATUS_HOLD_MS = 5 * 60_000;
33
35
  // Provider strings are short; the rendered page's whole text is not, and the
34
36
  // identity check reads all of it, so it passes its own limit to `clean`.
@@ -65,22 +67,41 @@ export class UPSUnavailableError extends IndeterminateError {
65
67
  }
66
68
  /**
67
69
  * The browser step, skipped while UPS's status call would likely answer HTTP 500
68
- * again: inconclusive, so another source may answer.
70
+ * again, or Akamai block the page again: inconclusive, so another source may answer.
69
71
  */
70
72
  export class UPSStatusHeldError extends IndeterminateError {
71
73
  heldMs;
72
74
  constructor(heldMs) {
73
- super('UPS', `UPS's status call answered HTTP 500 moments ago; the browser waits ${Math.ceil(heldMs / 1_000)} s more`);
75
+ super('UPS', `UPS's tracking failed in the browser moments ago; the browser waits ${Math.ceil(heldMs / 1_000)} s more`);
74
76
  this.heldMs = heldMs;
75
77
  this.name = 'UPSStatusHeldError';
76
78
  }
77
79
  }
78
80
  /**
79
- * When the status call last answered HTTP 500, by browser service. Every
80
- * tracker in the process that uses the service shares it, as a host's several
81
- * registries do; a restart forgets it.
81
+ * When the status call last answered HTTP 500, or Akamai last blocked the
82
+ * page, by browser service. Every tracker in the process that uses the service
83
+ * shares it, as a host's several registries do; a restart forgets it.
82
84
  */
83
85
  const statusFailedAt = new Map();
86
+ /**
87
+ * Whether the browser service's HTTP 500 says its last browser tier stayed
88
+ * blocked, as on Akamai's interstitial, until the tiers or the deadline ran
89
+ * out. An excerpt cut short still shows the status.
90
+ */
91
+ function trawlBlocked(error) {
92
+ if (!(error instanceof UpstreamHttpError) || error.status !== 500)
93
+ return false;
94
+ const excerpt = error.diagnostics?.body_excerpt ?? '';
95
+ let reply;
96
+ try {
97
+ reply = JSON.parse(excerpt);
98
+ }
99
+ catch {
100
+ return /"status"\s*:\s*"blocked"/.test(excerpt);
101
+ }
102
+ const last = isRecord(reply) && Array.isArray(reply.timings) ? reply.timings.at(-1) : undefined;
103
+ return isRecord(last) && (last.tier === 2 || last.tier === 3) && last.status === 'blocked';
104
+ }
84
105
  /** How long the browser service still rests after a 500, or 0. */
85
106
  function statusHeldMs(trawl) {
86
107
  const failedAt = statusFailedAt.get(trawl.endpoint.href);
@@ -579,21 +600,29 @@ export class UPSTracker {
579
600
  * validated in the page. The page the browser rendered is the last resort.
580
601
  */
581
602
  async #trawlResult(trawl, number, timeoutMs, signal) {
582
- const page = await trawl.scrape({
583
- url: upsTrackingUrl(number),
584
- skipHttp: true,
585
- maxTier: 3,
586
- maxTimeout: timeoutMs,
587
- captureResponses: [STATUS_API],
588
- settleTimeout: SETTLE_TIMEOUT_MS,
589
- }, {
590
- provider: TRAWL_PROVIDER,
591
- timeoutMs,
592
- maxBytes: MAX_BYTES,
593
- fetcher: this.#fetcher,
594
- signal,
595
- captureWindow: 'last',
596
- });
603
+ let page;
604
+ try {
605
+ page = await trawl.scrape({
606
+ url: upsTrackingUrl(number),
607
+ skipHttp: true,
608
+ maxTier: 3,
609
+ maxTimeout: timeoutMs,
610
+ captureResponses: [STATUS_API],
611
+ settleTimeout: SETTLE_TIMEOUT_MS,
612
+ }, {
613
+ provider: TRAWL_PROVIDER,
614
+ timeoutMs,
615
+ maxBytes: MAX_BYTES,
616
+ fetcher: this.#fetcher,
617
+ signal,
618
+ captureWindow: 'last',
619
+ });
620
+ }
621
+ catch (error) {
622
+ if (trawlBlocked(error))
623
+ statusFailedAt.set(trawl.endpoint.href, Date.now());
624
+ throw error;
625
+ }
597
626
  // The last matching response is authoritative. An earlier success cannot
598
627
  // answer after the page's retry was refused or returned invalid data.
599
628
  const entry = page.capturedResponses.filter((capture) => capture.url === STATUS_API).slice(-MAX_CAPTURED).at(-1);
@@ -18,8 +18,10 @@ export class WanbTracker {
18
18
  }
19
19
  async direct(raw, context = {}) {
20
20
  const number = normalizeWanbNumber(raw);
21
+ // Node's fetch sends `Accept-Language: *` unless told otherwise, and the
22
+ // tracker fails on it with HTTP 500, often after several seconds.
21
23
  const { bytes } = await fetchBounded(`${API}?TrackingNumber=${number}`, { signal: context.signal,
22
- headers: { Accept: 'application/json', 'User-Agent': userAgentOf(this.environment.userAgent) } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(context.budgetMs ?? 5000)), maxBytes: MAX_BYTES, fetcher: this.environment.fetcher });
24
+ headers: { Accept: 'application/json', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.environment.userAgent) } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(context.budgetMs ?? 5000)), maxBytes: MAX_BYTES, fetcher: this.environment.fetcher });
23
25
  return this.parse(decodeText(bytes), number);
24
26
  }
25
27
  parse(body, number) {
@@ -8,8 +8,8 @@
8
8
  * rules). A stage a provider declares and a UPU event code it maps are
9
9
  * `carrier_map`, so their scans never reach the app's review. Wording no rule
10
10
  * reads has no stage. Chronopost's notice to the recipient, a bare delivery
11
- * estimate and a note that the recipient's details changed, none of them a
12
- * scan, are left out on purpose.
11
+ * estimate, a note that the recipient's details changed and SEUR's delay
12
+ * notice, none of them a scan, are left out on purpose.
13
13
  */
14
14
  import { CHRONOPOST_NOTIFICATION } from '../carriers/chronopost/status.js';
15
15
  import { normalizeStatusWording } from '../core/status/statusMap.js';
@@ -29,5 +29,8 @@ export const statusMap = {
29
29
  }, {
30
30
  wording: 'recipient information updated',
31
31
  note: "A note that the recipient's details changed, after the local carrier's receipt: not a scan, so it keeps the stage the parcel already had.",
32
+ }, {
33
+ wording: 'en demora - el envío ha sufrido un retraso provocando una posible demora en la entrega.',
34
+ note: "SEUR's delay notice as Ship24 relays it, group before wording: not a scan, so it keeps the stage the parcel already had.",
32
35
  }],
33
36
  };
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.636.0"
5
+ "version": "0.637.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.636.0-main.2999",
3
+ "version": "0.637.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",