universal-parcel-scraper 0.487.0-main.2349 → 0.488.0-main.2356

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 **530 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **531 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,354 such statuses, each filed under one stage.
158
+ The carrier folders record 15,361 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
@@ -8915,7 +8915,9 @@
8915
8915
  "timezone": "Asia/Seoul",
8916
8916
  "tracking": {
8917
8917
  "mode": "automatic",
8918
- "adapter": "universal"
8918
+ "adapter": "chunil",
8919
+ "recognitionRank": 621,
8920
+ "localClocks": true
8919
8921
  },
8920
8922
  "canaryUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
8921
8923
  "trackingUrlTemplate": "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 772,
3
- "activeAdapters": 530,
3
+ "activeAdapters": 531,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -9406,6 +9406,28 @@
9406
9406
  "j-and-t"
9407
9407
  ]
9408
9408
  },
9409
+ {
9410
+ "input": "10000000001",
9411
+ "carrier": "unknown",
9412
+ "confidence": "low",
9413
+ "candidates": [
9414
+ "postlogistics",
9415
+ "gls-ch",
9416
+ "gls-fr",
9417
+ "gls-de",
9418
+ "dhl-ecommerce-pl",
9419
+ "gls-at",
9420
+ "gls-pl",
9421
+ "ppl"
9422
+ ],
9423
+ "asked": [
9424
+ "gls-fr",
9425
+ "gls-de",
9426
+ "gls-ch",
9427
+ "postlogistics",
9428
+ "dhl-ecommerce-pl"
9429
+ ]
9430
+ },
9409
9431
  {
9410
9432
  "input": "1000000157123",
9411
9433
  "carrier": "unknown",
@@ -13483,28 +13505,6 @@
13483
13505
  "zto"
13484
13506
  ]
13485
13507
  },
