universal-parcel-scraper 0.802.4-main.4321 → 0.803.0-main.4323

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **651 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **652 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>835 catalog entries · 135 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -161,7 +161,7 @@ the command line's `track --source ParcelsApp` does the same.
161
161
  <!-- GENERATED:stages -->
162
162
  <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">
163
163
 
164
- The carrier folders record 17,602 such statuses, each filed under one stage.
164
+ The carrier folders record 17,632 such statuses, each filed under one stage.
165
165
  <!-- /GENERATED:stages -->
166
166
 
167
167
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -33948,7 +33948,8 @@
33948
33948
  "timezone": "Asia/Shanghai",
33949
33949
  "tracking": {
33950
33950
  "mode": "automatic",
33951
- "adapter": "universal"
33951
+ "adapter": "ydh",
33952
+ "localClocks": true
33952
33953
  },
33953
33954
  "canaryUrl": "https://track.ydhex.com/track_query.aspx",
33954
33955
  "trackingUrlTemplate": "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 835,
3
- "activeAdapters": 651,
3
+ "activeAdapters": 652,
4
4
  "countries": 135,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -25644,7 +25644,8 @@ var tt = {
25644
25644
  timezone: "Asia/Shanghai",
25645
25645
  tracking: {
25646
25646
  mode: "automatic",
25647
- adapter: "universal"
25647
+ adapter: "ydh",
25648
+ localClocks: !0
25648
25649
  },
25649
25650
  canaryUrl: "https://track.ydhex.com/track_query.aspx",
25650
25651
  trackingUrlTemplate: "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}",
@@ -0,0 +1,21 @@
1
+ import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ /** The tracker's page for one parcel. */
5
+ export declare const YDH_PAGE = "https://track.ydhex.com/track_query.aspx";
6
+ /** The tracking platform's lookup on YDH's tenant. */
7
+ export declare const YDH_API = "https://track.ydhex.com/webservice/Service.ashx";
8
+ /** The JSONP callback the adapter names. */
9
+ export declare const YDH_CALLBACK = "ydhTrack";
10
+ /** The platform's request for one number. */
11
+ export declare function ydhLookupUrl(number: string): string;
12
+ export declare class YdhTracker {
13
+ private readonly options;
14
+ constructor(options?: {
15
+ fetcher?: typeof fetch;
16
+ recorder?: StepRecorder;
17
+ userAgent?: string;
18
+ });
19
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
20
+ }
21
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,47 @@
1
+ import { TransportError } from '../../core/errors/index.js';
2
+ import { runSteps } from '../../core/runner/index.js';
3
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
4
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
5
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
6
+ import { RTB56_REDIRECTS, rtb56Json, rtb56LookupUrl } from '../yypost/rtb56.js';
7
+ import { normalizeYdhNumber, parseYdh, PROVIDER, YDH_MAX_BYTES } from './parser.js';
8
+ /** The tracker's page for one parcel. */
9
+ export const YDH_PAGE = 'https://track.ydhex.com/track_query.aspx';
10
+ /** The tracking platform's lookup on YDH's tenant. */
11
+ export const YDH_API = 'https://track.ydhex.com/webservice/Service.ashx';
12
+ /** The JSONP callback the adapter names. */
13
+ export const YDH_CALLBACK = 'ydhTrack';
14
+ /** The platform's request for one number. */
15
+ export function ydhLookupUrl(number) {
16
+ return rtb56LookupUrl(YDH_API, YDH_CALLBACK, number);
17
+ }
18
+ export class YdhTracker {
19
+ options;
20
+ constructor(options = {}) {
21
+ this.options = options;
22
+ }
23
+ async fetch(raw, context = {}) {
24
+ const number = normalizeYdhNumber(raw);
25
+ const read = async (signal, timeoutMs) => {
26
+ const { response, bytes } = await fetchBounded(ydhLookupUrl(number), { method: 'GET', signal, headers: {
27
+ Accept: 'text/javascript, application/javascript, */*; q=0.01', Referer: `${YDH_PAGE}?track_number=${number}`,
28
+ 'User-Agent': userAgentOf(this.options.userAgent),
29
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: YDH_MAX_BYTES,
30
+ redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...RTB56_REDIRECTS, 404, 410] });
31
+ // The lookup answers every number with HTTP 200; a redirect or a 404 is a moved route.
32
+ if (response.status !== 200)
33
+ throw new TransportError(PROVIDER, 'YDH tracking lookup moved', { status: response.status });
34
+ return parseYdh(rtb56Json(decodeText(bytes), YDH_CALLBACK, PROVIDER), number);
35
+ };
36
+ // A read that fails to reach the site, or hangs, gets one more: the first gets half of the budget.
37
+ return runSteps({ carrier: 'ydh', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
38
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
39
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
40
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
41
+ ]);
42
+ }
43
+ }
44
+ export const adapter = (environment) => {
45
+ const tracker = new YdhTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
46
+ return { id: 'ydh', recordsSteps: true, steps: ['direct', 'retry'], track: (input, context) => tracker.fetch(input.number, context) };
47
+ };
@@ -19,18 +19,53 @@
19
19
  "timezone": "Asia/Shanghai",
20
20
  "tracking": {
21
21
  "mode": "automatic",
22
- "adapter": "universal",
23
- "steps": []
22
+ "adapter": "ydh",
23
+ "steps": [
24
+ "direct",
25
+ "retry"
26
+ ],
27
+ "localClocks": true
24
28
  },
25
- "capabilities": [],
29
+ "capabilities": [
30
+ "history",
31
+ "location",
32
+ "delivery_tracking_number",
33
+ "destination_country"
34
+ ],
26
35
  "portal": {
27
36
  "url": "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}",
28
37
  "searchUrl": "https://track.ydhex.com/track_query.aspx",
29
38
  "canaryUrl": "https://track.ydhex.com/track_query.aspx",
30
- "shows": [],
31
- "scraped": [],
32
- "discarded": [],
33
- "unavailable": []
39
+ "shows": [
40
+ "status",
41
+ "history",
42
+ "location",
43
+ "destination_country"
44
+ ],
45
+ "scraped": [
46
+ "status",
47
+ "history",
48
+ "location",
49
+ "provider_code",
50
+ "destination_country",
51
+ "delivery_tracking_number"
52
+ ],
53
+ "discarded": [
54
+ "customer_reference",
55
+ "signatory",
56
+ "delivery_partner"
57
+ ],
58
+ "unavailable": [
59
+ "eta",
60
+ "weight",
61
+ "service_name",
62
+ "offset_timestamps",
63
+ "sender_name",
64
+ "recipient_name",
65
+ "recipient_address",
66
+ "pickup_point",
67
+ "proof_of_delivery"
68
+ ]
34
69
  },
35
70
  "links": [
36
71
  {
@@ -0,0 +1,14 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "YDH";
3
+ export declare const YDH_MAX_BYTES: number;
4
+ /** A YDH parcel number; anything else is not one it issues. */
5
+ export declare function normalizeYdhNumber(raw: string): string;
6
+ /**
7
+ * The reply of the tracker's `Service.ashx` lookup for one number: `data`
8
+ * holds an entry per number, with the scans newest first, each a wall clock,
9
+ * a place, a code and wording: YDH's own in Chinese or English, then those
10
+ * of the partner that delivers abroad, relayed in its language. Only the
11
+ * entry whose `server_hawbcode` is the whole number asked is read; the
12
+ * merchant's order reference and the signatory are never read.
13
+ */
14
+ export declare function parseYdh(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,51 @@
1
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
+ import { InvalidInputError } from '../../core/errors/index.js';
3
+ import { languageStageStatus } from '../../core/status/index.js';
4
+ import { countryCode } from '../../core/time/index.js';
5
+ import { RTB56_MAX_BYTES, rtb56Entry, rtb56Scans, rtb56Text } from '../yypost/rtb56.js';
6
+ import { ydhStage } from './status.js';
7
+ export const PROVIDER = 'YDH';
8
+ export const YDH_MAX_BYTES = RTB56_MAX_BYTES;
9
+ // YDH, ten digits and two letters.
10
+ const NUMBER = /^YDH\d{10}[A-Z]{2}$/;
11
+ const REFERENCE = /^[A-Z0-9]{4,40}$/;
12
+ const MAX_EVENTS = 100;
13
+ /** A YDH parcel number; anything else is not one it issues. */
14
+ export function normalizeYdhNumber(raw) {
15
+ const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
16
+ if (!NUMBER.test(number))
17
+ throw new InvalidInputError(PROVIDER, 'YDH parcel numbers are YDH, ten digits and two letters');
18
+ return number;
19
+ }
20
+ /**
21
+ * The reply of the tracker's `Service.ashx` lookup for one number: `data`
22
+ * holds an entry per number, with the scans newest first, each a wall clock,
23
+ * a place, a code and wording: YDH's own in Chinese or English, then those
24
+ * of the partner that delivers abroad, relayed in its language. Only the
25
+ * entry whose `server_hawbcode` is the whole number asked is read; the
26
+ * merchant's order reference and the signatory are never read.
27
+ */
28
+ export function parseYdh(payload, raw) {
29
+ const number = normalizeYdhNumber(raw);
30
+ const { item, rows } = rtb56Entry(payload, number, PROVIDER);
31
+ const events = rtb56Scans(rows, PROVIDER, ydhStage, { offsets: false, codes: true });
32
+ const newest = events[0];
33
+ // A label or electronic information recorded after the parcel moved is no step back.
34
+ const staged = events.find((event) => event.stage && event.stage !== 'registered') ?? events.find((event) => event.stage);
35
+ const stage = staged?.stage;
36
+ const country = countryCode(rtb56Text(item, 'destination_country', PROVIDER, 8));
37
+ const channel = normalizeTrackingNumber(rtb56Text(item, 'channel_hawbcode', PROVIDER, 64));
38
+ return {
39
+ status: stage ? languageStageStatus(stage) : 'unknown',
40
+ ...(stage ? { current_stage: stage, current_stage_source: staged.stage_source } : {}),
41
+ last_status_text: newest.description,
42
+ last_update: newest.time ?? null,
43
+ ...(newest.local_time ? { last_update_local: newest.local_time } : {}),
44
+ expected_delivery: null,
45
+ ...(country ? { destination_country: country } : {}),
46
+ // The partner's own number for the parcel; the hand-off decides who that is.
47
+ ...(REFERENCE.test(channel) && channel !== number ? { delivery_tracking_number: channel } : {}),
48
+ events: events.slice(0, MAX_EVENTS),
49
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
50
+ };
51
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassifiedWording } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** A scan's stage: its code, else its wording in the map, else the shared wording rules, else nothing. */
4
+ export declare function ydhStage(wording: string, code?: string): ClassifiedWording | null;
5
+ /** What the map says about a scan, by its code, or by its wording when it has none. */
6
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,30 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ import statuses from './statuses.json' with { type: 'json' };
4
+ const ENTRIES = statuses.entries;
5
+ /**
6
+ * YDH's scans carry a code when YDH or its partner abroad files a milestone,
7
+ * whatever the wording: YDH's own in Chinese or English, or the partner's
8
+ * relayed in its language. Scans without a code are YDH's warehouse and
9
+ * export steps, or the partner's, in their wording. The map is the codes and
10
+ * wording recorded in `statuses.json`.
11
+ */
12
+ const CODES = new Map(ENTRIES.filter((entry) => entry.code && entry.stage)
13
+ .map((entry) => [entry.code, entry.stage]));
14
+ const WORDINGS = new Map(ENTRIES.filter((entry) => !entry.code && entry.wording && entry.stage)
15
+ .map((entry) => [normalizeStatusWording(entry.wording), entry.stage]));
16
+ /** The codes that name no one milestone: the partner's wording does. */
17
+ const GAPS = ENTRIES.filter((entry) => entry.unmapped && entry.code).map((entry) => ({ code: entry.code, note: entry.note ?? '' }));
18
+ /** A scan's stage: its code, else its wording in the map, else the shared wording rules, else nothing. */
19
+ export function ydhStage(wording, code = '') {
20
+ const mapped = code ? CODES.get(code) : WORDINGS.get(normalizeStatusWording(wording));
21
+ if (mapped)
22
+ return { stage: mapped, source: 'carrier_map' };
23
+ const worded = classifyWording(wording, 'in_transit');
24
+ return worded.source === 'none' ? null : worded;
25
+ }
26
+ /** What the map says about a scan, by its code, or by its wording when it has none. */
27
+ export const statusMap = {
28
+ stage: (code, wording) => (code ? CODES.get(code) : WORDINGS.get(wording)),
29
+ gaps: GAPS,
30
+ };
@@ -1,4 +1,201 @@
1
1
  {
2
2
  "carrier": "ydh",
3
- "entries": []
3
+ "entries": [
4
+ {
5
+ "code": "IR",
6
+ "stage": "registered",
7
+ "confirmedBy": "live",
8
+ "note": "Electronic information received (\"Shipment information received\", \"Label Created\"), or the partner's own notice of it."
9
+ },
10
+ {
11
+ "code": "AF",
12
+ "stage": "in_transit",
13
+ "confirmedBy": "live",
14
+ "note": "\"Shipment arrived at facility\": YDH's warehouse in China."
15
+ },
16
+ {
17
+ "code": "GNZY",
18
+ "stage": "in_transit",
19
+ "confirmedBy": "live",
20
+ "note": "\"快件到达作业中心\", carried on within China."
21
+ },
22
+ {
23
+ "code": "HBYP",
24
+ "stage": "in_transit",
25
+ "confirmedBy": "live",
26
+ "note": "Waiting for a flight."
27
+ },
28
+ {
29
+ "code": "DF",
30
+ "stage": "in_transit",
31
+ "confirmedBy": "live",
32
+ "note": "\"Shipment departed from facility\"."
33
+ },
34
+ {
35
+ "code": "DA",
36
+ "stage": "in_transit",
37
+ "confirmedBy": "live",
38
+ "note": "\"Shipment departed from airport\" or \"航班起飞\"."
39
+ },
40
+ {
41
+ "code": "TDDY",
42
+ "stage": "in_transit",
43
+ "confirmedBy": "live",
44
+ "note": "\"Flight delay\"."
45
+ },
46
+ {
47
+ "code": "AA",
48
+ "stage": "in_transit",
49
+ "confirmedBy": "live",
50
+ "note": "\"Shipment arrived at airport\" or \"航班到达\"."
51
+ },
52
+ {
53
+ "code": "RC",
54
+ "stage": "in_transit",
55
+ "confirmedBy": "live",
56
+ "note": "\"Released from customs\" at the destination."
57
+ },
58
+ {
59
+ "code": "CARR",
60
+ "stage": "in_transit",
61
+ "confirmedBy": "live",
62
+ "note": "\"The goods have been handed over to the final carrier\"."
63
+ },
64
+ {
65
+ "code": "PU",
66
+ "stage": "in_transit",
67
+ "confirmedBy": "live",
68
+ "note": "The partner's first scans once it has the parcel, in its wording: its pickup from YDH, its manifest, its transit."
69
+ },
70
+ {
71
+ "code": "OD",
72
+ "stage": "out_for_delivery",
73
+ "confirmedBy": "live",
74
+ "note": "\"Out for Delivery\", or the partner's wording for a parcel at its delivery office being prepared for the round."
75
+ },
76
+ {
77
+ "code": "CC",
78
+ "stage": "delivered",
79
+ "confirmedBy": "live",
80
+ "note": "\"Delivered\", in YDH's or the partner's wording."
81
+ },
82
+ {
83
+ "code": "DD",
84
+ "unmapped": true,
85
+ "confirmedBy": "live",
86
+ "note": "A delivery the partner could not make or put off, filed as a delivery exception; the partner's wording tells which."
87
+ },
88
+ {
89
+ "wording": "快件电子信息已经收到",
90
+ "stage": "registered",
91
+ "language": "zh",
92
+ "confirmedBy": "live",
93
+ "note": "Electronic information received, without a code beside the coded scan."
94
+ },
95
+ {
96
+ "wording": "Shipping Label Created",
97
+ "stage": "registered",
98
+ "language": "en",
99
+ "confirmedBy": "live",
100
+ "note": "A partner's label, relayed without a code."
101
+ },
102
+ {
103
+ "wording": "快件已操作完成",
104
+ "stage": "in_transit",
105
+ "language": "zh",
106
+ "confirmedBy": "live",
107
+ "note": "Processing at YDH's warehouse done."
108
+ },
109
+ {
110
+ "wording": "扫描装箱",
111
+ "stage": "in_transit",
112
+ "language": "zh",
113
+ "confirmedBy": "live",
114
+ "note": "Scanned into a box."
115
+ },
116
+ {
117
+ "wording": "装箱完成",
118
+ "stage": "in_transit",
119
+ "language": "zh",
120
+ "confirmedBy": "live",
121
+ "note": "Boxing done."
122
+ },
123
+ {
124
+ "wording": "货物出库",
125
+ "stage": "in_transit",
126
+ "language": "zh",
127
+ "confirmedBy": "live",
128
+ "note": "Out of the warehouse."
129
+ },
130
+ {
131
+ "wording": "出口报关",
132
+ "stage": "in_transit",
133
+ "language": "zh",
134
+ "confirmedBy": "live",
135
+ "note": "Export declaration."
136
+ },
137
+ {
138
+ "wording": "出口放行并安排航班",
139
+ "stage": "in_transit",
140
+ "language": "zh",
141
+ "confirmedBy": "live",
142
+ "note": "Released for export, flight arranged."
143
+ },
144
+ {
145
+ "wording": "货交物流供应商",
146
+ "stage": "in_transit",
147
+ "language": "zh",
148
+ "confirmedBy": "live",
149
+ "note": "Handed to the logistics provider at the destination."
150
+ },
151
+ {
152
+ "wording": "快件到达目的地作业中心",
153
+ "stage": "in_transit",
154
+ "language": "zh",
155
+ "confirmedBy": "live",
156
+ "note": "Arrived at the destination's processing centre."
157
+ },
158
+ {
159
+ "wording": "Loaded on Vehicle",
160
+ "stage": "in_transit",
161
+ "language": "en",
162
+ "confirmedBy": "live",
163
+ "note": "The partner's scan in the United States, relayed without a code."
164
+ },
165
+ {
166
+ "wording": "Arrived at GOFO Delivery Station",
167
+ "stage": "in_transit",
168
+ "language": "en",
169
+ "confirmedBy": "live",
170
+ "note": "GOFO's scan in the United States, relayed without a code."
171
+ },
172
+ {
173
+ "wording": "Departed GOFO Delivery Station",
174
+ "stage": "in_transit",
175
+ "language": "en",
176
+ "confirmedBy": "live",
177
+ "note": "GOFO's scan in the United States, relayed without a code."
178
+ },
179
+ {
180
+ "wording": "Package Scan",
181
+ "stage": "in_transit",
182
+ "language": "en",
183
+ "confirmedBy": "live",
184
+ "note": "USPS's scan, relayed without a code."
185
+ },
186
+ {
187
+ "wording": "Shipment Shipped",
188
+ "stage": "in_transit",
189
+ "language": "en",
190
+ "confirmedBy": "live",
191
+ "note": "USPS's scan, relayed without a code."
192
+ },
193
+ {
194
+ "wording": "Arrival at Unit",
195
+ "stage": "in_transit",
196
+ "language": "en",
197
+ "confirmedBy": "live",
198
+ "note": "USPS's scan at the delivery office, relayed without a code."
199
+ }
200
+ ]
4
201
  }
@@ -35,11 +35,13 @@ export declare function rtb56Wording(row: Record<string, unknown>, provider: str
35
35
  * its offset gets its instant; any other scan keeps its wall clock. Some
36
36
  * tenants state China's offset for scans at the destination too, so a tenant
37
37
  * whose offsets were not seen to follow the scan's place keeps wall clocks.
38
+ * With `codes`, a scan keeps its `track_code`, which the stage is asked with.
38
39
  * The tracker orders the scans by their wall clocks, whatever their offsets;
39
40
  * when every scan has an instant they are ordered by it, equal ones in the
40
41
  * tracker's order. A row without wording says nothing, and a repeated one is
41
42
  * read once.
42
43
  */
43
- export declare function rtb56Scans(rows: unknown[], provider: string, stage: (wording: string) => ClassifiedWording | null, { offsets }: {
44
+ export declare function rtb56Scans(rows: unknown[], provider: string, stage: (wording: string, code: string) => ClassifiedWording | null, { offsets, codes }: {
44
45
  offsets: boolean;
46
+ codes?: boolean;
45
47
  }): CarrierEvent[];
@@ -14,6 +14,7 @@ export const RTB56_REDIRECTS = [301, 302, 303, 307, 308];
14
14
  const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|acw_sc__v2|aliyun_waf|_____tmd_____|waf\.tencent|<title>\s*(?:滑动验证|验证码|安全验证|访问受限|访问被拒绝)/i;
15
15
  const CLOCK = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/;
16
16
  const OFFSET = /^[+-](?:0\d|1[0-4]):[0-5]\d$/;
17
+ const CODE = /^[A-Z]{1,8}$/;
17
18
  const MAX_ROWS = 500;
18
19
  // Four to six digits standing alone in a place are a postcode.
19
20
  const POSTCODE_RUN = /(^|[^\p{L}\p{N}])\d{4,6}(?![\p{L}\p{N}])/gu;
@@ -104,12 +105,13 @@ export function rtb56Wording(row, provider) {
104
105
  * its offset gets its instant; any other scan keeps its wall clock. Some
105
106
  * tenants state China's offset for scans at the destination too, so a tenant
106
107
  * whose offsets were not seen to follow the scan's place keeps wall clocks.
108
+ * With `codes`, a scan keeps its `track_code`, which the stage is asked with.
107
109
  * The tracker orders the scans by their wall clocks, whatever their offsets;
108
110
  * when every scan has an instant they are ordered by it, equal ones in the
109
111
  * tracker's order. A row without wording says nothing, and a repeated one is
110
112
  * read once.
111
113
  */
112
- export function rtb56Scans(rows, provider, stage, { offsets }) {
114
+ export function rtb56Scans(rows, provider, stage, { offsets, codes = false }) {
113
115
  const seen = new Set();
114
116
  const scans = [];
115
117
  for (const row of rows) {
@@ -121,16 +123,20 @@ export function rtb56Scans(rows, provider, stage, { offsets }) {
121
123
  const { description, location } = rtb56Wording(row, provider);
122
124
  if (!description)
123
125
  continue;
124
- const key = JSON.stringify([instant?.iso ?? local, location, description]);
126
+ const code = codes ? rtb56Text(row, 'track_code', provider, 16).toUpperCase() : '';
127
+ if (code && !CODE.test(code))
128
+ throw new SchemaError(provider, `${provider} returned an invalid scan code`);
129
+ const key = JSON.stringify([instant?.iso ?? local, location, description, code]);
125
130
  if (seen.has(key))
126
131
  continue;
127
132
  seen.add(key);
128
- const staged = stage(description);
133
+ const staged = stage(description, code);
129
134
  scans.push({ at: instant?.timestamp ?? null, index: scans.length, event: {
130
135
  // A scan without its place's offset keeps its wall clock.
131
136
  ...(instant ? { time: instant.iso } : { local_time: local }),
132
137
  ...(location ? { location } : {}),
133
138
  description,
139
+ ...(code ? { provider_code: code } : {}),
134
140
  ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
135
141
  } });
136
142
  }
@@ -651,6 +651,7 @@ import { statusMap as yanwen } from '../../carriers/yanwen/status.js';
651
651
  import { statusMap as yunda } from '../../carriers/yunda/status.js';
652
652
  import { statusMap as ywe } from '../../carriers/ywe/status.js';
653
653
  import { statusMap as ycsLogistics } from '../../carriers/ycs-logistics/status.js';
654
+ import { statusMap as ydh } from '../../carriers/ydh/status.js';
654
655
  import { statusMap as zto } from '../../carriers/zto/status.js';
655
656
  import { statusMap as ztoInternational } from '../../carriers/zto-international/status.js';
656
657
  import { statusMap as yto } from '../../carriers/yto/status.js';
@@ -746,7 +747,7 @@ export const STATUS_MAPS = {
746
747
  'united-delivery-service': unitedDeliveryService, uniuni, upaya, ups, urbano, urvaam, usps,
747
748
  'uzbekistan-post': uzbekistanPost, valmo, 'vasp-expresso': vaspExpresso, veloces, venipak, 'via-cargo': viaCargo, viadireta, 'viettel-post': viettelPost,
748
749
  vimenpaq, 'vinted-go': vintedGo, vozovoz, 'vrl-logistics': vrlLogistics, wanbexpress, whistl, winit, 'xde-logistics': xdeLogistics, xdp, xlcourier, 'xpo-ltl': xpoLtl, xpressbees,
749
- yamato, yanwen, 'ycs-logistics': ycsLogistics, 'yilong-logistics': yilongLogistics, yto, yunexpress, yunda, ywe, yypost, zto, 'zto-international': ztoInternational,
750
+ yamato, yanwen, 'ycs-logistics': ycsLogistics, ydh, 'yilong-logistics': yilongLogistics, yto, yunexpress, yunda, ywe, yypost, zto, 'zto-international': ztoInternational,
750
751
  'yurtici-kargo': yurticiKargo, zajil, zeleris, 'zr-express': zrExpress,
751
752
  };
752
753
  /** The carrier the app files the universal providers' scans under. */
@@ -33948,7 +33948,8 @@
33948
33948
  "timezone": "Asia/Shanghai",
33949
33949
  "tracking": {
33950
33950
  "mode": "automatic",
33951
- "adapter": "universal"
33951
+ "adapter": "ydh",
33952
+ "localClocks": true
33952
33953
  },
33953
33954
  "canaryUrl": "https://track.ydhex.com/track_query.aspx",
33954
33955
  "trackingUrlTemplate": "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}",
@@ -21717,7 +21717,8 @@ export declare const CARRIER_CATALOG: {
21717
21717
  readonly timezone: "Asia/Shanghai";
21718
21718
  readonly tracking: {
21719
21719
  readonly mode: "automatic";
21720
- readonly adapter: "universal";
21720
+ readonly adapter: "ydh";
21721
+ readonly localClocks: true;
21721
21722
  };
21722
21723
  readonly canaryUrl: "https://track.ydhex.com/track_query.aspx";
21723
21724
  readonly trackingUrlTemplate: "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}";
@@ -33949,7 +33949,8 @@ export const CARRIER_CATALOG = {
33949
33949
  "timezone": "Asia/Shanghai",
33950
33950
  "tracking": {
33951
33951
  "mode": "automatic",
33952
- "adapter": "universal"
33952
+ "adapter": "ydh",
33953
+ "localClocks": true
33953
33954
  },
33954
33955
  "canaryUrl": "https://track.ydhex.com/track_query.aspx",
33955
33956
  "trackingUrlTemplate": "https://track.ydhex.com/track_query.aspx?track_number={trackingNumber}",
@@ -638,6 +638,7 @@ import { adapter as xpressbees } from '../carriers/xpressbees/adapter.js';
638
638
  import { adapter as yamato } from '../carriers/yamato/adapter.js';
639
639
  import { adapter as yanwen } from '../carriers/yanwen/adapter.js';
640
640
  import { adapter as ycsLogistics } from '../carriers/ycs-logistics/adapter.js';
641
+ import { adapter as ydh } from '../carriers/ydh/adapter.js';
641
642
  import { adapter as yilongLogistics } from '../carriers/yilong-logistics/adapter.js';
642
643
  import { adapter as yto } from '../carriers/yto/adapter.js';
643
644
  import { adapter as yunda } from '../carriers/yunda/adapter.js';
@@ -1291,6 +1292,7 @@ export const REGISTRY = {
1291
1292
  "yamato": yamato,
1292
1293
  "yanwen": yanwen,
1293
1294
  "ycs-logistics": ycsLogistics,
1295
+ "ydh": ydh,
1294
1296
  "yilong-logistics": yilongLogistics,
1295
1297
  "yto": yto,
1296
1298
  "yunda": yunda,
@@ -2121,7 +2123,7 @@ export const REGISTRY = {
2121
2123
  "yanwen": "yanwen",
2122
2124
  "yc-delivery": "universal",
2123
2125
  "ycs-logistics": "ycs-logistics",
2124
- "ydh": "universal",
2126
+ "ydh": "ydh",
2125
2127
  "yfhex": "universal",
2126
2128
  "yilong-logistics": "yilong-logistics",
2127
2129
  "yimidida": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.802.4-main.4321"
5
+ "version": "0.803.0-main.4323"
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.802.4-main.4321",
3
+ "version": "0.803.0-main.4323",
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",