NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Achievement Tracker Comparer // @description Compare achievements between AStats, completionist.me, Exophase, MetaGamerScore, Steam Hunters, TrueSteamAchievements and Steam Community profiles. // @version 1.4.3 // @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 // @connect truesteamachievements.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: ./src/utils/utils.ts const iconExternalLink = '<img src="https://community.cloudflare.steamstatic.com/public/images/skin_1/iconExternalLink.gif?utm_campaign=userscript" alt="" aria-hidden="true" />'; 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: 'HEAD', 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://astats.astats.nl/astats/User_Info.php?steamID64=${this.profileData.steamid}&utm_campaign=userscript`; } getGameURL(game) { return `https://astats.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://astats.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 PromisePool(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 ` <form method="post" action="https://completionist.me/steam/recover/profile?utm_campaign=userscript" target="_blank"> <input type="hidden" name="app_ids" value="${games.map(game => game.appid)}"> <input type="hidden" name="profile_id" value="${this.profileData.steamid}"> <button type="submit" class="whiteLink"> Recover ${iconExternalLink} </button> </form>`; } } ;// 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 ` <a class="whiteLink" href="https://www.exophase.com/account/?utm_campaign=userscript#tools" target="_blank"> Recover ${iconExternalLink} </a>`; } } ;// 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 ` <a class="whiteLink" href="https://metagamerscore.com/steam/index_reconcile?utm_campaign=userscript" target="_blank"> Recover ${iconExternalLink} </a>`; } } ;// 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 PromisePool(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 ` <form method="post" action="https://steamhunters.com/profiles/${this.profileData.steamid}/recover?utm_campaign=userscript" target="_blank"> <input type="hidden" name="version" value="2.0"> <input type="hidden" name="apps" value="${external_he_default().escape(JSON.stringify(games))}"> <button type="submit" class="whiteLink"> Recover ${iconExternalLink} </button> </form>`; } } ;// CONCATENATED MODULE: ./src/trackers/truesteamachievements.ts class TrueSteamAchievements extends Tracker { constructor() { super(...arguments); this.name = 'TrueSteamAchievements'; } getProfileURL() { return this.profileUrl; } getGameURL(game) { if (!game.tsaUrlName) { return `https://truesteamachievements.com/steamgame/${game.appid}?utm_campaign=userscript`; } return `https://truesteamachievements.com/game/${game.tsaUrlName}/achievements?gamerid=${this.gamerID}&utm_campaign=userscript`; } async getStartedGames(formData) { const games = []; const prefix = 'https://truesteamachievements.com/gamer/'; let profileUrl = `${formData.get('tsaProfileUrl')}/games?utm_campaign=userscript`; if (!profileUrl.startsWith(prefix)) { profileUrl = prefix + profileUrl; } this.profileUrl = profileUrl; const html = await getHTML(profileUrl); this.gamerID = /gamerid=(\d+)/.exec(html)[1]; const gamesList = document.createElement('div'); const params = `oGamerGamesList|oGamerGamesList_ItemsPerPage=99999999&txtGamerID=${this.gamerID}`; const gamesListURL = `${profileUrl}&executeformfunction&function=AjaxList¶ms=${encodeURIComponent(params)}`; gamesList.innerHTML = await getHTML(gamesListURL); const rows = gamesList.querySelectorAll('tr'); for (let i = 1; i < rows.length - 1; i++) { const row = rows[i]; const anchor = row.querySelector('a[href*="gameid="]'); const counts = row.cells[2].textContent.split(' of ').map(s => parseInt(s.replace(/,/g, ''))); const unlocked = counts[0]; const total = counts[1]; const isPerfect = unlocked >= total; games.push({ appid: 0, tsaGameId: parseInt(new URL(anchor.href).searchParams.get('gameid')), tsaUrlName: /game\/([^/]+)/.exec(row.querySelector('a').href)[1], name: row.cells[1].textContent, unlocked, total, isPerfect, isCompleted: isPerfect ? true : undefined, isCounted: isPerfect, isTrusted: undefined, }); } const iterator = this.setAppIdsIterator(games); const pool = new PromisePool(iterator, 6); await pool.start(); const unsetGames = games.filter(game => game.appid === 0); if (unsetGames.length !== 0) { const iterator = this.setAppIdsSlowIterator(unsetGames); const pool = new PromisePool(iterator, 6); await pool.start(); } return { games: games.filter(game => game.appid !== 0) }; } *setAppIdsIterator(games) { for (let i = 0; i < games.length; i += 100) { const batch = games.slice(i, i + 100); const url = `https://steamhunters.com/api/apps/app-ids?${batch.map(game => `tsaGameIds=${game.tsaGameId}`).join('&')}&utm_campaign=userscript`; yield getJSON(url) .then(response => { var _a; for (const game of batch) { game.appid = (_a = response[game.tsaGameId]) !== null && _a !== void 0 ? _a : 0; } }); } } *setAppIdsSlowIterator(games) { for (const game of games) { const url = this.getGameURL(game); yield getHTML(url) .then(response => { const match = /app\/(\d+)/.exec(response); if (match !== null) { game.appid = parseInt(match[1]); } }); } } getRecoverLinkHTML() { return undefined; } } ;// 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), new TrueSteamAchievements(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_tsa_profile_url { box-shadow: 1px 1px 1px rgb(255 255 255 / 10%); font-size: x-small; margin-top: 3px; padding: 3px; width: calc(100% - 6px); } .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; } .atc .profile_comment_area { margin-top: 0; } @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 = ` <div class="atc"> <div class="responsive_count_link_area"> <div class="atc_profile_achievement_tracker_links"> <div class="profile_count_link"> <form> <div class="ellipsis"> <a> <span class="count_link_label">Achievement Trackers</span> <span class="profile_count_link_total">${trackers.length}</span> </a> </div> ${trackers.sort((a, b) => a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1).map(tracker => `<div> <label> <input type="checkbox" name="trackerName" value="${tracker.name}" ${tracker.ownProfileOnly && !isOwnProfile ? 'disabled' : ''} /> ${tracker.name} </label> ${tracker.getProfileURL() === undefined ? '' : `<a class="whiteLink" href="${tracker.getProfileURL()}" target="_blank"> ${iconExternalLink} </a>`} ${tracker.signInLink ? '<small class="atc_help" title="Sign-in required" aria-describedby="atc_sign_in_required">1</small>' : ''} ${tracker.ownProfileOnly ? '<small class="atc_help" title="Own profile only" aria-describedby="atc_own_profile_only">2</small>' : ''} </div>`).join('')} <input type="text" name="tsaProfileUrl" id="atc_tsa_profile_url" placeholder="Enter the TSA profile URL..." required hidden pattern="[^/?#]+|https://truesteamachievements\\.com/gamer/[^/?#]+" /> <p ${isOwnProfile ? '' : 'hidden'}> <label> <input type="checkbox" name="trackerName" value="Steam" /> Steam profile showcases (slow) </label> </p> <p> <small id="atc_sign_in_required"> 1. Sign-in required </small> <small id="atc_own_profile_only"> 2. Own profile only </small> </p> <p> <button type="button" class="btn_profile_action btn_medium" id="atc_btn" disabled> <span>Find Differences</span> </button> <span id="atc_counter">0</span> selected </p> </form> <div id="atc_output"></div> </div> </div> </div> </div>`; const node = document.importNode(template.content, true); const form = node.querySelector('form'); const checkboxes = [...form.querySelectorAll('input[type="checkbox"]')]; const button = form.querySelector('button#atc_btn'); const buttonSpan = button.querySelector('span'); const counter = form.querySelector('#atc_counter'); const output = node.querySelector('#atc_output'); const tsaCheckbox = checkboxes.find(x => x.value === 'TrueSteamAchievements'); const tsaProfileUrlInput = form.querySelector('#atc_tsa_profile_url'); const tsaProfileUrlKey = profileData.steamid + '/tsaProfileUrl'; tsaCheckbox.addEventListener('input', () => { if (tsaCheckbox.checked) { tsaProfileUrlInput.hidden = false; } else { tsaProfileUrlInput.hidden = true; } }); const updateForm = async () => { const formData = new FormData(form); const trackerNames = formData.getAll('trackerName'); button.disabled = trackerNames.length < 2 || !tsaProfileUrlInput.hidden && !tsaProfileUrlInput.validity.valid; counter.textContent = trackerNames.length.toString(); try { await GM.setValue(tsaProfileUrlKey, tsaProfileUrlInput.value); } catch (e) { console.error(e); } }; form.addEventListener('change', updateForm); form.addEventListener('input', updateForm); button.addEventListener('click', async () => { const formData = new FormData(form); button.disabled = true; buttonSpan.textContent = 'Loading...'; for (const checkbox of checkboxes) { checkbox.dataset['disabled'] = checkbox.disabled.toString(); checkbox.disabled = true; } try { await findDifferences(formData, output); } catch (e) { console.error(e); } buttonSpan.textContent = 'Find Differences'; button.disabled = false; for (const checkbox of checkboxes) { checkbox.disabled = checkbox.dataset['disabled'] === 'true'; } }); container.appendChild(node); try { tsaProfileUrlInput.value = await GM.getValue(tsaProfileUrlKey, ''); } catch (e) { console.error(e); } }); async function findDifferences(formData, output) { var _a, _b, _c; output.innerHTML = ''; const trackerNames = formData.getAll('trackerName'); const results = await Promise.all(trackers .filter(tracker => trackerNames.includes(tracker.name)) .map(async (tracker) => ({ tracker, ...await tracker.getStartedGames(formData, []) }))); if (trackerNames.includes('Steam')) { const appids = new Set(); results.forEach(result => result.games.forEach(game => appids.add(game.appid))); const tracker = new Steam(profileData); results.push({ tracker, ...await tracker.getStartedGames(formData, [...appids]) }); } const numberOfTrackersWithGames = results.filter(result => result.games.length !== 0).length; const mismatchedAppids = groupBy(results.flatMap(r => r.games), g => g.appid) .filter(group => { if (group.length !== numberOfTrackersWithGames) { return true; } const [game, ...games] = group; return games.some(g => g.unlocked !== game.unlocked); }) .map(group => group.key); const mismatchedGames = []; const steamResult = results.find(result => result.tracker instanceof Steam); function* getMismatchedGamesIterator() { for (const appid of mismatchedAppids) { yield getMismatchedGame(appid).then(game => mismatchedGames.push(game)); } } async function getMismatchedGame(appid) { var _a; let game = steamResult === null || steamResult === void 0 ? void 0 : steamResult.games.find(game => game.appid === appid); if (game !== undefined) { return game; } let doc = await getDocument(`${unsafeWindow.g_rgProfileData.url}stats/${appid}/achievements?l=english`, { headers: { 'X-ValveUserAgent': 'panorama' } }); const match = doc.body.innerHTML.match(/g_rgAchievements = ({.*});/); if (match !== null) { const g_rgAchievements = JSON.parse(match[1]); const isPerfect = g_rgAchievements.totalClosed >= g_rgAchievements.total; return { appid, unlocked: g_rgAchievements.totalClosed, total: g_rgAchievements.total, name: (_a = doc.body.innerHTML.match(/'SetContentTitle', '(.*) Achievements'/)) === null || _a === void 0 ? void 0 : _a[1], isPerfect, isCompleted: isPerfect ? true : undefined, isCounted: isPerfect, isTrusted: undefined, }; } doc = await getDocument(`https://steamcommunity.com/stats/${appid}/achievements`); const total = doc.querySelectorAll('.achieveRow').length; const games = results.flatMap(r => r.games).filter(game => game.appid === appid); game = games.find(game => game.total === total); if (game !== undefined) { return game; } game = games[0]; const unlocked = Math.min(game.unlocked, total); const isPerfect = unlocked >= total && unlocked !== 0; return { appid, name: game.name, unlocked, total, isPerfect, isCompleted: isPerfect ? true : undefined, isCounted: isPerfect, isTrusted: undefined, }; } const iterator = getMismatchedGamesIterator(); const pool = new PromisePool(iterator, 6); await pool.start(); output.innerHTML = ` <div class="profile_comment_area"> ${results.sort((a, b) => a.tracker.name.toUpperCase() < b.tracker.name.toUpperCase() ? -1 : 1).filter(result => result.tracker.name !== 'Steam').map(result => { var _a; let html = ` <div style="margin-top: 1em;"> <a class="whiteLink" href="${result.tracker.getProfileURL()}" target="_blank"> ${result.tracker.name} ${iconExternalLink} </a> </div>`; if (result.signIn) { html += ` <span style="color: #b33b32;"> ✖ <a class="whiteLink" href="${result.tracker.signInLink}" target="_blank"> Sign in ${result.signInAs ? `as ${external_he_default().escape(result.signInAs)}` : ''} ${iconExternalLink} </a> </span>`; } else if (result.error || result.games.length === 0) { html += ` <span style="color: #b33b32;"> ✖ ${(_a = result.error) !== null && _a !== void 0 ? _a : 'No achievements found'} </span>`; } else { const mismatchGames = mismatchedGames .map(sourceGame => ({ sourceGame, targetGame: result.games.find(game => game.appid === sourceGame.appid) })) .map(x => ({ sourceGame: x.sourceGame, targetGame: x.targetGame, game: merge(x.sourceGame, x.targetGame) })) .filter(x => { var _a; return x.sourceGame.unlocked !== ((_a = x.targetGame) === null || _a === void 0 ? void 0 : _a.unlocked); }); const gamesWithMissingAchievements = mismatchGames.filter(x => { var _a, _b; return x.sourceGame.unlocked > ((_b = (_a = x.targetGame) === null || _a === void 0 ? void 0 : _a.unlocked) !== null && _b !== void 0 ? _b : 0); }); const gamesWithRemovedAchievements = mismatchGames.filter(x => { var _a, _b; return x.sourceGame.unlocked < ((_b = (_a = x.targetGame) === null || _a === void 0 ? void 0 : _a.unlocked) !== null && _b !== void 0 ? _b : 0); }); if (gamesWithMissingAchievements.length === 0 && gamesWithRemovedAchievements.length === 0) { html += ` <span style="color: #90ba3c;"> ✔ Up to date </span>`; } else { if (gamesWithMissingAchievements.length !== 0) { const missingAchievementsSum = gamesWithMissingAchievements .map(x => { var _a, _b; return x.sourceGame.unlocked - ((_b = (_a = x.targetGame) === null || _a === void 0 ? void 0 : _a.unlocked) !== null && _b !== void 0 ? _b : 0); }) .reduce((a, b) => a + b); const namesHTML = gamesWithMissingAchievements .map(x => { var _a; return ({ name: external_he_default().escape((_a = x.game.name) !== null && _a !== void 0 ? _a : `Unknown App ${x.game.appid}`), url: result.tracker.getGameURL(x.game) }); }) .sort((a, b) => a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1) .map(x => x.url !== undefined ? `<a class="whiteLink" href="${x.url}" target="_blank">${x.name}</a>` : x.name) .join(' • '); const jsonGames = gamesWithMissingAchievements.map(x => ({ appid: x.sourceGame.appid, unlocked: x.sourceGame.unlocked, total: x.sourceGame.total })); const recoverLinkHTML = result.tracker.getRecoverLinkHTML(isOwnProfile, jsonGames); html += ` <span style="color: #b33b32;"> ✖ ${missingAchievementsSum.toLocaleString()} missing achievement${missingAchievementsSum !== 1 ? 's' : ''} in ${gamesWithMissingAchievements.length.toLocaleString()} game${gamesWithMissingAchievements.length !== 1 ? 's' : ''} </span> <div class="commentthread_entry_quotebox"> ${namesHTML} </div> <div style="font-size: 11px; margin-bottom: 1em;"> <a class="whiteLink" data-copy="${gamesWithMissingAchievements.map(g => g.sourceGame.appid)}"> Copy App IDs </a> • <a class="whiteLink" data-copy="${external_he_default().escape(JSON.stringify({ version: '2.0', apps: jsonGames }))}"> Copy JSON </a> ${recoverLinkHTML === undefined ? '' : ` • ${recoverLinkHTML} `} </div>`; } if (gamesWithRemovedAchievements.length !== 0) { const removedAchievementsSum = gamesWithRemovedAchievements .map(x => { var _a, _b; return ((_b = (_a = x.targetGame) === null || _a === void 0 ? void 0 : _a.unlocked) !== null && _b !== void 0 ? _b : 0) - x.sourceGame.unlocked; }) .reduce((a, b) => a + b); const namesHTML = gamesWithRemovedAchievements .map(x => { var _a; return ({ name: external_he_default().escape((_a = x.game.name) !== null && _a !== void 0 ? _a : `Unknown App ${x.game.appid}`), url: result.tracker.getGameURL(x.game) }); }) .sort((a, b) => a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1) .map(x => x.url !== undefined ? `<a class="whiteLink" href="${x.url}" target="_blank">${x.name}</a>` : x.name) .join(' • '); const jsonGames = gamesWithMissingAchievements.map(x => ({ appid: x.sourceGame.appid, unlocked: x.sourceGame.unlocked, total: x.sourceGame.total })); html += ` <span style="color: #b33b32;"> ✖ ${removedAchievementsSum.toLocaleString()} removed achievement${removedAchievementsSum !== 1 ? 's' : ''} in ${gamesWithRemovedAchievements.length.toLocaleString()} game${gamesWithRemovedAchievements.length !== 1 ? 's' : ''} </span> <div class="commentthread_entry_quotebox"> ${namesHTML} </div> <div style="font-size: 11px;"> <a class="whiteLink" data-copy="${gamesWithRemovedAchievements.map(g => g.sourceGame.appid)}"> Copy App IDs </a> • <a class="whiteLink" data-copy="${external_he_default().escape(JSON.stringify({ version: '2.0', apps: jsonGames }))}"> Copy JSON </a> </div>`; } } } return html; }).join('')} </div>`; for (const anchor of output.querySelectorAll('a[data-copy]')) { anchor.addEventListener('click', async function () { await navigator.clipboard.writeText(this.dataset['copy']); alert('Copied to clipboard.'); }); } for (let sourceIndex = 0; sourceIndex < results.length; sourceIndex++) { const source = results[sourceIndex]; const validationErrors = []; for (const game of source.games) { const messages = source.tracker.validate(game); if (messages.length !== 0) { validationErrors.push({ name: (_a = game.name) !== null && _a !== void 0 ? _a : `Unknown App ${game.appid}`, messages: messages.join(', ') }); } } if (validationErrors.length !== 0) { // TODO: display validation errors on screen instead of logging to console const csv = `Name,Validation Errors\n` + validationErrors.map(e => `${escapeCSV(e.name)},${e.messages}`).join('\n'); console.info(`Validation errors on ${source.tracker.name}:`); if (validationErrors.length <= 100) { console.table(validationErrors); console.debug(csv); } else { console.info(csv); } } for (let targetIndex = sourceIndex + 1; targetIndex < results.length; targetIndex++) { const target = results[targetIndex]; // join games from both trackers into map const gamesMap = new Map(); for (const game of source.games) { gamesMap.set(game.appid, { source: game }); } for (const game of target.games) { let value = gamesMap.get(game.appid); if (value === undefined) { value = {}; gamesMap.set(game.appid, value); } value.target = game; } // convert map into array const games = [...gamesMap].map(([appid, game]) => { var _a, _b, _c, _d; return ({ appid: appid, name: (_d = (_b = (_a = game.source) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : (_c = game.target) === null || _c === void 0 ? void 0 : _c.name) !== null && _d !== void 0 ? _d : `Unknown App ${appid}`, source: game.source, target: game.target, }); }); const differences = []; for (const game of games) { const messages = []; if (game.source === undefined) { messages.push(`missing on ${source.tracker.name}`); } else if (game.target === undefined) { messages.push(`missing on ${target.tracker.name}`); } else { if (game.source.unlocked > game.target.unlocked) { messages.push(`+${game.source.unlocked - game.target.unlocked} unlocked on ${source.tracker.name}`); } else if (game.target.unlocked > game.source.unlocked) { messages.push(`+${game.target.unlocked - game.source.unlocked} unlocked on ${target.tracker.name}`); } else if (game.source.isPerfect === true && game.target.isPerfect === false) { messages.push(`perfect on ${source.tracker.name} but not on ${target.tracker.name}`); } else if (game.target.isPerfect === true && game.source.isPerfect === false) { messages.push(`perfect on ${target.tracker.name} but not on ${source.tracker.name}`); } else if (game.source.isCompleted === true && game.target.isCompleted === false) { messages.push(`completed on ${source.tracker.name} but not on ${target.tracker.name}`); } else if (game.target.isCompleted === true && game.source.isCompleted === false) { messages.push(`completed on ${target.tracker.name} but not on ${source.tracker.name}`); } else if (game.source.isCounted === true && game.target.isCounted === false) { messages.push(`counts on ${source.tracker.name} but not on ${target.tracker.name}`); } else if (game.target.isCounted === true && game.source.isCounted === false) { messages.push(`counts on ${target.tracker.name} but not on ${source.tracker.name}`); } if (game.source.isTrusted === true && game.target.isTrusted === false) { messages.push(`trusted on ${source.tracker.name} but not on ${target.tracker.name}`); } else if (game.target.isTrusted === true && game.source.isTrusted === false) { messages.push(`trusted on ${target.tracker.name} but not on ${source.tracker.name}`); } } if (messages.length !== 0) { differences.push({ appid: game.appid, name: game.name, messages: messages.join('; '), sourceURL: source.tracker.getGameURL((_b = game.source) !== null && _b !== void 0 ? _b : game.target), targetURL: target.tracker.getGameURL((_c = game.target) !== null && _c !== void 0 ? _c : game.source), }); } } if (differences.length === 0) { console.info(`No differences between ${source.tracker.name} and ${target.tracker.name}.`); continue; } differences.sort((a, b) => a.appid - b.appid); // TODO: display differences on screen instead of logging to console const csv = `App ID,Name,Differences,${source.tracker.name} URL,${target.tracker.name} URL\n` + differences.map(d => `${d.appid},${escapeCSV(d.name)},${d.messages},${d.sourceURL},${d.targetURL}`).join('\n'); console.info(`Differences between ${source.tracker.name} and ${target.tracker.name}:`); if (differences.length <= 100) { console.table(differences); console.debug(csv); } else { console.info(csv); } } } } function escapeCSV(string) { if (string.indexOf('"') !== -1) { return `"${string.replace(/"/g, '""')}"`; } else if (string.indexOf(',') !== -1) { return `"${string}"`; } return string; } /******/ })() ;