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 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=17track \
56
- -e SEVENTEEN_TRACK_API_KEY=your-key \
57
- -e SEVENTEEN_TRACK_WEBHOOK_SECRET=pick-a-secret \
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 17Track (recommended)
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 \u2014 intake tracking numbers from seller messages/screenshots, auto-track via 17Track, push notifications, archive.",
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.0",
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
- $('photo-input').addEventListener('change', async (e) => {
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 &amp; 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');
@@ -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 {