hh-shipments 0.3.5 → 0.3.6

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.5",
21
+ "version": "0.3.6",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/extract.js CHANGED
@@ -56,10 +56,85 @@
56
56
  return out;
57
57
  }
58
58
 
59
+ // Strict UPS structure: 1Z + 6-char shipper number + 2-digit service + 8-digit serial.
60
+ var UPS_STRICT = /^1Z[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
61
+ function repairUpsZ(ups) {
62
+ // Tesseract often reads the "1Z" anchor as "12" (or "IZ"). Repair it
63
+ // only when the rest has strict UPS structure AND the shipper segment
64
+ // contains a letter — real UPS shipper numbers do, random digit runs
65
+ // don't. Never invent numbers from thin air.
66
+ var cand = null;
67
+ if (/^12[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
68
+ else if (/^IZ[0-9A-Z]{16}$/i.test(ups)) cand = '1Z' + ups.slice(2);
69
+ if (!cand || !UPS_STRICT.test(cand)) return null;
70
+ if (!/[A-Z]/.test(cand.slice(2, 8))) return null;
71
+ return cand;
72
+ }
73
+
74
+ // UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
75
+ function spacedUpsIn(text, found, seen, conf) {
76
+ var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
77
+ while ((m = re.exec(text)) !== null) {
78
+ var ups = normalize(m[0]);
79
+ var num = /^1Z[0-9A-Z]{16}$/.test(ups) ? ups : repairUpsZ(ups);
80
+ if (num && !seen[num]) {
81
+ seen[num] = true;
82
+ found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups });
83
+ }
84
+ }
85
+ }
86
+
59
87
  function extractTrackingNumbers(text) {
60
88
  var found = [];
61
89
  var seen = {};
62
90
  if (!text) return found;
91
+ // 0) ANCHOR PASS — on shipping labels the tracking number sits on or just
92
+ // under a line like "UPS GROUND" or "TRACKING #:". Scan those hot zones
93
+ // first so label structure decides, not blind whole-image regex. Anything
94
+ // found here outranks whole-image guesses (dedupe keeps the first hit).
95
+ anchorZones(text).forEach(function (z) {
96
+ runPatterns(z.text, found, seen, z.carrier);
97
+ spacedUpsIn(z.text, found, seen, 'high');
98
+ });
99
+ // 1) Whole-text strict patterns
100
+ runPatterns(text, found, seen, false);
101
+ // 2) OCR-tolerant recovery pass
102
+ extractOcrTolerant(text, found, seen);
103
+ var rank = { high: 0, medium: 1, low: 2 };
104
+ found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
105
+ return found;
106
+ }
107
+
108
+ // Shipping-label structure: the tracking number lives on or just under
109
+ // lines like "UPS GROUND" / "TRACKING #:". Returns hot-zone chunks with the
110
+ // carrier named on/near the anchor (null when the anchor is generic).
111
+ function anchorCarrier(line) {
112
+ var l = String(line).toLowerCase();
113
+ if (/\bups\b/.test(l)) return 'ups';
114
+ if (/\bfed\s?ex\b/.test(l)) return 'fedex';
115
+ if (/\busps\b/.test(l) || /priority mail/.test(l)) return 'usps';
116
+ if (/\bdhl\b/.test(l)) return 'dhl';
117
+ return null;
118
+ }
119
+ function isAnchorLine(line) {
120
+ if (/track/.test(String(line).toLowerCase())) return true;
121
+ return anchorCarrier(line) !== null;
122
+ }
123
+ function anchorZones(text) {
124
+ var lines = String(text).split(/\r?\n/);
125
+ var zones = [];
126
+ for (var i = 0; i < lines.length; i++) {
127
+ if (!isAnchorLine(lines[i])) continue;
128
+ // anchor line + the two lines under it (the number is printed below)
129
+ var slice = lines.slice(i, Math.min(lines.length, i + 3));
130
+ var carrier = null;
131
+ slice.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
132
+ zones.push({ text: slice.join('\n'), carrier: carrier });
133
+ }
134
+ return zones;
135
+ }
136
+
137
+ function runPatterns(text, found, seen, boostCarrier) {
63
138
  PATTERNS.forEach(function (p) {
64
139
  p.re.lastIndex = 0;
65
140
  var m;
@@ -67,11 +142,13 @@
67
142
  var num = normalize(m[1]);
68
143
  if (seen[num]) continue;
69
144
  seen[num] = true;
70
- // Context boost: carrier word near the number
145
+ // Context boost: carrier word near the number, or a hot-zone anchor
146
+ // naming this carrier (a label's "UPS GROUND" line means the number
147
+ // under it really is UPS).
71
148
  var idx = m.index;
72
149
  var window = text.slice(Math.max(0, idx - 60), idx + 60 + m[1].length);
73
150
  var confidence = p.confidence;
74
- var boosted = false;
151
+ var boosted = !!(boostCarrier && p.carrier === boostCarrier);
75
152
  CARRIER_WORDS.forEach(function (cw) {
76
153
  if (cw.carrier === p.carrier && cw.re.test(window)) boosted = true;
77
154
  });
@@ -80,11 +157,6 @@
80
157
  found.push({ number: num, carrier: p.carrier, confidence: confidence });
81
158
  }
82
159
  });
83
- // Sort: high confidence first, then by position
84
- extractOcrTolerant(text, found, seen);
85
- var rank = { high: 0, medium: 1, low: 2 };
86
- found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
87
- return found;
88
160
  }
