hh-shipments 0.3.27 → 0.3.29

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.27",
22
+ "version": "0.3.29",
23
23
  "bin": {
24
24
  "hh-shipments": "server.js"
25
25
  },
package/public/app.js CHANGED
@@ -154,10 +154,16 @@ async function runQuick(text, targetEl, preset) {
154
154
  }
155
155
 
156
156
  // When OCR finds no tracking number, show the raw read so the user can
157
- // spot the misread and type it in manually.
158
- function showOcrRead(targetEl, text) {
157
+ // spot the misread and type it in manually. Includes which OCR stage
158
+ // produced the text and the app build, so a failure is diagnosable from
159
+ // the screenshot itself — no more blind "it didn't work".
160
+ function appBuild() {
161
+ return (document.querySelector('meta[name="app-version"]') || {}).content || '?';
162
+ }
163
+ function showOcrRead(targetEl, text, stage) {
159
164
  const t = (text || '').trim();
160
- targetEl.innerHTML += `<details class="ocr-read"><summary>What I read from the picture</summary><pre>${esc(t || '(nothing readable)')}</pre></details>`;
165
+ const diag = stage ? ` <span class="muted">· read via ${esc(stage)} · build ${esc(appBuild())}</span>` : '';
166
+ targetEl.innerHTML += `<details class="ocr-read"><summary>What I read from the picture</summary>${diag}<pre>${esc(t || '(nothing readable)')}</pre></details>`;
161
167
  }
162
168
  async function loadDashCounts() { /* counts refresh on next dashboard visit */ }
163
169
 
@@ -179,17 +185,22 @@ function loadTesseract() {
179
185
  // - Chat screenshots (dark): invert, PSM 6 (uniform block)
180
186
  // - Labels (light): crop to tracking zone, upscale, PSM 6
181
187
  // - Falls back to full-image PSM 3 if targeted fails
188
+ // Returns { text, stage } — the stage that produced the text is surfaced in
189
+ // the UI so a failed read is diagnosable from a screenshot (which pass ran,
190
+ // which build), instead of another blind "it didn't work".
182
191
  async function ocrFile(file, statusEl) {
183
192
  await loadTesseract();
184
- statusEl.textContent = 'Reading screenshot…';
193
+ if (statusEl) statusEl.textContent = 'Reading screenshot…';
185
194
  const isDark = await isDarkImage(file);
186
195
  file = isDark ? await invertImage(file) : file;
187
196
 
188
197
  const log = (m) => { if (m.status === 'recognizing text' && statusEl) statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; };
198
+ const ANCHOR = /(1Z|12|IZ|17)\s*[0-9A-Z)\]}(\[{|!]/i;
189
199
 
190
200
  // Pass 1: targeted — for labels, crop to the tracking-number zone (bottom 45%)
191
201
  // and upscale for Tesseract. For chats, use the full image.
192
- let bestText = '';
202
+ let bestText = '', bestStage = 'none';
203
+ const consider = (t, stage) => { if (t && t.length > bestText.length) { bestText = t; bestStage = stage; } };
193
204
  if (!isDark) {
194
205
  try {
195
206
  const zoned = await cropAndUpscale(file, 0, 0.55, 1, 0.45, 2);
@@ -197,36 +208,61 @@ async function ocrFile(file, statusEl) {
197
208
  logger: log,
198
209
  tessedit_pageseg_mode: '6', // uniform block of text
199
210
  });
200
- bestText = r1.data.text || '';
211
+ const t1 = r1.data.text || '';
212
+ consider(t1, 'zone');
201
213
  // If the zone has a plausible tracking number, we're done
202
- if (/(1Z|12|IZ|17)\s*[0-9A-Z)\]}(\[{|!]/i.test(bestText)) {
214
+ if (ANCHOR.test(t1)) {
203
215
  if (statusEl) statusEl.textContent = '';
204
- return bestText;
216
+ return { text: t1, stage: 'zone' };
205
217
  }
206
218
  } catch (e) {}
207
219
  }
208
220
 
221
+ // Pass 1b: tracking-line band SWEEP — the printed "TRACKING #:" line sits
222
+ // in a narrow band whose vertical position shifts with photo framing and
223
+ // angle. A single fixed band missed real labels, so sweep overlapping
224
+ // bands; the first whose OCR contains the tracking anchor wins.
225
+ // Verified on a real label: the center band alone read
226
+ // "TRACKING # 12 GE2 384 03 3726 7306" off a photo the other passes
227
+ // scored as "1 1 (1".
228
+ if (!isDark) {
229
+ const bands = [0.50, 0.44, 0.56, 0.38, 0.62];
230
+ for (const fy of bands) {
231
+ try {
232
+ const band = await cropAndUpscale(file, 0.03, fy, 0.87, 0.12, 3);
233
+ const rb = await Tesseract.recognize(band, 'eng', {
234
+ logger: log,
235
+ tessedit_pageseg_mode: '6',
236
+ });
237
+ const tb = rb.data.text || '';
238
+ consider(tb, 'band');
239
+ if (ANCHOR.test(tb)) {
240
+ if (statusEl) statusEl.textContent = '';
241
+ return { text: tb, stage: 'band' };
242
+ }
243
+ } catch (e) {}
244
+ }
245
+ }
246
+
209
247
  // Pass 2: full image with PSM 6
210
248
  try {
211
249
  const r2 = await Tesseract.recognize(file, 'eng', {
212
250
  logger: log,
213
251
  tessedit_pageseg_mode: '6',
214
252
  });
215
- const t2 = r2.data.text || '';
216
- if (t2.length > bestText.length) bestText = t2;
253
+ consider(r2.data.text || '', 'full');
217
254
  } catch (e) {}
218
255
 
219
256
  // Pass 3: fallback to default PSM 3 if we got nothing useful
220
257
  if (!bestText || bestText.trim().length < 10) {
221
258
  try {
222
259
  const r3 = await Tesseract.recognize(file, 'eng', { logger: log });
223
- const t3 = r3.data.text || '';
224
- if (t3.length > bestText.length) bestText = t3;
260
+ consider(r3.data.text || '', 'psm3');
225
261
  } catch (e) {}
226
262
  }
227
263
 
228
264
  if (statusEl) statusEl.textContent = '';
229
- return bestText;
265
+ return { text: bestText, stage: bestStage };
230
266
  }
231
267
 
232
268
  async function isDarkImage(file) {
@@ -300,7 +336,7 @@ async function handlePhotoFile(f) {
300
336
  target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live-photo">📷 Scanning barcode…</p></div>`;
301
337
  const live = $('ocr-live-photo');
302
338
  const barcode = await decodeBarcode(f);
303
- let text, preset, topText = null, upIdPhoto = null;
339
+ let text, preset, topText = null, upIdPhoto = null, ocrStage = null;
304
340
  if (barcode) {
305
341
  text = barcode;
306
342
  if (live) live.textContent = '📷 Barcode read — reading sender…';
@@ -315,7 +351,9 @@ async function handlePhotoFile(f) {
315
351
  preset = topText ? { labelText: topText } : undefined;
316
352
  } else {
317
353
  if (live) live.textContent = '📖 Reading screenshot…';
318
- text = await ocrFile(f, $('ocr-status'));
354
+ const ocrRes = await ocrFile(f, $('ocr-status'));
355
+ text = ocrRes.text;
356
+ ocrStage = ocrRes.stage;
319
357
  // Upload original for reference, then run the quick flow on OCR text
320
358
  const fd = new FormData();
321
359
  fd.append('image', f);
@@ -330,7 +368,7 @@ async function handlePhotoFile(f) {
330
368
  if (upIdPhoto) api.patch('/api/intake/' + upIdPhoto, { state: 'reviewed' }).catch(() => {});
331
369
  if (looksLikeAppScreenshot(topText || text)) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — choose the label photo directly for best results.</p>');
332
370
  if (barcode && r && r.added.length) $('photo-result').insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
333
- if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead($('photo-result'), text);
371
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead($('photo-result'), text, barcode ? 'barcode' : ocrStage);
334
372
  loadPending();
335
373
  } catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
336
374
  }
@@ -419,7 +457,7 @@ async function ocrTopStrip(origFile, statusEl) {
419
457
  bmp.close();
420
458
  const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.92));
421
459
  if (!blob) return null;
422
- const p = ocrFile(new File([blob], 'top.jpg', { type: 'image/jpeg' }), statusEl);
460
+ const p = ocrFile(new File([blob], 'top.jpg', { type: 'image/jpeg' }), statusEl).then((r) => r.text);
423
461
  return await Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('top-strip timeout')), 25000))]);
