hh-shipments 0.3.24 → 0.3.26

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/package.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "dependencies": {
3
+ "@zxing/library": "^0.23.0",
3
4
  "bcryptjs": "^2.4.3",
4
5
  "cookie-parser": "^1.4.6",
5
6
  "express": "^4.21.2",
@@ -15,10 +16,10 @@
15
16
  "name": "hh-shipments",
16
17
  "scripts": {
17
18
  "start": "node server.js",
18
- "test": "node test/run-tests.js && node test/run-carriers-tests.js"
19
+ "test": "node test/run-extract-tests.js && node test/run-tests.js && node test/run-carriers-tests.js"
19
20
  },
20
21
  "type": "commonjs",
21
- "version": "0.3.24",
22
+ "version": "0.3.26",
22
23
  "bin": {
23
24
  "hh-shipments": "server.js"
24
25
  },
package/public/app.js CHANGED
@@ -175,53 +175,120 @@ function loadTesseract() {
175
175
  return tesseractLoading;
176
176
  }
177
177
 
178
- // Tesseract reads dark-text-on-light far better than the reverse. Chat
179
- // screenshots (dark mode) come out as white-on-black, so detect dark images
180
- // and invert them before OCR. Light images (labels) pass through untouched.
181
- async function maybeInvertForOcr(file) {
178
+ // "Better eyes": smart OCR pipeline that adapts to image type.
179
+ // - Chat screenshots (dark): invert, PSM 6 (uniform block)
180
+ // - Labels (light): crop to tracking zone, upscale, PSM 6
181
+ // - Falls back to full-image PSM 3 if targeted fails
182
+ async function ocrFile(file, statusEl) {
183
+ await loadTesseract();
184
+ statusEl.textContent = 'Reading screenshot…';
185
+ const isDark = await isDarkImage(file);
186
+ file = isDark ? await invertImage(file) : file;
187
+
188
+ const log = (m) => { if (m.status === 'recognizing text' && statusEl) statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; };
189
+
190
+ // Pass 1: targeted — for labels, crop to the tracking-number zone (bottom 45%)
191
+ // and upscale for Tesseract. For chats, use the full image.
192
+ let bestText = '';
193
+ if (!isDark) {
194
+ try {
195
+ const zoned = await cropAndUpscale(file, 0, 0.55, 1, 0.45, 2);
196
+ const r1 = await Tesseract.recognize(zoned, 'eng', {
197
+ logger: log,
198
+ tessedit_pageseg_mode: '6', // uniform block of text
199
+ });
200
+ bestText = r1.data.text || '';
201
+ // If the zone has a plausible tracking number, we're done
202
+ if (/(1Z|12|IZ|17)\s*[0-9A-Z)\]}(\[{|!]/i.test(bestText)) {
203
+ if (statusEl) statusEl.textContent = '';
204
+ return bestText;
205
+ }
206
+ } catch (e) {}
207
+ }
208
+
209
+ // Pass 2: full image with PSM 6
210
+ try {
211
+ const r2 = await Tesseract.recognize(file, 'eng', {
212
+ logger: log,
213
+ tessedit_pageseg_mode: '6',
214
+ });
215
+ const t2 = r2.data.text || '';
216
+ if (t2.length > bestText.length) bestText = t2;
217
+ } catch (e) {}
218
+
219
+ // Pass 3: fallback to default PSM 3 if we got nothing useful
220
+ if (!bestText || bestText.trim().length < 10) {
221
+ try {
222
+ const r3 = await Tesseract.recognize(file, 'eng', { logger: log });
223
+ const t3 = r3.data.text || '';
224
+ if (t3.length > bestText.length) bestText = t3;
225
+ } catch (e) {}
226
+ }
227
+
228
+ if (statusEl) statusEl.textContent = '';
229
+ return bestText;
230
+ }
231
+
232
+ async function isDarkImage(file) {
182
233
  try {
183
234
  const bmp = await createImageBitmap(file);
184
- const w = bmp.width, h = bmp.height;
185
- const canvas = document.createElement('canvas');
186
- // sample at reduced size for speed
187
- const sw = Math.min(200, w), sh = Math.min(200, h);
188
- canvas.width = sw; canvas.height = sh;
189
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
235
+ const sw = Math.min(100, bmp.width), sh = Math.min(100, bmp.height);
236
+ const c = document.createElement('canvas');
237
+ c.width = sw; c.height = sh;
238
+ const ctx = c.getContext('2d', { willReadFrequently: true });
190
239
  ctx.drawImage(bmp, 0, 0, sw, sh);
191
240
  const px = ctx.getImageData(0, 0, sw, sh).data;
192
241
  let sum = 0, n = 0;
193
242
  for (let i = 0; i < px.length; i += 16) {
194
- sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
243
+ sum += 0.299 * px[i] + 0.587 * px[i+1] + 0.114 * px[i+2];
195
244
  n++;
196
245
  }
197
- const avg = sum / n;
198
246
  bmp.close();
199
- if (avg > 110) return file; // already light — leave it alone
200
- // dark image: invert to dark-on-light
201
- const c2 = document.createElement('canvas');
202
- c2.width = w; c2.height = h;
203
- const x2 = c2.getContext('2d');
204
- const full = await createImageBitmap(file);
205
- x2.drawImage(full, 0, 0);
206
- full.close();
207
- const img = x2.getImageData(0, 0, w, h);
247
+ return (sum / n) < 110;
248
+ } catch (e) { return false; }
249
+ }
250
+
251
+ async function invertImage(file) {
252
+ try {
253
+ const bmp = await createImageBitmap(file);
254
+ const c = document.createElement('canvas');
255
+ c.width = bmp.width; c.height = bmp.height;
256
+ const ctx = c.getContext('2d');
257
+ ctx.drawImage(bmp, 0, 0);
258
+ bmp.close();
259
+ const img = ctx.getImageData(0, 0, c.width, c.height);
208
260
  const d = img.data;
209
261
  for (let i = 0; i < d.length; i += 4) {
210
- d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2];
262
+ d[i] = 255 - d[i]; d[i+1] = 255 - d[i+1]; d[i+2] = 255 - d[i+2];
211
263
  }
212
- x2.putImageData(img, 0, 0);
213
- const blob = await new Promise((res) => c2.toBlob(res, 'image/jpeg', 0.92));
264
+ ctx.putImageData(img, 0, 0);
265
+ const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.92));
214
266
  return blob ? new File([blob], 'inv.jpg', { type: 'image/jpeg' }) : file;
215
267
  } catch (e) { return file; }
