universal-parcel-scraper 0.428.0-main.2178 → 0.429.0-main.2180
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 +2 -2
- package/data/catalog.json +3 -1
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +4 -1
- package/dist/carriers/vrl-logistics/adapter.d.ts +15 -0
- package/dist/carriers/vrl-logistics/adapter.js +47 -0
- package/dist/carriers/vrl-logistics/carrier.json +49 -7
- package/dist/carriers/vrl-logistics/numbers.json +2 -2
- package/dist/carriers/vrl-logistics/parser.d.ts +15 -0
- package/dist/carriers/vrl-logistics/parser.js +160 -0
- package/dist/carriers/vrl-logistics/status.d.ts +7 -0
- package/dist/carriers/vrl-logistics/status.js +32 -0
- package/dist/carriers/vrl-logistics/statuses.json +6 -0
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/data/catalog.json +3 -1
- package/dist/generated/catalog.d.ts +3 -1
- package/dist/generated/catalog.js +3 -1
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **464 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>767 catalog entries · 134 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
|
@@ -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 14,
|
|
158
|
+
The carrier folders record 14,530 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
|
@@ -29631,7 +29631,9 @@
|
|
|
29631
29631
|
"timezone": "Asia/Kolkata",
|
|
29632
29632
|
"tracking": {
|
|
29633
29633
|
"mode": "automatic",
|
|
29634
|
-
"adapter": "
|
|
29634
|
+
"adapter": "vrl-logistics",
|
|
29635
|
+
"recognitionRank": 935,
|
|
29636
|
+
"localClocks": true
|
|
29635
29637
|
},
|
|
29636
29638
|
"canaryUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
29637
29639
|
"trackingSearchUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -22265,7 +22265,9 @@ var Re = {
|
|
|
22265
22265
|
timezone: "Asia/Kolkata",
|
|
22266
22266
|
tracking: {
|
|
22267
22267
|
mode: "automatic",
|
|
22268
|
-
adapter: "
|
|
22268
|
+
adapter: "vrl-logistics",
|
|
22269
|
+
recognitionRank: 935,
|
|
22270
|
+
localClocks: !0
|
|
22269
22271
|
},
|
|
22270
22272
|
canaryUrl: "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
22271
22273
|
trackingSearchUrl: "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
@@ -29494,6 +29496,7 @@ var _l = {
|
|
|
29494
29496
|
venipak: 240,
|
|
29495
29497
|
"via-cargo": 936,
|
|
29496
29498
|
"vinted-go": 410,
|
|
29499
|
+
"vrl-logistics": 935,
|
|
29497
29500
|
wanbexpress: 33,
|
|
29498
29501
|
whistl: 320,
|
|
29499
29502
|
winit: 855,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type AdapterFactory, type 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 tracking page on www.vrlgroup.in, whose form posts the LR number back to it. */
|
|
5
|
+
export declare const VRL_TRACKER = "https://www.vrlgroup.in/Tracking/TrackShipment";
|
|
6
|
+
export declare class VrlTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
});
|
|
13
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
14
|
+
}
|
|
15
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { normalizeVrlNumber, parseVrl, PROVIDER, VRL_MAX_BYTES } from './parser.js';
|
|
8
|
+
/** The tracking page on www.vrlgroup.in, whose form posts the LR number back to it. */
|
|
9
|
+
export const VRL_TRACKER = 'https://www.vrlgroup.in/Tracking/TrackShipment';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
export class VrlTracker {
|
|
12
|
+
options;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async fetch(raw, context = {}) {
|
|
17
|
+
const number = normalizeVrlNumber(raw);
|
|
18
|
+
const read = async (signal, timeoutMs) => {
|
|
19
|
+
// The form also sends its anti-forgery token and cookie, which the page does not check.
|
|
20
|
+
const { response, bytes } = await fetchBounded(VRL_TRACKER, { method: 'POST', signal, headers: {
|
|
21
|
+
Accept: 'text/html', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
22
|
+
}, body: new URLSearchParams({ lrno: number }).toString() }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)),
|
|
23
|
+
maxBytes: VRL_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
24
|
+
if (REDIRECTS.includes(response.status))
|
|
25
|
+
throw new TransportError(PROVIDER, 'VRL redirected its tracking page');
|
|
26
|
+
// The page answers an unknown LR number with HTTP 200; a 404 is a moved route.
|
|
27
|
+
if (response.status === 404 || response.status === 410)
|
|
28
|
+
throw new TransportError(PROVIDER, 'VRL tracking page is unavailable');
|
|
29
|
+
return parseVrl(decodeText(bytes), number);
|
|
30
|
+
};
|
|
31
|
+
// A request 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.
|
|
33
|
+
return runSteps({ carrier: 'vrl-logistics', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
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
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export const adapter = (environment) => {
|
|
41
|
+
const tracker = new VrlTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
42
|
+
return {
|
|
43
|
+
id: 'vrl-logistics', recordsSteps: true, steps: ['direct', 'retry'],
|
|
44
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
45
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeVrlNumber(number))),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
@@ -19,17 +19,59 @@
|
|
|
19
19
|
"timezone": "Asia/Kolkata",
|
|
20
20
|
"tracking": {
|
|
21
21
|
"mode": "automatic",
|
|
22
|
-
"adapter": "
|
|
23
|
-
"steps": [
|
|
22
|
+
"adapter": "vrl-logistics",
|
|
23
|
+
"steps": [
|
|
24
|
+
"direct",
|
|
25
|
+
"retry"
|
|
26
|
+
],
|
|
27
|
+
"recognition": {
|
|
28
|
+
"rank": 935
|
|
29
|
+
},
|
|
30
|
+
"localClocks": true
|
|
24
31
|
},
|
|
25
|
-
"capabilities": [
|
|
32
|
+
"capabilities": [
|
|
33
|
+
"history",
|
|
34
|
+
"location"
|
|
35
|
+
],
|
|
26
36
|
"portal": {
|
|
27
37
|
"searchUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
28
38
|
"canaryUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
29
|
-
"shows": [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
"shows": [
|
|
40
|
+
"status",
|
|
41
|
+
"history",
|
|
42
|
+
"location",
|
|
43
|
+
"scan_times",
|
|
44
|
+
"origin",
|
|
45
|
+
"destination",
|
|
46
|
+
"parcel_count",
|
|
47
|
+
"delivery_method",
|
|
48
|
+
"depot_code",
|
|
49
|
+
"progress_rail"
|
|
50
|
+
],
|
|
51
|
+
"scraped": [
|
|
52
|
+
"status",
|
|
53
|
+
"history",
|
|
54
|
+
"location"
|
|
55
|
+
],
|
|
56
|
+
"discarded": [
|
|
57
|
+
"scan_times",
|
|
58
|
+
"origin",
|
|
59
|
+
"destination",
|
|
60
|
+
"parcel_count",
|
|
61
|
+
"delivery_method",
|
|
62
|
+
"depot_code",
|
|
63
|
+
"progress_rail"
|
|
64
|
+
],
|
|
65
|
+
"unavailable": [
|
|
66
|
+
"eta",
|
|
67
|
+
"weight",
|
|
68
|
+
"service_name",
|
|
69
|
+
"delivered_at",
|
|
70
|
+
"offset_timestamps",
|
|
71
|
+
"sender_name",
|
|
72
|
+
"recipient_name",
|
|
73
|
+
"proof_of_delivery"
|
|
74
|
+
]
|
|
33
75
|
},
|
|
34
76
|
"links": [
|
|
35
77
|
{
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"access": "indexed_excerpt"
|
|
146
146
|
},
|
|
147
147
|
"context": {
|
|
148
|
-
"assessment": "
|
|
148
|
+
"assessment": "confirmed"
|
|
149
149
|
},
|
|
150
150
|
"expect": {
|
|
151
151
|
"carrier": "unknown",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"gls-es"
|
|
162
162
|
]
|
|
163
163
|
},
|
|
164
|
-
"note": "An archived lookup on VRL's consignment tracking page; the opened capture loads the result separately and shows no details,
|
|
164
|
+
"note": "An archived lookup on VRL's consignment tracking page; the opened capture loads the result separately and shows no details. VRL's current tracker knows the LR, with its booking, transit rows and delivery."
|
|
165
165
|
}
|
|
166
166
|
]
|
|
167
167
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "VRL Logistics";
|
|
3
|
+
export declare const VRL_MAX_BYTES: number;
|
|
4
|
+
/** An LR (lorry receipt) number: nine or ten digits. Anything else is not a number VRL issues. */
|
|
5
|
+
export declare function normalizeVrlNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The page `POST /Tracking/TrackShipment` answers for one LR number. The
|
|
8
|
+
* server renders the consignment's details card (its number, booking branch,
|
|
9
|
+
* destination branch, booking day and delivery type), then a transit card with
|
|
10
|
+
* its current status and a rail of its scans between the booking and the
|
|
11
|
+
* destination, oldest first, in two layouts: wide screens and phones. The
|
|
12
|
+
* phone layout is read. A hidden sample card ships with every page. An LR
|
|
13
|
+
* number the tracker does not know comes back as the form with an error toast.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseVrl(html: string, raw: string): CarrierResult;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { ChallengeError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { vrlMappedStage, vrlStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'VRL Logistics';
|
|
9
|
+
// The page is under a hundred kilobytes, most of it menus and styles.
|
|
10
|
+
export const VRL_MAX_BYTES = 1024 * 1024;
|
|
11
|
+
const NUMBER = /^\d{9,10}$/;
|
|
12
|
+
const DAY = /^\d{2}-[A-Z][a-z]{2}-\d{4}$/;
|
|
13
|
+
const DAY_FORMAT = 'dd-LLL-yyyy';
|
|
14
|
+
// A scan's clock as the page writes it, "19:03, 06-Mar-2026": the hour, then the month where the minutes belong.
|
|
15
|
+
const SCAN_CLOCK = /^([01]\d|2[0-3]):[0-5]\d, (\S+)$/;
|
|
16
|
+
// The page's answer for an LR number it does not know, in an error toast above the form.
|
|
17
|
+
const NO_DATA = 'No data found';
|
|
18
|
+
// The toast for an LR number the tracker refuses outright, also its answer to a request without one.
|
|
19
|
+
const INVALID = /^Invalid LR number\b/i;
|
|
20
|
+
// Bot checks a CDN or firewall could put in place of the page.
|
|
21
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected)|requested URL was rejected|g-recaptcha|h-captcha|AwsWafIntegration|awswaf\.com|_Incapsula_Resource/i;
|
|
22
|
+
// The ends of the progress rail: the booking day at the origin, the delivery day at the destination.
|
|
23
|
+
const MARKERS = new Set(['ORIGIN', 'DESTINATION']);
|
|
24
|
+
const MAX_ROWS = 500;
|
|
25
|
+
/** An LR (lorry receipt) number: nine or ten digits. Anything else is not a number VRL issues. */
|
|
26
|
+
export function normalizeVrlNumber(raw) {
|
|
27
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
28
|
+
if (!NUMBER.test(number))
|
|
29
|
+
throw new InvalidInputError(PROVIDER, 'VRL LR numbers are nine or ten digits');
|
|
30
|
+
return number;
|
|
31
|
+
}
|
|
32
|
+
/** A day as the page writes it, "06-Mar-2026", as an ISO calendar day. */
|
|
33
|
+
function calendarDay(text) {
|
|
34
|
+
// Parsing in UTC checks the calendar alone; a day that reads back changed is not one.
|
|
35
|
+
const parsed = DateTime.fromFormat(text, DAY_FORMAT, { zone: 'UTC', locale: 'en-US' });
|
|
36
|
+
if (!DAY.test(text) || !parsed.isValid || parsed.toFormat(DAY_FORMAT, { locale: 'en-US' }) !== text) {
|
|
37
|
+
throw new SchemaError(PROVIDER, 'VRL returned an invalid date');
|
|
38
|
+
}
|
|
39
|
+
return parsed.toISODate();
|
|
40
|
+
}
|
|
41
|
+
/** The labelled values of a card section, by label. */
|
|
42
|
+
function labelled($, section) {
|
|
43
|
+
const values = new Map();
|
|
44
|
+
section.find('p.track-label').each((_, node) => {
|
|
45
|
+
const value = $(node).next('p.track-value');
|
|
46
|
+
if (value.length)
|
|
47
|
+
values.set(clean($(node).text(), 80), clean(value.text(), 120));
|
|
48
|
+
});
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
/** The rail's scans in the page's order, oldest first, without its two ends and the rows that have no date. */
|
|
52
|
+
function scansOf($, timeline) {
|
|
53
|
+
const rails = timeline.find('div.track-container.sm-scr-progress');
|
|
54
|
+
if (rails.length !== 1)
|
|
55
|
+
throw new SchemaError(PROVIDER, 'VRL returned a page without its transit rail');
|
|
56
|
+
const steps = rails.children('div.track-stepp');
|
|
57
|
+
if (steps.length > MAX_ROWS)
|
|
58
|
+
throw new SchemaError(PROVIDER, 'VRL returned an oversized history');
|
|
59
|
+
const rows = [];
|
|
60
|
+
steps.each((index, node) => {
|
|
61
|
+
const content = $(node).children('div.step-content');
|
|
62
|
+
const code = clean(content.find('div.step-labell div.status').text(), 40);
|
|
63
|
+
const clock = content.children('div.step-date');
|
|
64
|
+
if (!clock.length) {
|
|
65
|
+
if (!MARKERS.has(code))
|
|
66
|
+
throw new SchemaError(PROVIDER, 'VRL returned a transit row without its date');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const written = clean(clock.text(), 40);
|
|
70
|
+
// The page can list a row with neither a time nor a day, as ", ".
|
|
71
|
+
if (/^,?$/.test(written))
|
|
72
|
+
return;
|
|
73
|
+
const match = SCAN_CLOCK.exec(written);
|
|
74
|
+
if (!match)
|
|
75
|
+
throw new SchemaError(PROVIDER, 'VRL returned an invalid scan time');
|
|
76
|
+
const description = clean(content.children('label').first().text(), 120);
|
|
77
|
+
const location = clean(content.find('div.step-labell div.city').text(), 120);
|
|
78
|
+
if (!description || !location)
|
|
79
|
+
throw new SchemaError(PROVIDER, 'VRL returned a transit row without its status or branch');
|
|
80
|
+
const day = calendarDay(match[2]);
|
|
81
|
+
rows.push({ event: { local_time: day, location, description }, day, index });
|
|
82
|
+
});
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The page `POST /Tracking/TrackShipment` answers for one LR number. The
|
|
87
|
+
* server renders the consignment's details card (its number, booking branch,
|
|
88
|
+
* destination branch, booking day and delivery type), then a transit card with
|
|
89
|
+
* its current status and a rail of its scans between the booking and the
|
|
90
|
+
* destination, oldest first, in two layouts: wide screens and phones. The
|
|
91
|
+
* phone layout is read. A hidden sample card ships with every page. An LR
|
|
92
|
+
* number the tracker does not know comes back as the form with an error toast.
|
|
93
|
+
*/
|
|
94
|
+
export function parseVrl(html, raw) {
|
|
95
|
+
const number = normalizeVrlNumber(raw);
|
|
96
|
+
const $ = load(html);
|
|
97
|
+
const details = $('section.track-card--details').not('.d-none');
|
|
98
|
+
const timeline = $('section.track-card--timeline').not('.d-none');
|
|
99
|
+
if (!details.length && !timeline.length) {
|
|
100
|
+
const toast = clean($('#errorToast .toast-body').text(), 200);
|
|
101
|
+
if (toast === NO_DATA)
|
|
102
|
+
throw new NotFoundError(PROVIDER);
|
|
103
|
+
// A nine- or ten-digit number is one the tracker takes: a refusal means the request changed.
|
|
104
|
+
if (INVALID.test(toast))
|
|
105
|
+
throw new SchemaError(PROVIDER, 'VRL refused the LR number as invalid');
|
|
106
|
+
if (!$('#trackingForm').length && CHALLENGE.test(html.slice(0, 64_000)))
|
|
107
|
+
throw new ChallengeError(PROVIDER, 'VRL answered with a browser check');
|
|
108
|
+
throw new SchemaError(PROVIDER, 'VRL returned a page without an answer');
|
|
109
|
+
}
|
|
110
|
+
if (details.length !== 1 || timeline.length !== 1)
|
|
111
|
+
throw new SchemaError(PROVIDER, 'VRL returned an unexpected number of consignments');
|
|
112
|
+
const heading = details.find('#lrnum');
|
|
113
|
+
if (heading.length !== 1)
|
|
114
|
+
throw new SchemaError(PROVIDER, 'VRL returned a consignment without its number');
|
|
115
|
+
const echoed = clean(heading.text(), 40).replace(/^#\s*/, '');
|
|
116
|
+
if (echoed !== number)
|
|
117
|
+
throw new SchemaError(PROVIDER, 'VRL answered for a different LR number');
|
|
118
|
+
const booking = labelled($, details);
|
|
119
|
+
const transit = labelled($, timeline);
|
|
120
|
+
if (!booking.has('Booking Date') || !booking.has('Booking From'))
|
|
121
|
+
throw new SchemaError(PROVIDER, 'VRL returned a consignment without its booking');
|
|
122
|
+
const rows = scansOf($, timeline);
|
|
123
|
+
const bookedOn = booking.get('Booking Date');
|
|
124
|
+
const bookedAt = booking.get('Booking From');
|
|
125
|
+
// The rail starts with the booking day at the first word of the booking branch's name; the details card names it whole.
|
|
126
|
+
if (bookedOn) {
|
|
127
|
+
const day = calendarDay(bookedOn);
|
|
128
|
+
rows.unshift({ event: { local_time: day, ...(bookedAt ? { location: bookedAt } : {}), description: 'Booked' }, day, index: -1 });
|
|
129
|
+
}
|
|
130
|
+
if (!rows.length)
|
|
131
|
+
throw new NoHistoryError(PROVIDER);
|
|
132
|
+
// Days in order; within a day, the page's order. Without the hour, a row the
|
|
133
|
+
// same day at the same branch with the same status says nothing new.
|
|
134
|
+
const ordered = rows.sort((a, b) => a.day.localeCompare(b.day) || a.index - b.index).reverse();
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const events = [];
|
|
137
|
+
for (const { event } of ordered) {
|
|
138
|
+
const key = JSON.stringify([event.local_time, event.description, event.location]);
|
|
139
|
+
if (seen.has(key))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(key);
|
|
142
|
+
const staged = vrlStage(event.description);
|
|
143
|
+
events.push({ ...event, ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}) });
|
|
144
|
+
}
|
|
145
|
+
// The transit card's current status decides when the map knows it; else the newest scan with a stage.
|
|
146
|
+
const summary = transit.get('Current Status') ?? '';
|
|
147
|
+
const summarized = summary ? vrlMappedStage(summary) : undefined;
|
|
148
|
+
const decider = summarized ? undefined : events.find((event) => event.stage);
|
|
149
|
+
const stage = (summarized ?? decider?.stage);
|
|
150
|
+
const newest = events[0];
|
|
151
|
+
return {
|
|
152
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
153
|
+
...(stage ? { current_stage: stage, current_stage_source: summarized ? 'carrier_map' : decider.stage_source } : {}),
|
|
154
|
+
last_status_text: summarized ? summary : newest.description,
|
|
155
|
+
last_update: null,
|
|
156
|
+
last_update_local: newest.local_time,
|
|
157
|
+
expected_delivery: null,
|
|
158
|
+
events,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage the map gives a wording, if it knows it. */
|
|
4
|
+
export declare function vrlMappedStage(wording: string): Stage | undefined;
|
|
5
|
+
/** A wording's stage from the map, else from the shared wording rules; wording neither knows stays without one. */
|
|
6
|
+
export declare function vrlStage(wording: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tracker's scans and its current status carry wording only, in capitals:
|
|
5
|
+
* a move between branches, the arrival at the destination branch and the
|
|
6
|
+
* delivery. The adapter words the booking `Booked`. Only wording seen on the
|
|
7
|
+
* current tracker is mapped; other wording, such as the former tracker's,
|
|
8
|
+
* falls to the shared rules.
|
|
9
|
+
*/
|
|
10
|
+
const WORDINGS = new Map([
|
|
11
|
+
['booked', 'accepted'],
|
|
12
|
+
['in-transit', 'in_transit'],
|
|
13
|
+
// The page does not say whether the parcel waits there for collection or goes out to the door.
|
|
14
|
+
['arrived at destination', 'in_transit'],
|
|
15
|
+
['delivered', 'delivered'],
|
|
16
|
+
]);
|
|
17
|
+
/** The stage the map gives a wording, if it knows it. */
|
|
18
|
+
export function vrlMappedStage(wording) {
|
|
19
|
+
return WORDINGS.get(normalizeStatusWording(wording));
|
|
20
|
+
}
|
|
21
|
+
/** A wording's stage from the map, else from the shared wording rules; wording neither knows stays without one. */
|
|
22
|
+
export function vrlStage(wording) {
|
|
23
|
+
const mapped = vrlMappedStage(wording);
|
|
24
|
+
if (mapped)
|
|
25
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
26
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
27
|
+
return worded.source === 'none' ? null : worded;
|
|
28
|
+
}
|
|
29
|
+
export const statusMap = {
|
|
30
|
+
stage: (code, wording) => (code ? undefined : vrlMappedStage(wording)),
|
|
31
|
+
gaps: [],
|
|
32
|
+
};
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "vrl-logistics",
|
|
3
3
|
"entries": [
|
|
4
|
+
{"wording": "Booked", "stage": "accepted", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Written by the adapter for the details card's booking day, at the booking branch."},
|
|
5
|
+
{"wording": "IN-TRANSIT", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "A transit row at a branch or transhipment point on the way, several a day."},
|
|
6
|
+
{"wording": "ARRIVED AT DESTINATION", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "A transit row at the destination branch, and the current status of a consignment that stays there. The page does not say whether it waits for collection or goes out to the door."},
|
|
7
|
+
{"wording": "DELIVERED", "stage": "delivered", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "A transit row at the destination branch, and the current status once delivered, for an office delivery too. An undated row with this wording can sit under a consignment still at its destination; the adapter skips undated rows."},
|
|
8
|
+
{"wording": "ORIGIN", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "The start of the progress rail, with the booking day and the first word of the booking branch's name; not a scan."},
|
|
9
|
+
{"wording": "DESTINATION", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "The end of the progress rail, with the destination town and, once delivered, the delivery day; not a scan."},
|
|
4
10
|
{"wording": "MATERIAL DELIVERED", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of the LR tracking page, on a delivery run."},
|
|
5
11
|
{"wording": "MATERIAL OUT FOR DELIVERY", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of the LR tracking page, on a delivery run."},
|
|
6
12
|
{"wording": "NOT DELIVERED", "stage": "failed_attempt", "language": "en", "confirmedBy": "portal", "note": "Seen in an archived capture of the LR tracking page, on a delivery run cancelled with a reason."},
|
|
@@ -370,6 +370,7 @@ import { statusMap as venipak } from '../../carriers/venipak/status.js';
|
|
|
370
370
|
import { statusMap as viaCargo } from '../../carriers/via-cargo/status.js';
|
|
371
371
|
import { statusMap as viettelPost } from '../../carriers/viettel-post/status.js';
|
|
372
372
|
import { statusMap as vintedGo } from '../../carriers/vinted-go/status.js';
|
|
373
|
+
import { statusMap as vrlLogistics } from '../../carriers/vrl-logistics/status.js';
|
|
373
374
|
import { statusMap as wanbexpress } from '../../carriers/wanbexpress/status.js';
|
|
374
375
|
import { statusMap as whistl } from '../../carriers/whistl/status.js';
|
|
375
376
|
import { statusMap as winit } from '../../carriers/winit/status.js';
|
|
@@ -446,7 +447,7 @@ export const STATUS_MAPS = {
|
|
|
446
447
|
trackon, 'tracx-logis': tracxLogis, transaher, transcorp, transmission, tsjy, 'tws-express': twsExpress, 'ubi-smart-parcel': ubiSmartParcel,
|
|
447
448
|
'united-delivery-service': unitedDeliveryService, ups, urbano, urvaam,
|
|
448
449
|
'uzbekistan-post': uzbekistanPost, valmo, 'vasp-expresso': vaspExpresso, veloces, venipak, 'via-cargo': viaCargo, 'viettel-post': viettelPost,
|
|
449
|
-
'vinted-go': vintedGo, wanbexpress, whistl, winit, 'xde-logistics': xdeLogistics, xdp, xlcourier, 'xpo-ltl': xpoLtl,
|
|
450
|
+
'vinted-go': vintedGo, 'vrl-logistics': vrlLogistics, wanbexpress, whistl, winit, 'xde-logistics': xdeLogistics, xdp, xlcourier, 'xpo-ltl': xpoLtl,
|
|
450
451
|
'yilong-logistics': yilongLogistics, yunexpress, zto,
|
|
451
452
|
'yurtici-kargo': yurticiKargo, zajil, zeleris, 'zr-express': zrExpress,
|
|
452
453
|
};
|
package/dist/data/catalog.json
CHANGED
|
@@ -29631,7 +29631,9 @@
|
|
|
29631
29631
|
"timezone": "Asia/Kolkata",
|
|
29632
29632
|
"tracking": {
|
|
29633
29633
|
"mode": "automatic",
|
|
29634
|
-
"adapter": "
|
|
29634
|
+
"adapter": "vrl-logistics",
|
|
29635
|
+
"recognitionRank": 935,
|
|
29636
|
+
"localClocks": true
|
|
29635
29637
|
},
|
|
29636
29638
|
"canaryUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
29637
29639
|
"trackingSearchUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
@@ -18868,7 +18868,9 @@ export declare const CARRIER_CATALOG: {
|
|
|
18868
18868
|
readonly timezone: "Asia/Kolkata";
|
|
18869
18869
|
readonly tracking: {
|
|
18870
18870
|
readonly mode: "automatic";
|
|
18871
|
-
readonly adapter: "
|
|
18871
|
+
readonly adapter: "vrl-logistics";
|
|
18872
|
+
readonly recognitionRank: 935;
|
|
18873
|
+
readonly localClocks: true;
|
|
18872
18874
|
};
|
|
18873
18875
|
readonly canaryUrl: "https://www.vrlgroup.in/Tracking/TrackShipment";
|
|
18874
18876
|
readonly trackingSearchUrl: "https://www.vrlgroup.in/Tracking/TrackShipment";
|
|
@@ -29632,7 +29632,9 @@ export const CARRIER_CATALOG = {
|
|
|
29632
29632
|
"timezone": "Asia/Kolkata",
|
|
29633
29633
|
"tracking": {
|
|
29634
29634
|
"mode": "automatic",
|
|
29635
|
-
"adapter": "
|
|
29635
|
+
"adapter": "vrl-logistics",
|
|
29636
|
+
"recognitionRank": 935,
|
|
29637
|
+
"localClocks": true
|
|
29636
29638
|
},
|
|
29637
29639
|
"canaryUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
29638
29640
|
"trackingSearchUrl": "https://www.vrlgroup.in/Tracking/TrackShipment",
|
|
@@ -443,6 +443,7 @@ import { adapter as venipak } from '../carriers/venipak/adapter.js';
|
|
|
443
443
|
import { adapter as viaCargo } from '../carriers/via-cargo/adapter.js';
|
|
444
444
|
import { adapter as viettelPost } from '../carriers/viettel-post/adapter.js';
|
|
445
445
|
import { adapter as vintedGo } from '../carriers/vinted-go/adapter.js';
|
|
446
|
+
import { adapter as vrlLogistics } from '../carriers/vrl-logistics/adapter.js';
|
|
446
447
|
import { adapter as wanbexpress } from '../carriers/wanbexpress/adapter.js';
|
|
447
448
|
import { adapter as whistl } from '../carriers/whistl/adapter.js';
|
|
448
449
|
import { adapter as winit } from '../carriers/winit/adapter.js';
|
|
@@ -908,6 +909,7 @@ export const REGISTRY = {
|
|
|
908
909
|
"via-cargo": viaCargo,
|
|
909
910
|
"viettel-post": viettelPost,
|
|
910
911
|
"vinted-go": vintedGo,
|
|
912
|
+
"vrl-logistics": vrlLogistics,
|
|
911
913
|
"wanbexpress": wanbexpress,
|
|
912
914
|
"whistl": whistl,
|
|
913
915
|
"winit": winit,
|
|
@@ -1666,7 +1668,7 @@ export const REGISTRY = {
|
|
|
1666
1668
|
"vitran-express": "universal",
|
|
1667
1669
|
"vnpost": "universal",
|
|
1668
1670
|
"vozovoz": "universal",
|
|
1669
|
-
"vrl-logistics": "
|
|
1671
|
+
"vrl-logistics": "vrl-logistics",
|
|
1670
1672
|
"wahana": "universal",
|
|
1671
1673
|
"wanbexpress": "wanbexpress",
|
|
1672
1674
|
"ward-trucking": "universal",
|
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.429.0-main.2180",
|
|
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",
|