424
462
  } catch (e) { return null; }
425
463
  }
@@ -450,6 +488,18 @@ async function decodeOne(reader, url, ms = 9000) {
450
488
  } catch (e) { return null; }
451
489
  }
452
490
 
491
+ // Rank barcode candidates by format specificity: a 1Z UPS read or a
492
+ // 22-digit USPS read is unambiguous, while a 10-digit "DHL" read off a
493
+ // label is often a fragment of another carrier's number (or a routing
494
+ // barcode). Weak hits never beat strong ones.
495
+ function barcodeRank(num) {
496
+ if (/^1Z/i.test(num)) return 0; // UPS
497
+ if (/^9[1-5]\d{18,20}$/.test(num)) return 1; // USPS
498
+ if (/^96\d{20}$/.test(num) || /^\d{15}$/.test(num) || /^\d{20}$/.test(num)) return 2; // FedEx long
499
+ if (/^\d{12}$/.test(num)) return 3; // FedEx 12 (weak: may be routing)
500
+ return 4; // DHL 10/11 (weakest)
501
+ }
502
+
453
503
  // Whole-label decode usually fails (barcodes too small in frame), so try
454
504
  // horizontal region strips, upscaled — the 1Z barcode lives low on UPS labels.
455
505
  async function decodeBarcode(file) {
@@ -467,6 +517,11 @@ async function decodeBarcode(file) {
467
517
  [0, 0.60, 1, 0.35],
468
518
  [0.05, 0.55, 0.90, 0.40],
469
519
  ];
520
+ // Collect every candidate across the sweep and take the strongest —
521
+ // never let the first weak hit (e.g. a 10-digit routing barcode read as
522
+ // DHL) win over the real tracking barcode. Unambiguous reads (UPS/USPS)
523
+ // still return immediately on the fast path.
524
+ let best = null, bestRank = 99;
470
525
  try {
471
526
  for (const [fx, fy, fw, fh] of regions) {
472
527
  for (const scale of [1, 1.5, 2]) {
@@ -491,11 +546,15 @@ async function decodeBarcode(file) {
491
546
  const url = canvas.toDataURL('image/png');
492
547
  const text = await decodeOne(reader, url);
493
548
  const num = strictTracking(text);
494
- if (num) return num;
549
+ if (num) {
550
+ const r = barcodeRank(num);
551
+ if (r < bestRank) { best = num; bestRank = r; }
552
+ if (r <= 1) return num; // UPS or USPS: unambiguous, fast path
553
+ }
495
554
  }
496
555
  }
497
556
  } finally { bmp.close(); }
498
- return null;
557
+ return best;
499
558
  }
500
559
  // Detect when the pasted image is a screenshot OF THE APP itself (its own
501
560
  // header/buttons/tab bar get OCR'd) — the label inside is too small to read
@@ -516,7 +575,7 @@ async function handlePastedImageFile(f) {
516
575
  const live = $('ocr-live');
517
576
  // 1) Barcode first: checksum-verified, essentially never wrong
518
577
  const barcode = await decodeBarcode(f);
519
- let text;
578
+ let text, ocrStage = null;
520
579
  if (barcode) {
521
580
  text = barcode;
522
581
  if (live) live.textContent = '📷 Barcode read — reading sender…';
@@ -536,17 +595,18 @@ async function handlePastedImageFile(f) {
536
595
  if (upId) api.patch('/api/intake/' + upId, { state: 'reviewed' }).catch(() => {});
537
596
  if (looksLikeAppScreenshot(topText)) target.insertAdjacentHTML('afterbegin', '<p class="warn">⚠️ This looks like a screenshot of the app itself — paste the label photo directly for best results.</p>');
538
597
  if (r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
539
- if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
598
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text, 'barcode');
540
599
  return;
541
600
  }
542
601
  // 2) Fallback: OCR the printed text (+ the sender strip for the name,
543
602
  // in parallel — the number crop usually excludes the sender block)
544
603
  if (live) live.textContent = '📖 Reading screenshot…';
545
- const [ocrText, topTextFb] = await Promise.all([
604
+ const [ocrRes, topTextFb] = await Promise.all([
546
605
  ocrFile(f, $('ocr-status-paste')),
547
606
  ocrTopStrip(orig, $('ocr-status-paste')),
548
607
  ]);
549
- text = ocrText;
608
+ text = ocrRes.text;
609
+ ocrStage = ocrRes.stage;
550
610
  if (live) live.textContent = '⬆️ Uploading…';
551
611
  const fd = new FormData();
552
612
  fd.append('image', f);
@@ -560,7 +620,7 @@ async function handlePastedImageFile(f) {
560
620
  // Paste flow already handled this image — don't leave it nagging in the review queue.
561
621
  if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
562
622
  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>');
563
- if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
623
+ if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text, ocrStage);
564
624
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
565
625
  }
566
626
 
@@ -617,7 +677,7 @@ async function reviewPendingImage(id) {
617
677
  const resp = await fetch(`/api/intake/image/${id}`);
618
678
  const blob = await resp.blob();
619
679
  const file = new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' });
620
- const text = await ocrFile(file, null);
680
+ const { text } = await ocrFile(file, null);
621
681
  await api.patch('/api/intake/' + id, { state: 'reviewed' });
622
682
  await runQuick(text, $('photo-result'));
623
683
  loadPending();
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,17 +74,55 @@
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);
80
82
  if (num && !seen[num]) {
81
83
  seen[num] = true;
82
- found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups });
84
+ found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups, ocr_path: true });
83
85
  }
