Bobby's Pixiv Utils

Compatible with mobile. "Edit bookmark" and "Toggle bookmarked" buttons, publish dates conversion, block AI-generated works, block by Pixiv tags, UTags integration, and more!

  1. // ==UserScript==
  2. // @name Bobby's Pixiv Utils
  3. // @namespace https://github.com/BobbyWibowo
  4. // @version 1.6.29
  5. // @description Compatible with mobile. "Edit bookmark" and "Toggle bookmarked" buttons, publish dates conversion, block AI-generated works, block by Pixiv tags, UTags integration, and more!
  6. // @author Bobby Wibowo
  7. // @license MIT
  8. // @match *://www.pixiv.net/*
  9. // @exclude *://www.pixiv.net/setting*
  10. // @exclude *://www.pixiv.net/manage*
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
  12. // @run-at document-start
  13. // @grant GM_addStyle
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant window.onurlchange
  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 _LOG_TIME_FORMAT = new Intl.DateTimeFormat('en-GB', {
  27. hour: '2-digit',
  28. minute: '2-digit',
  29. second: '2-digit',
  30. fractionalSecondDigits: 3
  31. });
  32.  
  33. const log = (message, ...args) => {
  34. const prefix = `[${_LOG_TIME_FORMAT.format(Date.now())}]: `;
  35. if (typeof message === 'string') {
  36. return console.log(prefix + message, ...args);
  37. } else {
  38. return console.log(prefix, message, ...args);
  39. }
  40. };
  41.  
  42. /** CONFIG **/
  43.  
  44. /* It's recommended to edit these values through your userscript manager's storage/values editor.
  45. * Visit Pixiv once after installing the script to allow it to populate its storage with default values.
  46. * Especially necessary for Tampermonkey to show the script's Storage tab when Advanced mode is turned on.
  47. */
  48. const ENV_DEFAULTS = {
  49. MODE: 'PROD',
  50.  
  51. TEXT_EDIT_BOOKMARK: '✏️',
  52. TEXT_EDIT_BOOKMARK_TOOLTIP: 'Edit bookmark',
  53.  
  54. TEXT_TOGGLE_BOOKMARKED: '❤️',
  55. TEXT_TOGGLE_BOOKMARKED_TOOLTIP: 'Cycle bookmarked display (Right-Click to cycle back)',
  56. TEXT_TOGGLE_BOOKMARKED_SHOW_ALL: 'Show all',
  57. TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED: 'Show bookmarked',
  58. TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED: 'Show not bookmarked',
  59.  
  60. SELECTORS_HOME: null,
  61. SELECTORS_OWN_PROFILE: null,
  62. SELECTORS_IMAGE: null,
  63. SELECTORS_IMAGE_TITLE: null,
  64. SELECTORS_IMAGE_ARTIST_AVATAR: null,
  65. SELECTORS_IMAGE_ARTIST_NAME: null,
  66. SELECTORS_IMAGE_CONTROLS: null,
  67. SELECTORS_IMAGE_BOOKMARKED: null,
  68. SELECTORS_EXPANDED_VIEW_IMAGE: null,
  69. SELECTORS_EXPANDED_VIEW_CONTROLS: null,
  70. SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: null,
  71. SELECTORS_MULTI_VIEW: null,
  72. SELECTORS_MULTI_VIEW_CONTROLS: null,
  73. SELECTORS_FOLLOW_BUTTON_CONTAINER: null,
  74. SELECTORS_FOLLOW_BUTTON: null,
  75. SELECTORS_RECOMMENDED_USER_CONTAINER: null,
  76. SELECTORS_TAG_BUTTON: null,
  77.  
  78. DATE_CONVERSION: true,
  79. DATE_CONVERSION_LOCALES: 'en-GB',
  80. DATE_CONVERSION_OPTIONS: {
  81. hour12: true,
  82. year: 'numeric',
  83. month: 'long',
  84. day: 'numeric',
  85. hour: '2-digit',
  86. minute: '2-digit'
  87. },
  88. SELECTORS_DATE: null,
  89.  
  90. REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: false,
  91.  
  92. SECTIONS_TOGGLE_BOOKMARKED: null,
  93.  
  94. ENABLE_KEYBINDS: true,
  95.  
  96. PIXIV_HIGHLIGHTED_TAGS: null,
  97. PIXIV_HIGHLIGHTED_COLOR: '#32cd32',
  98.  
  99. PIXIV_BLOCK_AI: false,
  100. PIXIV_BLOCKED_TAGS: null,
  101. // Instead of merely masking them à la Pixiv's built-in tags mute.
  102. PIXIV_REMOVE_BLOCKED: false,
  103.  
  104. UTAGS_INTEGRATION: true,
  105. UTAGS_BLOCKED_TAGS: null,
  106. // Instead of merely masking them à la Pixiv's built-in tags mute.
  107. UTAGS_REMOVE_BLOCKED: false
  108. };
  109.  
  110. /* Hard-coded preset values.
  111. * Specifying custom values will extend instead of replacing them.
  112. */
  113. const PRESETS = {
  114. // Keys that starts with "SELECTORS_", and in array, will automatically be converted to single-line strings.
  115. SELECTORS_HOME: '[data-ga4-label="page_root"]',
  116.  
  117. SELECTORS_OWN_PROFILE: [
  118. 'a[href*="settings/profile"]', // desktop
  119. '.ui-button[href*="setting_profile.php"]' // mobile
  120. ],
  121.  
  122. SELECTORS_IMAGE: [
  123. 'li[data-ga4-label="thumbnail"]', // home's latest works grid
  124. '.sc-96f10c4f-0 > li', // home's recommended works grid
  125. '.bCxfvI > li', // following page's grid
  126. '.iyMBlR > li', // following page's grid (novel)
  127. '.eyusRs > div', // user profile popup
  128. '.jELUak > li', // artist page's grid
  129. '.blqLzT > li', // artist page's featured works
  130. '.ibaIoN > div:has(a[href])', // expanded view's recommended works after pop-in
  131. '.gMVVng > div', // expanded view's other works sidebar
  132. '.xPzcf > .fVofhy', // illustrations/manga page's daily ranking
  133. '.hHLaTl > li', // tags page's grid
  134. '.eTKQDQ .jOuhfn > div', // tags page's users tab
  135. '.fhUcsb > li', // "newest by all" page
  136. '.buGhFj > li', // requests page
  137. '.bkRoSP > li', // manga page's followed works
  138. '.dHJLGd > div', // novels page's ongoing contests
  139. '.ranking-item', // rankings page
  140. '._ranking-item', // rankings page (novel)
  141. '.works-item-illust:has(.thumb:not([src^="data"]))', // mobile
  142. '.works-item:not(.works-item-illust):has(.thumb:not([src^="data"]))', // mobile (novel)
  143. '.works-item-novel-editor-recommend:has(.cover:not([style^="data"]))', // mobile's novels page's editor's picks
  144. '.stacclist > li.illust' // mobile's feed page
  145. ],
  146.  
  147. SELECTORS_IMAGE_TITLE: [
  148. '[data-ga4-label="title_link"]', // home's recommended works grid
  149. '.gtm-illust-recommend-title', // discovery page's grid
  150. '.gtm-followlatestpage-thumbnail-link', // following page
  151. '.fNOdSq', // artist/tags page
  152. '.title', // rankings page
  153. '.illust-info > a[class*="c-text"]' // mobile list view
  154. ],
  155.  
  156. SELECTORS_IMAGE_ARTIST_AVATAR: [
  157. '[data-ga4-label="user_icon_link"]', // home's recommended works grid
  158. '.sc-1rx6dmq-1', // expanded view's related works grid
  159. '.lbFgXO', // artist/tags page
  160. '._user-icon' // rankings page
  161. ],
  162.  
  163. SELECTORS_IMAGE_ARTIST_NAME: [
  164. '[data-ga4-label="user_name_link"]', // home's recommended works grid
  165. '.gtm-illust-recommend-user-name', // expanded view's related works grid
  166. '.QzTPT', // artist/tags page
  167. '.user-name', // rankings page
  168. '.illust-author' // mobile list view
  169. ],
  170.  
  171. SELECTORS_IMAGE_CONTROLS: [
  172. '.lhECTV', // home's latest/recommended works grid
  173. '.jTSPzA', // following page's grid
  174. '.XziBq', // following page's grid (novel)
  175. '.gtm-illust-recommend-bookmark', // discovery page's grid
  176. '.hLHrVH', // artist page's grid
  177. '.chJny', // artist page's grid (novel)
  178. '.fRrNLv', // artist page's featured works
  179. '.cOEQuT', // artist page's featured works (novel)
  180. '.gHWpGx', // tags page's users tab
  181. '.ZBDKi', // "newest by all" page
  182. '.fvFuEP', // requests page
  183. '.khAYZn', // requests page (novel)
  184. '.byWzRq', // expanded view's artist bottom bar (novel)
  185. '.hFAmSK', // novels page
  186. '.cUooIb', // novels page's followed works
  187. '.djUdtd > div:last-child', // novels page's editor's picks
  188. '.gAyuNi', // novels page's ongoing contests
  189. '._layout-thumbnail', // rankings page
  190. '.novel-right-contents', // rankings page (novel)
  191. '.imgoverlay', // mobile's feed page
  192. '.bookmark', // mobile
  193. '.hSoPoc' // mobile
  194. ],
  195.  
  196. SELECTORS_IMAGE_BOOKMARKED: [
  197. '.epoVSE', // desktop
  198. '.bvHYhE', // expanded view
  199. '.lbkCkj', // artist/tags page
  200. '.wQCIS', // "newest by all" page
  201. '._one-click-bookmark.on', // rankings page
  202. '.works-bookmark-button svg path[fill="#FF4060"]' // mobile
  203. ],
  204.  
  205. SELECTORS_EXPANDED_VIEW_IMAGE: [
  206. '.cxsjmo', // desktop
  207. '.illust-details-view' // mobile
  208. ],
  209.  
  210. SELECTORS_EXPANDED_VIEW_CONTROLS: [
  211. '.gPBXUH', // desktop
  212. '.work-interactions' // mobile
  213. ],
  214.  
  215. SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE: '.eoaxji > div:has(a[href])',
  216.  
  217. SELECTORS_MULTI_VIEW: '[data-ga4-label="work_content"]:has(a[href])',
  218.  
  219. SELECTORS_MULTI_VIEW_CONTROLS: '& > .w-full:last-child > .flex:first-child > .flex-row:first-child',
  220.  
  221. SELECTORS_FOLLOW_BUTTON_CONTAINER: [
  222. '.hNGTeS', // artist page's header
  223. '.kIkMnj', // artist hover popup
  224. '.kngwLX', // expanded view's artist bottom bar
  225. '.dcCqBF', // expanded view's artist sidebar
  226. '.user-details', // mobile's artist page
  227. '.user-details-card' // mobile's expanded view
  228. ],
  229.  
  230. SELECTORS_FOLLOW_BUTTON: [
  231. '[data-click-label="follow"]:not([disabled])', // desktop
  232. '.ui-button' // mobile
  233. ],
  234.  
  235. SELECTORS_RECOMMENDED_USER_CONTAINER: [
  236. // home's recommended users sidebar
  237. '.hSNbaL > .grid > :nth-child(2) .flex-col.gap-8:first-child .flex-row.items-center:not(.mr-auto)',
  238. '.YXoqY .grid li.list-none', // tags page's users tab
  239. ],
  240.  
  241. SELECTORS_TAG_BUTTON: [
  242. '.hsAWPs', // artist page
  243. '.lgNtXn' // illustrations page
  244. ],
  245.  
  246. SELECTORS_DATE: [
  247. '.dgDuKx', // desktop
  248. '.kzGSfF', // desktop "updated on" popup
  249. '.times', // mobile
  250. '.reupload-info .tooltip-text' // mobile "updated on" popup
  251. ],
  252.  
  253. // Selectors must be single-line strings.
  254. SECTIONS_TOGGLE_BOOKMARKED: [
  255. // Following page
  256. {
  257. selectorParent: '.buChOd',
  258. selectorHeader: '.hYPUjr',
  259. selectorImagesContainer: '.bghEFg'
  260. },
  261. // Artist page
  262. {
  263. selectorParent: '.cYFOlQ',
  264. selectorHeader: '.aCLRB',
  265. selectorImagesContainer: '.aCLRB ~ div:not([class])'
  266. },
  267. // Artist page's requests tab
  268. {
  269. selectorParent: '.fKsmTC > section',
  270. selectorHeader: '.eGMhPO',
  271. selectorImagesContainer: '.eGMhPO ~ ul'
  272. },
  273. // Artist page's bookmarks tab
  274. {
  275. selectorParent: '.buChOd',
  276. selectorHeader: '.giPitS',
  277. selectorImagesContainer: '.giPitS ~ div:not([class])',
  278. sanityCheck: () => {
  279. // Skip if in own profile.
  280. return document.querySelector('a[href*="settings/profile"]');
  281. }
  282. },
  283. // Tags page
  284. {
  285. selectorParent: '.buChOd',
  286. selectorHeader: '.dlidhK',
  287. selectorImagesContainer: '.cmMzCq'
  288. },
  289. // Tags page (novel)
  290. {
  291. selectorParent: '.buChOd',
  292. selectorHeader: '.dlidhK',
  293. selectorImagesContainer: '.dsoOUK'
  294. },
  295. // "Newest by all" page
  296. {
  297. selectorParent: '.ldgmym > section',
  298. selectorHeader: '.hYDgvb',
  299. selectorImagesContainer: '.kDcbyT'
  300. },
  301. // Rankings page
  302. {
  303. selectorParent: '#wrapper ._unit',
  304. selectorHeader: '.ranking-menu',
  305. selectorImagesContainer: '.ranking-items-container'
  306. },
  307. // Mobile artist page's illustrations/bookmarks tab, following page, tags page
  308. {
  309. selectorParent: '.v-nav-tabs + div:not(.header-buttons), ' +
  310. '.nav-tab + div, ' +
  311. '.search-nav-config + div',
  312. selectorHeader: '.pager-view-nav',
  313. selectorImagesContainer: '.works-grid-list',
  314. sanityCheck: () => {
  315. // Skip if in own profile (intended for bookmarks page).
  316. return document.querySelector('.ui-button[href*="setting_profile.php"]');
  317. }
  318. },
  319. // Mobile artist page's home tab
  320. {
  321. selectorParent: '.work-set > div',
  322. selectorHeader: '.title-line > div:last-child',
  323. selectorImagesContainer: '.works-grid-list'
  324. },
  325. // Mobile rankings page
  326. {
  327. selectorParent: '.ranking-page',
  328. selectorHeader: '.header-buttons',
  329. selectorImagesContainer: '.works-grid-list'
  330. }
  331. ],
  332.  
  333. // To ensure any custom values will be inserted into array, or combined together if also an array.
  334. PIXIV_HIGHLIGHTED_TAGS: [],
  335.  
  336. PIXIV_BLOCKED_TAGS: [],
  337.  
  338. UTAGS_BLOCKED_TAGS: ['block', 'hide']
  339. };
  340.  
  341. const ENV = {};
  342.  
  343. // Store default values.
  344. for (const key of Object.keys(ENV_DEFAULTS)) {
  345. const stored = GM_getValue(key);
  346. if (stored === null || stored === undefined) {
  347. ENV[key] = ENV_DEFAULTS[key];
  348. GM_setValue(key, ENV_DEFAULTS[key]);
  349. } else {
  350. ENV[key] = stored;
  351. }
  352. }
  353.  
  354. const _DOCUMENT_FRAGMENT = document.createDocumentFragment();
  355. const queryCheck = selector => _DOCUMENT_FRAGMENT.querySelector(selector);
  356.  
  357. const isSelectorValid = selector => {
  358. try {
  359. queryCheck(selector);
  360. } catch {
  361. return false;
  362. }
  363. return true;
  364. };
  365.  
  366. const CONFIG = {};
  367.  
  368. // Extend hard-coded preset values with user-defined custom values, if applicable.
  369. for (const key of Object.keys(ENV)) {
  370. if (key.startsWith('SELECTORS_')) {
  371. if (Array.isArray(PRESETS[key])) {
  372. CONFIG[key] = PRESETS[key].join(', ');
  373. } else {
  374. CONFIG[key] = PRESETS[key] || '';
  375. }
  376. if (ENV[key]) {
  377. CONFIG[key] += `, ${Array.isArray(ENV[key]) ? ENV[key].join(', ') : ENV[key]}`;
  378. }
  379. if (!isSelectorValid(CONFIG[key])) {
  380. console.error(`${key} contains invalid selector =`, CONFIG[key]);
  381. return;
  382. }
  383. } else if (Array.isArray(PRESETS[key])) {
  384. CONFIG[key] = PRESETS[key];
  385. if (ENV[key]) {
  386. const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
  387. CONFIG[key].push(...customValues);
  388. }
  389. } else {
  390. CONFIG[key] = PRESETS[key] || null;
  391. if (ENV[key] !== null) {
  392. CONFIG[key] = ENV[key];
  393. }
  394. }
  395. }
  396.  
  397. let logDebug = () => {};
  398. let logKeys = Object.keys(CONFIG);
  399. if (CONFIG.MODE === 'PROD') {
  400. // In PROD mode, only print some.
  401. logKeys = [
  402. 'DATE_CONVERSION',
  403. 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME',
  404. 'ENABLE_KEYBINDS',
  405. 'PIXIV_HIGHLIGHTED_TAGS',
  406. 'PIXIV_BLOCK_AI',
  407. 'PIXIV_BLOCKED_TAGS',
  408. 'PIXIV_REMOVE_BLOCKED',
  409. 'UTAGS_INTEGRATION',
  410. 'UTAGS_BLOCKED_TAGS',
  411. 'UTAGS_REMOVE_BLOCKED'
  412. ];
  413. } else {
  414. logDebug = log;
  415. }
  416.  
  417. for (const key of logKeys) {
  418. log(`${key} =`, CONFIG[key]);
  419. }
  420.  
  421. /** GLOBAL UTILS **/
  422.  
  423. const addPageDateStyle = /*css*/`
  424. .bookmark-detail-unit .meta:not([data-pixiv_utils_duplicate_date]) {
  425. display: none !important;
  426. }
  427.  
  428. .bookmark-detail-unit .meta[data-pixiv_utils_duplicate_date] {
  429. display: block;
  430. font-size: 16px;
  431. font-weight: bold;
  432. color: inherit;
  433. margin-left: 0;
  434. margin-top: 10px;
  435. }
  436. `;
  437.  
  438. const DATE_FORMAT = new Intl.DateTimeFormat(CONFIG.DATE_CONVERSION_LOCALES, CONFIG.DATE_CONVERSION_OPTIONS);
  439.  
  440. const DATE_FORMAT_NO_HMS_OPTIONS = Object.assign({}, CONFIG.DATE_CONVERSION_OPTIONS);
  441. delete DATE_FORMAT_NO_HMS_OPTIONS.hour12;
  442. delete DATE_FORMAT_NO_HMS_OPTIONS.hourCycle;
  443. delete DATE_FORMAT_NO_HMS_OPTIONS.hour;
  444. delete DATE_FORMAT_NO_HMS_OPTIONS.minute;
  445. delete DATE_FORMAT_NO_HMS_OPTIONS.second;
  446.  
  447. const DATE_FORMAT_NO_HMS = new Intl.DateTimeFormat(CONFIG.DATE_CONVERSION_LOCALES, DATE_FORMAT_NO_HMS_OPTIONS);
  448.  
  449. const convertDate = (element, fixJapanTime = false) => {
  450. // Support "updated on" popups
  451. const updatedOnRegexes = [
  452. /(^Image updated on )(.*)$/i // EN
  453. ];
  454.  
  455. let prefix = '';
  456. let date;
  457. let dateHasHMS = true;
  458.  
  459. let duplicateDate = element.nextElementSibling;
  460. if (!duplicateDate || !duplicateDate.dataset.pixiv_utils_duplicate_date) {
  461. duplicateDate = element.cloneNode(true);
  462. duplicateDate.dataset.pixiv_utils_duplicate_date = true;
  463. element.after(duplicateDate);
  464. }
  465.  
  466. const attr = element.getAttribute('datetime');
  467. if (attr) {
  468. date = new Date(attr);
  469. } else {
  470. let dateText = element.innerText.trim();
  471. if (!dateText.includes(':')) {
  472. dateHasHMS = false;
  473. }
  474.  
  475. for (const regex of updatedOnRegexes) {
  476. const _match = dateText.match(regex);
  477. if (_match) {
  478. dateText = _match[2];
  479. prefix = _match[1];
  480. break;
  481. }
  482. }
  483.  
  484. // For dates hard-coded to Japan locale.
  485. const match = dateText.match(/^(\d{4})年(\d{2})月(\d{2})日 (\d{2}:\d{2})$/);
  486. if (match) {
  487. dateText = `${match[2]}-${match[3]}-${match[1]} ${match[4]}`;
  488. }
  489.  
  490. // For pages which have the date display hardcoded to Japan time.
  491. if (fixJapanTime) {
  492. dateText += ' UTC+9';
  493. }
  494.  
  495. date = new Date(dateText);
  496. }
  497.  
  498. if (!date) {
  499. return false;
  500. }
  501.  
  502. const timestamp = date.getTime();
  503. if (Number.isNaN(timestamp) || duplicateDate.dataset.pixiv_utils_date_timestamp === String(timestamp)) {
  504. return false;
  505. }
  506.  
  507. if (prefix) {
  508. duplicateDate.dataset.pixiv_utils_date_prefix = prefix;
  509. }
  510.  
  511. let dateString = '';
  512. if (dateHasHMS) {
  513. dateString = DATE_FORMAT.format(date);
  514. } else {
  515. dateString = DATE_FORMAT_NO_HMS.format(date);
  516. }
  517.  
  518. duplicateDate.dataset.pixiv_utils_date_timestamp = timestamp;
  519. duplicateDate.innerHTML = prefix + dateString;
  520. return true;
  521. };
  522.  
  523. /** INTERCEPT EARLY FOR CERTAIN ROUTES **/
  524.  
  525. const waitPageLoaded = () => {
  526. return new Promise(resolve => {
  527. if (document.readyState === 'complete' ||
  528. document.readyState === 'loaded' ||
  529. document.readyState === 'interactive') {
  530. resolve();
  531. } else {
  532. document.addEventListener('DOMContentLoaded', resolve);
  533. }
  534. });
  535. };
  536.  
  537. const path = location.pathname;
  538.  
  539. // Codes beyond this block will not execute for these routes (mainly for efficiency).
  540. if (path.startsWith('/bookmark_add.php') || path.startsWith('/novel/bookmark_add.php')) {
  541. if (CONFIG.DATE_CONVERSION) {
  542. waitPageLoaded().then(() => {
  543. GM_addStyle(addPageDateStyle);
  544. const date = document.querySelector('.bookmark-detail-unit .meta');
  545. if (date) {
  546. // This page has the date display hardcoded to Japan time without an accompanying timestamp.
  547. convertDate(date, true);
  548. }
  549. });
  550. }
  551.  
  552. log('bookmark_add.php detected. Excluding date conversion, script has terminated early.');
  553. return;
  554. }
  555.  
  556. /** MAIN UTILS */
  557.  
  558. // NOTE Keep in sync with SELECTORS_IMAGE (parent selector).
  559. const SELECTORS_IMAGE_CONTAINER_SIMPLIFIED = [
  560. '.eyusRs', // user profile popup
  561. '.gMVVng' // expanded view's other works sidebar
  562. ].join(', ');
  563.  
  564. // NOTE Keep in sync with SELECTORS_IMAGE.
  565. const SELECTORS_IMAGE_SMALL = [
  566. '.iyMBlR > li' // following page's grid (novel)
  567. ].join(', ');
  568.  
  569. const SELECTORS_IMAGE_MOBILE = '.works-item';
  570.  
  571. const PIXIV_HIGHLIGHTED_TAGS_FORMATTED = [];
  572.  
  573. for (const config of CONFIG.PIXIV_HIGHLIGHTED_TAGS) {
  574. const buildTags = tags => {
  575. const result = {
  576. string: [],
  577. regexp: []
  578. };
  579. for (const tag of tags) {
  580. if (typeof tag === 'string') {
  581. result.string.push(tag);
  582. } else if (Array.isArray(tag)) {
  583. result.regexp.push(new RegExp(tag[0], tag[1] || ''));
  584. }
  585. }
  586. return result;
  587. };
  588.  
  589. const _config = {};
  590.  
  591. if (typeof config === 'object' && !Array.isArray(config)) {
  592. Object.assign(_config, buildTags(config.tags));
  593. if (typeof config.color === 'string') {
  594. _config.color = config.color;
  595. }
  596. } else {
  597. Object.assign(_config, buildTags([config]));
  598. }
  599.  
  600. if (_config.string.length || _config.regexp.length) {
  601. PIXIV_HIGHLIGHTED_TAGS_FORMATTED.push(_config);
  602. }
  603. }
  604.  
  605. logDebug('PIXIV_HIGHLIGHTED_TAGS_FORMATTED = ', PIXIV_HIGHLIGHTED_TAGS_FORMATTED);
  606. const PIXIV_HIGHLIGHTED_TAGS_VALIDATED = PIXIV_HIGHLIGHTED_TAGS_FORMATTED.length;
  607.  
  608. const PIXIV_BLOCKED_TAGS_STRING = [];
  609. const PIXIV_BLOCKED_TAGS_REGEXP = [];
  610.  
  611. for (const tag of CONFIG.PIXIV_BLOCKED_TAGS) {
  612. if (typeof tag === 'string') {
  613. PIXIV_BLOCKED_TAGS_STRING.push(String(tag));
  614. } else if (Array.isArray(tag)) {
  615. PIXIV_BLOCKED_TAGS_REGEXP.push(new RegExp(tag[0], tag[1] || ''));
  616. }
  617. }
  618.  
  619. logDebug('PIXIV_BLOCKED_TAGS_STRING = ', PIXIV_BLOCKED_TAGS_STRING);
  620. logDebug('PIXIV_BLOCKED_TAGS_REGEXP = ', PIXIV_BLOCKED_TAGS_REGEXP);
  621. const PIXIV_BLOCKED_TAGS_VALIDATED = PIXIV_BLOCKED_TAGS_STRING.length || PIXIV_BLOCKED_TAGS_REGEXP.length;
  622.  
  623. const SELECTORS_UTAGS = CONFIG.UTAGS_BLOCKED_TAGS.map(s => `[data-utags_tag="${s}"]`).join(', ');
  624. logDebug('SELECTORS_UTAGS =', SELECTORS_UTAGS);
  625.  
  626. let currentUrl = new URL(window.location.href, window.location.origin).href;
  627. const notify = (method, url) => {
  628. const newUrl = new URL(url || window.location.href, window.location.origin).href;
  629. if (currentUrl !== newUrl) {
  630. const event = new CustomEvent('detectnavigate');
  631. window.dispatchEvent(event);
  632. currentUrl = newUrl;
  633. }
  634. };
  635.  
  636. if (window.onurlchange === null) {
  637. window.addEventListener('urlchange', event => {
  638. notify('urlchange', event.url);
  639. });
  640. logDebug('Using window.onurlchange.');
  641. } else {
  642. const oldMethods = {};
  643. ['pushState', 'replaceState'].forEach(method => {
  644. oldMethods[method] = history[method];
  645. history[method] = function (...args) {
  646. oldMethods[method].apply(this, args);
  647. notify(method, args[2]);
  648. };
  649. });
  650.  
  651. window.addEventListener('popstate', event => {
  652. notify(event.type);
  653. });
  654. logDebug('Using window.onurlchange polyfill.');
  655. }
  656.  
  657. /** MAIN STYLES **/
  658.  
  659. const formatChildSelector = (parentSelector, childSelector) => {
  660. let child = childSelector;
  661. if (childSelector.startsWith('&')) {
  662. child = childSelector.substring(1).trimStart();
  663. }
  664.  
  665. const formatted = [];
  666. const parents = parentSelector.split(', ');
  667. for (const parent of parents) {
  668. formatted.push(`${parent} ${child}`);
  669. }
  670.  
  671. return formatted.join(', ');
  672. };
  673.  
  674. const _formatSelectorsMultiViewControls = () => {
  675. const multiViews = CONFIG.SELECTORS_MULTI_VIEW.split(', ');
  676. const multiViewsControls = CONFIG.SELECTORS_MULTI_VIEW_CONTROLS.split(', ');
  677.  
  678. const formatted = [];
  679. for (const parent of multiViews) {
  680. for (const child of multiViewsControls) {
  681. formatted.push(formatChildSelector(parent, child));
  682. }
  683. }
  684.  
  685. return formatted;
  686. };
  687.  
  688. const _SELECTORS_IMAGE_CONTROLS = CONFIG.SELECTORS_IMAGE_CONTROLS.split(', ');
  689.  
  690. const _FILTERED_SELECTORS_IMAGE_CONTROLS = _SELECTORS_IMAGE_CONTROLS
  691. .filter(s => !['._layout-thumbnail', '.novel-right-contents'].includes(s))
  692. .join(', ');
  693.  
  694. const SELECTORS_IMAGE_HIGHLIGHTED =
  695. ':not(.page-count, [size="16"], [size="24"]):has(> img:not([src^="data"]))::after';
  696.  
  697. // NOTE Keep in sync with SELECTORS_IMAGE_CONTROLS.
  698. // Strictly for tweaking button positioning, which only some will need.
  699. const SELECTORS_IMAGE_CONTROLS_NOVEL = [
  700. '.XziBq', // following page's grid (novel)
  701. '.chJny', // artist page's grid (novel)
  702. '.khAYZn', // requests page (novel)
  703. '.byWzRq', // expanded view's artist bottom bar (novel)
  704. '.hFAmSK', // novels page
  705. '.cUooIb', // novels page's followed works
  706. '.gAyuNi' // novels page's ongoing contests
  707. ];
  708.  
  709. const SELECTORS_TOGGLE_BOOKMARKED_HEADER = [
  710. '.hyniYI', // general page
  711. '.eYXksB', // artist page's requests tab
  712. '.gnbCrF', // tags page (novel)
  713. '.eEVUIK' // "newest by all" page
  714. ];
  715.  
  716. const mainStyle = /*css*/`
  717. .flex:has(+ .pixiv_utils_edit_bookmark_container) {
  718. flex-grow: 1;
  719. }
  720.  
  721. .ranking-item.muted .pixiv_utils_edit_bookmark_container {
  722. display: none;
  723. }
  724.  
  725. :is(${SELECTORS_IMAGE_CONTROLS_NOVEL.join(', ')}) .pixiv_utils_edit_bookmark {
  726. margin-top: -26px;
  727. }
  728.  
  729. .pixiv_utils_edit_bookmark {
  730. color: rgb(245, 245, 245);
  731. background: rgba(0, 0, 0, 0.5);
  732. display: block;
  733. box-sizing: border-box;
  734. padding: 0px 8px;
  735. margin-top: 7px;
  736. margin-right: 2px;
  737. border-radius: 10px;
  738. font-weight: bold;
  739. font-size: 10px;
  740. line-height: 20px;
  741. height: 20px;
  742. cursor: pointer;
  743. user-select: none;
  744. position: relative;
  745. z-index: 1;
  746. }
  747.  
  748. :is(${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS}) .pixiv_utils_edit_bookmark,
  749. :is(${_formatSelectorsMultiViewControls().join(', ')}) .pixiv_utils_edit_bookmark {
  750. font-size: 12px;
  751. height: 24px;
  752. line-height: 24px;
  753. margin-top: 5px;
  754. margin-right: 7px;
  755. }
  756.  
  757. :is(._layout-thumbnail, .novel-right-contents, .imgoverlay) .pixiv_utils_edit_bookmark {
  758. position: absolute !important;
  759. right: calc(50% - 71px);
  760. bottom: 4px;
  761. z-index: 2;
  762. }
  763.  
  764. .novel-right-contents .pixiv_utils_edit_bookmark {
  765. right: 50px;
  766. }
  767.  
  768. .imgoverlay .pixiv_utils_edit_bookmark {
  769. right: 40px;
  770. bottom: 15px;
  771. }
  772.  
  773. :not(#higher_specificity) :has(> .pixiv_utils_image_artist_container) {
  774. position: relative !important;
  775. }
  776.  
  777. .pixiv_utils_image_artist_container {
  778. position: absolute;
  779. padding: 5px;
  780. bottom: 0;
  781. left: 0;
  782. max-width: calc(100% - 76px);
  783. }
  784.  
  785. .pixiv_utils_image_artist {
  786. color: rgb(245, 245, 245);
  787. background: rgba(0, 0, 0, 0.5);
  788. box-sizing: border-box;
  789. padding: 0px 8px;
  790. border-radius: 10px;
  791. font-weight: bold;
  792. font-size: 14px;
  793. line-height: 20px;
  794. height: 20px;
  795. text-overflow: ellipsis;
  796. overflow: hidden;
  797. white-space: nowrap;
  798. float: left;
  799. width: 100%;
  800. }
  801.  
  802. :is(${SELECTORS_TOGGLE_BOOKMARKED_HEADER.join(', ')}):has(+ .pixiv_utils_toggle_bookmarked_container) {
  803. flex-grow: 1;
  804. justify-content: flex-end;
  805. }
  806.  
  807. .pixiv_utils_toggle_bookmarked_container {
  808. text-align: center;
  809. }
  810.  
  811. .pixiv_utils_toggle_bookmarked {
  812. color: rgb(245, 245, 245);
  813. background: rgb(58, 58, 58);
  814. display: inline-block;
  815. box-sizing: border-box;
  816. padding: 6px;
  817. border-radius: 10px;
  818. font-weight: bold;
  819. margin-left: 12px;
  820. cursor: pointer;
  821. user-select: none;
  822. }
  823.  
  824. .pixiv_utils_toggle_bookmarked:hover {
  825. text-decoration: none;
  826. }
  827.  
  828. .pixiv_utils_toggle_bookmarked span {
  829. padding-left: 6px;
  830. }
  831.  
  832. ${_FILTERED_SELECTORS_IMAGE_CONTROLS} {
  833. display: flex;
  834. justify-content: flex-end;
  835. }
  836.  
  837. [data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
  838. box-shadow: inset 0 0 0 3px var(--pixiv_utils_highlight_color, ${CONFIG.PIXIV_HIGHLIGHTED_COLOR});
  839. border-radius: 8px;
  840. content: '';
  841. display: block;
  842. width: 100%;
  843. height: 100%;
  844. position: absolute;
  845. top: 0;
  846. }
  847.  
  848. :is(${SELECTORS_IMAGE_CONTAINER_SIMPLIFIED}) [data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED},
  849. :is(${SELECTORS_IMAGE_SMALL})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED},
  850. :is(${SELECTORS_IMAGE_MOBILE})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
  851. box-shadow: inset 0 0 0 2px var(--pixiv_utils_highlight_color, ${CONFIG.PIXIV_HIGHLIGHTED_COLOR});
  852. }
  853.  
  854. /* expanded view's artist bottom bar */
  855. .eoaxji > div:has(a[href])[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
  856. border-radius: 4px;
  857. }
  858.  
  859. .eyusRs > div[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED}, /* user profile popup */
  860. :is(${SELECTORS_IMAGE_MOBILE})[data-pixiv_utils_highlight] ${SELECTORS_IMAGE_HIGHLIGHTED} {
  861. border-radius: 0;
  862. }
  863.  
  864. .eyusRs > div[data-pixiv_utils_highlight]:nth-child(1) ${SELECTORS_IMAGE_HIGHLIGHTED} {
  865. border-bottom-left-radius: 8px;
  866. }
  867.  
  868. .eyusRs > div[data-pixiv_utils_highlight]:nth-child(3) ${SELECTORS_IMAGE_HIGHLIGHTED} {
  869. border-bottom-right-radius: 8px;
  870. }
  871.  
  872. :not(#higher_specificity) :has(+ .pixiv_utils_blocked_image_container) {
  873. display: none !important;
  874. }
  875.  
  876. .pixiv_utils_blocked_image {
  877. display: flex;
  878. justify-content: center;
  879. align-items: center;
  880. width: 100%;
  881. color: rgb(92, 92, 92);
  882. min-width: 90px;
  883. aspect-ratio: 1 / 1;
  884. }
  885.  
  886. .pixiv_utils_blocked_image svg {
  887. fill: currentcolor;
  888. }
  889.  
  890. .ranking-item .pixiv_utils_blocked_image {
  891. max-width: 150px;
  892. margin: 0 auto;
  893. border: 1px solid rgb(242, 242, 242);
  894. }
  895.  
  896. .works-item:not(.works-item-illust) .pixiv_utils_blocked_image {
  897. min-width: 64px;
  898. max-width: 64px;
  899. }
  900.  
  901. /* Pixiv's built-in tags mute. */
  902. .ranking-item.muted .work img {
  903. filter: brightness(50%);
  904. }
  905.  
  906. .ranking-item.muted .muted-thumbnail .negative {
  907. position: relative;
  908. z-index: 1;
  909. color: rgb(92, 92, 92);
  910. }
  911.  
  912. /* Only use black background on desktop layout. */
  913. body > div:not(#wrapper) .pixiv_utils_blocked_image,
  914. body > div:not(#wrapper) .ranking-item.muted .work img {
  915. background: rgb(0, 0, 0);
  916. }
  917.  
  918. [data-pixiv_utils_blocked] :is(.series-title, .tag-container, .show-more-creator-works-button),
  919. [data-pixiv_utils_blocked] .pqkmS, /* desktop: show more creator works button */
  920. [data-pixiv_utils_blocked] ._illust-series-title-text {
  921. display: none !important;
  922. }
  923.  
  924. [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_TITLE}) {
  925. display: none !important;
  926. }
  927.  
  928. [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR}) {
  929. display: none !important;
  930. }
  931.  
  932. [data-pixiv_utils_blocked] :is(${CONFIG.SELECTORS_IMAGE_ARTIST_NAME}) {
  933. display: none !important;
  934. }
  935.  
  936. [data-pixiv_utils_blocked] :is(${_SELECTORS_IMAGE_CONTROLS.join(', ')}) {
  937. display: none !important;
  938. }
  939.  
  940. [data-pixiv_utils_blocked] .pixiv_utils_image_artist_container {
  941. max-width: calc(100% - 10px);
  942. }
  943.  
  944. [data-pixiv_utils_blocked] .pixiv_utils_image_artist {
  945. background: none;
  946. padding: 0;
  947. width: 0;
  948. }
  949.  
  950. [data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .work-main-image) :is(img, canvas) {
  951. filter: blur(32px);
  952. }
  953.  
  954. [data-pixiv_utils_expanded_view_blocked] :is([role="presentation"], .work-main-image):hover :is(img, canvas) {
  955. filter: unset;
  956. }
  957. `;
  958.  
  959. const SELECTORS_DATE_ORIGINAL = `:is(${CONFIG.SELECTORS_DATE}):not([data-pixiv_utils_duplicate_date])`;
  960.  
  961. const mainDateStyle = /*css*/`
  962. ${SELECTORS_DATE_ORIGINAL} {
  963. display: none !important;
  964. }
  965.  
  966. :is(${CONFIG.SELECTORS_DATE})[data-pixiv_utils_duplicate_date]:not([data-pixiv_utils_date_prefix]) {
  967. font-size: 14px !important;
  968. font-weight: bold !important;
  969. color: rgb(214, 214, 214) !important;
  970. }
  971. `;
  972.  
  973. const BLOCKED_IMAGE_HTML = /*html*/`
  974. <div radius="4" class="pixiv_utils_blocked_image">
  975. <svg viewBox="0 0 24 24" style="width: 48px; height: 48px;">
  976. <path d="M5.26763775,4 L9.38623853,11.4134814 L5,14.3684211 L5,18 L13.0454155,18 L14.1565266,20 L5,20
  977. 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
  978. C20.1045695,4 21,4.8954305 21,6 L21,18 C21,19.1045695 20.1045695,20 19,20 L18.7323623,20 L17.6212511,18
  979. L19,18 L19,13 L16,15 L15.9278695,14.951913 L9.84347336,4 Z M16,7 C14.8954305,7 14,7.8954305 14,9
  980. 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
  981. M7.38851434,1.64019979 L18.3598002,21.3885143 L16.6114857,22.3598002 L5.64019979,2.61148566
  982. L7.38851434,1.64019979 Z"></path>
  983. </svg>
  984. </div>
  985. `;
  986.  
  987. /** MAIN **/
  988.  
  989. GM_addStyle(mainStyle);
  990.  
  991. if (CONFIG.DATE_CONVERSION) {
  992. GM_addStyle(mainDateStyle);
  993. }
  994.  
  995. const uuidv4 = () => {
  996. return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
  997. (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
  998. );
  999. };
  1000.  
  1001. const WAIT_FOR_POLL = 1000; // ms
  1002.  
  1003. const waitForIntervals = {};
  1004.  
  1005. const waitFor = (func, element = document) => {
  1006. if (typeof func !== 'function') {
  1007. return false;
  1008. }
  1009.  
  1010. return new Promise(resolve => {
  1011. let interval = null;
  1012. const find = () => {
  1013. const result = func(element);
  1014. if (result) {
  1015. if (interval) {
  1016. delete waitForIntervals[interval];
  1017. clearInterval(interval);
  1018. }
  1019. return resolve(result);
  1020. }
  1021. };
  1022. find();
  1023. interval = setInterval(find, WAIT_FOR_POLL);
  1024. waitForIntervals[interval] = { func, element, resolve };
  1025. });
  1026. };
  1027.  
  1028. const initElementObserver = (element, callback, options = {}) => {
  1029. if (!element || typeof callback !== 'function' || typeof options !== 'object' || !Object.keys(options).length) {
  1030. return false;
  1031. }
  1032.  
  1033. // Skip if already observing.
  1034. if (element.dataset.pixiv_utils_observing) {
  1035. return false;
  1036. }
  1037.  
  1038. if (options.attributes &&
  1039. (!options.attributeFilter || options.attributeFilter.includes('pixiv_utils_observing'))) {
  1040. console.error('initElementObserver cannot be initiated without proper attributes filtering', element);
  1041. return false;
  1042. }
  1043.  
  1044. // Mark as observing.
  1045. element.dataset.pixiv_utils_observing = true;
  1046.  
  1047. const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
  1048. const observer = new MutationObserver((mutations, observer) => {
  1049. callback.call(this, mutations, observer);
  1050. });
  1051.  
  1052. observer.observe(element, options);
  1053. return observer;
  1054. };
  1055.  
  1056. const editBookmarkButton = (id, isNovel = false) => {
  1057. const buttonContainer = document.createElement('div');
  1058. buttonContainer.className = 'pixiv_utils_edit_bookmark_container';
  1059.  
  1060. const button = document.createElement('a');
  1061. button.className = 'pixiv_utils_edit_bookmark';
  1062. button.innerText = CONFIG.TEXT_EDIT_BOOKMARK;
  1063.  
  1064. if (CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP) {
  1065. button.title = CONFIG.TEXT_EDIT_BOOKMARK_TOOLTIP;
  1066. }
  1067.  
  1068. if (isNovel) {
  1069. button.href = `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`;
  1070. } else {
  1071. button.href = `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`;
  1072. }
  1073.  
  1074. buttonContainer.append(button);
  1075. return buttonContainer;
  1076. };
  1077.  
  1078. const findArtworkUrl = element => {
  1079. return element.querySelector('a[href*="artworks/"]');
  1080. };
  1081.  
  1082. const findIllustUrl = element => {
  1083. return element.querySelector('a[href*="illust_id="]');
  1084. };
  1085.  
  1086. const findNovelUrl = element => {
  1087. return element.querySelector('a[href*="novel/show.php?id="]');
  1088. };
  1089.  
  1090. const findIllustImg = element => {
  1091. return element.querySelector('img[src*="_p0"]');
  1092. };
  1093.  
  1094. const findItemData = (element, tryImg = false) => {
  1095. const methods = [
  1096. { func: findIllustImg, regex: /\/(\d+)_p0/, img: true },
  1097. { func: findArtworkUrl, regex: /artworks\/(\d+)/ },
  1098. { func: findIllustUrl, regex: /illust_id=(\d+)/ },
  1099. { func: findNovelUrl, regex: /novel\/show\.php\?id=(\d+)/, novel: true }
  1100. ];
  1101.  
  1102. const result = {
  1103. id: null,
  1104. novel: false
  1105. };
  1106.  
  1107. for (const method of methods) {
  1108. if (method.img && !tryImg) {
  1109. continue;
  1110. }
  1111.  
  1112. const found = method.func(element);
  1113. if (found) {
  1114. let value = '';
  1115. if (method.img) {
  1116. value = found.src;
  1117. result.img = found;
  1118. } else {
  1119. value = found.href;
  1120. result.link = found;
  1121. }
  1122.  
  1123. const match = value.match(method.regex);
  1124. if (match) {
  1125. result.id = match[1];
  1126. result.novel = Boolean(method.novel);
  1127. }
  1128.  
  1129. break;
  1130. }
  1131. }
  1132.  
  1133. return result;
  1134. };
  1135.  
  1136. // Toggle Bookmarked Modes.
  1137. // 0 = Show all
  1138. // 1 = Show not bookmarked
  1139. // 2 = Show bookmarked
  1140. const _TB_MIN = 0;
  1141. const _TB_MAX = 2;
  1142.  
  1143. let toggleBookmarkedMode = null;
  1144.  
  1145. const isImageBookmarked = element => {
  1146. return element.querySelector(CONFIG.SELECTORS_IMAGE_BOOKMARKED) !== null;
  1147. };
  1148.  
  1149. const getImagePixivData = async element => {
  1150. const data = {
  1151. title: null,
  1152. ai: null,
  1153. tags: null
  1154. };
  1155.  
  1156. if (element.__vue__) {
  1157. for (const key of ['item', 'illustDetails']) {
  1158. const _data = element.__vue__._props?.[key];
  1159. if (!_data) {
  1160. continue;
  1161. }
  1162.  
  1163. if (key === 'item') {
  1164. const awaited = await waitFor(() => !_data.notLoaded, element);
  1165. if (!awaited) {
  1166. return false;
  1167. }
  1168. }
  1169.  
  1170. data.title = _data.title;
  1171. data.ai = _data.ai_type === 2;
  1172. data.tags = _data.tags;
  1173. }
  1174. } else {
  1175. const reactFiberKey = Object.keys(element).find(k => k.startsWith('__reactFiber'));
  1176. if (reactFiberKey) {
  1177. const MAX_STEPS = 5;
  1178.  
  1179. let step = 0;
  1180. const traverseChild = obj => {
  1181. if (!obj || !obj.memoizedProps) {
  1182. return;
  1183. }
  1184.  
  1185. step++;
  1186. let source = null;
  1187.  
  1188. const props = obj.memoizedProps;
  1189. if (props.tags) {
  1190. source = props;
  1191. } else if (props.content?.thumbnails?.length) {
  1192. source = props.content.thumbnails[0];
  1193. } else if (props.children) {
  1194. let children = props.children;
  1195. if (!Array.isArray(children) || typeof children !== 'object') {
  1196. children = props.children.props?.children;
  1197. }
  1198. if (children) {
  1199. if (!Array.isArray(children)) {
  1200. children = [children];
  1201. }
  1202. for (const child of children) {
  1203. if (child.props?.thumbnail) {
  1204. source = child.props.thumbnail;
  1205. break;
  1206. }
  1207. }
  1208. }
  1209. } else {
  1210. for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
  1211. if (props[key]) {
  1212. source = props[key];
  1213. break;
  1214. }
  1215. }
  1216. }
  1217.  
  1218. if (source !== null) {
  1219. data.title = source.title;
  1220. data.ai = source.aiType === 2;
  1221. data.tags = source.tags;
  1222. }
  1223.  
  1224. if (data.tags === null && step < MAX_STEPS) {
  1225. traverseChild(obj.child);
  1226. }
  1227. };
  1228.  
  1229. traverseChild(element[reactFiberKey]);
  1230. }
  1231. }
  1232.  
  1233. if (!data.tags) {
  1234. data.tags = [];
  1235. }
  1236.  
  1237. // Re-map extended tags data.
  1238. data.tags = data.tags.map(tag => typeof tag !== 'string' ? tag.name : tag);
  1239.  
  1240. return data;
  1241. };
  1242.  
  1243. const setImageTitle = (element, options = {}) => {
  1244. let title = '';
  1245.  
  1246. if (options.pixivData.title) {
  1247. title += options.pixivData.title;
  1248. }
  1249.  
  1250. if (options.pixivData.ai) {
  1251. title += '\nAI-generated';
  1252. }
  1253.  
  1254. if (options.pixivData.tags.length) {
  1255. title += `\n${options.pixivData.tags.join(', ')}`;
  1256. }
  1257.  
  1258. if (options.footer) {
  1259. title += `\n${options.footer}`;
  1260. }
  1261.  
  1262. title = title.trim();
  1263.  
  1264. if (title.length) {
  1265. element.title = title.trim();
  1266. }
  1267. };
  1268.  
  1269. const isImageHighlightedByData = data => {
  1270. const result = {
  1271. highlightedTags: [],
  1272. hint: '',
  1273. color: null
  1274. };
  1275.  
  1276. for (const config of PIXIV_HIGHLIGHTED_TAGS_FORMATTED) {
  1277. for (const tag of data.tags) {
  1278. if (config.string?.includes(tag) || config.regexp?.some(t => t.test(tag))) {
  1279. result.highlightedTags.push(tag);
  1280. if (!result.color && config.color) {
  1281. result.color = config.color;
  1282. }
  1283. }
  1284. }
  1285. }
  1286.  
  1287. if (!result.highlightedTags.length) {
  1288. return false;
  1289. }
  1290.  
  1291. if (!result.color) {
  1292. result.color = CONFIG.PIXIV_HIGHLIGHTED_COLOR;
  1293. }
  1294.  
  1295. result.hint = `Tags: ${result.highlightedTags.join(', ')}`;
  1296. return result;
  1297. };
  1298.  
  1299. const doHighlightImage = (element, options = {}) => {
  1300. // Skip if already highlighted.
  1301. if (element.dataset.pixiv_utils_highlight) {
  1302. return false;
  1303. }
  1304.  
  1305. const highlighted = isImageHighlightedByData(options.pixivData);
  1306. if (!highlighted) {
  1307. return false;
  1308. }
  1309.  
  1310. element.dataset.pixiv_utils_highlight = true;
  1311.  
  1312. if (highlighted.color) {
  1313. element.style.setProperty('--pixiv_utils_highlight_color', highlighted.color);
  1314. }
  1315.  
  1316. return highlighted;
  1317. };
  1318.  
  1319. const isImageBlockedByData = data => {
  1320. const result = {
  1321. blockedAI: CONFIG.PIXIV_BLOCK_AI && data.ai,
  1322. blockedTags: [],
  1323. hint: ''
  1324. };
  1325.  
  1326. for (const tag of data.tags) {
  1327. if (PIXIV_BLOCKED_TAGS_STRING.includes(tag) || PIXIV_BLOCKED_TAGS_REGEXP.some(t => t.test(tag))) {
  1328. result.blockedTags.push(tag);
  1329. }
  1330. }
  1331.  
  1332. if (!result.blockedAI && !result.blockedTags.length) {
  1333. return false;
  1334. }
  1335.  
  1336. if (CONFIG.PIXIV_BLOCK_AI && result.blockedAI) {
  1337. result.hint += 'AI-generated';
  1338. }
  1339.  
  1340. if (result.blockedTags.length) {
  1341. result.hint += `\nTags: ${result.blockedTags.join(', ')}`;
  1342. }
  1343.  
  1344. result.hint = result.hint.trim();
  1345. return result;
  1346. };
  1347.  
  1348. const getImageBlockSkipReason = (element, options = {}) => {
  1349. const skipReason = [];
  1350.  
  1351. if (options.isOwnProfile) {
  1352. skipReason.push('In own profile');
  1353. }
  1354.  
  1355. if (options.bookmarked) {
  1356. skipReason.push('Image is bookmarked');
  1357. }
  1358.  
  1359. return skipReason;
  1360. };
  1361.  
  1362. const setImageBlocked = (element, options = {}) => {
  1363. // Skip if already blocked (check for element due to possibility of dynamic update).
  1364. if (element.querySelector('.pixiv_utils_blocked_image_container')) {
  1365. return false;
  1366. }
  1367.  
  1368. element.dataset.pixiv_utils_blocked = true;
  1369.  
  1370. // For mobile, never remove blocked, as it does not behave well with Pixiv's in-place navigation.
  1371. if (options.remove && !options.mobile) {
  1372. element.style.display = 'none';
  1373. return true;
  1374. }
  1375.  
  1376. const blockedThumb = document.createElement('a');
  1377. blockedThumb.className = 'pixiv_utils_blocked_image_container';
  1378. blockedThumb.href = options.link.href;
  1379. blockedThumb.innerHTML = BLOCKED_IMAGE_HTML;
  1380.  
  1381. options.link.after(blockedThumb);
  1382.  
  1383. return true;
  1384. };
  1385.  
  1386. const doBlockImage = async (element, options = {}) => {
  1387. const blockable = isImageBlockedByData(options.pixivData);
  1388. if (!blockable) {
  1389. return false;
  1390. }
  1391.  
  1392. // Do not ever remove in sections known to have display issues.
  1393. let remove = CONFIG.PIXIV_REMOVE_BLOCKED;
  1394. if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED) ||
  1395. element.matches(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE)) {
  1396. remove = false;
  1397. }
  1398.  
  1399. if (options.skipReason) {
  1400. logDebug(`Image is blockable, but skipped (reason: ${options.skipReason})`, element);
  1401. } else {
  1402. const status = setImageBlocked(element, {
  1403. mobile: element.matches(SELECTORS_IMAGE_MOBILE),
  1404. remove,
  1405. link: options.data.link
  1406. });
  1407.  
  1408. if (status) {
  1409. setImageTitle(element, {
  1410. data: options.data,
  1411. pixivData: options.pixivData,
  1412. footer: `Blocked by:\n${blockable.hint}`
  1413. });
  1414. logDebug(`Image blocked (${blockable.hint.replace('\n', ', ')})`, element);
  1415. }
  1416. }
  1417.  
  1418. return blockable;
  1419. };
  1420.  
  1421. const addImageArtist = async element => {
  1422. let userId = null;
  1423. let userName = null;
  1424.  
  1425. if (element.__vue__) {
  1426. const awaited = await waitFor(() => !element.__vue__._props?.item?.notLoaded, element);
  1427. if (!awaited) {
  1428. return false;
  1429. }
  1430.  
  1431. userId = element.__vue__._props.item.user_id;
  1432. userName = element.__vue__._props.item.author_details.user_name;
  1433. } else {
  1434. const reactPropsKey = Object.keys(element).find(k => k.startsWith('__reactProps'));
  1435. if (!reactPropsKey) {
  1436. return false;
  1437. }
  1438.  
  1439. for (const key of ['rawThumbnail', 'thumbnail', 'work']) {
  1440. if (element[reactPropsKey].children?.props?.[key]) {
  1441. userId = element[reactPropsKey].children.props[key].userId;
  1442. userName = element[reactPropsKey].children.props[key].userName;
  1443. break;
  1444. }
  1445. }
  1446. }
  1447.  
  1448. if (!userId || !userName) {
  1449. return false;
  1450. }
  1451.  
  1452. const div = document.createElement('div');
  1453. div.className = 'pixiv_utils_image_artist_container';
  1454. div.innerHTML = /*html*/`
  1455. <a class="pixiv_utils_image_artist" href="https://www.pixiv.net/users/${userId}">${userName}</a>
  1456. `;
  1457.  
  1458. element.append(div);
  1459. return true;
  1460. };
  1461.  
  1462. const doImage = async (element, options = {}) => {
  1463. // Skip if invalid.
  1464. if (!element.querySelector('a[href]')) {
  1465. return false;
  1466. }
  1467.  
  1468. const data = findItemData(element);
  1469. if (data.id === null) {
  1470. return false;
  1471. }
  1472.  
  1473. if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && options.isHome && data.novel) {
  1474. element.style.display = 'none';
  1475. logDebug('Novel recommendation removed from home', element);
  1476. return true;
  1477. }
  1478.  
  1479. // Process new entries in toggled bookmarked sections.
  1480. const bookmarked = isImageBookmarked(element);
  1481. if (element.closest('[data-pixiv_utils_toggle_bookmarked_section]')) {
  1482. if (toggleBookmarkedMode === 1) {
  1483. element.style.display = bookmarked ? 'none' : '';
  1484. } else if (toggleBookmarkedMode === 2) {
  1485. element.style.display = bookmarked ? '' : 'none';
  1486. }
  1487. }
  1488.  
  1489. // Skip if edit bookmark button already inserted, unless forced.
  1490. if (element.querySelector('.pixiv_utils_edit_bookmark') && !options.forced) {
  1491. return false;
  1492. }
  1493.  
  1494. // Init MutationObserver for dynamic images.
  1495. if (element.__vue__) {
  1496. if (!element.dataset.pixiv_utils_last_tx) {
  1497. initElementObserver(element, () => {
  1498. const lastGrid = element.dataset.pixiv_utils_last_grid === 'true';
  1499. if (element.dataset.tx !== element.dataset.pixiv_utils_last_tx ||
  1500. element.classList.contains('grid') !== lastGrid) {
  1501. options.forced = true;
  1502. doImage(element, options);
  1503. }
  1504. }, {
  1505. attributes: true,
  1506. // Monitor class tag to also detect list/grid view change.
  1507. attributeFilter: ['class', 'data-tx']
  1508. });
  1509. }
  1510. element.dataset.pixiv_utils_last_tx = element.dataset.tx;
  1511. element.dataset.pixiv_utils_last_grid = element.classList.contains('grid');
  1512. }
  1513.  
  1514. // Skip if already blocked, unless forced.
  1515. if (element.dataset.pixiv_utils_blocked) {
  1516. if (options.forced) {
  1517. delete element.dataset.pixiv_utils_blocked;
  1518. const blockedThumb = element.querySelector('.pixiv_utils_blocked_image_container');
  1519. if (blockedThumb) {
  1520. blockedThumb.remove();
  1521. }
  1522. } else {
  1523. return false;
  1524. }
  1525. }
  1526.  
  1527. // Reset other statuses if forced.
  1528. if (options.forced) {
  1529. delete element.title;
  1530. delete element.dataset.pixiv_utils_highlight;
  1531. element.style.removeProperty('--pixiv_utils_highlight_color');
  1532. }
  1533.  
  1534. const pixivData = await getImagePixivData(element);
  1535.  
  1536. // Only block images if not in own profile, and not bookmarked.
  1537. const skipReason = getImageBlockSkipReason(element, { isOwnProfile, bookmarked });
  1538.  
  1539. let blockable = false;
  1540. if (PIXIV_BLOCKED_TAGS_VALIDATED) {
  1541. blockable = await doBlockImage(element, { data, pixivData, skipReason: skipReason.join(', ') });
  1542. if (blockable && !skipReason.length) {
  1543. return true;
  1544. }
  1545. }
  1546.  
  1547. let footer = '';
  1548.  
  1549. if (PIXIV_HIGHLIGHTED_TAGS_VALIDATED) {
  1550. const highlighted = await doHighlightImage(element, { data, pixivData });
  1551. if (highlighted) {
  1552. footer += `Highlighted by:\n${highlighted.hint}`;
  1553. }
  1554. }
  1555.  
  1556. if (blockable) {
  1557. footer += `\nBlockable by:\n${blockable.hint}` +
  1558. `\nSkipped due to:\n${skipReason.join('\n')}`;
  1559. }
  1560.  
  1561. setImageTitle(element, { data, pixivData, footer: footer.trim() });
  1562.  
  1563. // Exit early if in own profile, and not in bookmarks tab.
  1564. if (options.isOwnProfile && currentUrl.indexOf('/bookmarks') === -1) {
  1565. return false;
  1566. }
  1567.  
  1568. // Exit early if in sections where images won't have control buttons.
  1569. if (element.closest(SELECTORS_IMAGE_CONTAINER_SIMPLIFIED)) {
  1570. return false;
  1571. }
  1572.  
  1573. const oldImageArtist = element.querySelector('.pixiv_utils_image_artist_container');
  1574. if (oldImageArtist) {
  1575. oldImageArtist.remove();
  1576. }
  1577.  
  1578. let imageControls = null;
  1579. if (data.novel) {
  1580. imageControls = element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
  1581. } else {
  1582. // If it's not a novel, assume image controls may be delayed due to still being generated.
  1583. imageControls = await waitFor(() => {
  1584. return element.querySelector(CONFIG.SELECTORS_IMAGE_CONTROLS);
  1585. }, element);
  1586. }
  1587.  
  1588. if (!imageControls) {
  1589. return false;
  1590. }
  1591.  
  1592. const artistTag = element.querySelector('a[href*="users/"]');
  1593. let hasVisibleArtistTag = Boolean(artistTag);
  1594. if (hasVisibleArtistTag && element.offsetParent !== null) {
  1595. // If the image itself is visible, but its built-in artist tag is not.
  1596. hasVisibleArtistTag = artistTag.offsetParent !== null;
  1597. }
  1598.  
  1599. // Add artist tag if necessary.
  1600. if (!hasVisibleArtistTag &&
  1601. // never in mobile expanded view's artist bottom bar
  1602. !element.closest('.user-details-card ~ div .works-horizontal-list') &&
  1603. (currentUrl.indexOf('users/') === -1 || // never in artist page (except bookmarks tab)
  1604. (currentUrl.indexOf('users/') !== -1 && currentUrl.indexOf('/bookmarks') !== -1))) {
  1605. await addImageArtist(element);
  1606. }
  1607.  
  1608. const oldEditBookmarkButton = imageControls.querySelector('.pixiv_utils_edit_bookmark_container');
  1609. if (oldEditBookmarkButton) {
  1610. oldEditBookmarkButton.remove();
  1611. }
  1612.  
  1613. imageControls.prepend(editBookmarkButton(data.id, data.novel));
  1614. return true;
  1615. };
  1616.  
  1617. const doBlockMultiView = async (element, options = {}) => {
  1618. const blocked = isImageBlockedByData(options.pixivData);
  1619. if (!blocked) {
  1620. return false;
  1621. }
  1622.  
  1623. // For multi view artwork, always hide the whole entry instead.
  1624. element.parentNode.style.display = 'none';
  1625. logDebug(`Multi view entry removed (${blocked.hint})`, element);
  1626. return true;
  1627. };
  1628.  
  1629. const doMultiView = async (element, options = {}) => {
  1630. const data = findItemData(element);
  1631. if (data.id === null) {
  1632. return false;
  1633. }
  1634.  
  1635. const pixivDataSource = element.querySelector('div[data-ga4-label="thumbnail_link"]');
  1636. if (pixivDataSource) {
  1637. const pixivData = await getImagePixivData(pixivDataSource);
  1638. if (pixivData) {
  1639. // Only block images if not bookmarked.
  1640. const skipReason = getImageBlockSkipReason(element, {
  1641. bookmarked: isImageBookmarked(element)
  1642. });
  1643.  
  1644. let blockable = false;
  1645. if (PIXIV_BLOCKED_TAGS_VALIDATED) {
  1646. blockable = await doBlockMultiView(element, { pixivData, skipReason: skipReason.join(', ') });
  1647. if (blockable && !skipReason.length) {
  1648. return true;
  1649. }
  1650. }
  1651.  
  1652. let footer = '';
  1653. if (blockable) {
  1654. footer += `Blockable by:\n${blockable.hint}` +
  1655. `\nSkipped due to:\n${skipReason.join('\n')}`;
  1656. }
  1657.  
  1658. setImageTitle(element, { data, pixivData, footer });
  1659. }
  1660. }
  1661.  
  1662. if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && options.isHome && data.novel) {
  1663. element.parentNode.style.display = 'none';
  1664. logDebug('Novel recommendation removed from home', element);
  1665. return true;
  1666. }
  1667.  
  1668. // Skip if edit bookmark button already inserted.
  1669. if (element.querySelector('.pixiv_utils_edit_bookmark')) {
  1670. return false;
  1671. }
  1672.  
  1673. const multiViewControls = element.querySelector(CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
  1674. if (!multiViewControls) {
  1675. return false;
  1676. }
  1677.  
  1678. multiViewControls.lastChild.before(editBookmarkButton(data.id, data.novel));
  1679. return true;
  1680. };
  1681.  
  1682. const doBlockExpandedView = async (element, options = {}) => {
  1683. // Reset blocked status if necessary.
  1684. delete element.dataset.pixiv_utils_expanded_view_blocked;
  1685.  
  1686. const blocked = isImageBlockedByData(options.pixivData);
  1687. if (!blocked) {
  1688. return false;
  1689. }
  1690.  
  1691. if (options.skipReason) {
  1692. logDebug(`Expanded view is blockable, but skipped (reason: ${options.skipReason})`, element);
  1693. return false;
  1694. } else {
  1695. element.dataset.pixiv_utils_expanded_view_blocked = true;
  1696. logDebug(`Expanded view blocked (${blocked.hint})`, element);
  1697. return true;
  1698. }
  1699. };
  1700.  
  1701. const doExpandedViewControls = async (element, options = {}) => {
  1702. const image = element.closest(CONFIG.SELECTORS_EXPANDED_VIEW_IMAGE);
  1703. if (!image) {
  1704. return false;
  1705. }
  1706.  
  1707. const pixivData = await getImagePixivData(image);
  1708. if (pixivData && PIXIV_BLOCKED_TAGS_VALIDATED) {
  1709. // Only block image if not bookmarked.
  1710. const skipReason = getImageBlockSkipReason(image, {
  1711. bookmarked: isImageBookmarked(image)
  1712. });
  1713.  
  1714. await doBlockExpandedView(image, {
  1715. pixivData,
  1716. skipReason: skipReason.join(', ')
  1717. });
  1718. }
  1719.  
  1720. // Init MutationObserver for dynamic expanded view.
  1721. if (image.__vue__) {
  1722. const target = image.querySelector('.work-main-image');
  1723. if (!image.dataset.pixiv_utils_last_id) {
  1724. initElementObserver(target, mutations => {
  1725. const data = findItemData(image, true);
  1726. if (data.id !== image.dataset.pixiv_utils_last_id) {
  1727. options.forced = true;
  1728. doExpandedViewControls(image, options);
  1729. }
  1730. }, {
  1731. subtree: true,
  1732. childList: true,
  1733. attributes: true,
  1734. attributeFilter: ['href', 'src']
  1735. });
  1736. }
  1737. const data = findItemData(image, true);
  1738. image.dataset.pixiv_utils_last_id = data.id;
  1739. }
  1740.  
  1741. // Skip if edit bookmark button already inserted, unless forced.
  1742. if (element.querySelector('.pixiv_utils_edit_bookmark') && !options.forced) {
  1743. return false;
  1744. }
  1745.  
  1746. // Re-attempt to convert date.
  1747. if (CONFIG.DATE_CONVERSION) {
  1748. const dates = image.querySelectorAll(SELECTORS_DATE_ORIGINAL);
  1749. for (const date of dates) {
  1750. convertDate(date);
  1751. }
  1752. }
  1753.  
  1754. let id = null;
  1755. let isNovel = false;
  1756.  
  1757. let match = window.location.href.match(/artworks\/(\d+)/);
  1758. if (match && match[1]) {
  1759. id = match[1];
  1760. } else {
  1761. match = window.location.href.match(/novel\/show\.php\?id=(\d+)/);
  1762. if (match && match[1]) {
  1763. id = match[1];
  1764. isNovel = true;
  1765. }
  1766. }
  1767.  
  1768. if (id !== null) {
  1769. element.append(editBookmarkButton(id, isNovel));
  1770.  
  1771. // Re-process expanded view's artist bottom bar.
  1772. const images = document.querySelectorAll(CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE);
  1773. for (const image of images) {
  1774. await doImage(image, { forced: true });
  1775. }
  1776.  
  1777. return true;
  1778. }
  1779.  
  1780. return false;
  1781. };
  1782.  
  1783. const formatToggleBookmarkedButtonHtml = mode => {
  1784. if (mode === 0) {
  1785. return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_ALL}<span>`;
  1786. } else if (mode === 1) {
  1787. return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_NOT_BOOKMARKED}<span>`;
  1788. } else if (mode === 2) {
  1789. return /*html*/`${CONFIG.TEXT_TOGGLE_BOOKMARKED}<span>${CONFIG.TEXT_TOGGLE_BOOKMARKED_SHOW_BOOKMARKED}<span>`;
  1790. }
  1791. };
  1792.  
  1793. let toggling = false;
  1794. const toggleBookmarked = (button, parent, header, imagesContainer, options = {}) => {
  1795. if (toggling) {
  1796. return false;
  1797. }
  1798.  
  1799. toggling = true;
  1800.  
  1801. if (options.sync) {
  1802. toggleBookmarkedMode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN);
  1803. } else if (options.rightClick) {
  1804. toggleBookmarkedMode--;
  1805. } else {
  1806. toggleBookmarkedMode++;
  1807. }
  1808. if (toggleBookmarkedMode > _TB_MAX) {
  1809. toggleBookmarkedMode = _TB_MIN;
  1810. } else if (toggleBookmarkedMode < _TB_MIN) {
  1811. toggleBookmarkedMode = _TB_MAX;
  1812. }
  1813.  
  1814. button.innerHTML = formatToggleBookmarkedButtonHtml(toggleBookmarkedMode);
  1815.  
  1816. let images = Array.from(imagesContainer.querySelectorAll(CONFIG.SELECTORS_IMAGE));
  1817.  
  1818. // Do not process blocked images if they are already forcefully hidden.
  1819. if (CONFIG.PIXIV_REMOVE_BLOCKED || CONFIG.UTAGS_REMOVE_BLOCKED) {
  1820. images = images.filter(image => !image.dataset.pixiv_utils_blocked);
  1821. }
  1822.  
  1823. if (toggleBookmarkedMode === 0) {
  1824. for (const image of images) {
  1825. image.style.display = '';
  1826. }
  1827. } else if (toggleBookmarkedMode === 1) {
  1828. for (const image of images) {
  1829. if (image.dataset.pixiv_utils_blocked || isImageBookmarked(image)) {
  1830. image.style.display = 'none';
  1831. } else {
  1832. image.style.display = '';
  1833. }
  1834. }
  1835. } else if (toggleBookmarkedMode === 2) {
  1836. for (const image of images) {
  1837. if (image.dataset.pixiv_utils_blocked || !isImageBookmarked(image)) {
  1838. image.style.display = 'none';
  1839. } else {
  1840. image.style.display = '';
  1841. }
  1842. }
  1843. }
  1844.  
  1845. GM_setValue('PREF_TOGGLE_BOOKMARKED_MODE', toggleBookmarkedMode);
  1846.  
  1847. toggling = false;
  1848.  
  1849. return true;
  1850. };
  1851.  
  1852. const doToggleBookmarkedSection = async (element, sectionConfig) => {
  1853. // Skip if this config has a sanity check function, and it passes.
  1854. if (typeof sectionConfig.sanityCheck === 'function' && sectionConfig.sanityCheck()) {
  1855. return false;
  1856. }
  1857.  
  1858. const imagesContainer = element.querySelector(sectionConfig.selectorImagesContainer);
  1859. if (!imagesContainer) {
  1860. return false;
  1861. }
  1862.  
  1863. // Skip if already processed.
  1864. if (element.dataset.pixiv_utils_toggle_bookmarked_section) {
  1865. if (element.dataset.pixiv_utils_toggle_bookmarked_section ===
  1866. imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section) {
  1867. return false;
  1868. }
  1869. logDebug('Refreshing toggle bookmarked section due to images container update', element);
  1870. }
  1871.  
  1872. // Load latest state from storage for the first time.
  1873. if (toggleBookmarkedMode === null) {
  1874. toggleBookmarkedMode = GM_getValue('PREF_TOGGLE_BOOKMARKED_MODE', _TB_MIN);
  1875. }
  1876.  
  1877. const header = element.querySelector(sectionConfig.selectorHeader);
  1878. if (!header) {
  1879. return false;
  1880. }
  1881.  
  1882. // Mark as processed.
  1883. const uuid = element.dataset.pixiv_utils_toggle_bookmarked_section || uuidv4();
  1884. element.dataset.pixiv_utils_toggle_bookmarked_section =
  1885. imagesContainer.dataset.pixiv_utils_toggle_bookmarked_section = uuid;
  1886.  
  1887. // Clear old button if it's being refreshed.
  1888. const oldButtonContainer = element.querySelector('.pixiv_utils_toggle_bookmarked_container');
  1889. if (oldButtonContainer) {
  1890. oldButtonContainer.remove();
  1891. }
  1892.  
  1893. const buttonContainer = document.createElement('div');
  1894. buttonContainer.className = 'pixiv_utils_toggle_bookmarked_container';
  1895.  
  1896. const button = document.createElement('a');
  1897. button.className = 'pixiv_utils_toggle_bookmarked';
  1898. button.innerHTML = formatToggleBookmarkedButtonHtml(toggleBookmarkedMode);
  1899.  
  1900. if (CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP) {
  1901. button.title = CONFIG.TEXT_TOGGLE_BOOKMARKED_TOOLTIP;
  1902. }
  1903.  
  1904. // Left click.
  1905. button.addEventListener('click', event => {
  1906. event.preventDefault();
  1907. toggleBookmarked(button, element, header, imagesContainer, {
  1908. sync: event.shiftKey
  1909. });
  1910. });
  1911.  
  1912. // Right click.
  1913. button.addEventListener('contextmenu', event => {
  1914. event.preventDefault();
  1915. toggleBookmarked(button, element, header, imagesContainer, {
  1916. rightClick: true
  1917. });
  1918. });
  1919.  
  1920. buttonContainer.append(button);
  1921. header.append(buttonContainer);
  1922. return true;
  1923. };
  1924.  
  1925. const doTagButton = element => {
  1926. let tag = null;
  1927.  
  1928. const tags = element.querySelectorAll('div[title]');
  1929. if (tags.length) {
  1930. const tagRaw = tags[tags.length - 1].textContent;
  1931. if (tagRaw.startsWith('#')) {
  1932. tag = tagRaw.substring(1);
  1933. }
  1934. }
  1935.  
  1936. if (!tag) {
  1937. return false;
  1938. }
  1939.  
  1940. const blocked = PIXIV_BLOCKED_TAGS_STRING.includes(tag) || PIXIV_BLOCKED_TAGS_REGEXP.some(t => t.test(tag));
  1941.  
  1942. if (blocked) {
  1943. element.style.display = 'none';
  1944. logDebug(`Tag button blocked (${tag})`);
  1945. }
  1946.  
  1947. return blocked;
  1948. };
  1949.  
  1950. const doUtags = async (element, options) => {
  1951. let image = element.closest(CONFIG.SELECTORS_IMAGE);
  1952.  
  1953. let mobile = false;
  1954. if (image) {
  1955. mobile = image.matches(SELECTORS_IMAGE_MOBILE);
  1956. } else {
  1957. // For mobile images, re-attempt query with some patience.
  1958. image = element.closest(SELECTORS_IMAGE_MOBILE);
  1959. if (image) {
  1960. mobile = true;
  1961. const awaited = await waitFor(() => image.querySelector('.thumb:not([src^="data"])'), image);
  1962. if (!awaited) {
  1963. return false;
  1964. }
  1965. }
  1966. }
  1967.  
  1968. const utag = element.dataset.utags_tag;
  1969.  
  1970. if (image) {
  1971. const data = findItemData(image);
  1972. if (data.id === null) {
  1973. return false;
  1974. }
  1975.  
  1976. // Only block images if not in own profile, and not bookmarked.
  1977. const skipReason = getImageBlockSkipReason(image, {
  1978. isOwnProfile,
  1979. bookmarked: isImageBookmarked(image)
  1980. });
  1981.  
  1982. const pixivData = await getImagePixivData(image);
  1983.  
  1984. if (skipReason.length) {
  1985. setImageTitle(image, {
  1986. data,
  1987. pixivData,
  1988. footer: `Blockable by:\nUTag: ${utag}` +
  1989. `\nSkipped due to:\n${skipReason.join('\n')}`
  1990. });
  1991. logDebug(`Image is blockable, but skipped (reason: ${skipReason.join(', ')})`, image);
  1992. return false;
  1993. } else {
  1994. const status = setImageBlocked(image, {
  1995. mobile,
  1996. remove: CONFIG.UTAGS_REMOVE_BLOCKED,
  1997. link: data.link
  1998. });
  1999. if (status) {
  2000. setImageTitle(image, {
  2001. data,
  2002. pixivData,
  2003. footer: `Blocked by:\nUTag: ${utag}`
  2004. });
  2005. logDebug(`Image blocked (UTag: ${utag})`, image);
  2006. }
  2007. return status;
  2008. }
  2009. }
  2010.  
  2011. const multiView = element.closest(CONFIG.SELECTORS_MULTI_VIEW);
  2012. if (multiView) {
  2013. // For multi view artwork, always hide the whole entry instead.
  2014. multiView.parentNode.style.display = 'none';
  2015. logDebug(`Multi view entry removed (UTag: ${utag})`, multiView);
  2016. return true;
  2017. }
  2018.  
  2019. const recommendedUserContainer = element.closest(CONFIG.SELECTORS_RECOMMENDED_USER_CONTAINER);
  2020. if (recommendedUserContainer) {
  2021. recommendedUserContainer.style.display = 'none';
  2022. logDebug(`Recommended user removed (UTag: ${utag})`, recommendedUserContainer);
  2023. return true;
  2024. }
  2025.  
  2026. const followButtonContainer = element.closest(CONFIG.SELECTORS_FOLLOW_BUTTON_CONTAINER);
  2027. if (followButtonContainer) {
  2028. const followButton = followButtonContainer.querySelector(CONFIG.SELECTORS_FOLLOW_BUTTON);
  2029. if (followButton) {
  2030. // Cosmetic only. This will not disable Pixiv's built-in "F" keybind.
  2031. followButton.classList.add('disabled');
  2032. followButton.disabled = true;
  2033. logDebug(`Follow button disabled (UTag: ${utag})`, followButtonContainer);
  2034. // Return early since there will only be one follow button per container.
  2035. return true;
  2036. }
  2037. }
  2038.  
  2039. return false;
  2040. };
  2041.  
  2042. let isHome = false;
  2043. let isOwnProfile = false;
  2044.  
  2045. const determinePageType = () => {
  2046. isHome = Boolean(document.querySelector(CONFIG.SELECTORS_HOME));
  2047. isOwnProfile = Boolean(document.querySelector(CONFIG.SELECTORS_OWN_PROFILE));
  2048. logDebug(`isHome: ${isHome}, isOwnProfile: ${isOwnProfile}`);
  2049. };
  2050.  
  2051. window.addEventListener('detectnavigate', event => {
  2052. const intervals = Object.keys(waitForIntervals);
  2053. for (const interval of intervals) {
  2054. clearInterval(interval);
  2055. waitForIntervals[interval].resolve();
  2056. delete waitForIntervals[interval];
  2057. }
  2058. if (intervals.length > 0) {
  2059. logDebug(`Cleared ${intervals.length} pending waitFor interval(s).`);
  2060. }
  2061.  
  2062. // Reset page type.
  2063. isHome = isOwnProfile = false;
  2064. });
  2065.  
  2066. /** SENTINEL */
  2067.  
  2068. waitPageLoaded().then(() => {
  2069. // Immediately attempt to determine page type.
  2070. determinePageType();
  2071.  
  2072. sentinel.on(CONFIG.SELECTORS_HOME, () => {
  2073. isHome = true;
  2074. logDebug(`isHome: ${isHome}`);
  2075. });
  2076.  
  2077. sentinel.on(CONFIG.SELECTORS_OWN_PROFILE, () => {
  2078. isOwnProfile = true;
  2079. logDebug(`isOwnProfile: ${isOwnProfile}`);
  2080. });
  2081.  
  2082. // Expanded View Controls
  2083. sentinel.on(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS, element => {
  2084. doExpandedViewControls(element);
  2085. });
  2086.  
  2087. // Images
  2088. sentinel.on([
  2089. CONFIG.SELECTORS_IMAGE,
  2090. CONFIG.SELECTORS_EXPANDED_VIEW_ARTIST_BOTTOM_IMAGE
  2091. ], element => {
  2092. doImage(element, { isHome, isOwnProfile });
  2093. });
  2094.  
  2095. // Multi View Entries
  2096. sentinel.on(CONFIG.SELECTORS_MULTI_VIEW, element => {
  2097. doMultiView(element, { isHome });
  2098. });
  2099.  
  2100. // Toggle Bookmarked Sections
  2101. for (const sectionConfig of CONFIG.SECTIONS_TOGGLE_BOOKMARKED) {
  2102. let configValid = true;
  2103. for (const key of ['selectorParent', 'selectorHeader', 'selectorImagesContainer']) {
  2104. if (!sectionConfig[key] || !isSelectorValid(sectionConfig[key])) {
  2105. console.error(`SECTIONS_TOGGLE_BOOKMARKED contains invalid ${key} =`, sectionConfig[key]);
  2106. configValid = false;
  2107. break;
  2108. }
  2109. }
  2110.  
  2111. if (!configValid) {
  2112. continue;
  2113. }
  2114.  
  2115. sentinel.on(sectionConfig.selectorParent, element => {
  2116. doToggleBookmarkedSection(element, sectionConfig);
  2117. });
  2118.  
  2119. const formattedSelector = formatChildSelector(
  2120. sectionConfig.selectorParent,
  2121. sectionConfig.selectorImagesContainer
  2122. );
  2123.  
  2124. sentinel.on(formattedSelector, element => {
  2125. const parent = element.closest(sectionConfig.selectorParent);
  2126. if (parent && !element.dataset.pixiv_utils_toggle_bookmarked_section) {
  2127. doToggleBookmarkedSection(parent, sectionConfig);
  2128. }
  2129. });
  2130. }
  2131.  
  2132. // Tag Buttons
  2133. if (PIXIV_BLOCKED_TAGS_VALIDATED && CONFIG.PIXIV_REMOVE_BLOCKED) {
  2134. // Only process if blocked images are also removed instead of just muted.
  2135. sentinel.on(CONFIG.SELECTORS_TAG_BUTTON, element => {
  2136. doTagButton(element);
  2137. });
  2138. }
  2139.  
  2140. // Dates
  2141. if (CONFIG.DATE_CONVERSION) {
  2142. sentinel.on([
  2143. `:has(> :is(${CONFIG.SELECTORS_DATE})):not(:has(> [data-pixiv_utils_duplicate_date]))`,
  2144. `.reupload-info:has(${SELECTORS_DATE_ORIGINAL})`
  2145. ], element => {
  2146. const date = element.querySelector(SELECTORS_DATE_ORIGINAL);
  2147. if (date) {
  2148. convertDate(date);
  2149. }
  2150. });
  2151. }
  2152.  
  2153. // UTags Integration
  2154. if (CONFIG.UTAGS_INTEGRATION) {
  2155. sentinel.on(SELECTORS_UTAGS, element => {
  2156. doUtags(element, { isOwnProfile });
  2157. });
  2158. }
  2159.  
  2160. if (CONFIG.MODE !== 'PROD') {
  2161. setInterval(() => {
  2162. const intervals = Object.keys(waitForIntervals);
  2163. if (intervals.length > 0) {
  2164. // Debug first pending interval.
  2165. logDebug('waitFor', waitForIntervals[intervals[0]].element);
  2166. }
  2167. }, 2500);
  2168. }
  2169. });
  2170.  
  2171. /** KEYBINDS **/
  2172.  
  2173. if (CONFIG.ENABLE_KEYBINDS) {
  2174. const selectors = {
  2175. editBookmark: CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS
  2176. .split(', ').map(s => `${s} .pixiv_utils_edit_bookmark`).join(', ')
  2177. };
  2178.  
  2179. const onCooldown = {};
  2180.  
  2181. const processKeyEvent = (id, element) => {
  2182. if (!element) {
  2183. return false;
  2184. }
  2185.  
  2186. if (onCooldown[id]) {
  2187. log(`"${id}" keybind still on cooldown.`);
  2188. return false;
  2189. }
  2190.  
  2191. onCooldown[id] = true;
  2192. element.click();
  2193. setTimeout(() => { onCooldown[id] = false; }, 1000);
  2194. };
  2195.  
  2196. document.addEventListener('keydown', event => {
  2197. event = event || window.event;
  2198.  
  2199. // Ignore keybinds when currently focused to an input/textarea/editable element.
  2200. if (document.activeElement &&
  2201. (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable)) {
  2202. return;
  2203. }
  2204.  
  2205. // "Shift+B" for Edit Bookmark.
  2206. // Pixiv has built-in keybind "B" for just bookmarking.
  2207. if (event.keyCode === 66) {
  2208. if (event.ctrlKey || event.altKey) {
  2209. // Ignore "Ctrl+B" or "Alt+B".
  2210. return;
  2211. }
  2212. if (event.shiftKey) {
  2213. event.stopPropagation();
  2214. const element = document.querySelector(selectors.editBookmark);
  2215. return processKeyEvent('bookmarkEdit', element);
  2216. }
  2217. }
  2218. });
  2219.  
  2220. logDebug('Listening for keybinds.');
  2221. } else {
  2222. logDebug('Keybinds disabled.');
  2223. }
  2224. })();