Lex's SG Chart Maker

Create bundle charts for Steam Gifts.

目前为 2025-01-03 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Lex's SG Chart Maker
  3. // @namespace https://www.steamgifts.com/user/lext
  4. // @version 0.3.5
  5. // @description Create bundle charts for Steam Gifts.
  6. // @author Lex
  7. // @match *://store.steampowered.com/app/*
  8. // @match *://store.steampowered.com/sub/*
  9. // @match *://store.steampowered.com/bundle/*
  10. // @require http://code.jquery.com/jquery-3.2.1.min.js
  11. // @require http://code.jquery.com/ui/1.12.1/jquery-ui.min.js
  12. // @require https://cdn.jsdelivr.net/npm/markdown-it@11.0.0/dist/markdown-it.min.js
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_deleteValue
  16. // @grant GM_addStyle
  17. // @grant GM_xmlhttpRequest
  18. // @connect api.isthereanydeal.com
  19. // @connect rafaelgssa.com
  20. // ==/UserScript==
  21.  
  22. /* eslint curly: "off", no-prototype-builtins: 1 */
  23. /* eslint-env jquery */
  24.  
  25. (function() {
  26. 'use strict';
  27.  
  28. //GM_deleteValue("gameOrder");
  29. //GM_deleteValue("games");
  30. if ("sets" in JSON.parse(GM_getValue("cardData", "{}"))) {
  31. console.log("Deleting old card data");
  32. GM_deleteValue("cardData");
  33. }
  34. var ITAD_API_KEY = GM_getValue("ITAD_API_KEY");
  35. const API_KEY_REGEXP = /[0-9A-Za-z]{40}/;
  36. const INVALIDATION_TIME = 60*60*1000; // 60 minute cache time
  37. const GameID = window.location.pathname.match(/(app|sub|bundle)\/\d+/)[0];
  38. const NOCV_ICON = "☠";
  39. const CARD_ICON = "❤";
  40. const ADULT_ICON = "🔞";
  41. const LEARNING_ICON = "⚙️";
  42. const LIMITED_ICON = "⛔";
  43. const FOOTER = "Chart created with [Lex's SG Chart Maker](https://www.steamgifts.com/discussion/ed1gC/userscript-lexs-sg-chart-maker)\n";
  44. const ACHIEVEMENTS_URL = "https://steamhunters.com/apps/{0}/achievements";
  45. // other possiblities: "DailyIndieGame" "GreenMan Gaming"
  46. const BUNDLE_BLACKLIST = ["Chrono.GG", "Chrono.gg", "Ikoid", "Humble Mobile Bundle", "PlayInjector", "Vodo",
  47. "Get Loaded", "Indie Ammo Box", "MacGameStore", "PeonBundle", "Select n'Play", "StackSocial",
  48. "StoryBundle", "Bundle Central", "Cult of Mac", "GOG", "Gram.pl", "Indie Fort", "IUP Bundle", "Paddle",
  49. "SavyGamer", "Shinyloot", "Sophie Houlden", "Unversala", "Indie Game Stand", "Fourth Wall Games"];
  50.  
  51. $("head").append ('<link ' +
  52. 'href="//ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/base/jquery-ui.min.css" ' +
  53. 'rel="stylesheet" type="text/css">'
  54. );
  55.  
  56. // From https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format
  57. if (!String.format) {
  58. String.format = function(format) {
  59. var args = Array.prototype.slice.call(arguments, 1);
  60. return format.replace(/{(\d+)}/g, function(match, number) {
  61. return typeof args[number] != 'undefined'
  62. ? args[number]
  63. : match
  64. ;
  65. });
  66. };
  67. }
  68.  
  69. // Promise wrapper for GM_xmlhttpRequest
  70. const Request = details => new Promise((resolve, reject) => {
  71. details.onerror = details.ontimeout = reject;
  72. details.onload = resolve;
  73. GM_xmlhttpRequest(details);
  74. });
  75.  
  76. // Adapted from https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time
  77. function timeDifference(current, previous) {
  78. const msPerMinute = 60 * 1000;
  79. const msPerHour = msPerMinute * 60;
  80. const msPerDay = msPerHour * 24;
  81. const msPerMonth = msPerDay * 30;
  82. const msPerYear = msPerDay * 365;
  83. const elapsed = current - previous;
  84. if (elapsed < msPerMinute*2)
  85. return Math.floor(elapsed/1000) + ' seconds ago';
  86. else if (elapsed < msPerHour*2)
  87. return Math.floor(elapsed/msPerMinute) + ' minutes ago';
  88. else if (elapsed < msPerDay*2)
  89. return Math.floor(elapsed/msPerHour ) + ' hours ago';
  90. else if (elapsed < msPerMonth*2)
  91. return Math.floor(elapsed/msPerDay) + ' days ago';
  92. else if (elapsed < msPerYear*2)
  93. return 12*(current.getFullYear() - previous.getFullYear()) + (current.getMonth() - previous.getMonth()) + ' months ago';
  94. else
  95. return Math.floor(elapsed/msPerYear) + ' years ago';
  96. }
  97.  
  98. function getGames() { return JSON.parse(GM_getValue("games", '{}')); }
  99. function getGameOrder() { return JSON.parse(GM_getValue("gameOrder", '[]')); }
  100.  
  101. function generateQuickGuid() { return Math.random().toString(36).substring(2, 15) +
  102. Math.random().toString(36).substring(2, 15); }
  103.  
  104. function getCachedJSONValue(key, default_value, invalidation_time) {
  105. try {
  106. let result = JSON.parse(GM_getValue(key));
  107. if ((new Date()).getTime() - result.UPDATE_TIME < (invalidation_time || INVALIDATION_TIME))
  108. return result;
  109. } catch (err) { }
  110. return default_value;
  111. }
  112.  
  113. function setCachedJSONValue(key, value) {
  114. value.UPDATE_TIME = (new Date()).getTime();
  115. GM_setValue(key, JSON.stringify(value));
  116. }
  117.  
  118. async function fetchNoCV() {
  119. const noCVData = getCachedJSONValue("noCVData", undefined, 48*60*60*1000); // 48 hour cache time for nocv data
  120. if (noCVData !== undefined) {
  121. return noCVData;
  122. }
  123. console.log("Downloading new No CV data");
  124. const response = await Request({
  125. method: "GET",
  126. url: "https://rafaelgssa.com/esgst/games/ncv",
  127. timeout: 30000
  128. });
  129. const jresp = JSON.parse(response.responseText);
  130. if (jresp && jresp.error == null) {
  131. setCachedJSONValue("noCVData", jresp.result.found);
  132. return jresp.result.found;
  133. }
  134. }
  135.  
  136. // nocv is an object { apps: { }, subs: {} }
  137. async function loadNoCV() {
  138. const nocv = await fetchNoCV();
  139. const games = getGames();
  140. for (let g of Object.values(games)) {
  141. if (g.subid) {
  142. if (g.subid in nocv.subs)
  143. g.noCV = true;
  144. } else if (g.appid in nocv.apps)
  145. g.noCV = true;
  146. }
  147. GM_setValue("games", JSON.stringify(games));
  148. dumpListing();
  149. }
  150.  
  151. async function itad_gid_by_shopid(shopIds) {
  152. const response = await Request({
  153. method: "POST",
  154. url: "https://api.isthereanydeal.com/lookup/id/shop/61/v1?key=" + ITAD_API_KEY,
  155. data: JSON.stringify(shopIds)
  156. });
  157. return JSON.parse(response.responseText);
  158. }
  159.  
  160. async function itad_getbundles(itadGid) {
  161. const response = await Request({
  162. method: "GET",
  163. url: "https://api.isthereanydeal.com/games/bundles/v2?key=" + ITAD_API_KEY + "&expired=true&id=" + itadGid
  164. });
  165. return JSON.parse(response.responseText);
  166. }
  167.  
  168. // Functions for scraping data from an app page
  169. const appPage = {
  170. rating(context = document) {
  171. const rating = $("div[itemprop=aggregateRating]", context).attr('data-tooltip-html').replace(/(\d+)%[^\d]*([\d,]*).*/, "$1% of $2 reviews");
  172. if (rating.startsWith("Need more")) {
  173. let total = parseInt($("label[for=review_type_all]", context).text().match(/[\d,]+/)[0].replace(/,/g,''));
  174. let pos = parseInt($("label[for=review_type_positive]", context).text().match(/[\d,]+/)[0].replace(/,/g,''));
  175. return Math.round(100*pos/total) + `% of ${total} reviews`;
  176. } else
  177. return rating;
  178. },
  179. appid: () => window.location.pathname.split('/')[2],
  180. name: (context=document) => context.querySelector(".apphub_AppName")?.firstChild?.textContent || "",
  181. price: (context=document) => context.querySelector(".game_area_purchase_game .price, .game_area_purchase_game .discount_original_price")?.textContent.trim() || "",
  182. windows: (context=document) => context.querySelector(".platform_img.win") !== null,
  183. mac: (context=document) => context.querySelector(".platform_img.mac") !== null,
  184. linux: (context=document) => context.querySelector(".platform_img.linux") !== null,
  185. steamDeckVerified: (context=document) => context.querySelector("div[data-featuretarget='deck-verified-results']")?.textContent.includes("Verified"),
  186. steamDeckPlayable: (context=document) => context.querySelector("div[data-featuretarget='deck-verified-results']")?.textContent.includes("Playable"),
  187. achievements: (context=document) => context.querySelector("#achievement_block")?.textContent.includes("Achievements"),
  188. achievementCount: (context=document) => context.querySelector("#achievement_block .block_title")?.textContent.match(/(\d+) Steam/)?.[1],
  189. cards: (context=document) => context.querySelector("img.category_icon[src$='ico_cards.png']") !== null,
  190. cardCount: (context=document) => context.querySelector("div.es_cards_owned")?.innerText.match(/of (\d+)/)?.[1],
  191. learningAbout: (context=document) => context.querySelector("img.category_icon[src$='ico_learning_about_game.png']") !== null,
  192. profileLimited: (context=document) => context.querySelector("img.category_icon[src$='ico_info.png']") !== null,
  193. adultOnly: (context=document) => context.querySelector("div.mature_content_notice") !== null,
  194. dlc: (context=document) => context.querySelector(".game_area_dlc_bubble") !== null,
  195. };
  196.  
  197. function addToGameOrder(gameid) {
  198. let gameOrder = getGameOrder();
  199. gameOrder.push(gameid);
  200. GM_setValue("gameOrder", JSON.stringify(gameOrder));
  201.  
  202. loadNoCV();
  203. }
  204.  
  205. // Add the current page's App to the chart
  206. // Does not work for package Subs
  207. function addAppToChart() {
  208. if (getGameOrder().includes(GameID)) // Game already in chart
  209. return;
  210. var game = {
  211. gameid: GameID,
  212. appid: appPage.appid(),
  213. name: appPage.name(),
  214. rating: appPage.rating(),
  215. windows: appPage.windows(),
  216. mac: appPage.mac(),
  217. linux: appPage.linux(),
  218. steamDeckVerified: appPage.steamDeckVerified(),
  219. steamDeckPlayable: appPage.steamDeckPlayable(),
  220. achievements: appPage.achievements(),
  221. achievementCount: appPage.achievementCount(),
  222. learningAbout: appPage.learningAbout(),
  223. profileLimited: appPage.profileLimited(),
  224. adultOnly : appPage.adultOnly(),
  225. cards: appPage.cards(),
  226. card_count: appPage.cardCount(),
  227. price: appPage.price(),
  228. url: window.location.href,
  229. dlc: appPage.dlc(),
  230. bundles: undefined,
  231. };
  232. var games = getGames();
  233. games[GameID] = game;
  234. GM_setValue("games", JSON.stringify(games));
  235.  
  236. addToGameOrder(GameID);
  237. }
  238.  
  239. // From the main app page, adds a package listed like a deluxe edition
  240. // elem: the div for the package listing on the main app's page
  241. function addPackageToChart(elem) {
  242. const subid = elem.find("input[name=subid]").attr("value");
  243. const gameid = "sub/" + subid;
  244. if (getGameOrder().includes(gameid))
  245. return;
  246. var game = {
  247. gameid,
  248. appid: appPage.appid(),
  249. subid,
  250. name: elem.find("h1")[0].childNodes[0].nodeValue.substring(4).trim(),
  251. windows: appPage.windows(elem),
  252. mac: appPage.mac(elem),
  253. linux: appPage.linux(elem),
  254. steamDeckVerified: appPage.steamDeckVerified(elem),
  255. steamDeckPlayable: appPage.steamDeckPlayable(elem),
  256. achievements: appPage.achievements(),
  257. achievementCount: appPage.achievementCount(),
  258. learningAbout: appPage.learningAbout(),
  259. profileLimited: appPage.profileLimited(),
  260. adultOnly : appPage.adultOnly(),
  261. rating: appPage.rating(),
  262. cards: appPage.cards(),
  263. card_count: appPage.cardCount(),
  264. price: $.trim(elem.find(".price,.discount_original_price").text()),
  265. url: window.location.protocol+"//store.steampowered.com/sub/" + subid,
  266. dlc: appPage.dlc(),
  267. bundles: undefined,
  268. };
  269. var games = getGames();
  270. games[gameid] = game;
  271. GM_setValue("games", JSON.stringify(games));
  272.  
  273. addToGameOrder(gameid);
  274. }
  275.  
  276. // Loads the rating for a gameid because sub pages do not have reviews
  277. // gameid is the gameid on the chart, appid is the Steam App ID to get the rating for
  278. async function getGameRating(gameid, appid) {
  279. const response = await Request({
  280. "method": "GET",
  281. "url": window.location.protocol+"//store.steampowered.com/app/" + appid
  282. });
  283. const dom = $(response.responseText);
  284. var games = getGames();
  285. let game = games[gameid];
  286. game.rating = appPage.rating(dom);
  287. game.achievements = appPage.achievements(dom);
  288. game.achievementCount = appPage.achievementCount(dom);
  289. game.appid = appid;
  290. GM_setValue("games", JSON.stringify(games));
  291. dumpListing();
  292. }
  293.  
  294. // elem: the div for the package listing on the sub's page
  295. function addSubToChart(elem) {
  296. const subid = elem.find("input[name=subid]").attr("value");
  297. const gameid = "sub/" + subid;
  298. if (getGameOrder().includes(gameid)) // sub id already in the chart
  299. return;
  300. var game = {
  301. gameid: gameid,
  302. appid: subid,
  303. subid: subid,
  304. name: elem.find("h1")[0].childNodes[0].nodeValue.substring(4).trim(),
  305. rating: "?",
  306. windows: appPage.windows(),
  307. mac: appPage.mac(),
  308. linux: appPage.linux(),
  309. steamDeckVerified: appPage.steamDeckVerified(),
  310. steamDeckPlayable: appPage.steamDeckPlayable(),
  311. achievements: appPage.achievements(),
  312. achievementCount: appPage.achievementCount(),
  313. learningAbout: appPage.learningAbout(),
  314. profileLimited: appPage.profileLimited(),
  315. adultOnly : appPage.adultOnly(),
  316. cards: appPage.cards(),
  317. card_count: appPage.cardCount(),
  318. price: $.trim(elem.find(".price,.discount_original_price").text()),
  319. url: window.location.protocol+"//store.steampowered.com/sub/" + subid,
  320. dlc: appPage.dlc(),
  321. bundles: undefined,
  322. };
  323. var games = getGames();
  324. games[gameid] = game;
  325. GM_setValue("games", JSON.stringify(games));
  326.  
  327. // Submit an AJAX request to get the game's rating
  328. const appid = $(".tab_item:first").attr("data-ds-appid");
  329. getGameRating(gameid, appid);
  330.  
  331. addToGameOrder(gameid);
  332. }
  333.  
  334. function addBundleToChart(elem) {
  335. const bundleid = elem.attr('data-ds-bundleid');
  336. const gameid = "bundle/" + bundleid;
  337. if (getGameOrder().includes(gameid)) // game id already in the chart
  338. return;
  339. var game = {
  340. gameid: gameid,
  341. appid: bundleid,
  342. bundleid: bundleid,
  343. name: $.trim(elem.find("h1")[0].childNodes[0].nodeValue.substring(4)),
  344. rating: "?",
  345. achievements: appPage.achievements(),
  346. achievementCount: appPage.achievementCount(),
  347. windows: appPage.windows(),
  348. mac: appPage.mac(),
  349. linux: appPage.linux(),
  350. learningAbout: appPage.learningAbout(),
  351. profileLimited: appPage.profileLimited(),
  352. adultOnly : appPage.adultOnly(),
  353. cards: appPage.cards(),
  354. card_count: appPage.cardCount(),
  355. price: '?',
  356. url: window.location.protocol+"//store.steampowered.com/" + gameid,
  357. dlc: appPage.dlc(),
  358. bundles: undefined,
  359. };
  360. var games = getGames();
  361. games[gameid] = game;
  362. GM_setValue("games", JSON.stringify(games));
  363.  
  364. // Submit an AJAX request to get the game's rating
  365. let appid = $(".tab_item:first").attr("data-ds-appid");
  366. getGameRating(gameid, appid);
  367.  
  368. addToGameOrder(gameid);
  369. }
  370.  
  371. // Called from the Load Bundle Info button
  372. async function loadBundleInfo() {
  373. const gameids = getGameOrder().filter(g => !g.startsWith("tier"));
  374. const itadGidMap = await itad_gid_by_shopid(gameids);
  375. const bundleInfo = new Map()
  376. for (const steamId of gameids) {
  377. const itadGid = itadGidMap[steamId];
  378. if (itadGid === undefined) {
  379. console.log(`Error finding the ITAD GID for game ${steamId}`)
  380. }
  381. bundleInfo[steamId] = await itad_getbundles(itadGid)
  382. }
  383. const games = getGames();
  384. for (const steamId of gameids) {
  385. const game = games[steamId];
  386. game.bundles = bundleInfo[steamId]
  387. game.itadGid = itadGidMap[steamId]
  388. }
  389. GM_setValue("games", JSON.stringify(games));
  390. dumpListing();
  391. updateListing();
  392. }
  393.  
  394. function showChartMaker() {
  395. if (!$("#lcm_dialog").length) {
  396. // Create the dialog
  397. GM_addStyle(".lcm_dialog { display: flex; flex-direction: column; } " +
  398. "#lcm_dialog a { color: blue; text-decoration: underline; } " +
  399. "#lcm_list { list-style-type: none; margin: 0 auto; padding: 0; width: 75%; }" +
  400. "#lcm_dump { margin: 25px auto 0 auto; display: block; flex-grow: 1; resize: none; width: 95%; }" +
  401. "#lcm_bundle_info { margin-bottom: 5px; }" +
  402. "#lcm_itad { float: left; margin-bottom: 5px; }" +
  403. "#lcm_center_btns { float:none; text-align: center; }");
  404. var d = $(`<div id="lcm_dialog" class="lcm_dialog"><div name="top-container">
  405. <div id="lcm_itad">
  406. <div>
  407. <a href="https://isthereanydeal.com/dev/app/" target=_blank>IsThereAnyDeal API Key</a>: <input type="text"></input><button>Submit</button>
  408. </div>
  409. <a style="display:none" href="javascript:">Delete ITAD<br/>API Key?</a>
  410. </div>
  411. <div style="float: right"><button id="lcm_bundle_info" class="ui-button ui-widget ui-corner-all">Load Bundle Info</button></div>
  412. <div id="lcm_center_btns">
  413. <div style="margin-bottom: 2px">
  414. <button id="lcm_add_tier" class="ui-button ui-widget ui-corner-all">🛆 Add Tier</button>
  415. <label for="lcm_totals">🧮 Totals</label>
  416. <input type="checkbox" id="lcm_totals"/>
  417. <button id="lcm_clear_chart" class="ui-button ui-widget ui-corner-all">🗑️ Empty</button>
  418. <button id="lcm_show_preview" class="ui-button ui-widget ui-corner-all">🖼️ Preview</button>
  419. </div>
  420. <div id="lcm_columns" style="margin-bottom: 2px">
  421. <label for="lcm_rating" title="Show or hide the Rating column">⭐</label>
  422. <input type="checkbox" id="lcm_rating"/>
  423. <label for="lcm_cards" title="Show or hide the Cards column">❤</label>
  424. <input type="checkbox" id="lcm_cards"/>
  425. <label for="lcm_achievements" title="Show or hide the Achievements column">🏆</label>
  426. <input type="checkbox" id="lcm_achievements"/>
  427. <label for="lcm_details" title="Show or hide the Details column">📃</label>
  428. <input type="checkbox" id="lcm_details"/>
  429. <label for="lcm_platforms" title="Show or hide the Platforms column">🖥️</label>
  430. <input type="checkbox" id="lcm_platforms"/>
  431. <label for="lcm_bundles" title="Show or hide the Bundled column">📦</label>
  432. <input type="checkbox" id="lcm_bundles"/>
  433. <label for="lcm_discount" title="Show or hide the Discount column">💸</label>
  434. <input type="checkbox" id="lcm_discount"/>
  435. <label for="lcm_currentprice" title="Show or hide the Current Price column">🛒</label>
  436. <input type="checkbox" id="lcm_currentprice"/>
  437. </div>
  438. </div>
  439. </div>
  440. <ul id="lcm_list"></ul>
  441. <textarea id="lcm_dump"></textarea></div>`);
  442. $("body").append(d);
  443. if (GM_getValue("ITAD_API_KEY") !== undefined)
  444. $("#lcm_itad div,#lcm_itad a").toggle();
  445. const ColumnToggles = [
  446. // [ HTML ID, GM value key, default value ]
  447. ["#lcm_rating", "addRating", true],
  448. ["#lcm_achievements", "addAchievements", true],
  449. ["#lcm_details", "addDetails", true],
  450. ["#lcm_platforms", "addPlatforms", true],
  451. ["#lcm_cards", "addCards", true],
  452. ["#lcm_bundles", "addBundles", true],
  453. ["#lcm_discount", "addDiscount", false],
  454. ["#lcm_currentprice", "addCurrentPrice", false]
  455. ]
  456. ColumnToggles.forEach(tgl => {
  457. $(tgl[0])
  458. .prop('checked', GM_getValue(tgl[1], tgl[2]))
  459. .button()
  460. .click(function(){
  461. GM_setValue(tgl[1], $(this).prop('checked'));
  462. dumpListing();
  463. });
  464. });
  465. /*$("#lcm_columns").sortable({
  466. deactivate: function (event, ui) {
  467. dumpListing();
  468. }
  469. });*/
  470. // Add Totals button
  471. $("#lcm_totals").prop('checked', GM_getValue("addTotals", false))
  472. .button()
  473. .click(function(){
  474. GM_setValue("addTotals", $(this).prop('checked'));
  475. dumpListing();
  476. });
  477. // Load ITAD API key
  478. $("#lcm_itad button").click(function(){
  479. try{
  480. ITAD_API_KEY = $("#lcm_itad input").val().match(API_KEY_REGEXP)[0];
  481. GM_setValue("ITAD_API_KEY", ITAD_API_KEY);
  482. $("#lcm_itad div,#lcm_itad a").toggle();
  483. }catch(err){
  484. alert("Error setting API key");
  485. }
  486. });
  487. // Add tier button
  488. $("#lcm_add_tier").click(function(){
  489. addToGameOrder("tier-" + generateQuickGuid());
  490. updateListing();
  491. dumpListing();
  492. });
  493. // Delete API key button
  494. $("#lcm_itad a").click(function(){
  495. GM_deleteValue("ITAD_API_KEY");
  496. ITAD_API_KEY = undefined;
  497. $("#lcm_itad div,#lcm_itad a").toggle();
  498. });
  499.  
  500. $("#lcm_dialog").dialog({
  501. modal: false,
  502. title: "Lex's SG Chart Maker v" + GM_info.script.version,
  503. position: {
  504. my: "center",
  505. at: "center",
  506. of: window,
  507. collusion: "none"
  508. },
  509. width: 800,
  510. height: 400,
  511. minWidth: 300,
  512. minHeight: 200,
  513. zIndex: 3666,
  514. })
  515. .dialog("widget").draggable("option", "containment", "none");
  516. $("#lcm_list").sortable({
  517. deactivate: function (event, ui) {
  518. saveGameOrder();
  519. dumpListing();
  520. }
  521. });
  522. $("#lcm_bundle_info").click(loadBundleInfo);
  523. $("#lcm_show_preview").click(showPreviewWindow);
  524. $("#lcm_clear_chart").click(() => {
  525. GM_deleteValue("gameOrder");
  526. GM_deleteValue("games");
  527. updateListing();
  528. dumpListing();
  529. });
  530. $("#lcm_dump").bind("input propertychange", () => {
  531. updatePreview($("#lcm_dump").val());
  532. });
  533. } else {
  534. $("#lcm_dialog").dialog();
  535. }
  536. updateListing();
  537. dumpListing();
  538. }
  539.  
  540. function showPreviewWindow() {
  541. if ($("#lcm_preview").length) {
  542. $("#lcm_preview").dialog();
  543. } else {
  544. GM_addStyle(`.markdown h1,.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{color:#324862;padding-top:5px;margin-bottom:8px!important;line-height:1em!important}.markdown h1{font:300 28px "Open Sans",sans-serif}.markdown h2{font:700 18px "Open Sans",sans-serif}.markdown h3{font:700 14px "Open Sans",sans-serif}.markdown{word-wrap:break-word}.markdown--resize-body{font-size:13px;line-height:1.55em}.markdown table{border-collapse:collapse;border:1px solid #d2d6e0;table-layout:fixed;width:100%}.markdown thead{background-color:#e8eaef;font-weight:700;border-bottom:1px solid #d2d6e0}.markdown td,.markdown th{padding:3px 10px}.markdown td:not(:last-child),.markdown th:not(:last-child){border-right:1px solid #d2d6e0}.markdown tr:not(:last-child){border-bottom:1px solid #d2d6e0}.markdown pre{white-space:pre-wrap;background-color:#e8eef6;border:1px solid #d0dced;padding:5px 15px;border-radius:4px;text-shadow:1px 1px rgba(255,255,255,.2);color:#5c7397}.markdown code{font-family:"Droid Sans Mono",sans-serif;font-size:11px}.markdown hr{border-top:1px solid #d2d6e0;border-bottom:1px solid rgba(255,255,255,.3);border-left:none;border-right:none}.markdown .have>:not(:last-child):not(div),.markdown .want>:not(:last-child):not(div),.markdown>:not(:last-child):not(div){margin-bottom:15px}.markdown .spoiler:not(:hover){background-color:#d9dee6;color:transparent;text-shadow:none}.markdown .spoiler:not(:hover) a{color:transparent;text-decoration:none}.markdown ol,.markdown ul{margin-right:25px;margin-left:25px}.markdown ol>li{counter-increment:list}.markdown li{padding:2px 5px}.markdown ol>li:before{content:counter(list) "."}.markdown ul>li:before{content:"•"}.markdown li p:not(:last-child){margin-bottom:5px}.markdown li:before{color:#da5d88;margin-left:-60px;font-weight:700;font-size:11px;position:absolute;width:50px;text-align:right}.markdown .search_highlight{background-color:#ff0;text-shadow:none}.markdown img{max-width:500px;max-height:500px;margin-top:5px;display:inline-block}.markdown .comment__toggle-attached{font-size:11px;font-style:italic;text-decoration:underline;color:#c86848;cursor:pointer}.markdown a{color:#4b72d4;text-decoration:underline}.markdown blockquote{border-left:5px solid #d2d6e0;padding:3px 15px;font-style:italic;opacity:.8}.markdown .have,.markdown .want{border-left:5px solid;padding:10px 20px}.markdown .have:not(:last-child),.markdown .want:not(:last-child){margin-bottom:15px}.markdown .have{border-left-color:#e1868c;background-color:#efedf0}.markdown .want{border-left-color:#6bbfdb;background-color:#e8eff3}.markdown blockquote blockquote{border-left:none;padding:0;opacity:1}`);
  545. var d = $(`<div id="lcm_preview" class="lcm_dialog markdown" style="font-size:13px"></div>`);
  546. $("body").append(d);
  547. $("#lcm_preview").dialog({
  548. modal: false,
  549. title: "Lex's SG Chart Maker Preview",
  550. position: {
  551. my: "right",
  552. at: "right",
  553. of: window,
  554. collusion: "none"
  555. },
  556. width: 820, // results in a table 796px wide which is the same as SG
  557. height: 400,
  558. minWidth: 300,
  559. minHeight: 200,
  560. zIndex: 3666,
  561. })
  562. .dialog("widget").draggable("option", "containment", "none");
  563.  
  564. updatePreview($("#lcm_dump").val());
  565. }
  566. }
  567.  
  568. function updatePreview(dump) {
  569. if ($("#lcm_preview")) {
  570. var md = window.markdownit();
  571. $("#lcm_preview").html(md.render(dump));
  572. }
  573. }
  574.  
  575. function updateListing() {
  576. $("#lcm_list").empty();
  577. var games = getGames();
  578. for (let id of getGameOrder()) {
  579. const p = (!id.startsWith("tier") && games[id].price) ? games[id].price : "?";
  580. const text = id.startsWith("tier") ? "Tier" : `<a href="${games[id].url}">${games[id].name}</a> - ${id} - ${p}`;
  581. $(`<li class="ui-state-default" data-appid="${id}">${text}<a href="javascript:" style="float:right; color:red; margin-top:-3px">✖</a></li>`)
  582. .appendTo("#lcm_list")
  583. .find("a:last").click(function(){ // Delete button
  584. deleteGame($(this).parent().attr("data-appid"));
  585. updateListing();
  586. dumpListing();
  587. });
  588. }
  589. }
  590.  
  591. // Read order from the sortable and saves it
  592. function saveGameOrder() {
  593. const gameOrder = $("#lcm_list li").map((i,e) => e.getAttribute("data-appid")).get();
  594. if (gameOrder.concat().sort().join(",") !== getGameOrder().sort().join(",")) {
  595. alert("Chart data is out of date! Were you editing in a different tab? Reloading data from cache...");
  596. updateListing();
  597. } else
  598. GM_setValue("gameOrder", JSON.stringify(gameOrder));
  599. }
  600.  
  601. var dumpFormatters = {
  602. name: [ "Game", ":-", function(g) { // Dumps the name entry for a game
  603. return `**[${g.name}](${g.url})**` + (g.dlc ? " (DLC)" : "");
  604. }],
  605. rating: ["Ratings", ":-:", function(g) {
  606. return g.rating;
  607. }],
  608. cards: ["Cards", ":-:", function(g) {
  609. if (!g.cards) return "-";
  610. let tooltip = "";
  611. if (g.card_count) tooltip = g.card_count + " cards";
  612. if (g.dlc)
  613. return "(Base game has cards)";
  614. else
  615. return `[**${CARD_ICON}**](http://www.steamcardexchange.net/index.php?gamepage-appid-${g.appid} "${tooltip}")`;
  616. }],
  617. achievements: ["Cheevos", ":-:", function(g) {
  618. if (!g.achievements)
  619. return "-";
  620. if (!g.achievementCount) {
  621. return `[🏆](${String.format(ACHIEVEMENTS_URL, g.appid)})`;
  622. } else {
  623. return `[🏆](${String.format(ACHIEVEMENTS_URL, g.appid)} "${g.achievementCount} achievements")`;
  624. }
  625. }],
  626. details: ["Details", ":-:", function(g) {
  627. let url = "https://www.steamgifts.com/giveaways/search?app=" + g.appid;
  628. if (g.subid || g.bundleid)
  629. url = "https://www.steamgifts.com/giveaways/search?q=" + encodeURIComponent(g.name).replace(/%20/g,"+");
  630. let cv = 0.0;
  631. if (g.price)
  632. cv = parseFloat(g.price.replace(/\$/g,''))*0.15;
  633. const isUSD = g.price.startsWith("$");
  634. if (!isUSD) {
  635. cv = 0.0;
  636. }
  637.  
  638. let icons = [];
  639. if (g.noCV || g.price == "Free" || g.price == "Free To Play") {
  640. icons.push(NOCV_ICON);
  641. cv = 0.0;
  642. }
  643. if (g.learningAbout || g.profileLimited) icons.push(LEARNING_ICON);
  644. if (g.adultOnly) icons.push(ADULT_ICON);
  645. if (icons.length) icons = " " + icons.join(""); // prepend a space
  646.  
  647. let gameId = "app/" + g.appid;
  648. if (g.subid) gameId = "sub/" + g.subid;
  649. if (g.bundleid) gameId = "bundle/" + g.bundleid;
  650. const [idType, id] = gameId.split("/")
  651. return `[${cv.toFixed(2)} CV](${url})${icons} ${idType}/[${id}](https://steamdb.info/${gameId}/)`;
  652. }],
  653. platforms: ["Platforms", ":-:", function(g) {
  654. let ps = [];
  655. const fullNames = [];
  656. if (g.windows) {ps.push("W"); fullNames.push("Windows")}
  657. if (g.mac) {ps.push("M"); fullNames.push("macOS")}
  658. if (g.linux) {ps.push("L"); fullNames.push("Linux")}
  659. if (g.steamDeckVerified) {ps.push('D✅');fullNames.push("Steam Deck Verified")}
  660. if (g.steamDeckPlayable) {ps.push('D🟡'); fullNames.push("Steam Deck Playable")}
  661. return `[${ps.join(" ")}](# "${fullNames.join(", ")}")`;
  662. }],
  663. bundles: ["Bundled", ":-:", function(g) {
  664. let bundleCount = "?";
  665. let tooltip = "";
  666. if (g.bundles !== undefined) {
  667. // Bundles not on blacklist and at least 48 hours old
  668. const notBlacklisted = (b) => !BUNDLE_BLACKLIST.includes(b.page.name) && (new Date() - new Date(b.publish)) > 48*60*60*1000;
  669. bundleCount = g.bundles.filter(notBlacklisted).length;
  670. //💵📉📦🛒💸💰
  671. const formatBundle = function(b) {
  672. let delta = timeDifference(new Date(), new Date(b.expiry));
  673. if (b.publish && (b.expiry === null || new Date(b.expiry) > new Date()))
  674. delta = "ongoing";
  675. return "📦 " + b.title.trim() + " (" + delta + ")";
  676. }
  677. tooltip = g.bundles.length + " bundles " + g.bundles.map(formatBundle).join(" ");
  678. }
  679. return `[${bundleCount}](https://isthereanydeal.com/game/id:${g.itadGid}/info/#/a:bundles "${tooltip.trim()}")`;
  680. }],
  681. price: ["Retail Price", ":-:", function(g) {
  682. let price = g.price || "?";
  683. if (price == "Free")
  684. price = "🆓 Free";
  685. if (price == "Free To Play")
  686. price = "💩 Free To Play";
  687. let tooltip = "";
  688. if (g.euPrice)
  689. tooltip = ' "' + g.euPrice + '€"';
  690. if (g.plain)
  691. price = `[${price}](https://isthereanydeal.com/#/page:game/info?plain=${g.plain}${tooltip})`;
  692. return price;
  693. }],
  694. discount: ["Discount", ":-:", function(g) {
  695. if (g.eu_price_cut !== undefined && g.eu_price_cut !== g.price_cut)
  696. return `[-${g.price_cut}%](# "-${g.eu_price_cut}%")`;
  697. return "-" + g.price_cut + "%";
  698. }],
  699. currentPrice: ["Current Price", ":-:", function(g) {
  700. if (g.eu_price_new !== undefined)
  701. return `[$${g.price_new}](# "${g.eu_price_new}€")`;
  702. return "$" + g.price_new;
  703. }],
  704. };
  705.  
  706. // Post chart code to the textarea
  707. function dumpListing() {
  708. // Enable or disable columns by setting them to true or false. Defaults to true
  709. const colToggles = {
  710. name: true,
  711. rating: $("#lcm_rating").prop('checked'),
  712. cards: $("#lcm_cards").prop('checked'),
  713. achievements: $("#lcm_achievements").prop('checked'),
  714. details: $("#lcm_details").prop('checked'),
  715. platforms: $("#lcm_platforms").prop('checked'),
  716. bundles: $("#lcm_bundles").prop('checked'),
  717. price: true,
  718. discount: $("#lcm_discount").prop('checked'),
  719. currentPrice: $("#lcm_currentprice").prop('checked'),
  720. }
  721. // columns is a list of enabled dumpFormatter keys to dump
  722. const columns = Object.keys(dumpFormatters).filter(k => colToggles[k]);
  723.  
  724. // First two rows of the table
  725. let header = columns.map(colKey => dumpFormatters[colKey][0]).join(" | ") + "\n";
  726. header += columns.map(colKey => dumpFormatters[colKey][1]).join(" | ") + "\n";
  727.  
  728. let dump = header;
  729. // If at least one Tier is added, display Tier 1 at the top
  730. if (getGameOrder().filter(g => g.startsWith("tier")).length)
  731. dump = `### **Tier 1**\n` + dump;
  732. let totals = [0]; // total prices
  733. let gameOrder = getGameOrder();
  734. const games = getGames();
  735. for (let idx = 0; idx < gameOrder.length; idx++) {
  736. let gid = gameOrder[idx];
  737. if (gid.startsWith("tier")) {
  738. if (idx !== 0) {
  739. totals.push(0);
  740. }
  741. dump = (idx===0 ? "":dump+"\n") + `### **Tier ${totals.length}**\n${header}`;
  742. continue;
  743. }
  744. const g = games[gid];
  745. if (g === undefined)
  746. continue;
  747.  
  748. totals[totals.length-1] += parseFloat(g.price ? g.price.replace(/\$/g,'') : "0.0");
  749.  
  750. dump += columns.map(e => dumpFormatters[e][2](g)).join(" | ");
  751. dump += "\n";
  752. }
  753. // If any games have no CV
  754. if (Object.values(games).reduce((a,c) => a || c.noCV, false))
  755. dump += NOCV_ICON + " - Game was free at some time and does not grant any CV if given away.\n";
  756. // If any games are being learned about or profile limited
  757. if (Object.values(games).reduce((a,c) => a || c.learningAbout || c.profileLimited, false))
  758. dump += LEARNING_ICON + " - Not currently eligible to appear in certain showcases on your Steam Profile, and does not contribute to global Achievement or game collector counts.\n";
  759. // If any games are adult only
  760. if (Object.values(games).reduce((a,c) => a || c.adultOnly, false))
  761. dump += ADULT_ICON + " - Adult only\n";
  762. if (GM_getValue("addTotals")) {
  763. if (totals.length > 1 && totals[0] === 0)
  764. totals.splice(0, 1); // Cut off empty first tier
  765. dump += "\n**Retail:**\n";
  766. let cv = "\n**CV:**\n";
  767. for (let i = 0; i < totals.length; i++) {
  768. let t = totals[i];
  769. const cumCost = totals.slice(0, i+1).reduce((p,c) => p + c, 0);
  770. const prep = totals.length === 1 ? `* ` : `* Tier ${[...Array(i+2).keys()].slice(1).join(" + ")} = `;
  771. cv += prep + `${(cumCost*0.15).toFixed(4)}\n`;
  772. dump += prep + `$${cumCost.toFixed(2)}\n`;
  773. }
  774. dump += cv + "\n";
  775. }
  776. dump += FOOTER;
  777. $("#lcm_dump").val(dump);
  778.  
  779. updatePreview(dump);
  780. }
  781.  
  782. function deleteGame(aid) {
  783. if (aid == GameID) // Unmark the + Chart button
  784. $("#lcm_add_btn").removeClass("queue_btn_active");
  785.  
  786. let gameOrder = getGameOrder();
  787. try {
  788. gameOrder.splice(gameOrder.indexOf(aid), 1);
  789. GM_setValue("gameOrder", JSON.stringify(gameOrder));
  790. }catch(err) {}
  791.  
  792. let games = getGames();
  793. try {
  794. delete games[aid];
  795. GM_setValue("games", JSON.stringify(games));
  796. }catch(err) {}
  797. }
  798.  
  799. function createSubButton(callback) {
  800. let btn = document.createElement("button");
  801. btn.type = "button";
  802. btn.innerText = " +⊞ Chart";
  803. btn.addEventListener('click', function(){
  804. callback.call(this);
  805. showChartMaker();
  806. });
  807. btn.style.cssFloat = 'right';
  808. btn.style.fontSize = "110%";
  809. btn.style.marginTop = "-1px";
  810. return btn;
  811. }
  812.  
  813. function handleAppPage() {
  814. // Add button to app page
  815. $(`<a id="lcm_add_btn" class="btnv6_blue_hoverfade btn_medium btn_steamdb"><span>+ <span style="position:relative;top:-1px">&#x229e;</span> Chart</span></a>`)
  816. .appendTo(`.apphub_OtherSiteInfo:first`)
  817. .click(function(){
  818. $(this).addClass("queue_btn_active");
  819. addAppToChart();
  820. showChartMaker();
  821. })
  822. .toggleClass("queue_btn_active", GameID in getGames());
  823.  
  824. $(".game_area_purchase_game:first").prepend(createSubButton(addAppToChart));
  825.  
  826. // Find other purchase options on the page
  827. let subs = $(".game_area_purchase_game:not(:first)");
  828. // But ignore bundles
  829. subs = subs.filter((i,e) => !e.parentNode.matches("[data-ds-bundleid]"));
  830. // add chart buttons to each of them
  831. const callback = function(){ addPackageToChart($(this).closest(".game_area_purchase_game")); };
  832. subs.each((i,e) => e.prepend(createSubButton(callback)));
  833. }
  834.  
  835. function handleSubPage() {
  836. // Add buttons to the package listing
  837. const callback = function(){ addSubToChart($(this).closest(".game_area_purchase_game")); };
  838. document.querySelector(".game_area_purchase_game").prepend(createSubButton(callback));
  839. }
  840.  
  841. function handleBundlePage() {
  842. // Add buttons to the bundle listing
  843. const callback = function(){ addBundleToChart($(this).closest(".game_area_purchase_game")); };
  844. $(".game_area_purchase_game").each((i,e) => e.prepend(createSubButton(callback)));
  845. }
  846.  
  847. if (window.location.pathname.match(/app\/\d+/))
  848. handleAppPage();
  849. if (window.location.pathname.match(/sub\/\d+/))
  850. handleSubPage();
  851. if (window.location.pathname.match(/bundle\/\d+/))
  852. handleBundlePage();
  853. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址