Bobby's Pixiv Utils

7/2/2024, 8:37:14 PM

当前为 2025-04-06 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Bobby's Pixiv Utils
  3. // @namespace https://github.com/BobbyWibowo
  4. // @match *://www.pixiv.net/*
  5. // @exclude *://www.pixiv.net/setting*
  6. // @exclude *://www.pixiv.net/manage*
  7. // @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
  8. // @grant GM_addStyle
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant window.onurlchange
  12. // @run-at document-start
  13. // @version 1.5.1
  14. // @author Bobby Wibowo
  15. // @license MIT
  16. // @description 7/2/2024, 8:37:14 PM
  17. // @require https://cdn.jsdelivr.net/npm/sentinel-js@0.0.7/dist/sentinel.min.js
  18. // @noframes
  19. // ==/UserScript==
  20.  
  21. /* global sentinel */
  22.  
  23. (function () {
  24. 'use strict';
  25.  
  26. const _logTime = () => {
  27. return new Date().toLocaleTimeString([], {
  28. hourCycle: 'h12',
  29. hour: '2-digit',
  30. minute: '2-digit',
  31. second: '2-digit',
  32. fractionalSecondDigits: 3
  33. })
  34. .replaceAll('.', ':')
  35. .replace(',', '.')
  36. .toLocaleUpperCase();
  37. };
  38.  
  39. const log = (message, ...args) => {
  40. const prefix = `[${_logTime()}]: `;
  41. if (typeof message === 'string') {
  42. return console.log(prefix + message, ...args);
  43. } else {
  44. return console.log(prefix, message, ...args);
  45. }
  46. };
  47.  
  48. /** CONFIG **/
  49.  
  50. // It's recommended to edit these values through your userscript manager's storage/values editor.
  51. // For Tampermonkey users, load Pixiv once after installing the userscript,
  52. // to allow it to populate its storage with default values.
  53. const ENV_DEFAULTS = {
  54. MODE: 'PROD',
  55.  
  56. TEXT_EDIT_BOOKMARK: '✏️',
  57. TEXT_EDIT_BOOKMARK_TOOLTIP: 'Edit bookmark',
  58.  
  59. TEXT_TOGGLE_BOOKMARKED: '❤️',
  60. TEXT_TOGGLE_BOOKMARKED_TOOLTIP: 'Cycle bookmarked display (Right-Click to cycle back)',
  61. TEXT_TOGGLE_BOOKMARKED_SHOW_ALL: 'Show all',
  62. TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED: 'Show bookmarked',
  63. TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED: 'Show not bookmarked',
  64.  
  65. // The following options have hard-coded preset values. Scroll further to find them.
  66. // Specifiying custom values will extend instead of replacing them.
  67. SELECTORS_HOME: null,
  68. SELECTORS_IMAGE: null,
  69. SELECTORS_IMAGE_TITLE: null,
  70. SELECTORS_IMAGE_ARTIST_AVATAR: null,
  71. SELECTORS_IMAGE_ARTIST_NAME: null,
  72. SELECTORS_IMAGE_CONTROLS: null,
  73. SELECTORS_IMAGE_BOOKMARKED: null,
  74. SELECTORS_EXPANDED_VIEW_CONTROLS: null,
  75. SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: null,
  76. SELECTORS_MULTI_VIEW: null,
  77. SELECTORS_MULTI_VIEW_CONTROLS: null,
  78. SELECTORS_FOLLOW_BUTTON_CONTAINER: null,
  79. SELECTORS_FOLLOW_BUTTON: null,
  80.  
  81. DATE_CONVERSION: true,
  82. DATE_CONVERSION_LOCALES: 'en-GB',
  83. DATE_CONVERSION_OPTIONS: {
  84. hour12: true,
  85. year: 'numeric',
  86. month: 'long',
  87. day: 'numeric',
  88. hour: '2-digit',
  89. minute: '2-digit'
  90. },
  91. // This has a hard-coded preset value. Specifiying a custom value will extend instead of replacing it.
  92. SELECTORS_DATE: null,
  93.  
  94. REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: false,
  95.  
  96. // This has a hard-coded preset value. Specifiying a custom value will extend instead of replacing it.
  97. SECTIONS_TOGGLE_BOOKMARKED: null,
  98.  
  99. ENABLE_KEYBINDS: true,
  100.  
  101. UTAGS_INTEGRATION: true,
  102. // Hard-coded presets of "block" and "hide" tags. Specifying custom values will extend instead of replacing them.
  103. UTAGS_BLOCKED_TAGS: null,
  104. // Instead of merely hiding them à la Pixiv's built-in tags mute.
  105. UTAGS_REMOVE_BLOCKED: false
  106. };
  107.  
  108. const ENV = {};
  109.  
  110. // Store preset values.
  111. for (const key of Object.keys(ENV_DEFAULTS)) {
  112. const stored = GM_getValue(key);
  113. if (stored === null || stored === undefined) {
  114. ENV[key] = ENV_DEFAULTS[key];
  115. GM_setValue(key, ENV_DEFAULTS[key]);
  116. } else {
  117. ENV[key] = stored;
  118. }
  119. }
  120.  
  121. /* DOCUMENTATION
  122. * -------------
  123. * For any section that does not have complete selectors, it's implied that they are already matched using selectors contained in sections that preceded it.
  124. * NOTE: Figure out selectors that are more update-proof.
  125. *
  126. * Home's recommended works grid:
  127. * Image: .sc-96f10c4f-0 > li
  128. * Title: [data-ga4-label="title_link"]
  129. * Artist avatar: [data-ga4-label="user_icon_link"]
  130. * Artist name: [data-ga4-label="user_name_link"]
  131. * Controls: .sc-eacaaccb-9
  132. * Bookmarked: .bXjFLc
  133. *
  134. * Home's latest works grid:
  135. * Image: li[data-ga4-label="thumbnail"]
  136. *
  137. * Discovery page's grid:
  138. * Title: .gtm-illust-recommend-title
  139. * Controls: .ppQNN
  140. *
  141. * Artist page's grid:
  142. * Image: .sc-9y4be5-1 > li
  143. * Controls: .sc-iasfms-4
  144. *
  145. * Expanded view's artist works bottom row:
  146. * Image: .sc-1nhgff6-4 > div:has(a[href])
  147. *
  148. * Expanded view's related works grid:
  149. * Artist avatar: .sc-1rx6dmq-1
  150. * Artist name: .gtm-illust-recommend-user-name
  151. *
  152. * Artist page's featured works:
  153. * Image: .sc-1sxj2bl-5 > li
  154. * Controls: .sc-xsxgxe-3
  155. *
  156. * Bookmarks page's grid:
  157. * Title: .sc-iasfms-6
  158. * Artist name: .sc-1rx6dmq-2
  159. *
  160. * Tag page's grid:
  161. * Image: .sc-l7cibp-1 > li
  162. *
  163. * Rankings page:
  164. * Image: .ranking-item
  165. * Title: .title
  166. * Artist avatar: ._user-icon
  167. * Artist name: .user-name
  168. * Controls: ._layout-thumbnail
  169. * Bookmarked: ._one-click-bookmark.on
  170. *
  171. * Newest by all page:
  172. * Image: .sc-e6de33c8-0 > li
  173. * Bookmarked: .epoVSE
  174. *
  175. * General mobile page:
  176. * Image: .works-item-illust:has(.thumb:not([src^=data]))
  177. * Controls: .bookmark, .hSoPoc
  178. */
  179. const PRESETS = {
  180. SELECTORS_HOME: '[data-ga4-label="page_root"]',
  181. SELECTORS_IMAGE: '.sc-96f10c4f-0 > li, li[data-ga4-label="thumbnail"], .sc-9y4be5-1 > li, .sc-1sxj2bl-5 > li, .sc-l7cibp-1 > li, .ranking-item, .sc-e6de33c8-0 > li, .works-item-illust:has(.thumb:not([src^=data]))',
  182. SELECTORS_IMAGE_TITLE: '[data-ga4-label="title_link"], .gtm-illust-recommend-title, .sc-iasfms-6, .title',
  183. SELECTORS_IMAGE_ARTIST_AVATAR: '[data-ga4-label="user_icon_link"], .sc-1rx6dmq-1, ._user-icon',
  184. SELECTORS_IMAGE_ARTIST_NAME: '[data-ga4-label="user_name_link"], .gtm-illust-recommend-user-name, .sc-1rx6dmq-2, .user-name',
  185. SELECTORS_IMAGE_CONTROLS: '.sc-eacaaccb-9, .ppQNN, .sc-iasfms-4, .sc-xsxgxe-3, ._layout-thumbnail, .bookmark, .hSoPoc',
  186. SELECTORS_IMAGE_BOOKMARKED: '.bXjFLc, ._one-click-bookmark.on, .epoVSE',
  187. SELECTORS_EXPANDED_VIEW_CONTROLS: '.sc-181ts2x-0, .work-interactions',
  188. SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: '.sc-1nhgff6-4 > div:has(a[href])',
  189. SELECTORS_MULTI_VIEW: '[data-ga4-label="work_content"]:has(a[href])',
  190. SELECTORS_MULTI_VIEW_CONTROLS: '& > .w-full:last-child > .flex:first-child > .flex-row:first-child',
  191. SELECTORS_FOLLOW_BUTTON_CONTAINER: '.sc-gulj4d-2, .sc-k3uf3r-3, .sc-10gpz4q-3, .sc-f30yhg-3',
  192. SELECTORS_FOLLOW_BUTTON: '[data-click-label="follow"]:not([disabled])',
  193. SELECTORS_DATE: '.sc-5981ly-1',
  194.  
  195. SECTIONS_TOGGLE_BOOKMARKED: [
  196. // Bookmarks page
  197. {
  198. selectorParent: '.sc-jgyytr-0',
  199. selectorHeader: '.sc-s8zj3z-2',
  200. selectorImagesContainer: '.sc-s8zj3z-4'
  201. },
  202. // Artist page
  203. {
  204. selectorParent: '.sc-1xj6el2-3',
  205. selectorHeader: '.sc-1xj6el2-2',
  206. selectorImagesContainer: '& > div:last-child'
  207. },
  208. // Tag page
  209. {
  210. selectorParent: '.sc-jgyytr-0',
  211. selectorHeader: '.sc-7zddlj-0',
  212. selectorImagesContainer: '.sc-l7cibp-0'
  213. },
  214. // Rankings page
  215. {
  216. selectorParent: '#wrapper ._unit',
  217. selectorHeader: '.ranking-menu',
  218. selectorImagesContainer: '.ranking-items-container'
  219. },
  220. // Newest by all page
  221. {
  222. selectorParent: '.sc-7b5ed552-0',
  223. selectorHeader: '.sc-f08ce4e3-2',
  224. selectorImagesContainer: '.sc-a7a11491-1'
  225. }
  226. ],
  227.  
  228. UTAGS_BLOCKED_TAGS: ['block', 'hide']
  229. };
  230.  
  231. const CONFIG = {};
  232.  
  233. // Extend hard-coded preset values with user-defined custom values, if applicable.
  234. for (const key of Object.keys(ENV)) {
  235. if (key.startsWith('SELECTORS_')) {
  236. CONFIG[key] = PRESETS[key] || '';
  237. if (ENV[key]) {
  238. CONFIG[key] += `, ${Array.isArray(ENV[key]) ? ENV[key].join(', ') : ENV[key]}`;
  239. }
  240. } else if (Array.isArray(PRESETS[key])) {
  241. CONFIG[key] = PRESETS[key];
  242. if (ENV[key]) {
  243. const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
  244. CONFIG[key].push(...customValues);
  245. }
  246. } else {
  247. CONFIG[key] = PRESETS[key] || null;
  248. if (ENV[key] !== null) {
  249. CONFIG[key] = ENV[key];
  250. }
  251. }
  252. }
  253.  
  254. let logDebug = () => {};
  255. let logKeys = Object.keys(CONFIG);
  256. if (CONFIG.MODE === 'PROD') {
  257. // In PROD mode, only print some.
  258. logKeys = ['DATE_CONVERSION', 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME', 'ENABLE_KEYBINDS', 'UTAGS_INTEGRATION'];
  259. } else {
  260. logDebug = log;
  261. }
  262.  
  263. for (const key of logKeys) {
  264. log(`${key} =`, CONFIG[key]);
  265. }
  266.  
  267. /** GLOBAL UTILS **/
  268.  
  269. const addPageDateStyle = /* css */`
  270. .bookmark-detail-unit .meta {
  271. display: block;
  272. font-size: 16px;
  273. font-weight: bold;
  274. color: inherit;
  275. margin-left: 0;
  276. margin-top: 10px;
  277. }
  278. `;
  279.  
  280. const convertDate = (element, fixJapanTime = false) => {
  281. let date;
  282.  
  283. const attr = element.getAttribute('datetime');
  284. if (attr) {
  285. date = new Date(attr);
  286. } else {
  287. // For pages which have the date display hardcoded to Japan time.
  288. let dateText = element.innerText;
  289.  
  290. // For dates hard-coded to Japan locale.
  291. const match = dateText.match(/^(\d{4})年(\d{2})月(\d{2})日 (\d{2}:\d{2})$/);
  292. if (match) {
  293. dateText = `${match[2]}-${match[3]}-${match[1]} ${match[4]}`;
  294. }
  295.  
  296. if (fixJapanTime) {
  297. dateText += ' UTC+9';
  298. }
  299. date = new Date(dateText);
  300. }
  301.  
  302. if (!date) {
  303. return false;
  304. }
  305.  
  306. const timestamp = String(date.getTime());
  307. if (element.dataset.oldTimestamp && element.dataset.oldTimestamp === timestamp) {
  308. return false;
  309. }
  310.  
  311. element.dataset.oldTimestamp = timestamp;
  312. element.innerText = date.toLocaleString(CONFIG.DATE_CONVERSION_LOCALES, CONFIG.DATE_CONVERSION_OPTIONS);
  313. return true;
  314. };
  315.  
  316. /** INTERCEPT EARLY FOR CERTAIN ROUTES **/
  317.  
  318. const waitPageLoaded = () => {
  319. return new Promise(resolve => {
  320. if (document.readyState === 'complete' ||
  321. document.readyState === 'loaded' ||
  322. document.readyState === 'interactive') {
  323. resolve();
  324. } else {
  325. document.addEventListener('DOMContentLoaded', resolve);
  326. }
  327. });
  328. };
  329.  
  330. const path = location.pathname;
  331.  
  332. // Codes beyond this block will not execute for these routes (mainly for efficiency).
  333. if (path.startsWith('/bookmark_add.php') || path.startsWith('/novel/bookmark_add.php')) {
  334. if (CONFIG.DATE_CONVERSION) {
  335. waitPageLoaded().then(() => {
  336. GM_addStyle(addPageDateStyle);
  337. const date = document.querySelector('.bookmark-detail-unit .meta');
  338. if (date) {
  339. // This page has the date display hardcoded to Japan time without an accompanying timestamp.
  340. convertDate(date, true);
  341. }
  342. });
  343. }
  344.  
  345. log('bookmark_add.php detected. Excluding date conversion, script has terminated early.');
  346. return;
  347. }
  348.  
  349. /** MAIN UTILS */
  350.  
  351. let currentUrl = new URL(window.location.href, window.location.origin).href;
  352. const notify = (method, url) => {
  353. const newUrl = new URL(url || window.location.href, window.location.origin).href;
  354. if (currentUrl !== newUrl) {
  355. const event = new CustomEvent('detectnavigate');
  356. window.dispatchEvent(event);
  357. currentUrl = newUrl;
  358. }
  359. };
  360.  
  361. if (window.onurlchange === null) {
  362. window.addEventListener('urlchange', event => {
  363. notify('urlchange', event.url);
  364. });
  365. logDebug('Using window.onurlchange.');
  366. } else {
  367. const oldMethods = {};
  368. ['pushState', 'replaceState'].forEach(method => {
  369. oldMethods[method] = history[method];
  370. history[method] = function (...args) {
  371. oldMethods[method].apply(this, args);
  372. notify(method, args[2]);
  373. };
  374. });
  375.  
  376. window.addEventListener('popstate', event => {
  377. notify(event.type);
  378. });
  379. logDebug('Using window.onurlchange polyfill.');
  380. }
  381.  
  382. /** MAIN STYLES **/
  383.  
  384. // To properly handle "&" CSS keyword, in context of also having to support user-defined custom values.
  385. // Somewhat overkill, but I'm out of ideas.
  386. const _formatSelectorsMultiViewControls = () => {
  387. const multiViews = CONFIG.SELECTORS_MULTI_VIEW.split(', ');
  388. const multiViewsControls = CONFIG.SELECTORS_MULTI_VIEW_CONTROLS.split(', ');
  389.  
  390. const formatted = [];
  391. for (const x of multiViews) {
  392. for (const y of multiViewsControls) {
  393. let z = y;
  394. if (y.startsWith('&')) {
  395. z = y.substring(1);
  396. }
  397. formatted.push(`${x} ${z.trim()}`);
  398. }
  399. }
  400. return formatted;
  401. };
  402.  
  403. const mainStyle = /* css */`
  404. .flex:has(+ .pixiv_utils_edit_bookmark_container) {
  405. flex-grow: 1;
  406. }
  407.  
  408. .pixiv_utils_edit_bookmark {
  409. color: rgb(245, 245, 245);
  410. background: rgba(0, 0, 0, 0.5);
  411. display: block;
  412. box-sizing: border-box;
  413. padding: 0px 8px;
  414. margin-top: 7px;
  415. margin-right: 2px;
  416. border-radius: 10px;
  417. font-weight: bold;
  418. font-size: 10px;
  419. line-height: 20px;
  420. height: 20px;
  421. cursor: pointer;
  422. user-select: none;
  423. }
  424.  
  425. ${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS.split(', ').map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')},
  426. ${_formatSelectorsMultiViewControls().map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')} {
  427. font-size: 12px;
  428. height: 24px;
  429. line-height: 24px;
  430. margin-top: 5px;
  431. margin-right: 7px;
  432. }
  433.  
  434. ._layout-thumbnail .pixiv_utils_edit_bookmark {
  435. position: absolute;
  436. right: calc(50% - 71px);
  437. bottom: 4px;
  438. z-index: 2;
  439. }
  440.  
  441. .ranking-item.muted .pixiv_utils_edit_bookmark {
  442. display: none;
  443. }
  444.  
  445. *:has(> .pixiv_utils_image_artist_container) {
  446. position: relative;
  447. }
  448.  
  449. .pixiv_utils_image_artist_container {
  450. color: rgb(245, 245, 245);
  451. background: rgba(0, 0, 0, 0.5);
  452. display: inline-block;
  453. box-sizing: border-box;
  454. padding: 0px 8px;
  455. border-radius: 10px;
  456. font-weight: bold;
  457. font-size: 14px;
  458. line-height: 20px;
  459. height: 20px;
  460. position: absolute;
  461. bottom: 6px;
  462. left: 6px;
  463. }
  464.  
  465. .pixiv_utils_image_artist_container a {
  466. color: inherit;
  467. }
  468.  
  469. .sc-s8zj3z-3:has(+ .pixiv_utils_toggle_bookmarked_container),
  470. .sc-7c5ab71e-2:has(+ .pixiv_utils_toggle_bookmarked_container) {
  471. flex-grow: 1;
  472. justify-content: flex-end;
  473. }
  474.  
  475. .pixiv_utils_toggle_bookmarked_container {
  476. text-align: center;
  477. }
  478.  
  479. .pixiv_utils_toggle_bookmarked {
  480. color: rgb(245, 245, 245);
  481. background: rgb(58, 58, 58);
  482. display: inline-block;
  483. box-sizing: border-box;
  484. padding: 6px;
  485. border-radius: 10px;
  486. font-weight: bold;
  487. margin-left: 12px;
  488. cursor: pointer;
  489. user-select: none;
  490. }
  491.  
  492. .pixiv_utils_toggle_bookmarked:hover {
  493. text-decoration: none;
  494. }
  495.  
  496. .pixiv_utils_toggle_bookmarked span {
  497. padding-left: 6px;
  498. }
  499.  
  500. ${CONFIG.SELECTORS_IMAGE_CONTROLS} {
  501. display: flex;
  502. justify-content: flex-end;
  503. }
  504. `;
  505.  
  506. const mainDateStyle = /* css */`
  507. .dqHJfP {
  508. font-size: 14px !important;
  509. font-weight: bold;
  510. color: rgb(214, 214, 214) !important;
  511. }
  512. `;
  513.  
  514. /** UTAGS INTEGRATION INIT **/
  515.  
  516. const mainUtagsStyle = /* css */`
  517. .pixiv_utils_blocked_image {
  518. display: flex;
  519. justify-content: center;
  520. align-items: center;
  521. width: 100%;
  522. height: 100%;
  523. border-radius: 4px;
  524. color: rgb(92, 92, 92);
  525. min-width: 96px;
  526. min-height: 96px;
  527. }
  528.  
  529. .pixiv_utils_blocked_image svg {
  530. fill: currentcolor;
  531. }
  532.  
  533. ${CONFIG.SELECTORS_IMAGE_TITLE.split(', ').map(s => `[data-pixiv_utils_blocked] ${s}`).join(', ')} {
  534. color: rgb(133, 133, 133) !important;
  535. }
  536.  
  537. .ranking-item[data-pixiv_utils_blocked] ._illust-series-title-text {
  538. display: none;
  539. }
  540.  
  541. ${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR.split(', ').map(s => `[data-pixiv_utils_blocked] ${s}`).join(', ')} {
  542. display: none;
  543. }
  544.  
  545. ${CONFIG.SELECTORS_IMAGE_CONTROLS.split(', ').map(s => `[data-pixiv_utils_blocked] ${s}`).join(', ')} {
  546. display: none;
  547. }
  548. `;
  549.  
  550. const SELECTORS_UTAGS = CONFIG.UTAGS_BLOCKED_TAGS.map(s => `[data-utags_tag="${s}"]`).join(', ');
  551.  
  552. log('SELECTORS_UTAGS =', SELECTORS_UTAGS);
  553.  
  554. const BLOCKED_IMAGE_HTML = `
  555. <div radius="4" class="pixiv_utils_blocked_image">
  556. <svg viewBox="0 0 24 24" style="width: 48px; height: 48px;">
  557. <path d="M5.26763775,4 L9.38623853,11.4134814 L5,14.3684211 L5,18 L13.0454155,18 L14.1565266,20 L5,20
  558. C3.8954305,20 3,19.1045695 3,18 L3,6 C3,4.8954305 3.8954305,4 5,4 L5.26763775,4 Z M9.84347336,4 L19,4
  559. C20.1045695,4 21,4.8954305 21,6 L21,18 C21,19.1045695 20.1045695,20 19,20 L18.7323623,20 L17.6212511,18
  560. L19,18 L19,13 L16,15 L15.9278695,14.951913 L9.84347336,4 Z M16,7 C14.8954305,7 14,7.8954305 14,9
  561. C14,10.1045695 14.8954305,11 16,11 C17.1045695,11 18,10.1045695 18,9 C18,7.8954305 17.1045695,7 16,7 Z
  562. M7.38851434,1.64019979 L18.3598002,21.3885143 L16.6114857,22.3598002 L5.64019979,2.61148566
  563. L7.38851434,1.64019979 Z"></path>
  564. </svg>
  565. </div>
  566. `;
  567.  
  568. /** MAIN **/
  569.  
  570. GM_addStyle(mainStyle);
  571.  
  572. if (CONFIG.DATE_CONVERSION) {
  573. GM_addStyle(mainDateStyle);
  574. }
  575.  
  576. if (CONFIG.UTAGS_INTEGRATION) {
  577. GM_addStyle(mainUtagsStyle);
  578. }
  579.  
  580. const waitForIntervals = {};
  581.  
  582. const waitFor = (element = document, func) => {
  583. if (typeof func !== 'function') {
  584. return false;
  585. }
  586.  
  587. return new Promise((resolve) => {
  588. let interval = null;
  589. const find = () => {
  590. const result = func(element);
  591. if (result) {
  592. if (interval) {
  593. delete waitForIntervals[interval];
  594. clearInterval(interval);
  595. }
  596. return resolve(result);
  597. }
  598. };
  599. find();
  600. interval = setInterval(find, 100);
  601. waitForIntervals[interval] = { element, func, resolve };
  602. });
  603. };
  604.  
  605. const editBookmarkButton = (id, isNovel = false) => {
  606. const buttonContainer = document.createElement('div');
  607. buttonContainer.className = 'pixiv_utils_edit_bookmark_container';
  608.  
  609. const button = document.createElement('a');
  610. button.className = 'pixiv_utils_edit_bookmark';
  611. button.innerText = CONFIG.TEXT_EDIT_BOOKMARK;
  612.  
  613. if (CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP) {
  614. button.title = CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP;
  615. }
  616.  
  617. if (isNovel) {
  618. button.href = `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`;
  619. } else {
  620. button.href = `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`;
  621. }
  622.  
  623. buttonContainer.append(button);
  624. return buttonContainer;
  625. };
  626.  
  627. const findUrl = element => {
  628. return element.querySelector('a[href*="artworks/"]');
  629. };
  630.  
  631. const findNovelUrl = element => {
  632. return element.querySelector('a[href*="novel/show.php?id="]');
  633. };
  634.  
  635. const findItemId = element => {
  636. let id = null;
  637. let isNovel = false;
  638.  
  639. let link = findUrl(element);
  640. if (link) {
  641. const match = link.href.match(/artworks\/(\d+)/);
  642. id = match ? match[1] : null;
  643. } else {
  644. link = findNovelUrl(element);
  645. if (link) {
  646. const match = link.href.match(/novel\/show\.php\?id=(\d+)/);
  647. id = match ? match[1] : null;
  648. isNovel = true;
  649. }
  650. }
  651.  
  652. return { id, isNovel };
  653. };
  654.  
  655. // Toggle Bookmarked Modes.
  656. // 0 = Show all
  657. // 1 = Show not bookmarked
  658. // 2 = Show bookmarked
  659. const _TB_MIN = 0;
  660. const _TB_MAX = 2;
  661.  
  662. const isImageBookmarked = element => {
  663. return element.querySelector(CONFIG.SELECTORS_IMAGE_BOOKMARKED) !== null;
  664. };
  665.  
  666. const addImageArtist = async element => {
  667. let userId = null;
  668. let userName = null;
  669.  
  670. if (element.__vue__) {
  671. await waitFor(element, () => !element.__vue__._props.item.notLoaded);
  672.  
  673. userId = element.__vue__._props.item.user_id;
  674. userName = element.__vue__._props.item.author_details.user_name;
  675. } else {
  676. const reactPropsKey = Object.keys(element).find(k => k.startsWith('__reactProps'));
  677. if (!reactPropsKey || !element[reactPropsKey].children.props.thumbnail) {
  678. return false;
  679. }
  680.  
  681. userId = element[reactPropsKey].children.props.thumbnail.userId;
  682. userName = element[reactPropsKey].children.props.thumbnail.userName;
  683. }
  684.  
  685. const div = document.createElement('div');
  686. div.className = 'pixiv_utils_image_artist_container';
  687. div.innerHTML = /* html */`
  688. <a href="https://www.pixiv.net/users/${userId}">${userName}</a>
  689. `;
  690.  
  691. element.append(div);
  692. return true;
  693. };
  694.  
  695. const doImage = async (element, isHome = false) => {
  696. // Skip if invalid.
  697. if (!element.querySelector('a[href]')) {
  698. return false;
  699. }
  700.  
  701. if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
  702. if (findNovelUrl(element)) {
  703. element.style.display = 'none';
  704. logDebug('Removed novel recommendation from home', element);
  705. return true;
  706. }
  707. }
  708.  
  709. // Process new entries in toggled bookmarked sections.
  710. if (element.closest('[data-pixiv_utils_toggle_bookmarked_section]')) {
  711. const mode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN);
  712. if (mode === 1) {
  713. element.style.display = isImageBookmarked(element) ? 'none' : '';
  714. } else if (mode === 2) {
  715. element.style.display = isImageBookmarked(element) ? '' : 'none';
  716. }
  717. }
  718.  
  719. // Skip if edit bookmark button already inserted.
  720. if (element.querySelector('.pixiv_utils_edit_bookmark')) {
  721. return false;
  722. }
  723.  
  724. // Add artist tag if necessary, but never in artist page, or mobile expanded view's artist works bottom row.
  725. if (!element.querySelector('a[href*="users/"]') &&
  726. currentUrl.indexOf('users/') === -1 &&
  727. !element.closest('.user-badge')) {
  728. await addImageArtist(element);
  729. }
  730.  
  731. // Wait if image controls is still being generated.
  732. const imageControls = await waitFor(element, () => {
  733. return element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
  734. });
  735. if (!imageControls) {
  736. return false;
  737. }
  738.  
  739. const { id, isNovel } = findItemId(element);
  740. if (id !== null) {
  741. imageControls.prepend(editBookmarkButton(id, isNovel));
  742. return true;
  743. }
  744.  
  745. return false;
  746. };
  747.  
  748. const doMultiView = async (element, isHome = false) => {
  749. if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
  750. if (findNovelUrl(element)) {
  751. element.parentNode.style.display = 'none';
  752. logDebug('Removed novel recommendation from home', element);
  753. return true;
  754. }
  755. }
  756.  
  757. // Skip if edit bookmark button already inserted.
  758. if (element.querySelector('.pixiv_utils_edit_bookmark')) {
  759. return false;
  760. }
  761.  
  762. const multiViewControls = element.querySelector(CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
  763. if (!multiViewControls) {
  764. return false;
  765. }
  766.  
  767. const { id, isNovel } = findItemId(element);
  768. if (id !== null) {
  769. multiViewControls.lastChild.before(editBookmarkButton(id, isNovel));
  770. return true;
  771. }
  772.  
  773. return false;
  774. };
  775.  
  776. const doExpandedViewControls = async element => {
  777. // Skip if edit bookmark button already inserted.
  778. if (element.querySelector('.pixiv_utils_edit_bookmark')) {
  779. return false;
  780. }
  781.  
  782. let id = null;
  783. let isNovel = false;
  784.  
  785. let match = window.location.href.match(/artworks\/(\d+)/);
  786. if (match && match[1]) {
  787. id = match[1];
  788. } else {
  789. match = window.location.href.match(/novel\/show\.php\?id=(\d+)/);
  790. if (match && match[1]) {
  791. id = match[1];
  792. isNovel = true;
  793. }
  794. }
  795.  
  796. if (id !== null) {
  797. element.append(editBookmarkButton(id, isNovel));
  798.  
  799. // Re-process expanded view's artist works bottom row.
  800. const images = document.querySelectorAll(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE);
  801. for (const image of images) {
  802. await doImage(image);
  803. }
  804.  
  805. return true;
  806. }
  807.  
  808. return false;
  809. };
  810.  
  811. const formatToggleBookmarkedButtonHtml = mode => {
  812. if (mode === 0) {
  813. return /* html */`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL}<span>`;
  814. } else if (mode === 1) {
  815. return /* html */`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED}<span>`;
  816. } else if (mode === 2) {
  817. return /* html */`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED}<span>`;
  818. }
  819. };
  820.  
  821. let toggling = false;
  822. const toggleBookmarked = (button, parent, header, imagesContainer, rightClick = false) => {
  823. if (toggling) {
  824. return false;
  825. }
  826.  
  827. toggling = true;
  828.  
  829. let mode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN);
  830. if (rightClick) { mode--; } else { mode++; }
  831. if (mode > _TB_MAX) { mode = _TB_MIN; } else if (mode < _TB_MIN) { mode = _TB_MAX; }
  832.  
  833. button.innerHTML = formatToggleBookmarkedButtonHtml(mode);
  834.  
  835. let images = Array.from(imagesContainer.querySelectorAll(CONFIG.SELECTORS_IMAGE));
  836.  
  837. // Do not process blocked images if they are already forcefully hidden.
  838. if (CONFIG.UTAGS_REMOVE_BLOCKED) {
  839. images = images.filter(image => !image.dataset.pixiv_utils_blocked);
  840. }
  841.  
  842. if (mode === 0) {
  843. for (const image of images) {
  844. image.style.display = '';
  845. }
  846. } else if (mode === 1) {
  847. for (const image of images) {
  848. if (image.dataset.pixiv_utils_blocked || isImageBookmarked(image)) {
  849. image.style.display = 'none';
  850. } else {
  851. image.style.display = '';
  852. }
  853. }
  854. } else if (mode === 2) {
  855. for (const image of images) {
  856. if (image.dataset.pixiv_utils_blocked || !isImageBookmarked(image)) {
  857. image.style.display = 'none';
  858. } else {
  859. image.style.display = '';
  860. }
  861. }
  862. }
  863.  
  864. GM_setValue('PREF_TOGGLE_BOOKMARKED_MODE', mode);
  865.  
  866. toggling = false;
  867.  
  868. return true;
  869. };
  870.  
  871. const doToggleBookmarkedSection = (element, sectionConfig) => {
  872. // Skip if already processed.
  873. if (element.dataset.pixiv_utils_toggle_bookmarked_section) {
  874. return false;
  875. }
  876.  
  877. const header = element.querySelector(sectionConfig.selectorHeader);
  878. const imagesContainer = element.querySelector(sectionConfig.selectorImagesContainer);
  879.  
  880. if (!header || !imagesContainer) {
  881. return false;
  882. }
  883.  
  884. // Mark as processed.
  885. element.dataset.pixiv_utils_toggle_bookmarked_section = true;
  886.  
  887. const buttonContainer = document.createElement('div');
  888. buttonContainer.className = 'pixiv_utils_toggle_bookmarked_container';
  889.  
  890. const button = document.createElement('a');
  891. button.className = 'pixiv_utils_toggle_bookmarked';
  892. button.innerHTML = formatToggleBookmarkedButtonHtml(GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN));
  893.  
  894. if (CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP) {
  895. button.title = CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP;
  896. }
  897.  
  898. // Left click.
  899. button.addEventListener('click', event => toggleBookmarked(button, element, header, imagesContainer));
  900.  
  901. // Right click.
  902. button.addEventListener('contextmenu', event => {
  903. event.preventDefault();
  904. toggleBookmarked(button, element, header, imagesContainer, true);
  905. });
  906.  
  907. buttonContainer.append(button);
  908. header.append(buttonContainer);
  909. return true;
  910. };
  911.  
  912. const doUtags = element => {
  913. const image = element.closest(CONFIG.SELECTORS_IMAGE);
  914. if (image) {
  915. const imageLink = image.querySelector('a[href*="artworks/"], a[href*="novel/"]');
  916. if (!imageLink) {
  917. return false;
  918. }
  919.  
  920. // Skip if already blocked.
  921. if (image.dataset.pixiv_utils_blocked) {
  922. return false;
  923. }
  924.  
  925. image.dataset.pixiv_utils_blocked = true;
  926.  
  927. if (CONFIG.UTAGS_REMOVE_BLOCKED) {
  928. image.style.display = 'none';
  929. return true;
  930. }
  931.  
  932. imageLink.innerHTML = BLOCKED_IMAGE_HTML;
  933.  
  934. const imageTitle = image.querySelector(CONFIG.SELECTORS_IMAGE_TITLE);
  935. if (imageTitle) {
  936. if (element.dataset.utags_tag === 'hide') {
  937. imageTitle.innerText = 'Hidden';
  938. } else {
  939. // "block" tag and custom tags
  940. imageTitle.innerText = 'Blocked';
  941. }
  942. }
  943.  
  944. // Empty the text instead of hiding it, so that the utags will still display properly to provide context.
  945. const artistLink = image.querySelector(CONFIG.SELECTORS_IMAGE_ARTIST_NAME +
  946. ', .pixiv_utils_image_artist_container a');
  947. if (artistLink) {
  948. artistLink.innerText = '';
  949. }
  950.  
  951. return true;
  952. }
  953.  
  954. const multiView = element.closest(CONFIG.SELECTORS_MULTI_VIEW);
  955. if (multiView) {
  956. // For multi view artwork, just hide the whole entry instead.
  957. multiView.parentNode.style.display = 'none';
  958. logDebug('Removed multi view entry due to UTag', element);
  959. return true;
  960. }
  961.  
  962. const followButtonContainer = element.closest(CONFIG.SELECTORS_FOLLOW_BUTTON_CONTAINER);
  963. if (followButtonContainer) {
  964. const followButton = followButtonContainer.querySelector(CONFIG.SELECTORS_FOLLOW_BUTTON);
  965. if (followButton) {
  966. // Cosmetic only. This will not disable Pixiv's built-in "F" keybind.
  967. followButton.disabled = true;
  968. // Return early since there will only be one follow button per container.
  969. return true;
  970. }
  971. }
  972.  
  973. return false;
  974. };
  975.  
  976. let isHome = false;
  977.  
  978. window.addEventListener('detectnavigate', event => {
  979. const intervals = Object.keys(waitForIntervals);
  980. if (intervals.length) {
  981. logDebug(`Clearing ${intervals.length} pending waitFor interval(s).`);
  982. }
  983. for (const interval of intervals) {
  984. clearInterval(interval);
  985. waitForIntervals[interval].resolve();
  986. delete waitForIntervals[interval];
  987. }
  988.  
  989. isHome = Boolean(document.querySelector(CONFIG.SELECTORS_HOME));
  990. });
  991.  
  992. /** SENTINEL */
  993.  
  994. waitPageLoaded().then(() => {
  995. isHome = Boolean(document.querySelector(CONFIG.SELECTORS_HOME));
  996.  
  997. // Expanded View Controls
  998. sentinel.on(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS, element => {
  999. doExpandedViewControls(element);
  1000. });
  1001.  
  1002. // Images
  1003. sentinel.on([
  1004. CONFIG.SELECTORS_IMAGE,
  1005. CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE
  1006. ], element => {
  1007. doImage(element, isHome);
  1008. });
  1009.  
  1010. // Multi View Entries
  1011. sentinel.on(CONFIG.SELECTORS_MULTI_VIEW, element => {
  1012. doMultiView(element, isHome);
  1013. });
  1014.  
  1015. // Toggle Bookmarked Sections
  1016. for (const sectionConfig of CONFIG.SECTIONS_TOGGLE_BOOKMARKED) {
  1017. if (!sectionConfig.selectorParent || !sectionConfig.selectorHeader || !sectionConfig.selectorImagesContainer) {
  1018. log('Invalid "SECTIONS_TOGGLE_BOOKMARKED" config', sectionConfig);
  1019. continue;
  1020. }
  1021.  
  1022. sentinel.on(sectionConfig.selectorParent, element => {
  1023. doToggleBookmarkedSection(element, sectionConfig);
  1024. });
  1025. }
  1026.  
  1027. // Dates
  1028. sentinel.on(CONFIG.SELECTORS_DATE, element => {
  1029. convertDate(element);
  1030. });
  1031.  
  1032. // UTags Integration
  1033. if (CONFIG.UTAGS_INTEGRATION) {
  1034. sentinel.on(SELECTORS_UTAGS, element => {
  1035. doUtags(element);
  1036. });
  1037. }
  1038.  
  1039. if (CONFIG.MODE !== 'PROD') {
  1040. setInterval(() => {
  1041. const intervals = Object.keys(waitForIntervals);
  1042. if (intervals.length > 0) {
  1043. // Debug first pending interval.
  1044. logDebug('waitFor', waitForIntervals[intervals[0]].element);
  1045. }
  1046. }, 1000);
  1047. }
  1048. });
  1049.  
  1050. /** KEYBINDS **/
  1051.  
  1052. if (CONFIG.ENABLE_KEYBINDS) {
  1053. const selectors = {
  1054. editBookmark: CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS
  1055. .split(', ').map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')
  1056. };
  1057.  
  1058. const onCooldown = {};
  1059.  
  1060. const processKeyEvent = (id, element) => {
  1061. if (!element) {
  1062. return false;
  1063. }
  1064.  
  1065. if (onCooldown[id]) {
  1066. log(`"${id}" keybind still on cooldown.`);
  1067. return false;
  1068. }
  1069.  
  1070. onCooldown[id] = true;
  1071. element.click();
  1072. setTimeout(() => { onCooldown[id] = false; }, 1000);
  1073. };
  1074.  
  1075. document.addEventListener('keydown', event => {
  1076. event = event || window.event;
  1077.  
  1078. // Ignore keybinds when currently focused to an input/textarea/editable element.
  1079. if (document.activeElement && (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable)) {
  1080. return;
  1081. }
  1082.  
  1083. // "Shift+B" for Edit Bookmark.
  1084. // Pixiv has built-in keybind "B" for just bookmarking.
  1085. if (event.keyCode === 66) {
  1086. if (event.ctrlKey || event.altKey) {
  1087. // Ignore "Ctrl+B" or "Alt+B".
  1088. return;
  1089. }
  1090. if (event.shiftKey) {
  1091. event.stopPropagation();
  1092. const element = document.querySelector(selectors.editBookmark);
  1093. return processKeyEvent('bookmarkEdit', element);
  1094. }
  1095. }
  1096. });
  1097.  
  1098. logDebug('Listening for keybinds.');
  1099. } else {
  1100. logDebug('Keybinds disabled.');
  1101. }
  1102. })();