hh-shipments 0.3.7 → 0.3.9

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.7",
21
+ "version": "0.3.9",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -111,7 +111,10 @@ async function loadDash() {
111
111
  async function runQuick(text, targetEl, preset) {
112
112
  targetEl.innerHTML = '<p class="muted">Extracting…</p>';
113
113
  try {
114
- const r = await api.post('/api/intake/quick', { text, seller_name: preset && preset.seller, device: preset && preset.device });
114
+ const r = await Promise.race([
115
+ api.post('/api/intake/quick', { text, seller_name: preset && preset.seller, device: preset && preset.device }),
116
+ new Promise((_, rej) => setTimeout(() => rej(new Error('extract timed out — check your connection and try again')), 45000)),
117
+ ]);
115
118
  let html = '';
116
119
  r.added.forEach((a) => { html += `<div class="review">✅ Added <b>${esc(a.number)}</b> (${esc(a.carrier.toUpperCase())})${r.simulated ? ' <span class="sim-badge">SIMULATED</span>' : ''}</div>`; });
117
120
  r.duplicates.forEach((n) => { html += `<div class="review">⛔ <b>${esc(n)}</b> is already tracked — skipped.</div>`; });
@@ -171,13 +174,16 @@ async function ocrFile(file, statusEl) {
171
174
  async function handlePhotoFile(f) {
172
175
  const target = $('photo-result');
173
176
  try {
177
+ f = await shrinkImage(f);
174
178
  const url = URL.createObjectURL(f);
175
179
  target.innerHTML = `<div class="review"><img src="${url}" style="max-width:100%;border-radius:10px"><p class="muted">📖 Reading screenshot…</p></div>`;
176
180
  const text = await ocrFile(f, $('ocr-status'));
177
181
  // Upload original for reference, then run the quick flow on OCR text
178
182
  const fd = new FormData();
179
183
  fd.append('image', f);
180
- const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
184
+ let up;
185
+ try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
186
+ catch (e) { throw new Error('upload timed out — check your connection and try again'); }
181
187
  if (!up.ok) throw new Error('upload failed');
182
188
  const r = await runQuick(text, $('photo-result'));
183
189
  if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead($('photo-result'), text);
@@ -206,21 +212,46 @@ function pastedImageFiles(e) {
206
212
  return files;
207
213
  }
208
214
 
209
- // Paste-text tab: pasting a screenshot runs OCR and extracts automatically —
210
- // no "Extract" tap needed. Plain text paste still lands in the textarea.
215
+ // Shrink huge phone photos before OCR/upload: much faster to read and send,
216
+ // and less exposed to a stalled connection. Falls back to the original file.
217
+ async function shrinkImage(file, maxDim = 1600) {
218
+ try {
219
+ const bmp = await createImageBitmap(file);
220
+ const scale = Math.min(1, maxDim / Math.max(bmp.width, bmp.height));
221
+ if (scale >= 1) { bmp.close(); return file; }
222
+ const canvas = document.createElement('canvas');
223
+ canvas.width = Math.round(bmp.width * scale);
224
+ canvas.height = Math.round(bmp.height * scale);
225
+ canvas.getContext('2d').drawImage(bmp, 0, 0, canvas.width, canvas.height);
226
+ bmp.close();
227
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.85));
228
+ return blob ? new File([blob], 'label.jpg', { type: 'image/jpeg' }) : file;
229
+ } catch (e) { return file; }
230
+ }
231
+
232
+ async function fetchTimeout(url, opts = {}, ms = 60000) {
233
+ const c = new AbortController();
234
+ const t = setTimeout(() => c.abort(), ms);
235
+ try { return await fetch(url, { ...opts, signal: c.signal }); }
236
+ finally { clearTimeout(t); }
237
+ }
211
238
  async function handlePastedImageFile(f) {
212
239
  const target = $('quick-result');
213
240
  try {
214
241
  // Instant feedback: show a thumbnail + progress the moment the image lands
242
+ f = await shrinkImage(f);
215
243
  const url = URL.createObjectURL(f);
216
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>`;
217
245
  const text = await ocrFile(f, $('ocr-status-paste'));
218
246
  const live = $('ocr-live');
219
- if (live) live.textContent = '🔍 Finding tracking number…';
247
+ if (live) live.textContent = '⬆️ Uploading…';
220
248
  const fd = new FormData();
221
249
  fd.append('image', f);
222
- const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
250
+ let up;
251
+ try { up = await fetchTimeout('/api/intake/image', { method: 'POST', body: fd }, 60000); }
252
+ catch (e) { throw new Error('upload timed out — check your connection and try again'); }
223
253
  if (!up.ok) throw new Error('upload failed');
254
+ if (live) live.textContent = '🔍 Finding tracking number…';
224
255
  const r = await runQuick(text, target);
225
256
  if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead(target, text);
226
257
  } catch (err) { $('ocr-status-paste').textContent = ''; target.innerHTML = `<p class="error">${esc(err.message)}</p>`; }
package/src/extract.js CHANGED
@@ -76,7 +76,13 @@
76
76
  var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
77
77
  while ((m = re.exec(text)) !== null) {
78
78
  var ups = normalize(m[0]);
79
- var num = /^1Z[0-9A-Z]{16}$/.test(ups) ? ups : repairUpsZ(ups);
79
+ var num = null;
80
+ if (/^1Z[0-9A-Z]{6}[0-9]{10}$/.test(ups)) num = ups; // fully strict
81
+ else if (/^1Z[0-9A-Z]{16}$/.test(ups)) {
82
+ // letters sitting in the digit zone are OCR confusions — repair them
83
+ num = repairUpsTail(ups.slice(2)) || ups;
84
+ }
85
+ else num = repairUpsZ(ups) || repairUpsTail(ups.slice(2));
80
86
  if (num && !seen[num]) {
81
87
  seen[num] = true;
82
88
  found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups });
@@ -84,6 +90,39 @@
84
90
  }
85
91
  }
86
92
 
93
+ // Tesseract also mangles punctuation on labels: ")" / "]" / "}" for J,
94
+ // "(" / "[" / "{" for C, "|" / "!" for 1 (e.g. "1Z 1X) 539 ..." for 1XJ).
95
+ // The UPS structure is strict (1Z + 6-char shipper + 2-digit service +
96
+ // 8-digit serial), so a mangled tail can be repaired and accepted only
97
+ // when the repaired result validates strictly — never invented.
98
+ var OCR_PUNCT_FIX = { ')': 'J', ']': 'J', '}': 'J', '(': 'C', '[': 'C', '{': 'C', '|': '1', '!': '1' };
99
+ var OCR_TAIL_DIGIT_FIX = { 'O': '0', 'Q': '0', 'I': '1', 'l': '1', '|': '1', '!': '1', 'Z': '2', 'S': '5', 'B': '8' };
100
+ function repairUpsTail(tail) {
101
+ // tail: 16 chars after the 1Z anchor (6-char shipper + 2-digit service +
102
+ // 8-digit serial), possibly with OCR confusions.
103
+ var ship = tail.slice(0, 6), digits = tail.slice(6, 16);
104
+ var fixShip = ship.replace(/[)\]}(\[{|!]/g, function (c) { return OCR_PUNCT_FIX[c]; });
105
+ var fixDigits = digits.replace(/[OQIil|!ZSB]/g, function (c) { return OCR_TAIL_DIGIT_FIX[c]; });
106
+ var cand = '1Z' + fixShip + fixDigits;
107
+ if (!UPS_STRICT.test(cand)) return null;
108
+ if (!/[A-Z]/.test(cand.slice(2, 8))) return null; // real shipper numbers contain a letter
109
+ return cand;
110
+ }
111
+ function looseUpsIn(text, found, seen, conf) {
112
+ // Loose: anchor + 16 chars from an OCR-confusion-tolerant alphabet,
113
+ // spaces/dashes allowed. repairUpsTail decides; strictness lives there.
114
+ var m, re = /\b(?:1Z|12|IZ)[\s-]?((?:[0-9A-Z)\]}(\[{|!][\s-]?){16})\b/gi;
115
+ while ((m = re.exec(text)) !== null) {
116
+ var tail = m[1].replace(/[\s-]/g, '').toUpperCase();
117
+ if (tail.length !== 16) continue;
118
+ var num = repairUpsTail(tail);
119
+ if (num && !seen[num]) {
120
+ seen[num] = true;
121
+ found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: true });
122
+ }
123
+ }
124
+ }
125
+
87
126
  function extractTrackingNumbers(text) {
88
127
  var found = [];
89
128
  var seen = {};
@@ -95,6 +134,7 @@
95
134
  anchorZones(text).forEach(function (z) {
96
135
  runPatterns(z.text, found, seen, z.carrier);
97
136
  spacedUpsIn(z.text, found, seen, 'high');
137
+ looseUpsIn(z.text, found, seen, 'high');
98
138
  });
99
139
  // 1) Whole-text strict patterns
100
140
  runPatterns(text, found, seen, false);
@@ -194,6 +234,7 @@
194
234
  // a) UPS number split by spaces/dashes (whole-text fallback; the anchor
195
235
  // pass already tried the hot zones at high confidence)
196
236
  spacedUpsIn(text, found, seen, 'medium');
237
+ looseUpsIn(text, found, seen, 'medium');
197
238
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
198
239
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
199
240
  while ((run = runRe.exec(text)) !== null) {