├── README.md └── amazon.js /README.md: -------------------------------------------------------------------------------- 1 | # amazonshelper 2 | 3 | TamperMonkey script (browser extension) for Amazon to add pricing based on volume for materials and other improvements to the site. 4 | 5 | Features 6 | - Price out sheets of materials by volume, eg a 2x3x4in sheet of acrylic for $48 will show $2/in^3 7 | - Price out rods of materials by volume, eg a 10in long, 2in diam rod for $40 will show $1.27/in^3 8 | - Show pricing by quantity of items, e.g. a 4-pack selling for $10 will show $2.50/count 9 | - Sort items by lowest cost by volume, or lowest cost by per-item, or by full price if no volume/per-item pricing available 10 | - Hide striked out pricing 11 | - Hide out of stock items 12 | -------------------------------------------------------------------------------- /amazon.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Amazon Price by Volume 3 | // @version 0.0.3 4 | // @author samy kamkar 5 | // @description Show prices on Amazon by volume, including quantity, for relative pricing 6 | // @include *://*.amazon.com/*s?* 7 | // @require https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js 8 | // @Xrequire file:///Users/samy/Code/amazon/amazonshelper/amazon.js 9 | // @namespace https://samy.pl 10 | // ==/UserScript== 11 | 12 | /* 13 | TODO 14 | - add input to remove items by description, eg -polycarbonate (amazon doesn't support this in search) 15 | - add the lengths/qtys/etc in adjustable inputs 16 | - allow removing items by clicking n 'x' 17 | - might not be needed once sorting is added 18 | 19 | */ (function() { 20 | 21 | // a '/' in the value means inches, eg 1/8 22 | // XXX generate re_size from the `sizes` object starting with longest first 23 | // break down our regexpes into reusable chunks 24 | let re_size = '(?:(?:\'|"|inches|inch|in|cm|mm|thou|mil|ft|feet|foot|thi[cn]k(?:ness)?)\\.?)' // 25 | let re_num = '(?:(?:\\d+\\s+)?\\d+(?:[/.]\\d+)?|\\.\\d+)' // 1 1/2 OR 4 OR 5.2 OR 3/4 26 | let re_inch = '(?:(?:\\d+\\s+)?\\d+/\\d+|\\d+\\.\\d{3})' // 3/4 OR 1 1/2 27 | let pre_type = `(?:${re_num}\\s*-?${re_size}|${re_inch})` // 3.4 inches 28 | let pre_notype = `(?:${re_num}\\s*-?${re_size}?|${re_inch})` // 3.4 29 | let num_type = `(?:(${pre_type})(?:\\s*\\(${pre_type}\\))?)` // 1 in (2.54cm) 30 | let num_notype = `(?:(${pre_notype})(?:\\s*\\(${pre_notype}\\))?)` // 1 (2.54) 31 | let diam = `(?:diameter|diam|dia)` 32 | let len = `(?:length|len|long)` 33 | let by = `\\s*(?:x|by)\\s*` 34 | // width by height 35 | let re_wh = `${num_notype}${by}${num_notype}` 36 | // quantity 37 | let re_qty = /(\d+)\s*-?\s*(?:qty|quantity|pack|piece|pcs?|sheets?)|pack\s+of\s+(\d+)/ 38 | // various width by height by depth options 39 | let re_whd_arr = [ // ORs 40 | `${num_notype}\\s*thi[cn]k.*?${re_wh}`, 41 | `${re_wh}.*?${num_notype}\\s*thi[cn]k`, 42 | 43 | `${num_notype}${by}${re_wh}`, 44 | `${num_type}.*?${re_wh}`, 45 | 46 | `${re_wh}.*?${num_type}`, 47 | ] 48 | // for rods, various length by diameter options 49 | let re_diamlen_arr = [ // ORs 50 | `${num_notype}.*?${num_notype}\\s*long`, 51 | `${num_notype}\\s*${diam}?${by}(?:\\s*${len}\\s*)?${num_notype}`, 52 | `${diam}\\s*${num_notype}\\s*${len}\\s*${num_type}` 53 | ] 54 | 55 | let re_whd = re_whd_arr.join('|') 56 | let re_diamlen = re_diamlen_arr.join('|') 57 | let amazonItem = '.s-main-slot>.s-result-item' 58 | 59 | let rg_size = new RegExp(`(${re_size})`, 'i') 60 | let rg_rmsize = new RegExp(`\\s*-?${re_size}`, 'i') 61 | let sizes = { 62 | '"': 'in', 63 | 'in': 'in', 64 | 'inch': 'in', 65 | 'inches': 'in', 66 | 67 | "'": 'ft', 68 | 'ft': 'ft', 69 | 'feet': 'ft', 70 | 'foot': 'ft', 71 | 'foots': 'ft', // :) 72 | 73 | 'mil': 'mil', 74 | 'mils': 'mil', 75 | 'thou': 'mil' 76 | } 77 | 78 | let defaultDesc = [ 79 | { 80 | 're': re_qty, 81 | 'names': ['qty'], 82 | } 83 | ] 84 | 85 | // calculations to do for different volumes 86 | let calcs = [ 87 | { // support sheets 88 | 'searchRe': /sheet|panel/, // search box must match this text (regexp) 89 | 'calc': 'price/(thick*width*height*qty)', // text gets replaced with item.data(text) 90 | 'type': '^3', 91 | 'descRe': [ // the calc variables are produced from these regexpes 92 | { 93 | // maybe remove 's' from pcs? may have false positives 94 | 're': re_qty, 95 | 'names': ['qty'], 96 | }, { 97 | 're': new RegExp(re_whd), 98 | 'names': ['width', 'height', 'thick'] 99 | } 100 | ] 101 | }, 102 | { // support rods 103 | 'searchRe': /rod|bar/, 104 | 'calc': 'price/(3.14*((diam/2)**2)*length*qty)', // text gets replaced with item.data(text) 105 | 'type': '^3', 106 | 'descRe': [ 107 | { 108 | // maybe remove 's' from pcs? may have false positives 109 | 're': re_qty, 110 | 'names': ['qty'], 111 | }, { 112 | 're': new RegExp(re_diamlen), 113 | 'names': ['diam', 'length'] 114 | // }, { 115 | // 're': new RegExp(`${num_type}\s+long`), 116 | //'names': ['height'] 117 | } 118 | ] 119 | } 120 | ] 121 | 122 | // on page load 123 | $(document).ready(onPageLoad) 124 | 125 | ////////////////////////////////////////////////////////////////////////////// 126 | // functions 127 | // 128 | // 129 | 130 | // hide elements we don't like 131 | function hideElems() 132 | { 133 | //$('.a-color-secondary').hide() // hide per-item pricing 134 | $('.a-text-price').hide() // hide striked out prices 135 | $('span span span:contains("Out of Stock")').closest('div.sg-col-inner').parent().hide() // hide out of stock items 136 | } 137 | 138 | // page load! 139 | function onPageLoad() 140 | { 141 | console.log('amazon volume pricing started') 142 | 143 | // hide stuff we don't want 144 | hideElems() 145 | 146 | // grab search box text 147 | let search = $('input[name="field-keywords"]').val().toLowerCase() 148 | 149 | // scan through items with the calculation we want done based on the search text 150 | scanItems(calcs.find(o => search.match(o.searchRe))) 151 | } 152 | 153 | // scan through items and calculate price by volume 154 | function scanItems(obj) 155 | { 156 | let items = $(amazonItem) 157 | // go through each amazon item 158 | items.each(function(ind) 159 | { 160 | let item = $(this) 161 | 162 | // set price and desc 163 | item.data('price', getPrice(this)) 164 | item.data('desc', cleanup(item.find('span.a-text-normal').first().text())) 165 | 166 | // go through regexpes to pull data out of description 167 | parseText(item, item.data('desc'), obj ? obj.descRe : defaultDesc) 168 | item.data('qty', item.data('qty') || 1) 169 | item.data('unitPrice', round(item.data('price') / item.data('qty'))) 170 | 171 | // if we don't have a secondary price and we do have qty pricing 172 | if (!item.find('.a-price').next().length) 173 | item.find('.a-price').after(`($${item.data('unitPrice')}/ea)`) 174 | 175 | // calculate volume of the items 176 | if (obj) 177 | volumeCalc(obj, item) 178 | }) 179 | 180 | // resort by price 181 | items.sort(function(a, b) 182 | { 183 | let pa = obj ? $(a).data('volPrice') : getPrice(a) 184 | let pb = obj ? $(b).data('volPrice') : getPrice(b) 185 | if (!pa || pa <= 0) pa = 10000 186 | if (!pb || pb <= 0) pb = 10000 187 | // let pa = getPrice(a), pb = getPrice(b) 188 | return (pa > pb) ? (pa > pb) ? 1 : 0 : -1 189 | }).appendTo(items.parent()) 190 | 191 | } // end of items() 192 | 193 | // return amazon price from element 194 | function getPrice(elem) 195 | { 196 | // get the price of each item 197 | let price = $(elem).find('span.a-size-base.a-color-secondary').first().text().replace(/[^\d.]/g, '') 198 | 199 | console.log('price', price) 200 | // if no such price, get total price of item 201 | if (!price) 202 | price = $(elem).find('span.a-offscreen').first().text().replace('$', '') 203 | 204 | return parseFloat(price) 205 | } 206 | 207 | function volumeCalc(obj, item) 208 | { 209 | // find length types 210 | for (let key of Object.keys(item.data())) 211 | { 212 | let match = rg_size.exec(item.data(key)) 213 | if (match) 214 | { 215 | // remove type from string (3 in -> 3) 216 | item.data(key, item.data(key).replace(rg_rmsize, '')) 217 | console.log("length type", key,`\\s*${re_size}`, item.data(key), match, sizes[match[1]]) 218 | 219 | let type = match[1].toLowerCase() 220 | if (!item.data(`type`)) 221 | item.data(`type`, sizes[type] || type) 222 | item.data(`${key}_type`, sizes[type] || type || item.data('type')) 223 | } 224 | 225 | // consider 1/2 as inches 226 | else if (item.data(key).length && (item.data(key).substr('/') >= 0 || item.data(key).indexOf('"') > -1)) 227 | item.data(`${key}_type`, sizes['inch']) 228 | } 229 | 230 | // XXX 231 | // fill in other types 232 | if (!item.data('width_type')) 233 | item.data('width_type', item.data('height_type') || item.data('thick_type')) 234 | if (!item.data('height_type')) 235 | item.data('height_type', item.data('width_type') || item.data('thick_type')) 236 | if (!item.data('thick_type')) 237 | item.data('thick_type', item.data('width_type') || item.data('height_type')) 238 | 239 | // general type 240 | if (!item.data('type')) 241 | item.data('type', item.data('width_type')) 242 | 243 | let calc = runCalc(item, obj) 244 | if (!isNaN(calc) && Number.isFinite(calc)) 245 | { 246 | item.data('volPrice', calc) 247 | calc = round(calc) 248 | item.find('.a-price-fraction').append(` \$${calc}/${item.data('type')}${obj.type}`) 249 | } 250 | 251 | console.log(item.data()) 252 | } 253 | 254 | // round 1.2345 to 1.235 255 | function round(num, points) 256 | { 257 | return num.toFixed(points || 3) 258 | } 259 | 260 | // convert smart quotes/unicode to normal quotes 261 | function cleanup(str) 262 | { 263 | return str.toLowerCase().replace(/[\u201c\u201d]/g, '"').replace(/[\u2018\u2019]/g, "'").replace(/''/g, '"') 264 | } 265 | 266 | // convert units as some descriptions mix metric and imperial 267 | function convert(val, from, to) 268 | { 269 | console.log(`convert val=${val} from=${from} to=${to}`) 270 | if (sizes[from] === sizes.mm && sizes[to] === sizes.inch) 271 | val /= 25.4 272 | else if (sizes[from] === sizes.mm && sizes[to] === sizes.foot) 273 | val /= 304.8 274 | else 275 | val = -1 276 | return val 277 | } 278 | 279 | // calculate data from object 280 | function runCalc(item, obj) 281 | { 282 | console.log('runcalc', item.data()) 283 | 284 | // ensure values are same type 285 | for (const [k, v] of Object.entries(item.data())) 286 | { 287 | console.log('kv', k, v) 288 | if (item.data(`${k}_type`) && item.data(`${k}_type`) !== item.data('type')) 289 | { 290 | item.data(k, convert(v, item.data(`${k}_type`), item.data('type'))) 291 | item.data(`${k}_type`, item.data('type')) 292 | } 293 | } 294 | let calcstr = obj.calc.replace(/([a-z]+)/g, key => item.data(key) || 0) 295 | let out 296 | try { 297 | out = eval(calcstr) 298 | } catch(err) { 299 | console.log('error on eval', err) 300 | } 301 | console.log('new str', obj.calc, calcstr, item.data(), out) 302 | return out 303 | } // runCalc 304 | 305 | // parses text (like description) to match regexpes, stores data in object 306 | function parseText(elem, text, regs) 307 | { 308 | // make sure we have something to parse 309 | if (!text.length) 310 | return 311 | 312 | // loop through regexp objs 313 | for (const obj of regs) 314 | { 315 | let match = obj.re.exec(text) 316 | console.log('parsetext', text, obj.names, obj.re, typeof match, match) 317 | 318 | // find the matches and assign to values when there's data 319 | if (match) 320 | for (let i = 1; i < match.length; i++) 321 | // if we don't already have a value, and our i is an int, and we have data to store 322 | if (!elem.data(obj.names[(i-1) % obj.names.length]) && 323 | Number.isInteger(i) && 324 | typeof match[i] !== 'undefined' && match[i].lengt 325 | ) 326 | elem.data(obj.names[(i-1) % obj.names.length], match[i].trim()) 327 | } 328 | } // parseText 329 | 330 | 331 | })() // 332 | --------------------------------------------------------------------------------