84
86
  }
85
87
  }
86
88
 
89
+ // ---- UPS Mod-10 check digit ----
90
+ // Every real 1Z number carries a check digit (last char). This is the
91
+ // arbiter when blurry OCR produces 2↔Z / O↔0 style ambiguity: the wrong
92
+ // read fails the check, the right one passes. Verified against two real
93
+ // shipped labels (1Z1XJ5390328621735, 1ZGE23840337267306).
94
+ // Letter values: (alphabetical index + 1) mod 10 → A=2 … I=0, J=1 … Z=7.
95
+ var UPS_LETTER_VAL = {};
96
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach(function (ch, i) { UPS_LETTER_VAL[ch] = (i + 2) % 10; });
97
+ function upsCheckDigitOk(num) {
98
+ var n = String(num).toUpperCase();
99
+ if (!/^1Z[0-9A-Z]{16}$/.test(n)) return false;
100
+ var body = n.slice(2, 17); // 15 chars after 1Z, excluding check digit
101
+ var vals = [];
102
+ for (var i = 0; i < body.length; i++) {
103
+ var ch = body[i];
104
+ vals.push(ch >= '0' && ch <= '9' ? +ch : UPS_LETTER_VAL[ch]);
105
+ }
106
+ var odd = 0, even = 0;
107
+ for (var j = 0; j < vals.length; j++) { if (j % 2 === 0) odd += vals[j]; else even += vals[j]; }
108
+ var check = (10 - ((odd + even * 2) % 10)) % 10;
109
+ return check === +n[17];
110
+ }
111
+ // Try single-character OCR-confusion swaps until the check digit passes.
112
+ // A repair that validates is error correction, not invention.
113
+ var UPS_CHECK_CONFUSIONS = { 'Z': '2', '2': 'Z', 'O': '0', '0': 'O', 'I': '1', '1': 'I', 'S': '5', '5': 'S', 'B': '8', '8': 'B', 'G': '6', '6': 'G', 'A': '4', '4': 'A' };
114
+ function repairUpsCheckDigit(num) {
115
+ var upper = String(num).toUpperCase();
116
+ for (var i = 2; i < 18; i++) {
117
+ var rep = UPS_CHECK_CONFUSIONS[upper[i]];
118
+ if (!rep) continue;
119
+ var cand = upper.slice(0, i) + rep + upper.slice(i + 1);
120
+ if (!UPS_STRICT.test(cand)) continue;
121
+ if (upsCheckDigitOk(cand)) return cand;
122
+ }
123
+ return null;
124
+ }
125
+
87
126
  function extractTrackingNumbers(text) {
88
127
  var found = [];
89
128
  var seen = {};
@@ -105,6 +144,21 @@
105
144
  spacedDigitGroupsIn(text, found, seen, 'medium');
106
145
  // 2) OCR-tolerant recovery pass
107
146
  extractOcrTolerant(text, found, seen);
147
+ // 3) UPS check-digit gate: a 1Z candidate that fails the Mod-10 check is
148
+ // an OCR misread (2↔Z etc.). Repair it via confusion swaps when one
149
+ // validates; otherwise demote to review — never auto-add a wrong number.
150
+ for (var i = 0; i < found.length; i++) {
151
+ var c = found[i];
152
+ if (c.carrier !== 'ups' || c.confidence === 'low') continue;
153
+ if (!/^1Z[0-9A-Z]{16}$/i.test(c.number)) continue;
154
+ if (upsCheckDigitOk(c.number)) continue;
155
+ var fixed = (c.ocr_fixed || c.ocr_path) ? repairUpsCheckDigit(c.number) : null;
156
+ if (fixed) {
157
+ if (seen[fixed]) { found.splice(i, 1); i--; } // another pass already found it
158
+ else { seen[fixed] = true; c.number = fixed; c.ocr_fixed = true; }
159
+ }
160
+ else c.confidence = 'medium';
161
+ }
108
162
  var rank = { high: 0, medium: 1, low: 2 };
109
163
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
110
164
  return found;
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.25">
7
+ <meta name="app-version" content="__APP_VERSION__">
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/server.js CHANGED
@@ -599,7 +599,18 @@ app.get('/sw.js', (req, res) => {
599
599
  res.type('application/javascript').set('Cache-Control', 'no-cache').send(src.replace(/__APP_VERSION__/g, APP_VERSION));
600
600
  });
601
601
  });
