hh-shipments 0.3.3 → 0.3.5

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.3",
21
+ "version": "0.3.5",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -134,7 +134,15 @@ async function runQuick(text, targetEl, preset) {
134
134
  };
135
135
  });
136
136
  loadDashCounts();
137
- } catch (e) { targetEl.innerHTML = `<p class="error">${esc(e.message)}</p>`; }
137
+ return r;
138
+ } catch (e) { targetEl.innerHTML = `<p class="error">${esc(e.message)}</p>`; return null; }
139
+ }
140
+
141
+ // When OCR finds no tracking number, show the raw read so the user can
142
+ // spot the misread and type it in manually.
143
+ function showOcrRead(targetEl, text) {
144
+ const t = (text || '').trim();
145
+ targetEl.innerHTML += `<details class="ocr-read"><summary>What I read from the picture</summary><pre>${esc(t || '(nothing readable)')}</pre></details>`;
138
146
  }
139
147
  async function loadDashCounts() { /* counts refresh on next dashboard visit */ }
140
148
 
@@ -168,7 +176,8 @@ async function handlePhotoFile(f) {
168
176
  fd.append('image', f);
169
177
  const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
170
178
  if (!up.ok) throw new Error('upload failed');
171
- await runQuick(text, $('photo-result'));
179
+ const r = await runQuick(text, $('photo-result'));
180
+ if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead($('photo-result'), text);
172
181
  loadPending();
173
182
  } catch (err) { $('ocr-status').textContent = ''; $('photo-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
174
183
  }
@@ -203,7 +212,8 @@ async function handlePastedImageFile(f) {
203
212
  fd.append('image', f);
204
213
  const up = await fetch('/api/intake/image', { method: 'POST', body: fd });
205
214
  if (!up.ok) throw new Error('upload failed');
206
- await runQuick(text, $('quick-result'));
215
+ const r = await runQuick(text, $('quick-result'));
216
+ if (r && !r.added.length && !r.needs_review.length && !r.duplicates.length) showOcrRead($('quick-result'), text);
207
217
  } catch (err) { $('ocr-status-paste').textContent = ''; $('quick-result').innerHTML = `<p class="error">${esc(err.message)}</p>`; }
208
218
  }
209
219
 
package/public/extract.js CHANGED
@@ -81,6 +81,7 @@
81
81
  }
82
82
  });
83
83
  // Sort: high confidence first, then by position
84
+ extractOcrTolerant(text, found, seen);
84
85
  var rank = { high: 0, medium: 1, low: 2 };
85
86
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
86
87
  return found;
@@ -95,6 +96,85 @@
95
96
  return false;
96
97
  }
97
98
 
