pixiv bulk downloader

simple script to download multiple arts from pixiv illustration

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        pixiv bulk downloader
// @description simple script to download multiple arts from pixiv illustration
// @version     0.0.2
// @namespace   owowed.moe
// @author      owowed
// @license     GPL-3.0-or-later
// @match       *://www.pixiv.net/*
// @require     https://update.greasyfork.org/scripts/488160/1335044/make-mutation-observer.js
// @require     https://update.greasyfork.org/scripts/488161/1335046/wait-for-element.js
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_download
// @run-at      document-end
// @copyright   All rights reserved. Licensed under GPL-3.0-or-later. View license at https://spdx.org/licenses/GPL-3.0-or-later.html
// ==/UserScript==

;3 ;3 ;3

!async function() { // main async function

/* Pixiv Website Navigation Event */

const navigationEvent = new EventTarget();
const charcoal = await waitForElement(".charcoal-token > div > div[style]:not([class])");

// Charcoal Navigation

let lastHrefDispatched;

makeMutationObserverOptions(
    { target: charcoal, childList: true, attributes: true },
    () => {
        if (lastHrefDispatched != window.location.href) {
            navigationEvent.dispatchEvent(new Event("charcoal-navigate"));
            lastHrefDispatched = window.location.href;
        }
    }
);

setTimeout(() => {
    if (document.readyState == "loading") {
        document.addEventListener("DOMContentLoaded", () => {
            dispatchCharcoalNavigateEvent();
        });
    }
    else {
        dispatchCharcoalNavigateEvent();
    }
});

// Illustration Navigation

navigationEvent.addEventListener("charcoal-navigate", async () => {
    if (!window.location.href.includes("/artworks/")) return;

    navigationEvent.dispatchEvent(new Event("illust-open"));
    navigationEvent.dispatchEvent(new Event("illust-navigate"));

    const illustAnchor = await waitForElement("figure:has(~ figcaption)");
    let lastWindowHref = window.location.href;

    const observer = makeMutationObserverOptions({ target: illustAnchor, childList: true, subtree: true }, () => {
        if (lastWindowHref == window.location.href) return;
        navigationEvent.dispatchEvent(new Event("illust-navigate"));
        lastWindowHref = window.location.href;
    });

    navigationEvent.addEventListener("charcoal-navigate", () => {
        observer.disconnect();
        navigationEvent.dispatchEvent(new Event("illust-close"));
    }, { once: true });
});

/* Pixiv Bulk Downloader */

// Downloader Box

const filenameTemplateVariablesGuide = ""
    + "/illust-id/ Numeric id for the illustration\n"
    + "/illust-title/ Illustration title\n"
    + "/illust-tags-short/ Short tags derived from the illustration title\n"
    + "/illust-original/ If the illustration is tagged original, then it is written 'original', otherwise it is 'non-original'\n"
    + "/illust-author-name/ Author name\n"
    + "/illust-author-id/ Numeric id for the author\n"
    + "/illust-like-num/ Illustration like count\n"
    + "/illust-bookmark-num/ Illustration bookmark count\n"
    + "/illust-view-num/ Illustration view count\n"
    + "/illust-datetime/ Illustration posting date and time in long format\n"
    + "/illust-datetime-hours-24/ Illustration posting 24-hour format\n"
    + "/illust-datetime-hours/ Illustration posting 12-hour format\n"
    + "/illust-datetime-minutes/ Illustration posting minutes\n"
    + "/illust-datetime-seconds/ Illustration posting seconds\n"
    + "/illust-datetime-ampm/ Illustration posting AM/PM\n"
    + "/illust-datetime-date/ Illustration posting date\n"
    + "/illust-datetime-day/ Illustration posting day of the week as a number\n"
    + "/illust-datetime-month/ Illustration posting month as a number\n"
    + "/illust-datetime-year/ Illustration posting year\n"
    + "/illust-datetime-day-name/ Illustration posting day of the week as a name\n"
    + "/illust-datetime-month-name/ Illustration posting month as a name\n"
    + "/illust-datetime-timestamp/ Illustration posting timestamp\n"
    + "/current-datetime/ Current date and time in long format\n"
    + "/current-datetime-{...}/ Same as 'illust-datetime-{...}'\n"
    + "/artwork-quality/ Selected artwork quality\n"
    + "/artwork-part/ Selected artwork part\n"
    + "/artwork-parts-num/ Artwork total part count\n"
    + "/file-extension/ File extension for the image\n"
    + "/file-url-name/ File name from the source URL\n"
    + "/file-url-datetime/ File URL's associated date and time in long format\n"
    + "/file-url-datetime-{...}/ Same as 'illust-datetime-{...}'\n"
    + "/website-title/ Website title when it was downloaded\n"
    + "/website-lang/ Website language from the website URL";
const defaultFilenameTemplate = "/illust-title/ by /illust-author-name/ #/artwork-part/ (/illust-tags-short/) [pixiv /illust-id/]./file-extension/";
const { parent, shadow } = createShadowDom(`
    <button id="pbd-btn" class="btn-green expander closed">
        [+] pbd
    </button>
    <div id="pbd-box" class="popup" hidden>
        <h1>pivix bulk downloader</h1>
        <span class="note">userscript made by owowed</span>
        
        <div>
            <h2>Filename Template</h2>
            <div>Naming format for the filename. Include artwork info: author name, creation date, etc.</div>
            <textarea id="filename-template" cols="70" spellcheck="false">${GM_getValue("filename-template", defaultFilenameTemplate)}</textarea>
        </div>

        <div>
            <h2>Artwork Quality</h2>
            <div>Select artwork quality to download.</div>
            <select id="artwork-quality-selector">
                <option value="original">Original (best)</option>
                <option value="regular">Regular</option>
                <option value="small">Small</option>
                <option value="thumb_mini">Thumbnail Mini</option>
            </select>
        </div>

        <div id="selected-artwork-part-entry">
            <h2>Selected Artwork Part</h2>
            <div>Illustration can have multiple artworks (comic, doujin, etc.), you can manully select or bulk download them.</div>
            <select id="artwork-part-selector"></select>
            <div id="bulk-range" hidden>
                From: <select id="bulk-from"></select> To: <select id="bulk-to"></select>
            </div>
        </div>

        <!-- Author Page Exclusive -->

        <div class="artist-filter" hidden>
            <h2>Illustration Date</h2>
            <div>Select illustrations to download from the posting date</div>
            From: <input type="date" />
            To: <input type="date" />
        </div>

        <div class="artist-filter" hidden>
            <h2>Illustration Tags</h2>
            <div>Select illustrations to download from inclusion/exclusion of tags</div>
            <div class="tags-row">
                <div>
                    Inclusion:
                    <div class="tag-list">
                        <div class="added-tags">
                            <input type="text" value="touhou-project" readonly/>
                            <input type="text" value="satori-komeiji" readonly/>
                        </div>
                        <input type="text"/>
                    </div>
                </div>
                <div>
                    Exclusion:
                    <div class="tag-list">
                        <div class="added-tags">
                            <input type="text" value="touhou-project" readonly/>
                            <input type="text" value="satori-komeiji" readonly/>
                        </div>
                        <input type="text"/>
                    </div>
                </div>
            </div>
        </div>

        <style>
            :not([data-page="artist"]) .artist-filter {
                display: none;
            }
        </style>

        <div>
            <button id="btn-download">Start Download</button>
        </div>

        <div class="popup-footer">
            <button id="logs-btn" class="btn-green" hidden>[?] Logs</button>
            <button id="filename-template-variable-list-btn" class="btn-green">[?] Filename Template Variable List</button>
        </div>

        <div id="filename-template-variable-list-guide" class="popup" hidden>
            <pre class="guide-title">Filename Template Variables</pre>
            <pre class="guide-body">${filenameTemplateVariablesGuide}</pre>
        </div>
    </div>
    <style>
        #pbd-btn {
            margin: 12px 0 4px 0;
        }
        .popup {
            background: #E3E0D1;
            font-family: arial,helvetica,sans-serif;
            color: black;
            text-align: center;

            border: 2px solid grey;
            padding: 10px;
            margin: 4px 0;

            resize: both;
            overflow: auto;

            z-index: 100;
        }
        .popup > *:not(:first-child) {
            margin: 10px 0;
        }
        .popup-footer {
            text-align: left;
        }
        .expander {
            display: inline-block;
            padding: 5px;
            resize: none;
        }
        .expander.closed {
            font-weight: bold;
        }
        .btn-green {
            background-color: #edebdf;
            color: black;
            border: 2px solid grey;
            cursor: pointer;
        }
        pre.guide-title {
            text-align: center;
        }
        pre.guide-body {
            margin-left: 2.4cm;
            text-align: start;
        }
        .tags-row {
            display: flex;
            flex-direction: row;
            justify-content: center;
            gap: 14px;
        }
        .tag-list {
            display: flex;
            flex-direction: column;
            width: min-content;
            /* margin: auto; */
        }
        .tag-list .added-tags {
            max-height: 100px;
            overflow: auto;
        }
        h1, h2, h3, h4 {
            all: unset;
            display: block;
        }
        h1 {
            font-weight: bold;
            font-style: italic;
            font-size: 14pt;
        }
        h2 {
            font-size: 12pt;
        }
        .note {
            font-style: italic;
        }
    </style>
`);

const ftvlButton = shadow.getElementById("filename-template-variable-list-btn");
const ftvlContainer = shadow.getElementById("filename-template-variable-list-guide");

ftvlButton.addEventListener("click", () => {
    ftvlContainer.hidden = !ftvlContainer.hidden;
});

const pbdButton = shadow.getElementById("pbd-btn");
const pbdBox = shadow.getElementById("pbd-box");

pbdButton.addEventListener("click", () => {
    pbdBox.hidden = !pbdBox.hidden;
    pbdBox.classList.toggle("closed");
    pbdButton.textContent = `[${pbdBox.hidden ? "+" : "-"}] pbd`;
});

const filenameTemplateTextarea = shadow.getElementById("filename-template");

GM_setValue("filename-template", filenameTemplateTextarea.value)

filenameTemplateTextarea.addEventListener("change", () => {
    GM_setValue("filename-template", filenameTemplateTextarea.value)
});

navigationEvent.addEventListener("illust-navigate", async () => {
    selectedArtworkPartEntry.hidden = true;
    const caption = await waitForElement("figure ~ figcaption > div:has(div footer)", { parent: charcoal });
    const column = caption.children[0];
    column.append(parent);
});

// Artwork Selector & Artwork Quality

const selectedArtworkPartEntry = shadow.getElementById("selected-artwork-part-entry");
const artworkPartSelector = shadow.getElementById("artwork-part-selector");
const bulkRangeContainer = shadow.getElementById("bulk-range");
const bulkFromSelector = shadow.getElementById("bulk-from");
const bulkToSelector = shadow.getElementById("bulk-to");
const artworkQualitySelector = shadow.getElementById("artwork-quality-selector");

let artworkPartSelectorDict = {};

navigationEvent.addEventListener("illust-navigate", async () => {
    pbdBox.dataset.page = "illust";
    const pixivIllustPagesUrl = `https://www.pixiv.net/ajax/illust/${window.location.pathname.split("/").slice(-1)}/pages?lang=en`;
    artworkPartSelector.replaceChildren(); // remove all children
    bulkFromSelector.replaceChildren();
    bulkToSelector.replaceChildren();
    artworkPartSelectorDict = {};

    bulkRangeContainer.hidden = true;

    const illustPages = await fetch(pixivIllustPagesUrl, {
        headers: {
            Accept: "application/json",
            Referer: window.location.href
        }
    }).then(i => i.json());

    let counter = 0;
    for (const { urls, width, height } of illustPages.body) {
        const artworkPartOption = Object.assign(document.createElement("option"), {
            textContent: `p${counter}: ${width}x${height}`,
            value: urls.original
        });
        artworkPartSelector.append(artworkPartOption);
        artworkPartSelectorDict[urls.original] = { urls, width, height };

        const bulkFromToNumOption = Object.assign(document.createElement("option"), {
            textContent: counter,
            value: counter
        });

        bulkFromSelector.append(bulkFromToNumOption);
        bulkToSelector.append(bulkFromToNumOption.cloneNode(true));

        counter++;
    }

    if (counter > 1) {
        selectedArtworkPartEntry.hidden = false;
    }

    bulkToSelector.value = counter;
    
    const bulkDownloadOption = Object.assign(document.createElement("option"), {
        id: "bulk-download",
        textContent: "Bulk Download",
        value: "bulk-download",
    });

    artworkPartSelector.append(bulkDownloadOption);
});

// Download Button

const downloadButton = shadow.getElementById("btn-download");

downloadButton.addEventListener("click", () => {
    const filenameTemplate = filenameTemplateTextarea.value;
    if (artworkPartSelector.value == "bulk-download") {
        for (const imageUrl of Object.keys(artworkPartSelectorDict)) {
            downloadIllust(imageUrl, filenameTemplate);
        }
    }
    else {
        downloadIllust(artworkPartSelector.value, filenameTemplate);
    }
});

function downloadIllust(imageUrl, filenameTemplate) {
    const downloadUrl = artworkPartSelectorDict[imageUrl].urls[artworkQualitySelector.value];
    const dictionary = {
        ...getIllustDictionary(),
        ...getDownloadDictionary({
            url: new URL(downloadUrl),
            artworkQuality: artworkQualitySelector.value,
            artworkPart: Object.keys(artworkPartSelectorDict).findIndex(i => i == imageUrl),
            artworkPartTotal: Object.keys(artworkPartSelectorDict).length
        })
    };
    GM_download({
        url: downloadUrl,
        name: formatTemplate(filenameTemplate, dictionary),
        headers: {
            Referer: window.location.href
        },
        saveAs: false
    });
}

// Artist Filter

navigationEvent.addEventListener("charcoal-navigate", () => {
    pbdBox.dataset.page = "artist";
});


}(); // main async function

function createShadowDom(innerHTML) {
    const parent = document.createElement("div");
    const shadow = parent.attachShadow({ mode: "closed" });
    shadow.innerHTML = innerHTML;
    return { parent, shadow };
}

// Filename Template Functions

function formatTemplate(template, dictionary, { matcher = "/{{@.-}}/" } = {}) {
    let formatted = template;
    for (const [k, v] of Object.entries(dictionary)) {
        formatted = formatted.replace(matcher.replace("{{@.-}}", k), v);
    }
    return formatted;
}

function getIllustDictionary() {
    const authorAsideProfile = document.querySelector("aside h2 > div [data-gtm-value]:has([title])");
    const authorName = authorAsideProfile.querySelector("[title]").getAttribute("title");
    const illustPostingDate = new Date(document.querySelector("time[title='Posting date']").dateTime);
    return {
        // illustration info
        "illust-id": window.location.pathname.split("/").slice(-1)[0],
        "illust-title": document.title.split("/").slice(1).join("/").slice(1).split(" - pixiv")[0],
        "illust-tags-short": document.title.split("/")[0].slice(0,-1),
        "illust-original": document.querySelector("figure ~ figcaption [href*='オリジナル']") ? "original" : "non-original",
        "illust-author-name": authorName,
        "illust-author-id": authorAsideProfile.dataset.gtmValue,
        // illustration social stats
        "illust-like-num": document.querySelector("dd[title='Like']").textContent,
        "illust-bookmark-num": document.querySelector("dd[title='Bookmarks']").textContent,
        "illust-view-num": document.querySelector("dd[title='Views']").textContent,
        // illustration posting datetime
        ...getDateTimeDictionary("illust", illustPostingDate),
        // current datetime
        ...getDateTimeDictionary("current", new Date),
        // website info
        "website-title": document.title,
        "website-lang": window.location.pathname.split("/")[1],
    };
}

