NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Anything Not Saved // @namespace https://openuserjs.org/users/Sauvegarde // @version 5.7 // @author Sauvegarde // @description Save every picture you like in one click. // @match https://aryion.com/g4/view/* // @match https://www.furaffinity.net/view/* // @match https://www.furaffinity.net/full/* // @match https://www.hentai-foundry.com/pictures/* // @match https://inkbunny.net/s/* // @match https://inkbunny.net/submissionview.php?id=* // @match https://www.weasyl.com/*/submissions/* // @match https://www.newgrounds.com/art/view/*/* // @match https://twitter.com/* // @match https://x.com/* // @match https://subscribestar.adult/* // @connect wixmp.com // @connect twitter-video-download.com // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_download // @iconURL https://i.ibb.co/59f9S0g/floppy.png // @updateURL https://openuserjs.org/meta/Sauvegarde/Anything_Not_Saved.meta.js // @downloadURL https://openuserjs.org/install/Sauvegarde/Anything_Not_Saved.user.js // @supportURL https://openuserjs.org/scripts/Sauvegarde/Anything_Not_Saved/issues // @copyright 2024, Sauvegarde (https://openuserjs.org/users/Sauvegarde) // @license GPL-3.0-or-later // ==/UserScript== /* `Anything Not Saved` will attempt to create a "Save as" button near artworks in compatible websites. * The "Save as" button will query the full size image (if applicable) and open the corresponding prompt. * The prompt will supply a filename in the form of "artist - artwork" for convenience. * Any character forbidden by Windows (such as slashes) will be replaced (often with dashes) or stripped. * If the button cannot be created, it will fallback to a pre-highlighted text node with the proper name. * * Requisite permissions: * * GM_xmlhttpRequest is required for websites that serves content indirectly. * A head request must be sent in order to know the file extension or the "Save as" button won't work. * Case in point: DeviantArt, which uses `wixmp.com` as a CDN. * * GM_download is required for "Save as" functionality. * TamperMonkey uses an extension whitelist for download candidates (pictures are covered by default). * You may have to extend it yourself if you want to download more exotic files ("docx", "pdf", etc). * Go to Dashboard > Parameters > Downloads and add the extensions you want in the list. * * This script is developed and tested with the plugin TamperMonkey on Firefox. */ /* jshint esversion: 11 */ /** For testing purpose. */ const forceFailure = false; /** SVG path of a universally recognized save icon 💾 :p */ const disketPathData = "M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm2 16H5V5h11.17L19 " + "7.83V19zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3zM6 6h9v4H6z"; /** SVG icon element. */ function disketSvg() { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttributeNS(null, "width", "24"); svg.setAttributeNS(null, "height", "24"); svg.setAttributeNS(null, "viewBox", "0 0 24 24"); const path = document.createElementNS(ns, "path"); path.setAttributeNS(null, "d", disketPathData); path.setAttributeNS(null, "style", "fill: currentColor;"); svg.appendChild(path); return svg; } /** * Generates a correct artwork name in the form of "$artist - $title". * Any character forbidden by Windows are replaced or removed */ function parseName(name) { const author = name.replace(/(^.*) by (.*?$)/g, "$2"); const picture = name.replace(/(^.*) by (.*?$)/g, "$1"); let title = author + " - " + picture; title = title.replace(/[?.*_~=`"]/g, " "); // forbidden characters title = title.replace(/[\/\\><]/g, "-"); // slashes and stripes title = title.replace(/:/g, " - "); // colon title = title.replace(/\-+\s+\-+/g, "-"); // redundant dashes title = title.replace(/\s+/g, " "); // redundant spaces title = title.replace(/^\s|\s$/g, ""); // start/end spaces title = title.replace(/^\-+\s+|\s+\-+$/g, ""); // start/end dashes return title; } /** Highlights all the text in the element, ready for a ctrl+c. */ function selectText(element) { if (document.body.createTextRange) { const range = document.body.createTextRange(); range.moveToElementText(element); range.select(); } else if (window.getSelection) { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } } /** Gets the nth parent of an element. */ function getParent(el, nth) { let parent = el; for (let i = 0; i < nth; ++i) { parent = parent.parentElement; } return parent; } /** * Adds a <style> tag with the rule. * Necessary when dealing with pseudo-classes or anything too complicated for JS. * https://stackoverflow.com/a/11371599/6355515 */ function addCssRule(cssRule) { const style = document.createElement("style"); if (style.styleSheet) { style.styleSheet.cssText = cssRule; } else { style.appendChild(document.createTextNode(cssRule)); } document.getElementsByTagName("head")[0].appendChild(style); } /** Waits for a set amount of time. */ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** Adds the required number of zeroes to keep a constant amount of digits. */ function padWithZeroes(num, max) { let strNum = num.toString(); const paddingLength = max.toString().length; while (strNum.length < paddingLength) { strNum = "0" + strNum; } return strNum; } /** Clones and replace the button to remove all previous event listeners. */ function cloneButton(saveBtn) { const newSaveBtn = saveBtn.cloneNode(true); removeFailure(newSaveBtn); saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); return newSaveBtn; } /** Creates a generic "Save as" HTML element with an id and a label. */ function createButton(tagName, text) { tagName = tagName ?? "button"; const btn = document.createElement(tagName); if (tagName === "button") { btn.type = "button"; } btn.id = "artname-btn"; btn.innerText = text ?? "Save as"; return btn; } /** * Create a button which opens the "Save as" dialog with the corrected filename. * https://www.tampermonkey.net/documentation.php#GM_download */ function createAndAssign(tagName, urlList, artName, errorCallback) { const btn = createButton(tagName); if (!urlList || !GM.download || forceFailure) { admitFailure(btn, errorCallback); return btn; } if (typeof urlList === "string") { urlList = [urlList]; } if (urlList.length > 1) { btn.innerText = "Download all"; } // The button stays blurry until all the AJAX requests to get file extensions are resolved btn.style.opacity = "0.3"; assignClick(btn, urlList, artName).then(() => { btn.style.opacity = ""; }); return btn; } /** Assign the "Save as" event with the correct extension on button click. */ async function assignClick(btn, urlList, artName, errorCallback) { if (forceFailure) { admitFailure(btn, errorCallback); return; } if (typeof urlList === "string") { urlList = [urlList]; } // Retrieves the targets extensions const extList = []; const nameWithExt = /(.+)\.(\w{3,4})$/; if (nameWithExt.test(artName)) { // The name provided already has the file extension const twoPartsName = artName.match(nameWithExt); artName = twoPartsName[1]; // We assume every file in the sequence will have the same extension urlList.forEach(() => extList.push(twoPartsName[2])); } else { // The extensions must be derived from the URL for (let i = 0; i < urlList.length; ++i) { const url = urlList[i]; const ext = await detectExtension(btn, url, errorCallback); extList[i] = ext; } } btn.addEventListener("click", () => saveAs(btn, urlList, extList, artName)); } /** Call GM.download and updates the button status on success/failure. */ function saveAs(btn, urlList, extList, artName) { let completed = 0; if (typeof urlList === "string") { urlList = [urlList]; } if (typeof extList === "string") { extList = [extList]; } const total = urlList.length; // No rage-clicks setBusy(); // Only one picture to be saved as if (total === 1) { const url = urlList[0]; const ext = extList[0]; GM.download({ url: url, name: artName + "." + ext, saveAs: true, onerror: error => handleError(error, ext), ontimeout: () => handleTimeout(), }).then(unsetBusy); } else { // Batch downloading of multiple pictures const requestList = []; btn.innerText = "Download (0/" + total + ")"; for (let i = 0; i < total; ++i) { const url = urlList[i]; const ext = extList[i]; const request = GM.download({ url: url, name: artName + " - " + padWithZeroes(i + 1, total) + "." + ext, saveAs: false, onload: response => completeOne(), onerror: error => handleError(error, ext), ontimeout: () => handleTimeout(), }); requestList.push(request); } Promise.all(requestList).then(unsetBusy); } function completeOne() { completed++; btn.innerText = "Download (" + completed + "/" + total + ")"; } function setBusy() { btn.disabled = true; btn.style.cursor = "wait"; } function unsetBusy() { btn.disabled = false; btn.style.cursor = ""; completed = 0; } function handleError(error, ext) { switch (error.error) { case "not_enabled": alert("GM_download is not enabled."); break; case "not_permitted": alert("GM_download permission has not been granted."); break; case "not_supported": alert("GM_download is not supported by the browser/version."); break; case "not_succeeded": console.error(error); alert("GM_download has vulgarly failed. Please retry."); break; case "not_whitelisted": // https://github.com/Tampermonkey/tampermonkey/issues/643 alert( "The requested file extension (" + ext + ") is not whitelisted.\n\n" + "You have to add it manually (see 'Downloads' in Tampermonkey settings)." ); break; case "Download canceled by the user": // User just clicked "Cancel" on the prompt break; default: console.error(error); alert("GM_download has unexpectedly failed with the following error: " + error.error); break; } unsetBusy(); } function handleTimeout() { alert("The download target has timed out :("); unsetBusy(); } } /** * Attempts to detect the extension of the download target from the URL. * If that fails, uses a AJAX request and parses it in the response. * In that case, we need a cross-scripting permission to access the CDN. * It also means the button will take more time to appear. * https://www.tampermonkey.net/documentation.php#GM_xmlhttpRequest */ async function detectExtension(btn, url, errorCallback) { // Tries to fetch the file extension from the supplied URL // This is the simplest method but it does not alway work const type = /\.(\w{3,4})\?|\.(\w{3,4})$|format=(\w{3,4})/; if (type.test(url)) { // For some reason, there is sometimes 'undefined' in matched groups... const ext = type.exec(url).filter(el => el !== undefined)[1]; // Excludes web pages that indirectly deliver content const invalidExtensions = ["html", "htm", "php", "jsp", "asp"]; if (!invalidExtensions.includes(ext)) { // Finished! return ext; } } // If it does not work, we send a head query and infer extension from the response if (GM.xmlHttpRequest) { let ext = null; const response = await GM.xmlHttpRequest({ method: "head", url: url, onerror: error => { if (error.status === 403) { // TODO: add referer console.error("Cannot determine extension of target: head request denied.", error); } else { console.error("Cannot determine extension of target.", error); } admitFailure(btn, errorCallback); }, ontimeout: () => { console.error("Cannot determine extension of target: head request timed out."); admitFailure(btn, errorCallback); }, }); const headers = response.responseHeaders; const filename = /filename=".*?\.(\w+)"/; const mimeType = /content-type: image\/(\w+)/; if (filename.test(headers)) { ext = filename.exec(headers)[1]; } else if (mimeType.test(headers)) { // Legacy handling of DeviantArt before it went full Eclipse ext = mimeType.exec(headers)[1]; } else { console.error("Cannot determine extension of target from head response.", response); admitFailure(btn, errorCallback); } if (ext) { // Finished! return ext.replace("jpeg", "jpg"); } } else { console.error("Cannot determine extension of target: no GM_xmlhttpRequest permission."); } return null; } /** Marks the button as failed and execute the text-only fallback. */ function admitFailure(btn, fallback) { btn.classList.add("failed"); if (fallback) { fallback(); } } /** Takes it back. */ function removeFailure(btn) { btn.classList.remove("failed"); // Removes the "under construction" status btn.style.opacity = ""; } /** Indicates the button won't work. */ function isFailed(btn) { return btn == null || btn.classList.contains("failed"); } /** Eka's Portal sometimes requires XMLHttpRequest for text files. */ function processAryion() { const boxes = document.querySelectorAll(".g-box"); for (let box of boxes) { const bar = box.querySelector(".g-box-header + .g-box-header span + span"); if (bar) { const name = parseName(document.title.substr(6, document.title.length)); const noscript = document.querySelector(".item-box noscript"); let url = null; if (noscript) { // Creates download buttons from the noscript picture URL url = /src='(.*?)'/.exec(noscript.innerText)[1].replace("//", "https://"); } else { // Slower, because it goes the XMLHttpRequest way to get the file extension const downloadAnchor = document.querySelectorAll(".func-box .g-box-header.g-corner-all a")[1]; url = downloadAnchor.href; } const sabt = createAndAssign("a", url, name, () => { // Adds the formatted name under the regular title const title = document.createElement("div"); title.innerHTML = name; bar.appendChild(title); selectText(title); }); const func = document.querySelector(".func-box .g-box-header.g-corner-all"); const sep = document.createElement("span"); sep.innerHTML = " | "; func.appendChild(sep); func.appendChild(sabt); return; } } } /** FurAffinity */ function processFuraffinity() { const actions = document.querySelector("#page-submission .actions"); const betaSection = document.querySelector("#submission_page .submission-sidebar"); if (actions) { // Classic template (no longer maintained) const name = parseName(document.title.substr(0, document.title.length - 26)); for (let i = 0; i < actions.childNodes.length; ++i) { if (actions.childNodes[i].textContent.match("Download")) { const dlbt = actions.childNodes[i].childNodes[0]; dlbt.title = name; dlbt.innerHTML = name; selectText(dlbt); const sabt = createAndAssign("button", dlbt.href, name, () => {}); dlbt.parentElement.parentElement.appendChild(sabt); break; } } } else if (betaSection) { // Modern template const name = parseName(document.title.substr(0, document.title.length - 26)); const side = betaSection.querySelector("section.buttons"); const sideDownloadLink = side.querySelector("div.download a"); const sideSaveAsLink = createAndAssign("a", sideDownloadLink.href, name, () => { // Adds the formatted name as a new meta info const container = document.createElement("div"); const strong = document.createElement("strong"); const nameSpan = document.createElement("span"); strong.innerText = "Name"; nameSpan.innerText = name; container.appendChild(strong); container.appendChild(document.createTextNode(" ")); container.appendChild(nameSpan); const meta = betaSection.querySelector("section.info.text"); meta.insertBefore(container, meta.firstChild); selectText(nameSpan); }); // Adjust styling and insert into sidebar sideSaveAsLink.href = "#"; const sideDownload = sideDownloadLink.parentElement; sideDownload.style.padding = "0 8px"; const sideSaveAs = document.createElement("div"); sideSaveAs.style.borderRight = "none"; sideSaveAs.appendChild(sideSaveAsLink); sideDownload.insertAdjacentElement("afterend", sideSaveAs); // Insert a second button into picture bottom bar const bottom = document.querySelector(".favorite-nav"); const bottomDownloadLink = Array.from(bottom.children).find(a => a.innerHTML === "Download"); const bottomSaveAsLink = createAndAssign("a", sideDownloadLink.href, name, () => {}); bottomSaveAsLink.className += " " + bottomDownloadLink.className; bottomSaveAsLink.style.marginLeft = "4px"; // simulates a fucking blank space bottomDownloadLink.insertAdjacentElement("afterend", bottomSaveAsLink); } } /** Hentai Foundry */ function processHentaiFoundry() { const boxFooter = document.querySelector("#picBox .boxfooter"); if (boxFooter) { const name = parseName(document.title.substr(0, document.title.length - 17)); const yt0 = boxFooter.querySelector("yt0"); // broken thumb const yt1 = boxFooter.querySelector("yt1"); // favorite picture const img = document.querySelector(".boxbody img.center"); const url = img.onclick ? "https:" + /src='(.*?)'/.exec(img.onclick.toString())[1] : img.src; const sabt = createAndAssign("a", url, name, () => { // Replace the picture title with the formatted name const boxTitle = document.querySelector("#descriptionBox .boxheader .boxtitle"); boxTitle.innerHTML = name; selectText(boxTitle); }); sabt.classList.add("linkButton"); sabt.classList.add("picButton"); // HF uses font-awesome which is convenient const icon = document.createElement("i"); icon.className = "fa fa-floppy-o"; sabt.innerHTML = ""; sabt.appendChild(icon); const text = document.createElement("span"); text.innerHTML = " SAVE AS"; sabt.appendChild(text); boxFooter.insertBefore(sabt, boxFooter.firstChild); } } /** InkBunny */ function processInkbunny() { const pictop = document.querySelector("#pictop"); if (pictop) { const name = parseName(document.title.substr(0, document.title.length - 49)); const sctn = document.querySelector("#size_container"); const downloadLink = sctn.querySelector("div+a"); let url; if (downloadLink) { url = downloadLink.href; } else { const img = document.querySelector("img#magicbox"); const picLink = img.parentElement; url = picLink.href; } const sabt = createAndAssign("a", url, name, () => { // Replace the picture title with the formatted name const h1 = pictop.querySelector("h1"); h1.innerHTML = name; selectText(h1); }); const icon = disketSvg(); icon.style = "height: 14px; vertical-align: text-bottom;"; sabt.insertBefore(icon, sabt.firstChild); sabt.style = "margin-left: 24px; cursor: pointer;"; sctn.appendChild(sabt); } } /** Weasyl */ function processWeasyl() { const name = parseName(document.querySelector("h1#detail-title").innerText); const bar = document.querySelector("ul#detail-actions"); const dlbt = bar.querySelector("li a[download]"); const sabt = createAndAssign("a", dlbt.href, name, () => { // Adds the formatted name under the action bar, above the description const nameTxt = document.createElement("div"); nameTxt.innerHTML = name; const detailContent = document.querySelector("#detail-content"); detailContent.insertBefore(nameTxt, detailContent.firstChild); selectText(nameTxt); }); const icon = disketSvg(); icon.style = "vertical-align: middle; margin-right: 4px;"; sabt.insertBefore(icon, sabt.firstChild); const li = document.createElement("li"); li.appendChild(sabt); bar.insertBefore(li, dlbt.parentElement.nextSibling); } /** Newgrounds */ function processNewgrounds() { const name = parseName(document.title.substr(0, document.title.length - 14)); const nav = document.querySelector("#gallery-nav"); let urlList = []; if (nav) { // fuck it... const dlbt = createButton("button", "Download all"); dlbt.onclick = () => downloadSlideshow(nav, dlbt); addButton(dlbt); } else { const artList = document.querySelectorAll(".pod-body a[data-action=view-image]"); urlList = [...artList].map(a => a.href); const sabt = createAndAssign("button", urlList, name, () => { console.warn("Unable to create Save As button."); }); addButton(sabt); } async function downloadSlideshow(nav, dlbt) { dlbt.disabled = true; dlbt.style.cursor = "wait"; const thumbs = [...nav.querySelectorAll("a.art-gallery-thumb")]; const total = thumbs.length; dlbt.innerText = "Download (0/" + total + ")"; let url = null; for (let i = 0; i < total; ++i) { const thumb = thumbs[i]; thumb.click(); let nextUrl = null; do { await sleep(200); nextUrl = document.querySelector(".pod-body a[data-action=view-image]").href; } while (url === nextUrl); url = nextUrl; const ext = await detectExtension(dlbt, url); await GM.download({ url: url, name: name + " - " + padWithZeroes(i + 1, total) + "." + ext, saveAs: false, }); dlbt.innerText = "Download (" + (i + 1) + "/" + total + ")"; } dlbt.disabled = false; dlbt.style.cursor = ""; } function addButton(bt) { const icon = disketSvg(); icon.style = "vertical-align: middle; margin: -2px 4px 0 0;"; bt.insertBefore(icon, bt.firstChild); const span = document.createElement("span"); span.appendChild(bt); const bar = document.querySelectorAll(".pod-head")[0]; bar.appendChild(span); } } /** X/Twitter */ function processTwitter() { const nameUrlRelation = new Map(); const observer = new MutationObserver(changes => { changes.forEach(change => { if (change.addedNodes.length > 0) { for (const node of change.addedNodes) { processNode(node); } } }); }); observer.observe(document.body, { childList: true, subtree: true }); function processNode(node) { const testAnchor = anchor => /\/status\/\d+/.test(anchor.href); switch (node.tagName) { case "IMG": if (node.src) { const isMedia = node.src.startsWith("https://pbs.twimg.com/media/"); const isQuote = node.src.endsWith("name=240x240") || node.src.endsWith("name=120x120"); if (isMedia && !isQuote) { const parentAnchor = node.closest("a"); if (parentAnchor) { processTweet(parentAnchor, node); } } } break; case "DIV": if (node.querySelector("video")) { const article = node.closest("article"); const anchors = article.querySelectorAll("a"); for (const anchor of anchors) { if (testAnchor(anchor)) { processTweet(anchor, node.querySelector("video")); break; } } } break; } } function processTweet(anchor, srcElem) { const name = parseName(anchor.href); const url = parseUrl(srcElem.src); const article = anchor.closest("article"); if (!article) { // Not a tweet return; } if (url.startsWith("blob")) { // Cannot process return; } let preBtn = article.querySelector("#artname-btn"); if (preBtn) { const urlArray = nameUrlRelation.get(name); if (urlArray === undefined) { // miniature of a quote tweet, skip return; } urlArray.push(url); // Reassign with new set of URL preBtn = cloneButton(preBtn); assignClick(preBtn, urlArray, name); preBtn.innerText = "Download all"; } else { const saBtn = createAndAssign("button", url, name, () => { console.warn("Unable to create Save As button."); }); addButton(saBtn, article); nameUrlRelation.set(name, [url]); } } function parseName(href) { // https://twitter.com/{user}/status/{mark} const elem = href.split("/"); const user = elem[3]; const mark = elem[5]; return user + " - " + mark; } function parseUrl(src) { // https://pbs.twimg.com/media/{internal-id}?format=png&name=small return src.replace(/&name=\w+/, "&name=4096x4096"); } function addButton(btn, article) { const bar = article.querySelector("[role=group]"); btn.style.maxHeight = "30px"; btn.style.margin = "auto 10px"; btn.style.cursor = "pointer"; bar.appendChild(btn); } } /** Subscribestar */ function processSubscribestar() { document.body.addEventListener("click", () => setTimeout(main, 500), true); function main() { const imgLinks = document.querySelectorAll("a.gallery-image_original_link"); if (imgLinks.length === 1) { const imgLink = imgLinks[0]; const existing = document.getElementById("artname-btn"); if (!existing && GM_download) { const sabt = createAndAssign("a", imgLink.href, imgLink.download); sabt.className = imgLink.className; sabt.style.display = "inline-block"; sabt.style.padding = "0 0 10px 0"; sabt.style.cursor = "pointer"; const sep = document.createElement("span"); sep.innerHTML = " | "; imgLink.style.display = "inline-block"; imgLink.style.padding = "10px 0 0 0"; imgLink.parentElement.appendChild(sep); imgLink.parentElement.appendChild(sabt); } } } } window.addEventListener("load", function () { // Button becomes red if it doesn't work addCssRule("#artname-btn.failed {color: red !important;}"); // URL includes filtering switch (location.host) { case "aryion.com": processAryion(); break; case "www.furaffinity.net": processFuraffinity(); break; case "www.hentai-foundry.com": processHentaiFoundry(); break; case "inkbunny.net": processInkbunny(); break; case "www.weasyl.com": processWeasyl(); break; case "www.newgrounds.com": processNewgrounds(); break; case "twitter.com": case "x.com": processTwitter(); break; case "subscribestar.adult": processSubscribestar(); break; default: console.error("URL include / host filtering mismatch."); break; } }); /* Changelog: ** 5.7: added support for Subscribestar, dropped support for DeviantArt ** 5.6: fixed action bar detection for X/Twitter ** 5.5: added partial support for X/Twitter ** 5.4: handled Newgrounds slideshow, improved DA, refactored all the asynchronous sub-processes ** 5.3: fixed Newgrounds (+improved integration) and replaced @include with @match ** 5.2: fixed dumb issue ("data-hook") with DA ** 5.1.1: fixed InkBunny image catching when download link is missing ** 5.1 * added another "Save as" button at picture bottom on FA * improved integration with FA * fixed DA * refactored createSaveAsButton() to be clearer ** 5.0 * added support for Weasyl * improved integration with InkBunny * corrected DeviantArt process yet again * changed name from "Artname" to "Anything Not Saved" because it rocks * changed script icon for something more adequate * added in-script documentation * removed CSS spinner in favor of simpler cursor change over the button * removed options: now the text node is properly used as a fallback mechanism * added a `forceFailure` boolean to test such fallback ** 4.0 * added support for DeviantArt Eclipse * improved the AJAX system by large * added some error handling * added better graphic integration of the button in websites * made the `addArtName` functionality optional */