universal-parcel-scraper 0.495.1 → 0.496.0-main.2375
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/hovership/adapter.d.ts +17 -0
- package/dist/carriers/hovership/adapter.js +53 -0
- package/dist/carriers/hovership/carrier.json +57 -7
- package/dist/carriers/hovership/parser.d.ts +19 -0
- package/dist/carriers/hovership/parser.js +196 -0
- package/dist/carriers/hovership/status.d.ts +9 -0
- package/dist/carriers/hovership/status.js +44 -0
- package/dist/carriers/hovership/statuses.json +9 -7
- package/dist/core/catalog/statusMaps.js +4 -2
- 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 **540 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>772 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 15,
|
|
158
|
+
The carrier folders record 15,413 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
|
@@ -16054,7 +16054,9 @@
|
|
|
16054
16054
|
"timezone": "America/Los_Angeles",
|
|
16055
16055
|
"tracking": {
|
|
16056
16056
|
"mode": "automatic",
|
|
16057
|
-
"adapter": "
|
|
16057
|
+
"adapter": "hovership",
|
|
16058
|
+
"recognitionRank": 687,
|
|
16059
|
+
"localClocks": true
|
|
16058
16060
|
},
|
|
16059
16061
|
"canaryUrl": "https://www.hovership.com/track/",
|
|
16060
16062
|
"trackingUrlTemplate": "https://www.hovership.com/track/{trackingNumber}/",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -12333,7 +12333,9 @@ var Ue = {
|
|
|
12333
12333
|
timezone: "America/Los_Angeles",
|
|
12334
12334
|
tracking: {
|
|
12335
12335
|
mode: "automatic",
|
|
12336
|
-
adapter: "
|
|
12336
|
+
adapter: "hovership",
|
|
12337
|
+
recognitionRank: 687,
|
|
12338
|
+
localClocks: !0
|
|
12337
12339
|
},
|
|
12338
12340
|
canaryUrl: "https://www.hovership.com/track/",
|
|
12339
12341
|
trackingUrlTemplate: "https://www.hovership.com/track/{trackingNumber}/",
|
|
@@ -29686,6 +29688,7 @@ var Tl = {
|
|
|
29686
29688
|
"hermes-at": 876,
|
|
29687
29689
|
"hermes-de": 840,
|
|
29688
29690
|
"houng-ah-loun": 637,
|
|
29691
|
+
hovership: 687,
|
|
29689
29692
|
"hr-parcel": 887,
|
|
29690
29693
|
"hrvatska-posta": 160,
|
|
29691
29694
|
hywl: 993,
|
|
@@ -0,0 +1,17 @@
|
|
|
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.hovership.com; one parcel's page is its number under it. */
|
|
5
|
+
export declare const HOVERSHIP_TRACK = "https://www.hovership.com/track/";
|
|
6
|
+
/** The page the tracking form opens for one whole number. */
|
|
7
|
+
export declare function hovershipTrackUrl(number: string): string;
|
|
8
|
+
export declare class HovershipTracker {
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(options?: {
|
|
11
|
+
fetcher?: typeof fetch;
|
|
12
|
+
recorder?: StepRecorder;
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
});
|
|
15
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { NotFoundError, 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 { CARRIER, HOVERSHIP_MAX_BYTES, isHovershipNotFound, normalizeHovershipNumber, parseHovership, PROVIDER } from './parser.js';
|
|
8
|
+
/** The tracking page on www.hovership.com; one parcel's page is its number under it. */
|
|
9
|
+
export const HOVERSHIP_TRACK = 'https://www.hovership.com/track/';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
/** The page the tracking form opens for one whole number. */
|
|
12
|
+
export function hovershipTrackUrl(number) {
|
|
13
|
+
return `${HOVERSHIP_TRACK}${encodeURIComponent(normalizeHovershipNumber(number))}/`;
|
|
14
|
+
}
|
|
15
|
+
export class HovershipTracker {
|
|
16
|
+
options;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
async fetch(raw, context = {}) {
|
|
21
|
+
const number = normalizeHovershipNumber(raw);
|
|
22
|
+
const read = async (signal, timeoutMs) => {
|
|
23
|
+
const { response, bytes } = await fetchBounded(hovershipTrackUrl(number), { method: 'GET', signal, headers: {
|
|
24
|
+
Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
25
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: HOVERSHIP_MAX_BYTES, redirect: 'manual',
|
|
26
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
27
|
+
if (REDIRECTS.includes(response.status))
|
|
28
|
+
throw new TransportError(PROVIDER, 'Hovership redirected its tracking page');
|
|
29
|
+
// The page answers a number it has no shipment for with HTTP 404 and its own notice;
|
|
30
|
+
// any other 404 is a moved route.
|
|
31
|
+
if (response.status === 404 && isHovershipNotFound(decodeText(bytes)))
|
|
32
|
+
throw new NotFoundError(PROVIDER);
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new TransportError(PROVIDER, 'Hovership tracking page is unavailable');
|
|
35
|
+
return parseHovership(decodeText(bytes), number);
|
|
36
|
+
};
|
|
37
|
+
// A request that fails to reach the site, or hangs, gets one more try: the
|
|
38
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
39
|
+
return runSteps({ carrier: CARRIER, budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
40
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
41
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
42
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const adapter = (environment) => {
|
|
47
|
+
const tracker = new HovershipTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
48
|
+
return {
|
|
49
|
+
id: CARRIER, recordsSteps: true, steps: ['direct', 'retry'],
|
|
50
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
51
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeHovershipNumber(number))),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -17,18 +17,68 @@
|
|
|
17
17
|
"timezone": "America/Los_Angeles",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "hovership",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 687
|
|
27
|
+
},
|
|
28
|
+
"localClocks": true
|
|
22
29
|
},
|
|
23
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history",
|
|
32
|
+
"provider_code",
|
|
33
|
+
"delivered_at",
|
|
34
|
+
"eta",
|
|
35
|
+
"offset_timestamps"
|
|
36
|
+
],
|
|
24
37
|
"portal": {
|
|
25
38
|
"url": "https://www.hovership.com/track/{trackingNumber}/",
|
|
26
39
|
"searchUrl": "https://www.hovership.com/track/",
|
|
27
40
|
"canaryUrl": "https://www.hovership.com/track/",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
"shows": [
|
|
42
|
+
"status",
|
|
43
|
+
"progress_rail",
|
|
44
|
+
"history",
|
|
45
|
+
"eta",
|
|
46
|
+
"delivered_at",
|
|
47
|
+
"offset_timestamps",
|
|
48
|
+
"origin",
|
|
49
|
+
"destination",
|
|
50
|
+
"recipient_address",
|
|
51
|
+
"proof_of_delivery",
|
|
52
|
+
"driver_name",
|
|
53
|
+
"coordinates"
|
|
54
|
+
],
|
|
55
|
+
"scraped": [
|
|
56
|
+
"status",
|
|
57
|
+
"history",
|
|
58
|
+
"provider_code",
|
|
59
|
+
"eta",
|
|
60
|
+
"delivered_at",
|
|
61
|
+
"offset_timestamps"
|
|
62
|
+
],
|
|
63
|
+
"discarded": [
|
|
64
|
+
"progress_rail",
|
|
65
|
+
"origin",
|
|
66
|
+
"destination",
|
|
67
|
+
"recipient_address",
|
|
68
|
+
"proof_of_delivery",
|
|
69
|
+
"driver_name",
|
|
70
|
+
"coordinates"
|
|
71
|
+
],
|
|
72
|
+
"unavailable": [
|
|
73
|
+
"location",
|
|
74
|
+
"weight",
|
|
75
|
+
"service_name",
|
|
76
|
+
"sender_name",
|
|
77
|
+
"recipient_name",
|
|
78
|
+
"pickup_point",
|
|
79
|
+
"delivery_partner",
|
|
80
|
+
"delivery_tracking_number"
|
|
81
|
+
]
|
|
32
82
|
},
|
|
33
83
|
"links": [
|
|
34
84
|
{
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Hovership";
|
|
3
|
+
export declare const CARRIER = "hovership";
|
|
4
|
+
export declare const HOVERSHIP_MAX_BYTES: number;
|
|
5
|
+
/** A number for Hovership's tracker, in upper case as its form sends it; anything else is not one it issues. */
|
|
6
|
+
export declare function normalizeHovershipNumber(raw: string): string;
|
|
7
|
+
/** A scan's line without phone numbers, and a delivery's without who took the parcel. */
|
|
8
|
+
export declare function hovershipWording(detail: string, delivered: boolean): string;
|
|
9
|
+
/** Whether a page is the tracker's own answer for a number it has no shipment for. */
|
|
10
|
+
export declare function isHovershipNotFound(html: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The page `www.hovership.com/track/{number}/` answers. The server renders the
|
|
13
|
+
* parcel under its number, a headline status with its latest delivery time or
|
|
14
|
+
* its delivery, and the history newest first, each scan a title, a clock and a
|
|
15
|
+
* line. The progress rail, the origin and destination towns and the form that
|
|
16
|
+
* unlocks the address and proof of delivery for the delivery ZIP code are not
|
|
17
|
+
* read.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseHovership(html: string, raw: string): CarrierResult;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { calendarDay } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { hovershipHeadlineStage, hovershipStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'Hovership';
|
|
9
|
+
export const CARRIER = 'hovership';
|
|
10
|
+
// A parcel's page is about 45 kilobytes, most of it the site's menus.
|
|
11
|
+
export const HOVERSHIP_MAX_BYTES = 512 * 1024;
|
|
12
|
+
// The tracking form's rule, six to thirty letters, digits, underscores and
|
|
13
|
+
// dashes, without the dashes the library's normalization removes.
|
|
14
|
+
const NUMBER = /^(?=[A-Z0-9_]*[A-Z0-9])[A-Z0-9_]{6,30}$/;
|
|
15
|
+
// The line over a parcel's status, "Tracking · {number}".
|
|
16
|
+
const MARKER = /^Tracking\s*·\s*(\S+)$/;
|
|
17
|
+
// The page's answer for a number it has no shipment for.
|
|
18
|
+
const NOT_FOUND = /^Tracking number not found$/i;
|
|
19
|
+
// The page's headline note for a parcel not yet delivered: its latest delivery time.
|
|
20
|
+
const ESTIMATE = /^Arriving (?:no later than|by) (.+)$/;
|
|
21
|
+
const DELIVERED = /^Delivered (.+)$/;
|
|
22
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
23
|
+
// "Sep 9, 2026, 2:15 PM PDT": the clock in US English, then its zone's short name.
|
|
24
|
+
const STAMP = /^([A-Z][a-z]{2}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}) ([AP]M)(?: (\S{2,9}))?$/;
|
|
25
|
+
// The short names US English gives the zones of the states Hovership serves, and their offsets in minutes.
|
|
26
|
+
const OFFSETS = {
|
|
27
|
+
EST: -300, EDT: -240, CST: -360, CDT: -300, MST: -420, MDT: -360, PST: -480, PDT: -420,
|
|
28
|
+
AKST: -540, AKDT: -480, HST: -600, HDT: -540, AST: -240, UTC: 0, GMT: 0,
|
|
29
|
+
};
|
|
30
|
+
// A zone US English has no short name for, written as its offset ("GMT-7", "GMT+5:30").
|
|
31
|
+
const GMT_OFFSET = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/;
|
|
32
|
+
// Bot checks a CDN or firewall could put in place of the page.
|
|
33
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required|Access Denied|ERROR: The request could not be satisfied)|AwsWafIntegration|awswaf\.com|gokuProps/i;
|
|
34
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens between them, as phone numbers are written.
|
|
35
|
+
const PHONE = /(?<![A-Z\d])\+?\(?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
36
|
+
// Who took a delivered parcel, after the scan's line ("Delivered, signed by …").
|
|
37
|
+
const RECEIVER = /\s*[,;:.\-–]?\s*\b(?:signed(?: for)? by|received by|accepted by|left with)\b[\s\S]*$/i;
|
|
38
|
+
const MAX_ROWS = 300;
|
|
39
|
+
const MAX_EVENTS = 100;
|
|
40
|
+
/** A number for Hovership's tracker, in upper case as its form sends it; anything else is not one it issues. */
|
|
41
|
+
export function normalizeHovershipNumber(raw) {
|
|
42
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
43
|
+
if (!NUMBER.test(number))
|
|
44
|
+
throw new InvalidInputError(PROVIDER, 'Hovership numbers are six to thirty letters, digits and underscores');
|
|
45
|
+
return number;
|
|
46
|
+
}
|
|
47
|
+
function invalid(message) {
|
|
48
|
+
throw new SchemaError(PROVIDER, message);
|
|
49
|
+
}
|
|
50
|
+
/** The offset a zone's short name stands for, as an ISO suffix; null for a name not on the list. */
|
|
51
|
+
function zoneOffset(name) {
|
|
52
|
+
const gmt = name ? GMT_OFFSET.exec(name) : null;
|
|
53
|
+
const minutes = name && Object.hasOwn(OFFSETS, name) ? OFFSETS[name]
|
|
54
|
+
: gmt && Number(gmt[2]) <= 14 && Number(gmt[3] ?? 0) <= 59 ? (gmt[1] === '-' ? -1 : 1) * (Number(gmt[2]) * 60 + Number(gmt[3] ?? 0))
|
|
55
|
+
: null;
|
|
56
|
+
if (minutes === null)
|
|
57
|
+
return null;
|
|
58
|
+
if (minutes === 0)
|
|
59
|
+
return 'Z';
|
|
60
|
+
const whole = Math.abs(minutes);
|
|
61
|
+
return `${minutes < 0 ? '-' : '+'}${String(Math.floor(whole / 60)).padStart(2, '0')}:${String(whole % 60).padStart(2, '0')}`;
|
|
62
|
+
}
|
|
63
|
+
/** A clock the page prints, or null when it is not one. Date libraries accept 0:00 PM, so the fields are checked here. */
|
|
64
|
+
function readStamp(text) {
|
|
65
|
+
const match = STAMP.exec(text);
|
|
66
|
+
if (!match)
|
|
67
|
+
return null;
|
|
68
|
+
const [, monthName, date, year, hour, minute, half, zone] = match;
|
|
69
|
+
const day = calendarDay(Number(year), MONTHS.indexOf(monthName) + 1, Number(date));
|
|
70
|
+
if (!day || Number(hour) < 1 || Number(hour) > 12 || Number(minute) > 59)
|
|
71
|
+
return null;
|
|
72
|
+
const hours = (Number(hour) % 12) + (half === 'PM' ? 12 : 0);
|
|
73
|
+
return { day, wall: `${day}T${String(hours).padStart(2, '0')}:${minute}:00`, offset: zoneOffset(zone) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A scan's clock: an instant in the offset of the zone the page names after
|
|
77
|
+
* it. A zone the page names some other way leaves the wall clock in
|
|
78
|
+
* `local_time`.
|
|
79
|
+
*/
|
|
80
|
+
function scanClock(text) {
|
|
81
|
+
const stamp = readStamp(text) ?? invalid('Hovership returned an invalid scan time');
|
|
82
|
+
return stamp.offset ? { time: `${stamp.wall}${stamp.offset}` } : { local_time: stamp.wall };
|
|
83
|
+
}
|
|
84
|
+
/** A scan's line without phone numbers, and a delivery's without who took the parcel. */
|
|
85
|
+
export function hovershipWording(detail, delivered) {
|
|
86
|
+
const line = delivered ? detail.replace(RECEIVER, '') : detail;
|
|
87
|
+
return clean(line.replace(PHONE, ' ').replace(/\s+([,;:.])/g, '$1'), 300).replace(/^[\s,;:.\-–]+|[\s,;:\-–]+$/g, '');
|
|
88
|
+
}
|
|
89
|
+
/** The history's rows, newest first as the page lists them; none when the page has no history. */
|
|
90
|
+
function historyRows($) {
|
|
91
|
+
const labels = $('main p.stat-card-label').filter((_, label) => clean($(label).text()) === 'Tracking history');
|
|
92
|
+
if (labels.length > 1)
|
|
93
|
+
invalid('Hovership returned repeated histories');
|
|
94
|
+
if (!labels.length)
|
|
95
|
+
return [];
|
|
96
|
+
const lists = labels.parent().find('ol');
|
|
97
|
+
if (lists.length !== 1)
|
|
98
|
+
invalid('Hovership returned a history without its list');
|
|
99
|
+
return lists.children('li').toArray();
|
|
100
|
+
}
|
|
101
|
+
function projectRow($, row) {
|
|
102
|
+
const times = $(row).find('time');
|
|
103
|
+
if (times.length !== 1)
|
|
104
|
+
invalid('Hovership returned an incomplete scan');
|
|
105
|
+
const header = times.parent();
|
|
106
|
+
const label = clean(header.children('p').first().text(), 80);
|
|
107
|
+
if (!label)
|
|
108
|
+
invalid('Hovership returned a scan without its title');
|
|
109
|
+
const clock = scanClock(clean(times.text(), 60));
|
|
110
|
+
const detail = clean(header.nextAll('p').first().text(), 400);
|
|
111
|
+
const staged = hovershipStage(label, detail);
|
|
112
|
+
const description = hovershipWording(detail, staged?.stage === 'delivered') || label;
|
|
113
|
+
return {
|
|
114
|
+
...clock,
|
|
115
|
+
description,
|
|
116
|
+
provider_code: label,
|
|
117
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The headline note's clock after `pattern`, as an instant or else its day; null when the note is something else. */
|
|
121
|
+
function noteClock(note, pattern) {
|
|
122
|
+
const text = pattern.exec(note)?.[1];
|
|
123
|
+
const stamp = text ? readStamp(text) : null;
|
|
124
|
+
return stamp ? { time: stamp.offset ? `${stamp.wall}${stamp.offset}` : null, day: stamp.day } : null;
|
|
125
|
+
}
|
|
126
|
+
/** Whether a page is the tracker's own answer for a number it has no shipment for. */
|
|
127
|
+
export function isHovershipNotFound(html) {
|
|
128
|
+
return NOT_FOUND.test(clean(load(html)('main h1').first().text(), 120));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The page `www.hovership.com/track/{number}/` answers. The server renders the
|
|
132
|
+
* parcel under its number, a headline status with its latest delivery time or
|
|
133
|
+
* its delivery, and the history newest first, each scan a title, a clock and a
|
|
134
|
+
* line. The progress rail, the origin and destination towns and the form that
|
|
135
|
+
* unlocks the address and proof of delivery for the delivery ZIP code are not
|
|
136
|
+
* read.
|
|
137
|
+
*/
|
|
138
|
+
export function parseHovership(html, raw) {
|
|
139
|
+
const number = normalizeHovershipNumber(raw);
|
|
140
|
+
const $ = load(html);
|
|
141
|
+
const headline = $('main h1').first();
|
|
142
|
+
const heading = clean(headline.text(), 120);
|
|
143
|
+
if (NOT_FOUND.test(heading))
|
|
144
|
+
throw new NotFoundError(PROVIDER);
|
|
145
|
+
const markers = $('main p.section-marker').map((_, marker) => clean($(marker).text(), 80)).get()
|
|
146
|
+
.filter((text) => MARKER.test(text));
|
|
147
|
+
if (markers.length !== 1 || !heading) {
|
|
148
|
+
if (CHALLENGE.test(html.slice(0, 64_000)))
|
|
149
|
+
throw new ChallengeError(PROVIDER, 'Hovership answered with a browser check');
|
|
150
|
+
invalid('Hovership returned a page without a parcel');
|
|
151
|
+
}
|
|
152
|
+
const verify = $('form[data-verify-form]').attr('data-tracking');
|
|
153
|
+
if (MARKER.exec(markers[0])[1].toUpperCase() !== number || (verify !== undefined && clean(verify, 64).toUpperCase() !== number)) {
|
|
154
|
+
throw new IndeterminateError(PROVIDER, 'Hovership answered for another parcel');
|
|
155
|
+
}
|
|
156
|
+
const rows = historyRows($);
|
|
157
|
+
if (rows.length > MAX_ROWS)
|
|
158
|
+
invalid('Hovership returned an oversized history');
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const events = [];
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
const event = projectRow($, row);
|
|
163
|
+
const key = JSON.stringify([event.time ?? event.local_time, event.provider_code, event.description]);
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
events.push(event);
|
|
168
|
+
}
|
|
169
|
+
// Newest first, as the page lists them; an oldest-first list is turned round.
|
|
170
|
+
const first = events[0]?.time;
|
|
171
|
+
const last = events.at(-1)?.time;
|
|
172
|
+
if (first && last && Date.parse(first) < Date.parse(last))
|
|
173
|
+
events.reverse();
|
|
174
|
+
const note = clean(headline.nextAll('p').first().text(), 120);
|
|
175
|
+
const summary = hovershipHeadlineStage(heading);
|
|
176
|
+
const current = events.find((event) => event.stage);
|
|
177
|
+
const stage = (current?.stage ?? summary);
|
|
178
|
+
const final = stage === 'delivered' || stage === 'returned';
|
|
179
|
+
const estimate = final ? null : noteClock(note, ESTIMATE);
|
|
180
|
+
const delivered = stage === 'delivered' ? current?.time ?? noteClock(note, DELIVERED)?.time : undefined;
|
|
181
|
+
const newest = events[0];
|
|
182
|
+
if (!newest && !summary)
|
|
183
|
+
throw new NoHistoryError(PROVIDER);
|
|
184
|
+
return {
|
|
185
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
186
|
+
...(stage ? { current_stage: stage, current_stage_source: current?.stage_source ?? 'carrier_map' } : {}),
|
|
187
|
+
last_status_text: newest?.description ?? heading,
|
|
188
|
+
last_update: newest?.time ?? null,
|
|
189
|
+
...(typeof newest?.local_time === 'string' ? { last_update_local: newest.local_time } : {}),
|
|
190
|
+
expected_delivery: estimate ? estimate.time ?? estimate.day : null,
|
|
191
|
+
...(delivered ? { delivered_at: delivered } : {}),
|
|
192
|
+
...(newest ? {} : { summary_only: true }),
|
|
193
|
+
events: events.slice(0, MAX_EVENTS),
|
|
194
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import type { CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage a scan's title gives. */
|
|
4
|
+
export declare function hovershipLabelStage(label: string): Stage | undefined;
|
|
5
|
+
/** The stage the page's headline gives the parcel. */
|
|
6
|
+
export declare function hovershipHeadlineStage(headline: string): Stage | undefined;
|
|
7
|
+
/** A scan's stage: its title, else the shared wording rules on the title, then on its line, else nothing. */
|
|
8
|
+
export declare function hovershipStage(label: string, detail: string): ClassifiedWording | null;
|
|
9
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The titles the tracking page gives its scans, seen in histories. They name
|
|
4
|
+
* the shipment statuses of Hovership's API, such as `POSSESSION`; the API's
|
|
5
|
+
* exception and return statuses have not been seen on the page and fall to
|
|
6
|
+
* the shared wording rules.
|
|
7
|
+
*/
|
|
8
|
+
const LABELS = new Map([
|
|
9
|
+
['label created', 'registered'], // The shipper made the label; Hovership has not scanned the parcel.
|
|
10
|
+
['in possession', 'accepted'], // Scanned in at a Hovership sort facility.
|
|
11
|
+
['in transit', 'in_transit'],
|
|
12
|
+
['out for delivery', 'out_for_delivery'],
|
|
13
|
+
['delivered', 'delivered'],
|
|
14
|
+
]);
|
|
15
|
+
/** The page's headline over the history, the parcel's current status. */
|
|
16
|
+
const HEADLINES = new Map([
|
|
17
|
+
['in our possession', 'accepted'],
|
|
18
|
+
['delivered', 'delivered'],
|
|
19
|
+
]);
|
|
20
|
+
const key = (text) => text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
21
|
+
/** The stage a scan's title gives. */
|
|
22
|
+
export function hovershipLabelStage(label) {
|
|
23
|
+
return LABELS.get(key(label));
|
|
24
|
+
}
|
|
25
|
+
/** The stage the page's headline gives the parcel. */
|
|
26
|
+
export function hovershipHeadlineStage(headline) {
|
|
27
|
+
return HEADLINES.get(key(headline));
|
|
28
|
+
}
|
|
29
|
+
/** A scan's stage: its title, else the shared wording rules on the title, then on its line, else nothing. */
|
|
30
|
+
export function hovershipStage(label, detail) {
|
|
31
|
+
const mapped = hovershipLabelStage(label);
|
|
32
|
+
if (mapped)
|
|
33
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
34
|
+
for (const text of [label, detail]) {
|
|
35
|
+
const worded = text ? classifyWording(text, 'in_transit') : null;
|
|
36
|
+
if (worded && worded.source !== 'none')
|
|
37
|
+
return worded;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export const statusMap = {
|
|
42
|
+
stage: (code) => (code ? hovershipLabelStage(code) : undefined),
|
|
43
|
+
gaps: [],
|
|
44
|
+
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "hovership",
|
|
3
3
|
"entries": [
|
|
4
|
-
{"
|
|
5
|
-
{"wording": "
|
|
6
|
-
{"
|
|
7
|
-
{"
|
|
8
|
-
{"wording": "
|
|
9
|
-
{"wording": "
|
|
10
|
-
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "
|
|
4
|
+
{"code": "Label Created", "wording": "Shipment label created", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "A scan's title and its line; the shipper made the label and Hovership has not scanned the parcel yet."},
|
|
5
|
+
{"code": "In Possession", "wording": "Arrived at Hayward Sort Facility", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "The scan that takes the parcel into Hovership's network at a sort facility, which its line names."},
|
|
6
|
+
{"code": "In Transit", "wording": "Arrived at Hayward Sort Facility", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "A later scan at the sort facility that took the parcel in, before it went out for delivery."},
|
|
7
|
+
{"code": "Out for Delivery", "wording": "Shipment scanned out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live"},
|
|
8
|
+
{"code": "Delivered", "wording": "Delivered to Front Door", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "The line says where the parcel was left."},
|
|
9
|
+
{"wording": "In our possession", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "The headline of an archived capture of Hovership's tracker while the newest scan was In Possession."},
|
|
10
|
+
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "The headline over a delivered parcel's history, and the last step of the progress bar."},
|
|
11
|
+
{"wording": "In transit", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "A step of the progress bar."},
|
|
12
|
+
{"wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "A step of the progress bar."}
|
|
11
13
|
]
|
|
12
14
|
}
|
|
@@ -206,6 +206,7 @@ import { statusMap as hepsijet } from '../../carriers/hepsijet/status.js';
|
|
|
206
206
|
import { statusMap as hermesAt } from '../../carriers/hermes-at/status.js';
|
|
207
207
|
import { statusMap as hispapost } from '../../carriers/hispapost/status.js';
|
|
208
208
|
import { statusMap as houngAhLoun } from '../../carriers/houng-ah-loun/status.js';
|
|
209
|
+
import { statusMap as hovership } from '../../carriers/hovership/status.js';
|
|
209
210
|
import { statusMap as hrParcel } from '../../carriers/hr-parcel/status.js';
|
|
210
211
|
import { statusMap as hrvatskaPosta } from '../../carriers/hrvatska-posta/status.js';
|
|
211
212
|
import { statusMap as hunterExpress } from '../../carriers/hunter-express/status.js';
|
|
@@ -495,8 +496,9 @@ export const STATUS_MAPS = {
|
|
|
495
496
|
'gebrueder-weiss': gebruederWeiss, geis, 'geniki-taxydromiki': geniki, 'geodis-pl': geodisPl, 'gls-es': glsEs, 'gls-it': glsIt,
|
|
496
497
|
globalpost, 'go-express': goExpress, go4, gobolt, gofo, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, 'gofo-nl': gofoNl, 'gogo-xpress': gogoXpress,
|
|
497
498
|
'gs-postbox': gsPostbox, 'gsi-express': gsiExpress, 'gta-airskip': gtaAirskip, gzyl, hanjin, 'happy-post': happyPost, haypost,
|
|
498
|
-
'hct-logistics': hctLogistics, helthjem, hepsijet, 'hermes-at': hermesAt, hispapost, 'houng-ah-loun': houngAhLoun,
|
|
499
|
-
'hrvatska-posta': hrvatskaPosta, 'hunter-express': hunterExpress,
|
|
499
|
+
'hct-logistics': hctLogistics, helthjem, hepsijet, 'hermes-at': hermesAt, hispapost, 'houng-ah-loun': houngAhLoun, hovership,
|
|
500
|
+
'hr-parcel': hrParcel, 'hrvatska-posta': hrvatskaPosta, huahan, 'hunter-express': hunterExpress, hywl, 'ics-courier': icsCourier,
|
|
501
|
+
'ids-logistik': idsLogistik,
|
|
500
502
|
'ilyang-logis': ilyangLogis, imile, 'indah-logistik': indahLogistik,
|
|
501
503
|
instabox, 'internet-express': internetExpress, interrapidisimo,
|
|
502
504
|
'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-cargo': jntCargo,
|
package/dist/data/catalog.json
CHANGED
|
@@ -16054,7 +16054,9 @@
|
|
|
16054
16054
|
"timezone": "America/Los_Angeles",
|
|
16055
16055
|
"tracking": {
|
|
16056
16056
|
"mode": "automatic",
|
|
16057
|
-
"adapter": "
|
|
16057
|
+
"adapter": "hovership",
|
|
16058
|
+
"recognitionRank": 687,
|
|
16059
|
+
"localClocks": true
|
|
16058
16060
|
},
|
|
16059
16061
|
"canaryUrl": "https://www.hovership.com/track/",
|
|
16060
16062
|
"trackingUrlTemplate": "https://www.hovership.com/track/{trackingNumber}/",
|
|
@@ -10389,7 +10389,9 @@ export declare const CARRIER_CATALOG: {
|
|
|
10389
10389
|
readonly timezone: "America/Los_Angeles";
|
|
10390
10390
|
readonly tracking: {
|
|
10391
10391
|
readonly mode: "automatic";
|
|
10392
|
-
readonly adapter: "
|
|
10392
|
+
readonly adapter: "hovership";
|
|
10393
|
+
readonly recognitionRank: 687;
|
|
10394
|
+
readonly localClocks: true;
|
|
10393
10395
|
};
|
|
10394
10396
|
readonly canaryUrl: "https://www.hovership.com/track/";
|
|
10395
10397
|
readonly trackingUrlTemplate: "https://www.hovership.com/track/{trackingNumber}/";
|
|
@@ -16055,7 +16055,9 @@ export const CARRIER_CATALOG = {
|
|
|
16055
16055
|
"timezone": "America/Los_Angeles",
|
|
16056
16056
|
"tracking": {
|
|
16057
16057
|
"mode": "automatic",
|
|
16058
|
-
"adapter": "
|
|
16058
|
+
"adapter": "hovership",
|
|
16059
|
+
"recognitionRank": 687,
|
|
16060
|
+
"localClocks": true
|
|
16059
16061
|
},
|
|
16060
16062
|
"canaryUrl": "https://www.hovership.com/track/",
|
|
16061
16063
|
"trackingUrlTemplate": "https://www.hovership.com/track/{trackingNumber}/",
|
|
@@ -239,6 +239,7 @@ import { adapter as hermesDe } from '../carriers/hermes-de/adapter.js';
|
|
|
239
239
|
import { adapter as hispapost } from '../carriers/hispapost/adapter.js';
|
|
240
240
|
import { adapter as hongkongPost } from '../carriers/hongkong-post/adapter.js';
|
|
241
241
|
import { adapter as houngAhLoun } from '../carriers/houng-ah-loun/adapter.js';
|
|
242
|
+
import { adapter as hovership } from '../carriers/hovership/adapter.js';
|
|
242
243
|
import { adapter as hrParcel } from '../carriers/hr-parcel/adapter.js';
|
|
243
244
|
import { adapter as hrvatskaPosta } from '../carriers/hrvatska-posta/adapter.js';
|
|
244
245
|
import { adapter as huahan } from '../carriers/huahan/adapter.js';
|
|
@@ -780,6 +781,7 @@ export const REGISTRY = {
|
|
|
780
781
|
"hispapost": hispapost,
|
|
781
782
|
"hongkong-post": hongkongPost,
|
|
782
783
|
"houng-ah-loun": houngAhLoun,
|
|
784
|
+
"hovership": hovership,
|
|
783
785
|
"hr-parcel": hrParcel,
|
|
784
786
|
"hrvatska-posta": hrvatskaPosta,
|
|
785
787
|
"huahan": huahan,
|
|
@@ -1412,7 +1414,7 @@ export const REGISTRY = {
|
|
|
1412
1414
|
"horoz": "universal",
|
|
1413
1415
|
"hound-express": "universal",
|
|
1414
1416
|
"houng-ah-loun": "houng-ah-loun",
|
|
1415
|
-
"hovership": "
|
|
1417
|
+
"hovership": "hovership",
|
|
1416
1418
|
"hp-mostar": "universal",
|
|
1417
1419
|
"hr-parcel": "hr-parcel",
|
|
1418
1420
|
"hrvatska-posta": "hrvatska-posta",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED