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 +1 -1
- package/public/app.js +85 -25
- package/public/extract.js +60 -6
- package/public/index.html +1 -1
- package/server.js +12 -1
- package/src/extract.js +91 -8
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
|
}
|
|
@@ -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)
|
|
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
|
|
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 [
|
|
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 =
|
|
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"
|
|
63
|
-
// only when the rest has strict UPS structure
|
|
64
|
-
// contains a letter — real UPS shipper numbers
|
|
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
|
-
|
|
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="
|
|
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 = {};
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|