universal-parcel-scraper 0.769.9-main.4113 → 0.770.1-main.4120

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 **631 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **632 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>807 catalog entries · 135 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -161,7 +161,7 @@ the command line's `track --source ParcelsApp` does the same.
161
161
  <!-- GENERATED:stages -->
162
162
  <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">
163
163
 
164
- The carrier folders record 16,928 such statuses, each filed under one stage.
164
+ The carrier folders record 16,929 such statuses, each filed under one stage.
165
165
  <!-- /GENERATED:stages -->
166
166
 
167
167
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -16959,7 +16959,8 @@
16959
16959
  "timezone": "Europe/Istanbul",
16960
16960
  "tracking": {
16961
16961
  "mode": "automatic",
16962
- "adapter": "universal"
16962
+ "adapter": "horoz",
16963
+ "localClocks": true
16963
16964
  },
16964
16965
  "canaryUrl": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
16965
16966
  "trackingUrlTemplate": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 807,
3
- "activeAdapters": 631,
3
+ "activeAdapters": 632,
4
4
  "countries": 135,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -13098,7 +13098,8 @@ var Ze = {
13098
13098
  timezone: "Europe/Istanbul",
13099
13099
  tracking: {
13100
13100
  mode: "automatic",
13101
- adapter: "universal"
13101
+ adapter: "horoz",
13102
+ localClocks: !0
13102
13103
  },
13103
13104
  canaryUrl: "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
13104
13105
  trackingUrlTemplate: "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}",
@@ -1,6 +1,7 @@
1
1
  import { carrierErrorKind, IndeterminateError, NotFoundError } from '../../core/errors/index.js';
2
2
  import { fillPartialAnswer } from '../../core/result/partial.js';
3
3
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
4
+ import { dateLocalClocks } from '../../places/clocks.js';
4
5
  import { DPDTracker } from '../dpd/adapter.js';
5
6
  import { adapter as dpdAt } from '../dpd-at/adapter.js';
6
7
  import { DPDFranceTracker } from '../dpd-fr/adapter.js';
@@ -12,9 +13,10 @@ import { DpdDeAppClient } from './app.js';
12
13
  import { DpdOtherCountryError, sharedDpdAppService } from './service.js';
13
14
  /** Left to the guest API when the app's session is still opening ahead of it. */
14
15
  const GUEST_RESERVE_MS = 20_000;
16
+ // A unit's wall clocks are dated as its own lookup dates them, not on Germany's clock.
15
17
  const adapterUnit = (factory) => (environment) => {
16
18
  const unit = factory({ ...environment, recorder: NOOP_RECORDER });
17
- return (number, context) => unit.track({ number }, context);
19
+ return async (number, context) => dateLocalClocks(unit.id, await unit.track({ number }, context));
18
20
  };
19
21
  /**
20
22
  * DPD's other units with a source of their own that takes DPD parcel numbers, by the country code
@@ -16,6 +16,8 @@ function matches(incoming, stored) {
16
16
  && !incoming.providerCode !== !stored.providerCode
17
17
  && (location === saved || !location || !saved);
18
18
  }
19
+ // A unit abroad's scans, which a release dates on that unit's clock rather than Germany's, keep their rows.
19
20
  export const sameInstantIdentityPolicy = {
20
21
  sourceCarrierId: 'dpd-de', storedSources: ['dpd-de'], requireProviderCode: false, matchEachScan: true, matches, matchWithinMinute: true,
22
+ matchWallClock: true,
21
23
  };
@@ -5,7 +5,7 @@ import { normalizeTrackingNumber } from '../../core/detection/index.js';
5
5
  import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
6
6
  import { languageStageStatus } from '../../core/status/index.js';
7
7
  import { clean } from '../../core/transport/index.js';
8
- import { DPD_PT_COLLECTION, DPD_PT_DELIVERY, DPD_PT_POINT_RECEIPT, DPD_PT_RETURN, dpdPtStage, isDpdPtNotice } from './status.js';
8
+ import { DPD_PT_COLLECTION, DPD_PT_DELIVERY, DPD_PT_FORECAST, DPD_PT_POINT_RECEIPT, DPD_PT_RETURN, dpdPtStage, isDpdPtNotice } from './status.js';
9
9
  export const PROVIDER = 'DPD Portugal';
10
10
  // The page is under 50 kB with a long history.
11
11
  export const DPD_PT_MAX_BYTES = 1024 * 1024;
@@ -111,6 +111,9 @@ export function parseDpdPt(html, raw) {
111
111
  throw new SchemaError(PROVIDER, 'DPD Portugal returned an invalid scan code');
112
112
  if (!description)
113
113
  throw new SchemaError(PROVIDER, 'DPD Portugal returned a scan without wording');
114
+ // Its day and time are when DPD set the expected day: as a step it would show a day the parcel was not due.
115
+ if (code === DPD_PT_FORECAST)
116
+ continue;
114
117
  // The comment column mixes the receiver's name, delivery windows, depot
115
118
  // and pickup point codes and references; only a pickup point's name is read.
116
119
  const comment = code === DPD_PT_POINT_RECEIPT ? clean(cells.eq(4).text(), 120) : '';
@@ -121,6 +124,8 @@ export function parseDpdPt(html, raw) {
121
124
  seen.add(key);
122
125
  scans.push({ code, comment, event: { local_time, description, provider_code: code } });
123
126
  }
127
+ if (!scans.length)
128
+ throw new NoHistoryError(PROVIDER);
124
129
  // Oldest first: a collection and a delivery take their stage from the scans
125
130
  // before them, and a notice keeps the stage of the scan before it.
126
131
  let previous;
@@ -8,6 +8,8 @@ export declare const DPD_PT_DELIVERY = "POD";
8
8
  export declare const DPD_PT_RETURN = "424";
9
9
  /** The pickup point's own scan; its comment names the point. */
10
10
  export declare const DPD_PT_POINT_RECEIPT = "002";
11
+ /** The expected delivery day, dated when DPD set it rather than with the day: the history leaves it out. */
12
+ export declare const DPD_PT_FORECAST = "714";
11
13
  /** The stage DPD Portugal's code gives a scan, if any, read without the scans before it. */
12
14
  export declare function dpdPtMappedStage(code: string): Stage | undefined;
13
15
  export declare function isDpdPtNotice(code: string): boolean;
@@ -40,12 +40,14 @@ export const DPD_PT_DELIVERY = 'POD';
40
40
  export const DPD_PT_RETURN = '424';
41
41
  /** The pickup point's own scan; its comment names the point. */
42
42
  export const DPD_PT_POINT_RECEIPT = '002';
43
+ /** The expected delivery day, dated when DPD set it rather than with the day: the history leaves it out. */
44
+ export const DPD_PT_FORECAST = '714';
43
45
  // Messages to the recipient and requests for a new delivery or address: no
44
46
  // movement, so the parcel keeps its stage.
45
47
  const NOTICES = new Map([
46
48
  ['706', 'A message giving the recipient a delivery window; it moves nothing.'],
47
49
  ['709', 'A message telling the recipient of the next delivery; it moves nothing.'],
48
- ['714', 'The expected delivery day; it moves nothing.'],
50
+ ['714', 'The expected delivery day, which the history leaves out; it moves nothing.'],
49
51
  ['741', 'A request for a new delivery; the scans after it show what happened.'],
50
52
  ['743', 'A request to change the address; the scans after it show what happened.'],
51
53
  ]);
@@ -10,7 +10,7 @@
10
10
  {"code": "OFD", "wording": "EM DISTRIBUIÇÃO", "stage": "out_for_delivery", "language": "pt", "confirmedBy": "portal"},
11
11
  {"code": "706", "wording": "HORÁRIO DE ENTREGA ENVIADA AO DESTINATÁRIO", "language": "pt", "confirmedBy": "portal", "note": "A message giving the recipient a delivery window, which the comment column shows; it is not a scan."},
12
12
  {"code": "709", "wording": "MENSAGEM \"A PROXIMA ENTREGA\" ENVIADA", "language": "pt", "confirmedBy": "portal", "note": "A message telling the recipient of the next delivery; it is not a scan."},
13
- {"code": "714", "wording": "DATA PREVISTA DE ENTREGA", "language": "pt", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The expected delivery day, relayed by DPD Germany beside the depot's intake; it is not a scan."},
13
+ {"code": "714", "wording": "DATA PREVISTA DE ENTREGA", "language": "pt", "confirmedBy": "live", "firstSeen": "2026-10-27", "note": "The expected delivery day, dated when DPD set it, beside the depot's intake. It is not a scan, and the history leaves it out."},
14
14
  {"code": "500", "wording": "ENDEREÇO DO DESTINATÁRIO INCORRECTO OU DESCONHECIDO", "stage": "failed_attempt", "language": "pt", "confirmedBy": "portal", "note": "Not delivered because the address is wrong or unknown; the parcel went out again."},
15
15
  {"code": "501", "wording": "ENDEREÇO DO DESTINATÁRIO INCOMPLETO OU ILEGÍVEL", "stage": "failed_attempt", "language": "pt", "confirmedBy": "portal", "note": "Not delivered because the address is incomplete or unreadable."},
16
16
  {"code": "506", "wording": "AUSÊNCIA DO DESTINATÁRIO", "stage": "failed_attempt", "language": "pt", "confirmedBy": "portal", "note": "The recipient was not in."},
@@ -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 delivery-note page, which looks up the number in its address. */
5
+ export declare const HOROZ_PAGE = "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx";
6
+ /** The delivery-note page's address for a number, as Horoz's links build it. */
7
+ export declare function horozLookupUrl(number: string): string;
8
+ export declare class HorozTracker {
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,52 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, 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, HOROZ_MAX_BYTES, normalizeHorozNumber, parseHoroz, PROVIDER } from './parser.js';
8
+ /** The delivery-note page, which looks up the number in its address. */
9
+ export const HOROZ_PAGE = 'https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx';
10
+ const REDIRECTS = [301, 302, 303, 307, 308];
11
+ // The page answers an unknown number with empty fields: a missing route is not a missing shipment.
12
+ const GONE = [404, 405, 410];
13
+ /** The delivery-note page's address for a number, as Horoz's links build it. */
14
+ export function horozLookupUrl(number) {
15
+ return `${HOROZ_PAGE}?${new URLSearchParams({ Ptn: number })}`;
16
+ }
17
+ export class HorozTracker {
18
+ options;
19
+ constructor(options = {}) {
20
+ this.options = options;
21
+ }
22
+ async fetch(raw, context = {}) {
23
+ const number = normalizeHorozNumber(raw);
24
+ const read = async (signal, timeoutMs) => {
25
+ const { response, bytes } = await fetchBounded(horozLookupUrl(number), { method: 'GET', signal, headers: {
26
+ Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent),
27
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: HOROZ_MAX_BYTES, redirect: 'manual',
28
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, ...GONE, 403] });
29
+ const status = response.status;
30
+ if (status === 403)
31
+ throw new ChallengeError(PROVIDER, 'Horoz Lojistik refused the lookup', { status });
32
+ if (REDIRECTS.includes(status) || GONE.includes(status))
33
+ throw new TransportError(PROVIDER, 'Horoz Lojistik tracking page is unavailable', { status });
34
+ return parseHoroz(decodeText(bytes), number);
35
+ };
36
+ // A read that fails to reach the site, or hangs, gets one more try: the
37
+ // first gets half of the budget, so the retry fits in the rest.
38
+ return runSteps({ carrier: CARRIER, budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
39
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
40
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
41
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
42
+ ]);
43
+ }
44
+ }
45
+ export const adapter = (environment) => {
46
+ const tracker = new HorozTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
47
+ return {
48
+ id: CARRIER, recordsSteps: true, steps: ['direct', 'retry'],
49
+ track: (input, context) => tracker.fetch(input.number, context),
50
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeHorozNumber(number))),
51
+ };
52
+ };
@@ -17,18 +17,64 @@
17
17
  "timezone": "Europe/Istanbul",
