hh-shipments 0.1.0 → 0.3.1
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 +39 -3
- package/public/index.html +2 -1
- 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.1
|
|
21
|
+
"version": "0.3.1",
|
|
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,45 @@ $('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
|
+
// Shared: pull image files out of a paste event's clipboard data
|
|
184
|
+
function pastedImageFiles(e) {
|
|
185
|
+
const dt = e.clipboardData;
|
|
186
|
+
const files = [];
|
|
187
|
+
if (!dt) return 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
|
+
return files;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Paste-text tab: pasting a screenshot runs OCR and extracts tracking numbers;
|
|
198
|
+
// pasting plain text behaves normally
|
|
199
|
+
$('tab-paste').addEventListener('paste', async (e) => {
|
|
200
|
+
const files = pastedImageFiles(e);
|
|
201
|
+
if (!files.length) return; // text paste — let it land in the textarea
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
try {
|
|
205
|
+
const text = await ocrFile(f, $('ocr-status-paste'));
|
|
206
|
+
const fd = new FormData();
|
|
207
|
+
fd.append('image', f);
|
|
208
|
+
const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
|
|
209
|
+
if (!up.ok) throw new Error('upload failed');
|
|
210
|
+
await runQuick(text, $('quick-result'));
|
|
211
|
+
} catch (err) { $('quick-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
|
|
212
|
+
}
|
|
177
213
|
});
|
|
178
214
|
|
|
179
215
|
async function loadPending() {
|
package/public/index.html
CHANGED
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
<button class="tab" data-tab="manual">Manual</button>
|
|
62
62
|
</div>
|
|
63
63
|
<div id="tab-paste" class="tabpanel">
|
|
64
|
-
<textarea id="paste-text" rows="6" placeholder="Paste the seller's message here…"></textarea>
|
|
64
|
+
<textarea id="paste-text" rows="6" placeholder="Paste the seller's message or a tracking-number screenshot here…"></textarea>
|
|
65
|
+
<div id="ocr-status-paste" class="muted"></div>
|
|
65
66
|
<button id="btn-quick" class="primary">Extract & add</button>
|
|
66
67
|
<div id="quick-result"></div>
|
|
67
68
|
</div>
|
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 {
|