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 +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 +66 -6
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|