99
+ function guessCarrier(num) {
100
+ var carriers = ['ups', 'usps', 'fedex', 'dhl'];
101
+ for (var i = 0; i < carriers.length; i++) if (isValidFormat(num, carriers[i])) return carriers[i];
102
+ return 'unknown';
103
+ }
104
+
105
+ // ---- OCR-tolerant pass ----
106
+ // Screenshots often mangle characters (0/O, 1/I/l, 5/S, 8/B) or split a
107
+ // number across spaces. Recover what the strict patterns miss.
108
+ var OCR_DIGIT_FIX = { O: '0', o: '0', I: '1', i: '1', l: '1', L: '1', S: '5', s: '5', B: '8', b: '8' };
109
+ function ocrDigitFix(token) {
110
+ return token.replace(/[OoIiLlSsBb]/g, function (c) { return OCR_DIGIT_FIX[c]; });
111
+ }
112
+
113
+ function extractOcrTolerant(text, found, seen) {
114
+ // Strict UPS structure: 1Z + 6-char shipper number + 2-digit service + 8-digit serial.
115
+ // Used to validate "1Z"-as-"12" OCR repairs so we don't invent false positives.
116
+ var UPS_STRICT = /^1Z[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
117
+ function push(num, carrier, confidence, extra) {
118
+ num = normalize(num);
119
+ if (!num || seen[num]) return;
120
+ seen[num] = true;
121
+ var c = { number: num, carrier: carrier, confidence: confidence, ocr_fixed: true };
122
+ if (extra) for (var k in extra) c[k] = extra[k];
123
+ found.push(c);
124
+ }
125
+ function repairUpsZ(ups) {
126
+ // Tesseract often reads the "1Z" anchor as "12" (or "IZ"). Repair it
127
+ // only when the rest has strict UPS structure AND the shipper segment
128
+ // contains a letter — real UPS shipper numbers do, random digit runs
129
+ // don't. Never invent numbers from thin air.
130
+ var cand = null;
131
+ if (/^12[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
132
+ else if (/^IZ[0-9A-Z]{16}$/i.test(ups)) cand = '1Z' + ups.slice(2);
133
+ if (!cand || !UPS_STRICT.test(cand)) return null;
134
+ if (!/[A-Z]/.test(cand.slice(2, 8))) return null;
135
+ return cand;
136
+ }
137
+ // a) UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
138
+ var m, spacedUps = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
139
+ while ((m = spacedUps.exec(text)) !== null) {
140
+ var ups = normalize(m[0]);
141
+ if (/^1Z[0-9A-Z]{16}$/.test(ups)) push(ups, 'ups', 'medium');
142
+ else {
143
+ var fixed = repairUpsZ(ups);
144
+ if (fixed) push(fixed, 'ups', 'medium');
145
+ }
146
+ }
147
+ // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
148
+ var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
149
+ while ((run = runRe.exec(text)) !== null) {
150
+ var fixed = normalize(ocrDigitFix(run[0]));
151
+ if (/^9[2-5]\d{18,20}$/.test(fixed)) push(fixed, 'usps', 'medium');
152
+ else if (/^96\d{20}$/.test(fixed)) push(fixed, 'fedex', 'medium');
153
+ else if (/^(\d{12}|\d{15}|\d{20})$/.test(fixed)) push(fixed, 'fedex', 'medium');
154
+ else if (/^\d{11}$/.test(fixed)) push(fixed, 'dhl', 'medium');
155
+ else if (/^\d{10}$/.test(fixed)) push(fixed, 'dhl', 'low');
156
+ }
157
+ // c) fallback: long alphanumeric tokens (must contain a digit) that matched
158
+ // nothing — surface the most plausible few as "verify the digits" candidates
159
+ var tokens = text.match(/\b(?=[A-Z0-9]*\d)[A-Z0-9]{12,30}\b/gi) || [];
160
+ tokens = tokens.map(normalize);
161
+ tokens = tokens.filter(function (t, i) {
162
+ if (seen[t] || seen[ocrDigitFix(t)]) return false; // already covered (possibly OCR-fixed)
163
+ return tokens.indexOf(t) === i;
164
+ });
165
+ tokens.sort(function (a, b) { return b.length - a.length; });
166
+ tokens.slice(0, 3).forEach(function (t) {
167
+ // UPS "1Z" misread as "12"/"IZ" in a joined (space-free) token
168
+ var fixed = repairUpsZ(t);
169
+ if (fixed) { push(fixed, 'ups', 'medium'); return; }
170
+ // OCR-fixed variant may match a carrier format (e.g. I->1 at the start)
171
+ var digitFixed = ocrDigitFix(t);
172
+ var gc = guessCarrier(digitFixed);
173
+ if (gc !== 'unknown') { push(digitFixed, gc, 'low', { possible: true }); return; }
174
+ push(t, guessCarrier(t), 'low', { possible: true });
175
+ });
176
+ }
177
+
98
178
  var api = { extractTrackingNumbers: extractTrackingNumbers, extractContext: extractContext, normalize: normalize, isValidFormat: isValidFormat };
99
179
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
100
180
  globalThis.HHExtract = api;
package/public/styles.css CHANGED
@@ -44,6 +44,8 @@ h2 { font-size: 17px; margin: 18px 0 8px; }
44
44
  .filebtn { display: block; text-align: center; background: #e5e5ea; border-radius: 12px; padding: 16px; font-weight: 600; cursor: pointer; }
45
45
  .pastebox { margin-top: 8px; border: 2px dashed #c7c7cc; border-radius: 12px; padding: 14px; text-align: center; outline: none; caret-color: transparent; min-height: 20px; }
46
46
  .pastebox:focus { border-color: #007aff; background: #f2f8ff; }
47
+ .ocr-read { margin-top: 8px; }
48
+ .ocr-read pre { white-space: pre-wrap; font-size: 12px; background: #f2f2f7; padding: 8px; border-radius: 8px; }
47
49
  .statusline { font-size: 17px; font-weight: 600; margin: 8px 0; }
48
50
  .timeline { border-left: 2px solid #d1d1d6; margin-left: 6px; padding-left: 14px; }
49
51
  .timeline .ev { margin-bottom: 12px; font-size: 14px; }
package/src/extract.js CHANGED
@@ -81,6 +81,7 @@
81
81
  }
82
82
  });
83
83
  // Sort: high confidence first, then by position
84
+ extractOcrTolerant(text, found, seen);
84
85
  var rank = { high: 0, medium: 1, low: 2 };
85
86
  found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
86
87
  return found;
@@ -95,6 +96,85 @@
95
96
  return false;
96
97
  }
97
98
 
99
+ function guessCarrier(num) {
100
+ var carriers = ['ups', 'usps', 'fedex', 'dhl'];
101
+ for (var i = 0; i < carriers.length; i++) if (isValidFormat(num, carriers[i])) return carriers[i];
102
+ return 'unknown';
103
+ }
104
+
105
+ // ---- OCR-tolerant pass ----
106
+ // Screenshots often mangle characters (0/O, 1/I/l, 5/S, 8/B) or split a
107
+ // number across spaces. Recover what the strict patterns miss.
108
+ var OCR_DIGIT_FIX = { O: '0', o: '0', I: '1', i: '1', l: '1', L: '1', S: '5', s: '5', B: '8', b: '8' };
109
+ function ocrDigitFix(token) {
110
+ return token.replace(/[OoIiLlSsBb]/g, function (c) { return OCR_DIGIT_FIX[c]; });
111
+ }
112
+
113
+ function extractOcrTolerant(text, found, seen) {
114
+ // Strict UPS structure: 1Z + 6-char shipper number + 2-digit service + 8-digit serial.
115
+ // Used to validate "1Z"-as-"12" OCR repairs so we don't invent false positives.
116
+ var UPS_STRICT = /^1Z[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
117
+ function push(num, carrier, confidence, extra) {
118
+ num = normalize(num);
119
+ if (!num || seen[num]) return;
120
+ seen[num] = true;
121
+ var c = { number: num, carrier: carrier, confidence: confidence, ocr_fixed: true };
122
+ if (extra) for (var k in extra) c[k] = extra[k];
123
+ found.push(c);
124
+ }
125
+ function repairUpsZ(ups) {
126
+ // Tesseract often reads the "1Z" anchor as "12" (or "IZ"). Repair it
127
+ // only when the rest has strict UPS structure AND the shipper segment
128
+ // contains a letter — real UPS shipper numbers do, random digit runs
129
+ // don't. Never invent numbers from thin air.
130
+ var cand = null;
131
+ if (/^12[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
132
+ else if (/^IZ[0-9A-Z]{16}$/i.test(ups)) cand = '1Z' + ups.slice(2);
133
+ if (!cand || !UPS_STRICT.test(cand)) return null;
134
+ if (!/[A-Z]/.test(cand.slice(2, 8))) return null;
135
+ return cand;
136
+ }
137
+ // a) UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
138
+ var m, spacedUps = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
139
+ while ((m = spacedUps.exec(text)) !== null) {
140
+ var ups = normalize(m[0]);
141
+ if (/^1Z[0-9A-Z]{16}$/.test(ups)) push(ups, 'ups', 'medium');
142
+ else {
143
+ var fixed = repairUpsZ(ups);
144
+ if (fixed) push(fixed, 'ups', 'medium');
145
+ }
146
+ }
147
+ // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
148
+ var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
149
+ while ((run = runRe.exec(text)) !== null) {
150
+ var fixed = normalize(ocrDigitFix(run[0]));
151
+ if (/^9[2-5]\d{18,20}$/.test(fixed)) push(fixed, 'usps', 'medium');
152
+ else if (/^96\d{20}$/.test(fixed)) push(fixed, 'fedex', 'medium');
153
+ else if (/^(\d{12}|\d{15}|\d{20})$/.test(fixed)) push(fixed, 'fedex', 'medium');
154
+ else if (/^\d{11}$/.test(fixed)) push(fixed, 'dhl', 'medium');
155
+ else if (/^\d{10}$/.test(fixed)) push(fixed, 'dhl', 'low');
156
+ }
157
+ // c) fallback: long alphanumeric tokens (must contain a digit) that matched
158
+ // nothing — surface the most plausible few as "verify the digits" candidates
159
+ var tokens = text.match(/\b(?=[A-Z0-9]*\d)[A-Z0-9]{12,30}\b/gi) || [];
160
+ tokens = tokens.map(normalize);
161
+ tokens = tokens.filter(function (t, i) {
162
+ if (seen[t] || seen[ocrDigitFix(t)]) return false; // already covered (possibly OCR-fixed)
163
+ return tokens.indexOf(t) === i;
164
+ });
165
+ tokens.sort(function (a, b) { return b.length - a.length; });
166
+ tokens.slice(0, 3).forEach(function (t) {
167
+ // UPS "1Z" misread as "12"/"IZ" in a joined (space-free) token
168
+ var fixed = repairUpsZ(t);
169
+ if (fixed) { push(fixed, 'ups', 'medium'); return; }
170
+ // OCR-fixed variant may match a carrier format (e.g. I->1 at the start)
171
+ var digitFixed = ocrDigitFix(t);
172
+ var gc = guessCarrier(digitFixed);
173
+ if (gc !== 'unknown') { push(digitFixed, gc, 'low', { possible: true }); return; }
174
+ push(t, guessCarrier(t), 'low', { possible: true });
175
+ });
176
+ }
177
+
98
178
  var api = { extractTrackingNumbers: extractTrackingNumbers, extractContext: extractContext, normalize: normalize, isValidFormat: isValidFormat };
99
179
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
100
180
  globalThis.HHExtract = api;