universal-parcel-scraper 0.374.0-main.2018 → 0.375.0-main.2020
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 +2 -1
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +2 -1
- package/dist/carriers/sundarban-courier/adapter.d.ts +23 -0
- package/dist/carriers/sundarban-courier/adapter.js +104 -0
- package/dist/carriers/sundarban-courier/carrier.json +53 -7
- package/dist/carriers/sundarban-courier/parser.d.ts +12 -0
- package/dist/carriers/sundarban-courier/parser.js +162 -0
- package/dist/carriers/sundarban-courier/status.d.ts +18 -0
- package/dist/carriers/sundarban-courier/status.js +64 -0
- package/dist/carriers/sundarban-courier/statuses.json +5 -5
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/data/catalog.json +2 -1
- package/dist/generated/catalog.d.ts +2 -1
- package/dist/generated/catalog.js +2 -1
- 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 **406 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>727 catalog entries · 133 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
package/data/catalog.json
CHANGED
|
@@ -25879,7 +25879,8 @@
|
|
|
25879
25879
|
"timezone": "Asia/Dhaka",
|
|
25880
25880
|
"tracking": {
|
|
25881
25881
|
"mode": "automatic",
|
|
25882
|
-
"adapter": "
|
|
25882
|
+
"adapter": "sundarban-courier",
|
|
25883
|
+
"localClocks": true
|
|
25883
25884
|
},
|
|
25884
25885
|
"canaryUrl": "https://operation.sundarbancourierltd.com/",
|
|
25885
25886
|
"trackingUrlTemplate": "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -19533,7 +19533,8 @@ var Pe = {
|
|
|
19533
19533
|
timezone: "Asia/Dhaka",
|
|
19534
19534
|
tracking: {
|
|
19535
19535
|
mode: "automatic",
|
|
19536
|
-
adapter: "
|
|
19536
|
+
adapter: "sundarban-courier",
|
|
19537
|
+
localClocks: !0
|
|
19537
19538
|
},
|
|
19538
19539
|
canaryUrl: "https://operation.sundarbancourierltd.com/",
|
|
19539
19540
|
trackingUrlTemplate: "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}",
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/** Sundarban's tracking page, whose script gives every visitor the lookup's key. */
|
|
5
|
+
export declare const SUNDARBAN_PAGE = "https://operation.sundarbancourierltd.com/";
|
|
6
|
+
/** The lookup the tracking page makes for a CN number. */
|
|
7
|
+
export declare const SUNDARBAN_API = "https://operation.sundarbancourierltd.com/Home/getDatabyCN";
|
|
8
|
+
/** The key the tracking page's script sends with every lookup, the same for every visitor. */
|
|
9
|
+
export declare const SUNDARBAN_KEY = "CzbZcWnwf7TNTzluD9rxyXCUqzN4xOhs";
|
|
10
|
+
/** The page's request: one CN number, looked up as a CN number over its default period. */
|
|
11
|
+
export declare function sundarbanLookupBody(number: string): string;
|
|
12
|
+
export declare class SundarbanTracker {
|
|
13
|
+
#private;
|
|
14
|
+
private readonly options;
|
|
15
|
+
constructor(options?: {
|
|
16
|
+
fetcher?: typeof fetch;
|
|
17
|
+
recorder?: StepRecorder;
|
|
18
|
+
userAgent?: string;
|
|
19
|
+
key?: string;
|
|
20
|
+
});
|
|
21
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
22
|
+
}
|
|
23
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ChallengeError, SchemaError, 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 { normalizeSundarbanNumber, parseSundarban, PROVIDER, SUNDARBAN_MAX_BYTES } from './parser.js';
|
|
7
|
+
/** Sundarban's tracking page, whose script gives every visitor the lookup's key. */
|
|
8
|
+
export const SUNDARBAN_PAGE = 'https://operation.sundarbancourierltd.com/';
|
|
9
|
+
/** The lookup the tracking page makes for a CN number. */
|
|
10
|
+
export const SUNDARBAN_API = 'https://operation.sundarbancourierltd.com/Home/getDatabyCN';
|
|
11
|
+
/** The key the tracking page's script sends with every lookup, the same for every visitor. */
|
|
12
|
+
export const SUNDARBAN_KEY = 'CzbZcWnwf7TNTzluD9rxyXCUqzN4xOhs'; // gitleaks:allow
|
|
13
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
14
|
+
const PAGE_MAX_BYTES = 1024 * 1024;
|
|
15
|
+
// The service's answer to a lookup without the page's key, as a JSON string.
|
|
16
|
+
const REFUSED = 'UNAUTHORIZED';
|
|
17
|
+
// The page script's header for the lookup.
|
|
18
|
+
const PAGE_KEY = /setRequestHeader\(\s*["']key["']\s*,\s*["']([A-Za-z0-9]{8,128})["']\s*\)/;
|
|
19
|
+
// Bot checks a CDN or firewall could put in place of the reply.
|
|
20
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required)/i;
|
|
21
|
+
/** The page's request: one CN number, looked up as a CN number over its default period. */
|
|
22
|
+
export function sundarbanLookupBody(number) {
|
|
23
|
+
return JSON.stringify({ selectedtypes: 'cnno', selectedtimes: '7', inputvalue: number });
|
|
24
|
+
}
|
|
25
|
+
function json(body) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(body);
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
31
|
+
throw new ChallengeError(PROVIDER, 'Sundarban answered with a browser check', { cause });
|
|
32
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned a reply that is not JSON', { cause });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class SundarbanTracker {
|
|
36
|
+
options;
|
|
37
|
+
#key;
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.#key = options.key ?? SUNDARBAN_KEY;
|
|
41
|
+
}
|
|
42
|
+
async fetch(raw, context = {}) {
|
|
43
|
+
const number = normalizeSundarbanNumber(raw);
|
|
44
|
+
const read = async (signal, timeoutMs) => {
|
|
45
|
+
const deadline = performance.now() + timeoutMs;
|
|
46
|
+
const left = () => Math.max(1, Math.floor(deadline - performance.now()));
|
|
47
|
+
const key = this.#key;
|
|
48
|
+
const reply = await this.#query(number, key, signal, left());
|
|
49
|
+
if (reply !== REFUSED)
|
|
50
|
+
return parseSundarban(reply, number);
|
|
51
|
+
// A refused key may have been replaced: the page gives the current one.
|
|
52
|
+
const current = await this.#pageKey(signal, left());
|
|
53
|
+
if (current === key)
|
|
54
|
+
throw new SchemaError(PROVIDER, 'Sundarban refused its tracking page\'s key');
|
|
55
|
+
const retried = await this.#query(number, current, signal, left());
|
|
56
|
+
if (retried === REFUSED)
|
|
57
|
+
throw new SchemaError(PROVIDER, 'Sundarban refused its tracking page\'s key');
|
|
58
|
+
this.#key = current;
|
|
59
|
+
return parseSundarban(retried, number);
|
|
60
|
+
};
|
|
61
|
+
// A read that fails to reach Sundarban, or hangs, gets one more: the first
|
|
62
|
+
// gets half of the budget, so the retry fits in the rest.
|
|
63
|
+
return runSteps({ carrier: 'sundarban-courier', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
64
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
65
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
66
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
/** The decoded reply for the number. */
|
|
70
|
+
async #query(number, key, signal, timeoutMs) {
|
|
71
|
+
const { response, bytes } = await fetchBounded(SUNDARBAN_API, { method: 'POST', signal, headers: {
|
|
72
|
+
Accept: 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/json;charset=utf-8', key,
|
|
73
|
+
'User-Agent': userAgentOf(this.options.userAgent),
|
|
74
|
+
}, body: sundarbanLookupBody(number) }, { provider: PROVIDER, timeoutMs, maxBytes: SUNDARBAN_MAX_BYTES, redirect: 'manual',
|
|
75
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
76
|
+
if (REDIRECTS.includes(response.status))
|
|
77
|
+
throw new TransportError(PROVIDER, 'Sundarban redirected its tracking lookup');
|
|
78
|
+
if (response.status !== 200)
|
|
79
|
+
throw new TransportError(PROVIDER, 'Sundarban tracking lookup is unavailable', { status: response.status });
|
|
80
|
+
return json(decodeText(bytes));
|
|
81
|
+
}
|
|
82
|
+
/** The key the tracking page's script sends now. */
|
|
83
|
+
async #pageKey(signal, timeoutMs) {
|
|
84
|
+
const { response, bytes } = await fetchBounded(SUNDARBAN_PAGE, { method: 'GET', signal,
|
|
85
|
+
headers: { Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent) } }, { provider: PROVIDER, timeoutMs, maxBytes: PAGE_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
|
|
86
|
+
allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
87
|
+
if (response.status !== 200)
|
|
88
|
+
throw new TransportError(PROVIDER, 'Sundarban\'s tracking page is unavailable', { status: response.status });
|
|
89
|
+
const page = decodeText(bytes);
|
|
90
|
+
const key = PAGE_KEY.exec(page)?.[1];
|
|
91
|
+
if (key)
|
|
92
|
+
return key;
|
|
93
|
+
if (CHALLENGE.test(page.slice(0, 64_000)))
|
|
94
|
+
throw new ChallengeError(PROVIDER, 'Sundarban answered its tracking page with a browser check');
|
|
95
|
+
throw new SchemaError(PROVIDER, 'Sundarban\'s tracking page no longer gives its lookup key');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export const adapter = (environment) => {
|
|
99
|
+
const tracker = new SundarbanTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
100
|
+
return {
|
|
101
|
+
id: 'sundarban-courier', recordsSteps: true, steps: ['direct', 'retry'],
|
|
102
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
@@ -18,18 +18,64 @@
|
|
|
18
18
|
"timezone": "Asia/Dhaka",
|
|
19
19
|
"tracking": {
|
|
20
20
|
"mode": "automatic",
|
|
21
|
-
"adapter": "
|
|
22
|
-
"steps": [
|
|
21
|
+
"adapter": "sundarban-courier",
|
|
22
|
+
"steps": [
|
|
23
|
+
"direct",
|
|
24
|
+
"retry"
|
|
25
|
+
],
|
|
26
|
+
"localClocks": true
|
|
23
27
|
},
|
|
24
|
-
"capabilities": [
|
|
28
|
+
"capabilities": [
|
|
29
|
+
"history",
|
|
30
|
+
"location",
|
|
31
|
+
"provider_code",
|
|
32
|
+
"pickup_point"
|
|
33
|
+
],
|
|
25
34
|
"portal": {
|
|
26
35
|
"url": "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}",
|
|
27
36
|
"searchUrl": "https://operation.sundarbancourierltd.com/",
|
|
28
37
|
"canaryUrl": "https://operation.sundarbancourierltd.com/",
|
|
29
|
-
"shows": [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
"shows": [
|
|
39
|
+
"status",
|
|
40
|
+
"history",
|
|
41
|
+
"location",
|
|
42
|
+
"pickup_point",
|
|
43
|
+
"origin",
|
|
44
|
+
"destination",
|
|
45
|
+
"departure_date",
|
|
46
|
+
"parcel_count",
|
|
47
|
+
"order_contents",
|
|
48
|
+
"recipient_contact",
|
|
49
|
+
"event_details",
|
|
50
|
+
"remarks"
|
|
51
|
+
],
|
|
52
|
+
"scraped": [
|
|
53
|
+
"status",
|
|
54
|
+
"history",
|
|
55
|
+
"location",
|
|
56
|
+
"provider_code",
|
|
57
|
+
"pickup_point"
|
|
58
|
+
],
|
|
59
|
+
"discarded": [
|
|
60
|
+
"origin",
|
|
61
|
+
"destination",
|
|
62
|
+
"departure_date",
|
|
63
|
+
"parcel_count",
|
|
64
|
+
"order_contents",
|
|
65
|
+
"recipient_contact",
|
|
66
|
+
"event_details",
|
|
67
|
+
"remarks"
|
|
68
|
+
],
|
|
69
|
+
"unavailable": [
|
|
70
|
+
"offset_timestamps",
|
|
71
|
+
"scan_times",
|
|
72
|
+
"eta",
|
|
73
|
+
"weight",
|
|
74
|
+
"delivered_at",
|
|
75
|
+
"service_name",
|
|
76
|
+
"proof_of_delivery",
|
|
77
|
+
"delivery_partner"
|
|
78
|
+
]
|
|
33
79
|
},
|
|
34
80
|
"links": [
|
|
35
81
|
{
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Sundarban Courier";
|
|
3
|
+
export declare const SUNDARBAN_MAX_BYTES: number;
|
|
4
|
+
/** A CN number for Sundarban's tracker; anything else is not one Sundarban issues. */
|
|
5
|
+
export declare function normalizeSundarbanNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The reply of the tracking page's lookup, `POST /Home/getDatabyCN`: a list of
|
|
8
|
+
* the shipments under the CN number, each naming its `cnNumber`. For a number
|
|
9
|
+
* it holds nothing for, the list is empty and the page only says an error
|
|
10
|
+
* occurred and asks for the correct format, so that answer is inconclusive.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseSundarban(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
2
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
|
|
3
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
4
|
+
import { calendarDay } from '../../core/time/index.js';
|
|
5
|
+
import { clean } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { SUNDARBAN_CODES, sundarbanStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'Sundarban Courier';
|
|
9
|
+
// A shipment with its history is about five kilobytes.
|
|
10
|
+
export const SUNDARBAN_MAX_BYTES = 512 * 1024;
|
|
11
|
+
// A CN number, or a sub-CN: fourteen or seventeen digits.
|
|
12
|
+
const NUMBER = /^\d{14}(?:\d{3})?$/;
|
|
13
|
+
const CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?$/;
|
|
14
|
+
const MAX_ENTRIES = 50;
|
|
15
|
+
const MAX_ROWS = 300;
|
|
16
|
+
const MAX_EVENTS = 100;
|
|
17
|
+
/** A CN number for Sundarban's tracker; anything else is not one Sundarban issues. */
|
|
18
|
+
export function normalizeSundarbanNumber(raw) {
|
|
19
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
20
|
+
if (!NUMBER.test(number))
|
|
21
|
+
throw new InvalidInputError(PROVIDER, 'Sundarban CN numbers are fourteen digits, or seventeen for a sub-CN');
|
|
22
|
+
return number;
|
|
23
|
+
}
|
|
24
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
25
|
+
function text(row, key, maxLength = 200) {
|
|
26
|
+
const value = row[key];
|
|
27
|
+
if (value === null || value === undefined)
|
|
28
|
+
return '';
|
|
29
|
+
if (typeof value !== 'string')
|
|
30
|
+
throw new SchemaError(PROVIDER, `Sundarban returned an invalid ${key}`);
|
|
31
|
+
return clean(value, maxLength);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A `statusDate`, "2026-08-04T00:00:00": the branch's wall clock with no zone.
|
|
35
|
+
* The service keeps the day only and writes it at midnight, so a clock at
|
|
36
|
+
* midnight exactly keeps only its day.
|
|
37
|
+
*/
|
|
38
|
+
function wallClock(value) {
|
|
39
|
+
const match = CLOCK.exec(value);
|
|
40
|
+
const day = match ? calendarDay(Number(match[1]), Number(match[2]), Number(match[3])) : null;
|
|
41
|
+
if (!match || !day || Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) {
|
|
42
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned an invalid status date');
|
|
43
|
+
}
|
|
44
|
+
return `${match[4]}${match[5]}${match[6]}` === '000000' ? day : `${day}T${match[4]}:${match[5]}:${match[6]}`;
|
|
45
|
+
}
|
|
46
|
+
/** A status as the page labels it: an international code by its label, a domestic status as written. */
|
|
47
|
+
function statusOf(status) {
|
|
48
|
+
return Object.hasOwn(SUNDARBAN_CODES, status) ? { description: SUNDARBAN_CODES[status].label, code: status } : { description: status, code: null };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A row of `cnStatusList`: its status, day and the branch it happened at. The
|
|
52
|
+
* branch it was sent towards, the vehicle, the bag or manifest number and the
|
|
53
|
+
* remarks are not kept. A row without a status is left out.
|
|
54
|
+
*/
|
|
55
|
+
function scanOf(row) {
|
|
56
|
+
if (!isRecord(row))
|
|
57
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned an invalid status row');
|
|
58
|
+
const status = text(row, 'status', 120);
|
|
59
|
+
const local = wallClock(text(row, 'statusDate', 40));
|
|
60
|
+
if (!status)
|
|
61
|
+
return null;
|
|
62
|
+
const { description, code } = statusOf(status);
|
|
63
|
+
const location = text(row, 'fromSubBranch', 120);
|
|
64
|
+
const staged = sundarbanStage(code, status);
|
|
65
|
+
return {
|
|
66
|
+
local_time: local,
|
|
67
|
+
...(location ? { location } : {}),
|
|
68
|
+
description,
|
|
69
|
+
...(code ? { provider_code: code } : {}),
|
|
70
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The shipment's statuses, newest first. The service lists them oldest first. */
|
|
74
|
+
function history(shipment) {
|
|
75
|
+
const rows = shipment.cnStatusList ?? [];
|
|
76
|
+
if (!Array.isArray(rows))
|
|
77
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned a shipment with an invalid history');
|
|
78
|
+
if (rows.length > MAX_ROWS)
|
|
79
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned an oversized history');
|
|
80
|
+
const scans = rows.map((row, index) => ({ event: scanOf(row), index }))
|
|
81
|
+
.filter((scan) => scan.event !== null);
|
|
82
|
+
// A day without a clock sorts before that day's clocks; within one, the later row is newer.
|
|
83
|
+
return scans.sort((a, b) => b.event.local_time.localeCompare(a.event.local_time) || b.index - a.index)
|
|
84
|
+
.map(({ event }) => event);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The branch counter the parcel waits at, or was collected from: the page's
|
|
88
|
+
* "Ready for C/D" names it, when nothing but a delivery followed.
|
|
89
|
+
*/
|
|
90
|
+
function counter(events) {
|
|
91
|
+
const [newest, before] = events;
|
|
92
|
+
const ready = newest?.stage === 'ready_for_pickup' ? newest : newest?.stage === 'delivered' && before?.stage === 'ready_for_pickup' ? before : undefined;
|
|
93
|
+
return ready?.location;
|
|
94
|
+
}
|
|
95
|
+
/** The shipment's current status alone, when the service lists no history for it. */
|
|
96
|
+
function summary(shipment) {
|
|
97
|
+
const status = text(shipment, 'status', 120);
|
|
98
|
+
if (!status)
|
|
99
|
+
throw new NoHistoryError(PROVIDER);
|
|
100
|
+
const { description, code } = statusOf(status);
|
|
101
|
+
const staged = sundarbanStage(code, status);
|
|
102
|
+
const date = text(shipment, 'statusDate', 40);
|
|
103
|
+
return {
|
|
104
|
+
status: staged ? languageStageStatus(staged.stage) : 'unknown',
|
|
105
|
+
...(staged ? { current_stage: staged.stage, current_stage_source: staged.source } : {}),
|
|
106
|
+
last_status_text: description,
|
|
107
|
+
...(code ? { provider_code: code } : {}),
|
|
108
|
+
last_update: null,
|
|
109
|
+
...(date ? { last_update_local: wallClock(date) } : {}),
|
|
110
|
+
summary_only: true,
|
|
111
|
+
events: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function project(shipment) {
|
|
115
|
+
const events = history(shipment);
|
|
116
|
+
if (!events.length)
|
|
117
|
+
return summary(shipment);
|
|
118
|
+
const newest = events[0];
|
|
119
|
+
const staged = events.find((event) => event.stage);
|
|
120
|
+
const stage = staged?.stage;
|
|
121
|
+
const point = counter(events);
|
|
122
|
+
return {
|
|
123
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
124
|
+
...(stage ? { current_stage: stage, current_stage_source: staged.stage_source ?? 'carrier_map' } : {}),
|
|
125
|
+
last_status_text: newest.description,
|
|
126
|
+
last_update: null,
|
|
127
|
+
last_update_local: newest.local_time,
|
|
128
|
+
expected_delivery: null,
|
|
129
|
+
...(point ? { pickup_point: point } : {}),
|
|
130
|
+
events: events.slice(0, MAX_EVENTS),
|
|
131
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The reply of the tracking page's lookup, `POST /Home/getDatabyCN`: a list of
|
|
136
|
+
* the shipments under the CN number, each naming its `cnNumber`. For a number
|
|
137
|
+
* it holds nothing for, the list is empty and the page only says an error
|
|
138
|
+
* occurred and asks for the correct format, so that answer is inconclusive.
|
|
139
|
+
*/
|
|
140
|
+
export function parseSundarban(payload, raw) {
|
|
141
|
+
const number = normalizeSundarbanNumber(raw);
|
|
142
|
+
if (!Array.isArray(payload))
|
|
143
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned an invalid reply');
|
|
144
|
+
if (payload.length > MAX_ENTRIES)
|
|
145
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned too many shipments');
|
|
146
|
+
if (!payload.length) {
|
|
147
|
+
throw new IndeterminateError(PROVIDER, 'Sundarban\'s tracker shows nothing for the number', { reason: 'portal_not_found' });
|
|
148
|
+
}
|
|
149
|
+
const shipments = payload.filter((entry) => {
|
|
150
|
+
if (!isRecord(entry))
|
|
151
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned an invalid shipment');
|
|
152
|
+
const own = text(entry, 'cnNumber', 64);
|
|
153
|
+
if (!own)
|
|
154
|
+
throw new SchemaError(PROVIDER, 'Sundarban returned a shipment without its CN number');
|
|
155
|
+
return normalizeTrackingNumber(own) === number;
|
|
156
|
+
});
|
|
157
|
+
if (shipments.length > 1)
|
|
158
|
+
throw new IndeterminateError(PROVIDER, 'Sundarban returned several shipments under the number');
|
|
159
|
+
if (!shipments.length)
|
|
160
|
+
throw new IndeterminateError(PROVIDER, 'Sundarban answered for another shipment');
|
|
161
|
+
return project(shipments[0]);
|
|
162
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The codes the service gives an international shipment's statuses, with the
|
|
5
|
+
* label the page shows for each.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SUNDARBAN_CODES: Readonly<Record<string, {
|
|
8
|
+
label: string;
|
|
9
|
+
stage: Stage;
|
|
10
|
+
}>>;
|
|
11
|
+
/** The stage the map gives a domestic status, if it knows the wording. */
|
|
12
|
+
export declare function sundarbanWordingStage(wording: string): Stage | undefined;
|
|
13
|
+
/** The stage the map gives an international status code, if it knows it. */
|
|
14
|
+
export declare function sundarbanCodeStage(code: string): Stage | undefined;
|
|
15
|
+
/** A status's stage: its code or wording, else the shared wording rules, else nothing. */
|
|
16
|
+
export declare function sundarbanStage(code: string | null, wording: string): ClassifiedWording | null;
|
|
17
|
+
/** International statuses carry their code, domestic ones only their wording. */
|
|
18
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The statuses a domestic CN goes through, as the tracking service words them.
|
|
5
|
+
* The page prints each as a Bengali sentence naming the branch; the two
|
|
6
|
+
* sentences without a branch are mapped too.
|
|
7
|
+
*/
|
|
8
|
+
const WORDINGS = new Map(Object.entries({
|
|
9
|
+
'Booking Complete': 'accepted',
|
|
10
|
+
// Sent on from a branch or hub towards the next one.
|
|
11
|
+
'Out for Destination': 'in_transit',
|
|
12
|
+
// Received at the next hub or at the delivering branch.
|
|
13
|
+
'Received by Dest.': 'in_transit',
|
|
14
|
+
'Out for Delivery': 'out_for_delivery',
|
|
15
|
+
// Ready for collection at the branch counter.
|
|
16
|
+
'Ready for C/D': 'ready_for_pickup',
|
|
17
|
+
'Delivered': 'delivered',
|
|
18
|
+
// Returned to the sender.
|
|
19
|
+
'Return': 'returned',
|
|
20
|
+
'Return at office': 'returned',
|
|
21
|
+
// The return has reached the sender.
|
|
22
|
+
'Return & Delivered': 'returned',
|
|
23
|
+
'Damage': 'exception',
|
|
24
|
+
'পণ্যটি ডেলিভারি সম্পন্ন হয়েছে।': 'delivered',
|
|
25
|
+
'আপনার পণ্যটি প্রেরকের কাছে রিটার্ন করা হয়েছে।': 'returned',
|
|
26
|
+
}).map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
|
|
27
|
+
/**
|
|
28
|
+
* The codes the service gives an international shipment's statuses, with the
|
|
29
|
+
* label the page shows for each.
|
|
30
|
+
*/
|
|
31
|
+
export const SUNDARBAN_CODES = {
|
|
32
|
+
export_processing: { label: 'Export Processing', stage: 'in_transit' },
|
|
33
|
+
export_customs_processing: { label: 'Export Customs Processing', stage: 'customs' },
|
|
34
|
+
shipment_in_transit: { label: 'Shipment In Transit', stage: 'in_transit' },
|
|
35
|
+
shipment_off_loaded: { label: 'Shipment Offloaded', stage: 'in_transit' },
|
|
36
|
+
shipment_delayed_in_transit: { label: 'Shipment Delayed In Transit', stage: 'in_transit' },
|
|
37
|
+
shipment_arrived_at_dest: { label: 'Shipment Arrived At Destination', stage: 'in_transit' },
|
|
38
|
+
customs_clearance_at_dest: { label: 'Customs Clearance At Destination', stage: 'customs' },
|
|
39
|
+
customs_clearance_delay: { label: 'Customs Clearance Delay', stage: 'customs' },
|
|
40
|
+
shipment_ready_for_delivery: { label: 'Shipment Ready For Delivery', stage: 'in_transit' },
|
|
41
|
+
shipment_delivered: { label: 'Shipment Delivered', stage: 'delivered' },
|
|
42
|
+
delivery_missed_rcpt_unavailable: { label: 'Delivery Missed Due To Recipient Unavailable', stage: 'failed_attempt' },
|
|
43
|
+
};
|
|
44
|
+
/** The stage the map gives a domestic status, if it knows the wording. */
|
|
45
|
+
export function sundarbanWordingStage(wording) {
|
|
46
|
+
return WORDINGS.get(normalizeStatusWording(wording));
|
|
47
|
+
}
|
|
48
|
+
/** The stage the map gives an international status code, if it knows it. */
|
|
49
|
+
export function sundarbanCodeStage(code) {
|
|
50
|
+
return Object.hasOwn(SUNDARBAN_CODES, code) ? SUNDARBAN_CODES[code].stage : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** A status's stage: its code or wording, else the shared wording rules, else nothing. */
|
|
53
|
+
export function sundarbanStage(code, wording) {
|
|
54
|
+
const mapped = code ? sundarbanCodeStage(code) : sundarbanWordingStage(wording);
|
|
55
|
+
if (mapped)
|
|
56
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
57
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
58
|
+
return worded.source === 'none' ? null : worded;
|
|
59
|
+
}
|
|
60
|
+
/** International statuses carry their code, domestic ones only their wording. */
|
|
61
|
+
export const statusMap = {
|
|
62
|
+
stage: (code, wording) => (code ? sundarbanCodeStage(code) : sundarbanWordingStage(wording)),
|
|
63
|
+
gaps: [],
|
|
64
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "sundarban-courier",
|
|
3
3
|
"entries": [
|
|
4
|
-
{"wording": "Booking Complete", "stage": "accepted", "language": "en", "confirmedBy": "
|
|
5
|
-
{"wording": "Out for Destination", "stage": "in_transit", "language": "en", "confirmedBy": "
|
|
6
|
-
{"wording": "Received by Dest.", "stage": "in_transit", "language": "en", "confirmedBy": "
|
|
7
|
-
{"wording": "Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "
|
|
4
|
+
{"wording": "Booking Complete", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Booked at the sending branch; the page shows it as a Bengali sentence naming the branch."},
|
|
5
|
+
{"wording": "Out for Destination", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Sent on from a branch or hub towards the next one; the page shows it as a Bengali sentence naming both."},
|
|
6
|
+
{"wording": "Received by Dest.", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Received at the next hub or the delivering branch; the page shows it as a Bengali sentence naming the branch."},
|
|
7
|
+
{"wording": "Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "The page shows it as a Bengali sentence naming the branch."},
|
|
8
8
|
{"wording": "Ready for C/D", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "official-doc", "note": "Status the tracking page's script expects: ready for collection at the branch counter."},
|
|
9
|
-
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "
|
|
9
|
+
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "The page shows it as a Bengali sentence."},
|
|
10
10
|
{"wording": "Return", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status the tracking page's script expects: returned to the sender."},
|
|
11
11
|
{"wording": "Return at office", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status the tracking page's script expects."},
|
|
12
12
|
{"wording": "Return & Delivered", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status the tracking page's script expects: the return has reached the sender."},
|
|
@@ -279,6 +279,7 @@ import { statusMap as spxVn } from '../../carriers/spx-vn/status.js';
|
|
|
279
279
|
import { statusMap as stallionExpress } from '../../carriers/stallion-express/status.js';
|
|
280
280
|
import { statusMap as starken } from '../../carriers/starken/status.js';
|
|
281
281
|
import { statusMap as sto } from '../../carriers/sto/status.js';
|
|
282
|
+
import { statusMap as sundarbanCourier } from '../../carriers/sundarban-courier/status.js';
|
|
282
283
|
import { statusMap as swiftx } from '../../carriers/swiftx/status.js';
|
|
283
284
|
import { statusMap as swissPost } from '../../carriers/swiss-post/status.js';
|
|
284
285
|
import { statusMap as tCat } from '../../carriers/t-cat/status.js';
|
|
@@ -369,7 +370,7 @@ export const STATUS_MAPS = {
|
|
|
369
370
|
sending, serpost, servientrega, sfc, shadowfax, shipentegra, 'skroutz-last-mile': skroutzLastMile, 'skynet-worldwide': skynetWorldwide, 'slovak-parcel-service': slovakParcelService,
|
|
370
371
|
'slovenska-posta': slovenskaPosta, 'smsa-express': smsaExpress, 'south-african-post-office': southAfricanPost,
|
|
371
372
|
'southeastern-freight-lines': southeasternFreightLines, 'sp-express': spExpress, speedaf, speedex, speedx, speedy, 'spx-br': spxBr, 'spx-id': spxId, 'spx-my': spxMy,
|
|
372
|
-
'spx-th': spxTh, 'spx-tw': spxTw, 'spx-vn': spxVn, 'stallion-express': stallionExpress, starken, sto, swiftx, 'swiss-post': swissPost,
|
|
373
|
+
'spx-th': spxTh, 'spx-tw': spxTw, 'spx-vn': spxVn, 'stallion-express': stallionExpress, starken, sto, 'sundarban-courier': sundarbanCourier, swiftx, 'swiss-post': swissPost,
|
|
373
374
|
't-cat': tCat, 't-group': tGroup, taxydema, 'tci-express': tciExpress, 'tcs-pakistan': tcsPakistan, tdn, tealca,
|
|
374
375
|
'team-global-express': teamGlobalExpress, teleship, 'tforce-freight': tforceFreight, 'thailand-post': thailandPost, thhy, tiki,
|
|
375
376
|
'tmm-express': tmmExpress, tnt, 'tnt-it': tntIt, toptrans, torrestir, 'total-express': totalExpress, transaher, transmission, tsjy,
|
package/dist/data/catalog.json
CHANGED
|
@@ -25879,7 +25879,8 @@
|
|
|
25879
25879
|
"timezone": "Asia/Dhaka",
|
|
25880
25880
|
"tracking": {
|
|
25881
25881
|
"mode": "automatic",
|
|
25882
|
-
"adapter": "
|
|
25882
|
+
"adapter": "sundarban-courier",
|
|
25883
|
+
"localClocks": true
|
|
25883
25884
|
},
|
|
25884
25885
|
"canaryUrl": "https://operation.sundarbancourierltd.com/",
|
|
25885
25886
|
"trackingUrlTemplate": "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}",
|
|
@@ -16501,7 +16501,8 @@ export declare const CARRIER_CATALOG: {
|
|
|
16501
16501
|
readonly timezone: "Asia/Dhaka";
|
|
16502
16502
|
readonly tracking: {
|
|
16503
16503
|
readonly mode: "automatic";
|
|
16504
|
-
readonly adapter: "
|
|
16504
|
+
readonly adapter: "sundarban-courier";
|
|
16505
|
+
readonly localClocks: true;
|
|
16505
16506
|
};
|
|
16506
16507
|
readonly canaryUrl: "https://operation.sundarbancourierltd.com/";
|
|
16507
16508
|
readonly trackingUrlTemplate: "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}";
|
|
@@ -25880,7 +25880,8 @@ export const CARRIER_CATALOG = {
|
|
|
25880
25880
|
"timezone": "Asia/Dhaka",
|
|
25881
25881
|
"tracking": {
|
|
25882
25882
|
"mode": "automatic",
|
|
25883
|
-
"adapter": "
|
|
25883
|
+
"adapter": "sundarban-courier",
|
|
25884
|
+
"localClocks": true
|
|
25884
25885
|
},
|
|
25885
25886
|
"canaryUrl": "https://operation.sundarbancourierltd.com/",
|
|
25886
25887
|
"trackingUrlTemplate": "https://operation.sundarbancourierltd.com/?cnnumber={trackingNumber}",
|
|
@@ -345,6 +345,7 @@ import { adapter as spxVn } from '../carriers/spx-vn/adapter.js';
|
|
|
345
345
|
import { adapter as stallionExpress } from '../carriers/stallion-express/adapter.js';
|
|
346
346
|
import { adapter as starken } from '../carriers/starken/adapter.js';
|
|
347
347
|
import { adapter as sto } from '../carriers/sto/adapter.js';
|
|
348
|
+
import { adapter as sundarbanCourier } from '../carriers/sundarban-courier/adapter.js';
|
|
348
349
|
import { adapter as sunyou } from '../carriers/sunyou/adapter.js';
|
|
349
350
|
import { adapter as swiftx } from '../carriers/swiftx/adapter.js';
|
|
350
351
|
import { adapter as swissPost } from '../carriers/swiss-post/adapter.js';
|
|
@@ -752,6 +753,7 @@ export const REGISTRY = {
|
|
|
752
753
|
"stallion-express": stallionExpress,
|
|
753
754
|
"starken": starken,
|
|
754
755
|
"sto": sto,
|
|
756
|
+
"sundarban-courier": sundarbanCourier,
|
|
755
757
|
"sunyou": sunyou,
|
|
756
758
|
"swiftx": swiftx,
|
|
757
759
|
"swiss-post": swissPost,
|
|
@@ -1439,7 +1441,7 @@ export const REGISTRY = {
|
|
|
1439
1441
|
"steadfast": "universal",
|
|
1440
1442
|
"sto": "sto",
|
|
1441
1443
|
"straightship": "universal",
|
|
1442
|
-
"sundarban-courier": "
|
|
1444
|
+
"sundarban-courier": "sundarban-courier",
|
|
1443
1445
|
"sunyou": "sunyou",
|
|
1444
1446
|
"surat-kargo": "universal",
|
|
1445
1447
|
"swiftx": "swiftx",
|
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.375.0-main.2020",
|
|
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",
|