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.
- package/README.md +1 -1
- package/data/catalog.json +3 -0
- package/dist/browser/scraper.js +2 -0
- package/dist/carriers/17feia/parser.js +11 -3
- package/dist/carriers/17feia/status.js +2 -0
- package/dist/carriers/17feia/statuses.json +1 -0
- package/dist/carriers/aliexpress/adapter.d.ts +11 -0
- package/dist/carriers/aliexpress/adapter.js +28 -4
- package/dist/carriers/chunghwa-post/parser.js +22 -2
- package/dist/carriers/correios-br/carrier.json +3 -1
- package/dist/carriers/dhl-express/status.js +6 -3
- package/dist/carriers/dhl-express/statuses.json +9 -0
- package/dist/carriers/dpd-fr/adapter.js +13 -13
- package/dist/carriers/dpd-uk/parser.js +24 -7
- package/dist/carriers/imile/adapter.d.ts +8 -1
- package/dist/carriers/imile/adapter.js +60 -8
- package/dist/carriers/imile/cainiao.d.ts +18 -0
- package/dist/carriers/imile/cainiao.js +109 -0
- package/dist/carriers/imile/carrier.json +2 -1
- package/dist/carriers/imile/parser.d.ts +3 -1
- package/dist/carriers/imile/parser.js +14 -9
- package/dist/carriers/imile/status.d.ts +1 -0
- package/dist/carriers/imile/status.js +8 -1
- package/dist/carriers/japan-post/adapter.js +20 -8
- package/dist/carriers/tsjy/parser.js +24 -8
- package/dist/carriers/ups/status.js +5 -6
- package/dist/carriers/ups/statuses.json +1 -1
- package/dist/carriers/yanwen/status.js +1 -0
- package/dist/carriers/yanwen/statuses.json +8 -0
- package/dist/carriers/yto/adapter.d.ts +2 -1
- package/dist/carriers/yto/adapter.js +22 -15
- package/dist/carriers/yto/carrier.json +2 -1
- package/dist/core/catalog/hints.js +11 -9
- package/dist/data/catalog.json +3 -0
- package/dist/generated/catalog.d.ts +1 -0
- package/dist/generated/catalog.js +3 -0
- package/dist/providers/shared/result.js +8 -0
- package/dist/server/openapi.json +1 -1
- 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,
|
|
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
package/dist/browser/scraper.js
CHANGED
|
@@ -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
|
-
/**
|
|
169
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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,
|
|
47
|
-
*
|
|
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
|
|
20
|
-
// fetched
|
|
21
|
-
//
|
|
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
|
-
//
|
|
237
|
-
//
|
|
238
|
-
if (
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
const
|
|
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
|
|
56
|
-
* elsewhere, so
|
|
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
|
-
...
|
|
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,
|
|
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
|
|
58
|
-
|
|
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": "
|
|
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<
|
|
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 }, [
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
};
|
|
@@ -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.
|
|
46
|
-
//
|
|
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'], ['
|
|
50
|
-
['
|
|
51
|
-
['
|
|
52
|
-
['
|
|
53
|
-
['
|
|
54
|
-
['
|
|
55
|
-
['
|
|
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).
|
package/dist/data/catalog.json
CHANGED
|
@@ -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";
|
|
@@ -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))
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"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",
|