hh-shipments 0.1.0 → 0.3.0
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 +26 -4
- package/package.json +3 -3
- package/public/app.js +25 -3
- package/public/index.html +1 -0
- package/public/styles.css +2 -0
- package/server.js +2 -1
- package/src/carriers.js +431 -0
- package/src/providers.js +8 -0
package/README.md
CHANGED
|
@@ -52,15 +52,37 @@ The app must stay running 24/7 for scheduled tracking checks. It needs HTTPS
|
|
|
52
52
|
```bash
|
|
53
53
|
docker build -t hh-shipments .
|
|
54
54
|
docker run -d -p 3000:3000 -v hh-data:/data \
|
|
55
|
-
-e TRACKING_PROVIDER=
|
|
56
|
-
-e
|
|
57
|
-
-e
|
|
55
|
+
-e TRACKING_PROVIDER=direct \
|
|
56
|
+
-e UPS_CLIENT_ID=<id> -e UPS_CLIENT_SECRET=<secret> \
|
|
57
|
+
-e FEDEX_CLIENT_ID=<id> -e FEDEX_CLIENT_SECRET=<secret> \
|
|
58
|
+
-e USPS_CLIENT_ID=<id> -e USPS_CLIENT_SECRET=<secret> \
|
|
58
59
|
hh-shipments
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
Put it behind HTTPS (Caddy/Cloudflare Tunnel/your host's TLS).
|
|
62
63
|
|
|
63
|
-
## Real tracking with
|
|
64
|
+
## Real tracking with direct carrier APIs (current plan)
|
|
65
|
+
|
|
66
|
+
Verified 2026-10-12. Free developer signups, no per-tracking fee. DHL is not
|
|
67
|
+
wired (skipped by choice — add later if needed).
|
|
68
|
+
|
|
69
|
+
1. **UPS** — developer.ups.com → Create Application → enable the **Tracking**
|
|
70
|
+
product → copy Client ID + Client Secret.
|
|
71
|
+
2. **FedEx** — developer.fedex.com → My Projects → new project → add the
|
|
72
|
+
**Track** API → copy API Key + Secret Key (test credentials work immediately;
|
|
73
|
+
production may need approval).
|
|
74
|
+
3. **USPS** — developer.usps.com → register → create an application → copy
|
|
75
|
+
Client ID + Client Secret.
|
|
76
|
+
4. Set `TRACKING_PROVIDER=direct` plus the six `*_CLIENT_ID`/`*_CLIENT_SECRET`
|
|
77
|
+
env vars on the server. Each number is auto-routed to the right carrier by
|
|
78
|
+
its format (1Z… → UPS, 9400…/AA…US → USPS, FedEx digit patterns → FedEx).
|
|
79
|
+
Or force one carrier for all numbers with `TRACKING_PROVIDER=ups|fedex|usps`.
|
|
80
|
+
|
|
81
|
+
Endpoint/field details verified 2026-10-12 against official docs (UPS
|
|
82
|
+
UPS-API/api-documentation Tracking.yaml, FedEx Track API reference,
|
|
83
|
+
USPS/api-examples).
|
|
84
|
+
|
|
85
|
+
## Real tracking with 17Track (alternative)
|
|
64
86
|
|
|
65
87
|
Verified 2026-10-12. 17Track's API covers UPS/USPS/FedEx/DHL with auto carrier
|
|
66
88
|
detection and webhooks.
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"node-cron": "^3.0.3",
|
|
8
8
|
"web-push": "^3.6.7"
|
|
9
9
|
},
|
|
10
|
-
"description": "HH Electronics shipment tracker
|
|
10
|
+
"description": "HH Electronics shipment tracker — intake tracking numbers from seller messages/screenshots, auto-track via 17Track, push notifications, archive.",
|
|
11
11
|
"engines": {
|
|
12
12
|
"node": ">=22.5"
|
|
13
13
|
},
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"name": "hh-shipments",
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "node server.js",
|
|
18
|
-
"test": "node test/run-tests.js"
|
|
18
|
+
"test": "node test/run-tests.js && node test/run-carriers-tests.js"
|
|
19
19
|
},
|
|
20
20
|
"type": "commonjs",
|
|
21
|
-
"version": "0.
|
|
21
|
+
"version": "0.3.0",
|
|
22
22
|
"bin": {
|
|
23
23
|
"hh-shipments": "server.js"
|
|
24
24
|
},
|
package/public/app.js
CHANGED
|
@@ -160,9 +160,7 @@ async function ocrFile(file, statusEl) {
|
|
|
160
160
|
return data.text || '';
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
const f = e.target.files[0];
|
|
165
|
-
if (!f) return;
|
|
163
|
+
async function handlePhotoFile(f) {
|
|
166
164
|
try {
|
|
167
165
|
const text = await ocrFile(f, $('ocr-status'));
|
|
168
166
|
// Upload original for reference, then run the quick flow on OCR text
|
|
@@ -173,7 +171,30 @@ $('photo-input').addEventListener('change', async (e) => {
|
|
|
173
171
|
await runQuick(text, $('photo-result'));
|
|
174
172
|
loadPending();
|
|
175
173
|
} catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
$('photo-input').addEventListener('change', async (e) => {
|
|
177
|
+
const f = e.target.files[0];
|
|
176
178
|
e.target.value = '';
|
|
179
|
+
if (!f) return;
|
|
180
|
+
await handlePhotoFile(f);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Paste a screenshot straight from the clipboard (iOS: tap the paste zone, long-press → Paste)
|
|
184
|
+
$('tab-photo').addEventListener('paste', async (e) => {
|
|
185
|
+
const dt = e.clipboardData;
|
|
186
|
+
if (!dt) return;
|
|
187
|
+
const files = [];
|
|
188
|
+
if (dt.items) for (const it of dt.items) {
|
|
189
|
+
if (it.type && it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) files.push(f); }
|
|
190
|
+
}
|
|
191
|
+
if (!files.length && dt.files) for (const f of dt.files) {
|
|
192
|
+
if (f.type && f.type.startsWith('image/')) files.push(f);
|
|
193
|
+
}
|
|
194
|
+
if (!files.length) return; // let text pastes behave normally
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
$('paste-zone').innerHTML = '📋 <span class="muted">or tap here, then paste your screenshot</span>';
|
|
197
|
+
for (const f of files) await handlePhotoFile(f);
|
|
177
198
|
});
|
|
178
199
|
|
|
179
200
|
async function loadPending() {
|
|
@@ -293,6 +314,7 @@ function urlB64(s) {
|
|
|
293
314
|
function activateTab(name) {
|
|
294
315
|
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
295
316
|
['paste', 'photo', 'manual'].forEach((n) => ($('tab-' + n).hidden = n !== name));
|
|
317
|
+
if (name === 'photo') { try { $('paste-zone').focus({ preventScroll: true }); } catch (_) {} }
|
|
296
318
|
}
|
|
297
319
|
document.querySelectorAll('.tab').forEach((t) => (t.onclick = () => activateTab(t.dataset.tab)));
|
|
298
320
|
document.querySelectorAll('#tabbar button').forEach((b) => {
|
package/public/index.html
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
</div>
|
|
68
68
|
<div id="tab-photo" class="tabpanel" hidden>
|
|
69
69
|
<label class="filebtn">📷 Choose screenshot<input id="photo-input" type="file" accept="image/*" hidden></label>
|
|
70
|
+
<div id="paste-zone" class="pastezone" contenteditable="true" spellcheck="false" aria-label="Paste a screenshot here">📋 <span class="muted">or tap here, then paste your screenshot</span></div>
|
|
70
71
|
<div id="ocr-status" class="muted"></div>
|
|
71
72
|
<div id="photo-result"></div>
|
|
72
73
|
<h3>Pending screenshots</h3>
|
package/public/styles.css
CHANGED
|
@@ -42,6 +42,8 @@ h2 { font-size: 17px; margin: 18px 0 8px; }
|
|
|
42
42
|
.tab.active { background: var(--blue); color: #fff; }
|
|
43
43
|
.tabpanel { margin-bottom: 12px; }
|
|
44
44
|
.filebtn { display: block; text-align: center; background: #e5e5ea; border-radius: 12px; padding: 16px; font-weight: 600; cursor: pointer; }
|
|
45
|
+
.pastezone { margin-top: 10px; border: 2px dashed #c7c7cc; border-radius: 12px; padding: 18px; text-align: center; font-size: 15px; min-height: 24px; outline: none; caret-color: transparent; }
|
|
46
|
+
.pastezone:focus { border-color: #007aff; background: #f2f8ff; }
|
|
45
47
|
.statusline { font-size: 17px; font-weight: 600; margin: 8px 0; }
|
|
46
48
|
.timeline { border-left: 2px solid #d1d1d6; margin-left: 6px; padding-left: 14px; }
|
|
47
49
|
.timeline .ev { margin-bottom: 12px; font-size: 14px; }
|
package/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* HH Electronics shipment tracker — server.
|
|
3
3
|
* Mobile-first PWA + JSON API. SQLite storage. VAPID web push.
|
|
4
|
-
* Tracking providers: simulated (default, clearly labeled) | 17track
|
|
4
|
+
* Tracking providers: simulated (default, clearly labeled) | 17track |
|
|
5
|
+
* direct (auto-route UPS/FedEx/USPS) | ups | fedex | usps.
|
|
5
6
|
*/
|
|
6
7
|
const express = require('express');
|
|
7
8
|
const path = require('path');
|
package/src/carriers.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/* Direct carrier tracking providers: UPS, FedEx, USPS.
|
|
2
|
+
*
|
|
3
|
+
* Chosen over the 17Track route: Hunter prefers free options, and each
|
|
4
|
+
* carrier's Tracking API is free with a developer signup (DHL skipped).
|
|
5
|
+
*
|
|
6
|
+
* Env vars:
|
|
7
|
+
* UPS_CLIENT_ID / UPS_CLIENT_SECRET (developer.ups.com app, Tracking product)
|
|
8
|
+
* FEDEX_CLIENT_ID / FEDEX_CLIENT_SECRET (developer.fedex.com project, Track API)
|
|
9
|
+
* USPS_CLIENT_ID / USPS_CLIENT_SECRET (developer.usps.com app)
|
|
10
|
+
* TRACKING_PROVIDER=direct -> auto-route each number by format
|
|
11
|
+
* TRACKING_PROVIDER=ups|fedex|usps -> force one carrier for all numbers
|
|
12
|
+
*
|
|
13
|
+
* All adapters normalize to the provider interface in providers.js:
|
|
14
|
+
* { number, ok, status, status_detail, latest_event_text, latest_event_at,
|
|
15
|
+
* estimated_delivery, events:[{ts,status,text,location}], simulated:false, error }
|
|
16
|
+
* Canonical statuses: pending | in_transit | out_for_delivery | delivered |
|
|
17
|
+
* exception | invalid
|
|
18
|
+
*
|
|
19
|
+
* Endpoint/field details verified 2026-10-12 against:
|
|
20
|
+
* UPS: UPS-API/api-documentation Tracking.yaml + n8n-ups-node integration notes
|
|
21
|
+
* FedEx: developer.fedex.com Track API + goshippo/vibe-hub API references
|
|
22
|
+
* USPS: USPS/api-examples (official) + developer.usps.com v3 docs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
/* ---------------- carrier number-format detection ----------------
|
|
30
|
+
* Same patterns as src/extract.js isValidFormat (ups/usps/fedex). dhl is
|
|
31
|
+
* not wired (skipped per user choice) — those numbers route as unconfigured.
|
|
32
|
+
*/
|
|
33
|
+
function detectCarrier(number) {
|
|
34
|
+
const n = String(number || '').trim().toUpperCase().replace(/[\s-]/g, '');
|
|
35
|
+
if (/^1Z[0-9A-Z]{16}$/.test(n)) return 'ups';
|
|
36
|
+
if (/^9[2-5]\d{18,20}$/.test(n) || /^[A-Z]{2}\d{9}US$/.test(n)) return 'usps';
|
|
37
|
+
if (/^(96\d{20}|\d{12}|\d{15}|\d{20})$/.test(n)) return 'fedex';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ---------------- shared helpers ---------------- */
|
|
42
|
+
|
|
43
|
+
function canonicalize(description) {
|
|
44
|
+
const d = String(description || '');
|
|
45
|
+
if (/out for delivery|out-for-delivery|on (the )?vehicle for delivery/i.test(d)) return 'out_for_delivery';
|
|
46
|
+
if (/delivered/i.test(d)) return 'delivered';
|
|
47
|
+
if (/exception|unable to deliver|delivery attempted|undeliverable|returned to sender|cancel/i.test(d)) return 'exception';
|
|
48
|
+
if (/label created|pre-?shipment|order processed|manifest|pickup/i.test(d)) return 'pending';
|
|
49
|
+
return 'in_transit';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fmtLocation(parts) {
|
|
53
|
+
const p = parts.filter(Boolean);
|
|
54
|
+
return p.length ? p.join(', ') : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isoOrNull(...vals) {
|
|
58
|
+
for (const v of vals) {
|
|
59
|
+
if (v && !Number.isNaN(Date.parse(v))) return new Date(v).toISOString();
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* In-memory OAuth token cache: carrier -> { token, expiresAt } */
|
|
65
|
+
const tokenCache = new Map();
|
|
66
|
+
|
|
67
|
+
async function fetchJson(url, opts = {}) {
|
|
68
|
+
const res = await fetch(url, opts);
|
|
69
|
+
const text = await res.text();
|
|
70
|
+
let json = null;
|
|
71
|
+
try { json = JSON.parse(text); } catch (_) { /* non-JSON body */ }
|
|
72
|
+
return { status: res.status, ok: res.ok, json, text };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getToken(carrier) {
|
|
76
|
+
const cached = tokenCache.get(carrier);
|
|
77
|
+
if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token;
|
|
78
|
+
|
|
79
|
+
const id = process.env[`${carrier.toUpperCase()}_CLIENT_ID`];
|
|
80
|
+
const secret = process.env[`${carrier.toUpperCase()}_CLIENT_SECRET`];
|
|
81
|
+
if (!id || !secret) {
|
|
82
|
+
throw new Error(`${carrier.toUpperCase()}_CLIENT_ID / ${carrier.toUpperCase()}_CLIENT_SECRET not set`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let url, body, headers;
|
|
86
|
+
if (carrier === 'ups') {
|
|
87
|
+
url = 'https://onlinetools.ups.com/security/v1/oauth/token';
|
|
88
|
+
headers = {
|
|
89
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
90
|
+
'Authorization': 'Basic ' + Buffer.from(`${id}:${secret}`).toString('base64'),
|
|
91
|
+
};
|
|
92
|
+
body = 'grant_type=client_credentials';
|
|
93
|
+
} else if (carrier === 'fedex') {
|
|
94
|
+
url = 'https://apis.fedex.com/oauth/token';
|
|
95
|
+
headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
96
|
+
body = `grant_type=client_credentials&client_id=${encodeURIComponent(id)}&client_secret=${encodeURIComponent(secret)}`;
|
|
97
|
+
} else { // usps
|
|
98
|
+
url = 'https://apis.usps.com/oauth2/v3/token';
|
|
99
|
+
headers = { 'Content-Type': 'application/json' };
|
|
100
|
+
body = JSON.stringify({ client_id: id, client_secret: secret, grant_type: 'client_credentials' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const r = await fetchJson(url, { method: 'POST', headers, body });
|
|
104
|
+
if (!r.ok || !r.json || !r.json.access_token) {
|
|
105
|
+
throw new Error(`${carrier} auth failed (HTTP ${r.status})`);
|
|
106
|
+
}
|
|
107
|
+
const ttl = Math.max(60, Number(r.json.expires_in) || 3600);
|
|
108
|
+
tokenCache.set(carrier, { token: r.json.access_token, expiresAt: Date.now() + ttl * 1000 });
|
|
109
|
+
return r.json.access_token;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ---------------- UPS ---------------- */
|
|
113
|
+
|
|
114
|
+
function mapUpsStatus(code, description) {
|
|
115
|
+
const c = String(code || '').toUpperCase();
|
|
116
|
+
if (c === 'D') return 'delivered';
|
|
117
|
+
if (c === 'M') return 'out_for_delivery';
|
|
118
|
+
if (c === 'P') return 'pending';
|
|
119
|
+
if (c === 'X' || c === 'E') return 'exception';
|
|
120
|
+
return canonicalize(description);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function upsEventDate(date, time) {
|
|
124
|
+
// UPS returns date "YYYYMMDD" and time "HHMMSS" (facility local). Build ISO.
|
|
125
|
+
if (!date || !/^\d{8}$/.test(String(date))) return isoOrNull(date);
|
|
126
|
+
const t = /^\d{6}$/.test(String(time || '')) ? String(time) : '120000';
|
|
127
|
+
const s = `${String(date).slice(0, 4)}-${String(date).slice(4, 6)}-${String(date).slice(6, 8)}T${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`;
|
|
128
|
+
return isoOrNull(s);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function upsTrackOne(number) {
|
|
132
|
+
const token = await getToken('ups');
|
|
133
|
+
const url = `https://onlinetools.ups.com/api/track/v1/details/${encodeURIComponent(number)}?locale=en_US&returnSignature=false`;
|
|
134
|
+
let r = await fetchJson(url, {
|
|
135
|
+
headers: {
|
|
136
|
+
'Authorization': `Bearer ${token}`,
|
|
137
|
+
'transId': crypto.randomUUID(),
|
|
138
|
+
'transactionSrc': 'HHShipments',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (r.status === 401) {
|
|
142
|
+
tokenCache.delete('ups');
|
|
143
|
+
const token2 = await getToken('ups');
|
|
144
|
+
r = await fetchJson(url, {
|
|
145
|
+
headers: { 'Authorization': `Bearer ${token2}`, 'transId': crypto.randomUUID(), 'transactionSrc': 'HHShipments' },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (!r.ok) {
|
|
149
|
+
const msg = (r.json && (r.json.response && r.json.response.errors || []).map((e) => e.message).join('; ')) || r.text.slice(0, 200);
|
|
150
|
+
if (r.status === 404 || /invalid|not found/i.test(msg)) {
|
|
151
|
+
return { number, ok: false, error: `Invalid UPS tracking number`, simulated: false };
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`UPS track failed (HTTP ${r.status}): ${msg}`);
|
|
154
|
+
}
|
|
155
|
+
const shipment = (r.json.trackResponse && r.json.trackResponse.shipment || [])[0] || {};
|
|
156
|
+
const pkg = (shipment.package || [])[0] || {};
|
|
157
|
+
const current = pkg.currentStatus || {};
|
|
158
|
+
const activities = pkg.activity || [];
|
|
159
|
+
|
|
160
|
+
const events = activities.map((a) => {
|
|
161
|
+
const loc = (a.location && a.location.address) || {};
|
|
162
|
+
return {
|
|
163
|
+
ts: upsEventDate(a.date, a.time),
|
|
164
|
+
status: mapUpsStatus(a.status && a.status.code, a.status && a.status.description),
|
|
165
|
+
text: (a.status && a.status.description) || '',
|
|
166
|
+
location: fmtLocation([loc.city, loc.stateProvinceCode || loc.postalCode]),
|
|
167
|
+
};
|
|
168
|
+
}).filter((e) => e.text).sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
|
|
169
|
+
|
|
170
|
+
// planned delivery date: shipment.deliveryDate[] {type:'PLD'|'DEL', date:'YYYYMMDD'}
|
|
171
|
+
let eta = null;
|
|
172
|
+
for (const d of shipment.deliveryDate || []) {
|
|
173
|
+
if (/^PLD/i.test(d.type || '')) { eta = upsEventDate(d.date); break; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const latest = events.length ? events[events.length - 1] : null;
|
|
177
|
+
const status = mapUpsStatus(current.code, current.description);
|
|
178
|
+
return {
|
|
179
|
+
number,
|
|
180
|
+
ok: true,
|
|
181
|
+
status: latest && latest.status === 'pending' && events.length > 1 ? 'in_transit' : status,
|
|
182
|
+
status_detail: current.description || '',
|
|
183
|
+
latest_event_text: latest ? latest.text : (current.description || ''),
|
|
184
|
+
latest_event_at: latest ? latest.ts : null,
|
|
185
|
+
estimated_delivery: eta,
|
|
186
|
+
events,
|
|
187
|
+
simulated: false,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function upsTrack(numbers) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const n of numbers) {
|
|
194
|
+
try {
|
|
195
|
+
out.push(await upsTrackOne(n));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
out.push({ number: n, ok: false, error: e.message.slice(0, 300), simulated: false });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ---------------- FedEx ---------------- */
|
|
204
|
+
|
|
205
|
+
const FEDEX_CODE_MAP = {
|
|
206
|
+
DL: 'delivered', OD: 'out_for_delivery', IT: 'in_transit', PU: 'in_transit',
|
|
207
|
+
DE: 'exception', OC: 'exception', OX: 'exception', SE: 'exception',
|
|
208
|
+
CA: 'exception', DY: 'exception', RS: 'exception',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function mapFedexStatus(derivedCode, description) {
|
|
212
|
+
const c = String(derivedCode || '').toUpperCase();
|
|
213
|
+
if (FEDEX_CODE_MAP[c]) return FEDEX_CODE_MAP[c];
|
|
214
|
+
return canonicalize(description);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function fedexTrackBatch(numbers) {
|
|
218
|
+
const token = await getToken('fedex');
|
|
219
|
+
const url = 'https://apis.fedex.com/track/v1/trackingnumbers';
|
|
220
|
+
const call = (t) => fetchJson(url, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: {
|
|
223
|
+
'Authorization': `Bearer ${t}`,
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
'X-locale': 'en_US',
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
includeDetailedScans: true,
|
|
229
|
+
trackingInfo: numbers.map((n) => ({ trackingNumberInfo: { trackingNumber: n } })),
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
let r = await call(token);
|
|
233
|
+
if (r.status === 401) {
|
|
234
|
+
tokenCache.delete('fedex');
|
|
235
|
+
r = await call(await getToken('fedex'));
|
|
236
|
+
}
|
|
237
|
+
if (!r.ok) throw new Error(`FedEx track failed (HTTP ${r.status}): ${r.text.slice(0, 200)}`);
|
|
238
|
+
return (r.json.output && r.json.output.completeTrackResults) || [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function fedexNormalize(trackingNumber, tr) {
|
|
242
|
+
const base = { number: trackingNumber, simulated: false };
|
|
243
|
+
if (!tr) return { ...base, ok: false, error: 'No FedEx track result' };
|
|
244
|
+
if (tr.error && tr.error.code) {
|
|
245
|
+
const invalid = /NOTFOUND|INVALID/i.test(tr.error.code);
|
|
246
|
+
return { ...base, ok: false, error: (invalid ? 'Invalid FedEx tracking number' : tr.error.message || tr.error.code) };
|
|
247
|
+
}
|
|
248
|
+
const lsd = tr.latestStatusDetail || {};
|
|
249
|
+
const scans = tr.scanEvents || [];
|
|
250
|
+
const events = scans.map((s) => {
|
|
251
|
+
const loc = s.scanLocation || {};
|
|
252
|
+
return {
|
|
253
|
+
ts: isoOrNull(s.date),
|
|
254
|
+
status: mapFedexStatus(s.derivedStatusCode || s.derivedStatus, s.eventDescription),
|
|
255
|
+
text: s.eventDescription || '',
|
|
256
|
+
location: fmtLocation([loc.city, loc.stateOrProvinceCode || loc.postalCode]),
|
|
257
|
+
};
|
|
258
|
+
}).filter((e) => e.text).sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
|
|
259
|
+
|
|
260
|
+
let eta = null;
|
|
261
|
+
for (const d of tr.dateAndTimes || []) {
|
|
262
|
+
if (/^ESTIMATED_DELIVERY$/i.test(d.type || '')) { eta = isoOrNull(d.dateTime); break; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const latest = events.length ? events[events.length - 1] : null;
|
|
266
|
+
const status = mapFedexStatus(lsd.derivedCode || lsd.code, lsd.description);
|
|
267
|
+
return {
|
|
268
|
+
...base,
|
|
269
|
+
ok: true,
|
|
270
|
+
status: status === 'pending' && events.length > 1 ? 'in_transit' : status,
|
|
271
|
+
status_detail: lsd.description || '',
|
|
272
|
+
latest_event_text: latest ? latest.text : (lsd.description || ''),
|
|
273
|
+
latest_event_at: latest ? latest.ts : null,
|
|
274
|
+
estimated_delivery: eta,
|
|
275
|
+
events,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function fedexTrack(numbers) {
|
|
280
|
+
const out = [];
|
|
281
|
+
// FedEx allows up to 30 tracking numbers per call.
|
|
282
|
+
for (let i = 0; i < numbers.length; i += 30) {
|
|
283
|
+
const chunk = numbers.slice(i, i + 30);
|
|
284
|
+
try {
|
|
285
|
+
const results = await fedexTrackBatch(chunk);
|
|
286
|
+
const byNum = new Map();
|
|
287
|
+
for (const cr of results) {
|
|
288
|
+
for (const tr of cr.trackResults || []) {
|
|
289
|
+
byNum.set(tr.trackingNumber, tr);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const n of chunk) out.push(fedexNormalize(n, byNum.get(n)));
|
|
293
|
+
} catch (e) {
|
|
294
|
+
for (const n of chunk) out.push({ number: n, ok: false, error: e.message.slice(0, 300), simulated: false });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ---------------- USPS ---------------- */
|
|
301
|
+
|
|
302
|
+
function mapUspsStatus(category, eventType) {
|
|
303
|
+
const s = `${category || ''} ${eventType || ''}`;
|
|
304
|
+
if (/out for delivery/i.test(s)) return 'out_for_delivery';
|
|
305
|
+
if (/delivered/i.test(s)) return 'delivered';
|
|
306
|
+
if (/alert|exception|undeliverable|returned/i.test(s)) return 'exception';
|
|
307
|
+
if (/pre-?shipment|label created|shipping label/i.test(s)) return 'pending';
|
|
308
|
+
if (/in transit|accepted|departed|arrived|processed/i.test(s)) return 'in_transit';
|
|
309
|
+
return canonicalize(eventType);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function uspsTrackOne(number) {
|
|
313
|
+
const token = await getToken('usps');
|
|
314
|
+
const clean = String(number).replace(/[\s-]/g, '');
|
|
315
|
+
const url = `https://apis.usps.com/tracking/v3/tracking/${encodeURIComponent(clean)}?expand=DETAIL`;
|
|
316
|
+
const call = (t) => fetchJson(url, {
|
|
317
|
+
headers: { 'Authorization': `Bearer ${t}`, 'Accept': 'application/json' },
|
|
318
|
+
});
|
|
319
|
+
let r = await call(token);
|
|
320
|
+
if (r.status === 401) {
|
|
321
|
+
tokenCache.delete('usps');
|
|
322
|
+
r = await call(await getToken('usps'));
|
|
323
|
+
}
|
|
324
|
+
if (!r.ok) {
|
|
325
|
+
if (r.status === 404) return { number, ok: false, error: 'Invalid USPS tracking number', simulated: false };
|
|
326
|
+
throw new Error(`USPS track failed (HTTP ${r.status}): ${r.text.slice(0, 200)}`);
|
|
327
|
+
}
|
|
328
|
+
const d = r.json || {};
|
|
329
|
+
const summary = d.statusSummary || {};
|
|
330
|
+
const rawEvents = d.trackingEvents || [];
|
|
331
|
+
|
|
332
|
+
const events = rawEvents.map((e) => ({
|
|
333
|
+
ts: isoOrNull(e.eventDateTime),
|
|
334
|
+
status: mapUspsStatus(e.statusCategory, e.eventType || e.eventDescription),
|
|
335
|
+
text: e.eventDescription || e.eventType || '',
|
|
336
|
+
location: fmtLocation([e.eventCity, e.eventState || e.eventZIPCode]),
|
|
337
|
+
})).filter((e) => e.text).sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
|
|
338
|
+
|
|
339
|
+
const latest = events.length ? events[events.length - 1] : null;
|
|
340
|
+
const status = mapUspsStatus(summary.statusCategory, summary.eventType);
|
|
341
|
+
const eta = isoOrNull(d.expectedDeliveryDate, d.expectedDeliveryTime, d.guaranteedDeliveryDate)
|
|
342
|
+
|| (summary && isoOrNull(summary.expectedDeliveryDate));
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
number,
|
|
346
|
+
ok: true,
|
|
347
|
+
status: status === 'pending' && events.length > 1 ? 'in_transit' : status,
|
|
348
|
+
status_detail: summary.eventDescription || summary.eventType || '',
|
|
349
|
+
latest_event_text: latest ? latest.text : (summary.eventDescription || ''),
|
|
350
|
+
latest_event_at: latest ? latest.ts : null,
|
|
351
|
+
estimated_delivery: eta,
|
|
352
|
+
events,
|
|
353
|
+
simulated: false,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function uspsTrack(numbers) {
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const n of numbers) {
|
|
360
|
+
try {
|
|
361
|
+
out.push(await uspsTrackOne(n));
|
|
362
|
+
} catch (e) {
|
|
363
|
+
out.push({ number: n, ok: false, error: e.message.slice(0, 300), simulated: false });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ---------------- adapters ---------------- */
|
|
370
|
+
|
|
371
|
+
const ADAPTERS = {
|
|
372
|
+
ups: {
|
|
373
|
+
track: upsTrack,
|
|
374
|
+
register: async (items) => items.map((i) => ({ number: i.number, ok: true, simulated: false })),
|
|
375
|
+
},
|
|
376
|
+
fedex: {
|
|
377
|
+
track: fedexTrack,
|
|
378
|
+
register: async (items) => items.map((i) => ({ number: i.number, ok: true, simulated: false })),
|
|
379
|
+
},
|
|
380
|
+
usps: {
|
|
381
|
+
track: uspsTrack,
|
|
382
|
+
register: async (items) => items.map((i) => ({ number: i.number, ok: true, simulated: false })),
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/* direct: route each number to the adapter matching its format */
|
|
387
|
+
async function directTrack(numbers) {
|
|
388
|
+
const groups = new Map();
|
|
389
|
+
const order = [];
|
|
390
|
+
for (const n of numbers) {
|
|
391
|
+
const c = detectCarrier(n);
|
|
392
|
+
order.push({ n, c });
|
|
393
|
+
if (c && ADAPTERS[c]) {
|
|
394
|
+
if (!groups.has(c)) groups.set(c, []);
|
|
395
|
+
groups.get(c).push(n);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const merged = new Map();
|
|
399
|
+
for (const [c, nums] of groups) {
|
|
400
|
+
const res = await ADAPTERS[c].track(nums);
|
|
401
|
+
for (const r of res) merged.set(r.number, r);
|
|
402
|
+
}
|
|
403
|
+
return order.map(({ n, c }) => {
|
|
404
|
+
if (!c) return { number: n, ok: false, error: 'Could not detect carrier for this tracking number', simulated: false };
|
|
405
|
+
if (!ADAPTERS[c]) return { number: n, ok: false, error: `Carrier ${c.toUpperCase()} is not wired (only UPS/FedEx/USPS)`, simulated: false };
|
|
406
|
+
return merged.get(n) || { number: n, ok: false, error: 'No result returned', simulated: false };
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function getCarrierProvider(name) {
|
|
411
|
+
const n = String(name || '').toLowerCase();
|
|
412
|
+
if (ADAPTERS[n]) {
|
|
413
|
+
return {
|
|
414
|
+
name: n,
|
|
415
|
+
simulated: false,
|
|
416
|
+
register: ADAPTERS[n].register,
|
|
417
|
+
track: ADAPTERS[n].track,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (n === 'direct') {
|
|
421
|
+
return {
|
|
422
|
+
name: 'direct',
|
|
423
|
+
simulated: false,
|
|
424
|
+
register: async (items) => items.map((i) => ({ number: i.number, ok: true, simulated: false })),
|
|
425
|
+
track: directTrack,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = { getCarrierProvider, detectCarrier, mapUpsStatus, mapFedexStatus, mapUspsStatus, ADAPTERS };
|
package/src/providers.js
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
* TRACKING_PROVIDER=17track -> 17Track API v2 (needs SEVENTEEN_TRACK_API_KEY).
|
|
6
6
|
* Verify endpoint/field details against
|
|
7
7
|
* https://www.17track.net/en/api before going live.
|
|
8
|
+
* TRACKING_PROVIDER=direct -> direct carrier APIs, auto-routing each number
|
|
9
|
+
* by format. Needs UPS_/FEDEX_/USPS_CLIENT_ID+SECRET.
|
|
10
|
+
* TRACKING_PROVIDER=ups|fedex|usps -> force one direct carrier for all numbers.
|
|
11
|
+
* Direct adapters live in src/carriers.js.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
const crypto = require('crypto');
|
|
15
|
+
const { getCarrierProvider } = require('./carriers');
|
|
11
16
|
|
|
12
17
|
const SIM_STAGES = ['pending', 'in_transit', 'in_transit', 'out_for_delivery', 'delivered'];
|
|
13
18
|
const SIM_EVENTS = {
|
|
@@ -134,6 +139,9 @@ function getProvider() {
|
|
|
134
139
|
track: t17Track,
|
|
135
140
|
};
|
|
136
141
|
}
|
|
142
|
+
// Direct carrier APIs (UPS / FedEx / USPS), or the 'direct' auto-router.
|
|
143
|
+
const carrier = getCarrierProvider(name);
|
|
144
|
+
if (carrier) return carrier;
|
|
137
145
|
// simulated default — safe, clearly labeled, costs nothing
|
|
138
146
|
const polls = new Map();
|
|
139
147
|
return {
|