WikiArt Downloader

Add button to download full resolution images from WikiArt

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WikiArt Downloader
// @namespace    https://greasyfork.org/en/scripts/492666-wikiart-downloader
// @version      1.0
// @description  Add button to download full resolution images from WikiArt
// @author       CertifiedDiplodocus
// @match        https://www.wikiart.org/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wikiart.org
// @grant        GM_addStyle
// @license      GPL-3.0-or-later
// ==/UserScript==

/* PURPOSE: Adds a "download" button to WikiArt gallery, single painting, and fullscreen views.
    - Default filename from painting info.
    - To change the default formatting, edit the variable saveAsName in the function savePaintingAs() below.
    - Verbose logging for debugging purposes. Errors will always be logged.

LIST OF JSON ATTRIBUTES (e.g. painting.title):
    title, year, width, height, artistName,
    imageUrl      (key renamed from "image"),
    paintingUrl   (url on WikiArt, e.g. "/en/zdzislaw-beksinski/untitled-1972-0"),
    artistUrl     (on WikiArt, e.g. "/en/zdzislaw-beksinski")

----------------------------------------------------------------------------------------------------------------------
*/

(function() {
    'use strict';

    const enableVerboseLogging = false // set to 'true' for debugging purposes

    // Attempt to find paintings
    verboseLog ('Page loading. Starting to search for painting information...')
    let painting
    let paintingInfoDiv = document.querySelector ('div[ng-init^="paintingJson"]') // why doesn't this detect the gallery items? (which I appreciate, but...)
    let galleryOuterContainer = document.querySelector ('.masonry-content')

    if (!paintingInfoDiv && !galleryOuterContainer) {verboseLog ('No gallery or painting info found.'); return null} //    EXIT

    // Create download button in fullscreen mode
    const newButtonFullscreen = createButton ('a','download-button-fullscreen', downloadFromFullscreen);
    document.getElementsByClassName('sueprsized-navigation-panel-right')[0].appendChild(newButtonFullscreen); // typo in source code

    // Create download button on the main artwork page
    if (paintingInfoDiv) {
        verboseLog('Found div with painting information.')

        const newButtonStandard = createButton ('div', 'download-button-standard', downloadFromDetailedView)
        document.getElementsByClassName('fav-controls-wrapper')[0].appendChild (newButtonStandard)
    }

    if (galleryOuterContainer) {
        verboseLog ('Gallery found! Waiting for user to select a painting...');

        // On page load (before the user does anything) get the container and watch for paintings being added or removed
        let typeOfGallery = document.querySelector('.masonry-content').firstElementChild.getAttribute('ng-switch-when')
        let galleryContainer = getGalleryContainer ()
        if (galleryContainer) {
            createGalleryObserver()
        }

        // If the user switches between details / masonry / text views, identify and create an observer for the new gallery
        const galleryTypeObserver = new MutationObserver (galleryTypeChange)
        galleryTypeObserver.observe (galleryOuterContainer, {childList: true})

        function galleryTypeChange (mutations, galleryTypeObserver) {
            for (const mutation of mutations) {
                for (const addedNode of mutation.addedNodes) {
                    if (addedNode.nodeType === 1) { // select elements, ignore comments
                        typeOfGallery = addedNode.getAttribute('ng-switch-when')
                        galleryContainer = getGalleryContainer ()
                        if (galleryContainer) {
                            createGalleryObserver()
                        }
                    }
                }
            }
        }

        function getGalleryContainer () {
            let galleryMasonryContainer = document.querySelector ('.wiki-masonry-container')
            let galleryDetailedContainer = document.querySelector('.wiki-detailed-container')
            switch (typeOfGallery) {
                case 'detailed':
                case 'masonry':
                    verboseLog (`A valid (${typeOfGallery}) gallery view was found.`);
                    return galleryMasonryContainer || galleryDetailedContainer;
                case 'text':
                    verboseLog ('Cannot download from gallery text view. Waiting for valid gallery type...');
                    return false;
                default:
                    console.error ('Could not evaluate the type of gallery.');
                    return null
            }
        }

        // If gallery items change...
        function createGalleryObserver() {
            const galleryObserver = new MutationObserver (galleryItemChanges)
            galleryObserver.observe (galleryContainer, {childList: true})
        }

        // ...create download buttons for each newly-added painting. Buttons are appended to a <div> with the painting's unique ID or JSON.
        function galleryItemChanges(mutations, galleryObserver) {
            let a = 0
            let r = 0
            let newGalleryItems = [] // empty array
            let classOfButtonParent
            typeOfGallery = document.querySelector('[ng-switch-when]').getAttribute('ng-switch-when')
            switch (typeOfGallery) {
                case 'masonry': classOfButtonParent = '.title-block'; break;
                case 'detailed': classOfButtonParent = '.wiki-layout-painting-info-bottom'; break;
            }

            // Select the added paintings (element type). Track removed paintings for debugging purposes.
            for (const mutation of mutations) {
                for (const addedNode of mutation.addedNodes) {
                    if (addedNode.nodeType === 1) {
                        let buttonParent = addedNode.querySelector(classOfButtonParent)
                        newGalleryItems.push(buttonParent)
                        a++
                    }
                }
                for (const removedNode of mutation.removedNodes) {
                    if (removedNode.nodeType === 1) {r++}
                }
            }
            verboseLog (`Loading: removed ${r} paintings, added ${a} paintings.`)
            if (a > 0) { createButtonsInGallery(newGalleryItems) }
        }

        // Create download buttons in gallery view
        function createButtonsInGallery (galleryItemList) {
            verboseLog (`Created buttons in ${typeOfGallery} view.`)
            switch (typeOfGallery) {
                case 'masonry':
                    galleryItemList.forEach(item => {
                        const newButtonGallery = createButton ('div', 'download-button-gallery like-overlay', downloadFromGalleryMasonry)
                        item.appendChild (newButtonGallery)
                    })
                    break;
                case 'detailed':
                    galleryItemList.forEach(item => {
                        const newButtonGallery = createButton ('div', 'download-button-standard', downloadFromDetailedView)
                        item.querySelector('.fav-controls-wrapper').appendChild(newButtonGallery) // needs to be in the fav wrapper or it will not be clickable
                    })
            }
        }
    }

    // EVENT LISTENER HANDLERS  -------------------------------------------------------
    // 'this' = the 'buttonGallery' element (https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_value_of_this_within_the_handler)

    function downloadFromDetailedView() { // single and gallery modes
        parsePaintingJson (this.parentElement.parentElement)
        savePaintingAs()
    }

    function downloadFromGalleryMasonry() {
        let parentId = this.parentElement.id
        painting = {
            title: document.querySelector(`#${parentId} .artwork-name`).innerText,
            year: document.querySelector(`#${parentId} .artwork-year`).innerText,
            artistName: document.querySelector(`#${parentId} .artist-name`).innerHTML.trim().split('&nbsp;')[0],
            imageUrl: this.parentElement.parentElement.querySelector('img').src.split('!')[0]
        }
        savePaintingAs()
    }

    function downloadFromFullscreen() {
        painting = {
            title: document.querySelector('.supersized-slide-name').title,
            year: document.querySelector('span.year').innerText.slice(1).slice(0,-1), // remove the first and last characters: (1956) -> 1956
            artistName: document.querySelector('.supersized-slide-header').title,
            imageUrl: document.querySelector('.primary-image').src
        }
        savePaintingAs()
    }

    function parsePaintingJson (sourceDiv) {
        let initContent = sourceDiv.getAttribute('ng-init')
        let jsonString = initContent.match('paintingJson = ({.*?})')[1] //   clean up
        jsonString = jsonString.replace('"image" :','"imageUrl" :') //       better key name

        if (!jsonString || jsonString.length<=1) {console.error ("Could not extract JSON from the element."); return null} // EXIT
        verboseLog ('Extracted JSON string:', jsonString);

        try {
            painting = JSON.parse(jsonString);
            verboseLog (painting.title + ' - ' + painting.year + ' (' + painting.artistName + ')');
            verboseLog ('Painting information parsed and extracted!');
        } catch (e) {
            console.error ('Error parsing JSON:', e);
            alert ('There was an error parsing the painting information. Check the console for more details.');
        };
    }

    // When a download button is clicked, save URL with the default filename = ARTIST - TITLE (YEAR).EXT
    function savePaintingAs () {
        const imgExtension = painting.imageUrl.split('.').pop(); // pop() returns the last element from an array; in this case, the extension
        let saveAsName = `${painting.artistName} - ${painting.title} (${painting.year}).${imgExtension}`;
        saveAsName = decodeHTML(saveAsName); // handle special characters (í, ç etc)
        downloadImage(painting.imageUrl, saveAsName);
    }

    // FUNCTIONS AND STYLES ---------------------------------------------------------------

    // Create a button with an event listener
    function createButton (elementType, buttonClass, callbackFunctionName) {
        let newButton = document.createElement (elementType)
        newButton.className = buttonClass
        newButton.addEventListener ('click', callbackFunctionName, false)
        return newButton
    }

    // Check for HTML codes in text and convert to characters
    function decodeHTML (stringInput) {
        let txt = document.createElement("textarea");
        txt.innerHTML = stringInput;
        return txt.value;
    }

    // Save image using fetch (a workaround because Chrome and Firefox both block the "download" attribute for images on different domains)
    async function downloadImage(imageSrc, imageName) {
        const image = await fetch(imageSrc)
        const imageBlob = await image.blob()
        const imageURL = URL.createObjectURL(imageBlob)

        const link = document.createElement('a')
        link.href = imageURL
        link.download = imageName
        document.body.appendChild(link)
        link.click()
        document.body.removeChild(link)
    }

    function verboseLog (textForConsole) {
        if (!enableVerboseLogging) {return null}
        console.log (textForConsole)
    }

    //--- Style newly-added button in CSS. Modify the page CSS to make room for the button (overwrite with !important flag).
    GM_addStyle ( `
    .download-button-fullscreen {
        display:block;
        height:40px;
        width:40px;
        background:url(https://upload.wikimedia.org/wikipedia/commons/8/8a/Download-icon.svg) center center no-repeat;
        margin:0 10px;
        cursor:pointer
    }
    .download-button-standard {
        display:block;
        height:40px;
        width:40px;
        position:absolute;
        right:0px;
        background:url(https://upload.wikimedia.org/wikipedia/commons/7/72/Download-icon-green.svg) center center no-repeat;
        background-size:24px;
        cursor:pointer
    }
    .download-button-gallery {
        left:calc(50% - 20px - 8px - 40px) !important;
        background:url(https://upload.wikimedia.org/wikipedia/commons/7/72/Download-icon-green.svg) center center no-repeat !important;
        background-size:24px !important;
        /*; background-color:#e9e9eb !important */
    }
    .wiki-masonry-container>li:hover .title-block .like-overlay.like-overlay-left {
        left:calc(50% - 20px) !important
    }
    .wiki-masonry-container>li:hover .title-block .like-overlay.like-overlay-right {
        left:calc(50% + 20px + 8px) !important
    }

    /* Fix weird formatting in gallery detailed view */
    .fav-controls-wrapper {
        width:120px !important
    }
    .copyright-wrapper {
        width:calc(100% - 120px) !important
    }
    .fav-controls-heart {
        top:0px !important
    }
    .fav-controls-folder {
        top:0px !important;
        right:40px !important
    }
    ` );

})();