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 CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **539 dedicated adapters** and **5 universal fallbacks**
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,411 such statuses, each filed under one stage.
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": "universal"
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}/",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 772,
3
- "activeAdapters": 539,
3
+ "activeAdapters": 540,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -12333,7 +12333,9 @@ var Ue = {
12333
12333
  timezone: "America/Los_Angeles",
12334
12334
  tracking: {
12335
12335
  mode: "automatic",
12336
- adapter: "universal"
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": "universal",
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
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
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
- {"wording": "Label Created", "stage": "registered", "language": "en", "confirmedBy": "portal", "note": "An event title in an archived capture of Hovership's tracker."},
5
- {"wording": "Shipment label created", "stage": "registered", "language": "en", "confirmedBy": "portal", "note": "The description under Label Created in an archived capture of Hovership's tracker."},
6
- {"wording": "In Possession", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "An event title in an archived capture of Hovership's tracker; Hovership has the parcel at a facility."},
7
- {"wording": "In our possession", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "The headline status of an archived capture of Hovership's tracker while the parcel was In Possession."},
8
- {"wording": "In transit", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "A step of the progress bar in an archived capture of Hovership's tracker."},
9
- {"wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "A step of the progress bar in an archived capture of Hovership's tracker."},
10
- {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "A step of the progress bar in an archived capture of Hovership's tracker."}
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, 'hr-parcel': hrParcel,
499
- 'hrvatska-posta': hrvatskaPosta, 'hunter-express': hunterExpress, huahan, hywl, 'ics-courier': icsCourier, 'ids-logistik': idsLogistik,
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,
@@ -16054,7 +16054,9 @@
16054
16054
  "timezone": "America/Los_Angeles",
16055
16055
  "tracking": {
16056
16056
  "mode": "automatic",
16057
- "adapter": "universal"
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: "universal";
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": "universal"
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}/",
@@ -187,6 +187,7 @@ export const CARRIER_RECOGNITION_RANKS = {
187
187
  "hermes-at": 876,
188
188
  "hermes-de": 840,
189
189
  "houng-ah-loun": 637,
190
+ "hovership": 687,
190
191
  "hr-parcel": 887,
191
192
  "hrvatska-posta": 160,
192
193
  "hywl": 993,
@@ -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": "universal",
1417
+ "hovership": "hovership",
1416
1418
  "hp-mostar": "universal",
1417
1419
  "hr-parcel": "hr-parcel",
1418
1420
  "hrvatska-posta": "hrvatska-posta",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.495.1"
5
+ "version": "0.496.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.495.1",
3
+ "version": "0.496.0-main.2375",
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",