hh-shipments 0.3.26 → 0.3.28

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
@@ -19,7 +19,7 @@
19
19
  "test": "node test/run-extract-tests.js && node test/run-tests.js && node test/run-carriers-tests.js"
20
20
  },
21
21
  "type": "commonjs",
22
- "version": "0.3.26",
22
+ "version": "0.3.28",
23
23
  "bin": {
24
24
  "hh-shipments": "server.js"
25
25
  },
package/public/app.js CHANGED
@@ -400,13 +400,15 @@ function loadZxing() {
400
400
  return zxingPromise;
401
401
  }
402
402
 
403
- // OCR just the top strip of a label (sender block) for the seller name.
403
+ // OCR the top strip of a label (sender block) for the seller name.
404
404
  // Works from the ORIGINAL full-resolution image, upscaled — small text
405
- // reads far better than on the shrunk copy. Returns text or null.
405
+ // reads far better than on the shrunk copy. Top 45%: covers the sender
406
+ // block on USPS labels (~35-40%) as well as UPS/FedEx (top). Returns text
407
+ // or null.
406
408
  async function ocrTopStrip(origFile, statusEl) {
407
409
  try {
408
410
  const bmp = await createImageBitmap(origFile);
409
- const sw = bmp.width, sh = Math.max(60, Math.round(bmp.height * 0.35));
411
+ const sw = bmp.width, sh = Math.max(60, Math.round(bmp.height * 0.45));
410
412
  const targetW = 1400, scale = targetW / sw;
411
413
  const canvas = document.createElement('canvas');
412
414
  canvas.width = targetW;
@@ -448,6 +450,18 @@ async function decodeOne(reader, url, ms = 9000) {
448
450
  } catch (e) { return null; }
449
451
  }
450
452
 
453
+ // Rank barcode candidates by format specificity: a 1Z UPS read or a
454
+ // 22-digit USPS read is unambiguous, while a 10-digit "DHL" read off a
455
+ // label is often a fragment of another carrier's number (or a routing
456
+ // barcode). Weak hits never beat strong ones.
457
+ function barcodeRank(num) {
458
+ if (/^1Z/i.test(num)) return 0; // UPS
459
+ if (/^9[1-5]\d{18,20}$/.test(num)) return 1; // USPS
460
+ if (/^96\d{20}$/.test(num) || /^\d{15}$/.test(num) || /^\d{20}$/.test(num)) return 2; // FedEx long
461
+ if (/^\d{12}$/.test(num)) return 3; // FedEx 12 (weak: may be routing)
462
+ return 4; // DHL 10/11 (weakest)
463
+ }
464
+
451
465
  // Whole-label decode usually fails (barcodes too small in frame), so try
452
466
  // horizontal region strips, upscaled — the 1Z barcode lives low on UPS labels.
453
467
  async function decodeBarcode(file) {
@@ -465,6 +479,11 @@ async function decodeBarcode(file) {
465
479
  [0, 0.60, 1, 0.35],
466
480
  [0.05, 0.55, 0.90, 0.40],
467
481
  ];
482
+ // Collect every candidate across the sweep and take the strongest —
483
+ // never let the first weak hit (e.g. a 10-digit routing barcode read as
484
+ // DHL) win over the real tracking barcode. Unambiguous reads (UPS/USPS)
485
+ // still return immediately on the fast path.
486
+ let best = null, bestRank = 99;
468
487
  try {
469
488
  for (const [fx, fy, fw, fh] of regions) {
470
489
  for (const scale of [1, 1.5, 2]) {
@@ -489,11 +508,15 @@ async function decodeBarcode(file) {
489
508
  const url = canvas.toDataURL('image/png');
490
509
  const text = await decodeOne(reader, url);
491
510
  const num = strictTracking(text);
492
- if (num) return num;
511
+ if (num) {
512
+ const r = barcodeRank(num);
513
+ if (r < bestRank) { best = num; bestRank = r; }
514
+ if (r <= 1) return num; // UPS or USPS: unambiguous, fast path
515
+ }
493
516
  }
494
517
  }
495
518
  } finally { bmp.close(); }
496
- return null;
519
+ return best;
497
520
  }
498
521
  // Detect when the pasted image is a screenshot OF THE APP itself (its own
499
522
  // header/buttons/tab bar get OCR'd) — the label inside is too small to read
@@ -537,9 +560,14 @@ async function handlePastedImageFile(f) {
537
560
  if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
538
561
  return;
539
562
  }
540
- // 2) Fallback: OCR the printed text
563
+ // 2) Fallback: OCR the printed text (+ the sender strip for the name,
564
+ // in parallel — the number crop usually excludes the sender block)
541
565
  if (live) live.textContent = '📖 Reading screenshot…';
542
- text = await ocrFile(f, $('ocr-status-paste'));
566
+ const [ocrText, topTextFb] = await Promise.all([
567
+ ocrFile(f, $('ocr-status-paste')),
568
+ ocrTopStrip(orig, $('ocr-status-paste')),
569
+ ]);
570
+ text = ocrText;
543
571
  if (live) live.textContent = '⬆️ Uploading…';
544
572
  const fd = new FormData();
545
573
  fd.append('image', f);
@@ -549,7 +577,7 @@ async function handlePastedImageFile(f) {
549
577
  if (!up.ok) throw new Error('upload failed');
550
578
  const upId2 = await up.json().then((j) => j.id).catch(() => null);
551
579
  if (live) live.textContent = '🔍 Finding tracking number…';
552
- const r = await runQuick(text, target);
580
+ const r = await runQuick(text, target, topTextFb ? { labelText: topTextFb } : undefined);
553
581
  // Paste flow already handled this image — don't leave it nagging in the review queue.
554
582
  if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
555
583
  if (looksLikeAppScreenshot(text)) target.insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — paste the label photo directly for best results.</p>');
package/public/extract.js CHANGED
@@ -59,13 +59,14 @@
59
59
  // Strict UPS structure: 1Z + 6-char shipper number + 2-digit service + 8-digit serial.
60
60
  var UPS_STRICT = /^1Z[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
61
61
  function repairUpsZ(ups) {
62
- // Tesseract often reads the "1Z" anchor as "12" (or "IZ"). Repair it
63
- // only when the rest has strict UPS structure AND the shipper segment
64
- // contains a letter — real UPS shipper numbers do, random digit runs
65
- // don't. Never invent numbers from thin air.
62
+ // Tesseract often reads the "1Z" anchor as "12" (or "IZ", or "17" —
63
+ // 7→Z confusion). Repair it only when the rest has strict UPS structure
64
+ // AND the shipper segment contains a letter — real UPS shipper numbers
65
+ // do, random digit runs don't. Never invent numbers from thin air.
66
66
  var cand = null;
67
67
  if (/^12[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
68
68
  else if (/^IZ[0-9A-Z]{16}$/i.test(ups)) cand = '1Z' + ups.slice(2);
69
+ else if (/^17[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
69
70
  if (!cand || !UPS_STRICT.test(cand)) return null;
70
71
  if (!/[A-Z]/.test(cand.slice(2, 8))) return null;
71
72
  return cand;
@@ -73,7 +74,8 @@
73
74
 
74
75
  // UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
75
76
  function spacedUpsIn(text, found, seen, conf) {
76
- var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
77
+ // Anchor covers common 1Z misreads: 12, IZ, 17 (7→Z confusion)
78
+ var m, re = /\b(?:1Z|12|IZ|17)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
77
79
  while ((m = re.exec(text)) !== null) {
78
80
  var ups = normalize(m[0]);
79
81
  var num = /^1Z[0-9A-Z]{16}$/.test(ups) ? ups : repairUpsZ(ups);
package/src/extract.js CHANGED
@@ -66,24 +66,43 @@
66
66
  var m2 = text.match(/(?:from|seller)\s*:\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
67
67
  if (m2) out.seller_name = m2[1].trim();
68
68
  }
69
- // Shipping labels: the sender name is the first or second line
70
- // ("MOHAMMED SALEM"). Only take it when it's clearly a name — never
71
- // digits, never address fragments, never OCR garbage like "EE SS",
72
- // and never the app's own UI words when a screenshot of the app itself
73
- // gets pasted ("Back Shipment").
69
+ // Shipping-label name test, shared by the address-anchor rule and the
70
+ // first-two-lines fallback below.
71
+ var labelWords = function (s) { return String(s).trim().split(/\s+/); };
72
+ var looksLikeLabelName = function (s) {
73
+ var words = labelWords(s);
74
+ if (words.length < 2 || words.length > 3) return false;
75
+ // Real shipping labels print names in ALL CAPS; app UI chrome
76
+ // ("Back Shipment") and OCR fragments ("Eg NTT") don't.
77
+ if (s !== s.toUpperCase()) return false;
78
+ // First word 4+ letters (a real first name — rejects "EE SS");
79
+ // later words 4+ letters or a single initial ("MOHAMED A").
80
+ if (!/^[A-Z]{4,}$/.test(words[0])) return false;
81
+ for (var wi = 1; wi < words.length; wi++) if (!/^([A-Z]{4,}|[A-Z])$/.test(words[wi])) return false;
82
+ if (/\b(UPS|FEDEX|USPS|DHL|GROUND|EXPRESS|PRIORITY|BILLING|SHIP\b|FROM|TRACKING|POSTAGE|LABEL|BACK|SHIPMENT|HOME|ARCHIVE|SETTINGS|SCREENSHOT|READING|FINDING|UPLOADING|EXTRACT|PASTE|DETAILS|SAVE|TAP|CLIPBOARD|NUMBER|HUNTER|HILLS)\b/.test(s)) return false;
83
+ return true;
84
+ };
85
+ // Shipping labels: the sender name sits on the line directly above the
86
+ // street-address line ("MOHAMED A" above "2507 GLACIER ST"). Find that
87
+ // anchor anywhere in the text — far more reliable than line position,
88
+ // since OCR crops don't always start at the label's first line. The
89
+ // first such block wins (sender prints above recipient).
90
+ if (!out.seller_name) {
91
+ var alines = String(text).split(/\r?\n/);
92
+ for (var ai = 0; ai < alines.length; ai++) {
93
+ var addr = alines[ai].trim();
94
+ if (!/^\d{1,6}\s+[A-Z0-9][A-Z0-9 .'-]*$/.test(addr)) continue;
95
+ if (!/\b(ST|STREET|AVE|AVENUE|RD|ROAD|DR|DRIVE|LN|LANE|BLVD|PL|PLACE|CT|COURT|WAY|TER|TERRACE|PKWY|PARKWAY)\b/.test(addr)) continue;
96
+ if (ai === 0) continue;
97
+ var cand = alines[ai - 1].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
98
+ if (looksLikeLabelName(cand)) { out.seller_name = cand; break; }
99
+ }
100
+ }
101
+ // Fallback: sender name is the first or second line ("MOHAMMED SALEM").
102
+ // Only take it when it's clearly a name — never digits, never address
103
+ // fragments, never OCR garbage like "EE SS", and never the app's own UI
104
+ // words when a screenshot of the app itself gets pasted ("Back Shipment").
74
105
  if (!out.seller_name) {
75
- var labelWords = function (s) { return String(s).trim().split(/\s+/); };
76
- var looksLikeLabelName = function (s) {
77
- var words = labelWords(s);
78
- if (words.length < 2 || words.length > 3) return false;
79
- // Real shipping labels print names in ALL CAPS; app UI chrome
80
- // ("Back Shipment") and OCR fragments ("Eg NTT") don't.
81
- if (s !== s.toUpperCase()) return false;
82
- // every word 4+ letters: OCR garbage ("EE SS") never qualifies.
83
- for (var i = 0; i < words.length; i++) if (!/^[A-Z]{4,}$/.test(words[i])) return false;
84
- if (/\b(UPS|FEDEX|USPS|DHL|GROUND|EXPRESS|PRIORITY|BILLING|SHIP\b|FROM|TRACKING|POSTAGE|LABEL|BACK|SHIPMENT|HOME|ARCHIVE|SETTINGS|SCREENSHOT|READING|FINDING|UPLOADING|EXTRACT|PASTE|DETAILS|SAVE|TAP|CLIPBOARD|NUMBER)\b/.test(s)) return false;
85
- return true;
86
- };
87
106
  var lines = String(text).split(/\r?\n/);
88
107
  for (var li = 0; li < Math.min(2, lines.length); li++) {
89
108
  var cleaned = lines[li].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
@@ -248,7 +267,8 @@
248
267
  runPatterns(z.text, found, seen, z.carrier);
249
268
  spacedUpsIn(z.text, found, seen, 'high');
250
269
  looseUpsIn(z.text, found, seen, 'high');
251
- spacedDigitGroupsIn(z.text, found, seen, 'high');
270
+ spacedDigitGroupsIn(z.text, found, seen, 'high', z.carrier);
271
+ if (z.carrier === 'ups') upsAnchorlessRecovery(z.text, found, seen);
252
272
  });
253
273
  // 1) Whole-text strict patterns
254
274
  runPatterns(text, found, seen, false);
@@ -286,7 +306,10 @@
286
306
  // anchor line + the two lines under it (the number is printed below)
287
307
  var slice = lines.slice(i, Math.min(lines.length, i + 3));
288
308
  var carrier = null;
289
- slice.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
309
+ // Scan the slice AND the line above it: "UPS GROUND" typically sits
310
+ // directly above the "TRACKING #:" line, and the zone needs to know.
311
+ var scan = slice.concat(i > 0 ? [lines[i - 1]] : []);
312
+ scan.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
290
313
  zones.push({ text: slice.join('\n'), carrier: carrier });
291
314
  }
292
315
  return zones;
@@ -358,7 +381,11 @@
358
381
  // contains a separator (contiguous runs are already covered by PATTERNS).
359
382
  // Joined candidates must validate against a carrier format; IMpb barcodes
360
383
  // yield their tracking number, never the routing.
361
- function spacedDigitGroupsIn(text, found, seen, conf) {
384
+ // zoneCarrier: when the anchor zone names a carrier ("UPS GROUND"), a
385
+ // candidate of any OTHER carrier is a fragment of the real number
386
+ // ("03 3985 0416" off a UPS label is not a DHL shipment) — demote it to
387
+ // low so it can only ever surface for review, never auto-add.
388
+ function spacedDigitGroupsIn(text, found, seen, conf, zoneCarrier) {
362
389
  var re = /\b\d(?:[\s-]?\d){9,33}\b/g, m;
363
390
  while ((m = re.exec(text)) !== null) {
364
391
  if (!/[\s-]/.test(m[0])) continue;
@@ -368,13 +395,34 @@
368
395
  var impb = parseImpb(num);
369
396
  if (impb) {
370
397
  seen[num] = true;
371
- if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: conf }); }
398
+ var ic = (zoneCarrier && zoneCarrier !== 'usps') ? 'low' : conf;
399
+ if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: ic }); }
372
400
  continue;
373
401
  }
374
402
  var carrier = guessCarrier(num);
375
403
  if (carrier === 'unknown') { seen[num] = true; continue; }
376
404
  seen[num] = true;
377
- found.push({ number: num, carrier: carrier, confidence: conf });
405
+ var c = (zoneCarrier && carrier !== zoneCarrier) ? 'low' : conf;
406
+ found.push({ number: num, carrier: carrier, confidence: c });
407
+ }
408
+ }
409
+
410
+ // Last resort for UPS zones: the "1Z" anchor itself was unreadable, so the
411
+ // number survives only as a 16-char alphanumeric tail ("X01 48H 03 3985
412
+ // 0416"). If it validates as strict UPS tail structure, offer 1Z+tail at
413
+ // medium (review-only, never auto-add) — far better than a DHL fragment.
414
+ var UPS_TAIL_STRICT = /^[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
415
+ function upsAnchorlessRecovery(text, found, seen) {
416
+ var re = /\b([0-9A-Z](?:[\s-]?[0-9A-Z]){15})\b/gi, m;
417
+ while ((m = re.exec(text)) !== null) {
418
+ var token = normalize(m[1]);
419
+ if (/^(1Z|12|IZ|17)/i.test(token)) continue; // anchored — handled elsewhere
420
+ if (!UPS_TAIL_STRICT.test(token)) continue;
421
+ if (!/[A-Z]/.test(token.slice(0, 6))) continue; // real shipper codes have a letter
422
+ var num = '1Z' + token;
423
+ if (seen[num]) continue;
424
+ seen[num] = true;
425
+ found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true });
378
426
  }
379
427
  }
380
428