pixiv bulk downloader

simple script to download multiple arts from pixiv illustration

  1. // ==UserScript==
  2. // @name pixiv bulk downloader
  3. // @description simple script to download multiple arts from pixiv illustration
  4. // @version 0.0.2
  5. // @namespace owowed.moe
  6. // @author owowed
  7. // @license GPL-3.0-or-later
  8. // @match *://www.pixiv.net/*
  9. // @require https://update.gf.qytechs.cn/scripts/488160/1335044/make-mutation-observer.js
  10. // @require https://update.gf.qytechs.cn/scripts/488161/1335046/wait-for-element.js
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_download
  14. // @run-at document-end
  15. // @copyright All rights reserved. Licensed under GPL-3.0-or-later. View license at https://spdx.org/licenses/GPL-3.0-or-later.html
  16. // ==/UserScript==
  17.  
  18. ;3 ;3 ;3
  19.  
  20. !async function() { // main async function
  21.  
  22. /* Pixiv Website Navigation Event */
  23.  
  24. const navigationEvent = new EventTarget();
  25. const charcoal = await waitForElement(".charcoal-token > div > div[style]:not([class])");
  26.  
  27. // Charcoal Navigation
  28.  
  29. let lastHrefDispatched;
  30.  
  31. makeMutationObserverOptions(
  32. { target: charcoal, childList: true, attributes: true },
  33. () => {
  34. if (lastHrefDispatched != window.location.href) {
  35. navigationEvent.dispatchEvent(new Event("charcoal-navigate"));
  36. lastHrefDispatched = window.location.href;
  37. }
  38. }
  39. );
  40.  
  41. setTimeout(() => {
  42. if (document.readyState == "loading") {
  43. document.addEventListener("DOMContentLoaded", () => {
  44. dispatchCharcoalNavigateEvent();
  45. });
  46. }
  47. else {
  48. dispatchCharcoalNavigateEvent();
  49. }
  50. });
  51.  
  52. // Illustration Navigation
  53.  
  54. navigationEvent.addEventListener("charcoal-navigate", async () => {
  55. if (!window.location.href.includes("/artworks/")) return;
  56.  
  57. navigationEvent.dispatchEvent(new Event("illust-open"));
  58. navigationEvent.dispatchEvent(new Event("illust-navigate"));
  59.  
  60. const illustAnchor = await waitForElement("figure:has(~ figcaption)");
  61. let lastWindowHref = window.location.href;
  62.  
  63. const observer = makeMutationObserverOptions({ target: illustAnchor, childList: true, subtree: true }, () => {
  64. if (lastWindowHref == window.location.href) return;
  65. navigationEvent.dispatchEvent(new Event("illust-navigate"));
  66. lastWindowHref = window.location.href;
  67. });
  68.  
  69. navigationEvent.addEventListener("charcoal-navigate", () => {
  70. observer.disconnect();
  71. navigationEvent.dispatchEvent(new Event("illust-close"));
  72. }, { once: true });
  73. });
  74.  
  75. /* Pixiv Bulk Downloader */
  76.  
  77. // Downloader Box
  78.  
  79. const filenameTemplateVariablesGuide = ""
  80. + "/illust-id/ Numeric id for the illustration\n"
  81. + "/illust-title/ Illustration title\n"
  82. + "/illust-tags-short/ Short tags derived from the illustration title\n"
  83. + "/illust-original/ If the illustration is tagged original, then it is written 'original', otherwise it is 'non-original'\n"
  84. + "/illust-author-name/ Author name\n"
  85. + "/illust-author-id/ Numeric id for the author\n"
  86. + "/illust-like-num/ Illustration like count\n"
  87. + "/illust-bookmark-num/ Illustration bookmark count\n"
  88. + "/illust-view-num/ Illustration view count\n"
  89. + "/illust-datetime/ Illustration posting date and time in long format\n"
  90. + "/illust-datetime-hours-24/ Illustration posting 24-hour format\n"
  91. + "/illust-datetime-hours/ Illustration posting 12-hour format\n"
  92. + "/illust-datetime-minutes/ Illustration posting minutes\n"
  93. + "/illust-datetime-seconds/ Illustration posting seconds\n"
  94. + "/illust-datetime-ampm/ Illustration posting AM/PM\n"
  95. + "/illust-datetime-date/ Illustration posting date\n"
  96. + "/illust-datetime-day/ Illustration posting day of the week as a number\n"
  97. + "/illust-datetime-month/ Illustration posting month as a number\n"
  98. + "/illust-datetime-year/ Illustration posting year\n"
  99. + "/illust-datetime-day-name/ Illustration posting day of the week as a name\n"
  100. + "/illust-datetime-month-name/ Illustration posting month as a name\n"
  101. + "/illust-datetime-timestamp/ Illustration posting timestamp\n"
  102. + "/current-datetime/ Current date and time in long format\n"
  103. + "/current-datetime-{...}/ Same as 'illust-datetime-{...}'\n"
  104. + "/artwork-quality/ Selected artwork quality\n"
  105. + "/artwork-part/ Selected artwork part\n"
  106. + "/artwork-parts-num/ Artwork total part count\n"
  107. + "/file-extension/ File extension for the image\n"
  108. + "/file-url-name/ File name from the source URL\n"
  109. + "/file-url-datetime/ File URL's associated date and time in long format\n"
  110. + "/file-url-datetime-{...}/ Same as 'illust-datetime-{...}'\n"
  111. + "/website-title/ Website title when it was downloaded\n"
  112. + "/website-lang/ Website language from the website URL";
  113. const defaultFilenameTemplate = "/illust-title/ by /illust-author-name/ #/artwork-part/ (/illust-tags-short/) [pixiv /illust-id/]./file-extension/";
  114. const { parent, shadow } = createShadowDom(`
  115. <button id="pbd-btn" class="btn-green expander closed">
  116. [+] pbd
  117. </button>
  118. <div id="pbd-box" class="popup" hidden>
  119. <h1>pivix bulk downloader</h1>
  120. <span class="note">userscript made by owowed</span>
  121. <div>
  122. <h2>Filename Template</h2>
  123. <div>Naming format for the filename. Include artwork info: author name, creation date, etc.</div>
  124. <textarea id="filename-template" cols="70" spellcheck="false">${GM_getValue("filename-template", defaultFilenameTemplate)}</textarea>
  125. </div>
  126.  
  127. <div>
  128. <h2>Artwork Quality</h2>
  129. <div>Select artwork quality to download.</div>
  130. <select id="artwork-quality-selector">
  131. <option value="original">Original (best)</option>
  132. <option value="regular">Regular</option>
  133. <option value="small">Small</option>
  134. <option value="thumb_mini">Thumbnail Mini</option>
  135. </select>
  136. </div>
  137.  
  138. <div id="selected-artwork-part-entry">
  139. <h2>Selected Artwork Part</h2>
  140. <div>Illustration can have multiple artworks (comic, doujin, etc.), you can manully select or bulk download them.</div>
  141. <select id="artwork-part-selector"></select>
  142. <div id="bulk-range" hidden>
  143. From: <select id="bulk-from"></select> To: <select id="bulk-to"></select>
  144. </div>
  145. </div>
  146.  
  147. <!-- Author Page Exclusive -->
  148.  
  149. <div class="artist-filter" hidden>
  150. <h2>Illustration Date</h2>
  151. <div>Select illustrations to download from the posting date</div>
  152. From: <input type="date" />
  153. To: <input type="date" />
  154. </div>
  155.  
  156. <div class="artist-filter" hidden>
  157. <h2>Illustration Tags</h2>
  158. <div>Select illustrations to download from inclusion/exclusion of tags</div>
  159. <div class="tags-row">
  160. <div>
  161. Inclusion:
  162. <div class="tag-list">
  163. <div class="added-tags">
  164. <input type="text" value="touhou-project" readonly/>
  165. <input type="text" value="satori-komeiji" readonly/>
  166. </div>
  167. <input type="text"/>
  168. </div>
  169. </div>
  170. <div>
  171. Exclusion:
  172. <div class="tag-list">
  173. <div class="added-tags">
  174. <input type="text" value="touhou-project" readonly/>
  175. <input type="text" value="satori-komeiji" readonly/>
  176. </div>
  177. <input type="text"/>
  178. </div>
  179. </div>
  180. </div>
  181. </div>
  182.  
  183. <style>
  184. :not([data-page="artist"]) .artist-filter {
  185. display: none;
  186. }
  187. </style>
  188.  
  189. <div>
  190. <button id="btn-download">Start Download</button>
  191. </div>
  192.  
  193. <div class="popup-footer">
  194. <button id="logs-btn" class="btn-green" hidden>[?] Logs</button>
  195. <button id="filename-template-variable-list-btn" class="btn-green">[?] Filename Template Variable List</button>
  196. </div>
  197.  
  198. <div id="filename-template-variable-list-guide" class="popup" hidden>
  199. <pre class="guide-title">Filename Template Variables</pre>
  200. <pre class="guide-body">${filenameTemplateVariablesGuide}</pre>
  201. </div>
  202. </div>
  203. <style>
  204. #pbd-btn {
  205. margin: 12px 0 4px 0;
  206. }
  207. .popup {
  208. background: #E3E0D1;
  209. font-family: arial,helvetica,sans-serif;
  210. color: black;
  211. text-align: center;
  212.  
  213. border: 2px solid grey;
  214. padding: 10px;
  215. margin: 4px 0;
  216.  
  217. resize: both;
  218. overflow: auto;
  219.  
  220. z-index: 100;
  221. }
  222. .popup > *:not(:first-child) {
  223. margin: 10px 0;
  224. }
  225. .popup-footer {
  226. text-align: left;
  227. }
  228. .expander {
  229. display: inline-block;
  230. padding: 5px;
  231. resize: none;
  232. }
  233. .expander.closed {
  234. font-weight: bold;
  235. }
  236. .btn-green {
  237. background-color: #edebdf;
  238. color: black;
  239. border: 2px solid grey;
  240. cursor: pointer;
  241. }
  242. pre.guide-title {
  243. text-align: center;
  244. }
  245. pre.guide-body {
  246. margin-left: 2.4cm;
  247. text-align: start;
  248. }
  249. .tags-row {
  250. display: flex;
  251. flex-direction: row;
  252. justify-content: center;
  253. gap: 14px;
  254. }
  255. .tag-list {
  256. display: flex;
  257. flex-direction: column;
  258. width: min-content;
  259. /* margin: auto; */
  260. }
  261. .tag-list .added-tags {
  262. max-height: 100px;
  263. overflow: auto;
  264. }
  265. h1, h2, h3, h4 {
  266. all: unset;
  267. display: block;
  268. }
  269. h1 {
  270. font-weight: bold;
  271. font-style: italic;
  272. font-size: 14pt;
  273. }
  274. h2 {
  275. font-size: 12pt;
  276. }
  277. .note {
  278. font-style: italic;
  279. }
  280. </style>
  281. `);
  282.  
  283. const ftvlButton = shadow.getElementById("filename-template-variable-list-btn");
  284. const ftvlContainer = shadow.getElementById("filename-template-variable-list-guide");
  285.  
  286. ftvlButton.addEventListener("click", () => {
  287. ftvlContainer.hidden = !ftvlContainer.hidden;
  288. });
  289.  
  290. const pbdButton = shadow.getElementById("pbd-btn");
  291. const pbdBox = shadow.getElementById("pbd-box");
  292.  
  293. pbdButton.addEventListener("click", () => {
  294. pbdBox.hidden = !pbdBox.hidden;
  295. pbdBox.classList.toggle("closed");
  296. pbdButton.textContent = `[${pbdBox.hidden ? "+" : "-"}] pbd`;
  297. });
  298.  
  299. const filenameTemplateTextarea = shadow.getElementById("filename-template");
  300.  
  301. GM_setValue("filename-template", filenameTemplateTextarea.value)
  302.  
  303. filenameTemplateTextarea.addEventListener("change", () => {
  304. GM_setValue("filename-template", filenameTemplateTextarea.value)
  305. });
  306.  
  307. navigationEvent.addEventListener("illust-navigate", async () => {
  308. selectedArtworkPartEntry.hidden = true;
  309. const caption = await waitForElement("figure ~ figcaption > div:has(div footer)", { parent: charcoal });
  310. const column = caption.children[0];
  311. column.append(parent);
  312. });
  313.  
  314. // Artwork Selector & Artwork Quality
  315.  
  316. const selectedArtworkPartEntry = shadow.getElementById("selected-artwork-part-entry");
  317. const artworkPartSelector = shadow.getElementById("artwork-part-selector");
  318. const bulkRangeContainer = shadow.getElementById("bulk-range");
  319. const bulkFromSelector = shadow.getElementById("bulk-from");
  320. const bulkToSelector = shadow.getElementById("bulk-to");
  321. const artworkQualitySelector = shadow.getElementById("artwork-quality-selector");
  322.  
  323. let artworkPartSelectorDict = {};
  324.  
  325. navigationEvent.addEventListener("illust-navigate", async () => {
  326. pbdBox.dataset.page = "illust";
  327. const pixivIllustPagesUrl = `https://www.pixiv.net/ajax/illust/${window.location.pathname.split("/").slice(-1)}/pages?lang=en`;
  328. artworkPartSelector.replaceChildren(); // remove all children
  329. bulkFromSelector.replaceChildren();
  330. bulkToSelector.replaceChildren();
  331. artworkPartSelectorDict = {};
  332.  
  333. bulkRangeContainer.hidden = true;
  334.  
  335. const illustPages = await fetch(pixivIllustPagesUrl, {
  336. headers: {
  337. Accept: "application/json",
  338. Referer: window.location.href
  339. }
  340. }).then(i => i.json());
  341.  
  342. let counter = 0;
  343. for (const { urls, width, height } of illustPages.body) {
  344. const artworkPartOption = Object.assign(document.createElement("option"), {
  345. textContent: `p${counter}: ${width}x${height}`,
  346. value: urls.original
  347. });
  348. artworkPartSelector.append(artworkPartOption);
  349. artworkPartSelectorDict[urls.original] = { urls, width, height };
  350.  
  351. const bulkFromToNumOption = Object.assign(document.createElement("option"), {
  352. textContent: counter,
  353. value: counter
  354. });
  355.  
  356. bulkFromSelector.append(bulkFromToNumOption);
  357. bulkToSelector.append(bulkFromToNumOption.cloneNode(true));
  358.  
  359. counter++;
  360. }
  361.  
  362. if (counter > 1) {
  363. selectedArtworkPartEntry.hidden = false;
  364. }
  365.  
  366. bulkToSelector.value = counter;
  367. const bulkDownloadOption = Object.assign(document.createElement("option"), {
  368. id: "bulk-download",
  369. textContent: "Bulk Download",
  370. value: "bulk-download",
  371. });
  372.  
  373. artworkPartSelector.append(bulkDownloadOption);
  374. });
  375.  
  376. // Download Button
  377.  
  378. const downloadButton = shadow.getElementById("btn-download");
  379.  
  380. downloadButton.addEventListener("click", () => {
  381. const filenameTemplate = filenameTemplateTextarea.value;
  382. if (artworkPartSelector.value == "bulk-download") {
  383. for (const imageUrl of Object.keys(artworkPartSelectorDict)) {
  384. downloadIllust(imageUrl, filenameTemplate);
  385. }
  386. }
  387. else {
  388. downloadIllust(artworkPartSelector.value, filenameTemplate);
  389. }
  390. });
  391.  
  392. function downloadIllust(imageUrl, filenameTemplate) {
  393. const downloadUrl = artworkPartSelectorDict[imageUrl].urls[artworkQualitySelector.value];
  394. const dictionary = {
  395. ...getIllustDictionary(),
  396. ...getDownloadDictionary({
  397. url: new URL(downloadUrl),
  398. artworkQuality: artworkQualitySelector.value,
  399. artworkPart: Object.keys(artworkPartSelectorDict).findIndex(i => i == imageUrl),
  400. artworkPartTotal: Object.keys(artworkPartSelectorDict).length
  401. })
  402. };
  403. GM_download({
  404. url: downloadUrl,
  405. name: formatTemplate(filenameTemplate, dictionary),
  406. headers: {
  407. Referer: window.location.href
  408. },
  409. saveAs: false
  410. });
  411. }
  412.  
  413. // Artist Filter
  414.  
  415. navigationEvent.addEventListener("charcoal-navigate", () => {
  416. pbdBox.dataset.page = "artist";
  417. });
  418.  
  419.  
  420. }(); // main async function
  421.  
  422. function createShadowDom(innerHTML) {
  423. const parent = document.createElement("div");
  424. const shadow = parent.attachShadow({ mode: "closed" });
  425. shadow.innerHTML = innerHTML;
  426. return { parent, shadow };
  427. }
  428.  
  429. // Filename Template Functions
  430.  
  431. function formatTemplate(template, dictionary, { matcher = "/{{@.-}}/" } = {}) {
  432. let formatted = template;
  433. for (const [k, v] of Object.entries(dictionary)) {
  434. formatted = formatted.replace(matcher.replace("{{@.-}}", k), v);
  435. }
  436. return formatted;
  437. }
  438.  
  439. function getIllustDictionary() {
  440. const authorAsideProfile = document.querySelector("aside h2 > div [data-gtm-value]:has([title])");
  441. const authorName = authorAsideProfile.querySelector("[title]").getAttribute("title");
  442. const illustPostingDate = new Date(document.querySelector("time[title='Posting date']").dateTime);
  443. return {
  444. // illustration info
  445. "illust-id": window.location.pathname.split("/").slice(-1)[0],
  446. "illust-title": document.title.split("/").slice(1).join("/").slice(1).split(" - pixiv")[0],
  447. "illust-tags-short": document.title.split("/")[0].slice(0,-1),
  448. "illust-original": document.querySelector("figure ~ figcaption [href*='オリジナル']") ? "original" : "non-original",
  449. "illust-author-name": authorName,
  450. "illust-author-id": authorAsideProfile.dataset.gtmValue,
  451. // illustration social stats
  452. "illust-like-num": document.querySelector("dd[title='Like']").textContent,
  453. "illust-bookmark-num": document.querySelector("dd[title='Bookmarks']").textContent,
  454. "illust-view-num": document.querySelector("dd[title='Views']").textContent,
  455. // illustration posting datetime
  456. ...getDateTimeDictionary("illust", illustPostingDate),
  457. // current datetime
  458. ...getDateTimeDictionary("current", new Date),
  459. // website info
  460. "website-title": document.title,
  461. "website-lang": window.location.pathname.split("/")[1],
  462. };
  463. }
  464.  
  465. function getDownloadDictionary(context) {
  466. const fileUrlName = context.url.pathname.split("/").slice(-1)[0];
  467. const urlDateArray = context.url.pathname.split("/img/")[1].split("/").slice(0, 6);
  468. const urlDate = new Date(urlDateArray.slice(0,3).join("-") + "T" + urlDateArray.slice(3).join(":") + "+09:00");
  469. return {
  470. // artwork
  471. "artwork-quality": context.artworkQuality,
  472. "artwork-part": context.artworkPart,
  473. "artwork-parts-num": context.artworkPartTotal,
  474. // file info
  475. "file-extension": fileUrlName.split(".").slice(-1)[0],
  476. "file-url-name": fileUrlName,
  477. ...getDateTimeDictionary("file-url", urlDate),
  478. };
  479. }
  480.  
  481. function getDateTimeDictionary(namespace, date) {
  482. return {
  483. [`${namespace}-datetime`]: date.toLocaleString("default", { dateStyle: "long" }),
  484. [`${namespace}-datetime-hours-24`]: date.getHours(),
  485. [`${namespace}-datetime-hours`]: Math.abs(date.getHours() % 12 || 12),
  486. [`${namespace}-datetime-minutes`]: date.getMinutes(),
  487. [`${namespace}-datetime-seconds`]: date.getSeconds(),
  488. [`${namespace}-datetime-ampm`]: date.getHours() >= 12 ? "PM" : "AM",
  489. [`${namespace}-datetime-date`]: date.getDate(),
  490. [`${namespace}-datetime-day`]: date.getDay(),
  491. [`${namespace}-datetime-month`]: date.getMonth() + 1,
  492. [`${namespace}-datetime-year`]: date.getFullYear(),
  493. [`${namespace}-datetime-day-name`]: date.toLocaleString("default", { weekday: "long" }),
  494. [`${namespace}-datetime-month-name`]: date.toLocaleString("default", { month: "long" }),
  495. [`${namespace}-datetime-timestamp`]: date.getTime(),
  496. };
  497. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址