function getDownloadDictionary(context) {
    const fileUrlName = context.url.pathname.split("/").slice(-1)[0];
    const urlDateArray = context.url.pathname.split("/img/")[1].split("/").slice(0, 6);
    const urlDate = new Date(urlDateArray.slice(0,3).join("-") + "T" + urlDateArray.slice(3).join(":") + "+09:00");
    return {
        // artwork
        "artwork-quality": context.artworkQuality,
        "artwork-part": context.artworkPart,
        "artwork-parts-num": context.artworkPartTotal,
        // file info
        "file-extension": fileUrlName.split(".").slice(-1)[0],
        "file-url-name": fileUrlName,
        ...getDateTimeDictionary("file-url", urlDate),
    };
}

function getDateTimeDictionary(namespace, date) {
    return {
        [`${namespace}-datetime`]: date.toLocaleString("default", { dateStyle: "long" }),
        [`${namespace}-datetime-hours-24`]: date.getHours(),
        [`${namespace}-datetime-hours`]: Math.abs(date.getHours() % 12 || 12),
        [`${namespace}-datetime-minutes`]: date.getMinutes(),
        [`${namespace}-datetime-seconds`]: date.getSeconds(),
        [`${namespace}-datetime-ampm`]: date.getHours() >= 12 ? "PM" : "AM",
        [`${namespace}-datetime-date`]: date.getDate(),
        [`${namespace}-datetime-day`]: date.getDay(),
        [`${namespace}-datetime-month`]: date.getMonth() + 1,
        [`${namespace}-datetime-year`]: date.getFullYear(),
        [`${namespace}-datetime-day-name`]: date.toLocaleString("default", { weekday: "long" }),
        [`${namespace}-datetime-month-name`]: date.toLocaleString("default", { month: "long" }),
        [`${namespace}-datetime-timestamp`]: date.getTime(),
    };
}