· 6 years ago · Sep 23, 2019, 09:00 PM
1// ==UserScript==
2// @name backpack.tf Premium Recent Sales Finder
3// @namespace http://steamcommunity.com/profiles/76561198080179568/
4// @version 4.0.9
5// @description Adds coloring to history pages indicating recent sales and includes compare links for sales
6// @author Julia
7// @include /^https?:\/\/(.*\.)?backpack\.tf(:\d+)?\/item\/\d+/
8// @include /^https?:\/\/(.*\.)?backpack\.tf\/profiles\/\d{17}\/?$
9// @include /^https?:\/\/(.*\.)?backpack\.tf(:\d+)?\/premium\/search.*/
10// @updateURL https://github.com/juliarose/backpack.tf-premium-sales-finder/raw/master/backpacktf-premium-sales-finder.meta.js
11// @downloadURL https://github.com/juliarose/backpack.tf-premium-sales-finder/raw/master/backpacktf-premium-sales-finder.user.js
12// @run-at document-end
13// @grant GM_addStyle
14// @grant unsafeWindow
15// ==/UserScript==
16
17(function() {
18
19'use strict';
20
21function getHistory({$, omitEmpty, dayDifference}) {
22 // jquery elements
23 const PAGE = {
24 $history: $('.history-sheet table.table'),
25 $item: $('.item'),
26 $panelExtras: $('.panel-extras'),
27 $username: $('.username')
28 };
29
30 // add contents to the page
31 function addTableLinks({$history, $item, $username}) {
32 /**
33 * Get a value from a URL according to pattern.
34 * @param {String} url - URL.
35 * @param {Object} pattern - Pattern to match. The 1st match group should be the value we are trying to get.
36 * @returns {(String|null)} Matched value, or null if the pattern does not match.
37 */
38 function getValueFromURL(url, pattern) {
39 const match = (url || '').match(pattern);
40
41 return (
42 match &&
43 match[1]
44 );
45 }
46
47 /**
48 * Add contents for a row.
49 * @param {Object} options - Options.
50 * @param {Object} options.table - Object containing details of table.
51 * @param {Number} options.$item - JQuery element for item.
52 * @param {Number} options.index - Index of row.
53 * @param {Object} options.$row - JQuery object of row.
54 * @param {String} [options.loggedInUserSteamId] - The steamid of the currently logged in user.
55 * @param {String} [options.prevSteamId] - The steamid of the previous row.
56 * @returns {undefined}
57 */
58 function addRowContents({table, $item, index, $row, loggedInUserSteamId, prevSteamId}) {
59 // contains methods for adding links
60 const addLink = (function () {
61 /**
62 * Creates a jQuery link.
63 * @param {String} href - URL.
64 * @param {String} contents - HTML contents of link.
65 * @returns {Object} JQuery object of link.
66 */
67 function getLink({href, contents}) {
68 const $link = $('<a/>').html(contents).attr({
69 'href': href,
70 'target': '_blank'
71 });
72
73 return $link;
74 }
75
76 return {
77 inline({href, contents, $cell}) {
78 const $link = getLink({href, contents});
79 const $span = $('<span/>').css({
80 'float': 'right',
81 'margin-left': '0.6em'
82 }).append($link);
83
84 $cell.append($span);
85 },
86 column({href, contents, excludeLink, $cell}) {
87 // the first row does not include a link
88 const html = excludeLink ? '--------' : getLink({href, contents});
89
90 $cell.html(html);
91 }
92 };
93 }());
94 // contains methods for getting urls
95 const getURL = {
96 /**
97 * Get URL of your steam history at date.
98 * @param {Object} $item - JQuery object of item.
99 * @param {Object} date - Date of history point.
100 * @returns {String} Inventory history URL.
101 */
102 inventoryHistory($item, date) {
103 const itemname = $item.attr('data-name');
104 // for adding a filter with history bastard -
105 // https://naknak.net/tf2/historybastard/historybastard.user.js
106 const filter = itemname ? '#filter-' + itemname : '';
107
108 return [
109 'http://steamcommunity.com/my/inventoryhistory/',
110 // unix timestamp
111 '?after_time=' + Math.round(date.getTime() / 1000),
112 '&prev=1',
113 filter
114 ].join('');
115 },
116 /**
117 * Get URL of compare link on backpack.tf.
118 * @param {String} steamid - SteamID of user.
119 * @param {Object} date - Date of history point.
120 * @returns {String} Inventory comparison URL.
121 */
122 compare(steamid, date) {
123 // set date to beginning of day
124 date.setUTCHours(0);
125 date.setUTCMinutes(0);
126 date.setUTCSeconds(0);
127 date.setUTCMilliseconds(0);
128
129 // unix timestamp
130 const x = Math.round(date.getTime() / 1000);
131
132 return [
133 'https://backpack.tf/profiles/' +
134 steamid,
135 '#!',
136 '/compare/',
137 x,
138 '/',
139 x,
140 // add "/nearest" so that we can treat this compare link in a special manner
141 // 'getInventory' will be called when this link is loaded
142 '/nearest'
143 ].join('');
144 }
145 };
146 // get an object of the columns for this row by each column name e.g. "User"
147 const rowColumns = Object.entries(table).reduce((prev, [name, column]) => {
148 // get nth cell in column cells
149 prev[name] = column.$cells.eq(index);
150
151 return prev;
152 }, {});
153 // get href from last seen
154 const href = rowColumns['Last seen'].find('a').attr('href');
155 // to extract its timmestamp value
156 const timestampValue = getValueFromURL(href, /time=(\d+)$/);
157 // then convert that value into a date
158 // it is the date when the item was last seen
159 const lastSeenDate = new Date(parseInt(timestampValue) * 1000);
160 // get the steamid of the user from the row
161 const userSteamId = rowColumns['User'].find('.user-handle a').attr('data-id');
162 // add links for row
163 const itemname = $item.attr('data-name');
164 // adds highlighting to row
165 const days = dayDifference(lastSeenDate, new Date());
166
167 // add coloring depending on how long ago the hat was last sold
168 if (days <= 60) {
169 $row.addClass('success');
170 } else if (days <= 90) {
171 $row.addClass('warning');
172 } else if (days <= 120) {
173 $row.addClass('danger');
174 }
175
176 // links to be added to the row
177 const links = {
178 column: [
179 // compare link for seller->buyer
180 {
181 href: getURL.compare(userSteamId, lastSeenDate),
182 contents: 'Compare',
183 // do not include the link if the index is 0
184 excludeLink: index === 0,
185 // add the link to the buyer cell
186 $cell: rowColumns.Seller
187 },
188 // compare link for buyer->seller
189 {
190 href: getURL.compare(prevSteamId, lastSeenDate),
191 contents: 'Compare',
192 // do not include the link if the index is 0
193 excludeLink: index === 0,
194 // add the link to the seller cell
195 $cell: rowColumns.Buyer
196 }
197 ],
198 inline: []
199 };
200
201 const addSteamLink = Boolean(
202 loggedInUserSteamId &&
203 // do not show if current owner, unless there is no item name (moved elsewhere)
204 // logged in user and steamid of row must match
205 ((prevSteamId || !itemname) && (loggedInUserSteamId == userSteamId)) ||
206 // if previous row steamid is the same as logged in user
207 (loggedInUserSteamId == prevSteamId)
208 );
209
210 // add steam link if all conditions are met
211 if (addSteamLink) {
212 links.inline.push({
213 href: getURL.inventoryHistory($item, lastSeenDate),
214 contents: '<i class="stm stm-steam"/>',
215 // add the link to the user cell
216 $cell: rowColumns.User
217 });
218 }
219
220 // add the links
221 Object.entries(links).forEach(([name, links]) => {
222 links.forEach(addLink[name]);
223 });
224
225 // set prev steamid to current now that we are done with this row
226 return userSteamId;
227 }
228
229 const $rows = $history.find('tbody > tr');
230 const columnDefinitions = [
231 {
232 columnName: 'Seller',
233 after: 'User'
234 },
235 {
236 columnName: 'Buyer',
237 after: 'User'
238 }
239 ];
240 // creates a new column (and adds the header after the previous column)
241 const defineColumn = ($rows, columnName, prevColumn) => {
242 // get the index and header from the previous column
243 const {index, $header, $cells} = prevColumn;
244 const $prevTds = $cells;
245 // increment from previous
246 const columnIndex = index + 1;
247 const $th = $('<th/>').text(columnName);
248 // a blank td
249 const $td = $('<td/>').html(columnName);
250
251 // add the header
252 $th.insertAfter($header);
253 // add the td after each previous td
254 $td.insertAfter($prevTds);
255
256 const $columnCells = $rows.find(`> td:nth-child(${columnIndex + 1})`);
257
258 return {
259 index: columnIndex,
260 $header: $th,
261 $cells: $columnCells
262 };
263 };
264 let columnsAdded = 0;
265 // construct a table
266 const table = $history
267 // all table headers in table head
268 .find('thead tr th')
269 // get the data for each column
270 .map((index, el) => {
271 const $header = $(el);
272 const name = $header.text().trim();
273 const $cells = $rows.find(`> td:nth-child(${index + 1})`);
274
275 return {
276 name,
277 // add index so we know the order of each column
278 index,
279 $header,
280 $cells
281 };
282 })
283 // get raw array value from jQuery map
284 .get()
285 // then reduce into object where the key is the column's name
286 .reduce((prev, column) => {
287 const {name, index, $header, $cells} = column;
288
289 // assign column based on column heading text
290 prev[name] = {
291 index: index + columnsAdded,
292 $header,
293 $cells
294 };
295
296 const columnsToAdd = columnDefinitions.filter(({after}) => {
297 return after === name;
298 });
299 let prevColumn = prev[name];
300
301 columnsAdded += columnsToAdd.length;
302 columnsToAdd.forEach(({columnName}) =>{
303 prev[columnName] = defineColumn($rows, columnName, prevColumn);
304 prevColumn = prev[columnName];
305 });
306
307 return prev;
308 }, {});
309 // throw 'no';
310 // get the href from the element containing details of the logged in user
311 const loggedInUserHref = $username.find('a').attr('href');
312 // current logged in user
313 const loggedInUserSteamId = getValueFromURL(loggedInUserHref, /\/profiles\/(\d{17})$/);
314 let prevSteamId;
315
316 // iterate to add links for each row
317 $rows.each((index, el) => {
318 const $row = $(el);
319
320 // function will return the steamid of the row
321 // which can then be passed to the next iteration
322 prevSteamId = addRowContents({
323 table,
324 $item,
325 index,
326 $row,
327 loggedInUserSteamId,
328 prevSteamId
329 });
330 });
331 }
332
333 /**
334 * Adds a button link to the page.
335 * @param {Object} options - Options.
336 * @param {String} options.name - Link text.
337 * @param {String} options.url - URL of link.
338 * @param {String} [options.icon='fa-search'] - The icon for the link.
339 * @param {Object} $container - JQuery object for container.
340 * @returns {undefined}
341 */
342 function addButton($container, {name, url, icon}) {
343 let $pullRight = $container.find('.pull-right');
344 const $btnGroup = $('<div class="btn-group"/>');
345 const $link = $(`<a class="btn btn-panel" href="${url}"><i class="fa ${icon || 'fa-search'}"></i> ${name}</a>`);
346
347 if ($pullRight.length === 0) {
348 // add a pull-right element if one does not already exist
349 // so that we can left align this on the right of the panel
350 $pullRight = $('<div class="pull-right"/>');
351 $container.append($pullRight);
352 }
353
354 $btnGroup.append($link);
355 $pullRight.prepend($btnGroup);
356 }
357
358 const urlGenerators = {
359 // get details for bot.tf listing snapshots link to page
360 botTF($item) {
361 const data = $item.data();
362 const params = omitEmpty({
363 def: data.defindex,
364 q: data.quality,
365 ef: data.effect_name,
366 craft: data.craftable ? 1 : 0,
367 aus: data.australium ? 1 : 0,
368 ks: data.ks_tier || 0
369 });
370 const queryString = Object.keys(params).map((key) => {
371 return `${key}=${encodeURIComponent(params[key])}`;
372 }).join('&');
373 const url = 'https://bot.tf/stats/listings?' + queryString;
374
375 return url;
376 },
377 // add marketplace link to page
378 marketplaceTF($item) {
379 const data = $item.data();
380 const $itemIcon = $item.find('.item-icon');
381 // get the war paint id from the background image
382 const backgroundImage = $itemIcon.css('background-image');
383 // matches the url for a war paint image
384 const reWarPaintPattern = /https:\/\/scrap\.tf\/img\/items\/warpaint\/(?:(?![×Þß÷þø_])[%\-'0-9a-zÀ-ÿA-z])+_(\d+)_(\d+)_(\d+)\.png/i;
385 const warPaintMatch = backgroundImage.match(reWarPaintPattern);
386 // will be in first group
387 const warPaintId = warPaintMatch ? warPaintMatch[1] : null;
388 // get the id of the wear using the name of the wear
389 const wearId = {
390 'Factory New': 1,
391 'Minimal Wear': 2,
392 'Field-Tested': 3,
393 'Well-Worn': 4,
394 'Battle Scarred': 5
395 }[data.wear_tier];
396 const params = [
397 data.defindex,
398 data.quality,
399 data.effect_id ? 'u' + data.effect_id : null,
400 wearId ? 'w' + wearId : null,
401 warPaintId ? 'pk' + warPaintId : null,
402 data.ks_tier ? 'kt-' + data.ks_tier : null,
403 data.australium ? 'australium' : null,
404 !data.craftable ? 'uncraftable' : null,
405 // is a strange version
406 data.quality_elevated == '11' ? 'strange' : null
407 ].filter(param => param !== null);
408 const url = 'https://marketplace.tf/items/tf2/' + params.join(';');
409
410 return url;
411 }
412 };
413
414 addTableLinks(PAGE);
415
416 // only if an item exists on page
417 if (PAGE.$item.length > 0) {
418 const $item = PAGE.$item;
419 const $container = PAGE.$panelExtras;
420 const generators = {
421 'Bot.tf': urlGenerators.botTF,
422 'Marketplace.tf': urlGenerators.marketplaceTF
423 };
424
425 Object.entries(generators).forEach(([name, generator]) => {
426 // generate the button details using the generator
427 const url = generator($item);
428
429 // add it to the given container
430 addButton($container, {
431 name,
432 url
433 });
434 });
435 }
436}
437
438function getInventory({$}) {
439 // jquery elements
440 const PAGE = {
441 $snapshots: $('#historicalview option')
442 };
443
444 // update the location so that each timestamp is at the closest time according to recorded inventory snapshots
445 function changeLocation({$snapshots}) {
446 /**
447 * Get closet snapshot time according to timestamp.
448 * @param {Number[]} snapshots - Array of snapshot unix timestamps.
449 * @param {Number} timestamp - Unix timestamp.
450 * @param {Boolean} [before] - Whether the closest snapshot should appear before 'timestamp'.
451 * @param {Number} [other] - Snapshot must not be the same as this value.
452 * @returns {(Number|null)} Closest snapshot to date.
453 */
454 function getClosestSnapshot(snapshots, timestamp, before, other) {
455 // sort ascending
456 const asc = (a, b) => (b - a);
457 // sort descending
458 const desc = (a, b) => (a - b);
459
460 // loop until we find the first result that is at or before the timestamp if "before" is set to true
461 // when "before" is set, array is sorted in descending order, or ascending if not set
462 return snapshots.sort(before ? desc : asc).find((snapshot) => {
463 let isBefore = timestamp <= snapshot;
464 let isAfter = timestamp >= snapshot;
465 let isOther = snapshot === other;
466
467 return (
468 before ? isBefore : isAfter
469 ) && !isOther; // snapshot must also not be the same as "other"
470 }) || (before ? Math.min : Math.max)(...snapshots);
471 // default value is first or last snapshot if one did not meet conditions
472 // will probably only default to this if the time is closest to the first or last snapshot
473 // or with one-snapshot inventories
474 }
475
476 // generate page snapshots
477 const snapshots = $snapshots.map((i, el) => {
478 return parseInt(el.value);
479 }).get().filter(Boolean);
480 const pattern = /(\d{10})\/(\d{10})\/nearest$/;
481 // should always match
482 const timestamps = location.href.match(pattern).slice(1).map(a => parseInt(a));
483 // must be at or before the first date
484 const from = getClosestSnapshot(snapshots, timestamps[0], true);
485 // must be at or before the second date, and not the same date as 'from'
486 const to = getClosestSnapshot(snapshots, timestamps[1], false, from);
487
488 // finally update location.href using new timestamps
489 location.href = location.href.replace(pattern, [from, to].join('/'));
490 }
491
492 changeLocation(PAGE);
493}
494
495function getPremium({$, dayDifference}) {
496 const PAGE = {
497 $results: $('.premium-search-results .result')
498 };
499
500 function highlightResults($results) {
501 function highlightOwner($result, days) {
502 function prependClass($element, front) {
503 const classes = $element.attr('class');
504
505 $element.attr('class', [front, classes].join(' '));
506 }
507
508 const $buttons = $result.find('.buttons a');
509
510 // add coloring depending on how long ago the hat was last sold
511 if (days <= 60) {
512 // we add it to the beginning of the classlist
513 // because the order of classes takes priority in styling (from first to last)
514 prependClass($buttons, 'btn-success');
515 $result.addClass('success');
516 } else if (days <= 90) {
517 prependClass($buttons, 'btn-warning');
518 $result.addClass('warning');
519 } else if (days <= 120) {
520 prependClass($buttons, 'btn-danger');
521 $result.addClass('danger');
522 }
523 }
524
525 $results.each((i, el) => {
526 const $result = $(el);
527 const $previousOwner = $result.find('.owners .owner').eq(1);
528 const $time = $previousOwner.find('abbr');
529
530 if ($time.length > 0) {
531 const date = new Date($time.attr('title'));
532 const now = new Date();
533 const days = dayDifference(now, date);
534
535 highlightOwner($result, days);
536 }
537 });
538 }
539
540 highlightResults(PAGE.$results);
541}
542
543// run the page scripts
544(function() {
545 const DEPS = (function() {
546 // our window object
547 const WINDOW = unsafeWindow;
548
549 // get our global variables from the window object
550 const {$} = WINDOW;
551
552 /**
553 * Super basic omitEmpty function.
554 * @param {Object} obj - Object to omit values from.
555 * @returns {Object} Object with null, undefined, or empty string values omitted.
556 */
557 function omitEmpty(obj) {
558 // create clone so we do not modify original object
559 let result = Object.assign({}, obj);
560
561 for (let k in result) {
562 if (result[k] === null || result[k] === undefined || result[k] === '') {
563 delete result[k];
564 }
565 }
566
567 return result;
568 }
569
570 /**
571 * Get difference in days between two dates.
572 * @param {Object} date1 - First date.
573 * @param {Object} date2 - Second date.
574 * @returns {Number} Difference.
575 */
576 function dayDifference(date1, date2) {
577 const oneDay = 24 * 60 * 60 * 1000;
578 const difference = Math.abs(date1.getTime() - date2.getTime());
579
580 return Math.round(difference / oneDay);
581 }
582
583 return {
584 WINDOW,
585 $,
586 omitEmpty,
587 dayDifference
588 };
589 }());
590 const scripts = [
591 {
592 pattern: /^https?:\/\/(.*\.)?backpack\.tf(:\d+)?\/item\/\d+/,
593 method: getHistory
594 },
595 {
596 pattern: /^https?:\/\/(.*\.)?backpack\.tf\/profiles\/\d{17}#!\/compare\/\d{10}\/\d{10}\/nearest/,
597 method: getInventory
598 },
599 {
600 pattern: /^https?:\/\/(.*\.)?backpack\.tf(:\d+)?\/premium\/search.*/,
601 styles: `
602 .premium-search-results .result.success {
603 background-color: #dff0d8;
604 }
605
606 .premium-search-results .result.warning {
607 background-color: #faf2cc;
608 }
609
610 .premium-search-results .result.danger {
611 background-color: #f2dede;
612 }
613 `,
614 method: getPremium
615 }
616 ];
617 const script = scripts.find(({pattern}) => pattern.test(location.href));
618
619 if (script) {
620 if (script.styles) {
621 // add the styles
622 GM_addStyle(script.styles);
623 }
624
625 // run the script
626 script.method(DEPS);
627 }
628}());
629
630}());