// ==UserScript== // @name Achievement Tracker Comparer // @description Compare achievements between AStats, completionist.me, Exophase, MetaGamerScore, Steam Hunters and Steam Community profiles. // @version 1.4.8 // @author Rudey // @homepage https://github.com/RudeySH/achievement-tracker-comparer#readme // @supportURL https://github.com/RudeySH/achievement-tracker-comparer/issues // @include /^https://steamcommunity\.com/id/[a-zA-Z0-9_-]{3,32}/*$/ // @include /^https://steamcommunity\.com/profiles/\d{17}/*$/ // @connect astats.nl // @connect completionist.me // @connect exophase.com // @connect metagamerscore.com // @connect steamhunters.com // @downloadURL https://raw.githubusercontent.com/RudeySH/achievement-tracker-comparer/main/dist/achievement-tracker-comparer.user.js // @grant GM.getValue // @grant GM.setValue // @grant GM.xmlHttpRequest // @license AGPL-3.0-or-later // @namespace https://github.com/RudeySH/achievement-tracker-comparer // @require https://cdnjs.cloudflare.com/ajax/libs/es6-promise-pool/2.5.0/es6-promise-pool.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/he/1.2.0/he.min.js // @updateURL https://raw.githubusercontent.com/RudeySH/achievement-tracker-comparer/main/dist/achievement-tracker-comparer.meta.js // ==/UserScript== /******/ (() => { // webpackBootstrap /******/ "use strict"; /******/ // The require scope /******/ var __webpack_require__ = {}; /******/ /************************************************************************/ /******/ /* webpack/runtime/compat get default export */ /******/ (() => { /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = (module) => { /******/ var getter = module && module.__esModule ? /******/ () => (module['default']) : /******/ () => (module); /******/ __webpack_require__.d(getter, { a: getter }); /******/ return getter; /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports /******/ __webpack_require__.d = (exports, definition) => { /******/ for(var key in definition) { /******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); /******/ } /******/ } /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); /******/ /************************************************************************/ var __webpack_exports__ = {}; ;// CONCATENATED MODULE: external "he" const external_he_namespaceObject = he; var external_he_default = /*#__PURE__*/__webpack_require__.n(external_he_namespaceObject); ;// CONCATENATED MODULE: external "PromisePool" const external_PromisePool_namespaceObject = PromisePool; var external_PromisePool_default = /*#__PURE__*/__webpack_require__.n(external_PromisePool_namespaceObject); ;// CONCATENATED MODULE: ./src/utils/utils.ts const iconExternalLink = ''; const domParser = new DOMParser(); async function getDocument(url, details) { const html = await getHTML(url, details); return domParser.parseFromString(html, 'text/html'); } async function getHTML(url, details) { const data = await xmlHttpRequest({ method: 'GET', overrideMimeType: 'text/html', url, ...details, }); return data.responseText; } async function getJSON(url, details) { const data = await xmlHttpRequest({ method: 'GET', overrideMimeType: 'application/json', url, ...details, }); return JSON.parse(data.responseText); } async function getRedirectURL(url) { const data = await xmlHttpRequest({ method: 'GET', url, }); return data.finalUrl; } function xmlHttpRequest(details) { return retry(() => { console.debug(`${details.method} ${details.url}`); return new Promise((resolve, reject) => { GM.xmlHttpRequest({ onabort: reject, onerror: reject, ontimeout: reject, onload: resolve, ...details, }); }); }); } async function retry(func) { const attempts = 10; let error = undefined; for (let attempt = 1; attempt <= attempts; attempt++) { try { return await func(); } catch (e) { if (attempt >= attempts) { error = e; break; } await delay(1000 * attempt); console.debug('Retrying...'); } } throw error; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function mapBy(items, keySelector) { const map = new Map(); for (const item of items) { const key = keySelector(item); const values = map.get(key); if (values !== undefined) { values.push(item); } else { map.set(key, [item]); } } return map; } function groupBy(items, keySelector) { const map = mapBy(items, keySelector); return [...map].map(([key, values]) => new Grouping(key, values)); } class Grouping extends Array { constructor(key, items) { super(...items); this.key = key; } } function merge(source, target) { if (!target) { return source; } const source2 = Object.fromEntries(Object.entries(source).filter(([_, v]) => v !== undefined)); return Object.assign({ ...target }, source2); } function trim(string, trim) { if (string.startsWith(trim)) { string = string.substring(trim.length); } if (string.endsWith(trim)) { string = string.substring(0, string.length - trim.length); } return string; } ;// CONCATENATED MODULE: ./src/trackers/tracker.ts class Tracker { constructor(profileData) { this.signInLink = undefined; this.ownProfileOnly = false; this.profileData = profileData; } validate(_game) { return []; } } ;// CONCATENATED MODULE: ./src/trackers/astats.ts class AStats extends Tracker { constructor() { super(...arguments); this.name = 'AStats'; } getProfileURL() { return `https://www.astats.nl/astats/User_Info.php?steamID64=${this.profileData.steamid}&utm_campaign=userscript`; } getGameURL(game) { return `https://www.astats.nl/astats/Steam_Game_Info.php?AppID=${game.appid}&SteamID64=${this.profileData.steamid}&utm_campaign=userscript`; } async getStartedGames() { const games = []; const doc = await getDocument(`https://www.astats.nl/astats/User_Games.php?Limit=0&Hidden=1&AchievementsOnly=1&SteamID64=${this.profileData.steamid}&utm_campaign=userscript`); const rows = doc.querySelectorAll('table:not(.Pager) tbody tr'); for (const row of rows) { const validUnlocked = parseInt(row.cells[2].textContent); const unlocked = validUnlocked + (parseInt(row.cells[3].textContent) || 0); if (unlocked <= 0) { continue; } const total = parseInt(row.cells[4].textContent); if (total <= 0) { continue; } const anchor = row.querySelector('a[href*="AppID="]'); const appid = parseInt(new URL(anchor.href).searchParams.get('AppID')); const name = row.cells[1].textContent; const validTotal = row.cells[4].textContent.split(' - ').map(x => parseInt(x)).reduce((a, b) => a - b); const isPerfect = unlocked >= total; const isCompleted = isPerfect || validUnlocked > 0 && validUnlocked >= validTotal; const isCounted = isCompleted; const isTrusted = undefined; games.push({ appid, name, unlocked, total, isPerfect, isCompleted, isCounted, isTrusted }); } return { games }; } getRecoverLinkHTML() { return undefined; } } ;// CONCATENATED MODULE: ./src/trackers/completionist.ts class Completionist extends Tracker { constructor() { super(...arguments); this.name = 'completionist.me'; } getProfileURL() { return `https://completionist.me/steam/profile/${this.profileData.steamid}?utm_campaign=userscript`; } getGameURL(game) { return `https://completionist.me/steam/profile/${this.profileData.steamid}/app/${game.appid}?utm_campaign=userscript`; } async getStartedGames() { const games = []; const url = `https://completionist.me/steam/profile/${this.profileData.steamid}/apps?display=flat&sort=started&order=asc&completion=started&utm_campaign=userscript`; const doc = await this.addStartedGames(games, url); const lastPageAnchor = doc.querySelector('.pagination a:last-of-type'); if (lastPageAnchor !== null) { const pageCount = parseInt(new URL(lastPageAnchor.href).searchParams.get('page')); const iterator = this.getStartedGamesIterator(games, url, pageCount); const pool = new (external_PromisePool_default())(iterator, 6); await pool.start(); } return { games }; } *getStartedGamesIterator(games, url, pageCount) { for (let page = 2; page <= pageCount; page++) { yield this.addStartedGames(games, `${url}&page=${page}`); } } async addStartedGames(games, url) { var _a; const doc = await getDocument(url); const rows = doc.querySelectorAll('.games-list tbody tr'); for (const row of rows) { const nameCell = row.cells[1]; const anchor = nameCell.querySelector('a'); const counts = row.cells[4].textContent.split('/').map(s => parseInt(s.replace(/,/g, ''))); const unlocked = counts[0]; const total = (_a = counts[1]) !== null && _a !== void 0 ? _a : unlocked; const isPerfect = unlocked >= total; games.push({ appid: parseInt(anchor.href.substring(anchor.href.lastIndexOf('/') + 1)), name: nameCell.textContent.trim(), unlocked, total, isPerfect, isCompleted: isPerfect ? true : undefined, isCounted: isPerfect, isTrusted: nameCell.querySelector('.fa-spinner') === null, }); } return doc; } getRecoverLinkHTML(isOwnProfile, games) { if (!isOwnProfile) { return undefined; } return `
`; } } ;// CONCATENATED MODULE: ./src/trackers/exophase.ts class Exophase extends Tracker { constructor() { super(...arguments); this.name = 'Exophase'; this.signInLink = 'https://www.exophase.com/login/?utm_campaign=userscript'; this.ownProfileOnly = true; } getProfileURL() { return `https://www.exophase.com/steam/id/${this.profileData.steamid}?utm_campaign=userscript`; } getGameURL(game) { return `https://www.exophase.com/steam/game/id/${game.appid}/stats/${this.profileData.steamid}?utm_campaign=userscript`; } async getStartedGames() { var _a; let credentials; try { credentials = await getJSON('https://www.exophase.com/account/token?utm_campaign=userscript'); } catch { return { games: [], signIn: true }; } const overview = await getJSON('https://api.exophase.com/account/games?filter=steam&utm_campaign=userscript', { headers: { 'Authorization': `Bearer ${credentials.token}` } }); if (((_a = overview.services.find(s => s.environment === 'steam')) === null || _a === void 0 ? void 0 : _a.canonical_id) !== this.profileData.steamid) { return { games: [], signIn: true, signInAs: this.profileData.personaname }; } const games = overview.games['steam'].map(game => ({ appid: parseInt(game.canonical_id), name: game.title, unlocked: game.earned_awards, total: game.total_awards, isPerfect: game.earned_awards >= game.total_awards, isCompleted: game.earned_awards >= game.total_awards ? true : undefined, isCounted: game.earned_awards >= game.total_awards, isTrusted: undefined, })); return { games }; } getRecoverLinkHTML(isOwnProfile) { if (!isOwnProfile) { return undefined; } return ` Recover ${iconExternalLink} `; } } ;// CONCATENATED MODULE: ./src/trackers/metagamerscore.ts class MetaGamerScore extends Tracker { constructor() { super(...arguments); this.name = 'MetaGamerScore'; this.signInLink = 'https://metagamerscore.com/users/sign_in?utm_campaign=userscript'; } getProfileURL() { return `https://metagamerscore.com/steam/id/${this.profileData.steamid}?utm_campaign=userscript`; } getGameURL(game) { if (!game.name) { return undefined; } if (!game.mgsId) { return `https://metagamerscore.com/my_games?user=${this.userID}&filter=${encodeURIComponent(game.name)}&utm_campaign=userscript`; } const urlFriendlyName = trim(game.name.toLowerCase().replace(/\W+/g, '-'), '-'); return `https://metagamerscore.com/game/${game.mgsId}-${urlFriendlyName}?user=${this.userID}&utm_campaign=userscript`; } async getStartedGames() { const profileURL = this.getProfileURL(); const redirectURL = await getRedirectURL(profileURL); this.userID = new URL(redirectURL).pathname.split('/')[2]; let mgsGames; try { const response = await getJSON(`https://metagamerscore.com/api/mygames/steam/${this.userID}?utm_campaign=userscript`); if (Array.isArray(response)) { mgsGames = response; } else { return { games: [], error: response.error }; } } catch { return { games: [], signIn: true }; } const games = mgsGames.map(game => { const unlocked = game.earned + game.earnedUnobtainable; const total = game.total + game.totalUnobtainable; return { appid: parseInt(game.appid), mgsId: game.mgs_id, name: game.name, unlocked, total, isPerfect: total !== 0 && unlocked >= total, isCompleted: game.total !== 0 && game.earned >= game.total, isCounted: game.total !== 0 && game.earned >= game.total, isTrusted: undefined, }; }); return { games }; } getRecoverLinkHTML(isOwnProfile) { if (!isOwnProfile) { return undefined; } return ` Recover ${iconExternalLink} `; } } ;// CONCATENATED MODULE: ./src/trackers/steam.ts class Steam extends Tracker { constructor() { super(...arguments); this.name = 'Steam'; } getProfileURL() { return this.profileData.url.substring(0, this.profileData.url.length - 1); } getGameURL(game) { return `${this.getProfileURL()}/stats/${game.appid}?tab=achievements`; } async getStartedGames(_formData, appids) { const response = await fetch(`${this.getProfileURL()}/edit/showcases`, { credentials: 'same-origin' }); const doc = domParser.parseFromString(await response.text(), 'text/html'); const achievementShowcaseGames = JSON.parse(doc.getElementById('showcase_preview_17').innerHTML.match(/g_rgAchievementShowcaseGamesWithAchievements = (.*);/)[1]); const completionistShowcaseGames = JSON.parse(doc.getElementById('showcase_preview_23').innerHTML.match(/g_rgAchievementsCompletionshipShowcasePerfectGames = (.*);/)[1]); appids = [...new Set([ ...appids, ...achievementShowcaseGames.map(game => game.appid), ...completionistShowcaseGames.map(game => game.appid), ])]; const games = []; const iterator = this.getStartedGamesIterator(appids, achievementShowcaseGames, completionistShowcaseGames, games); const pool = new (external_PromisePool_default())(iterator, 6); await pool.start(); return { games }; } *getStartedGamesIterator(appids, achievementShowcaseGames, completionistShowcaseGames, games) { for (const appid of appids) { yield this.getStartedGame(appid, achievementShowcaseGames, completionistShowcaseGames).then(game => games.push(game)); } } async getStartedGame(appid, achievementShowcaseGames, completionistShowcaseGames) { var _a; if (appid === 247750) { const name = 'The Stanley Parable Demo'; const unlocked = await this.getAchievementShowcaseCount(appid); const isPerfect = unlocked === 1; return { appid, name, unlocked, total: 1, isPerfect, isCompleted: isPerfect, isCounted: isPerfect, isTrusted: true }; } const completionistShowcaseGame = completionistShowcaseGames.find(game => game.appid === appid); let { unlocked, total } = await this.getFavoriteGameShowcaseCounts(appid); total !== null && total !== void 0 ? total : (total = completionistShowcaseGame === null || completionistShowcaseGame === void 0 ? void 0 : completionistShowcaseGame.num_achievements); if (unlocked === undefined) { unlocked = await this.getAchievementShowcaseCount(appid); if (unlocked === 9999 && completionistShowcaseGame !== undefined) { unlocked = completionistShowcaseGame.num_achievements; } } const achievementShowcaseGame = achievementShowcaseGames.find(game => game.appid === appid); const name = (_a = achievementShowcaseGame === null || achievementShowcaseGame === void 0 ? void 0 : achievementShowcaseGame.name) !== null && _a !== void 0 ? _a : completionistShowcaseGame === null || completionistShowcaseGame === void 0 ? void 0 : completionistShowcaseGame.name; const isPerfect = total !== undefined ? unlocked >= total : undefined; const isCompleted = isPerfect ? true : undefined; const isCounted = completionistShowcaseGame !== undefined; const isTrusted = achievementShowcaseGame !== undefined; return { appid, name, unlocked, total, isPerfect, isCompleted, isCounted, isTrusted }; } async getFavoriteGameShowcaseCounts(appid) { const url = `${this.getProfileURL()}/ajaxpreviewshowcase`; const body = new FormData(); body.append('customization_type', '6'); body.append('sessionid', unsafeWindow.g_sessionID); body.append('slot_data', `{"0":{"appid":${appid}}}`); const response = await retry(() => { console.debug(`POST ${url}`); return fetch(url, { method: 'POST', body, credentials: 'same-origin' }); }); const text = await response.text(); const template = document.createElement('template'); template.innerHTML = text.replace(/src="[^"]+"/g, ''); const ellipsis = template.content.querySelector('.ellipsis'); let unlocked = undefined; let total = undefined; if (ellipsis !== null) { const split = ellipsis.textContent.split(/\D+/).filter(s => s !== ''); unlocked = parseInt(split[0]); total = parseInt(split[1]); } return { unlocked, total }; } async getAchievementShowcaseCount(appid) { var _a; const url = `${this.getProfileURL()}/ajaxgetachievementsforgame/${appid}`; const response = await retry(() => { console.debug(`GET ${url}`); return fetch(url); }); const text = await response.text(); const template = document.createElement('template'); template.innerHTML = text; const list = template.content.querySelector('.achievement_list'); if (list === null) { const h3 = template.content.querySelector('h3'); throw new Error((_a = h3 === null || h3 === void 0 ? void 0 : h3.textContent) !== null && _a !== void 0 ? _a : `Response is invalid: ${url}`); } return list.querySelectorAll('.achievement_list_item').length; } getRecoverLinkHTML() { return undefined; } validate(game) { const messages = []; if (game.isCounted === true) { if (game.isPerfect === false) { messages.push('counted but not perfect on Steam'); } if (game.isTrusted === false) { messages.push('counted but not trusted on Steam'); } } else { if (game.isPerfect === true && game.isTrusted === true) { messages.push('perfect & trusted but not counted on Steam'); } } return messages; } } ;// CONCATENATED MODULE: ./src/trackers/steam-hunters.ts class SteamHunters extends Tracker { constructor() { super(...arguments); this.name = 'Steam Hunters'; } getProfileURL() { return `https://steamhunters.com/profiles/${this.profileData.steamid}?utm_campaign=userscript`; } getGameURL(game) { return `https://steamhunters.com/profiles/${this.profileData.steamid}/apps/${game.appid}?utm_campaign=userscript`; } async getStartedGames() { const licenses = await getJSON(`https://steamhunters.com/api/steam-users/${this.profileData.steamid}/licenses?state=started&utm_campaign=userscript`); const games = Object.entries(licenses).map(([appid, license]) => ({ appid: parseInt(appid), name: license.app.name, unlocked: license.achievementUnlockCount, total: license.app.achievementCount, isPerfect: license.achievementUnlockCount >= license.app.achievementCount, isCompleted: license.isCompleted, isCounted: license.isCompleted && !license.isInvalid, isTrusted: !license.app.isRestricted, })); return { games }; } getRecoverLinkHTML(_isOwnProfile, games) { return ` `; } } ;// CONCATENATED MODULE: ./src/index.ts var _a; const profileData = (_a = unsafeWindow.g_rgProfileData) !== null && _a !== void 0 ? _a : {}; const isOwnProfile = unsafeWindow.g_steamID === profileData.steamid; const trackers = [ new Completionist(profileData), new SteamHunters(profileData), new AStats(profileData), new Exophase(profileData), new MetaGamerScore(profileData), ]; window.addEventListener('load', async () => { const container = document.querySelector('.profile_rightcol'); if (container === null) { return; } const style = document.createElement('style'); style.innerHTML = ` .atc button { border: none; } .atc button:disabled { pointer-events: none; } .atc button.whiteLink { background-color: transparent; font-size: inherit; padding: 0; } .atc form { display: inline; } .atc input[type="checkbox"] { vertical-align: top; } .atc .atc_help { cursor: help; } .atc .atc_profile_achievement_tracker_links { margin-bottom: 40px; } .atc .commentthread_entry_quotebox { font-size: 11px; height: 48px; min-height: 48px; overflow-y: scroll; resize: vertical; } @media screen and (max-width: 910px) { .atc .atc_profile_achievement_tracker_links { margin-top: -4px; margin-bottom: 12px; padding-bottom: 4px; } .atc .profile_count_link { float: none !important; height: auto !important; width: auto !important; } }`; document.head.appendChild(style); const template = document.createElement('template'); template.innerHTML = `