├── 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 |
--------------------------------------------------------------------------------