hh-shipments 0.3.28 → 0.3.30

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.28",
22
+ "version": "0.3.30",
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
  }
@@ -537,7 +575,7 @@ async function handlePastedImageFile(f) {
537
575
  const live = $('ocr-live');
538
576
  // 1) Barcode first: checksum-verified, essentially never wrong
539
577
  const barcode = await decodeBarcode(f);
540
- let text;
578
+ let text, ocrStage = null;
541
579
  if (barcode) {
542
580
  text = barcode;
543
581
  if (live) live.textContent = '📷 Barcode read — reading sender…';
@@ -557,17 +595,18 @@ async function handlePastedImageFile(f) {
557
595
  if (upId) api.patch('/api/intake/' + upId, { state: 'reviewed' }).catch(() => {});
558
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>');
559
597
  if (r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
560
- 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');
561
599
  return;
562
600
  }
563
601
  // 2) Fallback: OCR the printed text (+ the sender strip for the name,
564
602
  // in parallel — the number crop usually excludes the sender block)
565
603
  if (live) live.textContent = '📖 Reading screenshot…';
566
- const [ocrText, topTextFb] = await Promise.all([
604
+ const [ocrRes, topTextFb] = await Promise.all([
567
605
  ocrFile(f, $('ocr-status-paste')),
568
606
  ocrTopStrip(orig, $('ocr-status-paste')),
569
607
  ]);
570
- text = ocrText;
608
+ text = ocrRes.text;
609
+ ocrStage = ocrRes.stage;
571
610
  if (live) live.textContent = '⬆️ Uploading…';
572
611
  const fd = new FormData();
573
612
  fd.append('image', f);
@@ -581,7 +620,7 @@ async function handlePastedImageFile(f) {
581
620
  // Paste flow already handled this image — don't leave it nagging in the review queue.
582
621
  if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
583
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>');
584
- 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);
585
624
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
586
625
  }
587
626
 
@@ -638,7 +677,7 @@ async function reviewPendingImage(id) {
638
677
  const resp = await fetch(`/api/intake/image/${id}`);
639
678
  const blob = await resp.blob();
640
679
  const file = new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' });
641
- const text = await ocrFile(file, null);
680
+ const { text } = await ocrFile(file, null);
642
681
  await api.patch('/api/intake/' + id, { state: 'reviewed' });
643
682
  await runQuick(text, $('photo-result'));
644
683
  loadPending();
package/public/extract.js CHANGED
@@ -81,11 +81,48 @@
81
81
  var num = /^1Z[0-9A-Z]{16}$/.test(ups) ? ups : repairUpsZ(ups);
82
82
  if (num && !seen[num]) {
83
83
  seen[num] = true;
84
- 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 });
85
85
  }
86
86
  }
87
87
  }
88
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
+
89
126
  function extractTrackingNumbers(text) {
90
127
  var found = [];
91
128
  var seen = {};
@@ -107,6 +144,21 @@
107
144
  spacedDigitGroupsIn(text, found, seen, 'medium');
108
145
  // 2) OCR-tolerant recovery pass
109
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
+ }
110
162
  var rank = { high: 0, medium: 1, low: 2 };
111
163
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
112
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
@@ -76,9 +76,15 @@
76
76
  // ("Back Shipment") and OCR fragments ("Eg NTT") don't.
77
77
  if (s !== s.toUpperCase()) return false;
78
78
  // First word 4+ letters (a real first name — rejects "EE SS");
79
- // later words 4+ letters or a single initial ("MOHAMED A").
79
+ // later words 4+ letters, a single initial ("MOHAMED A"), or a
80
+ // corporate suffix ("CHONKEYS INC" — businesses ship too).
80
81
  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
+ var CORP_SUFFIX = /^(INC|LLC|CORP|INCORPORATED|LTD|LIMITED|CO|COMPANY|ENTERPRISES?|TRADING|STORE|SHOP|SUPPLY|SUPPLIES|SERVICES?|GROUP|HOLDINGS|INDUSTRIES)$/;
83
+ for (var wi = 1; wi < words.length; wi++) {
84
+ if (/^([A-Z]{4,}|[A-Z])$/.test(words[wi])) continue;
85
+ if (CORP_SUFFIX.test(words[wi])) continue;
86
+ return false;
87
+ }
82
88
  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
89
  return true;
84
90
  };
@@ -189,7 +195,7 @@
189
195
  var num = repairUpsTail(expanded.slice(2)) || expanded;
190
196
  if (num && UPS_STRICT.test(num) && /[A-Z]/.test(num.slice(2, 8)) && !seen[num]) {
191
197
  seen[num] = true;
192
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
198
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true, ocr_path: true });
193
199
  }
