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 CHANGED
@@ -18,7 +18,7 @@
18
18
  "test": "node test/run-tests.js && node test/run-carriers-tests.js"
19
19
  },
20
20
  "type": "commonjs",
21
- "version": "0.3.9",
21
+ "version": "0.3.11",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
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">📖 Reading screenshot…</p></div>`;
180
- const text = await ocrFile(f, $('ocr-status'));
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">📖 Reading screenshot…</p></div>`;
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);