hh-shipments 0.3.26 → 0.3.28
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 +36 -8
- package/public/extract.js +7 -5
- package/src/extract.js +70 -22
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -400,13 +400,15 @@ function loadZxing() {
|
|
|
400
400
|
return zxingPromise;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
// OCR
|
|
403
|
+
// OCR the top strip of a label (sender block) for the seller name.
|
|
404
404
|
// Works from the ORIGINAL full-resolution image, upscaled — small text
|
|
405
|
-
// reads far better than on the shrunk copy.
|
|
405
|
+
// reads far better than on the shrunk copy. Top 45%: covers the sender
|
|
406
|
+
// block on USPS labels (~35-40%) as well as UPS/FedEx (top). Returns text
|
|
407
|
+
// or null.
|
|
406
408
|
async function ocrTopStrip(origFile, statusEl) {
|
|
407
409
|
try {
|
|
408
410
|
const bmp = await createImageBitmap(origFile);
|
|
409
|
-
const sw = bmp.width, sh = Math.max(60, Math.round(bmp.height * 0.
|
|
411
|
+
const sw = bmp.width, sh = Math.max(60, Math.round(bmp.height * 0.45));
|
|
410
412
|
const targetW = 1400, scale = targetW / sw;
|
|
411
413
|
const canvas = document.createElement('canvas');
|
|
412
414
|
canvas.width = targetW;
|
|
@@ -448,6 +450,18 @@ async function decodeOne(reader, url, ms = 9000) {
|
|
|
448
450
|
} catch (e) { return null; }
|
|
449
451
|
}
|
|
450
452
|
|
|
453
|
+
// Rank barcode candidates by format specificity: a 1Z UPS read or a
|
|
454
|
+
// 22-digit USPS read is unambiguous, while a 10-digit "DHL" read off a
|
|
455
|
+
// label is often a fragment of another carrier's number (or a routing
|
|
456
|
+
// barcode). Weak hits never beat strong ones.
|
|
457
|
+
function barcodeRank(num) {
|
|
458
|
+
if (/^1Z/i.test(num)) return 0; // UPS
|
|
459
|
+
if (/^9[1-5]\d{18,20}$/.test(num)) return 1; // USPS
|
|
460
|
+
if (/^96\d{20}$/.test(num) || /^\d{15}$/.test(num) || /^\d{20}$/.test(num)) return 2; // FedEx long
|
|
461
|
+
if (/^\d{12}$/.test(num)) return 3; // FedEx 12 (weak: may be routing)
|
|
462
|
+
return 4; // DHL 10/11 (weakest)
|
|
463
|
+
}
|
|
464
|
+
|
|
451
465
|
// Whole-label decode usually fails (barcodes too small in frame), so try
|
|
452
466
|
// horizontal region strips, upscaled — the 1Z barcode lives low on UPS labels.
|
|
453
467
|
async function decodeBarcode(file) {
|
|
@@ -465,6 +479,11 @@ async function decodeBarcode(file) {
|
|
|
465
479
|
[0, 0.60, 1, 0.35],
|
|
466
480
|
[0.05, 0.55, 0.90, 0.40],
|
|
467
481
|
];
|
|
482
|
+
// Collect every candidate across the sweep and take the strongest —
|
|
483
|
+
// never let the first weak hit (e.g. a 10-digit routing barcode read as
|
|
484
|
+
// DHL) win over the real tracking barcode. Unambiguous reads (UPS/USPS)
|
|
485
|
+
// still return immediately on the fast path.
|
|
486
|
+
let best = null, bestRank = 99;
|
|
468
487
|
try {
|
|
469
488
|
for (const [fx, fy, fw, fh] of regions) {
|
|
470
489
|
for (const scale of [1, 1.5, 2]) {
|
|
@@ -489,11 +508,15 @@ async function decodeBarcode(file) {
|
|
|
489
508
|
const url = canvas.toDataURL('image/png');
|
|
490
509
|
const text = await decodeOne(reader, url);
|
|
491
510
|
const num = strictTracking(text);
|
|
492
|
-
if (num)
|
|
511
|
+
if (num) {
|
|
512
|
+
const r = barcodeRank(num);
|
|
513
|
+
if (r < bestRank) { best = num; bestRank = r; }
|
|
514
|
+
if (r <= 1) return num; // UPS or USPS: unambiguous, fast path
|
|
515
|
+
}
|
|
493
516
|
}
|
|
494
517
|
}
|
|
495
518
|
} finally { bmp.close(); }
|
|
496
|
-
return
|
|
519
|
+
return best;
|
|
497
520
|
}
|
|
498
521
|
// Detect when the pasted image is a screenshot OF THE APP itself (its own
|
|
499
522
|
// header/buttons/tab bar get OCR'd) — the label inside is too small to read
|
|
@@ -537,9 +560,14 @@ async function handlePastedImageFile(f) {
|
|
|
537
560
|
if (r && !r.added.length && !r.duplicates.length && !r.needs_review.some((c) => c.carrier && c.carrier !== 'unknown')) showOcrRead(target, text);
|
|
538
561
|
return;
|
|
539
562
|
}
|
|
540
|
-
// 2) Fallback: OCR the printed text
|
|
563
|
+
// 2) Fallback: OCR the printed text (+ the sender strip for the name,
|
|
564
|
+
// in parallel — the number crop usually excludes the sender block)
|
|
541
565
|
if (live) live.textContent = '📖 Reading screenshot…';
|
|
542
|
-
|
|
566
|
+
const [ocrText, topTextFb] = await Promise.all([
|
|
567
|
+
ocrFile(f, $('ocr-status-paste')),
|
|
568
|
+
ocrTopStrip(orig, $('ocr-status-paste')),
|
|
569
|
+
]);
|
|
570
|
+
text = ocrText;
|
|
543
571
|
if (live) live.textContent = '⬆️ Uploading…';
|
|
544
572
|
const fd = new FormData();
|
|
545
573
|
fd.append('image', f);
|
|
@@ -549,7 +577,7 @@ async function handlePastedImageFile(f) {
|
|
|
549
577
|
if (!up.ok) throw new Error('upload failed');
|
|
550
578
|
const upId2 = await up.json().then((j) => j.id).catch(() => null);
|
|
551
579
|
if (live) live.textContent = '🔍 Finding tracking number…';
|
|
552
|
-
const r = await runQuick(text, target);
|
|
580
|
+
const r = await runQuick(text, target, topTextFb ? { labelText: topTextFb } : undefined);
|
|
553
581
|
// Paste flow already handled this image — don't leave it nagging in the review queue.
|
|
554
582
|
if (upId2) api.patch('/api/intake/' + upId2, { state: 'reviewed' }).catch(() => {});
|
|
555
583
|
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>');
|
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,7 +74,8 @@
|
|
|
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);
|
package/src/extract.js
CHANGED
|
@@ -66,24 +66,43 @@
|
|
|
66
66
|
var m2 = text.match(/(?:from|seller)\s*:\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
|
|
67
67
|
if (m2) out.seller_name = m2[1].trim();
|
|
68
68
|
}
|
|
69
|
-
// Shipping
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
// Shipping-label name test, shared by the address-anchor rule and the
|
|
70
|
+
// first-two-lines fallback below.
|
|
71
|
+
var labelWords = function (s) { return String(s).trim().split(/\s+/); };
|
|
72
|
+
var looksLikeLabelName = function (s) {
|
|
73
|
+
var words = labelWords(s);
|
|
74
|
+
if (words.length < 2 || words.length > 3) return false;
|
|
75
|
+
// Real shipping labels print names in ALL CAPS; app UI chrome
|
|
76
|
+
// ("Back Shipment") and OCR fragments ("Eg NTT") don't.
|
|
77
|
+
if (s !== s.toUpperCase()) return false;
|
|
78
|
+
// First word 4+ letters (a real first name — rejects "EE SS");
|
|
79
|
+
// later words 4+ letters or a single initial ("MOHAMED A").
|
|
80
|
+
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
|
+
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
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
// Shipping labels: the sender name sits on the line directly above the
|
|
86
|
+
// street-address line ("MOHAMED A" above "2507 GLACIER ST"). Find that
|
|
87
|
+
// anchor anywhere in the text — far more reliable than line position,
|
|
88
|
+
// since OCR crops don't always start at the label's first line. The
|
|
89
|
+
// first such block wins (sender prints above recipient).
|
|
90
|
+
if (!out.seller_name) {
|
|
91
|
+
var alines = String(text).split(/\r?\n/);
|
|
92
|
+
for (var ai = 0; ai < alines.length; ai++) {
|
|
93
|
+
var addr = alines[ai].trim();
|
|
94
|
+
if (!/^\d{1,6}\s+[A-Z0-9][A-Z0-9 .'-]*$/.test(addr)) continue;
|
|
95
|
+
if (!/\b(ST|STREET|AVE|AVENUE|RD|ROAD|DR|DRIVE|LN|LANE|BLVD|PL|PLACE|CT|COURT|WAY|TER|TERRACE|PKWY|PARKWAY)\b/.test(addr)) continue;
|
|
96
|
+
if (ai === 0) continue;
|
|
97
|
+
var cand = alines[ai - 1].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
98
|
+
if (looksLikeLabelName(cand)) { out.seller_name = cand; break; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Fallback: sender name is the first or second line ("MOHAMMED SALEM").
|
|
102
|
+
// Only take it when it's clearly a name — never digits, never address
|
|
103
|
+
// fragments, never OCR garbage like "EE SS", and never the app's own UI
|
|
104
|
+
// words when a screenshot of the app itself gets pasted ("Back Shipment").
|
|
74
105
|
if (!out.seller_name) {
|
|
75
|
-
var labelWords = function (s) { return String(s).trim().split(/\s+/); };
|
|
76
|
-
var looksLikeLabelName = function (s) {
|
|
77
|
-
var words = labelWords(s);
|
|
78
|
-
if (words.length < 2 || words.length > 3) return false;
|
|
79
|
-
// Real shipping labels print names in ALL CAPS; app UI chrome
|
|
80
|
-
// ("Back Shipment") and OCR fragments ("Eg NTT") don't.
|
|
81
|
-
if (s !== s.toUpperCase()) return false;
|
|
82
|
-
// every word 4+ letters: OCR garbage ("EE SS") never qualifies.
|
|
83
|
-
for (var i = 0; i < words.length; i++) if (!/^[A-Z]{4,}$/.test(words[i])) return false;
|
|
84
|
-
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)\b/.test(s)) return false;
|
|
85
|
-
return true;
|
|
86
|
-
};
|
|
87
106
|
var lines = String(text).split(/\r?\n/);
|
|
88
107
|
for (var li = 0; li < Math.min(2, lines.length); li++) {
|
|
89
108
|
var cleaned = lines[li].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -248,7 +267,8 @@
|
|
|
248
267
|
runPatterns(z.text, found, seen, z.carrier);
|
|
249
268
|
spacedUpsIn(z.text, found, seen, 'high');
|
|
250
269
|
looseUpsIn(z.text, found, seen, 'high');
|
|
251
|
-
spacedDigitGroupsIn(z.text, found, seen, 'high');
|
|
270
|
+
spacedDigitGroupsIn(z.text, found, seen, 'high', z.carrier);
|
|
271
|
+
if (z.carrier === 'ups') upsAnchorlessRecovery(z.text, found, seen);
|
|
252
272
|
});
|
|
253
273
|
// 1) Whole-text strict patterns
|
|
254
274
|
runPatterns(text, found, seen, false);
|
|
@@ -286,7 +306,10 @@
|
|
|
286
306
|
// anchor line + the two lines under it (the number is printed below)
|
|
287
307
|
var slice = lines.slice(i, Math.min(lines.length, i + 3));
|
|
288
308
|
var carrier = null;
|
|
289
|
-
slice
|
|
309
|
+
// Scan the slice AND the line above it: "UPS GROUND" typically sits
|
|
310
|
+
// directly above the "TRACKING #:" line, and the zone needs to know.
|
|
311
|
+
var scan = slice.concat(i > 0 ? [lines[i - 1]] : []);
|
|
312
|
+
scan.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
|
|
290
313
|
zones.push({ text: slice.join('\n'), carrier: carrier });
|
|
291
314
|
}
|
|
292
315
|
return zones;
|
|
@@ -358,7 +381,11 @@
|
|
|
358
381
|
// contains a separator (contiguous runs are already covered by PATTERNS).
|
|
359
382
|
// Joined candidates must validate against a carrier format; IMpb barcodes
|
|
360
383
|
// yield their tracking number, never the routing.
|
|
361
|
-
|
|
384
|
+
// zoneCarrier: when the anchor zone names a carrier ("UPS GROUND"), a
|
|
385
|
+
// candidate of any OTHER carrier is a fragment of the real number
|
|
386
|
+
// ("03 3985 0416" off a UPS label is not a DHL shipment) — demote it to
|
|
387
|
+
// low so it can only ever surface for review, never auto-add.
|
|
388
|
+
function spacedDigitGroupsIn(text, found, seen, conf, zoneCarrier) {
|
|
362
389
|
var re = /\b\d(?:[\s-]?\d){9,33}\b/g, m;
|
|
363
390
|
while ((m = re.exec(text)) !== null) {
|
|
364
391
|
if (!/[\s-]/.test(m[0])) continue;
|
|
@@ -368,13 +395,34 @@
|
|
|
368
395
|
var impb = parseImpb(num);
|
|
369
396
|
if (impb) {
|
|
370
397
|
seen[num] = true;
|
|
371
|
-
|
|
398
|
+
var ic = (zoneCarrier && zoneCarrier !== 'usps') ? 'low' : conf;
|
|
399
|
+
if (!seen[impb]) { seen[impb] = true; found.push({ number: impb, carrier: 'usps', confidence: ic }); }
|
|
372
400
|
continue;
|
|
373
401
|
}
|
|
374
402
|
var carrier = guessCarrier(num);
|
|
375
403
|
if (carrier === 'unknown') { seen[num] = true; continue; }
|
|
376
404
|
seen[num] = true;
|
|
377
|
-
|
|
405
|
+
var c = (zoneCarrier && carrier !== zoneCarrier) ? 'low' : conf;
|
|
406
|
+
found.push({ number: num, carrier: carrier, confidence: c });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Last resort for UPS zones: the "1Z" anchor itself was unreadable, so the
|
|
411
|
+
// number survives only as a 16-char alphanumeric tail ("X01 48H 03 3985
|
|
412
|
+
// 0416"). If it validates as strict UPS tail structure, offer 1Z+tail at
|
|
413
|
+
// medium (review-only, never auto-add) — far better than a DHL fragment.
|
|
414
|
+
var UPS_TAIL_STRICT = /^[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
|
|
415
|
+
function upsAnchorlessRecovery(text, found, seen) {
|
|
416
|
+
var re = /\b([0-9A-Z](?:[\s-]?[0-9A-Z]){15})\b/gi, m;
|
|
417
|
+
while ((m = re.exec(text)) !== null) {
|
|
418
|
+
var token = normalize(m[1]);
|
|
419
|
+
if (/^(1Z|12|IZ|17)/i.test(token)) continue; // anchored — handled elsewhere
|
|
420
|
+
if (!UPS_TAIL_STRICT.test(token)) continue;
|
|
421
|
+
if (!/[A-Z]/.test(token.slice(0, 6))) continue; // real shipper codes have a letter
|
|
422
|
+
var num = '1Z' + token;
|
|
423
|
+
if (seen[num]) continue;
|
|
424
|
+
seen[num] = true;
|
|
425
|
+
found.push({ number: num, carrier: 'ups', confidence: 'medium', ocr_fixed: true });
|
|
378
426
|
}
|
|
379
427
|
}
|
|
380
428
|
|