216
268
  }
217
269
 
218
- async function ocrFile(file, statusEl) {
219
- await loadTesseract();
220
- statusEl.textContent = 'Reading screenshot…';
221
- file = await maybeInvertForOcr(file);
222
- const { data } = await Tesseract.recognize(file, 'eng', { logger: (m) => { if (m.status === 'recognizing text') statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; } });
223
- statusEl.textContent = '';
224
- return data.text || '';
270
+ async function cropAndUpscale(file, fx, fy, fw, fh, scale) {
271
+ const bmp = await createImageBitmap(file);
272
+ const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
273
+ const c = document.createElement('canvas');
274
+ c.width = Math.round(sw * scale);
275
+ c.height = Math.round(sh * scale);
276
+ const ctx = c.getContext('2d');
277
+ ctx.drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, c.width, c.height);
278
+ bmp.close();
279
+ // Boost contrast for text
280
+ try {
281
+ const img = ctx.getImageData(0, 0, c.width, c.height);
282
+ const d = img.data;
283
+ for (let i = 0; i < d.length; i += 4) {
284
+ const v = 0.299*d[i] + 0.587*d[i+1] + 0.114*d[i+2];
285
+ const nv = v < 140 ? Math.max(0, v - 40) : Math.min(255, v + 40);
286
+ d[i] = d[i+1] = d[i+2] = nv;
287
+ }
288
+ ctx.putImageData(img, 0, 0);
289
+ } catch (e) {}
290
+ const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.95));
291
+ return new File([blob], 'crop.jpg', { type: 'image/jpeg' });
225
292
  }
226
293
 