13486
- {
13487
- "input": "11633265509",
13488
- "carrier": "unknown",
13489
- "confidence": "low",
13490
- "candidates": [
13491
- "postlogistics",
13492
- "gls-ch",
13493
- "gls-fr",
13494
- "gls-de",
13495
- "dhl-ecommerce-pl",
13496
- "gls-at",
13497
- "gls-pl",
13498
- "ppl"
13499
- ],
13500
- "asked": [
13501
- "gls-fr",
13502
- "gls-de",
13503
- "gls-ch",
13504
- "postlogistics",
13505
- "dhl-ecommerce-pl"
13506
- ]
13507
- },
13508
13508
  {
13509
13509
  "input": "116439049496",
13510
13510
  "carrier": "unknown",
@@ -7075,7 +7075,9 @@ var Ue = {
7075
7075
  timezone: "Asia/Seoul",
7076
7076
  tracking: {
7077
7077
  mode: "automatic",
7078
- adapter: "universal"
7078
+ adapter: "chunil",
7079
+ recognitionRank: 621,
7080
+ localClocks: !0
7079
7081
  },
7080
7082
  canaryUrl: "https://www.chunil.co.kr/HTrace/HTrace.jsp",
7081
7083
  trackingUrlTemplate: "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -29550,6 +29552,7 @@ var Tl = {
29550
29552
  "chit-chats": 115,
29551
29553
  chronodiali: 781,
29552
29554
  chronopost: 890,
29555
+ chunil: 621,
29553
29556
  ciblex: 640,
29554
29557
  "city-express-rs": 817,
29555
29558
  citymail: 868,
@@ -1,5 +1,7 @@
1
1
  import { type ClassifiedWording } from '../../core/status/index.js';
2
2
  import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** Chronopost's notice to the recipient, which providers relay as it is. */
4
+ export declare const CHRONOPOST_NOTIFICATION = "Destinataire inform\u00E9 par SMS ou mail";
3
5
  /** A notice to the recipient, or the recipient's own delivery instruction: neither is a scan. */
4
6
  export declare function isChronopostNotification(label: string): boolean;
5
7
  /** The scan that sends the parcel back to its sender. */
@@ -52,6 +52,8 @@ for (const [code, label, stage] of OBSERVED)
52
52
  observed.set(code, (observed.get(code) ?? new Map()).set(label, stage));
53
53
  const NOTIFICATION = 'Destinataire informé par SMS ou mail';
54
54
  const INSTRUCTION = 'Instruction de livraison reçue';
55
+ /** Chronopost's notice to the recipient, which providers relay as it is. */
56
+ export const CHRONOPOST_NOTIFICATION = NOTIFICATION;
55
57
  /** A notice to the recipient, or the recipient's own delivery instruction: neither is a scan. */
56
58
  export function isChronopostNotification(label) {
57
59
  return label === NOTIFICATION || label === INSTRUCTION;
@@ -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 page the tracking fields on Chunil's sites open for a waybill. */
5
+ export declare const CHUNIL_TRACE = "https://www.chunil.co.kr/HTrace/HTrace.jsp";
6
+ /** The page's address for one whole waybill. */
7
+ export declare function chunilTraceUrl(number: string): string;
8
+ export declare class ChunilTracker {
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,54 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { 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 { CHUNIL_MAX_BYTES, normalizeChunilNumber, parseChunil, PROVIDER } from './parser.js';
8
+ /** The page the tracking fields on Chunil's sites open for a waybill. */
9
+ export const CHUNIL_TRACE = 'https://www.chunil.co.kr/HTrace/HTrace.jsp';
10
+ const REDIRECTS = [301, 302, 303, 307, 308];
11
+ // The page answers an unknown number with HTTP 200; a missing route is not a missing parcel.
12
+ const GONE = [404, 410];
13
+ // The page is served as UTF-8; an older Korean page would name EUC-KR or its Windows superset.
14
+ const KOREAN_CHARSET = /charset=["']?(?:euc-kr|ks_c_5601-1987|cp949|windows-949)\b/i;
15
+ /** The page's address for one whole waybill. */
16
+ export function chunilTraceUrl(number) {
17
+ return `${CHUNIL_TRACE}?${new URLSearchParams({ transNo: normalizeChunilNumber(number) })}`;
18
+ }
19
+ export class ChunilTracker {
20
+ options;
21
+ constructor(options = {}) {
22
+ this.options = options;
23
+ }
24
+ async fetch(raw, context = {}) {
25
+ const number = normalizeChunilNumber(raw);
26
+ const read = async (signal, timeoutMs) => {
27
+ const { response, bytes } = await fetchBounded(chunilTraceUrl(number), { method: 'GET', signal, headers: {
28
+ Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent),
29
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: CHUNIL_MAX_BYTES, redirect: 'manual',
30
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, ...GONE] });
31
+ if (REDIRECTS.includes(response.status))
32
+ throw new TransportError(PROVIDER, 'Chunil Express redirected its tracking page');
33
+ if (GONE.includes(response.status))
34
+ throw new TransportError(PROVIDER, 'Chunil Express tracking page is unavailable');
35
+ const encoding = KOREAN_CHARSET.test(response.headers.get('content-type') ?? '') ? 'euc-kr' : 'utf-8';
36
+ return parseChunil(decodeText(bytes, encoding), number);
37
+ };
38
+ // A request that fails to reach the site, or hangs, gets one more try: the
39
+ // first gets half of the budget, so the retry fits in the rest.
40
+ return runSteps({ carrier: 'chunil', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
41
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
42
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
43
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
44
+ ]);
45
+ }
46
+ }
47
+ export const adapter = (environment) => {
48
+ const tracker = new ChunilTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
49
+ return {
50
+ id: 'chunil', recordsSteps: true, steps: ['direct', 'retry'],
51
+ track: (input, context) => tracker.fetch(input.number, context),
52
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeChunilNumber(number))),
53
+ };
54
+ };
@@ -18,18 +18,61 @@
18
18
  "timezone": "Asia/Seoul",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "chunil",
22
+ "steps": [
23
+ "direct",
24
+ "retry"
25
+ ],
26
+ "recognition": {
27
+ "rank": 621
28
+ },
29
+ "localClocks": true
23
30
  },
24
- "capabilities": [],
31
+ "capabilities": [
32
+ "history",
33
+ "location"
34
+ ],
25
35
  "portal": {
26
36
  "url": "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
27
37
  "searchUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
28
38
  "canaryUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
29
- "shows": [],
30
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
39
+ "shows": [
40
+ "status",
41
+ "history",
42
+ "location",
43
+ "sender_name",
44
+ "recipient_name",
45
+ "signatory",
46
+ "origin",
47
+ "destination",
48
+ "office_contact_details",
49
+ "order_contents",
50
+ "parcel_count"
51
+ ],
52
+ "scraped": [
53
+ "status",
54
+ "history",
55
+ "location"
56
+ ],
57
+ "discarded": [
58
+ "sender_name",
59
+ "recipient_name",
60
+ "signatory",
61
+ "origin",
62
+ "destination",
63
+ "office_contact_details",
64
+ "order_contents",
65
+ "parcel_count"
66
+ ],
67
+ "unavailable": [
68
+ "offset_timestamps",
69
+ "delivered_at",
70
+ "eta",
71
+ "weight",
72
+ "service_name",
73
+ "provider_code",
74
+ "pickup_point"
75
+ ]
33
76
  },
34
77
  "links": [
35
78
  {
@@ -2,18 +2,11 @@
2
2
  "carrier": "chunil",
3
3
  "records": [
4
4
  {
5
- "number": "11633265509",
5
+ "number": "10000000001",
6
6
  "role": "shipment",
7
- "evidence": "public_shipment_report",
8
- "source": {
9
- "url": "https://web.archive.org/web/20240229082427/http://www.chunil.co.kr/HTrace/HTrace.jsp?transNo=11633265509",
10
- "date": "2024-02-29",
11
- "dateKind": "capture",
12
- "access": "full_page"
13
- },
14
- "context": {
15
- "assessment": "confirmed"
16
- },
7
+ "evidence": "synthetic",
8
+ "derivedFrom": "invented",
9
+ "shapeConfirmed": "2026-10-19",
17
10
  "expect": {
18
11
  "carrier": "unknown",
19
12
  "confidence": "low",
@@ -28,7 +21,7 @@
28
21
  "ppl"
29
22
  ]
30
23
  },
31
- "note": "The opened capture of Chunil Express's tracker shows the parcel with seven scans from acceptance in Daejeon to delivery."
24
+ "note": "Invented placeholder for an eleven-digit waybill; the public sample's tracker names its sender and recipient, masked."
32
25
  }
33
26
  ]
34
27
  }
@@ -0,0 +1,14 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Chunil Express";
3
+ export declare const CHUNIL_MAX_BYTES: number;
4
+ /** A Chunil Express waybill: eleven digits, as its tracking form asks for them. */
5
+ export declare function normalizeChunilNumber(raw: string): string;
6
+ /**
7
+ * The page `www.chunil.co.kr/HTrace/HTrace.jsp?transNo={number}` answers. The
8
+ * search field keeps the number asked; a parcel's details are headed by it,
9
+ * and its route table lists the scans oldest first, each with its day,
10
+ * branch, the branch's phone number and status. The sender, the recipient and
11
+ * the signer (masked names), the goods, the parcel count and the branches'
12
+ * phone numbers are not read.
13
+ */
14
+ export declare function parseChunil(html: string, raw: string): CarrierResult;
@@ -0,0 +1,141 @@
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 { chunilStage } from './status.js';
8
+ export const PROVIDER = 'Chunil Express';
9
+ // A parcel's page is about 20 kilobytes.
10
+ export const CHUNIL_MAX_BYTES = 512 * 1024;
11
+ const NUMBER = /^\d{11}$/;
12
+ // Date, branch, phone and status.
13
+ const COLUMNS = '날짜|영업소|전화번호|상태';
14
+ // The heading above a parcel: "below is the result of looking up waybill number {number}".
15
+ const RESULT_FOR = '조회한 결과입니다';
16
+ // The page's own answer for a number it has no parcel for: "there is no result".
17
+ const MISSING = '결과가 없습니다.';
18
+ // The delivery result's label in the parcel's details.
19
+ const OUTCOME = '배송/출고결과:';
20
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
21
+ // Bot checks a CDN or firewall could put in place of the page.
22
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|g-recaptcha|h-captcha|AwsWafIntegration|awswaf\.com/i;
23
+ // A run of nine or more digits, with hyphens, dots, spaces or brackets between them, as phone numbers are written.
24
+ const PHONE = /(?<!\d)\+?\d(?:[\s().-]{0,2}\d){8,}(?!\d)/g;
25
+ const MAX_ROWS = 500;
26
+ const MAX_EVENTS = 100;
27
+ /** A Chunil Express waybill: eleven digits, as its tracking form asks for them. */
28
+ export function normalizeChunilNumber(raw) {
29
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
30
+ if (!NUMBER.test(number))
31
+ throw new InvalidInputError(PROVIDER, 'Chunil Express waybills are eleven digits');
32
+ return number;
33
+ }
34
+ function invalid(message) {
35
+ throw new SchemaError(PROVIDER, message);
36
+ }
37
+ /** A scan's day, as the page writes it; the history gives no time of day. */
38
+ function scanDay(text) {
39
+ const match = DAY.exec(text);
40
+ const day = match ? calendarDay(Number(match[1]), Number(match[2]), Number(match[3])) : null;
41
+ return day ?? invalid('Chunil Express returned an invalid scan date');
42
+ }
43
+ /** The value beside a label in one of the parcel's detail tables, by the label without its spaces. */
44
+ function detail($, label) {
45
+ const values = $('table.table02 tr').filter((_, row) => clean($(row).children('td').first().text()).replace(/\s/g, '') === label)
46
+ .map((_, row) => clean($(row).children('td').eq(1).text(), 80)).get();
47
+ if (values.length > 1)
48
+ invalid('Chunil Express returned repeated parcel details');
49
+ return values[0] ?? '';
50
+ }
51
+ /**
52
+ * The page `www.chunil.co.kr/HTrace/HTrace.jsp?transNo={number}` answers. The
53
+ * search field keeps the number asked; a parcel's details are headed by it,
54
+ * and its route table lists the scans oldest first, each with its day,
55
+ * branch, the branch's phone number and status. The sender, the recipient and
56
+ * the signer (masked names), the goods, the parcel count and the branches'
57
+ * phone numbers are not read.
58
+ */
59
+ export function parseChunil(html, raw) {
60
+ const number = normalizeChunilNumber(raw);
61
+ const $ = load(html);
62
+ const field = $('input#transNo');
63
+ if (field.length !== 1) {
64
+ if (CHALLENGE.test(html.slice(0, 64_000)))
65
+ throw new ChallengeError(PROVIDER, 'Chunil Express answered with a browser check');
66
+ invalid('Chunil Express returned a page without its search field');
67
+ }
68
+ // The page trims the number it was sent and keeps it in its search field.
69
+ const asked = clean(field.attr('value'), 32);
70
+ const history = $('table#tracking');
71
+ const missing = $('td').filter((_, cell) => clean($(cell).text()) === MISSING);
72
+ if (missing.length) {
73
+ if (history.length || missing.length > 1)
74
+ invalid('Chunil Express returned an unexpected empty answer');
75
+ if (asked !== number)
76
+ throw new IndeterminateError(PROVIDER, 'Chunil Express answered for another number');
77
+ throw new NotFoundError(PROVIDER);
78
+ }
79
+ const heading = $('b').filter((_, bold) => clean($(bold).closest('td').text()).includes(RESULT_FOR))
80
+ .map((_, bold) => clean($(bold).text(), 32)).get();
81
+ if (heading.length !== 1 || history.length !== 1)
82
+ invalid('Chunil Express returned a page without a parcel');
83
+ if (heading[0] !== number || asked !== number)
84
+ throw new IndeterminateError(PROVIDER, 'Chunil Express answered for another parcel');
85
+ const rows = history.find('tr').toArray();
86
+ const columns = $(rows[0]).children('td').map((_, cell) => clean($(cell).text())).get().join('|');
87
+ if (columns !== COLUMNS)
88
+ invalid('Chunil Express returned an unexpected route table');
89
+ if (rows.length > MAX_ROWS)
90
+ invalid('Chunil Express returned an oversized history');
91
+ const seen = new Set();
92
+ const listed = [];
93
+ for (const row of rows.slice(1)) {
94
+ const cells = $(row).children('td');
95
+ if (cells.length !== 4)
96
+ invalid('Chunil Express returned an incomplete scan');
97
+ const local_time = scanDay(clean(cells.eq(0).text(), 16));
98
+ // The branch; its phone number, in the next column, is not read.
99
+ const location = clean(cells.eq(1).text().replace(PHONE, ' ').replace(/\(\s*\)/g, ' '), 80);
100
+ const description = clean(cells.eq(3).text(), 80);
101
+ if (!description)
102
+ invalid('Chunil Express returned a scan without its status');
103
+ const key = JSON.stringify([local_time, description, location]);
104
+ if (seen.has(key))
105
+ continue;
106
+ seen.add(key);
107
+ const staged = chunilStage(description);
108
+ listed.push({
109
+ local_time,
110
+ ...(location ? { location } : {}),
111
+ description,
112
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
113
+ });
114
+ }
115
+ // Newest first, in the page's order: scans of one day cannot be told apart by their clocks.
116
+ const events = listed.reverse();
117
+ const outcome = detail($, OUTCOME);
118
+ if (!events.length && !outcome)
119
+ throw new NoHistoryError(PROVIDER);
120
+ // A delivery, in the route or as the delivery result, settles the parcel. Otherwise the newest
121
+ // staged scan says where it is, and the delivery result stands in for a route without one.
122
+ const result = outcome ? chunilStage(outcome) : null;
123
+ const delivery = events.find((event) => event.stage === 'delivered');
124
+ const staged = events.find((event) => event.stage);
125
+ const decided = delivery ? { stage: 'delivered', source: delivery.stage_source }
126
+ : result?.stage === 'delivered' ? result
127
+ : staged ? { stage: staged.stage, source: staged.stage_source } : result;
128
+ const newest = events[0];
129
+ const fromOutcome = decided === result && result !== null && !delivery;
130
+ return {
131
+ status: decided ? languageStageStatus(decided.stage) : 'unknown',
132
+ ...(decided ? { current_stage: decided.stage, current_stage_source: decided.source } : {}),
133
+ last_status_text: fromOutcome ? outcome : newest?.description ?? outcome,
134
+ last_update: null,
135
+ ...(newest && !fromOutcome ? { last_update_local: newest.local_time } : {}),
136
+ expected_delivery: null,
137
+ ...(events.length ? {} : { summary_only: true }),
138
+ events: events.slice(0, MAX_EVENTS),
139
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
140
+ };
141
+ }
@@ -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 Chunil's labels give a status, if the map knows it. */
4
+ export declare function chunilMappedStage(label: string): Stage | undefined;
5
+ /** A status's stage: the map, else the shared wording rules, else nothing. */
6
+ export declare function chunilStage(label: string): ClassifiedWording | null;
7
+ /** What the map says about one scan, by its label. */
8
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,39 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ /**
3
+ * The labels of the route table's 상태 (status) column, which are also the
4
+ * values of the delivery result. Rows carry no code. The labels seen on the
5
+ * tracker are mapped, with those an open-source Korean tracker lists for this
6
+ * page; another label falls to the wording rules.
7
+ */
8
+ const LABELS = new Map([
9
+ ['접수', 'accepted'], // The sending branch took the parcel in.
10
+ ['발송', 'in_transit'], // Sent on from the sending branch.
11
+ ['발송터미널하차', 'in_transit'], // Unloaded at the origin terminal.
12
+ ['발송터미널출발', 'in_transit'], // Left the origin terminal.
13
+ ['간선상차', 'in_transit'], // Loaded onto a trunk-line truck.
14
+ ['간선하차', 'in_transit'], // Unloaded from a trunk-line truck.
15
+ ['중계도착', 'in_transit'], // Arrived at a relay terminal.
16
+ ['중계발송', 'in_transit'], // Left a relay terminal.
17
+ ['도착터미널하차', 'in_transit'], // Unloaded at the destination terminal.
18
+ ['도착', 'in_transit'], // Arrived; the page's template places it before the terminal scans.
19
+ ['영업소도착', 'in_transit'], // Arrived at the delivering branch.
20
+ ['배송출발', 'out_for_delivery'], // Left the delivering branch for the recipient.
21
+ ['배송완료', 'delivered'],
22
+ ]);
23
+ /** The stage Chunil's labels give a status, if the map knows it. */
24
+ export function chunilMappedStage(label) {
25
+ return LABELS.get(label.replace(/\s/g, ''));
26
+ }
27
+ /** A status's stage: the map, else the shared wording rules, else nothing. */
28
+ export function chunilStage(label) {
29
+ const mapped = chunilMappedStage(label);
30
+ if (mapped)
31
+ return { stage: mapped, source: 'carrier_map' };
32
+ const worded = classifyWording(label, 'in_transit');
33
+ return worded.source === 'none' ? null : worded;
34
+ }
35
+ /** What the map says about one scan, by its label. */
36
+ export const statusMap = {
37
+ stage: (code, wording) => (code ? undefined : chunilMappedStage(wording)),
38
+ gaps: [],
39
+ };
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "carrier": "chunil",
3
3
  "entries": [
4
- {"wording": "접수", "stage": "accepted", "language": "ko", "confirmedBy": "portal", "note": "The sending branch accepted the parcel. Seen in an archived reply of the tracker."},
5
- {"wording": "발송", "stage": "in_transit", "language": "ko", "confirmedBy": "portal", "note": "Dispatched from the sending branch. Seen in an archived reply of the tracker."},
6
- {"wording": "발송터미널하차", "stage": "in_transit", "language": "ko", "confirmedBy": "portal", "note": "Unloaded at the origin terminal. Seen in an archived reply of the tracker."},
7
- {"wording": "발송터미널출발", "stage": "in_transit", "language": "ko", "confirmedBy": "portal", "note": "Left the origin terminal. Seen in an archived reply of the tracker."},
8
- {"wording": "도착터미널하차", "stage": "in_transit", "language": "ko", "confirmedBy": "portal", "note": "Unloaded at the destination terminal. Seen in an archived reply of the tracker."},
9
- {"wording": "영업소도착", "stage": "in_transit", "language": "ko", "confirmedBy": "portal", "note": "Arrived at the delivering branch. Seen in an archived reply of the tracker."},
10
- {"wording": "배송완료", "stage": "delivered", "language": "ko", "confirmedBy": "portal", "note": "Delivered; the reply repeats it as the delivery result. Seen in an archived reply of the tracker."}
4
+ {"wording": "접수", "language": "ko", "stage": "accepted", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "The sending branch took the parcel in."},
5
+ {"wording": "발송", "language": "ko", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Sent on from the sending branch."},
6
+ {"wording": "발송터미널하차", "language": "ko", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Unloaded at the origin terminal."},
7
+ {"wording": "발송터미널출발", "language": "ko", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Left the origin terminal."},
8
+ {"wording": "간선상차", "language": "ko", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Loaded onto a trunk-line truck. Listed for this tracker by an open-source Korean parcel tracker."},
9
+ {"wording": "간선하차", "language": "ko", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Unloaded from a trunk-line truck. Listed for this tracker by an open-source Korean parcel tracker."},
10
+ {"wording": "중계도착", "language": "ko", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Arrived at a relay terminal. Listed for this tracker by an open-source Korean parcel tracker."},
11
+ {"wording": "중계발송", "language": "ko", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Left a relay terminal. Listed for this tracker by an open-source Korean parcel tracker."},
12
+ {"wording": "도착터미널하차", "language": "ko", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Unloaded at the destination terminal."},
13
+ {"wording": "도착", "language": "ko", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Arrived; the page's template has a block for it between the sending and terminal scans. Listed for this tracker by an open-source Korean parcel tracker."},
14
+ {"wording": "영업소도착", "language": "ko", "stage": "in_transit", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Arrived at the delivering branch."},
15
+ {"wording": "배송출발", "language": "ko", "stage": "out_for_delivery", "confirmedBy": "prior-art", "firstSeen": "2026-10-19", "note": "Left the delivering branch for the recipient. Listed for this tracker by an open-source Korean parcel tracker."},
16
+ {"wording": "배송완료", "language": "ko", "stage": "delivered", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "Delivered; the page repeats it as the delivery result."}
11
17
  ]
12
18
  }
@@ -268,6 +268,8 @@ export function parseGeodisTrackingResponse(payload, rawTrackingNumber) {
268
268
  ...(service ? { service_name: service } : {}),
269
269
  ...(weight !== null ? { weight_kg: weight } : {}),
270
270
  timezone: TIMEZONE,
271
+ // A delivered shipment's anonymous reply can carry the timeline step alone, without scans.
272
+ ...(events.length ? {} : { summary_only: true }),
271
273
  events,
272
274
  };
273
275
  }
@@ -39,6 +39,8 @@ const DEFAULT_TIMEOUT_MS = 15_000;
39
39
  const MAX_RESPONSE_BYTES = 2_000_000;
40
40
  const MAX_EVENTS = 100;
41
41
  const MAX_REDIRECTS = 1;
42
+ /** Steps of a delivery run that a stale banner must not hide. */
43
+ const ATTEMPT_STAGES = new Set(['failed_attempt', 'out_for_delivery', 'ready_for_pickup', 'delivered', 'returned']);
42
44
  const DEFAULT_ZONE = 'Europe/Paris';
43
45
  const PAACK_HOST = /(?:^|\.)paack\.(?:app|co)$/;
44
46
  const NOT_FOUND_PATTERN = /order not found|incorrect order number|commande introuvable|pedido no encontrado/i;
@@ -217,6 +219,32 @@ function pickupPoint(order) {
217
219
  : [];
218
220
  return [name, ...address].join('\n');
219
221
  }
222
+ /**
223
+ * The steps that happened: the timeline's, then the history's. The history
224
+ * (`triggeredEventList`) repeats the timeline's steps under the same `uniq_id`
225
+ * and adds those the page keeps off the timeline (`timeline: false`), such as
226
+ * a failed attempt, the new attempt and the courier assigned to it.
227
+ */
228
+ function happenedSteps(route) {
229
+ const steps = new Map();
230
+ for (const list of [route.eventList, route.triggeredEventList]) {
231
+ if (!Array.isArray(list))
232
+ continue;
233
+ for (const step of list.slice(0, 500)) {
234
+ if (!isRecord(step))
235
+ continue;
236
+ const time = normalizedTimestamp(step.timestamp ?? step.time);
237
+ if (!time)
238
+ continue;
239
+ const key = typeof step.uniq_id === 'string' && step.uniq_id
240
+ ? step.uniq_id
241
+ : `${String(step.id)}|${time.timestamp}`;
242
+ if (!steps.has(key) || (!steps.get(key).label && step.label))
243
+ steps.set(key, step);
244
+ }
245
+ }
246
+ return [...steps.values()];
247
+ }
220
248
  /** Letters and digits only, so "SW1A 1AA" and "4445-027" compare by content. */
221
249
  function postcodeKey(value) {
222
250
  return value.toLocaleUpperCase('en-US').replace(/[^A-Z0-9]/g, '');
@@ -237,15 +265,10 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
237
265
  if (echoedPostcode && echoedPostcode !== postcodeKey(postcode)) {
238
266
  throw new SchemaError('Paack', 'Paack returned a different shipment');
239
267
  }
240
- const rawEvents = Array.isArray(route.eventList) ? route.eventList : [];
241
268
  const parsedEvents = [];
242
- rawEvents.slice(0, 500).forEach((rawEvent, sourceIndex) => {
243
- if (!isRecord(rawEvent) || rawEvent.timeline === false)
244
- return;
269
+ happenedSteps(route).forEach((rawEvent, sourceIndex) => {
245
270
  const classified = classifyPaackEvent(rawEvent);
246
271
  const time = normalizedTimestamp(rawEvent.timestamp ?? rawEvent.time);
247
- if (!time)
248
- return;
249
272
  parsedEvents.push({
250
273
  event: {
251
274
  time: time.iso,
@@ -261,22 +284,30 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
261
284
  const events = parsedEvents.slice(0, MAX_EVENTS).map(({ event }) => event);
262
285
  const activeEvent = isRecord(route.activeEvent) ? route.activeEvent : null;
263
286
  const active = activeEvent ? classifyPaackEvent(activeEvent) : null;
264
- const latest = parsedEvents.find((event) => event.status !== 'unknown');
265
- const current = active && active.status !== 'unknown'
266
- ? active
267
- : latest && {
287
+ const activeTime = activeEvent
288
+ ? normalizedTimestamp(activeEvent.timestamp ?? activeEvent.time)
289
+ : null;
290
+ const banner = active && active.status !== 'unknown' ? active : null;
291
+ // The banner stays on "Out for delivery" after a failed attempt; a newer step
292
+ // of a delivery run replaces it. Scheduling notes and corrections do not.
293
+ const newer = banner && activeTime
294
+ ? parsedEvents.find((event) => event.status !== 'unknown' && event.timestamp > activeTime.timestamp
295
+ && ATTEMPT_STAGES.has(event.event.stage ?? ''))
296
+ : undefined;
297
+ const latest = newer ?? (banner ? undefined : parsedEvents.find((event) => event.status !== 'unknown'));
298
+ const current = latest
299
+ ? {
268
300
  status: latest.status,
269
301
  stage: latest.event.stage ?? 'in_transit',
270
302
  description: latest.event.description ?? 'Shipment update',
271
- };
303
+ }
304
+ : banner;
272
305
  if (!current)
273
306
  throw new SchemaError('Paack', 'Paack returned incomplete tracking details');
274
- const activeTime = activeEvent
275
- ? normalizedTimestamp(activeEvent.timestamp ?? activeEvent.time)
276
- : null;
277
307
  const zone = deliveryZone(address?.country, postcode);
278
308
  const newest = Math.max(activeTime?.timestamp ?? -Infinity, parsedEvents[0]?.timestamp ?? -Infinity);
279
- const expected = isRecord(order.expected_delivery_ts) && !['delivered', 'exception'].includes(current.status)
309
+ // A failed attempt keeps the window: it is the new attempt's.
310
+ const expected = isRecord(order.expected_delivery_ts) && !['delivered', 'returned', 'exception'].includes(current.stage)
280
311
  ? expectedDelivery(order.expected_delivery_ts, zone, newest)
281
312
  : null;
282
313
  const pickup = current.stage === 'ready_for_pickup' ? pickupPoint(order) : '';
@@ -284,7 +315,7 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
284
315
  status: current.status,
285
316
  current_stage: current.stage,
286
317
  last_status_text: current.description,
287
- last_update: activeTime?.iso ?? events[0]?.time ?? null,
318
+ last_update: newer?.event.time ?? activeTime?.iso ?? events[0]?.time ?? null,
288
319
  expected_delivery: expected,
289
320
  ...(pickup ? { pickup_point: pickup } : {}),
290
321
  timezone: zone,
@@ -14,6 +14,9 @@
14
14
  * before the generic words they contain: "rejectedByPudo" is a retried
15
15
  * delivery, not a rejection, and "collectedByCustomer" is the recipient
16
16
  * collecting the parcel, not a transit collection.
17
+ * The page's history also lists steps it keeps off the timeline. A failed
18
+ * delivery attempt can come there as `SA014` with no label, so that code is
19
+ * matched whole.
17
20
  * The provider's own wording is never returned; each mapped entry supplies the
18
21
  * English description we display.
19
22
  *
@@ -16,6 +16,10 @@ const OUT_FOR_DELIVERY = {
16
16
  };
17
17
  const DELIVERED = { status: 'delivered', stage: 'delivered', description: 'Delivered' };
18
18
  const DELIVERY_ISSUE = { status: 'exception', stage: 'failed_attempt', description: 'Delivery issue' };
19
+ /** History codes that come without a label, matched whole. */
20
+ const CODES = new Map([
21
+ ['sa014', { status: 'exception', stage: 'failed_attempt', description: 'Delivery attempt failed' }],
22
+ ]);
19
23
  /** Paack's English timeline labels, keyed like `statusKey`, with the identifier each one renders. */
20
24
  const RELAYED = new Map([
21
25
  ['orderdetailsreceived', REGISTERED], // manifested
@@ -30,6 +34,9 @@ export function paackScan(label) {
30
34
  }
31
35
  export function classifyPaackEvent(value) {
32
36
  const key = `${statusKey(value.label)} ${statusKey(value.id)}`;
37
+ const code = CODES.get(key.trim());
38
+ if (code)
39
+ return { ...code };
33
40
  if (includesAny(key, [
34
41
  'returnedsender',
35
42
  'returnedtosender',
@@ -78,13 +85,18 @@ export function classifyPaackEvent(value) {
78
85
  if (includesAny(key, ['droppedinpudo'])) {
79
86
  return { status: 'out_for_delivery', stage: 'ready_for_pickup', description: 'Ready for pickup' };
80
87
  }
81
- if (includesAny(key, ['outfordelivery', 'indelivery', 'driverassigned', 'inprogress']))
88
+ // The new attempt after a failed one: the stage stays on the failure until the courier sets out again.
89
+ if (includesAny(key, ['additionaldeliveryattemptscheduled'])) {
90
+ return { status: 'exception', stage: 'failed_attempt', description: 'New delivery attempt scheduled' };
91
+ }
92
+ if (includesAny(key, ['driverassigned']))
93
+ return { ...OUT_FOR_DELIVERY, description: 'Courier assigned' };
94
+ if (includesAny(key, ['outfordelivery', 'indelivery']))
82
95
  return { ...OUT_FOR_DELIVERY };
83
96
  if (includesAny(key, [
84
97
  'manifested',
85
98
  'created',
86
99
  'notreceivedfromretailer',
87
- 'additionaldeliveryattemptscheduled',
88
100
  'orderreactivated',
89
101
  'appointmentbroughtforward',
90
102
  'appointmentrescheduled',
@@ -101,6 +113,7 @@ export function classifyPaackEvent(value) {
101
113
  'warehouse',
102
114
  'sorted',
103
115
  'pudoassigned',
116
+ 'inprogress',
104
117
  ])) {
105
118
  return { status: 'in_transit', stage: 'in_transit', description: 'In transit' };
106
119
  }
@@ -105,6 +105,14 @@
105
105
  "firstSeen": "2026-09-12",
106
106
  "note": "Exercised by the delivered-order fixture's earlier attempt row. Reclassified to exception 2026-09-12."
107
107
  },
108
+ {
109
+ "code": "sa014",
110
+ "language": "en",
111
+ "stage": "failed_attempt",
112
+ "confirmedBy": "live",
113
+ "firstSeen": "2026-10-19",
114
+ "note": "History step (timeline: false) with no label, at a failed delivery attempt; the page has no text for it. Matched as the whole code."
115
+ },
108
116
  {
109
117
  "code": "absent",
110
118
  "language": "en",
@@ -285,17 +293,17 @@
285
293
  "code": "driverassigned",
286
294
  "language": "en",
287
295
  "stage": "out_for_delivery",
288
- "confirmedBy": "fixture",
296
+ "confirmedBy": "live",
289
297
  "firstSeen": "2026-09-12",
290
- "note": "Exercised by the delivered-order fixture's inProgress row."
298
+ "note": "History step (timeline: false), \"A courier was assigned to pick up your order\"; listed again for each new attempt. Read as \"Courier assigned\"."
291
299
  },
292
300
  {
293
301
  "code": "inprogress",
294
302
  "language": "en",
295
- "stage": "out_for_delivery",
296
- "confirmedBy": "fixture",
303
+ "stage": "in_transit",
304
+ "confirmedBy": "bundle",
297
305
  "firstSeen": "2026-09-12",
298
- "note": "Exercised by the delivered-order fixture's inProgress row."
306
+ "note": "Page label inProgress, \"The order is being processed by Paack.\": not yet out for delivery."
299
307
  },
300
308
  {
301
309
  "code": "manifested",
@@ -324,10 +332,10 @@
324
332
  {
325
333
  "code": "additionaldeliveryattemptscheduled",
326
334
  "language": "en",
327
- "stage": "registered",
328
- "confirmedBy": "prior-art",
335
+ "stage": "failed_attempt",
336
+ "confirmedBy": "live",
329
337
  "firstSeen": "2026-09-12",
330
- "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
338
+ "note": "History step (timeline: false) after a failed attempt, \"We scheduled a new delivery attempt.\" Kept on failed_attempt until the courier sets out again, so the stage does not fall back to registered."
331
339
  },
332
340
  {
333
341
  "code": "orderreactivated",
@@ -79,6 +79,7 @@ import { statusMap as chitChats } from '../../carriers/chit-chats/status.js';
79
79
  import { statusMap as chronodiali } from '../../carriers/chronodiali/status.js';
80
80
  import { statusMap as chronopost } from '../../carriers/chronopost/status.js';
81
81
  import { statusMap as chunghwaPost } from '../../carriers/chunghwa-post/status.js';
82
+ import { statusMap as chunil } from '../../carriers/chunil/status.js';
82
83
  import { statusMap as cityExpressRs } from '../../carriers/city-express-rs/status.js';
83
84
  import { statusMap as citymail } from '../../carriers/citymail/status.js';
84
85
  import { statusMap as cjLogistics } from '../../carriers/cj-logistics/status.js';
@@ -464,7 +465,7 @@ export const STATUS_MAPS = {
464
465
  bookurier, 'border-express': borderExpress, bosta, 'box-now': boxNow, 'bringer-parcel': bringerParcel, 'bulgarian-posts': bulgarianPosts, 'burd-delivery': burdDelivery, bussgods,
465
466
  byhd, c3x, cacesa, cargus, castores, 'cbl-logistica': cblLogistica, ccfs, cdek, cdl, celeritas,
466
467
  'central-transport': centralTransport, 'ceska-posta': ceskaPosta, 'ceva-logistics': cevaLogistics, chazki, chilexpress, 'china-post': chinaPost,
467
- 'chit-chats': chitChats, chronodiali, chronopost, 'chunghwa-post': chunghwaPost, 'city-express-rs': cityExpressRs, citymail,
468
+ 'chit-chats': chitChats, chronodiali, chronopost, 'chunghwa-post': chunghwaPost, chunil, 'city-express-rs': cityExpressRs, citymail,
468
469
  'cj-logistics': cjLogistics, 'cj-packet': cjPacket, 'closer-logistics': closerLogistics, 'connect-couriers': connectCouriers, coordinadora,
469
470
  'copa-courier': copaCourier,
470
471
  'correo-argentino': correoArgentino, 'correo-uruguayo': correoUruguayo, 'correos-de-mexico': correosDeMexico, 'correos-express': correosExpress, 'correos-spain': correosSpain,
@@ -8915,7 +8915,9 @@
8915
8915
  "timezone": "Asia/Seoul",
8916
8916
  "tracking": {
8917
8917
  "mode": "automatic",
8918
- "adapter": "universal"
8918
+ "adapter": "chunil",
8919
+ "recognitionRank": 621,
8920
+ "localClocks": true
8919
8921
  },
8920
8922
  "canaryUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
8921
8923
  "trackingUrlTemplate": "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -5843,7 +5843,9 @@ export declare const CARRIER_CATALOG: {
5843
5843
  readonly timezone: "Asia/Seoul";
5844
5844
  readonly tracking: {
5845
5845
  readonly mode: "automatic";
5846
- readonly adapter: "universal";
5846
+ readonly adapter: "chunil";
5847
+ readonly recognitionRank: 621;
5848
+ readonly localClocks: true;
5847
5849
  };
5848
5850
  readonly canaryUrl: "https://www.chunil.co.kr/HTrace/HTrace.jsp";
5849
5851
  readonly trackingUrlTemplate: "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}";
@@ -8916,7 +8916,9 @@ export const CARRIER_CATALOG = {
8916
8916
  "timezone": "Asia/Seoul",
8917
8917
  "tracking": {
8918
8918
  "mode": "automatic",
8919
- "adapter": "universal"
8919
+ "adapter": "chunil",
8920
+ "recognitionRank": 621,
8921
+ "localClocks": true
8920
8922
  },
8921
8923
  "canaryUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
8922
8924
  "trackingUrlTemplate": "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -62,6 +62,7 @@ export const CARRIER_RECOGNITION_RANKS = {
62
62
  "chit-chats": 115,
63
63
  "chronodiali": 781,
64
64
  "chronopost": 890,
65
+ "chunil": 621,
65
66
  "ciblex": 640,
66
67
  "city-express-rs": 817,
67
68
  "citymail": 868,
@@ -79,6 +79,7 @@ import { adapter as chitChats } from '../carriers/chit-chats/adapter.js';
79
79
  import { adapter as chronodiali } from '../carriers/chronodiali/adapter.js';
80
80
  import { adapter as chronopost } from '../carriers/chronopost/adapter.js';
81
81
  import { adapter as chunghwaPost } from '../carriers/chunghwa-post/adapter.js';
82
+ import { adapter as chunil } from '../carriers/chunil/adapter.js';
82
83
  import { adapter as ciblex } from '../carriers/ciblex/adapter.js';
83
84
  import { adapter as cityExpressRs } from '../carriers/city-express-rs/adapter.js';
84
85
  import { adapter as citymail } from '../carriers/citymail/adapter.js';
@@ -611,6 +612,7 @@ export const REGISTRY = {
611
612
  "chronodiali": chronodiali,
612
613
  "chronopost": chronopost,
613
614
  "chunghwa-post": chunghwaPost,
615
+ "chunil": chunil,
614
616
  "ciblex": ciblex,
615
617
  "city-express-rs": cityExpressRs,
616
618
  "citymail": citymail,
@@ -1178,7 +1180,7 @@ export const REGISTRY = {
1178
1180
  "chronopost": "chronopost",
1179
1181
  "chukou1": "universal",
1180
1182
  "chunghwa-post": "chunghwa-post",
1181
- "chunil": "universal",
1183
+ "chunil": "chunil",
1182
1184
  "ciblex": "ciblex",
1183
1185
  "city-express-rs": "city-express-rs",
1184
1186
  "citylink-express": "universal",
@@ -1,13 +1,2 @@
1
- /**
2
- * The universal providers' status map. The parcel app files every scan a
3
- * universal provider relays under the carrier `unknown`, and no carrier map
4
- * stands behind those scans: the reading the providers give one is their map.
5
- * That is a 17TRACK sub-status where it outranks the wording
6
- * (`seventeentrack/status.ts`), else the wording rules every provider
7
- * applies (`classifyEvent`: their own vocabulary around the shared language
8
- * rules). A stage a provider declares and a UPU event code it maps are
9
- * `carrier_map`, so their scans never reach the app's review. Wording no rule
10
- * reads has no stage, and nothing is left out on purpose.
11
- */
12
- import type { CarrierStatusMap } from '../core/status/statusMap.js';
1
+ import { type CarrierStatusMap } from '../core/status/statusMap.js';
13
2
  export declare const statusMap: CarrierStatusMap;
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The universal providers' status map. The parcel app files every scan a
3
+ * universal provider relays under the carrier `unknown`, and no carrier map
4
+ * stands behind those scans: the reading the providers give one is their map.
5
+ * That is a 17TRACK sub-status where it outranks the wording
6
+ * (`seventeentrack/status.ts`), else the wording rules every provider
7
+ * applies (`classifyEvent`: their own vocabulary around the shared language
8
+ * rules). A stage a provider declares and a UPU event code it maps are
9
+ * `carrier_map`, so their scans never reach the app's review. Wording no rule
10
+ * reads has no stage. Only Chronopost's notice to the recipient, which is no
11
+ * scan, is left out on purpose.
12
+ */
13
+ import { CHRONOPOST_NOTIFICATION } from '../carriers/chronopost/status.js';
14
+ import { normalizeStatusWording } from '../core/status/statusMap.js';
1
15
  import { subStatusStage } from './seventeentrack/status.js';
2
16
  import { classifyEvent } from './shared/result.js';
3
17
  export const statusMap = {
@@ -5,5 +19,8 @@ export const statusMap = {
5
19
  const read = classifyEvent(wording);
6
20
  return (code ? subStatusStage(code, read.stage) : undefined) ?? (read.stage_source === 'none' ? undefined : read.stage);
7
21
  },
8
- gaps: [],
22
+ gaps: [{
23
+ wording: normalizeStatusWording(CHRONOPOST_NOTIFICATION),
24
+ note: "Chronopost's notice to the recipient, relayed as it is: not a scan, so it keeps the stage the parcel already had.",
25
+ }],
9
26
  };
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.487.0"
5
+ "version": "0.488.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.487.0-main.2349",
3
+ "version": "0.488.0-main.2356",
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",