602
+ // Serve index.html dynamically with the real build version injected — the
603
+ // Settings "app v" line reads this meta tag, and it was hardcoded at 0.3.25
604
+ // for days, making every "confirm the version" check unverifiable. The
605
+ // version line must never lie about the build on the phone.
606
+ function serveIndex(req, res) {
607
+ fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (err, src) => {
608
+ if (err) return res.sendStatus(404);
609
+ res.type('text/html').set('Cache-Control', 'no-cache').send(src.replace(/__APP_VERSION__/g, APP_VERSION));
610
+ });
611
+ }
612
+ app.get(['/', '/index.html'], serveIndex);
602
613
  app.use(express.static(path.join(__dirname, 'public')));
603
- app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
614
+ app.get('*', serveIndex);
604
615
 
605
616
  app.listen(PORT, () => console.log(`[hh-shipments] listening on :${PORT}`));
package/src/extract.js CHANGED
@@ -189,7 +189,7 @@
189
189
  var num = repairUpsTail(expanded.slice(2)) || expanded;
190
190
  if (num && UPS_STRICT.test(num) && /[A-Z]/.test(num.slice(2, 8)) && !seen[num]) {
191
191
  seen[num] = true;
192
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
192
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true, ocr_path: true });
193
193
  }
194
194
  }
