universal-parcel-scraper 0.181.0 → 0.183.0-main.1321
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 +23 -5
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +50 -0
- package/dist/browser/scraper.js +21 -5
- package/dist/carriers/couriersplease/adapter.d.ts +17 -0
- package/dist/carriers/couriersplease/adapter.js +111 -0
- package/dist/carriers/couriersplease/carrier.json +51 -7
- package/dist/carriers/couriersplease/parser.d.ts +22 -0
- package/dist/carriers/couriersplease/parser.js +222 -0
- package/dist/carriers/couriersplease/status.d.ts +7 -0
- package/dist/carriers/couriersplease/status.js +96 -0
- package/dist/carriers/couriersplease/statuses.json +21 -18
- package/dist/carriers/gdex/adapter.d.ts +17 -0
- package/dist/carriers/gdex/adapter.js +58 -0
- package/dist/carriers/gdex/carrier.json +47 -7
- package/dist/carriers/gdex/numbers.json +38 -0
- package/dist/carriers/gdex/parser.d.ts +15 -0
- package/dist/carriers/gdex/parser.js +149 -0
- package/dist/carriers/gdex/status.d.ts +7 -0
- package/dist/carriers/gdex/status.js +41 -0
- package/dist/carriers/gdex/statuses.json +12 -3
- package/dist/carriers/leopards-courier/adapter.d.ts +13 -0
- package/dist/carriers/leopards-courier/adapter.js +73 -0
- package/dist/carriers/leopards-courier/carrier.json +52 -7
- package/dist/carriers/leopards-courier/numbers.json +6 -8
- package/dist/carriers/leopards-courier/parser.d.ts +26 -0
- package/dist/carriers/leopards-courier/parser.js +190 -0
- package/dist/carriers/leopards-courier/status.d.ts +7 -0
- package/dist/carriers/leopards-courier/status.js +34 -0
- package/dist/carriers/leopards-courier/statuses.json +4 -1
- package/dist/carriers/pos-indonesia/adapter.d.ts +14 -0
- package/dist/carriers/pos-indonesia/adapter.js +74 -0
- package/dist/carriers/pos-indonesia/carrier.json +53 -7
- package/dist/carriers/pos-indonesia/numbers.json +52 -14
- package/dist/carriers/pos-indonesia/parser.d.ts +25 -0
- package/dist/carriers/pos-indonesia/parser.js +201 -0
- package/dist/carriers/pos-indonesia/status.d.ts +12 -0
- package/dist/carriers/pos-indonesia/status.js +41 -0
- package/dist/carriers/pos-indonesia/statuses.json +7 -0
- package/dist/core/catalog/statusMaps.js +12 -5
- package/dist/data/catalog.json +23 -5
- package/dist/generated/catalog.d.ts +17 -5
- package/dist/generated/catalog.js +23 -5
- package/dist/generated/recognition.js +4 -0
- package/dist/generated/registry.js +12 -4
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { firstOccurrence } from '../../core/time/index.js';
|
|
7
|
+
import { clean } from '../../core/transport/index.js';
|
|
8
|
+
import { isRecord } from '../../core/types.js';
|
|
9
|
+
import { leopardsStage } from './status.js';
|
|
10
|
+
export const PROVIDER = 'Leopards';
|
|
11
|
+
// A results page is about 120 kB.
|
|
12
|
+
export const LEOPARDS_MAX_BYTES = 1_000_000;
|
|
13
|
+
export const LEOPARDS_ORIGIN = 'https://pk.leopardscourier.com';
|
|
14
|
+
/** The lookup the tracking box sends the number to. */
|
|
15
|
+
export const LEOPARDS_LOOKUP = `${LEOPARDS_ORIGIN}/shipment_tracking-new`;
|
|
16
|
+
/** The page that shows the consignment the lookup kept in the session. */
|
|
17
|
+
export const LEOPARDS_VIEW = `${LEOPARDS_ORIGIN}/shipment_tracking_view`;
|
|
18
|
+
// Two letters and nine or ten digits. The tracker pads nine digits with a leading zero.
|
|
19
|
+
const NUMBER = /^([A-Z]{2})(\d{9,10})$/;
|
|
20
|
+
const ZONE = 'Asia/Karachi';
|
|
21
|
+
// The date and the minute, as "22 September, 2026" and "(09:26)".
|
|
22
|
+
const CLOCK_FORMAT = 'd MMMM, yyyy HH:mm';
|
|
23
|
+
const DATE = /^\d{1,2} [A-Z][a-z]+, \d{4}$/;
|
|
24
|
+
const MINUTE = /^\((\d{2}:\d{2})\)$/;
|
|
25
|
+
const CONSIGNMENT = /^Consignment No\.\s*:\s*([A-Z0-9]{1,20})$/;
|
|
26
|
+
const NOT_FOUND = /^Your query about "([^"]{0,64})" appeared to be invalid \/ record not found\./;
|
|
27
|
+
// A wording that ends with the city it happened in, as "Inter Transfer at KARACHI".
|
|
28
|
+
const PLACED = /^(.*\S)\s+(?:at|in)\s+([A-Z][A-Z .'()/-]*[A-Z.)])$/;
|
|
29
|
+
// Bot checks and block pages a CDN or firewall could put in place of the site's.
|
|
30
|
+
const BLOCKED = /_cf_chl_opt|cf-turnstile|g-recaptcha|hcaptcha|_Incapsula_Resource|AwsWafIntegration|awswaf\.com|<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected|403 Forbidden)/i;
|
|
31
|
+
const MAX_EVENTS = 300;
|
|
32
|
+
/** A Leopards consignment number: two letters and ten digits, nine digits taking a leading zero as the tracker does. */
|
|
33
|
+
export function normalizeLeopardsNumber(raw) {
|
|
34
|
+
const match = NUMBER.exec(raw.length <= 64 ? normalizeTrackingNumber(raw) : '');
|
|
35
|
+
if (!match)
|
|
36
|
+
throw new InvalidInputError(PROVIDER, 'Leopards consignment numbers are two letters and nine or ten digits');
|
|
37
|
+
return `${match[1]}${match[2].padStart(10, '0')}`;
|
|
38
|
+
}
|
|
39
|
+
/** Whether a page is a firewall's or bot check's instead of the site's. */
|
|
40
|
+
export function isLeopardsBlockPage(html) {
|
|
41
|
+
return BLOCKED.test(html.slice(0, 64_000));
|
|
42
|
+
}
|
|
43
|
+
/** The site's notice for a number it has no record of, and the number it names. */
|
|
44
|
+
function notFoundNotice($) {
|
|
45
|
+
for (const cell of $('td').toArray()) {
|
|
46
|
+
const named = NOT_FOUND.exec(clean($(cell).text(), 400))?.[1];
|
|
47
|
+
if (named !== undefined)
|
|
48
|
+
return named;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The lookup's reply: `{"success":true}` once the site has kept the
|
|
54
|
+
* consignment in the session, or the tracking page with its notice for a
|
|
55
|
+
* number it has no record of.
|
|
56
|
+
*/
|
|
57
|
+
export function readLeopardsLookup(body, raw) {
|
|
58
|
+
const number = normalizeLeopardsNumber(raw);
|
|
59
|
+
if (/^\s*[{[]/.test(body)) {
|
|
60
|
+
let reply;
|
|
61
|
+
try {
|
|
62
|
+
reply = JSON.parse(body);
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
throw new SchemaError(PROVIDER, 'Leopards returned a reply that is not JSON', { cause });
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(reply) || typeof reply.success !== 'boolean')
|
|
68
|
+
throw new SchemaError(PROVIDER, 'Leopards returned an invalid lookup reply');
|
|
69
|
+
// The tracking box then opens the page with the site's notice; the reply says nothing about the parcel.
|
|
70
|
+
if (!reply.success)
|
|
71
|
+
throw new IndeterminateError(PROVIDER, 'Leopards did not complete the lookup');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const named = notFoundNotice(load(body));
|
|
75
|
+
if (named === undefined) {
|
|
76
|
+
if (isLeopardsBlockPage(body))
|
|
77
|
+
throw new ChallengeError(PROVIDER, 'Leopards answered with a block page');
|
|
78
|
+
throw new SchemaError(PROVIDER, 'Leopards did not answer the lookup');
|
|
79
|
+
}
|
|
80
|
+
if (named !== number)
|
|
81
|
+
throw new IndeterminateError(PROVIDER, 'Leopards answered for another consignment');
|
|
82
|
+
throw new NotFoundError(PROVIDER);
|
|
83
|
+
}
|
|
84
|
+
/** A wall clock to the minute, read in Karachi time: Leopards' scans are made in Pakistan, which keeps one offset. */
|
|
85
|
+
function clock(date, minute) {
|
|
86
|
+
const written = `${date} ${MINUTE.exec(minute)?.[1] ?? ''}`;
|
|
87
|
+
// UTC only checks the calendar fields. Luxon rolls 24:00 over to the next day.
|
|
88
|
+
const checked = DateTime.fromFormat(written, CLOCK_FORMAT, { zone: 'UTC', locale: 'en-US' });
|
|
89
|
+
// The day may be written with a leading zero.
|
|
90
|
+
if (!DATE.test(date) || !checked.isValid || ![CLOCK_FORMAT, `d${CLOCK_FORMAT}`].some(format => checked.toFormat(format, { locale: 'en-US' }) === written)) {
|
|
91
|
+
throw new SchemaError(PROVIDER, 'Leopards returned an invalid time');
|
|
92
|
+
}
|
|
93
|
+
const zoned = firstOccurrence(DateTime.fromISO(checked.toFormat("yyyy-MM-dd'T'HH:mm"), { zone: ZONE }));
|
|
94
|
+
return { time: zoned.toISO({ suppressMilliseconds: true }), at: zoned.toMillis() };
|
|
95
|
+
}
|
|
96
|
+
/** Who signed for the parcel, as the page's "Signed for by" line names them before a dash. */
|
|
97
|
+
function signatoryOf($) {
|
|
98
|
+
const label = $('td').filter((_, cell) => clean($(cell).text(), 40) === 'Signed for by :').first();
|
|
99
|
+
return clean(label.next('td').text(), 200).replace(/\s*-(?:\s.*)?$/, '');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A wording without who took the parcel. A delivery reads "Delivered at
|
|
103
|
+
* LAHORE NAME ~ RELATION": the name is cut when it is the signatory's, else
|
|
104
|
+
* only the wording before the city is kept, or its first word without a city.
|
|
105
|
+
*/
|
|
106
|
+
function withoutReceiver(wording, signatory) {
|
|
107
|
+
const tilde = wording.indexOf('~');
|
|
108
|
+
if (tilde < 0)
|
|
109
|
+
return wording;
|
|
110
|
+
const head = wording.slice(0, tilde).trim();
|
|
111
|
+
if (signatory && head.toUpperCase().endsWith(` ${signatory.toUpperCase()}`)) {
|
|
112
|
+
const rest = head.slice(0, head.length - signatory.length).trim();
|
|
113
|
+
if (PLACED.test(rest))
|
|
114
|
+
return rest;
|
|
115
|
+
}
|
|
116
|
+
const parts = head.split(/\s+(?:at|in)\s+/);
|
|
117
|
+
return parts.length > 1 ? parts[0].trim() : head.split(' ')[0];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The tracking page for the consignment the lookup kept in the session: its
|
|
121
|
+
* number, a delivery box naming who signed, the booking details and its scans
|
|
122
|
+
* newest first, each with a date, a minute and Leopards' wording. The booking
|
|
123
|
+
* details, the signatory and the receiver named in a delivery's wording are
|
|
124
|
+
* not read.
|
|
125
|
+
*/
|
|
126
|
+
export function parseLeopards(html, raw) {
|
|
127
|
+
const number = normalizeLeopardsNumber(raw);
|
|
128
|
+
const $ = load(html);
|
|
129
|
+
const named = $('td').map((_, cell) => CONSIGNMENT.exec(clean($(cell).text(), 200))?.[1]).get();
|
|
130
|
+
if (!named.length) {
|
|
131
|
+
// The page without a consignment in the session names none: the session was not kept.
|
|
132
|
+
const notice = notFoundNotice($);
|
|
133
|
+
if (notice !== undefined)
|
|
134
|
+
throw new IndeterminateError(PROVIDER, 'Leopards did not keep the lookup in the session', { reason: 'session_rejected' });
|
|
135
|
+
if (isLeopardsBlockPage(html))
|
|
136
|
+
throw new ChallengeError(PROVIDER, 'Leopards answered with a block page');
|
|
137
|
+
throw new SchemaError(PROVIDER, 'Leopards did not return its tracking page');
|
|
138
|
+
}
|
|
139
|
+
if (named.length !== 1)
|
|
140
|
+
throw new SchemaError(PROVIDER, 'Leopards returned several consignments');
|
|
141
|
+
if (named[0] !== number)
|
|
142
|
+
throw new IndeterminateError(PROVIDER, 'Leopards answered for another consignment');
|
|
143
|
+
const items = $('.tracking-list .tracking-item');
|
|
144
|
+
if (items.length > MAX_EVENTS)
|
|
145
|
+
throw new SchemaError(PROVIDER, 'Leopards returned an invalid history');
|
|
146
|
+
const signatory = signatoryOf($);
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const scans = [];
|
|
149
|
+
items.each((order, node) => {
|
|
150
|
+
const item = $(node);
|
|
151
|
+
const date = item.find('.tracking-date');
|
|
152
|
+
const content = item.find('.tracking-content');
|
|
153
|
+
if (date.length !== 1 || content.length !== 1)
|
|
154
|
+
throw new SchemaError(PROVIDER, 'Leopards returned an invalid scan');
|
|
155
|
+
const minute = clean(date.find('span').text(), 16);
|
|
156
|
+
const when = clock(clean(date.clone().children().remove().end().text(), 40), minute);
|
|
157
|
+
const description = withoutReceiver(clean(content.text(), 300), signatory);
|
|
158
|
+
if (!description)
|
|
159
|
+
throw new SchemaError(PROVIDER, 'Leopards returned a scan without wording');
|
|
160
|
+
const location = PLACED.exec(description)?.[2];
|
|
161
|
+
const staged = leopardsStage(description);
|
|
162
|
+
const event = {
|
|
163
|
+
time: when.time, description, ...(location ? { location } : {}),
|
|
164
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
165
|
+
};
|
|
166
|
+
const identity = JSON.stringify([event.time, description]);
|
|
167
|
+
if (seen.has(identity))
|
|
168
|
+
return;
|
|
169
|
+
seen.add(identity);
|
|
170
|
+
scans.push({ event, at: when.at, order });
|
|
171
|
+
});
|
|
172
|
+
if (!scans.length)
|
|
173
|
+
throw new NoHistoryError(PROVIDER);
|
|
174
|
+
// Newest first; scans at the same minute keep the page's order, which is newest first.
|
|
175
|
+
const events = scans.sort((a, b) => b.at - a.at || a.order - b.order).map(({ event }) => event);
|
|
176
|
+
const newest = events[0];
|
|
177
|
+
const current = events.find(event => event.stage);
|
|
178
|
+
const stage = current?.stage;
|
|
179
|
+
const delivered = stage === 'delivered' ? current : undefined;
|
|
180
|
+
return {
|
|
181
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
182
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
183
|
+
last_status_text: newest.description,
|
|
184
|
+
last_update: newest.time,
|
|
185
|
+
expected_delivery: null,
|
|
186
|
+
...(delivered ? { delivered_at: delivered.time } : {}),
|
|
187
|
+
...(normalizeTrackingNumber(raw) !== number ? { canonical_tracking_number: number } : {}),
|
|
188
|
+
events,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -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 a Leopards wording starts with, read case-insensitively. */
|
|
4
|
+
export declare function leopardsWordingStage(wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: its wording, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function leopardsStage(wording: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Leopards' wordings, read by how they start: the city after "at" or "in" and
|
|
4
|
+
* a pending delivery's reason vary. The first three come from its older COD
|
|
5
|
+
* tracker.
|
|
6
|
+
*/
|
|
7
|
+
const ACTIONS = [
|
|
8
|
+
[/^posted for consignment booking\b/, 'registered'],
|
|
9
|
+
[/^shipment picked\b/, 'accepted'],
|
|
10
|
+
// Handed to a courier for delivery. The page writes "Leopards" where the courier's name would be.
|
|
11
|
+
[/^assign to courier\b/, 'out_for_delivery'],
|
|
12
|
+
[/^assigned to leopards\b/, 'out_for_delivery'],
|
|
13
|
+
[/^inter transfer\b/, 'in_transit'],
|
|
14
|
+
// The delivery waits, with the reason in capitals, as "Pending WRONG/INCORRECT ADDRESS".
|
|
15
|
+
[/^pending\b/, 'failed_attempt'],
|
|
16
|
+
[/^delivered\b/, 'delivered'],
|
|
17
|
+
];
|
|
18
|
+
/** The stage a Leopards wording starts with, read case-insensitively. */
|
|
19
|
+
export function leopardsWordingStage(wording) {
|
|
20
|
+
const text = wording.toLowerCase().trim().split(/\s+/).join(' ');
|
|
21
|
+
return ACTIONS.find(([pattern]) => pattern.test(text))?.[1];
|
|
22
|
+
}
|
|
23
|
+
/** A scan's stage: its wording, else the shared wording rules, else nothing. */
|
|
24
|
+
export function leopardsStage(wording) {
|
|
25
|
+
const mapped = leopardsWordingStage(wording);
|
|
26
|
+
if (mapped)
|
|
27
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
28
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
29
|
+
return worded.source === 'none' ? null : worded;
|
|
30
|
+
}
|
|
31
|
+
export const statusMap = {
|
|
32
|
+
stage: (_code, wording) => leopardsWordingStage(wording),
|
|
33
|
+
gaps: [],
|
|
34
|
+
};
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
{"wording": "Shipment Picked in ISLAMABAD", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "In an archived page of Leopards' COD tracker on leopardscod.com. The city is the pickup city."},
|
|
6
6
|
{"wording": "Inter Transfer", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "In an archived page of Leopards' COD tracker on leopardscod.com."},
|
|
7
7
|
{"wording": "Pending WRONG/INCORRECT ADDRESS", "stage": "failed_attempt", "language": "en", "confirmedBy": "portal", "note": "In an archived page of Leopards' COD tracker on leopardscod.com. Delivery is pending because of the address."},
|
|
8
|
-
{"wording": "Assign to Courier", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "In an archived page of Leopards' COD tracker on leopardscod.com. Handed to a courier for delivery."}
|
|
8
|
+
{"wording": "Assign to Courier", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "In an archived page of Leopards' COD tracker on leopardscod.com. Handed to a courier for delivery."},
|
|
9
|
+
{"wording": "Inter Transfer at KARACHI", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "On Leopards' tracking page. Sent on between its cities; the city is where it left."},
|
|
10
|
+
{"wording": "Assigned to Leopards in LAHORE", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "On Leopards' tracking page, a few hours before the delivery in the same city. Handed to a courier, whom the page names as Leopards."},
|
|
11
|
+
{"wording": "Delivered at LAHORE", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "On Leopards' tracking page, followed by who took the parcel and their relation to the consignee, which the adapter drops."}
|
|
9
12
|
]
|
|
10
13
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
export declare const POS_INDONESIA_ENDPOINT = "https://jembatan.posindonesia.co.id/utility/2.0.0/tracking/";
|
|
5
|
+
export declare class PosIndonesiaTracker {
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(options?: {
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
recorder?: StepRecorder;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
});
|
|
12
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, IndeterminateError, TransportError, UpstreamHttpError } 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 { isPosIndonesiaNotFound, normalizePosIndonesiaNumber, parsePosIndonesia, posIndonesiaPayload, POS_INDONESIA_MAX_BYTES, PROVIDER } from './parser.js';
|
|
8
|
+
// The service Pos Indonesia's mobile app tracks items with. The tracking page
|
|
9
|
+
// at posindonesia.co.id asks it through a server action that checks a
|
|
10
|
+
// Cloudflare Turnstile token; the app sends its own key, the same for every
|
|
11
|
+
// install, and no token.
|
|
12
|
+
export const POS_INDONESIA_ENDPOINT = 'https://jembatan.posindonesia.co.id/utility/2.0.0/tracking/';
|
|
13
|
+
const APP_KEY = 'CpFzVJWl7MgFLWT8wL8Aqz1Jvo3zyytF'; // gitleaks:allow
|
|
14
|
+
// A firewall or server error page instead of the service's JSON.
|
|
15
|
+
const BLOCKED = /<html|<!doctype|akses diblokir|access denied|forbidden|just a moment/i;
|
|
16
|
+
export class PosIndonesiaTracker {
|
|
17
|
+
options;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
21
|
+
async fetch(raw, context = {}) {
|
|
22
|
+
const number = normalizePosIndonesiaNumber(raw);
|
|
23
|
+
const read = async (signal, timeoutMs) => {
|
|
24
|
+
let reply;
|
|
25
|
+
try {
|
|
26
|
+
reply = await fetchBounded(`${POS_INDONESIA_ENDPOINT}${encodeURIComponent(number)}`, { method: 'GET', signal,
|
|
27
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-API-KEY': APP_KEY,
|
|
28
|
+
'User-Agent': userAgentOf(this.options.userAgent) } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: POS_INDONESIA_MAX_BYTES,
|
|
29
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [400, 401] });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// An unknown number has its own notice; a missing route is not a missing item.
|
|
33
|
+
if (error instanceof UpstreamHttpError && [404, 410].includes(error.status)) {
|
|
34
|
+
throw new TransportError(PROVIDER, 'Pos Indonesia tracking service is unavailable', { cause: error });
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const { response, bytes } = reply;
|
|
39
|
+
if (response.status === 401)
|
|
40
|
+
throw new ChallengeError(PROVIDER, 'Pos Indonesia refused the app’s key', { status: 401 });
|
|
41
|
+
const body = decodeText(bytes);
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = posIndonesiaPayload(body);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (BLOCKED.test(body.slice(0, 64_000))) {
|
|
48
|
+
throw new ChallengeError(PROVIDER, 'Pos Indonesia answered with a block page', { status: response.status, cause: error });
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
// HTTP 400 carries the not-found notice, and nothing else is read from it.
|
|
53
|
+
if (response.status === 400 && !isPosIndonesiaNotFound(payload)) {
|
|
54
|
+
throw new IndeterminateError(PROVIDER, 'Pos Indonesia refused the lookup', { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
return parsePosIndonesia(payload, number);
|
|
57
|
+
};
|
|
58
|
+
// A request that fails to reach the service, or hangs, gets one more try:
|
|
59
|
+
// the first gets half of the budget, so the retry fits in the rest.
|
|
60
|
+
return runSteps({ carrier: 'pos-indonesia', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
61
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
62
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
63
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export const adapter = (environment) => {
|
|
68
|
+
const tracker = new PosIndonesiaTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
69
|
+
return {
|
|
70
|
+
id: 'pos-indonesia', recordsSteps: true, steps: ['direct', 'retry'],
|
|
71
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
72
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizePosIndonesiaNumber(number))),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -20,18 +20,64 @@
|
|
|
20
20
|
"timezone": "Asia/Jakarta",
|
|
21
21
|
"tracking": {
|
|
22
22
|
"mode": "automatic",
|
|
23
|
-
"adapter": "
|
|
24
|
-
"steps": [
|
|
23
|
+
"adapter": "pos-indonesia",
|
|
24
|
+
"steps": [
|
|
25
|
+
"direct",
|
|
26
|
+
"retry"
|
|
27
|
+
],
|
|
28
|
+
"recognition": {
|
|
29
|
+
"rank": 964
|
|
30
|
+
},
|
|
31
|
+
"localClocks": true
|
|
25
32
|
},
|
|
26
|
-
"capabilities": [
|
|
33
|
+
"capabilities": [
|
|
34
|
+
"history",
|
|
35
|
+
"location",
|
|
36
|
+
"provider_code",
|
|
37
|
+
"weight",
|
|
38
|
+
"destination_country"
|
|
39
|
+
],
|
|
27
40
|
"portal": {
|
|
28
41
|
"url": "https://www.posindonesia.co.id/id/tracking/{trackingNumber}",
|
|
29
42
|
"searchUrl": "https://www.posindonesia.co.id/id/tracking",
|
|
30
43
|
"canaryUrl": "https://www.posindonesia.co.id/id/tracking",
|
|
31
|
-
"shows": [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
"shows": [
|
|
45
|
+
"status",
|
|
46
|
+
"history",
|
|
47
|
+
"location",
|
|
48
|
+
"service_name",
|
|
49
|
+
"sender_name",
|
|
50
|
+
"recipient_name",
|
|
51
|
+
"recipient_address",
|
|
52
|
+
"staff_details",
|
|
53
|
+
"signatory",
|
|
54
|
+
"postcodes",
|
|
55
|
+
"payment_details"
|
|
56
|
+
],
|
|
57
|
+
"scraped": [
|
|
58
|
+
"status",
|
|
59
|
+
"history",
|
|
60
|
+
"location",
|
|
61
|
+
"provider_code",
|
|
62
|
+
"weight",
|
|
63
|
+
"destination_country"
|
|
64
|
+
],
|
|
65
|
+
"discarded": [
|
|
66
|
+
"service_name",
|
|
67
|
+
"sender_name",
|
|
68
|
+
"recipient_name",
|
|
69
|
+
"recipient_address",
|
|
70
|
+
"staff_details",
|
|
71
|
+
"signatory",
|
|
72
|
+
"postcodes",
|
|
73
|
+
"payment_details"
|
|
74
|
+
],
|
|
75
|
+
"unavailable": [
|
|
76
|
+
"eta",
|
|
77
|
+
"offset_timestamps",
|
|
78
|
+
"pickup_point",
|
|
79
|
+
"dimensions"
|
|
80
|
+
]
|
|
35
81
|
},
|
|
36
82
|
"links": [
|
|
37
83
|
{
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"access": "full_page"
|
|
13
13
|
},
|
|
14
14
|
"context": {
|
|
15
|
-
"assessment": "
|
|
15
|
+
"assessment": "confirmed"
|
|
16
16
|
},
|
|
17
17
|
"expect": {
|
|
18
18
|
"carrier": "unknown",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"pos-indonesia"
|
|
22
22
|
]
|
|
23
23
|
},
|
|
24
|
-
"note": "Pos Indonesia's reply to a Media Konsumen letter about a parcel to the Sula islands gives it as the barcode."
|
|
24
|
+
"note": "Pos Indonesia's reply to a Media Konsumen letter about a parcel to the Sula islands gives it as the barcode; Pos Indonesia's app service finds the item."
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"number": "P2312190008251",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "full_page"
|
|
35
35
|
},
|
|
36
36
|
"context": {
|
|
37
|
-
"assessment": "
|
|
37
|
+
"assessment": "confirmed"
|
|
38
38
|
},
|
|
39
39
|
"expect": {
|
|
40
40
|
"carrier": "unknown",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"pos-indonesia"
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
|
-
"note": "A Media Konsumen letter about a letter sent with Pos Indonesia gives it as the resi."
|
|
46
|
+
"note": "A Media Konsumen letter about a letter sent with Pos Indonesia gives it as the resi; Pos Indonesia's app service finds the item."
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"number": "15941320968",
|
|
@@ -134,13 +134,13 @@
|
|
|
134
134
|
"access": "full_page"
|
|
135
135
|
},
|
|
136
136
|
"context": {
|
|
137
|
-
"assessment": "
|
|
137
|
+
"assessment": "confirmed"
|
|
138
138
|
},
|
|
139
139
|
"expect": {
|
|
140
140
|
"carrier": "intl-post",
|
|
141
141
|
"confidence": "high"
|
|
142
142
|
},
|
|
143
|
-
"note": "Pos Indonesia's reply to a Media Konsumen letter about an item to China gives it as the barcode."
|
|
143
|
+
"note": "Pos Indonesia's reply to a Media Konsumen letter about an item to China gives it as the barcode; Pos Indonesia's app service finds the item."
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
"number": "P2208030060194",
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"access": "indexed_excerpt"
|
|
154
154
|
},
|
|
155
155
|
"context": {
|
|
156
|
-
"assessment": "
|
|
156
|
+
"assessment": "confirmed"
|
|
157
157
|
},
|
|
158
158
|
"expect": {
|
|
159
159
|
"carrier": "unknown",
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
"pos-indonesia"
|
|
163
163
|
]
|
|
164
164
|
},
|
|
165
|
-
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened."
|
|
165
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened. Pos Indonesia's app service finds the item."
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
168
|
"number": "P2407110206496",
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
"access": "indexed_excerpt"
|
|
176
176
|
},
|
|
177
177
|
"context": {
|
|
178
|
-
"assessment": "
|
|
178
|
+
"assessment": "confirmed"
|
|
179
179
|
},
|
|
180
180
|
"expect": {
|
|
181
181
|
"carrier": "unknown",
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
"pos-indonesia"
|
|
185
185
|
]
|
|
186
186
|
},
|
|
187
|
-
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened."
|
|
187
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened. Pos Indonesia's app service finds the item."
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
"number": "p2504170015857",
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
"access": "indexed_excerpt"
|
|
198
198
|
},
|
|
199
199
|
"context": {
|
|
200
|
-
"assessment": "
|
|
200
|
+
"assessment": "confirmed"
|
|
201
201
|
},
|
|
202
202
|
"expect": {
|
|
203
203
|
"carrier": "unknown",
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"pos-indonesia"
|
|
207
207
|
]
|
|
208
208
|
},
|
|
209
|
-
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened. Kept in the lowercase form the lookup used."
|
|
209
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened. Pos Indonesia's app service finds the item. Kept in the lowercase form the lookup used."
|
|
210
210
|
},
|
|
211
211
|
{
|
|
212
212
|
"number": "RR046768837ID",
|
|
@@ -219,13 +219,13 @@
|
|
|
219
219
|
"access": "indexed_excerpt"
|
|
220
220
|
},
|
|
221
221
|
"context": {
|
|
222
|
-
"assessment": "
|
|
222
|
+
"assessment": "confirmed"
|
|
223
223
|
},
|
|
224
224
|
"expect": {
|
|
225
225
|
"carrier": "intl-post",
|
|
226
226
|
"confidence": "high"
|
|
227
227
|
},
|
|
228
|
-
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened."
|
|
228
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened. Pos Indonesia's app service finds the item."
|
|
229
229
|
},
|
|
230
230
|
{
|
|
231
231
|
"number": "CC014356389ID",
|
|
@@ -290,6 +290,44 @@
|
|
|
290
290
|
]
|
|
291
291
|
},
|
|
292
292
|
"note": "An archived lookup on Pos Indonesia's own tracking page passes it as the barcode; the capture was not opened."
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"number": "LP107221675JP",
|
|
296
|
+
"role": "shipment",
|
|
297
|
+
"evidence": "public_shipment_report",
|
|
298
|
+
"source": {
|
|
299
|
+
"url": "https://web.archive.org/web/20261005043931/https://www.posindonesia.co.id/id/tracking/LP107221675JP",
|
|
300
|
+
"date": "2026-10-05",
|
|
301
|
+
"dateKind": "capture",
|
|
302
|
+
"access": "indexed_excerpt"
|
|
303
|
+
},
|
|
304
|
+
"context": {
|
|
305
|
+
"assessment": "confirmed"
|
|
306
|
+
},
|
|
307
|
+
"expect": {
|
|
308
|
+
"carrier": "japan-post",
|
|
309
|
+
"confidence": "high"
|
|
310
|
+
},
|
|
311
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes this item from Japan as the barcode; the capture was not opened. Pos Indonesia's app service finds its delivery in Indonesia."
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"number": "EE205187475BN",
|
|
315
|
+
"role": "shipment",
|
|
316
|
+
"evidence": "public_shipment_report",
|
|
317
|
+
"source": {
|
|
318
|
+
"url": "https://web.archive.org/web/20260123134244/https://www.posindonesia.co.id/en/tracking/EE205187475BN",
|
|
319
|
+
"date": "2026-01-23",
|
|
320
|
+
"dateKind": "capture",
|
|
321
|
+
"access": "indexed_excerpt"
|
|
322
|
+
},
|
|
323
|
+
"context": {
|
|
324
|
+
"assessment": "confirmed"
|
|
325
|
+
},
|
|
326
|
+
"expect": {
|
|
327
|
+
"carrier": "intl-post",
|
|
328
|
+
"confidence": "high"
|
|
329
|
+
},
|
|
330
|
+
"note": "An archived lookup on Pos Indonesia's own tracking page passes this item from Brunei as the barcode; the capture was not opened. Pos Indonesia's app service finds its delivery in Indonesia."
|
|
293
331
|
}
|
|
294
332
|
]
|
|
295
333
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Pos Indonesia";
|
|
3
|
+
export declare const POS_INDONESIA_MAX_BYTES = 512000;
|
|
4
|
+
/** A number for Pos Indonesia's service: a waybill, an older domestic barcode or an S10 item code, Indonesian or inbound. */
|
|
5
|
+
export declare function normalizePosIndonesiaNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The service's reply as JSON. For items it holds no waybill for, inbound
|
|
8
|
+
* items among them, it fills its template with nothing for numeric fields,
|
|
9
|
+
* which is not JSON; those fields are read as null.
|
|
10
|
+
*/
|
|
11
|
+
export declare function posIndonesiaPayload(text: string): unknown;
|
|
12
|
+
/** The not-found notice; the service sends it with HTTP 400. */
|
|
13
|
+
export declare function isPosIndonesiaNotFound(payload: unknown): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A scan's wording without the staff member who scanned it, the recipient a
|
|
16
|
+
* delivery names, phone numbers and office codes, which carry postcodes.
|
|
17
|
+
*/
|
|
18
|
+
export declare function posIndonesiaWording(content: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* The service's reply for one number: the waybill, then its history. The
|
|
21
|
+
* waybill names the number it was made for, and is empty for an item the
|
|
22
|
+
* service holds no waybill for; every scan names the item's number. Each must
|
|
23
|
+
* be the whole requested number: the service also answers for a booking code.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parsePosIndonesia(payload: unknown, raw: string): CarrierResult;
|