18
18
  "tracking": {
19
19
  "mode": "automatic",
20
- "adapter": "universal",
21
- "steps": []
20
+ "adapter": "horoz",
21
+ "steps": [
22
+ "direct",
23
+ "retry"
24
+ ],
25
+ "localClocks": true
22
26
  },
23
- "capabilities": [],
27
+ "capabilities": [
28
+ "history"
29
+ ],
24
30
  "portal": {
25
31
  "url": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}",
26
32
  "searchUrl": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
27
33
  "canaryUrl": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
28
- "shows": [],
29
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
34
+ "shows": [
35
+ "status",
36
+ "history",
37
+ "location",
38
+ "eta",
39
+ "delivered_at",
40
+ "service_name",
41
+ "waybill",
42
+ "sender_name",
43
+ "recipient_name",
44
+ "origin",
45
+ "destination",
46
+ "recipient_address",
47
+ "parcel_count",
48
+ "volume",
49
+ "order_contents",
50
+ "vehicle"
51
+ ],
52
+ "scraped": [
53
+ "status",
54
+ "history",
55
+ "location",
56
+ "eta"
57
+ ],
58
+ "discarded": [
59
+ "delivered_at",
60
+ "service_name",
61
+ "waybill",
62
+ "sender_name",
63
+ "recipient_name",
64
+ "origin",
65
+ "destination",
66
+ "recipient_address",
67
+ "parcel_count",
68
+ "volume",
69
+ "order_contents",
70
+ "vehicle"
71
+ ],
72
+ "unavailable": [
73
+ "provider_code",
74
+ "offset_timestamps",
75
+ "pickup_point",
76
+ "proof_of_delivery"
77
+ ]
32
78
  },