195
195
  }
@@ -216,7 +216,7 @@
216
216
  }
217
217
  if (num && !seen[num]) {
218
218
  seen[num] = true;
219
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups });
219
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups, ocr_path: true });
220
220
  }
221
221
  }
222
222
  }
@@ -250,11 +250,48 @@
250
250
  if (num && !seen[num]) {
251
251
  seen[num] = true;
252
252
  // repairUpsTail only returns strict-validated numbers: trust them.
253
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
253
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true, ocr_path: true });
254
254
  }
255
255
  }
256
256
  }
257
257
 
258
+ // ---- UPS Mod-10 check digit ----
259
+ // Every real 1Z number carries a check digit (last char). This is the
260
+ // arbiter when blurry OCR produces 2↔Z / O↔0 style ambiguity: the wrong
261
+ // read fails the check, the right one passes. Verified against two real
262
+ // shipped labels (1Z1XJ5390328621735, 1ZGE23840337267306).
263
+ // Letter values: (alphabetical index + 1) mod 10 → A=2 … I=0, J=1 … Z=7.
264
+ var UPS_LETTER_VAL = {};
265
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach(function (ch, i) { UPS_LETTER_VAL[ch] = (i + 2) % 10; });
266
+ function upsCheckDigitOk(num) {
267
+ var n = String(num).toUpperCase();
268
+ if (!/^1Z[0-9A-Z]{16}$/.test(n)) return false;
269
+ var body = n.slice(2, 17); // 15 chars after 1Z, excluding check digit
270
+ var vals = [];
271
+ for (var i = 0; i < body.length; i++) {
272
+ var ch = body[i];
273
+ vals.push(ch >= '0' && ch <= '9' ? +ch : UPS_LETTER_VAL[ch]);
274
+ }
275
+ var odd = 0, even = 0;
276
+ for (var j = 0; j < vals.length; j++) { if (j % 2 === 0) odd += vals[j]; else even += vals[j]; }
277
+ var check = (10 - ((odd + even * 2) % 10)) % 10;
278
+ return check === +n[17];
279
+ }
280
+ // Try single-character OCR-confusion swaps until the check digit passes.
281
+ // A repair that validates is error correction, not invention.
282
+ var UPS_CHECK_CONFUSIONS = { 'Z': '2', '2': 'Z', 'O': '0', '0': 'O', 'I': '1', '1': 'I', 'S': '5', '5': 'S', 'B': '8', '8': 'B', 'G': '6', '6': 'G', 'A': '4', '4': 'A' };
283
+ function repairUpsCheckDigit(num) {
284
+ var upper = String(num).toUpperCase();
285
+ for (var i = 2; i < 18; i++) {
286
+ var rep = UPS_CHECK_CONFUSIONS[upper[i]];
287
+ if (!rep) continue;
288
+ var cand = upper.slice(0, i) + rep + upper.slice(i + 1);
289
+ if (!UPS_STRICT.test(cand)) continue;
290
+ if (upsCheckDigitOk(cand)) return cand;
291
+ }
292
+ return null;
293
+ }
294
+
258
295
  function extractTrackingNumbers(text) {
259
296
  var found = [];
260
297
  var seen = {};
@@ -267,7 +304,8 @@
267
304
  runPatterns(z.text, found, seen, z.carrier);
268
305
  spacedUpsIn(z.text, found, seen, 'high');
269
306
  looseUpsIn(z.text, found, seen, 'high');
270
- spacedDigitGroupsIn(z.text, found, seen, 'high');
307
+ spacedDigitGroupsIn(z.text, found, seen, 'high', z.carrier);
308
+ if (z.carrier === 'ups') upsAnchorlessRecovery(z.text, found, seen);
271
309
  });
