// ==UserScript==
// @name GreasyFork: download script button
// @description If you have a script manager and you want to download some script without installing it, this script will help
// @author Konf
// @version 2.3.1
// @namespace https://greasyfork.org/users/424058
// @icon https://i.imgur.com/OIGiyQc.png
// @match https://greasyfork.org/*/scripts/*
// @match https://sleazyfork.org/*/scripts/*
// @match https://web.archive.org/web/*/https://greasyfork.org/*/scripts/*
// @match https://web.archive.org/web/*/https://sleazyfork.org/*/scripts/*
// @compatible Chrome
// @compatible Opera
// @compatible Firefox
// @run-at document-end
// @grant GM_addStyle
// @noframes
// ==/UserScript==
/* jshint esversion: 8 */
(function() {
'use strict';
const i18n = {
download: 'download',
downloadWithoutInstalling: 'downloadWithoutInstalling',
failedToDownload: 'failedToDownload',
};
const translate = (function() {
const userLang = location.pathname.split('/')[1];
const strings = {
'en': {
[i18n.download]: 'Download ⇩',
[i18n.downloadWithoutInstalling]: 'Download without installing',
[i18n.failedToDownload]:
'Failed to download the script. There is might be more info in the browser console',
},
'ru': {
[i18n.download]: 'Скачать ⇩',
[i18n.downloadWithoutInstalling]: 'Скачать не устанавливая',
[i18n.failedToDownload]:
'Не удалось скачать скрипт. Больше информации может быть в консоли браузера',
},
'zh-CN': {
[i18n.download]: '下载 ⇩',
[i18n.downloadWithoutInstalling]: '下载此脚本',
[i18n.failedToDownload]: '无法下载此脚本',
},
};
return id => (strings[userLang] || strings.en)[id] || strings.en[id];
}());
const installArea = document.querySelector('div#install-area');
const installBtns = installArea?.querySelectorAll(':scope > a.install-link');
const installHelpLinks = document.querySelectorAll('a.install-help-link');
const suggestion = document.querySelector('div#script-feedback-suggestion');
const libraryRequire = document.querySelector('div#script-content > p > code');
const libraryVersion = document.querySelector(
'#script-stats > dd.script-show-version > span'
);
// if a script/style is detected
if (
installArea &&
(installBtns.length > 0) &&
(installBtns.length === installHelpLinks.length)
) {
for (let i = 0; i < installBtns.length; i++) {
mountScriptDownloadButton(installBtns[i], installArea, installHelpLinks[i]);
}
}
// or maybe a library
else if (suggestion && libraryRequire) {
mountLibraryDownloadButton(suggestion, libraryRequire, libraryVersion);
}
function mountScriptDownloadButton(
installBtn,
installArea,
installHelpLink,
) {
if (!installBtn.href) throw new Error('script href is not found');
// https://img.icons8.com/pastel-glyph/64/ffffff/download.png
// array to fold the string in a code editor
const downloadIconBase64 = [
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaX',
'HeAAAABmJLR0QA/wD/AP+gvaeTAAABgUlEQVR4nO3ZTU6DUAAE4HnEk+jWG3TrHV',
'wY3XoEt23cGleamtRtTbyPS3sCV0bXjptHRAIEsM/hZ76kCZRHGaZAGwDMzMzMbJ',
'6CasMkMwBncXYbQvhSZZEgecEf56ocmWrDAA4L00eqEMoCBsEFqAOouQB1ADUXoA',
'6g5gLUAdRcgDqAmgtQB1BzAeoAakkLIHlN8pPkDcnWd59IBpK3cd1VyoxJkfwo3P',
'V5KJZAcllYtiy8H+LY3HvKjKlPgU1h+hLAuulIiMvWcWzVZ4xL/Dbv+Nsjyax8BM',
'Sx96Wxm3jzdLwaSliVCpjezucqzmuSfKuZJkvXi0moORKqTOebL2tRwnR3PtdQwv',
'R3PldRgmznlc8GA4DTOPscQqAqy6x1+X8+6Ke5yfNxIE9z6/TN1+XCM4inuQ165Z',
'vHz04DF6AOoOYC1AHUXIA6gNpBz/UWJK/2muTvFn1W6lvASXyNXpdTYJcsxf69th',
'3Y5QjYAiCA485x/tcLgCd1CDMzMzMbum8+xtkWw6QCvwAAAABJRU5ErkJggg==',
].join('');
GM_addStyle([`
.GF-DSB__script-download-button {
position: relative;
padding: 8px 22px;
cursor: pointer;
border: none;
background: #0F750F;
transition: box-shadow 0.2s;
}
.GF-DSB__script-download-button:hover,
.GF-DSB__script-download-button:focus {
box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%), 0 6px 20px 0 rgb(0 0 0 / 19%);
}
.GF-DSB__script-download-icon {
position: absolute;
}
.GF-DSB__script-download-icon--download {
width: 30px;
height: 30px;
top: 4px;
left: 7px;
}
.GF-DSB__script-download-icon--loading,
.GF-DSB__script-download-icon--loading:after {
border-radius: 50%;
width: 16px;
height: 16px;
}
.GF-DSB__script-download-icon--loading {
top: 8px;
left: 11px;
border-top: 3px solid rgba(255, 255, 255, 0.2);
border-right: 3px solid rgba(255, 255, 255, 0.2);
border-bottom: 3px solid rgba(255, 255, 255, 0.2);
border-left: 3px solid #ffffff;
transform: translateZ(0);
object-position: -99999px;
animation: GF-DSB__script-download-loading-icon 1.1s infinite linear;
}
@keyframes GF-DSB__script-download-loading-icon {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`][0]);
const b = document.createElement('a');
const bIcon = document.createElement('img');
b.href = '#';
b.title = translate(i18n.downloadWithoutInstalling);
b.draggable = false;
b.className = 'GF-DSB__script-download-button';
bIcon.src = downloadIconBase64;
bIcon.draggable = false;
bIcon.className =
'GF-DSB__script-download-icon GF-DSB__script-download-icon--download';
installHelpLink.style.position = 'relative'; // shadows bugfix
b.appendChild(bIcon);
installArea.insertBefore(b, installHelpLink);
// against doubleclicks
let isFetchingAllowed = true;
async function clicksHandler(ev) {
ev.preventDefault();
setTimeout(() => b === document.activeElement && b.blur(), 250);
if (isFetchingAllowed === false) return;
isFetchingAllowed = false;
bIcon.className =
'GF-DSB__script-download-icon GF-DSB__script-download-icon--loading';
try {
let scriptName = installBtn.dataset.scriptName;
if (installBtn.dataset.scriptVersion) {
scriptName += ` ${installBtn.dataset.scriptVersion}`;
}
await downloadScript({
fileExt: `.user.${installBtn.dataset.installFormat || 'txt'}`,
href: installBtn.href,
name: scriptName,
});
} catch (e) {
console.error(e);
alert(`${translate(i18n.failedToDownload)}: \n${e}`);
} finally {
setTimeout(() => {
isFetchingAllowed = true;
bIcon.className =
'GF-DSB__script-download-icon GF-DSB__script-download-icon--download';
}, 300);
}
}
b.addEventListener('click', clicksHandler);
b.addEventListener('auxclick', e => e.button === 1 && clicksHandler(e));
}
function mountLibraryDownloadButton(suggestion, libraryRequire, libraryVersion) {
let [
libraryHref,
libraryName,
] = libraryRequire.innerText.match(
/\/\/ @require (https:\/\/.+\/scripts\/\d+\/\d+\/(.*)\.js)/
).slice(1);
// this probably is completely useless but whatever
if (!libraryHref) throw new Error('library href is not found');
libraryName = decodeURIComponent(libraryName);
if (libraryVersion?.innerText) libraryName += ` ${libraryVersion.innerText}`;
GM_addStyle([`
.GF-DSB__library-download-button {
transition: box-shadow 0.2s;
}
.GF-DSB__library-download-button--loading {
animation: GF-DSB__loading-text 1s infinite linear;
}
@keyframes GF-DSB__loading-text {
50% {
opacity: 0.4;
}
}
`][0]);
const b = document.createElement('a');
b.href = '#';
b.draggable = false;
b.innerText = translate(i18n.download);
b.className = 'GF-DSB__library-download-button';
suggestion.appendChild(b);
// against doubleclicks
let isFetchingAllowed = true;
async function clicksHandler(ev) {
ev.preventDefault();
setTimeout(() => b === document.activeElement && b.blur(), 250);
if (isFetchingAllowed === false) return;
isFetchingAllowed = false;
b.className =
'GF-DSB__library-download-button GF-DSB__library-download-button--loading';
try {
await downloadScript({
fileExt: '.js',
href: libraryHref,
name: libraryName,
});
} catch (e) {
console.error(e);
alert(`${translate(i18n.failedToDownload)}: \n${e}`);
} finally {
setTimeout(() => {
isFetchingAllowed = true;
b.className = 'GF-DSB__library-download-button';
}, 300);
}
}
b.addEventListener('click', clicksHandler);
b.addEventListener('auxclick', e => e.button === 1 && clicksHandler(e));
}
// utils --------------------------------------------------------------------
// Is needed because you can't fetch a new format script link
// due to different domain cors restriction...
function convertScriptHrefToAnOldFormat(href) {
const regex = /https:\/\/update\.(\w+\.org)\/scripts\/(\d+)\/(\d+\/)?(.+)/;
const match = href.match(regex);
if (!match) throw new Error("can't convert href to an old format");
const domain = match[1];
const scriptId = match[2];
const version = match[3] ? `?version=${match[3]}` : '';
const scriptName = match[4];
return `https://${domain}/scripts/${scriptId}/code/${scriptName}${version}`;
}
async function downloadScript({
fileExt = '.txt',
href,
name = Date.now(),
} = {}) {
if (!href) throw new Error('Script href is missing');
const fetchErrors = [];
let linksToTry = [];
let url;
// "web.archive" part has been done poorly and unreliable
if (location.hostname === 'web.archive.org') {
// Get a "web.archive" link prefix. Full link example:
// https://web.archive.org/web/20220827221543/https://greasyfork...
// Prefix:
// https://web.archive.org/web/20220827221543
const webArchivePrefix =
location.href.match(/(.+)\/http(s|):\/\/(greas|sleaz)yfork\.org/)[1];
if (!webArchivePrefix) throw new Error('Failed to get script href');
// "id_" part is needed to get a clean file from the webarchive.
// By default there are some js metadata that will break the script.
// See: https://archive.org/post/1044859
// Possible alternative is to cut off these strings manually
// hoping that there are fixed amount of them, or maybe using regex
linksToTry.push(webArchivePrefix + 'id_/' + href);
} else {
// Consider first link as a main attempt. Second one is
// needed just for some unknown edge case scenarios. See:
// https://greasyfork.org/scripts/420872/discussions/216921
linksToTry = [
convertScriptHrefToAnOldFormat(href),
href,
];
}
for (const scriptHref of linksToTry) {
try {
const response = await fetch(scriptHref);
if (response.status !== 200) {
throw new Error(`Bad response: ${response.status}`);
}
url = window.URL.createObjectURL(await response.blob());
break;
} catch (e) {
fetchErrors.push(e);
}
}
if (!url) {
fetchErrors.forEach(e => console.error(e));
throw new Error('Failed to fetch. See console');
}
const a = document.createElement('a');
a.href = url;
a.download = `${name}${fileExt}`;
document.body.appendChild(a); // is needed due to firefox bug
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}
}());