33
79
  "links": [
34
80
  {
@@ -0,0 +1,21 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Horoz Lojistik";
3
+ export declare const CARRIER = "horoz";
4
+ export declare const HOROZ_MAX_BYTES: number;
5
+ /** A number for Horoz Lojistik's tracker; anything else is not one it issues. */
6
+ export declare function normalizeHorozNumber(raw: string): string;
7
+ /** A firewall or bot-check page in place of the tracker's own. */
8
+ export declare function isBlockPage(html: string): boolean;
9
+ /**
10
+ * The delivery note (ATF) Horoz Lojistik's tracker shows at
11
+ * `frmAtfDetay.aspx?Ptn={number}`: a headline status, the note's details,
12
+ * the masked sender and recipient, and a table of movements, oldest first.
13
+ * Each movement is a departure from or an arrival at a centre, with its
14
+ * clock; a delivered parcel's last row is the delivery. A number the tracker
15
+ * does not know gets the same page with every field empty and no table.
16
+ *
17
+ * The page's form must name the whole number asked. The masked names, the
18
+ * provinces and addresses, the waybill, the vehicle plates, the destination
19
+ * centres and the record clocks are not read.
20
+ */
21
+ export declare function parseHoroz(html: string, raw: string): CarrierResult;
@@ -0,0 +1,144 @@
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 { clean } from '../../core/transport/index.js';
7
+ import { horozStage } from './status.js';
8
+ export const PROVIDER = 'Horoz Lojistik';
9
+ export const CARRIER = 'horoz';
10
+ // A delivery note with a long movement list is under thirty kilobytes.
11
+ export const HOROZ_MAX_BYTES = 512 * 1024;
12
+ const NUMBER = /^\d{14}$/;
13
+ const HEADINGS = ['İşlem', 'Giriş/Çıkış Merkezi', 'Tarihi', 'Plaka', 'Varış Merkezi', 'İşlem Tarihi'];
14
+ // "14.03.2026 16:27:05": the movement's clock, with seconds.
15
+ const CLOCK = /^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2}):(\d{2})$/;
16
+ const DAY = /^(\d{2})\.(\d{2})\.(\d{4})$/;
17
+ // The page posts back to itself, so its form names the number it was opened with.
18
+ const FORM_ACTION = /^\.\/frmAtfDetay\.aspx\?Ptn=([^&#]*)$/;
19
+ // Firewall and bot-check pages that could stand in for the tracker's own.
20
+ const BLOCK = /<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected)|The requested URL was rejected|_cf_chl_opt|cf-turnstile|challenge-platform|captcha/i;
21
+ const MAX_ROWS = 500;
22
+ const FINAL = new Set(['delivered', 'returned']);
23
+ /** A number for Horoz Lojistik's tracker; anything else is not one it issues. */
24
+ export function normalizeHorozNumber(raw) {
25
+ const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
26
+ if (!NUMBER.test(number))
27
+ throw new InvalidInputError(PROVIDER, 'Horoz Lojistik numbers are fourteen digits');
28
+ return number;
29
+ }
30
+ /** A firewall or bot-check page in place of the tracker's own. */
31
+ export function isBlockPage(html) {
32
+ return BLOCK.test(html.slice(0, 64_000));
33
+ }
34
+ /** A movement's date and second as one wall time. The page gives no zone. */
35
+ function wallClock(text) {
36
+ const match = CLOCK.exec(text);
37
+ // UTC only checks the calendar fields; no offset is assigned.
38
+ const parsed = match ? DateTime.fromObject({ year: Number(match[3]), month: Number(match[2]), day: Number(match[1]),
39
+ hour: Number(match[4]), minute: Number(match[5]), second: Number(match[6]) }, { zone: 'UTC' }) : null;
40
+ // Luxon rolls 24:00 over to the next day; only a clock that reads back unchanged is kept.
41
+ if (!match || !parsed?.isValid || parsed.year < 2000 || parsed.toFormat('dd.MM.yyyy HH:mm:ss') !== text) {
42
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned an invalid movement time');
43
+ }
44
+ return parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss");
45
+ }
46
+ /** The delivery date the note expects, as a calendar day, when it gives a real one. */
47
+ function expectedDay(text) {
48
+ const match = DAY.exec(text);
49
+ const parsed = match ? DateTime.fromObject({ year: Number(match[3]), month: Number(match[2]), day: Number(match[1]) }, { zone: 'UTC' }) : null;
50
+ return parsed?.isValid && parsed.year >= 2000 && parsed.toFormat('dd.MM.yyyy') === text ? parsed.toISODate() : null;
51
+ }
52
+ /**
53
+ * The delivery note (ATF) Horoz Lojistik's tracker shows at
54
+ * `frmAtfDetay.aspx?Ptn={number}`: a headline status, the note's details,
55
+ * the masked sender and recipient, and a table of movements, oldest first.
56
+ * Each movement is a departure from or an arrival at a centre, with its
57
+ * clock; a delivered parcel's last row is the delivery. A number the tracker
58
+ * does not know gets the same page with every field empty and no table.
59
+ *
60
+ * The page's form must name the whole number asked. The masked names, the
61
+ * provinces and addresses, the waybill, the vehicle plates, the destination
62
+ * centres and the record clocks are not read.
63
+ */
64
+ export function parseHoroz(html, raw) {
65
+ const number = normalizeHorozNumber(raw);
66
+ if (isBlockPage(html))
67
+ throw new ChallengeError(PROVIDER, 'Horoz Lojistik answered with a browser check');
68
+ const $ = load(html);
69
+ const forms = $('form#form1');
70
+ const action = forms.length === 1 ? FORM_ACTION.exec(forms.attr('action') ?? '') : null;
71
+ const note = $('span#lblatfno');
72
+ if (!action || note.length !== 1 || $('span#LblBaslik').length !== 1)
73
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned an unexpected page');
74
+ let asked;
75
+ try {
76
+ asked = decodeURIComponent(action[1]);
77
+ }
78
+ catch {
79
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned an unexpected page');
80
+ }
81
+ if (asked !== number)
82
+ throw new IndeterminateError(PROVIDER, 'Horoz Lojistik answered for another shipment');
83
+ const tables = $('table#grdAtfHareket');
84
+ if (!clean(note.text(), 40)) {
85
+ // Empty fields and no movements are the answer for an unknown number.
86
+ if (!tables.length)
87
+ throw new NotFoundError(PROVIDER);
88
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned movements without a delivery note');
89
+ }
90
+ if (!tables.length)
91
+ throw new NoHistoryError(PROVIDER);
92
+ const rows = tables.length === 1 ? tables.find('tr') : null;
93
+ const headings = rows?.first().children('td').map((_, element) => clean($(element).text(), 40)).get() ?? [];
94
+ if (!rows || headings.join('|') !== HEADINGS.join('|'))
95
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik history layout changed');
96
+ if (rows.length - 1 > MAX_ROWS)
97
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned an oversized history');
98
+ const seen = new Set();
99
+ const movements = [];
100
+ rows.slice(1).each((index, element) => {
101
+ const cells = $(element).children('td');
102
+ if (cells.length !== HEADINGS.length)
103
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned an invalid movement');
104
+ const description = clean(cells.eq(0).text(), 200);
105
+ if (!description)
106
+ throw new SchemaError(PROVIDER, 'Horoz Lojistik returned a movement without wording');
107
+ const centre = clean(cells.eq(1).text(), 200);
108
+ const local = wallClock(clean(cells.eq(2).text(), 60));
109
+ // A row the page lists twice is kept once.
110
+ const identity = JSON.stringify([local, description, centre]);
111
+ if (seen.has(identity))
112
+ return;
113
+ seen.add(identity);
114
+ const staged = horozStage(description);
115
+ movements.push({ local, index, event: {
116
+ local_time: local,
117
+ description,
118
+ ...(centre ? { location: centre } : {}),
119
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
120
+ } });
121
+ });
122
+ if (!movements.length)
123
+ throw new NoHistoryError(PROVIDER);
124
+ // Newest first; of equal clocks, the row further down the page is the newer.
125
+ movements.sort((left, right) => right.local.localeCompare(left.local) || right.index - left.index);
126
+ const events = movements.map(({ event }) => event);
127
+ const newest = events[0];
128
+ // The headline sums the note up; without a stage of its own, the newest staged movement stands.
129
+ const headline = clean($('span#LblBaslik').text(), 200);
130
+ const summary = headline ? horozStage(headline) : null;
131
+ const latest = events.find((event) => event.stage);
132
+ const current = summary ?? (latest ? { stage: latest.stage, source: latest.stage_source } : null);
133
+ const stage = current?.stage;
134
+ const expected = stage && FINAL.has(stage) ? null : expectedDay(clean($('span#lblolasiteslimtarihi').text(), 40));
135
+ return {
136
+ status: stage ? languageStageStatus(stage) : 'unknown',
137
+ ...(stage ? { current_stage: stage, current_stage_source: current.source } : {}),
138
+ last_status_text: headline || newest.description,
139
+ last_update: null,
140
+ last_update_local: newest.local_time,
141
+ expected_delivery: expected,
142
+ events,
143
+ };
144
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The stage Horoz Lojistik's map gives a wording in `normalizeStatusWording` form. */
4
+ export declare function horozMappedStage(wording: string): Stage | undefined;
5
+ /** A wording's stage: the map, else the shared wording rules, else nothing. */
6
+ export declare function horozStage(wording: string): ClassifiedWording | null;
7
+ /** Every wording seen is mapped. */
8
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,39 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The wordings of Horoz Lojistik's delivery note, which carry no code: the
5
+ * movements of its table, its headline, and the states complaints quote from
6
+ * its tracking screen. Another wording falls to the shared wording rules.
7
+ */
8
+ const WORDINGS = new Map([
9
+ // A departure from, or an arrival at, a centre: the sender's depot or one of Horoz's.
10
+ ['Çıkış', 'in_transit'],
11
+ ['Giriş', 'in_transit'],
12
+ ['dağıtım şubesinde', 'in_transit'],
13
+ ['dağıtımda', 'out_for_delivery'],
14
+ ['dağıtıma çıktı', 'out_for_delivery'],
15
+ // Not delivered: the recipient was not found, or the address was not enough.
16
+ ['adreste bulunamadı', 'failed_attempt'],
17
+ ['siparişiniz teslim edilememiştir', 'failed_attempt'],
18
+ ['adres yetersiz', 'failed_attempt'],
19
+ ['Teslim', 'delivered'],
20
+ ['teslim edildi', 'delivered'],
21
+ ['GÖNDERİNİZ TESLİM EDİLMİŞTİR', 'delivered'],
22
+ ].map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
23
+ /** The stage Horoz Lojistik's map gives a wording in `normalizeStatusWording` form. */
24
+ export function horozMappedStage(wording) {
25
+ return WORDINGS.get(wording);
26
+ }
27
+ /** A wording's stage: the map, else the shared wording rules, else nothing. */
28
+ export function horozStage(wording) {
29
+ const mapped = horozMappedStage(normalizeStatusWording(wording));
30
+ if (mapped)
31
+ return { stage: mapped, source: 'carrier_map' };
32
+ const worded = classifyWording(wording, 'in_transit');
33
+ return worded.source === 'none' ? null : worded;
34
+ }
35
+ /** Every wording seen is mapped. */
36
+ export const statusMap = {
37
+ stage: (_code, wording) => horozMappedStage(wording),
38
+ gaps: [],
39
+ };
@@ -4,6 +4,7 @@
4
4
  {"wording": "Çıkış", "stage": "in_transit", "language": "tr", "confirmedBy": "portal", "note": "Departure from a centre, in the delivery note's movement list."},
5
5
  {"wording": "Giriş", "stage": "in_transit", "language": "tr", "confirmedBy": "portal", "note": "Arrival at a centre, in the delivery note's movement list."},
6
6
  {"wording": "Teslim", "stage": "delivered", "language": "tr", "confirmedBy": "portal", "note": "Last movement of a delivered parcel."},
7
+ {"wording": "GÖNDERİNİZ TESLİM EDİLMİŞTİR", "stage": "delivered", "language": "tr", "confirmedBy": "portal", "note": "The delivery note's headline once the parcel is delivered."},
7
8
  {"wording": "dağıtım şubesinde", "stage": "in_transit", "language": "tr", "confirmedBy": "source", "note": "Quoted in Şikayetvar complaints as the tracking state; at the delivery branch."},
8
9
  {"wording": "dağıtımda", "stage": "out_for_delivery", "language": "tr", "confirmedBy": "source", "note": "Quoted in Şikayetvar complaints as the tracking state."},
9
10
  {"wording": "dağıtıma çıktı", "stage": "out_for_delivery", "language": "tr", "confirmedBy": "source", "note": "Quoted in a Şikayetvar complaint as the tracking state."},
@@ -8,7 +8,7 @@ import { uspsPackageIdentifier } from '../../core/detection/usps.js';
8
8
  import { runSteps, singleFlight, takeTurn } from '../../core/runner/index.js';
9
9
  import { languageStageStatus } from '../../core/status/index.js';
10
10
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
11
- import { calendarDay } from '../../core/time/index.js';
11
+ import { calendarDay, countryCode, countryTimeZone, firstOccurrence } from '../../core/time/index.js';
12
12
  import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, TRAWL_TRANSPORT_ALLOWANCE_MS } from '../../core/transport/index.js';
13
13
  import { isRecord } from '../../core/types.js';
14
14
  import { UPSApiClient, upsApiCredentials, upsApiNotFound, upsApiNotices } from './api.js';
@@ -314,6 +314,35 @@ function statedOffset(stated) {
314
314
  const offset = /^([+-])(0\d|1[0-4]):([0-5]\d)$/.exec(stated);
315
315
  return offset ? (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3])) : null;
316
316
  }
