- // ==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.gf.qytechs.cn/scripts/488160/1335044/make-mutation-observer.js
- // @require https://update.gf.qytechs.cn/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(),
- };
- }