194
200
  }
195
201
  }
@@ -216,7 +222,7 @@
216
222
  }
217
223
  if (num && !seen[num]) {
218
224
  seen[num] = true;
219
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups });
225
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups, ocr_path: true });
220
226
  }
221
227
  }
222
228
  }
@@ -250,11 +256,48 @@
250
256
  if (num && !seen[num]) {
251
257
  seen[num] = true;
252
258
  // repairUpsTail only returns strict-validated numbers: trust them.
253
- found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
259
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true, ocr_path: true });
254
260
  }
255
261
  }
256
262
  }
257
263
 
264
+ // ---- UPS Mod-10 check digit ----
265
+ // Every real 1Z number carries a check digit (last char). This is the
266
+ // arbiter when blurry OCR produces 2↔Z / O↔0 style ambiguity: the wrong
267
+ // read fails the check, the right one passes. Verified against two real
268
+ // shipped labels (1Z1XJ5390328621735, 1ZGE23840337267306).
269
+ // Letter values: (alphabetical index + 1) mod 10 → A=2 … I=0, J=1 … Z=7.
270
+ var UPS_LETTER_VAL = {};
271
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach(function (ch, i) { UPS_LETTER_VAL[ch] = (i + 2) % 10; });
272
+ function upsCheckDigitOk(num) {
273
+ var n = String(num).toUpperCase();
274
+ if (!/^1Z[0-9A-Z]{16}$/.test(n)) return false;
275
+ var body = n.slice(2, 17); // 15 chars after 1Z, excluding check digit
276
+ var vals = [];
277
+ for (var i = 0; i < body.length; i++) {
278
+ var ch = body[i];
279
+ vals.push(ch >= '0' && ch <= '9' ? +ch : UPS_LETTER_VAL[ch]);
280
+ }
281
+ var odd = 0, even = 0;
282
+ for (var j = 0; j < vals.length; j++) { if (j % 2 === 0) odd += vals[j]; else even += vals[j]; }
283
+ var check = (10 - ((odd + even * 2) % 10)) % 10;
284
+ return check === +n[17];
285
+ }
286
+ // Try single-character OCR-confusion swaps until the check digit passes.
287
+ // A repair that validates is error correction, not invention.
288
+ 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' };
289
+ function repairUpsCheckDigit(num) {
290
+ var upper = String(num).toUpperCase();
291
+ for (var i = 2; i < 18; i++) {
292
+ var rep = UPS_CHECK_CONFUSIONS[upper[i]];
293
+ if (!rep) continue;
294
+ var cand = upper.slice(0, i) + rep + upper.slice(i + 1);
295
+ if (!UPS_STRICT.test(cand)) continue;
296
+ if (upsCheckDigitOk(cand)) return cand;
297
+ }
298
+ return null;
299
+ }
300
+
258
301
  function extractTrackingNumbers(text) {
259
302
  var found = [];
260
303
  var seen = {};
@@ -278,6 +321,23 @@
278
321
  spacedDigitGroupsIn(text, found, seen, 'medium');
279
322
  // 2) OCR-tolerant recovery pass
280
323
  extractOcrTolerant(text, found, seen);
324
+ // 3) UPS check-digit gate: a 1Z candidate that fails the Mod-10 check is
325
+ // an OCR misread (2↔Z etc.). On OCR-path candidates, repair it via
326
+ // confusion swaps when one validates; otherwise demote to review.
327
+ // Clean (non-OCR) reads are never rewritten — a typed typo goes to
328
+ // review, not silently "fixed" into a different number.
329
+ for (var i = 0; i < found.length; i++) {
330
+ var c = found[i];
331
+ if (c.carrier !== 'ups' || c.confidence === 'low') continue;
332
+ if (!/^1Z[0-9A-Z]{16}$/i.test(c.number)) continue;
333
+ if (upsCheckDigitOk(c.number)) continue;
334
+ var fixed = (c.ocr_fixed || c.ocr_path) ? repairUpsCheckDigit(c.number) : null;
335
+ if (fixed) {
336
+ if (seen[fixed]) { found.splice(i, 1); i--; } // another pass already found it
337
+ else { seen[fixed] = true; c.number = fixed; c.ocr_fixed = true; }
338
+ }
339
+ else c.confidence = 'medium';
340
+ }
281
341
  var rank = { high: 0, medium: 1, low: 2 };
282
342
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
283
343
  return found;
@@ -422,7 +482,7 @@
422
482
  var num = '1Z' + token;
423
483
  if (seen[num]) continue;
424
484
  seen[num] = true;
425
- found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true });
485
+ found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true, ocr_path: true });
426
486
  }
427
487
  }
428
488