317
+ /**
318
+ * The zone of a scan's place, from its country's code or English name, when the country keeps one
319
+ * civil time. Spain and Portugal have none here: their islands keep a clock an hour from the
320
+ * mainland's, the offset the mainland keeps in the other season.
321
+ */
322
+ function placeZone(country) {
323
+ const code = countryCode(country);
324
+ return code && code !== 'ES' && code !== 'PT' ? countryTimeZone(code) : null;
325
+ }
326
+ /**
327
+ * A scan UPS states on its place's offset for the other season, one the place's zone does not
328
+ * keep at that wall clock but keeps at another time of the year, dated on the place's clock.
329
+ * After the clocks went back, UPS dated deliveries in France on the summer offset: each reached
330
+ * its answer an hour after the instant that offset gives, and the wall clock was the place's.
331
+ * The scan keeps that wall clock, and the instant says its zone is the place's.
332
+ */
333
+ function placeClock(wall, offset, zone) {
334
+ if (!zone)
335
+ return null;
336
+ const readings = DateTime.fromISO(wall, { zone }).getPossibleOffsets();
337
+ if (!readings.length || readings.some((reading) => reading.offset === offset))
338
+ return null;
339
+ const year = Number(wall.slice(0, 4));
340
+ const seasons = [1, 7].map((month) => DateTime.utc(year, month, 1).setZone(zone).offset);
341
+ if (seasons[0] === seasons[1] || !seasons.includes(offset))
342
+ return null;
343
+ const time = firstOccurrence(DateTime.fromISO(wall, { zone })).toISO({ suppressMilliseconds: true });
344
+ return time ? { time, local_time: wall, time_zone: zone, time_zone_basis: 'place' } : null;
345
+ }
317
346
  /** The UTC pair, `gmtDate` (YYYYMMDD) and `gmtTime` (HH:MM:SS; the API's schema also shows HMMSS), as an ISO wall clock. */
