Itch.io Web Integration

Shows if an Itch.io link has been claimed or not

  1. // ==UserScript==
  2. // @name Itch.io Web Integration
  3. // @namespace Lex@GreasyFork
  4. // @match *://*.itch.io/*
  5. // @match *://*.steamgifts.com/discussion/*
  6. // @match *://*.keylol.com/*
  7. // @match *://*.reddit.com/r/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @version 0.1.8.8
  12. // @author Lex
  13. // @description Shows if an Itch.io link has been claimed or not
  14. // @connect itch.io
  15. // ==/UserScript==
  16.  
  17. (function(){
  18. 'use strict';
  19.  
  20. const CACHE_VERSION_KEY = "CacheVersion";
  21. const INVALIDATION_TIME = 5*60*60*1000; // 5 hour cache time
  22. const ITCH_GAME_CACHE_KEY = 'ItchGameCache';
  23. var ItchGameCache;
  24. // Promise wrapper for GM_xmlhttpRequest
  25. const Request = details => new Promise((resolve, reject) => {
  26. details.onerror = details.ontimeout = reject;
  27. details.onload = resolve;
  28. GM_xmlhttpRequest(details);
  29. });
  30. function versionCacheInvalidator() {
  31. const sVersion = v => {
  32. if (typeof v !== 'string' || !v.match(/\d+\.\d+/)) return 0;
  33. return parseFloat(v.match(/\d+\.\d+/)[0]);
  34. }
  35. const prev = sVersion(GM_getValue(CACHE_VERSION_KEY, '0.0'));
  36. if (prev < 0.1) {
  37. console.log(`${GM_info.script.version} > ${prev}`);
  38. console.log(`New minor version of ${GM_info.script.name} detected. Invalidating cache.`)
  39. _clearItchCache();
  40. }
  41. GM_setValue(CACHE_VERSION_KEY, GM_info.script.version);
  42. }
  43. function _clearItchCache() {
  44. ItchGameCache = {};
  45. _saveItchCache();
  46. }
  47. function loadItchCache() {
  48. ItchGameCache = JSON.parse(GM_getValue(ITCH_GAME_CACHE_KEY, '{}'));
  49. }
  50. function _saveItchCache() {
  51. if (ItchGameCache === undefined) return;
  52. GM_setValue(ITCH_GAME_CACHE_KEY, JSON.stringify(ItchGameCache));
  53. }
  54. function setItchGameCache(key, game) {
  55. loadItchCache(); // refresh our cache in case another tab has edited it
  56. ItchGameCache[key] = game;
  57. _saveItchCache();
  58. }
  59. function deleteItchGameCache(key) {
  60. if (key === undefined) return;
  61. loadItchCache();
  62. delete ItchGameCache[key];
  63. _saveItchCache();
  64. }
  65. function getItchGameCache(link) {
  66. if (!ItchGameCache) loadItchCache();
  67. if (Object.prototype.hasOwnProperty.call(ItchGameCache, link)) {
  68. return ItchGameCache[link];
  69. }
  70. return null;
  71. }
  72. async function claimGame(url) {
  73. const parser = new DOMParser();
  74. const purchase_url = url + "/purchase";
  75. console.log("Getting purchase page: " + purchase_url);
  76. const purchase_resp = await Request({method: "GET", url: purchase_url});
  77. const purchase_dom = parser.parseFromString(purchase_resp.responseText, 'text/html');
  78. const download_csrf_token = purchase_dom.querySelector("form.form").csrf_token.value;
  79. const download_url_resp = await Request({
  80. method: "POST",
  81. url: url + "/download_url",
  82. headers: {
  83. "Content-Type": "application/x-www-form-urlencoded"
  84. },
  85. data: 'csrf_token='+encodeURIComponent(download_csrf_token)
  86. });
  87. const downloadUrl = JSON.parse(download_url_resp.responseText).url;
  88. console.log("Received download url: " + downloadUrl);
  89.  
  90. const download_resp = await Request({method: "GET", url: downloadUrl});
  91. const dom = parser.parseFromString(download_resp.responseText, 'text/html');
  92. const claimForm = dom.querySelector(".claim_to_download_box form");
  93. const claim_csrf_token = claimForm.csrf_token.value;
  94. const claim_key_url = claimForm.action;
  95.  
  96. console.log("Claiming game using " + claim_key_url);
  97. const claim_key_resp = await Request({
  98. method: "POST",
  99. url: claim_key_url,
  100. headers: {
  101. "Content-Type": "application/x-www-form-urlencoded"
  102. },
  103. data: 'csrf_token='+encodeURIComponent(claim_csrf_token)
  104. });
  105. return /You claimed this/.test(claim_key_resp.responseText);
  106. }
  107. // Parses a DOM into a game object
  108. function parsePage(url, dom) {
  109. // Gets the inner text of an element if it can be found otherwise returns undefined
  110. const txt = query => { const e = dom.querySelector(query); return e && e.innerText.trim(); };
  111. // JSON.parse(document.querySelectorAll(`script[type="application/ld+json"]`)[1].innerText)
  112. const game = {};
  113. game.cachetime = (new Date()).getTime();
  114. game.url = url;
  115. game.title = txt('h1.game_title');
  116. game.isOwned = dom.querySelector(".purchase_banner_inner .key_row .ownership_reason") !== null;
  117. game.isClaimable = [...dom.querySelectorAll(".buy_btn")].find(e => e.innerText == "Download or claim") !== undefined;
  118. game.isFree = [...dom.querySelectorAll("span[itemprop=price]")].find(e => e.innerText === "$0.00 USD") !== undefined;
  119. game.hasPurchase = [...dom.querySelectorAll("span[itemprop=price]")].find(e => e.innerText !== "$0.00 USD") !== undefined;
  120. game.hasFreeDownload = [...dom.querySelectorAll("a.download_btn,a.buy_btn")].find(e => e.innerText == "Download" || e.innerText == "Download Now") !== undefined;
  121. game.hasCommunityCopies = document.querySelector(".reward_footer") !== null;
  122. const copiesBlock = document.querySelector(".remaining_count");
  123. game.communityCopies = copiesBlock && copiesBlock.innerText.match(/\d+/) && copiesBlock.innerText.match(/\d+/)[0];
  124. game.communityCopies = game.communityCopies || 0;
  125. game.original_price = txt("span.original_price");
  126. game.price = txt("span[itemprop=price]");
  127. game.saleRate = txt(".sale_rate");
  128. game.breadcrumbs = txt(".breadcrumbs");
  129. const categoryHeader = [...document.querySelectorAll(".game_info_panel_widget td:first-child")].find(e=>e.innerText === "Category");
  130. if (categoryHeader)
  131. game.category = categoryHeader.nextSibling.innerText;
  132. return game;
  133. }
  134. // Sends an XHR request and parses the results into a game object
  135. async function fetchItchGame(url) {
  136. const response = await Request({method: "GET",
  137. url: url});
  138. if (response.status != 200) {
  139. console.log(`Error ${response.status} fetching page ${url}`);
  140. return null;
  141. }
  142. const parser = new DOMParser();
  143. const dom = parser.parseFromString(response.responseText, 'text/html');
  144. return parsePage(url, dom);
  145. }
  146. // Loads an itch game from cache or fetches the page if needed
  147. async function getItchGame(url) {
  148. let game = getItchGameCache(url);
  149. if (game !== null) {
  150. const isExpired = (new Date()).getTime() - game.cachetime > INVALIDATION_TIME;
  151. // Expiration checking currently disabled
  152. /*if (isExpired) {
  153. game = null;
  154. }*/
  155. }
  156. if (game === null) {
  157. game = await fetchItchGame(url);
  158. if (game !== null)
  159. setItchGameCache(url, game);
  160. }
  161. return game;
  162. }
  163. async function claimClicked(a, game) {
  164. const iwic = a.closest(".iwi-container");
  165. const claimBtn = iwic.querySelector(".ClaimButton");
  166. console.log("Attempting to claim " + game.url);
  167. claimBtn.innerText += ' ⌛';
  168. claimBtn.onclick = null;
  169. const success = await claimGame(game.url);
  170. if (success === true) {
  171. claimBtn.style.display = "none";
  172. const ownMark = iwic.querySelector(".iwi-ownmark");
  173. ownMark.innerHTML = `<span title="Successfully claimed">✔️</span>`;
  174. deleteItchGameCache(game.url);
  175. } else {
  176. claimBtn.innerHTML = `❗ Error`;
  177. }
  178. }
  179. // Appends the isOwned tag to an anchor link
  180. function appendTags(a, game) {
  181. const div = document.createElement("div");
  182. div.className = "iwi-container";
  183. div.style.display = "inline-block";
  184. const span = document.createElement("span");
  185. div.append(span);
  186. span.style = "margin-left: 5px; background:rgb(230,230,230); padding: 2px; border-radius: 2px";
  187. if (game === null) {
  188. span.innerHTML = `<span title="Status unknown. Try refreshing.">❓</span>`;
  189. a.after(div);
  190. return;
  191. }
  192. if (game.isOwned) {
  193. span.innerHTML = `<span class="iwi-ownmark" title="Game is already claimed on itch.io">✔️</span>`;
  194. } else {
  195. if (!game.isClaimable) {
  196. if (game.hasFreeDownload && !game.hasPurchase) {
  197. span.innerHTML = `<span title="Game is a free download but not claimable">🆓</span>`;
  198. } else if (game.price) {
  199. span.innerHTML = `<span title="🛒 Game costs ${game.price}">🛒</span>`;
  200. } else {
  201. span.innerHTML = `<span title="Status unknown">👽</span>`;
  202. }
  203. } else {
  204. let tooltip = [`Game is claimable but you haven't claimed it.`];
  205. if (game.original_price) tooltip.push(`🛒 Original price: ${game.original_price}`);
  206. if (game.price) tooltip.push(`💸 Current Price: ${game.price}`);
  207. span.innerHTML = `<span class="iwi-ownmark" title="${tooltip.join(" ")}">❌</span>`;
  208. const claimBtn = document.createElement("span");
  209. claimBtn.style = `margin-left: 2px; padding: 2px; cursor:pointer; background:rgb(220,220,220); border-radius: 5px`;
  210. claimBtn.className = "ClaimButton";
  211. claimBtn.innerText = "🛄 Claim Game";
  212. claimBtn.onclick = function(event) { claimClicked(event.target, game); };
  213. span.after(claimBtn);
  214. }
  215. }
  216. if (game.hasCommunityCopies) {
  217. const communityTag = document.createElement("span");
  218. communityTag.title = `This game has ${game.communityCopies} Community Copies availible.`;
  219. communityTag.innerText = '👪';
  220. span.append(communityTag);
  221. }
  222. if (game.breadcrumbs) {
  223. span.firstChild.title += ' ℹ️ ' + game.breadcrumbs;
  224. if (!a.title)
  225. a.title = game.breadcrumbs;
  226. const tags = {
  227. //"Games": { icon: '🎮', title: "Video game" },
  228. "Tools": { icon: '🛠️', title: "Tool" },
  229. "Game assets": { icon: '🗃️', title: "Game asset" },
  230. "Comics": { icon: '🗨️', title: "Comic" },
  231. "Books": { icon: '📘', title: "Book" },
  232. "Physical games": { icon: '📖', title: "Physical game" },
  233. "Soundtracks": { icon: '🎵', title: "Soundtrack" },
  234. "Game mods": { icon: '⚙️', title: "Game mod" },
  235. }
  236. const category = game.breadcrumbs.split("›")[0].trim();
  237. if (Object.prototype.hasOwnProperty.call(tags, category)) {
  238. const tag = document.createElement("span");
  239. tag.title = tags[category].title;
  240. tag.innerText = tags[category].icon;
  241. span.append(tag);
  242. }
  243. }
  244. a.after(div);
  245. }
  246. function addClickHandler(a) {
  247. // If you open a link to an Itch page, it will delete that page from the cache
  248. // this forces an update the next time you load the page
  249. a.addEventListener('mouseup', event => {
  250. deleteItchGameCache(event.target.href);
  251. });
  252. }
  253.  
  254. // Handles an itch.io link on a page
  255. async function handleLink(a) {
  256. // Checks if the link has already been tagged
  257. if (!a.nextSibling || a.nextSibling.className !== "iwi-container") {
  258. addClickHandler(a);
  259. const game = await getItchGame(a.href);
  260. appendTags(a, game);
  261. }
  262. }
  263. function isGameUrl(url) {
  264. return /^https:\/\/[^.]+\.itch\.io\/[^/]+$/.test(url);
  265. }
  266. // Finds all the itch.io links on the current page
  267. function getItchLinks() {
  268. let links = [...document.querySelectorAll("a[href*='itch.io/']")];
  269. links = links.filter(a => isGameUrl(a.href));
  270. links = links.filter(a => !a.classList.contains("return_link"));
  271. links = links.filter(a => { const t = a.textContent.trim(); return t !== "" && t !== "GIF"; });
  272. return links;
  273. }
  274. function handlePage() {
  275. if (isGameUrl(window.location.href)) {
  276. // If we're on an Itch game page, update the cached details
  277. const game = parsePage(window.location.href, document);
  278. setItchGameCache(window.location.href, game);
  279. }
  280. // Try to find any itch links on the page and tag them
  281. const as = getItchLinks();
  282. as.forEach(handleLink);
  283. // Monitor new links loaded on the page
  284. setInterval(function(){
  285. const as = getItchLinks();
  286. as.forEach(handleLink);
  287. }, 1000);
  288. }
  289. versionCacheInvalidator();
  290. handlePage();
  291. })();