227
294
  async function handlePhotoFile(f) {
@@ -314,15 +381,17 @@ async function fetchTimeout(url, opts = {}, ms = 60000) {
314
381
  }
315
382
 
316
383
  // ---- Barcode-first decoding (ZXing) ----
317
- // A decoded barcode is checksum-verified (Code128 mod-103), so it is
318
- // essentially never wrong — far more reliable than OCR text for labels.
384
+ // Bundled locally at /vendor/zxing.min.js so a CDN outage can never
385
+ // silently kill it. A decoded barcode is checksum-verified (Code128
386
+ // mod-103), so it is essentially never wrong — far more reliable than
387
+ // OCR text for labels.
319
388
  let zxingPromise = null;
320
389
  function loadZxing() {
321
390
  if (window.ZXing) return Promise.resolve();
322
391
  if (!zxingPromise) {
323
392
  zxingPromise = new Promise((res, rej) => {
324
393
  const s = document.createElement('script');
325
- s.src = 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js';
394
+ s.src = '/vendor/zxing.min.js';
326
395
  s.onload = res;
327
396
  s.onerror = () => rej(new Error('barcode reader failed to load'));
328
397
  document.head.appendChild(s);
@@ -357,8 +426,13 @@ async function ocrTopStrip(origFile, statusEl) {
357
426
  function strictTracking(s) {
358
427
  s = String(s || '').toUpperCase().replace(/[\s-]/g, '');
359
428
  if (/^1Z[0-9A-Z]{16}$/.test(s)) return s; // UPS
360
- if (/^9\d{19,21}$/.test(s)) return s; // USPS
361
- if (/^(\d{12}|\d{15}|\d{20})$/.test(s)) return s; // FedEx
429
+ // IMpb barcode: strip the routing prefix, return the tracking number.
430
+ // The routing portion alone is never a shipment.
431
+ var impb = (window.HHExtract && HHExtract.parseImpb) ? HHExtract.parseImpb(s) : null;
432
+ if (impb) return impb;
433
+ if (/^9[1-5]\d{18,20}$/.test(s)) return s; // USPS
434
+ if (/^420\d{9}$/.test(s)) return null; // IMpb routing — never a shipment
435
+ if (/^(\d{12}|\d{15}|\d{20})$/.test(s)) return s; // FedEx
362
436
  if (/^\d{10,11}$/.test(s)) return s; // DHL
363
437
  return null;
364
438
  }
@@ -533,13 +607,12 @@ async function loadPending() {
533
607
  async function reviewPendingImage(id) {
534
608
  $('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
535
609
  try {
536
- await loadTesseract();
537
610
  const resp = await fetch(`/api/intake/image/${id}`);
538
611
  const blob = await resp.blob();
539
- const file = await maybeInvertForOcr(new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' }));
540
- const { data } = await Tesseract.recognize(file, 'eng');
612
+ const file = new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' });
613
+ const text = await ocrFile(file, null);
541
614
  await api.patch('/api/intake/' + id, { state: 'reviewed' });
542
- await runQuick(data.text || '', $('photo-result'));
615
+ await runQuick(text, $('photo-result'));
543
616
  loadPending();
544
617
  } catch (e) { $('photo-result').innerHTML = `<p class="error">${esc(e.message)}</p>`; }
545
618
  }
package/public/extract.js CHANGED
@@ -9,8 +9,8 @@
9
9
  var PATTERNS = [
10
10
  // UPS: 1Z + 16 alphanumerics
11
11
  { carrier: 'ups', confidence: 'high', re: /\b(1Z[0-9A-Z]{16})\b/gi },
12
- // USPS: 20-22 digits starting 92-95 (9400..., 9205..., etc.)
13
- { carrier: 'usps', confidence: 'high', re: /\b(9[2-5]\d{18,20})\b/g },
12
+ // USPS: 20-22 digits starting 91-95 (9400..., 9305..., 9205..., 9101..., etc.)
13
+ { carrier: 'usps', confidence: 'high', re: /\b(9[1-5]\d{18,20})\b/g },
14
14
  // USPS S10 international: 2 letters + 9 digits + 2 letters (ends US = USPS)
15
15
  { carrier: 'usps', confidence: 'medium', re: /\b([A-Z]{2}\d{9}US)\b/g },
16
16
  // FedEx SmartPost / Ground 22-digit starting 96
@@ -95,9 +95,14 @@
95
95
  anchorZones(text).forEach(function (z) {
96
96
  runPatterns(z.text, found, seen, z.carrier);
97
97
  spacedUpsIn(z.text, found, seen, 'high');
98
+ spacedDigitGroupsIn(z.text, found, seen, 'high');
98
99
  });
99
100
  // 1) Whole-text strict patterns
100
101
  runPatterns(text, found, seen, false);
102
+ // 1b) Spaced digit groups: labels print "9305 5110 4390 0011 2062 42"
103
+ // with spaces, which strict PATTERNS cannot match. Without this pass a
104
+ // perfectly-read number is silently dropped.
105
+ spacedDigitGroupsIn(text, found, seen, 'medium');
101
106
  // 2) OCR-tolerant recovery pass
102
107
  extractOcrTolerant(text, found, seen);
103
108
  var rank = { high: 0, medium: 1, low: 2 };
@@ -142,6 +147,9 @@
142
147
  var num = normalize(m[1]);
143
148
  if (seen[num]) continue;
144
149
  seen[num] = true;
150
+ // Never accept an IMpb routing prefix (420 + 9 digits) as a FedEx
151
+ // number — it is postal routing, not a shipment.
152
+ if (p.carrier === 'fedex' && /^420\d{9}$/.test(num)) continue;
145
153
  // Context boost: carrier word near the number, or a hot-zone anchor
146
154
  // naming this carrier (a label's "UPS GROUND" line means the number
147
155
  // under it really is UPS).
@@ -162,12 +170,61 @@
162
170
  function isValidFormat(number, carrier) {
163
171
  var n = normalize(number);
164
172
  if (carrier === 'ups') return /^1Z[0-9A-Z]{16}$/.test(n);
165
- if (carrier === 'usps') return /^9[2-5]\d{18,20}$/.test(n) || /^[A-Z]{2}\d{9}US$/.test(n);
166
- if (carrier === 'fedex') return /^(96\d{20}|\d{12}|\d{15}|\d{20})$/.test(n);
173
+ if (carrier === 'usps') return /^9[1-5]\d{18,20}$/.test(n) || /^[A-Z]{2}\d{9}US$/.test(n);
174
+ // FedEx: never accept an IMpb routing prefix (420 + 9 digits) — it is
175
+ // postal routing, not a shipment number.
176
+ if (carrier === 'fedex') return /^(96\d{20}|\d{12}|\d{15}|\d{20})$/.test(n) && !/^420\d{9}$/.test(n);
167
177
  if (carrier === 'dhl') return /^\d{10,11}$/.test(n) || /^[A-Z]{2,4}\d{10,20}$/.test(n);
168
178
  return false;
169
179
  }
170
180
 
181
+ // Intelligent Mail package barcode: 420 + routing digits + USPS tracking,
182
+ // e.g. 420078742956 + 9305511043900011206242 (34 digits). The full barcode
183
+ // is NOT the tracking number — return the trailing tracking number. The
184
+ // routing portion must never pass as a shipment number.
185
+ function parseImpb(s) {
186
+ var num = normalize(s);
187
+ if (!/^420\d{23,31}$/.test(num)) return null; // 26–34 digits total
188
+ var m = /(9[1-5]\d{18,20})$/.exec(num);
189
+ return m ? m[1] : null;
190
+ }
191
+
192
+ // A spaced/dashed digit run is only plausible as a tracking number when
193
+ // every group has 2+ digits — this rejects ZIP+4 glued to a nearby lone
194
+ // digit ("07874-2956 1 lb"), phone fragments ("1-800-555-1234"), etc.
195
+ function validSpacedGroups(raw) {
196
+ var groups = String(raw).split(/[\s-]+/);
197
+ if (groups.length < 2) return true; // contiguous — decided elsewhere
198
+ for (var i = 0; i < groups.length; i++) if (groups[i].length < 2) return false;
199
+ return true;
200
+ }
201
+
202
+ // Digit groups split by spaces/dashes: "9305 5110 4390 0011 2062 42".
203
+ // Strict PATTERNS only match contiguous runs, so this pass exists for the
204
+ // standard label printing format. Only fires when the raw match actually
205
+ // contains a separator (contiguous runs are already covered by PATTERNS).
206
+ // Joined candidates must validate against a carrier format; IMpb barcodes
207
+ // yield their tracking number, never the routing.
208
+ function spacedDigitGroupsIn(text, found, seen, conf) {
209
+ var re = /\b\d(?:[\s-]?\d){9,33}\b/g, m;
210
+ while ((m = re.exec(text)) !== null) {
211
+ if (!/[\s-]/.test(m[0])) continue;
212
+ if (!validSpacedGroups(m[0])) continue;
213
+ var num = normalize(m[0]);
214
+ if (!num || seen[num]) continue;
215
+ var impb = parseImpb(num);
216
+ if (impb) {
217
+ seen[num] = true;
218
+ if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: conf }); }
219
+ continue;
220
+ }
221
+ var carrier = guessCarrier(num);
222
+ if (carrier === 'unknown') { seen[num] = true; continue; }
223
+ seen[num] = true;
224
+ found.push({ number: num, carrier: carrier, confidence: conf });
225
+ }
226
+ }
227
+
171
228
  function guessCarrier(num) {
172
229
  var carriers = ['ups', 'usps', 'fedex', 'dhl'];
173
230
  for (var i = 0; i < carriers.length; i++) if (isValidFormat(num, carriers[i])) return carriers[i];
@@ -194,13 +251,20 @@
194
251
  // a) UPS number split by spaces/dashes (whole-text fallback; the anchor
195
252
  // pass already tried the hot zones at high confidence)
196
253
  spacedUpsIn(text, found, seen, 'medium');
197
- // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
198
- var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
254
+ // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats.
255
+ // Space-aware: OCR often reads "9305 51O0 ..." with both mangled chars
256
+ // AND spaces, so runs may contain separators. Runs up to 34 chars so a
257
+ // full 34-digit IMpb barcode is tested whole (parseImpb) instead of
258
+ // being split into chunks that false-positive as DHL/FedEx.
259
+ var runRe = /[0-9OILSBoilszb](?:[\s-]?[0-9OILSBoilszb]){9,33}/g, run;
199
260
  while ((run = runRe.exec(text)) !== null) {
261
+ if (/[\s-]/.test(run[0]) && !validSpacedGroups(run[0])) continue;
200
262
  var fixed = normalize(ocrDigitFix(run[0]));
201
- if (/^9[2-5]\d{18,20}$/.test(fixed)) push(fixed, 'usps', 'medium');
263
+ var impbFixed = parseImpb(fixed);
264
+ if (impbFixed) { push(impbFixed, 'usps', 'medium'); continue; }
265
+ if (/^9[1-5]\d{18,20}$/.test(fixed)) push(fixed, 'usps', 'medium');
202
266
  else if (/^96\d{20}$/.test(fixed)) push(fixed, 'fedex', 'medium');
203
- else if (/^(\d{12}|\d{15}|\d{20})$/.test(fixed)) push(fixed, 'fedex', 'medium');
267
+ else if (/^(\d{12}|\d{15}|\d{20})$/.test(fixed) && !/^420\d{9}$/.test(fixed)) push(fixed, 'fedex', 'medium');
204
268
  else if (/^\d{11}$/.test(fixed)) push(fixed, 'dhl', 'medium');
205
269
  else if (/^\d{10}$/.test(fixed)) push(fixed, 'dhl', 'low');
206
270
  }
@@ -214,6 +278,7 @@
214
278
  });
215
279
  tokens.sort(function (a, b) { return b.length - a.length; });
216
280
  tokens.slice(0, 3).forEach(function (t) {
281
+ if (/^420\d{9}$/.test(t)) return; // IMpb routing — never a shipment
217
282
  // UPS "1Z" misread as "12"/"IZ" in a joined (space-free) token
218
283
  var fixed = repairUpsZ(t);
219
284
  if (fixed) { push(fixed, 'ups', 'medium'); return; }
@@ -225,7 +290,7 @@
225
290
  });
226
291
  }
227
292
 
228
- var api = { extractTrackingNumbers: extractTrackingNumbers, extractContext: extractContext, normalize: normalize, isValidFormat: isValidFormat };
293
+ var api = { extractTrackingNumbers: extractTrackingNumbers, extractContext: extractContext, normalize: normalize, isValidFormat: isValidFormat, parseImpb: parseImpb };
229
294
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
230
295
  globalThis.HHExtract = api;
231
296
  })();
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#0a84ff">
7
- <meta name="app-version" content="0.3.24">
7
+ <meta name="app-version" content="0.3.25">
8
8
  <title>HH Shipments</title>
9
9
  <link rel="manifest" href="/manifest.webmanifest">
10
10
  <link rel="apple-touch-icon" href="/icon-192.png">
package/public/sw.js CHANGED
@@ -2,7 +2,7 @@
2
2
  CACHE name includes the app version (injected by the server), so every
3
3
  deploy installs a fresh shell and deletes the old one. */
4
4
  const CACHE = 'hh-ship-__APP_VERSION__';
5
- const SHELL = ['/', '/index.html', '/styles.css', '/app.js', '/extract.js', '/manifest.webmanifest'];
5
+ const SHELL = ['/', '/index.html', '/styles.css', '/app.js', '/extract.js', '/vendor/zxing.min.js', '/manifest.webmanifest'];
6
6
 
7
7
  self.addEventListener('install', (e) => {
8
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));