318
347
  function utcPair(activity) {
319
348
  const date = /^(\d{4})(\d{2})(\d{2})$/.exec(cleanScalar(activity.gmtDate));
@@ -328,30 +357,37 @@ function utcPair(activity) {
328
357
  * the second, is written on that offset when the local pair reads the same
329
358
  * minute on it, so the scan keeps its place's clock; otherwise it stays in
330
359
  * UTC. A zero offset is written as UTC was, `+00:00`. Without the UTC pair,
331
- * the local pair and its offset become the time, and no zone is ever guessed.
332
- * A local pair without an offset stays a `local_time`, a scan UPS dates by
333
- * its day alone, as it does some drop-offs, keeps that day, other text stays
334
- * as UPS wrote it, and a scan with no clock at all, like some label scans,
335
- * gets none. The page's reply and the official API write the local pair
336
- * differently and the rest alike, so one scan gets one time from either.
360
+ * the local pair and its offset become the time. An offset the place cannot
361
+ * have at that time of year gives way to the place's zone (`placeClock`);
362
+ * otherwise no zone is guessed. A local pair without an offset stays a
363
+ * `local_time`, a scan UPS dates by its day alone, as it does some drop-offs,
364
+ * keeps that day, other text stays as UPS wrote it, and a scan with no clock
365
+ * at all, like some label scans, gets none. The page's reply and the official
366
+ * API write the local pair differently and the rest alike, so one scan gets
367
+ * one time from either.
337
368
  */
338
- function scanClock(activity, local, day, raw) {
369
+ function scanClock(activity, local, day, raw, zone) {
339
370
  const stated = cleanScalar(activity.gmtOffset);
340
371
  const offset = statedOffset(stated);
341
372
  const utc = utcPair(activity);
342
373
  if (utc) {
343
- const shown = offset ? DateTime.fromISO(utc, { zone: 'utc' }).setZone(FixedOffsetZone.instance(offset)) : null;
344
- return { time: shown?.isValid && local && shown.toFormat("yyyy-MM-dd'T'HH:mm") === local.slice(0, 16)
345
- ? shown.toISO({ suppressMilliseconds: true }) : `${utc}+00:00` };
374
+ const shown = offset === null ? null : DateTime.fromISO(utc, { zone: 'utc' }).setZone(FixedOffsetZone.instance(offset));
375
+ if (!shown?.isValid || !local || shown.toFormat("yyyy-MM-dd'T'HH:mm") !== local.slice(0, 16))
376
+ return { time: `${utc}+00:00` };
377
+ return placeClock(shown.toFormat("yyyy-MM-dd'T'HH:mm:ss"), offset, zone)
378
+ ?? { time: offset ? shown.toISO({ suppressMilliseconds: true }) : `${utc}+00:00` };
379
+ }
380
+ if (local) {
381
+ if (offset === null)
382
+ return /^[+-]\d{2}:\d{2}$/.test(stated) ? { time: `${local}${stated}` } : { local_time: local };
383
+ return placeClock(local, offset, zone) ?? { time: `${local}${stated}` };
346
384
  }
347
- if (local)
348
- return /^[+-]\d{2}:\d{2}$/.test(stated) ? { time: `${local}${stated}` } : { local_time: local };
349
385
  if (day)
350
386
  return { local_time: day };
351
387
  return raw ? { provider_time_text: raw } : {};
352
388
  }
353
- /** The clock of a scan in the page's reply, whose local pair reads `08/04/2026` and `9:12 A.M.`. */
354
- function activityClock(activity) {
389
+ /** The clock of a scan in the page's reply, whose local pair reads `08/04/2026` and `9:12 A.M.`, at a place such as `ZUERICH, CH`. */
390
+ function activityClock(activity, place) {
355
391
  const localDate = cleanScalar(activity.date);
356
392
  const localTime = cleanScalar(activity.time);
357
393
  const dateMatch = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(localDate);
@@ -361,7 +397,7 @@ function activityClock(activity) {
361
397
  ? `${dateMatch[3]}-${dateMatch[1]}-${dateMatch[2]}T${String(hour % 12 + (timeMatch[3].toUpperCase() === 'P' ? 12 : 0)).padStart(2, '0')}:${timeMatch[2]}:00`
362
398
  : null;
363
399
  const day = dateMatch && !localTime ? calendarDay(Number(dateMatch[3]), Number(dateMatch[1]), Number(dateMatch[2])) : null;
364
- return scanClock(activity, local, day, clean(`${localDate} ${localTime}`));
400
+ return scanClock(activity, local, day, clean(`${localDate} ${localTime}`), placeZone(place.split(',').at(-1).trim()));
365
401
  }
366
402
  /** The clock of a scan in the official API's reply, whose local pair reads `20260804` and `091204`. */
367
403
  function apiActivityClock(activity) {
@@ -371,7 +407,8 @@ function apiActivityClock(activity) {
371
407
  const timeMatch = /^([01]\d|2[0-3])([0-5]\d)([0-5]\d)$/.exec(localTime);
372
408
  const day = dateMatch ? calendarDay(Number(dateMatch[1]), Number(dateMatch[2]), Number(dateMatch[3])) : null;
373
409
  const local = day && timeMatch ? `${day}T${timeMatch[1]}:${timeMatch[2]}:${timeMatch[3]}` : null;
374
- return scanClock(activity, local, localTime ? null : day, clean(`${localDate} ${localTime}`));
410
+ const address = isRecord(activity.location) && isRecord(activity.location.address) ? activity.location.address : {};
411
+ return scanClock(activity, local, localTime ? null : day, clean(`${localDate} ${localTime}`), placeZone(cleanScalar(address.countryCode || address.country, 8)));
375
412
  }
376
413
  const MONTHS = {
377
414
  jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
@@ -455,7 +492,7 @@ export function parseUPSTrackingResponse(payload, trackingNumber, today = new Da
455
492
  const code = cleanScalar(raw.actCode, 8).toUpperCase();
456
493
  const stage = code ? upsActivityStage(code) : upsUncodedScanStage(description);
457
494
  events.push({
458
- ...activityClock(raw),
495
+ ...activityClock(raw, text(raw.location)),
459
496
  location: pagePlace(text(raw.location)),
460
497
  description,
461
498
  ...(/^[A-Z0-9]{1,4}$/.test(code) ? { provider_code: code } : {}),
@@ -19,9 +19,11 @@ function sameTown(incoming, stored) {
19
19
  }
20
20
  // UPS can add a location later. Its wording distinguishes scans sharing a clock,
21
21
  // unless both carry an activity code: the official API words a scan without the
22
- // page's second sentence and writes its town in capitals.
22
+ // page's second sentence and writes its town in capitals. A scan UPS stated on the
23
+ // offset its place keeps in the other season, which a release dates on the place's
24
+ // clock, keeps its row.
23
25
  export const sameInstantIdentityPolicy = {
24
- sourceCarrierId: 'ups', storedSources: ['ups'], requireProviderCode: false,
26
+ sourceCarrierId: 'ups', storedSources: ['ups'], requireProviderCode: false, matchWallClock: true,
25
27
  matches(incoming, stored) {
26
28
  if (incoming.stage === '' || incoming.stage === 'unknown' || !sameActivity(incoming, stored))
27
29
  return false;
@@ -285,6 +285,7 @@ import { statusMap as hermesDe } from '../../carriers/hermes-de/status.js';
285
285
  import { statusMap as hispapost } from '../../carriers/hispapost/status.js';
286
286
  import { statusMap as homexpress } from '../../carriers/homexpress/status.js';
287
287
  import { statusMap as hongkongPost } from '../../carriers/hongkong-post/status.js';
288
+ import { statusMap as horoz } from '../../carriers/horoz/status.js';
288
289
  import { statusMap as houngAhLoun } from '../../carriers/houng-ah-loun/status.js';
289
290
  import { statusMap as hovership } from '../../carriers/hovership/status.js';
290
291
  import { statusMap as hrParcel } from '../../carriers/hr-parcel/status.js';
@@ -680,7 +681,7 @@ export const STATUS_MAPS = {
680
681
  globalpost, 'gms-worldwide': gmsWorldwide, 'go-express': goExpress, go4, gobolt, gofo, 'gofo-ca': gofoCa, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, 'gofo-nl': gofoNl, 'gogo-xpress': gogoXpress,
681
682
  'grand-slam-express': grandSlamExpress, 'gs-postbox': gsPostbox, 'gsi-express': gsiExpress, 'gta-airskip': gtaAirskip, 'gtl-goto': gtlGoto, guatex, gzyl,
682
683
  hanjin, 'happy-post': happyPost, haypost, 'hct-logistics': hctLogistics, helthjem, heppner, hepsijet, hermes, 'hermes-at': hermesAt, 'hermes-de': hermesDe, hispapost, homexpress, 'hongkong-post': hongkongPost,
683
- 'houng-ah-loun': houngAhLoun, hovership, 'hr-parcel': hrParcel, 'hrvatska-posta': hrvatskaPosta, huahan, 'hunter-express': hunterExpress, hywl,
684
+ horoz, 'houng-ah-loun': houngAhLoun, hovership, 'hr-parcel': hrParcel, 'hrvatska-posta': hrvatskaPosta, huahan, 'hunter-express': hunterExpress, hywl,
684
685
  'icc-worldwide': iccWorldwide, 'ics-courier': icsCourier, 'ids-logistik': idsLogistik, 'ilyang-logis': ilyangLogis, imile,
685
686
  'indah-logistik': indahLogistik, 'india-post': indiaPost, inpost, instabox, 'integral-pack': integralPack, 'intel-valley': intelValley, intelcom, 'internet-express': internetExpress, interrapidisimo,
686
687
  'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-au': jAndTAu, 'j-and-t-de': jAndTDe, 'j-and-t-cargo': jntCargo,
@@ -16959,7 +16959,8 @@
16959
16959
  "timezone": "Europe/Istanbul",
16960
16960
  "tracking": {
16961
16961
  "mode": "automatic",
16962
- "adapter": "universal"
16962
+ "adapter": "horoz",
16963
+ "localClocks": true
16963
16964
  },
16964
16965
  "canaryUrl": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
16965
16966
  "trackingUrlTemplate": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}",
@@ -10990,7 +10990,8 @@ export declare const CARRIER_CATALOG: {
10990
10990
  readonly timezone: "Europe/Istanbul";
10991
10991
  readonly tracking: {
10992
10992
  readonly mode: "automatic";
10993
- readonly adapter: "universal";
10993
+ readonly adapter: "horoz";
10994
+ readonly localClocks: true;
10994
10995
  };
10995
10996
  readonly canaryUrl: "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx";
10996
10997
  readonly trackingUrlTemplate: "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}";
@@ -16960,7 +16960,8 @@ export const CARRIER_CATALOG = {
16960
16960
  "timezone": "Europe/Istanbul",
16961
16961
  "tracking": {
16962
16962
  "mode": "automatic",
16963
- "adapter": "universal"
16963
+ "adapter": "horoz",
16964
+ "localClocks": true
16964
16965
  },
16965
16966
  "canaryUrl": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmGonderiTakip.aspx",
16966
16967
  "trackingUrlTemplate": "https://app3.horoz.com.tr/wsKurumsal/_genel/frmAtfDetay.aspx?Ptn={trackingNumber}",
@@ -274,6 +274,7 @@ import { adapter as hermesDe } from '../carriers/hermes-de/adapter.js';
274
274
  import { adapter as hispapost } from '../carriers/hispapost/adapter.js';
275
275
  import { adapter as homexpress } from '../carriers/homexpress/adapter.js';
276
276
  import { adapter as hongkongPost } from '../carriers/hongkong-post/adapter.js';
277
+ import { adapter as horoz } from '../carriers/horoz/adapter.js';
277
278
  import { adapter as houngAhLoun } from '../carriers/houng-ah-loun/adapter.js';
278
279
  import { adapter as hovership } from '../carriers/hovership/adapter.js';
279
280
  import { adapter as hrParcel } from '../carriers/hr-parcel/adapter.js';
@@ -907,6 +908,7 @@ export const REGISTRY = {
907
908
  "hispapost": hispapost,
908
909
  "homexpress": homexpress,
909
910
  "hongkong-post": hongkongPost,
911
+ "horoz": horoz,
910
912
  "houng-ah-loun": houngAhLoun,
911
913
  "hovership": hovership,
912
914
  "hr-parcel": hrParcel,
@@ -1603,7 +1605,7 @@ export const REGISTRY = {
1603
1605
  "hispapost": "hispapost",
1604
1606
  "homexpress": "homexpress",
1605
1607
  "hongkong-post": "hongkong-post",
1606
- "horoz": "universal",
1608
+ "horoz": "horoz",
1607
1609
  "hound-express": "universal",
1608
1610
  "houng-ah-loun": "houng-ah-loun",
1609
1611
  "hovership": "hovership",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.769.9-main.4113"
5
+ "version": "0.770.1-main.4120"
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.769.9-main.4113",
3
+ "version": "0.770.1-main.4120",
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",