89
161
 
90
162
  function isValidFormat(number, carrier) {
@@ -111,9 +183,6 @@
111
183
  }
112
184
 
113
185
  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
186
  function push(num, carrier, confidence, extra) {
118
187
  num = normalize(num);
119
188
  if (!num || seen[num]) return;
@@ -122,28 +191,9 @@
122
191
  if (extra) for (var k in extra) c[k] = extra[k];
123
192
  found.push(c);
124
193
  }
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
- }
194
+ // a) UPS number split by spaces/dashes (whole-text fallback; the anchor
195
+ // pass already tried the hot zones at high confidence)
196
+ spacedUpsIn(text, found, seen, 'medium');
147
197
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
148
198
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
149
199
  while ((run = runRe.exec(text)) !== null) {
package/src/extract.js CHANGED
@@ -56,10 +56,85 @@
56
56
  return out;
57
57
  }
58
58
 
59
+ // Strict UPS structure: 1Z + 6-char shipper number + 2-digit service + 8-digit serial.
60
+ var UPS_STRICT = /^1Z[0-9A-Z]{6}[0-9]{2}[0-9]{8}$/;
61
+ function repairUpsZ(ups) {
62
+ // Tesseract often reads the "1Z" anchor as "12" (or "IZ"). Repair it
63
+ // only when the rest has strict UPS structure AND the shipper segment
64
+ // contains a letter — real UPS shipper numbers do, random digit runs
65
+ // don't. Never invent numbers from thin air.
66
+ var cand = null;
67
+ if (/^12[0-9A-Z]{16}$/.test(ups)) cand = '1Z' + ups.slice(2);
68
+ else if (/^IZ[0-9A-Z]{16}$/i.test(ups)) cand = '1Z' + ups.slice(2);
69
+ if (!cand || !UPS_STRICT.test(cand)) return null;
70
+ if (!/[A-Z]/.test(cand.slice(2, 8))) return null;
71
+ return cand;
72
+ }
73
+
74
+ // UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
75
+ function spacedUpsIn(text, found, seen, conf) {
76
+ var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
77
+ while ((m = re.exec(text)) !== null) {
78
+ var ups = normalize(m[0]);
79
+ var num = /^1Z[0-9A-Z]{16}$/.test(ups) ? ups : repairUpsZ(ups);
80
+ if (num && !seen[num]) {
81
+ seen[num] = true;
82
+ found.push({ number: num, carrier: 'ups', confidence: conf, ocr_fixed: num !== ups });
83
+ }
84
+ }
85
+ }
86
+
59
87
  function extractTrackingNumbers(text) {
60
88
  var found = [];
61
89
  var seen = {};
62
90
  if (!text) return found;
91
+ // 0) ANCHOR PASS — on shipping labels the tracking number sits on or just
92
+ // under a line like "UPS GROUND" or "TRACKING #:". Scan those hot zones
93
+ // first so label structure decides, not blind whole-image regex. Anything
94
+ // found here outranks whole-image guesses (dedupe keeps the first hit).
95
+ anchorZones(text).forEach(function (z) {
96
+ runPatterns(z.text, found, seen, z.carrier);
97
+ spacedUpsIn(z.text, found, seen, 'high');
98
+ });
99
+ // 1) Whole-text strict patterns
100
+ runPatterns(text, found, seen, false);
101
+ // 2) OCR-tolerant recovery pass
102
+ extractOcrTolerant(text, found, seen);
103
+ var rank = { high: 0, medium: 1, low: 2 };
104
+ found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
105
+ return found;
106
+ }
107
+
108
+ // Shipping-label structure: the tracking number lives on or just under
109
+ // lines like "UPS GROUND" / "TRACKING #:". Returns hot-zone chunks with the
110
+ // carrier named on/near the anchor (null when the anchor is generic).
111
+ function anchorCarrier(line) {
112
+ var l = String(line).toLowerCase();
113
+ if (/\bups\b/.test(l)) return 'ups';
114
+ if (/\bfed\s?ex\b/.test(l)) return 'fedex';
115
+ if (/\busps\b/.test(l) || /priority mail/.test(l)) return 'usps';
116
+ if (/\bdhl\b/.test(l)) return 'dhl';
117
+ return null;
118
+ }
119
+ function isAnchorLine(line) {
120
+ if (/track/.test(String(line).toLowerCase())) return true;
121
+ return anchorCarrier(line) !== null;
122
+ }
123
+ function anchorZones(text) {
124
+ var lines = String(text).split(/\r?\n/);
125
+ var zones = [];
126
+ for (var i = 0; i < lines.length; i++) {
127
+ if (!isAnchorLine(lines[i])) continue;
128
+ // anchor line + the two lines under it (the number is printed below)
129
+ var slice = lines.slice(i, Math.min(lines.length, i + 3));
130
+ var carrier = null;
131
+ slice.forEach(function (sl) { carrier = carrier || anchorCarrier(sl); });
132
+ zones.push({ text: slice.join('\n'), carrier: carrier });
133
+ }
134
+ return zones;
135
+ }
136
+
137
+ function runPatterns(text, found, seen, boostCarrier) {
63
138
  PATTERNS.forEach(function (p) {
64
139
  p.re.lastIndex = 0;
65
140
  var m;
@@ -67,11 +142,13 @@
67
142
  var num = normalize(m[1]);
68
143
  if (seen[num]) continue;
69
144
  seen[num] = true;
70
- // Context boost: carrier word near the number
145
+ // Context boost: carrier word near the number, or a hot-zone anchor
146
+ // naming this carrier (a label's "UPS GROUND" line means the number
147
+ // under it really is UPS).
71
148
  var idx = m.index;
72
149
  var window = text.slice(Math.max(0, idx - 60), idx + 60 + m[1].length);
73
150
  var confidence = p.confidence;
74
- var boosted = false;
151
+ var boosted = !!(boostCarrier && p.carrier === boostCarrier);
75
152
  CARRIER_WORDS.forEach(function (cw) {
76
153
  if (cw.carrier === p.carrier && cw.re.test(window)) boosted = true;
77
154
  });
@@ -80,11 +157,6 @@
80
157
  found.push({ number: num, carrier: p.carrier, confidence: confidence });
81
158
  }
82
159
  });
83
- // Sort: high confidence first, then by position
84
- extractOcrTolerant(text, found, seen);
85
- var rank = { high: 0, medium: 1, low: 2 };
86
- found.sort(function (a, b) { return rank[a.confidence] - rank[b.confidence]; });
87
- return found;
88
160
  }
89
161
 
90
162
  function isValidFormat(number, carrier) {
@@ -111,9 +183,6 @@
111
183
  }
112
184
 
113
185
  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
186
  function push(num, carrier, confidence, extra) {
118
187
  num = normalize(num);
119
188
  if (!num || seen[num]) return;
@@ -122,28 +191,9 @@
122
191
  if (extra) for (var k in extra) c[k] = extra[k];
123
192
  found.push(c);
124
193
  }
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
- }
194
+ // a) UPS number split by spaces/dashes (whole-text fallback; the anchor
195
+ // pass already tried the hot zones at high confidence)
196
+ spacedUpsIn(text, found, seen, 'medium');
147
197
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
148
198
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;
149
199
  while ((run = runRe.exec(text)) !== null) {