pixiv bulk downloader

simple script to download multiple arts from pixiv illustration

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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(),
    };
}