272
310
  // 1) Whole-text strict patterns
273
311
  runPatterns(text, found, seen, false);
@@ -277,6 +315,23 @@
277
315
  spacedDigitGroupsIn(text, found, seen, 'medium');
278
316
  // 2) OCR-tolerant recovery pass
279
317
  extractOcrTolerant(text, found, seen);
318
+ // 3) UPS check-digit gate: a 1Z candidate that fails the Mod-10 check is
319
+ // an OCR misread (2↔Z etc.). On OCR-path candidates, repair it via
320
+ // confusion swaps when one validates; otherwise demote to review.
321
+ // Clean (non-OCR) reads are never rewritten — a typed typo goes to
322
+ // review, not silently "fixed" into a different number.
323
+ for (var i = 0; i < found.length; i++) {
324
+ var c = found[i];
325
+ if (c.carrier !== 'ups' || c.confidence === 'low') continue;
326
+ if (!/^1Z[0-9A-Z]{16}$/i.test(c.number)) continue;
327
+ if (upsCheckDigitOk(c.number)) continue;
328
+ var fixed = (c.ocr_fixed || c.ocr_path) ? repairUpsCheckDigit(c.number) : null;
329
+ if (fixed) {
330
+ if (seen[fixed]) { found.splice(i, 1); i--; } // another pass already found it
331
+ else { seen[fixed] = true; c.number = fixed; c.ocr_fixed = true; }
332
+ }
333
+ else c.confidence = 'medium';
334
+ }
280
335
  var rank = { high: 0, medium: 1, low: 2 };
