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 //////////////////////////////
//////////////////////////////////////////////////////////////////////////