hh-shipments 0.3.28 → 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 +1 -1
- package/public/app.js +62 -23
- package/public/extract.js +53 -1
- package/public/index.html +1 -1
- package/server.js +12 -1
- package/src/extract.js +58 -4
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
214
|
+
if (ANCHOR.test(t1)) {
|
|
203
215
|
if (statusEl) statusEl.textContent = '';
|
|
204
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 =
|
|
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="
|
|
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('*',
|
|
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 = {};
|
|
@@ -278,6 +315,23 @@
|
|
|
278
315
|
spacedDigitGroupsIn(text, found, seen, 'medium');
|
|
279
316
|
// 2) OCR-tolerant recovery pass
|
|
280
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
|
+
}
|
|
281
335
|
var rank = { high: 0, medium: 1, low: 2 };
|
|
282
336
|
found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
|
|
283
337
|
return found;
|
|
@@ -422,7 +476,7 @@
|
|
|
422
476
|
var num = '1Z' + token;
|
|
423
477
|
if (seen[num]) continue;
|
|
424
478
|
seen[num] = true;
|
|
425
|
-
found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true });
|
|
479
|
+
found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true, ocr_path: true });
|
|
426
480
|
}
|
|
427
481
|
}
|
|
428
482
|
|