281
336
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
282
337
  return found;
@@ -305,7 +360,10 @@
305
360
  // anchor line + the two lines under it (the number is printed below)
306
361
  var slice = lines.slice(i, Math.min(lines.length, i + 3));
307
362
  var carrier = null;
308
- slice.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
363
+ // Scan the slice AND the line above it: "UPS GROUND" typically sits
364
+ // directly above the "TRACKING #:" line, and the zone needs to know.
365
+ var scan = slice.concat(i > 0 ? [lines[i - 1]] : []);
366
+ scan.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
309
367
  zones.push({ text: slice.join('\n'), carrier: carrier });
310
368
  }
311
369
  return zones;
@@ -377,7 +435,11 @@
377
435
  // contains a separator (contiguous runs are already covered by PATTERNS).
378
436
  // Joined candidates must validate against a carrier format; IMpb barcodes
379
437
  // yield their tracking number, never the routing.
380
- function spacedDigitGroupsIn(text, found, seen, conf) {
438
+ // zoneCarrier: when the anchor zone names a carrier ("UPS GROUND"), a
439
+ // candidate of any OTHER carrier is a fragment of the real number
440
+ // ("03 3985 0416" off a UPS label is not a DHL shipment) — demote it to
441
+ // low so it can only ever surface for review, never auto-add.
442
+ function spacedDigitGroupsIn(text, found, seen, conf, zoneCarrier) {
381
443
  var re = /\b\d(?:[\s-]?\d){9,33}\b/g, m;
382
444
  while ((m = re.exec(text)) !== null) {
383
445
  if (!/[\s-]/.test(m[0])) continue;
@@ -387,13 +449,34 @@
387
449
  var impb = parseImpb(num);
388
450
  if (impb) {
389
451
  seen[num] = true;
390
- if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: conf }); }
452
+ var ic = (zoneCarrier && zoneCarrier !== 'usps') ? 'low' : conf;
453
+ if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: ic }); }
391
454
  continue;
392
455
  }
393
456
  var carrier = guessCarrier(num);
394
457
  if (carrier === 'unknown') { seen[num] = true; continue; }
395
458
  seen[num] = true;
396
- found.push({ number: num, carrier: carrier, confidence: conf });
459
+ var c = (zoneCarrier && carrier !== zoneCarrier) ? 'low' : conf;
460
+ found.push({ number: num, carrier: carrier, confidence: c });
461
+ }
462
+ }
463
+
464
+ // Last resort for UPS zones: the "1Z" anchor itself was unreadable, so the
465
+ // number survives only as a 16-char alphanumeric tail ("X01 48H 03 3985
466
+ // 0416"). If it validates as strict UPS tail structure, offer 1Z+tail at
467
+ // medium (review-only, never auto-add) — far better than a DHL fragment.
468
+ var UPS_TAIL_STRICT = /^[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
469
+ function upsAnchorlessRecovery(text, found, seen) {
470
+ var re = /\b([0-9A-Z](?:[\s-]?[0-9A-Z]){15})\b/gi, m;
471
+ while ((m = re.exec(text)) !== null) {
472
+ var token = normalize(m[1]);
473
+ if (/^(1Z|12|IZ|17)/i.test(token)) continue; // anchored — handled elsewhere
474
+ if (!UPS_TAIL_STRICT.test(token)) continue;
475
+ if (!/[A-Z]/.test(token.slice(0, 6))) continue; // real shipper codes have a letter
476
+ var num = '1Z' + token;
477
+ if (seen[num]) continue;
478
+ seen[num] = true;
479
+ found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true, ocr_path: true });
397
480
  }
398
481
  }
399
482