NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // ==UserLibrary== // @name gazelleApiLib // @namespace https://openuserjs.org/users/Anakunda // @version 1.12.10 // @author Anakunda // @license GPL-3.0-or-later // @copyright 2021, Anakunda (https://openuserjs.org/users/Anakunda) // @exclude * // ==/UserScript== // ==/UserLibrary== 'use strict'; function getConfigValue(name, _default) { if (!name) throw 'Invalid argument'; else if (name in localStorage) try { var value = eval(localStorage.getItem(name)); if (!isNaN(value)) return value; } catch(e) { console.warn(e) } const mangledName = name.replace(/_+(\w)/ig, (m, ch) => ch.toUpperCase()); if (mangledName != name && mangledName in localStorage) try { if (!isNaN(value = eval(localStorage.getItem(mangledName)))) return value; } catch(e) { console.warn(e) } return typeof GM_getValue == 'function' ? GM_getValue(name, _default) : _default; } const ajaxRequestsCache = new Map; if (typeof GM_getValue == 'function') switch (document.domain) { case 'redacted.ch': case 'redacted.sh': var ajaxApiKey = GM_getValue('redacted_api_key'); if (ajaxApiKey === undefined && typeof GM_setValue == 'function') GM_setValue('redacted_api_key', ''); break; } if (!ajaxApiKey) for (let key of ['ajaxApiKey', 'ajax_api_key', 'apiKey', 'api_key']) if (key in window.localStorage && (ajaxApiKey = window.localStorage.getItem(key))) break; let gazelleApiQuota = getConfigValue('gazelle_api_quota', ajaxApiKey ? 10 : 5), gazelleApiTimeWindow = getConfigValue('gazelle_api_timewindow', 10), gazelleApiTimeout = getConfigValue('gazelle_api_timeout', 90), gazelleApiMaxRetries = getConfigValue('gazelle_api_max_retries', 100), errorRetryDelay = getConfigValue('gazelle_api_retry_delay', 1000), shoutApiRateViolations = getConfigValue('gazelle_api_alert_rate_exceeds', false); let gazelleApiMutex = createMutex(), ajaxApiLogger, ajaxCache, domStorageLimitReached = false, ajaxLastCachedSuccess; const setAjaxApiLogger = callback => { ajaxApiLogger = typeof callback == 'function' ? callback : undefined }; function queryAjaxAPI(action, params, postData, useCache = 0) { if (!action) throw 'Action is missing'; params = new URLSearchParams(Object.assign({ action: action }, typeof params == 'object' ? params : null)); if (useCache >= 1 && !postData) { if (ajaxRequestsCache.has(params.toString())) return ajaxRequestsCache.get(params.toString()); if (useCache >= 2) { if (!ajaxCache && 'ajaxCache' in sessionStorage) try { ajaxCache = new Map(JSON.parse(sessionStorage.getItem('ajaxCache'))); } catch(e) { console.warn('AJAX static cache is invalid:', e) } if (!ajaxCache) ajaxCache = new Map; if (ajaxCache.has(params.toString())) return Promise.resolve(ajaxCache.get(params.toString())); } } const recoverableHttpErrors = [ 0, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530, ]; const worker = new Promise(function(resolve, reject) { const xhr = new XMLHttpRequest, url = 'ajax.php?' + params.toString(); if (postData && !(postData instanceof URLSearchParams) && !(postData instanceof FormData)) switch (typeof postData) { case 'object': postData = new URLSearchParams(postData); break; case 'string': try { postData = new URLSearchParams(JSON.parse(postData)) } catch(e) { } break; } let retryCounter = 0; const canRetry = () => !(gazelleApiMaxRetries > 0) || ++retryCounter < gazelleApiMaxRetries; const lockedUpdate = updater => new Promise(function(resolve) { gazelleApiMutex.lock(function() { if ('ajaxTimeFrame' in window.localStorage) try { var apiTimeFrame = JSON.parse(window.localStorage.getItem('ajaxTimeFrame')); } catch(e) { apiTimeFrame = { }; console.warn(e); } else apiTimeFrame = { }; if (typeof updater == 'function') { const retVal = updater(apiTimeFrame); if (retVal) { if (typeof retVal == 'object') apiTimeFrame = retVal; window.localStorage.setItem('ajaxTimeFrame', JSON.stringify(apiTimeFrame)); } } gazelleApiMutex.unlock(); resolve(apiTimeFrame); }); }); const request = (counter = 1) => lockedUpdate(function(apiTimeFrame) { if (!('expiresAt' in apiTimeFrame) || Date.now() >= apiTimeFrame.expiresAt) return { expiresAt: Date.now() + (gazelleApiTimeout > 0 ? gazelleApiTimeout : 120) * 1000, requestCounter: 1, }; else ++apiTimeFrame.requestCounter; return true; }).then(function(apiTimeFrame) { const timeStamps = [Date.now()]; if (apiTimeFrame.requestCounter <= gazelleApiQuota) { xhr.open(postData ? 'POST' : 'GET', url, true); if (action != 'preview') { xhr.responseType = 'json'; xhr.setRequestHeader('Accept', 'application/json'); } else { xhr.responseType = 'document'; xhr.setRequestHeader('Accept', 'text/html'); xhr.overrideMimeType('text/html'); } if (ajaxApiKey) xhr.setRequestHeader('Authorization', ajaxApiKey); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.onreadystatechange = function() { if (this.readyState < XMLHttpRequest.HEADERS_RECEIVED) return; let timeStamp = Date.now(); timeStamps.push(timeStamp); if (this.readyState < XMLHttpRequest.DONE || (timeStamp += gazelleApiTimeWindow * 1000) >= apiTimeFrame.expiresAt) return; apiTimeFrame.expiresAt = timeStamp; lockedUpdate(function(_apiTimeFrame) { if (_apiTimeFrame.expiresAt <= apiTimeFrame.expiresAt) return false; _apiTimeFrame.expiresAt = apiTimeFrame.expiresAt; return true; }); }; xhr.onload = function() { let response = this.response; if (this.status >= 200 && this.status < 400) { if (!response) { if ('responseText' in this && this.responseText) { response = new DOMParser().parseFromString(this.responseText, 'text/html'); let elem = response.body.querySelector('div#content div.header > h2'); if (elem != null && elem.textContent.trim() == 'Error' && (elem = response.body.querySelector('div#content div.header + div.box.pad')) != null) return reject('Error: ' + elem.textContent.trim()); } return reject('Malformed response (not JSON)'); } else if (action == 'preview') resolve(response.body); else if (response.status == 'success') { if (useCache >= 2 && !domStorageLimitReached && !postData) { ajaxCache.set(params.toString(), response.response); const serialized = JSON.stringify(Array.from(ajaxCache)); try { sessionStorage.setItem('ajaxCache', serialized); ajaxLastCachedSuccess = serialized; } catch(e) { console.warn(e, `(${serialized.length})`); if (/\b(?:NS_ERROR_DOM_QUOTA_REACHED)\b/.test(e) || e instanceof DOMException && e.name == 'QuotaExceededError') { domStorageLimitReached = true; if (ajaxLastCachedSuccess) { sessionStorage.setItem('ajaxCache', ajaxLastCachedSuccess); ajaxLastCachedSuccess = undefined; } } } } resolve(response.response); } else { console.warn('ajax.php says %s (%s): action=%s', response.error, response.status, action, apiTimeFrame, timeStamps[1] || timeStamps[0], retryCounter, counter); reject(response.error); } } else if (this.status == 404) { // status="failure" if (response.error == 'endpoint not found') reject(response.error); else reject('not found'); } else if (this.status == 429) { // status="failure", erros="rate limit exceeded" if (canRetry()) { setTimeout(request, gazelleApiTimeWindow * 1000, counter + 1); if (typeof ajaxApiLogger == 'function') ajaxApiLogger(action, apiTimeFrame, timeStamps[1] || timeStamps[0], counter); } else reject(defaultErrorHandler(this)); console.warn('ajax.php says %s (%s): action=%s', response.error, this.status, action, apiTimeFrame, timeStamps[1] || timeStamps[0], retryCounter, counter); if (shoutApiRateViolations) Promise.resolve(` ajax.php says: ${response.error} (HTTP/${this.status}) action=${action} timestamps=${timeStamps} expiresAt=${apiTimeFrame.expiresAt} counter=${counter} retryCounter=${retryCounter} `).then(alert); } else if (recoverableHttpErrors.includes(this.status) && canRetry()) { setTimeout(request, errorRetryDelay, counter + 1); defaultErrorHandler(this); } else reject(defaultErrorHandler(this)); }; xhr.onerror = function() { if (recoverableHttpErrors.includes(this.status) && canRetry()) { defaultErrorHandler(this); setTimeout(request, errorRetryDelay, counter + 1); } else reject(defaultErrorHandler(this)); }; xhr.ontimeout = function() { if (canRetry()) { defaultTimeoutHandler(this); request(counter + 1); } else reject(defaultTimeoutHandler(this)); }; if (gazelleApiTimeout > 0) xhr.timeout = gazelleApiTimeout * 1000; xhr.send(postData); } else { setTimeout(request, Math.min(apiTimeFrame.expiresAt - timeStamps[0], gazelleApiTimeWindow * 1000), counter + 1); if (typeof ajaxApiLogger == 'function') ajaxApiLogger(action, apiTimeFrame, timeStamps[0], counter); console.debug('AJAX API request quota exceeded: action=' + action, apiTimeFrame, timeStamps[0], retryCounter, counter); } }, alert); request(); }); if (useCache >= 1 && !postData) ajaxRequestsCache.set(params.toString(), worker); return worker; } const queryAjaxAPICached = (action, params, useStaticCache = false) => queryAjaxAPI(action, params, undefined, useStaticCache ? 2 : 1); const ajaxGetArtist = artistName => queryAjaxAPI('artist', { artistname: artistName }); const ajaxGetRequest = id => queryAjaxAPI('request', { id: id }); function defaultErrorHandler(response) { let reason = 'HTTP error ' + response.status; console.error(reason, response); if (response.status == 0) reason += '/' + response.readyState; let statusText = response.statusText; if (response.response) try { if (typeof response.response.error == 'string') statusText = response.response.error; } catch(e) { } if (statusText) reason += ' (' + statusText + ')'; return reason; } function defaultTimeoutHandler(response) { console.error('HTTP timeout:', response); let reason = 'HTTP timeout'; if (response.timeout) reason += ' (' + response.timeout + ')'; return reason; } for (const key of ['AJAX time frame']) if (key in localStorage) localStorage.removeItem(key); ////////////////////////////////////////////////////////////////////////// ////////////////////////////// SAFE PADDING ////////////////////////////// //////////////////////////////////////////////////////////////////////////