universal-parcel-scraper 0.576.0-main.2691 → 0.577.2-main.2708

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 (39) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +3 -0
  3. package/dist/browser/scraper.js +2 -0
  4. package/dist/carriers/17feia/parser.js +11 -3
  5. package/dist/carriers/17feia/status.js +2 -0
  6. package/dist/carriers/17feia/statuses.json +1 -0
  7. package/dist/carriers/aliexpress/adapter.d.ts +11 -0
  8. package/dist/carriers/aliexpress/adapter.js +28 -4
  9. package/dist/carriers/chunghwa-post/parser.js +22 -2
  10. package/dist/carriers/correios-br/carrier.json +3 -1
  11. package/dist/carriers/dhl-express/status.js +6 -3
  12. package/dist/carriers/dhl-express/statuses.json +9 -0
  13. package/dist/carriers/dpd-fr/adapter.js +13 -13
  14. package/dist/carriers/dpd-uk/parser.js +24 -7
  15. package/dist/carriers/imile/adapter.d.ts +8 -1
  16. package/dist/carriers/imile/adapter.js +60 -8
  17. package/dist/carriers/imile/cainiao.d.ts +18 -0
  18. package/dist/carriers/imile/cainiao.js +109 -0
  19. package/dist/carriers/imile/carrier.json +2 -1
  20. package/dist/carriers/imile/parser.d.ts +3 -1
  21. package/dist/carriers/imile/parser.js +14 -9
  22. package/dist/carriers/imile/status.d.ts +1 -0
  23. package/dist/carriers/imile/status.js +8 -1
  24. package/dist/carriers/japan-post/adapter.js +20 -8
  25. package/dist/carriers/tsjy/parser.js +24 -8
  26. package/dist/carriers/ups/status.js +5 -6
  27. package/dist/carriers/ups/statuses.json +1 -1
  28. package/dist/carriers/yanwen/status.js +1 -0
  29. package/dist/carriers/yanwen/statuses.json +8 -0
  30. package/dist/carriers/yto/adapter.d.ts +2 -1
  31. package/dist/carriers/yto/adapter.js +22 -15
  32. package/dist/carriers/yto/carrier.json +2 -1
  33. package/dist/core/catalog/hints.js +11 -9
  34. package/dist/data/catalog.json +3 -0
  35. package/dist/generated/catalog.d.ts +1 -0
  36. package/dist/generated/catalog.js +3 -0
  37. package/dist/providers/shared/result.js +8 -0
  38. package/dist/server/openapi.json +1 -1
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 16,184 such statuses, each filed under one stage.
158
+ The carrier folders record 16,187 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.
package/data/catalog.json CHANGED
@@ -3547,6 +3547,9 @@
3547
3547
  "correios-br": {
3548
3548
  "displayName": "Correios Brazil",
3549
3549
  "color": "#ffd500",
3550
+ "aliases": [
3551
+ "Brazil Post"
3552
+ ],
3550
3553
  "countries": [
3551
3554
  "BR"
3552
3555
  ],
@@ -3127,6 +3127,7 @@ var Ye = {
3127
3127
  "correios-br": {
3128
3128
  displayName: "Correios Brazil",
3129
3129
  color: "#ffd500",
3130
+ aliases: ["Brazil Post"],
3130
3131
  countries: ["BR"],
3131
3132
  selectable: !0,
3132
3133
  timezone: "UTC",
@@ -29638,6 +29639,7 @@ var tl = /* @__PURE__ */ new Map([
29638
29639
  ["AU", "australia-post"],
29639
29640
  ["BD", "bangladesh-post"],
29640
29641
  ["BG", "bulgarian-posts"],
29642
+ ["BR", "correios-br"],
29641
29643
  ["BY", "belpost"],
29642
29644
  ["CA", "canada-post"],
29643
29645
  ["CH", "swiss-post"],
@@ -16,6 +16,12 @@ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/;
16
16
  const OFFSET = /^[+-]\d{2}:\d{2}$/;
17
17
  // 17FEIA's backend keeps the order data on Beijing time; a row kept as a wall clock is read in it only to hold back future ones.
18
18
  const HOME_ZONE = 'Asia/Shanghai';
19
+ /**
20
+ * 17FEIA's steps at its warehouse in China before the flight. The backend
21
+ * writes them on Beijing time, as it writes the warehouse's receipt; the
22
+ * offset they carry is the destination line's, whatever the step's clock.
23
+ */
24
+ const WAREHOUSE_STEPS = new Set(['包裹在去设施路上', '订单待客户确认', '订单已确认', '订单已出库', '出口报关中']);
19
25
  // States whose places keep different clocks, so a state does not settle a clock's zone.
20
26
  const SPLIT_STATES = new Set(['AK', 'AZ', 'FL', 'ID', 'IN', 'KS', 'KY', 'MI', 'ND', 'NE', 'NV', 'OR', 'SD', 'TN', 'TX']);
21
27
  const MAX_ROWS = 500;
@@ -74,14 +80,16 @@ function placedTime(local, label, country, state) {
74
80
  return null;
75
81
  return { iso: placed.toISO({ suppressMilliseconds: true }), timestamp: placed.toMillis() };
76
82
  }
77
- /** A row's clock: its instant where its place settles one, else its wall clock. */
78
- function clockOf(stamp, label, country, state) {
83
+ /** A row's clock: its instant where its place or its step at the warehouse settles one, else its wall clock. */
84
+ function clockOf(stamp, step, label, country, state) {
79
85
  const wall = WALL_CLOCK.exec(stamp);
80
86
  const local = wall ? `${wall[1]}T${wall[2]}` : '';
81
87
  const home = local ? firstOccurrence(DateTime.fromISO(local, { zone: HOME_ZONE })) : null;
82
88
  // Luxon rolls 24:00:00 over to the next day; only a clock that reads back unchanged is kept.
83
89
  if (!home?.isValid || home.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local)
84
90
  throw new SchemaError(PROVIDER, '17FEIA returned an invalid scan time');
91
+ if (WAREHOUSE_STEPS.has(step))
92
+ return { time: home.toISO({ suppressMilliseconds: true }), at: home.toMillis() };
85
93
  const time = placedTime(local, label, country, state);
86
94
  return time ? { time: time.iso, at: time.timestamp } : { local_time: local, at: home.toMillis() };
87
95
  }
@@ -95,7 +103,7 @@ function projectRow(row, readAt) {
95
103
  const stamp = text(row, 'createTime', 64);
96
104
  const country = text(row, 'country', 8).toUpperCase();
97
105
  // A US row's state is read only for its clock.
98
- const { at, ...clock } = clockOf(stamp, text(row, 'timeZone', 16), country, country === 'US' ? text(row, 'state', 8).toUpperCase() : '');
106
+ const { at, ...clock } = clockOf(stamp, label, text(row, 'timeZone', 16), country, country === 'US' ? text(row, 'state', 8).toUpperCase() : '');
99
107
  // A row dated after the read is not a scan yet.
100
108
  if (at > readAt)
101
109
  return null;
@@ -27,6 +27,8 @@ const LABELS = new Map([
27
27
  ['清关行提货到仓', 'customs'],
28
28
  ['清关中', 'customs'],
29
29
  ['清关完成', 'in_transit'],
30
+ // Handed in at the partner's depot, before the partner's own first scan.
31
+ ['已交仓,等待上网', 'in_transit'],
30
32
  ['成功签收', 'delivered'],
31
33
  ]);
32
34
  /** The label on rows relayed from the delivering partner: their wording is the partner's. */
@@ -18,6 +18,7 @@
18
18
  {"code": "清关行提货到仓", "wording": "Broker picked up Shipment and delivered to warehouse.", "stage": "customs", "language": "en", "confirmedBy": "live", "note": "The customs broker takes the shipment to its warehouse for clearance; the shared rules would read a delivery."},
19
19
  {"code": "清关中", "wording": "Customs Clearance is in progress.", "stage": "customs", "language": "en", "confirmedBy": "live"},
20
20
  {"code": "清关完成", "wording": "Customs Clearance Completed.", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
21
+ {"code": "已交仓,等待上网", "wording": "Arrived at delivery hub,Awaiting carrie scan.", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "After the clearance, before the delivering partner's first scan: the parcel was handed in at the partner's depot and waits to enter its system."},
21
22
  {"code": "运输途中", "wording": "The international shipment has been processed in the parcel center of origin", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Relayed from DHL in Germany, with DHL's place, country and offset. The label marks every relayed row; the stage comes from the partner's wording."},
22
23
  {"code": "运输途中", "wording": "The international shipment has been processed in the export parcel center", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Relayed from DHL in Germany."},
23
24
  {"code": "运输途中", "wording": "The shipment will be transported to the destination country/destination area and, from there, handed over to the delivery organization.", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Relayed from DHL in Germany, for a parcel bound for France."},
@@ -21,6 +21,11 @@
21
21
  */
22
22
  import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
23
23
  import type { CarrierResult } from '../../core/result/index.js';
24
+ /**
25
+ * The carriers a `detail.json` payload names for the number asked, in lower
26
+ * case: the destination carrier and each scan's carrier.
27
+ */
28
+ export declare function cainiaoCarrierNames(value: unknown, trackingNumber: string): ReadonlySet<string>;
24
29
  /** Projects one `detail.json` payload. Pure: the offline tests target this. */
25
30
  export declare function parseCainiaoTrackingResponse(value: unknown, trackingNumber: string): CarrierResult;
26
31
  export declare class CainiaoTracker {
@@ -33,7 +38,13 @@ export declare class CainiaoTracker {
33
38
  userAgent?: string;
34
39
  });
35
40
  fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
41
+ /**
42
+ * The global feed's `detail.json` reply for a number, unparsed, for a
43
+ * carrier that reads Cainiao's leg of its own parcels.
44
+ */
45
+ detail(trackingNumber: string, context?: TrackingContext): Promise<unknown>;
36
46
  private read;
47
+ private payload;
37
48
  }
38
49
  /** Kept for the host's legacy dispatch chain until it is deleted. */
39
50
  export declare function fetchCainiao(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
@@ -165,8 +165,8 @@ function recordedInBeijing(scan, wall) {
165
165
  const beijing = new Date(epoch + BEIJING_OFFSET_MS);
166
166
  return Number.isFinite(beijing.getTime()) && beijing.toISOString().slice(0, 19).replace('T', ' ') === wall;
167
167
  }
168
- /** Projects one `detail.json` payload. Pure: the offline tests target this. */
169
- export function parseCainiaoTrackingResponse(value, trackingNumber) {
168
+ /** The module of a `detail.json` payload for the number asked; any other shipment is refused. */
169
+ function trackingModuleOf(value, trackingNumber) {
170
170
  const payload = record(value);
171
171
  const rawModules = Array.isArray(payload.module)
172
172
  ? payload.module
@@ -186,6 +186,20 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
186
186
  const trackingModule = identified.find((module) => comparableIdentifier(module.mailNo) === requested);
187
187
  if (!trackingModule)
188
188
  throw new SchemaError(PROVIDER, 'Cainiao returned a different shipment');
189
+ return trackingModule;
190
+ }
191
+ /**
192
+ * The carriers a `detail.json` payload names for the number asked, in lower
193
+ * case: the destination carrier and each scan's carrier.
194
+ */
195
+ export function cainiaoCarrierNames(value, trackingNumber) {
196
+ const trackingModule = trackingModuleOf(value, trackingNumber);
197
+ return carrierNames(trackingModule, recordArray(trackingModule.detailList));
198
+ }
199
+ /** Projects one `detail.json` payload. Pure: the offline tests target this. */
200
+ export function parseCainiaoTrackingResponse(value, trackingNumber) {
201
+ const trackingModule = trackingModuleOf(value, trackingNumber);
202
+ const requested = comparableIdentifier(trackingNumber);
189
203
  const rawStatus = text(trackingModule.status);
190
204
  const latest = record(trackingModule.latestTrace ?? trackingModule.globalCombinedLogisticsTraceDTO);
191
205
  const rawDetails = trackingModule.detailList;
@@ -278,7 +292,7 @@ export class CainiaoTracker {
278
292
  const budget = lookupBudget(context, this.timeoutMs);
279
293
  try {
280
294
  // The feed refuses a request another site refers ("uri is null or empty").
281
- return await this.read(`${DETAIL_URL}?${new URLSearchParams({ mailNos: trackingNumber, lang: 'en-US' })}`, DETAIL_PAGE, trackingNumber, budget);
295
+ return parseCainiaoTrackingResponse(await this.payload(`${DETAIL_URL}?${new URLSearchParams({ mailNos: trackingNumber, lang: 'en-US' })}`, DETAIL_PAGE, budget), trackingNumber);
282
296
  }
283
297
  catch (error) {
284
298
  if (!(error instanceof NotFoundError) || !HOME_DELIVERY.test(comparableIdentifier(trackingNumber)))
@@ -293,11 +307,21 @@ export class CainiaoTracker {
293
307
  throw new IndeterminateError(PROVIDER, 'Cainiao has no record of the shipment yet', { reason: 'portal_not_found' });
294
308
  }
295
309
  }
310
+ /**
311
+ * The global feed's `detail.json` reply for a number, unparsed, for a
312
+ * carrier that reads Cainiao's leg of its own parcels.
313
+ */
314
+ async detail(trackingNumber, context = {}) {
315
+ return this.payload(`${DETAIL_URL}?${new URLSearchParams({ mailNos: trackingNumber, lang: 'en-US' })}`, DETAIL_PAGE, lookupBudget(context, this.timeoutMs));
316
+ }
296
317
  async read(url, referer, trackingNumber, budget) {
318
+ return parseCainiaoTrackingResponse(await this.payload(url, referer, budget), trackingNumber);
319
+ }
320
+ async payload(url, referer, budget) {
297
321
  const { bytes } = await fetchBounded(url, { signal: budget.signal, headers: { ...BASE_HEADERS, 'User-Agent': this.userAgent, Referer: referer } }, { provider: UPSTREAM, timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()), fetcher: this.fetcher });
298
322
  if (sliderPage(decodeText(bytes)))
299
323
  throw new ChallengeError(PROVIDER);
300
- return parseCainiaoTrackingResponse(parseJsonBytes(bytes, UPSTREAM), trackingNumber);
324
+ return parseJsonBytes(bytes, UPSTREAM);
301
325
  }
302
326
  }
303
327
  /** Kept for the host's legacy dispatch chain until it is deleted. */
@@ -2,10 +2,12 @@ import { DateTime } from 'luxon';
2
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
3
  import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, RateLimitedError, SchemaError } from '../../core/errors/index.js';
4
4
  import { languageStageStatus } from '../../core/status/index.js';
5
+ import { firstOccurrence } from '../../core/time/index.js';
5
6
  import { clean } from '../../core/transport/text.js';
6
7
  import { isRecord } from '../../core/types.js';
7
8
  import { chunghwaPostStage } from './status.js';
8
9
  export const PROVIDER = 'Chunghwa Post';
10
+ const TAIWAN = 'Asia/Taipei';
9
11
  export const isSupportedChunghwaPostNumber = (number) => /^(?:[CELR][A-Z]\d{9}[A-Z]{2}|UH\d{9}TW)$/.test(number);
10
12
  export function normalizeChunghwaPostNumber(raw) {
11
13
  const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
@@ -32,6 +34,21 @@ function clock(value) {
32
34
  }
33
35
  throw new SchemaError(PROVIDER, 'Chunghwa Post returned an invalid scan clock');
34
36
  }
37
+ /**
38
+ * Whether Chunghwa Post's own branch made the scan. The official client reads a
39
+ * branch code of digits or a `TW` office of exchange as one of its own
40
+ * branches, and links another post's site for a scan made abroad.
41
+ */
42
+ function ownBranch(row) {
43
+ const branch = typeof row.BRHNO === 'string' ? row.BRHNO.trim().slice(0, 6) : '';
44
+ const abroad = row.POST_URL != null && (typeof row.POST_URL !== 'string' || row.POST_URL.trim() !== '');
45
+ return /^(?:\d{6}|TW[A-Z]{4})$/.test(branch) && !abroad;
46
+ }
47
+ /** A wall clock of Chunghwa Post's own branch: Taiwan keeps one zone, without daylight saving. */
48
+ function taiwanInstant(wall) {
49
+ const placed = firstOccurrence(DateTime.fromISO(wall, { zone: TAIWAN }));
50
+ return placed.isValid ? placed.toUTC().toISO({ suppressMilliseconds: true }) ?? undefined : undefined;
51
+ }
35
52
  export function parseChunghwaPost(payload, raw) {
36
53
  const number = normalizeChunghwaPostNumber(raw);
37
54
  if (!Array.isArray(payload) || !payload.length || payload.length > 4)
@@ -83,14 +100,17 @@ export function parseChunghwaPost(payload, raw) {
83
100
  throw new SchemaError(PROVIDER, 'Chunghwa Post returned an invalid scan status');
84
101
  const classified = chunghwaPostStage(description);
85
102
  const location = text(row, 'BRHNAT', 100);
86
- return { local_time: clock(row.DATIME), description, provider_code: code, ...(location ? { location } : {}),
103
+ const localTime = clock(row.DATIME);
104
+ // A scan abroad is on that country's local clock, as the tracker's notes say, so it keeps its wall clock.
105
+ const time = ownBranch(row) ? taiwanInstant(localTime) : undefined;
106
+ return { ...(time ? { time } : {}), local_time: localTime, description, provider_code: code, ...(location ? { location } : {}),
87
107
  ...(classified ? { stage: classified.stage, stage_source: classified.source } : {}) };
88
108
  });
89
109
  const newest = events[0];
90
110
  const classified = chunghwaPostStage(newest.description);
91
111
  return { status: classified ? languageStageStatus(classified.stage) : 'unknown',
92
112
  ...(classified ? { current_stage: classified.stage, current_stage_source: classified.source } : {}),
93
- provider_code: newest.provider_code, last_status_text: newest.description, last_update: null,
113
+ provider_code: newest.provider_code, last_status_text: newest.description, last_update: newest.time ?? null,
94
114
  last_update_local: newest.local_time, expected_delivery: null, events: events.slice(0, 100),
95
115
  ...(events.length > 100 ? { history_truncated: true } : {}) };
96
116
  }
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "id": "correios-br",
3
3
  "displayName": "Correios Brazil",
4
- "aliases": [],
4
+ "aliases": [
5
+ "Brazil Post"
6
+ ],
5
7
  "region": {
6
8
  "countries": [
7
9
  "BR"
@@ -16,6 +16,8 @@ const EXACT = new Map([
16
16
  // The recipient's duties or fees are paid; the clearance they held up completes next.
17
17
  ['payment is received and recorded for shipment related fees', 'customs'],
18
18
  ['shipment is in transit to destination', 'in_transit'],
19
+ // Waiting at a facility for the next linehaul or flight: on its way, not held.
20
+ ['scheduled to depart on the next planned movement', 'in_transit'],
19
21
  ['shipment is out with courier for delivery', 'out_for_delivery'],
20
22
  ['shipment is on hold', 'exception'],
21
23
  ['delivered', 'delivered'],
@@ -43,12 +45,13 @@ const PREFIXES = [
43
45
  * DHL's event codes on the unified feed, each seen with the wording above: SA
44
46
  * the shipment's acceptance, IC its processing for clearance, CD a clearance
45
47
  * step without its outcome, PY the payment of fees during clearance, MS a
46
- * missort, CC the wait for the consignee's collection, ND a delivery attempt
47
- * that could not be completed, RT the return to the shipper. They stage a scan
48
+ * missort, SM the wait for the next planned movement, CC the wait for the
49
+ * consignee's collection, ND a delivery attempt that could not be completed,
50
+ * RT the return to the shipper. They stage a scan
48
51
  * whose wording the map does not know, as in another language.
49
52
  */
50
53
  const CODES = new Map([
51
- ['SA', 'accepted'], ['IC', 'customs'], ['CD', 'customs'], ['PY', 'customs'], ['MS', 'exception'],
54
+ ['SA', 'accepted'], ['IC', 'customs'], ['CD', 'customs'], ['PY', 'customs'], ['MS', 'exception'], ['SM', 'in_transit'],
52
55
  ['CC', 'ready_for_pickup'], ['ND', 'failed_attempt'], ['RT', 'returned'],
53
56
  ]);
54
57
  /** The checkpoint's stage from DHL's own wording, if the wording is one of DHL's checkpoints. */
@@ -83,6 +83,15 @@
83
83
  "firstSeen": "2026-10-05",
84
84
  "note": "Followed by the facility; matched on its start."
85
85
  },
86
+ {
87
+ "code": "SM",
88
+ "wording": "Scheduled to depart on the next planned movement",
89
+ "language": "en",
90
+ "stage": "in_transit",
91
+ "confirmedBy": "live",
92
+ "firstSeen": "2026-10-20",
93
+ "note": "Waiting at a facility for its next linehaul or flight. The unified feed codes it SM; the mobile API sends the wording alone."
94
+ },
86
95
  {
87
96
  "wording": "Shipment has departed from a DHL facility",
88
97
  "language": "en",
@@ -16,9 +16,9 @@ import { classifyStatus, comparableText, includesAny } from './status.js';
16
16
  // - One page can describe two parcels: an outbound number and its return
17
17
  // number. The requested number decides which leg is read; the other leg's
18
18
  // rows never enter the result.
19
- // - Cloudflare normally challenges anonymous direct requests, so the page is
20
- // fetched directly first and through the private browser service's native
21
- // scrape API when that request is challenged.
19
+ // - Cloudflare challenges every anonymous direct request, so the page is
20
+ // fetched through the private browser service's native scrape API when one
21
+ // is configured, and directly only without one.
22
22
  const TRACKING_BASE = 'https://trace.dpd.fr/fr/trace';
23
23
  const TIMEZONE = 'Europe/Paris';
24
24
  const DEFAULT_TIMEOUT_MS = 90_000;
@@ -233,9 +233,9 @@ export class DPDFranceTracker {
233
233
  return await this.directGet(url, capped(this.directTimeoutMs, timeoutMs), signal);
234
234
  }
235
235
  catch (error) {
236
- // Without a browser service there is no second tier, so the
237
- // challenge has to carry the operator's next step itself.
238
- if (trawl || !(error instanceof DPDFranceChallengeError))
236
+ // The GET runs only without a browser service, so the challenge has
237
+ // to carry the operator's next step itself.
238
+ if (!(error instanceof DPDFranceChallengeError))
239
239
  throw error;
240
240
  throw new DPDFranceChallengeError('DPD France requires a browser challenge solver; configure FLARESOLVERR_URL');
241
241
  }
@@ -243,14 +243,15 @@ export class DPDFranceTracker {
243
243
  const html = await runSteps({
244
244
  carrier: 'dpd-fr', budgetMs: context.budgetMs ?? this.budgetMs, signal: context.signal, recorder: this.recorder,
245
245
  }, [
246
- // A GET that fails to reach DPD, or hangs, is sent once more: the first
247
- // gets at most half of what is left, so the second fits in the rest.
248
- { id: 'direct', run: ({ remainingMs, signal }) => direct(firstAttemptMs(remainingMs), signal) },
249
- networkRetryStep(({ remainingMs, signal }) => direct(remainingMs, signal)),
246
+ // Cloudflare challenges every plain GET, so a browser service, when
247
+ // there is one, is asked at once. Without one, a GET that fails to
248
+ // reach DPD, or hangs, is sent once more: the first gets at most half
249
+ // of what is left, so the second fits in the rest.
250
+ { id: 'direct', enabled: trawl === null, run: ({ remainingMs, signal }) => direct(firstAttemptMs(remainingMs), signal) },
251
+ { ...networkRetryStep(({ remainingMs, signal }) => direct(remainingMs, signal)), enabled: trawl === null },
250
252
  {
251
253
  id: 'trawl',
252
254
  enabled: trawl !== null,
253
- recovers: (error) => error instanceof DPDFranceChallengeError,
254
255
  run: async ({ remainingMs, signal }) => {
255
256
  const timeoutMs = capped(this.timeoutMs, remainingMs);
256
257
  return (await trawl.scrape({
@@ -307,8 +308,7 @@ export const adapter = (environment) => {
307
308
  });
308
309
  return {
309
310
  id: 'dpd-fr',
310
- // One direct HTML GET, then the browser service's native scrape API when
311
- // Cloudflare challenges it.
311
+ // The browser service's native scrape API, or a direct HTML GET without one.
312
312
  steps: ['direct', 'retry', 'trawl'],
313
313
  track: (input, context) => tracker.fetch(input.number, context),
314
314
  };
@@ -3,7 +3,7 @@ import { dpdParcelNumber } from '../../core/detection/dpd.js';
3
3
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
4
4
  import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
5
5
  import { classifyWording, languageStageStatus } from '../../core/status/index.js';
6
- import { explicitOffsetTime } from '../../core/time/index.js';
6
+ import { explicitOffsetTime, isoTime } from '../../core/time/index.js';
7
7
  import { clean } from '../../core/transport/index.js';
8
8
  import { isRecord } from '../../core/types.js';
9
9
  export const PROVIDER = 'DPD UK';
@@ -83,15 +83,32 @@ function publicDescription(value) {
83
83
  return text;
84
84
  return text.replace(/(\bdelivered)\s+(?:to\s+.+|and\s+signed\s+(?:for\s+)?by\s+.+|signed\s+(?:for\s+)?by\s+.+)$/i, '$1');
85
85
  }
86
- function clockFields(clock) {
86
+ /**
87
+ * DPD's group tracking service gives the scans of DPD's own network with
88
+ * London's offset, at the wall clocks this feed shows. The sender's data
89
+ * record, under the account's display name or `Sender`, comes from the same
90
+ * UK system. Partner networks abroad and Pickup shops may keep another clock.
91
+ */
92
+ function onUkClock(recorder, sender) {
93
+ return recorder === 'DPD' || recorder === 'Sender' || (sender !== '' && recorder === sender);
94
+ }
95
+ /** A UK wall clock's instant, unless the clocks skip it when summer time starts. */
96
+ function ukTime(local) {
97
+ const placed = isoTime(local, 'Europe/London');
98
+ return placed?.iso.startsWith(local) ? placed.iso : undefined;
99
+ }
100
+ function clockFields(clock, uk) {
87
101
  const shape = /^(\d{4}-\d{2}-\d{2})[ T]((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(Z|[+-]\d{2}:?\d{2})?$/.exec(clock);
88
102
  if (!shape)
89
103
  return { provider_time_text: clock };
90
104
  const iso = `${shape[1]}T${shape[2]}${shape[3] ?? ''}`;
91
105
  if (!DateTime.fromISO(iso, { zone: 'UTC', setZone: true }).isValid)
92
106
  return { provider_time_text: clock };
93
- if (!shape[3])
94
- return { local_time: `${shape[1]}T${shape[2]}` };
107
+ if (!shape[3]) {
108
+ const local = `${shape[1]}T${shape[2]}`;
109
+ const time = uk ? ukTime(local) : undefined;
110
+ return time ? { time } : { local_time: local };
111
+ }
95
112
  const offset = /[+-](\d{2}):?(\d{2})$/.exec(iso);
96
113
  if (offset && (Number(offset[1]) > 14 || Number(offset[2]) > 59 || (Number(offset[1]) === 14 && Number(offset[2]) !== 0))) {
97
114
  return { provider_time_text: clock };
@@ -107,6 +124,8 @@ export function parseDpdUkHistory(detailPayload, historyPayload, raw, code) {
107
124
  throw new SchemaError(PROVIDER);
108
125
  if (!historyPayload.data.length)
109
126
  throw new IndeterminateError(PROVIDER, 'DPD UK returned no parcel history');
127
+ // The account that booked the parcel: the shop whose name the page shows.
128
+ const sender = isRecord(detail.shipperDetails) ? clean(detail.shipperDetails.customerDisplayName, 120) : '';
110
129
  // The official page renders these rows newest first. Do not reorder uncertain clocks.
111
130
  const events = historyPayload.data.map(row => {
112
131
  if (!isRecord(row))
@@ -120,7 +139,7 @@ export function parseDpdUkHistory(detailPayload, historyPayload, raw, code) {
120
139
  throw new SchemaError(PROVIDER, 'DPD UK returned an incomplete scan');
121
140
  const classification = classifyDpdUkWording(description);
122
141
  // `eventLocation` names the network or the sender that recorded the scan, not a place.
123
- return { description, ...clockFields(clock),
142
+ return { description, ...clockFields(clock, onUkClock(clean(row.eventLocation, 120), sender)),
124
143
  ...(classification.source !== 'none' ? { stage: classification.stage, stage_source: classification.source } : {}) };
125
144
  });
126
145
  const seen = new Set();
@@ -135,8 +154,6 @@ export function parseDpdUkHistory(detailPayload, historyPayload, raw, code) {
135
154
  const summary = publicDescription(detail.trackingStatusCurrent);
136
155
  const summaryClassification = classifyDpdUkWording(summary);
137
156
  const current = summary ? summaryClassification : classifyDpdUkWording(latest.description);
138
- // The account that booked the parcel: the shop whose name the page shows.
139
- const sender = isRecord(detail.shipperDetails) ? clean(detail.shipperDetails.customerDisplayName, 120) : '';
140
157
  return { status: current.source !== 'none' ? languageStageStatus(current.stage) : 'unknown',
141
158
  ...(current.source !== 'none' ? { current_stage: current.stage, current_stage_source: current.source } : {}),
142
159
  last_status_text: summary || latest.description, last_update: latest.time ?? null,
@@ -29,6 +29,13 @@ export declare class ImileTracker {
29
29
  now?: () => number;
30
30
  signing?: Readonly<ImileSigning>;
31
31
  });
32
- fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
32
+ /**
33
+ * iMile's answer, with Cainiao's leg in front of its history unless
34
+ * `cainiao` is false. Cainiao is asked only once iMile has answered with
35
+ * scans, and its failure leaves iMile's answer as it is.
36
+ */
37
+ fetch(raw: string, context?: TrackingContext, { cainiao }?: {
38
+ cainiao?: boolean;
39
+ }): Promise<CarrierResult>;
33
40
  }
34
41
  export declare const adapter: AdapterFactory;
@@ -1,10 +1,12 @@
1
1
  import { constants, createHash, createPublicKey, publicEncrypt } from 'node:crypto';
2
2
  import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
3
- import { ChallengeError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
3
+ import { BudgetExceededError, ChallengeError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
4
4
  import { runSteps } from '../../core/runner/index.js';
5
5
  import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
6
- import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { errorTypeOf, NOOP_RECORDER, outcomeOf, safeRecorder } from '../../core/telemetry/index.js';
7
7
  import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
8
+ import { CainiaoTracker } from '../aliexpress/adapter.js';
9
+ import { CAINIAO, cainiaoScans, withCainiaoLeg } from './cainiao.js';
8
10
  import { IMILE_MAX_BYTES, normalizeImileNumber, parseImile, PROVIDER } from './parser.js';
9
11
  const ORIGIN = 'https://www.imile.com';
10
12
  /** The lookup the tracking page on www.imile.com makes for the number in its address. */
@@ -26,6 +28,11 @@ const SCRIPT_KEY = /setPublicKey\("([A-Za-z0-9+/]{200,1000}={0,2})"\)/;
26
28
  const SCRIPT_SALT = /\.MD5\(""\.concat\([\w$]+,"([\w-]{1,64})"\)\)/;
27
29
  // Bot checks Cloudflare, in front of the site, could put in place of a reply.
28
30
  const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required)/i;
31
+ const DEFAULT_BUDGET_MS = 20_000;
32
+ // Cainiao answers within a second or two; it gets half of what iMile left, at most this.
33
+ const CAINIAO_MS = 8_000;
34
+ // Less than this left for Cainiao is no time for a request.
35
+ const CAINIAO_MIN_MS = 250;
29
36
  function publicKey(key) {
30
37
  return createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' });
31
38
  }
@@ -66,12 +73,28 @@ export class ImileTracker {
66
73
  #signing;
67
74
  // When the page last showed the values in use.
68
75
  #checkedAt = Number.NEGATIVE_INFINITY;
76
+ #cainiao;
69
77
  constructor(options = {}) {
70
78
  this.options = options;
71
79
  this.#signing = options.signing ?? IMILE_SIGNING;
80
+ this.#cainiao = new CainiaoTracker({ fetcher: options.fetcher, userAgent: options.userAgent, timeoutMs: CAINIAO_MS });
72
81
  }
73
- async fetch(raw, context = {}) {
82
+ /**
83
+ * iMile's answer, with Cainiao's leg in front of its history unless
84
+ * `cainiao` is false. Cainiao is asked only once iMile has answered with
85
+ * scans, and its failure leaves iMile's answer as it is.
86
+ */
87
+ async fetch(raw, context = {}, { cainiao = true } = {}) {
74
88
  const number = normalizeImileNumber(raw);
89
+ const started = performance.now();
90
+ const budgetMs = context.budgetMs ?? DEFAULT_BUDGET_MS;
91
+ let attempts = 0;
92
+ const result = await this.#imile(number, { signal: context.signal, budgetMs }, (attempt) => { attempts = attempt; });
93
+ if (!cainiao)
94
+ return result;
95
+ return this.#withCainiao(number, result, context.signal, budgetMs - (performance.now() - started), attempts + 1);
96
+ }
97
+ async #imile(number, context, attempted) {
75
98
  const read = async (signal, timeoutMs) => {
76
99
  const deadline = performance.now() + timeoutMs;
77
100
  const left = () => Math.max(1, Math.floor(deadline - performance.now()));
@@ -92,12 +115,40 @@ export class ImileTracker {
92
115
  };
93
116
  // A request that fails to reach the site, or hangs, gets one more try: the
94
117
  // first gets half of the budget, so the retry fits in the rest.
95
- return runSteps({ carrier: 'imile', budgetMs: context.budgetMs ?? 20_000, signal: context.signal,
118
+ return runSteps({ carrier: 'imile', budgetMs: context.budgetMs, signal: context.signal,
96
119
  recorder: this.options.recorder ?? NOOP_RECORDER }, [
97
- { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
98
- networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
120
+ { id: 'direct', run: ({ signal, remainingMs, attempt }) => { attempted(attempt); return read(signal, firstAttemptMs(remainingMs)); } },
121
+ networkRetryStep(({ signal, remainingMs, attempt }) => { attempted(attempt); return read(signal, remainingMs); }),
99
122
  ]);
100
123
  }
124
+ /**
125
+ * Cainiao's leg of the parcel in front of iMile's history, recorded as the
126
+ * `cainiao` step. Cainiao gets half of the time iMile left, so iMile's
127
+ * answer comes back inside the budget whatever Cainiao does. Any failure,
128
+ * a challenge, a rate limit, an unknown number or a changed reply, is
129
+ * recorded with its kind and returns iMile's answer unchanged.
130
+ */
131
+ async #withCainiao(number, result, signal, remainingMs, attempt) {
132
+ const recorder = safeRecorder(this.options.recorder ?? NOOP_RECORDER);
133
+ const started = performance.now();
134
+ const record = (error) => recorder.step({
135
+ carrier: 'imile', step: 'cainiao', attempt, outcome: error === undefined ? 'ok' : outcomeOf(error),
136
+ errorType: error === undefined ? null : errorTypeOf(error), durationMs: Math.max(0, performance.now() - started),
137
+ fallbackFrom: null, fallbackReason: null, fallbackErrorType: null, ...(error === undefined ? {} : { error }),
138
+ });
139
+ const budgetMs = Math.min(CAINIAO_MS, Math.floor(remainingMs / 2));
140
+ try {
141
+ if (budgetMs < CAINIAO_MIN_MS)
142
+ throw new BudgetExceededError(CAINIAO, budgetMs);
143
+ const merged = withCainiaoLeg(result, cainiaoScans(await this.#cainiao.detail(number, { signal, budgetMs }), number));
144
+ record();
145
+ return merged;
146
+ }
147
+ catch (error) {
148
+ record(error);
149
+ return result;
150
+ }
151
+ }
101
152
  async #query(number, signing, signal, timeoutMs) {
102
153
  const { code, sign } = imileSignature(number, signing);
103
154
  const { response, bytes } = await fetchBounded(`${IMILE_API}?waybillNo=${encodeURIComponent(number)}&code=${code}`, { method: 'GET', signal,
@@ -138,8 +189,9 @@ export class ImileTracker {
138
189
  export const adapter = (environment) => {
139
190
  const tracker = new ImileTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
140
191
  return {
141
- id: 'imile', recordsSteps: true, steps: ['direct', 'retry'],
192
+ id: 'imile', recordsSteps: true, steps: ['direct', 'retry', 'cainiao'],
142
193
  track: (input, context) => tracker.fetch(input.number, context),
143
- recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeImileNumber(number))),
194
+ // Recognition asks iMile alone: Cainiao's record of a number is no evidence that iMile knows it.
195
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context, { cainiao: false }), () => accepted(() => normalizeImileNumber(number))),
144
196
  };
145
197
  };
@@ -0,0 +1,18 @@
1
+ import type { CarrierEvent, CarrierResult } from '../../core/result/index.js';
2
+ export declare const CAINIAO = "Cainiao";
3
+ /**
4
+ * Cainiao's scans of an iMile parcel, from its `detail.json` reply for the
5
+ * number. The record must name iMile as a carrier of the parcel: the same
6
+ * number in Cainiao's feed may be another network's parcel. A scan Cainiao
7
+ * does not place keeps its wall clock in `local_time`, as iMile's do.
8
+ */
9
+ export declare function cainiaoScans(payload: unknown, number: string): CarrierEvent[];
10
+ /**
11
+ * iMile's answer with Cainiao's scans from before iMile handled the parcel.
12
+ * iMile handles it from its first scan with a stage past its order record,
13
+ * such as a pickup, a flight or a centre: Cainiao's scans count only up to
14
+ * the last one that is older at every reading, and a scan both report stays
15
+ * iMile's. Before iMile's first such scan, its order record is all it has,
16
+ * and the newest scan of either feed gives the status.
17
+ */
18
+ export declare function withCainiaoLeg(imile: CarrierResult, cainiao: readonly CarrierEvent[]): CarrierResult;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Cainiao's leg of the parcels it hands to iMile.
3
+ *
4
+ * iMile's tracker starts with its order record and its own centres. For a
5
+ * parcel Cainiao carries out of China, Cainiao's global tracker holds the
6
+ * pickup, sorting, export, flight and import scans under the same iMile
7
+ * number, then relays iMile's own scans at their own clocks. Those older scans
8
+ * go in front of iMile's history; iMile's own scans stay as iMile gave them.
9
+ */
10
+ import { IndeterminateError } from '../../core/errors/index.js';
11
+ import { explicitOffsetTime } from '../../core/time/index.js';
12
+ import { cainiaoCarrierNames, parseCainiaoTrackingResponse } from '../aliexpress/adapter.js';
13
+ import { imileSummary } from './parser.js';
14
+ export const CAINIAO = 'Cainiao';
15
+ /** How Cainiao names iMile as the parcel's destination carrier or a scan's carrier ("Imile"). */
16
+ const IMILE_NAME = /^imile\b/;
17
+ const WALL_CLOCK = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2})?$/;
18
+ // A clock without an offset may be any instant up to 14 hours before its wall time.
19
+ const EARLIEST_OFFSET_MS = 14 * 3_600_000;
20
+ const SUMMARY_KEYS = ['status', 'current_stage', 'current_stage_source', 'last_status_text', 'last_update', 'last_update_local'];
21
+ /**
22
+ * Cainiao's scans of an iMile parcel, from its `detail.json` reply for the
23
+ * number. The record must name iMile as a carrier of the parcel: the same
24
+ * number in Cainiao's feed may be another network's parcel. A scan Cainiao
25
+ * does not place keeps its wall clock in `local_time`, as iMile's do.
26
+ */
27
+ export function cainiaoScans(payload, number) {
28
+ const events = parseCainiaoTrackingResponse(payload, number).events ?? [];
29
+ if (!events.length)
30
+ return [];
31
+ if (![...cainiaoCarrierNames(payload, number)].some((name) => IMILE_NAME.test(name))) {
32
+ throw new IndeterminateError(CAINIAO, 'Cainiao does not name iMile as a carrier of the parcel', { reason: 'carrier_unconfirmed' });
33
+ }
34
+ return events.flatMap(({ time, local_time, location, stage, stage_source, ...event }) => {
35
+ const clock = typeof time === 'string' && time ? time : typeof local_time === 'string' ? local_time : '';
36
+ if (!event.description || !clock)
37
+ return [];
38
+ const placed = explicitOffsetTime(clock) ? { time: clock } : WALL_CLOCK.test(clock) ? { local_time: clock.replace(' ', 'T') } : null;
39
+ if (!placed)
40
+ return [];
41
+ return [{ ...placed, ...(location ? { location } : {}), ...event,
42
+ ...(stage ? { stage, stage_source: typeof stage_source === 'string' ? stage_source : 'carrier_map' } : {}) }];
43
+ });
44
+ }
45
+ function clockOf(event) {
46
+ const time = typeof event.time === 'string' ? event.time : '';
47
+ const placed = explicitOffsetTime(time);
48
+ if (placed)
49
+ return { instant: placed.timestamp, wall: time.slice(0, 19), earliest: placed.timestamp, key: placed.timestamp };
50
+ const wall = (typeof event.local_time === 'string' ? event.local_time : time).replace(' ', 'T');
51
+ const read = WALL_CLOCK.test(wall) ? Date.parse(`${wall.length === 10 ? `${wall}T00:00:00` : wall}Z`) : Number.NaN;
52
+ const key = Number.isFinite(read) ? read : Number.NEGATIVE_INFINITY;
53
+ return { instant: null, wall, earliest: key - EARLIEST_OFFSET_MS, key };
54
+ }
55
+ /** The same scan in both feeds: Cainiao relays iMile's scans on iMile's own wall clock. */
56
+ function sameScan(cainiao, imile) {
57
+ if (cainiao.instant !== null && imile.instant !== null)
58
+ return cainiao.instant === imile.instant;
59
+ return cainiao.wall.length === 19 && cainiao.wall === imile.wall;
60
+ }
61
+ /** Two histories newest first in one, each in its own order; iMile's scan first when they sort alike. */
62
+ function interleave(imile, cainiao) {
63
+ const merged = [];
64
+ let i = 0;
65
+ let c = 0;
66
+ while (i < imile.length || c < cainiao.length) {
67
+ const takeImile = c >= cainiao.length || (i < imile.length && clockOf(imile[i]).key >= clockOf(cainiao[c]).key);
68
+ merged.push(takeImile ? imile[i++] : cainiao[c++]);
69
+ }
70
+ return merged;
71
+ }
72
+ /**
73
+ * iMile's answer with Cainiao's scans from before iMile handled the parcel.
74
+ * iMile handles it from its first scan with a stage past its order record,
75
+ * such as a pickup, a flight or a centre: Cainiao's scans count only up to
76
+ * the last one that is older at every reading, and a scan both report stays
77
+ * iMile's. Before iMile's first such scan, its order record is all it has,
78
+ * and the newest scan of either feed gives the status.
79
+ */
80
+ export function withCainiaoLeg(imile, cainiao) {
81
+ const own = imile.events ?? [];
82
+ // A history cut short has lost its oldest scans, which Cainiao's leg would go behind.
83
+ if (!own.length || !cainiao.length || imile.history_truncated)
84
+ return imile;
85
+ const ownClocks = own.map(clockOf);
86
+ const handled = own.flatMap((event, index) => (event.stage && event.stage !== 'registered' ? [index] : []));
87
+ const cut = handled.length ? Math.min(...handled.map((index) => ownClocks[index].earliest)) : Number.POSITIVE_INFINITY;
88
+ // Cainiao lists its scans newest first: its leg starts at its newest scan older than iMile's first.
89
+ const start = cainiao.findIndex((event) => {
90
+ const { instant } = clockOf(event);
91
+ return instant !== null && instant < cut;
92
+ });
93
+ const leg = (start < 0 ? [] : cainiao.slice(start)).filter((event) => {
94
+ const clock = clockOf(event);
95
+ return !(clock.instant !== null && clock.instant >= cut) && !ownClocks.some((other) => sameScan(clock, other));
96
+ });
97
+ if (!leg.length)
98
+ return imile;
99
+ if (handled.length) {
100
+ const kept = handled.at(-1) + 1;
101
+ return { ...imile, events: [...own.slice(0, kept), ...interleave(own.slice(kept), leg)] };
102
+ }
103
+ const events = interleave(own, leg);
104
+ // The merged history's summary replaces iMile's whole: no key of iMile's stays beside it.
105
+ const rest = { ...imile };
106
+ for (const key of SUMMARY_KEYS)
107
+ delete rest[key];
108
+ return { ...imileSummary(events), ...rest, events };
109
+ }
@@ -27,7 +27,8 @@
27
27
  "adapter": "imile",
28
28
  "steps": [
29
29
  "direct",
30
- "retry"
30
+ "retry",
31
+ "cainiao"
31
32
  ],
32
33
  "recognition": {
33
34
  "rank": 997
@@ -1,4 +1,4 @@
1
- import type { CarrierResult } from '../../core/result/index.js';
1
+ import type { CarrierEvent, CarrierResult } from '../../core/result/index.js';
2
2
  export declare const PROVIDER = "iMile";
3
3
  export declare const IMILE_MAX_BYTES: number;
4
4
  /** A number for iMile's tracker, in upper case; anything else is not one it answers. */
@@ -16,3 +16,5 @@ export declare function imileWording(raw: string): string;
16
16
  * found. A shipment must name the number asked.
17
17
  */
18
18
  export declare function parseImile(payload: unknown, raw: string): CarrierResult;
19
+ /** The status a history newest first gives: its newest staged scan's stage, and its newest scan's wording and clock. */
20
+ export declare function imileSummary(events: readonly CarrierEvent[]): CarrierResult;
@@ -155,9 +155,6 @@ export function parseImile(payload, raw) {
155
155
  throw new NoHistoryError(PROVIDER);
156
156
  // Newest first, as the page lists them; an oldest-first reply is turned round.
157
157
  const newestFirst = events[0].local_time < events.at(-1).local_time ? events.reverse() : events;
158
- const newest = newestFirst[0];
159
- const current = newestFirst.find((event) => event.stage);
160
- const stage = current?.stage;
161
158
  const code = text(shipment, 'country', 8).toUpperCase();
162
159
  const country = Object.hasOwn(COUNTRIES, code) ? COUNTRIES[code] : undefined;
163
160
  // Every scan shows the recipient country's clock: a country of one zone
@@ -167,16 +164,24 @@ export function parseImile(payload, raw) {
167
164
  const time = isoTime(local_time, zone)?.iso;
168
165
  return time ? { time, ...event } : { local_time, ...event };
169
166
  }) : newestFirst;
170
- const latest = placed[0];
171
167
  return {
172
- status: stage ? languageStageStatus(stage) : 'unknown',
173
- ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
174
- last_status_text: newest.description,
175
- last_update: latest.time ?? null,
176
- ...(latest.time ? {} : { last_update_local: newest.local_time }),
168
+ ...imileSummary(placed),
177
169
  expected_delivery: null,
178
170
  ...(country ? { destination_country: country } : {}),
179
171
  events: placed.slice(0, MAX_EVENTS),
180
172
  ...(newestFirst.length > MAX_EVENTS ? { history_truncated: true } : {}),
181
173
  };
182
174
  }
175
+ /** The status a history newest first gives: its newest staged scan's stage, and its newest scan's wording and clock. */
176
+ export function imileSummary(events) {
177
+ const newest = events[0];
178
+ const current = events.find((event) => event.stage);
179
+ const stage = current?.stage;
180
+ return {
181
+ status: stage ? languageStageStatus(stage) : 'unknown',
182
+ ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
183
+ last_status_text: newest.description,
184
+ last_update: newest.time ?? null,
185
+ ...(newest.time ? {} : { last_update_local: newest.local_time }),
186
+ };
187
+ }
@@ -9,4 +9,5 @@ export declare function imileMappedStage(code: string, wording: string): Stage |
9
9
  * A delivery-stage scan they do not read stays in transit as the fallback.
10
10
  */
11
11
  export declare function imileStage(code: string, wording: string): ClassifiedWording | null;
12
+ /** iMile's map for its own scans, and Cainiao's for the scans of Cainiao's leg in front of them. */
12
13
  export declare const statusMap: CarrierStatusMap;
@@ -1,4 +1,5 @@
1
1
  import { classifyWording } from '../../core/status/index.js';
2
+ import { statusMap as cainiaoStatusMap } from '../aliexpress/status.js';
2
3
  /**
3
4
  * iMile's `trackStage` codes, by the label the reply gives them. Delivered,
4
5
  * returned and return handling decide the stage whatever the wording says.
@@ -108,9 +109,15 @@ export function imileStage(code, wording) {
108
109
  const worded = classifyWording(wording, 'in_transit');
109
110
  return worded.source === 'none' && code !== DELIVERY ? null : worded;
110
111
  }
112
+ /** iMile's own codes are numbers; Cainiao's action codes, on the scans of its leg, are words. */
113
+ const IMILE_CODE = /^\d{1,6}$/;
114
+ /** iMile's map for its own scans, and Cainiao's for the scans of Cainiao's leg in front of them. */
111
115
  export const statusMap = {
112
- stage: (code, wording) => imileMappedStage(code ?? '', wording) ?? undefined,
116
+ stage: (code, wording) => (code && !IMILE_CODE.test(code)
117
+ ? cainiaoStatusMap.stage(code, wording)
118
+ : imileMappedStage(code ?? '', wording) ?? undefined),
113
119
  gaps: [
114
120
  { code: DELIVERY, wording: 'lost order', note: 'A driver\'s reason during a delivery run, followed by the attempt\'s end; the parcel it was seen on went out again and was delivered, so it does not establish a loss.' },
121
+ ...cainiaoStatusMap.gaps,
115
122
  ],
116
123
  };
@@ -25,16 +25,27 @@ const COUNTRY_NAMES = {
25
25
  // and Portugal on their islands, France in the overseas departments Japan
26
26
  // Post's France covers. A delivery scan names no office, only the country.
27
27
  const OVERSEAS_CLOCKS = new Set(['ES', 'FR', 'PT']);
28
+ // Offices of exchange on the mainland of those countries, by the name the UPU
29
+ // code list gives them: Correos' Madrid airport office is `MADRID AP`.
30
+ const MAINLAND_OFFICES = {
31
+ ES: { office: /^MADRID(?: |$)/, zone: 'Europe/Madrid' },
32
+ };
28
33
  // The history header states that overseas scans are in local time, the clock
29
34
  // of the post that scanned. A row's own Prefecture / Country cell settles its
30
- // zone when it names a prefecture or a single-zone country. The office,
31
- // another scan's zone or the parcel's destination never does.
32
- function rowZone(region) {
35
+ // zone when it names a prefecture or a single-zone country, and its own office
36
+ // when that is a mainland office of exchange of a country with other clocks.
37
+ // Another scan's zone or the parcel's destination never does.
38
+ function rowZone(region, office) {
33
39
  const name = region.toUpperCase();
34
40
  if (PREFECTURES.has(name))
35
41
  return 'Asia/Tokyo';
36
42
  const code = Object.hasOwn(COUNTRY_NAMES, name) ? COUNTRY_NAMES[name] : countryCode(name);
37
- return code && !OVERSEAS_CLOCKS.has(code) ? countryTimeZone(code) : null;
43
+ if (!code)
44
+ return null;
45
+ if (!OVERSEAS_CLOCKS.has(code))
46
+ return countryTimeZone(code);
47
+ const mainland = Object.hasOwn(MAINLAND_OFFICES, code) ? MAINLAND_OFFICES[code] : null;
48
+ return mainland?.office.test(office.toUpperCase()) ? mainland.zone : null;
38
49
  }
39
50
  export function normalizeJapanPostNumber(raw) {
40
51
  const number = normalizeTrackingNumber(raw);
@@ -55,8 +66,8 @@ function wallTime(raw) {
55
66
  throw new SchemaError(PROVIDER, 'Japan Post returned an invalid event date');
56
67
  return date.toFormat(format === 'MM/dd/yyyy' ? 'yyyy-MM-dd' : "yyyy-MM-dd'T'HH:mm:ss");
57
68
  }
58
- function eventInstant(localTime, region) {
59
- const zone = rowZone(region);
69
+ function eventInstant(localTime, region, office) {
70
+ const zone = rowZone(region, office);
60
71
  if (!zone || !localTime.includes('T'))
61
72
  return null;
62
73
  const date = firstOccurrence(DateTime.fromISO(localTime, { zone }));
@@ -142,8 +153,9 @@ export function parse(html, trackingNumber) {
142
153
  if (!description)
143
154
  throw new SchemaError(PROVIDER, 'Japan Post returned an empty event');
144
155
  const region = clean(cells.eq(4).text(), 100);
145
- const location = [clean(cells.eq(3).text(), 150), region].filter(Boolean).join(', ');
146
- const time = eventInstant(localTime, region);
156
+ const office = clean(cells.eq(3).text(), 150);
157
+ const location = [office, region].filter(Boolean).join(', ');
158
+ const time = eventInstant(localTime, region, office);
147
159
  const status = japanPostStatus(description);
148
160
  // The host treats `time` as an instant and applies the catalog's timezone
149
161
  // when no offset is present. Keep unresolved wall clocks in local_time.
@@ -2,6 +2,7 @@ import { DateTime } from 'luxon';
2
2
  import { detectCarrierMatch, normalizeTrackingNumber } from '../../core/detection/index.js';
3
3
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
4
  import { languageStageStatus } from '../../core/status/index.js';
5
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
5
6
  import { cleanScalar } from '../../core/transport/index.js';
6
7
  import { isRecord } from '../../core/types.js';
7
8
  import { normalizeSeventeenFeiaNumber } from '../17feia/parser.js';
@@ -18,6 +19,20 @@ const EARLIEST = Date.UTC(2015, 0, 1);
18
19
  const MAX_ORDERS = 20;
19
20
  const MAX_ROWS = 500;
20
21
  const MAX_EVENTS = 100;
22
+ /**
23
+ * 17FEIA's rows from its warehouse in China up to the export declaration.
24
+ * 17FEIA writes them on Beijing time, so TSJY's reading of them is their
25
+ * instant.
26
+ */
27
+ const SEVENTEENFEIA_WAREHOUSE_ROWS = new Set([
28
+ 'item information received.',
29
+ 'the package is on the way to the facility.',
30
+ 'warehouse receipt.',
31
+ 'the order has information pending customer confirmation.',
32
+ 'order confirmed.',
33
+ 'order dispatched from warehouse.',
34
+ 'export declaration in progress.',
35
+ ]);
21
36
  /**
22
37
  * The lines TSJY hands orders to, by the catalog id that tracks them and the
23
38
  * check that the line's number is one of its own. A channel number no line
@@ -52,16 +67,16 @@ function text(record, key, maxLength = 500) {
52
67
  /**
53
68
  * A row's clock. TSJY's server stamps its own steps with milliseconds: those
54
69
  * are instants. A partner's rows come in whole seconds, its wall clocks read
55
- * on Beijing time, which is right for 17FEIA's Chinese warehouse and unknown
56
- * elsewhere, so they keep that wall clock.
70
+ * on Beijing time, which is right for 17FEIA's rows from its warehouse in
71
+ * China and unknown elsewhere, so the others keep that wall clock.
57
72
  */
58
- function clockOf(stamp) {
73
+ function clockOf(stamp, warehouse) {
59
74
  const home = DateTime.fromMillis(stamp, { zone: HOME_ZONE });
60
- if (stamp % 1000 !== 0)
75
+ if (stamp % 1000 !== 0 || warehouse)
61
76
  return { time: home.startOf('second').toISO({ suppressMilliseconds: true }) };
62
77
  return { local_time: home.toFormat("yyyy-MM-dd'T'HH:mm:ss") };
63
78
  }
64
- function projectRow(row, readAt) {
79
+ function projectRow(row, readAt, seventeenFeia) {
65
80
  if (!isRecord(row))
66
81
  throw new SchemaError(PROVIDER, 'TSJY returned an invalid scan');
67
82
  const stamp = row.date;
@@ -75,7 +90,7 @@ function projectRow(row, readAt) {
75
90
  const location = text(row, 'loc', 200);
76
91
  const staged = tsjyStage(description);
77
92
  const event = {
78
- ...clockOf(stamp),
93
+ ...clockOf(stamp, seventeenFeia && SEVENTEENFEIA_WAREHOUSE_ROWS.has(normalizeStatusWording(description))),
79
94
  description,
80
95
  ...(location ? { location } : {}),
81
96
  ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
@@ -128,10 +143,11 @@ export function parseTsjy(payload, raw, readAt = Date.now()) {
128
143
  throw new SchemaError(PROVIDER, 'TSJY returned an order without scans');
129
144
  if (rows.length > MAX_ROWS)
130
145
  throw new SchemaError(PROVIDER, 'TSJY returned an oversized history');
146
+ const line = tsjyHandoff(text(order, 'channelNo', 64), number);
131
147
  const seen = new Set();
132
148
  const scans = [];
133
149
  for (const row of rows) {
134
- const scan = projectRow(row, readAt);
150
+ const scan = projectRow(row, readAt, line?.delivery_carrier === '17feia');
135
151
  if (!scan || seen.has(scan.key))
136
152
  continue;
137
153
  seen.add(scan.key);
@@ -152,7 +168,7 @@ export function parseTsjy(payload, raw, readAt = Date.now()) {
152
168
  ...(newest.local_time ? { last_update_local: newest.local_time } : {}),
153
169
  expected_delivery: null,
154
170
  ...(stage === 'delivered' && current.time ? { delivered_at: current.time } : {}),
155
- ...tsjyHandoff(text(order, 'channelNo', 64), number),
171
+ ...line,
156
172
  events: events.slice(0, MAX_EVENTS),
157
173
  ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
158
174
  };
@@ -48,15 +48,14 @@ const ACTIVITY_STAGES = new Map([
48
48
  // Shipper created a label; UPS does not have the parcel yet.
49
49
  ['MP', 'registered'],
50
50
  // Drop-off at an access point, the access point readying it for UPS, a
51
- // pickup scan, the RFID-confirmed collection from the access point, and the
52
- // origin scan.
53
- ['XD', 'accepted'], ['ZO', 'accepted'], ['PU', 'accepted'], ['PC', 'accepted'], ['OR', 'accepted'],
51
+ // pickup scan, the RFID-confirmed collection from the access point, the
52
+ // parcel on its way to its first UPS facility, and the origin scan there.
53
+ ['XD', 'accepted'], ['ZO', 'accepted'], ['PU', 'accepted'], ['PC', 'accepted'], ['AG', 'accepted'], ['OR', 'accepted'],
54
54
  // Facility scans, export and import scans.
55
55
  ['AR', 'in_transit'], ['DP', 'in_transit'], ['DS', 'in_transit'], ['YP', 'in_transit'], ['HL', 'in_transit'],
56
56
  ['EP', 'in_transit'], ['IP', 'in_transit'],
57
- // A notice that the package is on its way, without a facility, and one that
58
- // it is on its way to a UPS facility.
59
- ['DA', 'in_transit'], ['AG', 'in_transit'],
57
+ // A notice that the package is on its way, without a facility.
58
+ ['DA', 'in_transit'],
60
59
  // Delays and new delivery plans: a late flight, a possible delay, a new
61
60
  // plan, a delivery a day early.
62
61
  ['18', 'in_transit'], ['Q5', 'in_transit'], ['E3', 'in_transit'], ['2U', 'in_transit'],
@@ -19,7 +19,7 @@
19
19
  { "code": "YP", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-07", "note": "actCode: \"Processing at UPS Facility\"." },
20
20
  { "code": "HL", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-16", "note": "actCode: \"Your package was processed at our facility.\"" },
21
21
  { "code": "DA", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-18", "note": "actCode: \"Your package is on the way\", without a location." },
22
- { "code": "AG", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-20", "note": "actCode: \"Package is in transit to a UPS facility\"." },
22
+ { "code": "AG", "language": "en", "stage": "accepted", "confirmedBy": "live", "firstSeen": "2026-10-20", "note": "actCode: \"Package is in transit to a UPS facility\", seen before the origin scan: UPS has the parcel, not yet at a facility." },
23
23
  { "code": "EP", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-07", "note": "actCode: \"Export Scan\"." },
24
24
  { "code": "IP", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-07", "note": "actCode: \"Import Scan\"." },
25
25
  { "code": "18", "language": "en", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-07", "note": "actCode: a late flight delayed the parcel, which still arrives that day." },
@@ -20,6 +20,7 @@ const WORDING = new Map([
20
20
  // among them: the parcel is in Yanwen's hands and has not moved yet.
21
21
  ['Processing information input', { status: 'in_transit', stage: 'accepted' }],
22
22
  ['shipping label created', { status: 'in_transit', stage: 'accepted' }],
23
+ ['Shipment order created', { status: 'in_transit', stage: 'accepted' }],
23
24
  ['Order processed by shipper', { status: 'pending', stage: 'registered' }],
24
25
  ['Order Submited.', { status: 'pending', stage: 'registered' }],
25
26
  ['Waybill Generated', { status: 'pending', stage: 'registered' }],
@@ -127,6 +127,14 @@
127
127
  "confirmedBy": "live",
128
128
  "firstSeen": "2026-10-17"
129
129
  },
130
+ {
131
+ "wording": "Shipment order created",
132
+ "language": "en",
133
+ "stage": "accepted",
134
+ "confirmedBy": "live",
135
+ "firstSeen": "2026-10-20",
136
+ "note": "Written in the same second as Processing information input, after the pickup scan."
137
+ },
130
138
  {
131
139
  "wording": "Order processed by shipper",
132
140
  "language": "en",
@@ -1,4 +1,5 @@
1
1
  import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
2
3
  import { type StepRecorder } from '../../core/telemetry/index.js';
3
4
  export declare class YtoTracker {
4
5
  private readonly options;
@@ -7,6 +8,6 @@ export declare class YtoTracker {
7
8
  recorder?: StepRecorder;
8
9
  userAgent?: string;
9
10
  });
10
- fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
11
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
11
12
  }
12
13
  export declare const adapter: AdapterFactory;
@@ -1,5 +1,6 @@
1
1
  import { TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
2
  import { runSteps } from '../../core/runner/index.js';
3
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
3
4
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
4
5
  import { fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
5
6
  import { normalizeYtoNumber, parseYto } from './parser.js';
@@ -13,24 +14,30 @@ export class YtoTracker {
13
14
  }
14
15
  async fetch(raw, context = {}) {
15
16
  const number = normalizeYtoNumber(raw);
17
+ const read = async (signal, timeoutMs) => {
18
+ try {
19
+ const { bytes } = await fetchBounded(ENDPOINT, { method: 'POST', signal,
20
+ headers: { 'Content-Type': 'application/json', source: 'PC', 'User-Agent': userAgentOf(this.options.userAgent) }, body: JSON.stringify([number]),
21
+ }, { provider: 'YTO Express', timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: 1_000_000, fetcher: this.options.fetcher });
22
+ return parseYto(parseJsonBytes(bytes, 'YTO Express'), number);
23
+ }
24
+ catch (error) {
25
+ if (error instanceof UpstreamHttpError && [404, 410].includes(error.status)) {
26
+ throw new TransportError('YTO Express', 'YTO tracking endpoint is unavailable', { cause: error });
27
+ }
28
+ throw error;
29
+ }
30
+ };
31
+ // A read that fails to reach the site, or hangs, gets one more try: the
32
+ // first gets half of the budget, so the retry fits in the rest.
16
33
  return runSteps({ carrier: 'yto', signal: context.signal, budgetMs: context.budgetMs ?? 15_000,
17
- recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
18
- try {
19
- const { bytes } = await fetchBounded(ENDPOINT, { method: 'POST', signal,
20
- headers: { 'Content-Type': 'application/json', source: 'PC', 'User-Agent': userAgentOf(this.options.userAgent) }, body: JSON.stringify([number]),
21
- }, { provider: 'YTO Express', timeoutMs: Math.max(1, Math.floor(remainingMs)), maxBytes: 1_000_000, fetcher: this.options.fetcher });
22
- return parseYto(parseJsonBytes(bytes, 'YTO Express'), number);
23
- }
24
- catch (error) {
25
- if (error instanceof UpstreamHttpError && [404, 410].includes(error.status)) {
26
- throw new TransportError('YTO Express', 'YTO tracking endpoint is unavailable', { cause: error });
27
- }
28
- throw error;
29
- }
30
- } }]);
34
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
35
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
36
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
37
+ ]);
31
38
  }
32
39
  }
33
40
  export const adapter = environment => {
34
41
  const tracker = new YtoTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
35
- return { id: 'yto', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context) };
42
+ return { id: 'yto', recordsSteps: true, steps: ['direct', 'retry'], track: (input, context) => tracker.fetch(input.number, context) };
36
43
  };
@@ -16,7 +16,8 @@
16
16
  "mode": "automatic",
17
17
  "adapter": "yto",
18
18
  "steps": [
19
- "direct"
19
+ "direct",
20
+ "retry"
20
21
  ],
21
22
  "localClocks": true
22
23
  },
@@ -42,17 +42,19 @@ const CATALOG_NAMES = new Set([
42
42
  // from abroad, not for registered mail. Posta Kenya dates only scans it places
43
43
  // in Kenya by name. Correo Uruguayo's clocks name no zone, and those relayed
44
44
  // from abroad keep the sender's. Posta dates only scans at a Faroese place.
45
- // Correos de El Salvador's clocks name no zone either. The host also checks
46
- // adapter availability and required inputs before making a request.
45
+ // Correos de El Salvador's clocks name no zone either. Correios Brazil names
46
+ // each row's zone. The host also checks adapter availability and required
47
+ // inputs before making a request.
47
48
  const NATIONAL_POSTS = new Map([
48
49
  ['AM', 'haypost'], ['AR', 'correo-argentino'], ['AU', 'australia-post'], ['BD', 'bangladesh-post'],
49
- ['BG', 'bulgarian-posts'], ['BY', 'belpost'], ['CA', 'canada-post'], ['CH', 'swiss-post'], ['DE', 'dhl'],
50
- ['DZ', 'algerie-poste'], ['EG', 'egypt-post'], ['ES', 'correos-spain'], ['FI', 'posti'], ['FO', 'posta-faroe'],
51
- ['FR', 'la-poste'], ['GB', 'royal-mail'], ['IS', 'posturinn'], ['JO', 'jordan-post'], ['JP', 'japan-post'],
52
- ['KE', 'posta-kenya'], ['MA', 'barid-al-maghrib'], ['MK', 'posta-north-macedonia'], ['NL', 'spring-gds'],
53
- ['PH', 'phlpost'], ['PL', 'poczta-polska'], ['PT', 'ctt'], ['RO', 'posta-romana'], ['SA', 'saudi-post'],
54
- ['SE', 'postnord'], ['SV', 'correos-el-salvador'], ['UA', 'ukrposhta'], ['US', 'usps'],
55
- ['UY', 'correo-uruguayo'], ['UZ', 'uzbekistan-post'], ['ZA', 'south-african-post-office'],
50
+ ['BG', 'bulgarian-posts'], ['BR', 'correios-br'], ['BY', 'belpost'], ['CA', 'canada-post'],
51
+ ['CH', 'swiss-post'], ['DE', 'dhl'], ['DZ', 'algerie-poste'], ['EG', 'egypt-post'], ['ES', 'correos-spain'],
52
+ ['FI', 'posti'], ['FO', 'posta-faroe'], ['FR', 'la-poste'], ['GB', 'royal-mail'], ['IS', 'posturinn'],
53
+ ['JO', 'jordan-post'], ['JP', 'japan-post'], ['KE', 'posta-kenya'], ['MA', 'barid-al-maghrib'],
54
+ ['MK', 'posta-north-macedonia'], ['NL', 'spring-gds'], ['PH', 'phlpost'], ['PL', 'poczta-polska'],
55
+ ['PT', 'ctt'], ['RO', 'posta-romana'], ['SA', 'saudi-post'], ['SE', 'postnord'],
56
+ ['SV', 'correos-el-salvador'], ['UA', 'ukrposhta'], ['US', 'usps'], ['UY', 'correo-uruguayo'],
57
+ ['UZ', 'uzbekistan-post'], ['ZA', 'south-african-post-office'],
56
58
  ]);
57
59
  const englishCountries = new Intl.DisplayNames(['en'], { type: 'region' });
58
60
  // Deprecated codes carry their successor's name, so fold them into it ("FX" is France).
@@ -3547,6 +3547,9 @@
3547
3547
  "correios-br": {
3548
3548
  "displayName": "Correios Brazil",
3549
3549
  "color": "#ffd500",
3550
+ "aliases": [
3551
+ "Brazil Post"
3552
+ ],
3550
3553
  "countries": [
3551
3554
  "BR"
3552
3555
  ],
@@ -2409,6 +2409,7 @@ export declare const CARRIER_CATALOG: {
2409
2409
  readonly "correios-br": {
2410
2410
  readonly displayName: "Correios Brazil";
2411
2411
  readonly color: "#ffd500";
2412
+ readonly aliases: readonly ["Brazil Post"];
2412
2413
  readonly countries: readonly ["BR"];
2413
2414
  readonly selectable: true;
2414
2415
  readonly timezone: "UTC";
@@ -3548,6 +3548,9 @@ export const CARRIER_CATALOG = {
3548
3548
  "correios-br": {
3549
3549
  "displayName": "Correios Brazil",
3550
3550
  "color": "#ffd500",
3551
+ "aliases": [
3552
+ "Brazil Post"
3553
+ ],
3551
3554
  "countries": [
3552
3555
  "BR"
3553
3556
  ],
@@ -218,6 +218,14 @@ function providerWordingStage(description) {
218
218
  return 'in_transit';
219
219
  if (/^confirmation of preparation of the shipment received[.!]?$/i.test(value))
220
220
  return 'registered';
221
+ // Correios Brazil's scans in an aggregator's English for an item posted abroad. Correios' receipt follows
222
+ // the posting and the export, so it is transit here; the recipient still owes customs their tax number.
223
+ if (/^item posted[.!]?$/i.test(value))
224
+ return 'accepted';
225
+ if (/^item in transfer - please wait[.!]?$|^item received at the export unit in the country of origin[.!]?$|^item received by the brazilian post[.!]?$/i.test(value))
226
+ return 'in_transit';
227
+ if (/^need to inform (?:the )?recipient['’]?s cpf\/cnpj[.!]?$/i.test(value))
228
+ return 'customs';
221
229
  if (/^(?:received\.|(?:\d\) )?[a-z ]{1,40} cent(?:er|re)) your order has been received[.!]?$|^(?:\S+ )?cent(?:er|re) (?:received|parcel shipped to \S+)[.!]?$|^origin port warehouse receive\b|^the flight has taken off[.!]?$|^(?:\S+ )?dispatch task assigned to da[.!]?$/i.test(value))
222
230
  return 'in_transit';
223
231
  if (/^<bad address>$/i.test(value))
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.576.0"
5
+ "version": "0.577.2"
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.576.0-main.2691",
3
+ "version": "0.577.2-main.2708",
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",