hh-shipments 0.3.9 → 0.3.11
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 +95 -4
- package/src/extract.js +30 -1
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -176,8 +176,17 @@ async function handlePhotoFile(f) {
|
|
|
176
176
|
try {
|
|
177
177
|
f = await shrinkImage(f);
|
|
178
178
|
const url = URL.createObjectURL(f);
|
|
179
|
-
target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted"
|
|
180
|
-
const
|
|
179
|
+
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>`;
|
|
180
|
+
const live = $('ocr-live-photo');
|
|
181
|
+
const barcode = await decodeBarcode(f);
|
|
182
|
+
let text;
|
|
183
|
+
if (barcode) {
|
|
184
|
+
text = barcode;
|
|
185
|
+
if (live) live.textContent = '📷 Barcode read — verifying…';
|
|
186
|
+
} else {
|
|
187
|
+
if (live) live.textContent = '📖 Reading screenshot…';
|
|
188
|
+
text = await ocrFile(f, $('ocr-status'));
|
|
189
|
+
}
|
|
181
190
|
// Upload original for reference, then run the quick flow on OCR text
|
|
182
191
|
const fd = new FormData();
|
|
183
192
|
fd.append('image', f);
|
|
@@ -235,15 +244,96 @@ async function fetchTimeout(url, opts = {}, ms = 60000) {
|
|
|
235
244
|
try { return await fetch(url, { ...opts, signal: c.signal }); }
|
|
236
245
|
finally { clearTimeout(t); }
|
|
237
246
|
}
|
|
247
|
+
|
|
248
|
+
// ---- Barcode-first decoding (ZXing) ----
|
|
249
|
+
// A decoded barcode is checksum-verified (Code128 mod-103), so it is
|
|
250
|
+
// essentially never wrong — far more reliable than OCR text for labels.
|
|
251
|
+
let zxingPromise = null;
|
|
252
|
+
function loadZxing() {
|
|
253
|
+
if (window.ZXing) return Promise.resolve();
|
|
254
|
+
if (!zxingPromise) {
|
|
255
|
+
zxingPromise = new Promise((res, rej) => {
|
|
256
|
+
const s = document.createElement('script');
|
|
257
|
+
s.src = 'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js';
|
|
258
|
+
s.onload = res;
|
|
259
|
+
s.onerror = () => rej(new Error('barcode reader failed to load'));
|
|
260
|
+
document.head.appendChild(s);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return zxingPromise;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Strict client-side check: only barcode reads that look exactly like a
|
|
267
|
+
// tracking number are trusted (the server re-validates anyway).
|
|
268
|
+
function strictTracking(s) {
|
|
269
|
+
s = String(s || '').toUpperCase().replace(/[\s-]/g, '');
|
|
270
|
+
if (/^1Z[0-9A-Z]{16}$/.test(s)) return s; // UPS
|
|
271
|
+
if (/^9\d{19,21}$/.test(s)) return s; // USPS
|
|
272
|
+
if (/^(\d{12}|\d{15}|\d{20})$/.test(s)) return s; // FedEx
|
|
273
|
+
if (/^\d{10,11}$/.test(s)) return s; // DHL
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function decodeOne(reader, url, ms = 9000) {
|
|
278
|
+
try {
|
|
279
|
+
const img = new Image();
|
|
280
|
+
img.src = url;
|
|
281
|
+
await img.decode();
|
|
282
|
+
const p = reader.decodeFromImageElement(img);
|
|
283
|
+
const r = await Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('decode timeout')), ms))]);
|
|
284
|
+
return r.getText();
|
|
285
|
+
} catch (e) { return null; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Whole-label decode usually fails (barcodes too small in frame), so try
|
|
289
|
+
// horizontal region strips, upscaled — the 1Z barcode lives low on UPS labels.
|
|
290
|
+
async function decodeBarcode(file) {
|
|
291
|
+
try { await loadZxing(); } catch (e) { return null; }
|
|
292
|
+
let bmp;
|
|
293
|
+
try { bmp = await createImageBitmap(file); } catch (e) { return null; }
|
|
294
|
+
const hints = new Map([[ZXing.DecodeHintType.TRY_HARDER, true]]);
|
|
295
|
+
const reader = new ZXing.BrowserMultiFormatReader(hints);
|
|
296
|
+
const regions = [
|
|
297
|
+
[0, 0, 1, 1], // full image
|
|
298
|
+
[0, 0.35, 1, 0.35], // middle strip
|
|
299
|
+
[0, 0.55, 1, 0.40], // lower strip (UPS 1Z barcode zone)
|
|
300
|
+
];
|
|
301
|
+
try {
|
|
302
|
+
for (const [fx, fy, fw, fh] of regions) {
|
|
303
|
+
const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
|
|
304
|
+
if (sw < 60 || sh < 60) continue;
|
|
305
|
+
const scale = Math.max(1, 1200 / sw);
|
|
306
|
+
const canvas = document.createElement('canvas');
|
|
307
|
+
canvas.width = Math.round(sw * scale);
|
|
308
|
+
canvas.height = Math.round(sh * scale);
|
|
309
|
+
canvas.getContext('2d').drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
|
|
310
|
+
const url = canvas.toDataURL('image/png');
|
|
311
|
+
const text = await decodeOne(reader, url);
|
|
312
|
+
const num = strictTracking(text);
|
|
313
|
+
if (num) return num;
|
|
314
|
+
}
|
|
315
|
+
} finally { bmp.close(); }
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
238
318
|
async function handlePastedImageFile(f) {
|
|
239
319
|
const target = $('quick-result');
|
|
240
320
|
try {
|
|
241
321
|
// Instant feedback: show a thumbnail + progress the moment the image lands
|
|
242
322
|
f = await shrinkImage(f);
|
|
243
323
|
const url = URL.createObjectURL(f);
|
|
244
|
-
target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live"
|
|
245
|
-
const text = await ocrFile(f, $('ocr-status-paste'));
|
|
324
|
+
target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted" id="ocr-live">📷 Scanning barcode…</p></div>`;
|
|
246
325
|
const live = $('ocr-live');
|
|
326
|
+
// 1) Barcode first: checksum-verified, essentially never wrong
|
|
327
|
+
const barcode = await decodeBarcode(f);
|
|
328
|
+
let text;
|
|
329
|
+
if (barcode) {
|
|
330
|
+
text = barcode;
|
|
331
|
+
if (live) live.textContent = '📷 Barcode read — verifying…';
|
|
332
|
+
} else {
|
|
333
|
+
// 2) Fallback: OCR the printed text
|
|
334
|
+
if (live) live.textContent = '📖 Reading screenshot…';
|
|
335
|
+
text = await ocrFile(f, $('ocr-status-paste'));
|
|
336
|
+
}
|
|
247
337
|
if (live) live.textContent = '⬆️ Uploading…';
|
|
248
338
|
const fd = new FormData();
|
|
249
339
|
fd.append('image', f);
|
|
@@ -253,6 +343,7 @@ async function handlePastedImageFile(f) {
|
|
|
253
343
|
if (!up.ok) throw new Error('upload failed');
|
|
254
344
|
if (live) live.textContent = '🔍 Finding tracking number…';
|
|
255
345
|
const r = await runQuick(text, target);
|
|
346
|
+
if (barcode && r && r.added.length) target.insertAdjacentHTML('afterbegin', '<p class="muted">📷 Read from the barcode — checksum verified.</p>');
|
|
256
347
|
if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead(target, text);
|
|
257
348
|
} catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
|
|
258
349
|
}
|
package/src/extract.js
CHANGED
|
@@ -41,14 +41,43 @@
|
|
|
41
41
|
function extractContext(text) {
|
|
42
42
|
var out = { seller_name: null, device: null };
|
|
43
43
|
if (!text) return out;
|
|
44
|
+
// A plausible person name: no digits (addresses have them), not ALL CAPS
|
|
45
|
+
// (shipping-label blocks are), proper name shape. Shipping labels like
|
|
46
|
+
// "ORANGE NJ 07050 DWT: 11,9,7" must never land in the seller field.
|
|
47
|
+
function looksLikeName(s) {
|
|
48
|
+
s = String(s).trim();
|
|
49
|
+
if (!s || s.length > 40) return false;
|
|
50
|
+
if (/\d/.test(s)) return false;
|
|
51
|
+
if (/^[A-Z\s.,'-]+$/.test(s)) return false;
|
|
52
|
+
if (!/^[A-Za-z][A-Za-z .,'-]*$/.test(s)) return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
44
55
|
// WhatsApp export style: "[10/12/26, 9:41 AM] John Smith: message"
|
|
45
56
|
var m = text.match(/^\[?[^\]\n]*\]?\s*([^:\[\]\n]{2,40}):\s/m);
|
|
46
|
-
if (m && !/^(you|me|http)/i.test(m[1].trim())) out.seller_name = m[1].trim();
|
|
57
|
+
if (m && !/^(you|me|http)/i.test(m[1].trim()) && looksLikeName(m[1])) out.seller_name = m[1].trim();
|
|
47
58
|
// "from John", "seller: John"
|
|
48
59
|
if (!out.seller_name) {
|
|
49
60
|
var m2 = text.match(/(?:from|seller)\s*:\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
|
|
50
61
|
if (m2) out.seller_name = m2[1].trim();
|
|
51
62
|
}
|
|
63
|
+
// Shipping labels: the sender name is the first or second line
|
|
64
|
+
// ("MOHAMMED SALEM"). Only take it when it's clearly a name — never
|
|
65
|
+
// digits, never address fragments, never OCR garbage like "EE SS".
|
|
66
|
+
if (!out.seller_name) {
|
|
67
|
+
var labelWords = function (s) { return String(s).trim().split(/\s+/); };
|
|
68
|
+
var looksLikeLabelName = function (s) {
|
|
69
|
+
var words = labelWords(s);
|
|
70
|
+
if (words.length < 2 || words.length > 4) return false;
|
|
71
|
+
for (var i = 0; i < words.length; i++) if (!/^[A-Za-z]{3,}$/.test(words[i])) return false;
|
|
72
|
+
if (/\b(UPS|FEDEX|USPS|DHL|GROUND|EXPRESS|PRIORITY|BILLING|SHIP\b|FROM|TRACKING|POSTAGE|LABEL)\b/i.test(s)) return false;
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
var lines = String(text).split(/\r?\n/);
|
|
76
|
+
for (var li = 0; li < Math.min(2, lines.length); li++) {
|
|
77
|
+
var cleaned = lines[li].replace(/[^A-Za-z ]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
78
|
+
if (looksLikeLabelName(cleaned)) { out.seller_name = cleaned; break; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
52
81
|
// Device: iPhone 15 Pro Max 256GB, iPad Pro 11, etc.
|
|
53
82
|
var dm = text.match(/\b(iPhone\s+\d{1,2}\s*(?:Pro\s*Max|Pro|Plus|Mini|e)?|iPad\s*(?:Pro|Air|Mini)?\s*\d*(?:\.\d)?)\b/i);
|
|
54
83
|
var sm = text.match(/\b(64|128|256|512)\s?GB\b/i) || text.match(/\b1\s?TB\b/i);
|