Bobby's Pixiv Utils

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

// ==UserScript==
// @name        Bobby's Pixiv Utils
// @namespace   https://github.com/BobbyWibowo
// @match       *://www.pixiv.net/*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant       GM_addStyle
// @grant       GM_getValue
// @run-at      document-end
// @version     1.2.0
// @author      Bobby Wibowo
// @license     MIT
// @description 7/2/2024, 8:37:14 PM
// @noframes
// ==/UserScript==

(function () {
  'use strict'

  /** CONFIG **/

  const log = (message, ...args) => {
    console.log(`[Bobby's Pixiv Utils]: ${message}`, ...args)
  }

  const logError = (message, ...args) => {
    console.error(`[Bobby's Pixiv Utils]: ${message}`, ...args)
  }

  const ENV = {
    MODE: GM_getValue('MODE', 'PROD'),

    SELECTORS_IMAGE: GM_getValue('SELECTORS_IMAGE'),
    SELECTORS_IMAGE_CONTROLS: GM_getValue('SELECTORS_IMAGE_CONTROLS'),
    SELECTORS_EXPANDED_VIEW_CONTROLS: GM_getValue('SELECTORS_EXPANDED_VIEW_CONTROLS'),
    SELECTORS_MULTI_VIEW_CONTROLS: GM_getValue('SELECTORS_MULTI_VIEW_CONTROLS'),

    DATE_CONVERSION: GM_getValue('DATE_CONVERSION', true),
    SELECTORS_DATE: GM_getValue('SELECTORS_DATE'),

    ENABLE_KEYBINDS: GM_getValue('ENABLE_KEYBINDS', true)
  }

  const SELECTORS_IMAGE = '.jtUPOE > li, .gmoaNn > li, .hjtPnz > li, .boBnlf > div, .hkzusx > div, .ranking-item, .iXWLAI > li, .hdRpMN > li, .cgtmvA li'
    + (ENV.SELECTORS_IMAGE ? `, ${ENV.SELECTORS_IMAGE}` : '');
  const SELECTORS_IMAGE_CONTROLS = '.iHfghO, .cGfNRT, ._layout-thumbnail, .dVtEKY, .kmCXcW'
    + (ENV.SELECTORS_IMAGE_CONTROLS ? `, ${ENV.SELECTORS_IMAGE_CONTROLS}` : '');
  const SELECTORS_EXPANDED_VIEW_CONTROLS = '.gMEAWM'
    + (ENV.SELECTORS_EXPANDED_VIEW_CONTROLS ? `, ${ENV.SELECTORS_EXPANDED_VIEW_CONTROLS}` : '');
  const SELECTORS_MULTI_VIEW_CONTROLS = '[data-ga4-label="work_content"] > .w-full:last-child > .flex:first-child > .flex-row:first-child'
    + (ENV.SELECTORS_MULTI_VIEW_CONTROLS ? `, ${ENV.SELECTORS_MULTI_VIEW_CONTROLS}`: '');

  const DATE_CONVERSION = ENV.DATE_CONVERSION;
  const SELECTORS_DATE = '.dqHJfP'
    + (ENV.SELECTORS_DATE ? `, ${ENV.SELECTORS_DATE}` : '');

  if (ENV.MODE !== 'PROD') {
    log(`ENV: ${ENV.MODE}`);
    log(`SELECTORS_IMAGE: ${SELECTORS_IMAGE}`);
    log(`SELECTORS_IMAGE_CONTROLS: ${SELECTORS_IMAGE_CONTROLS}`);
    log(`SELECTORS_EXPANDED_VIEW_CONTROLS: ${SELECTORS_EXPANDED_VIEW_CONTROLS}`);
    log(`SELECTORS_MULTI_VIEW_CONTROLS: ${SELECTORS_MULTI_VIEW_CONTROLS}`);
    log(`DATE_CONVERSION: ${DATE_CONVERSION}`);
    log(`SELECTORS_DATE: ${SELECTORS_DATE}`);
  }

  log(`Date conversion ${ENV.DATE_CONVERSION ? 'enabled': 'disabled'}.`);

  /** STYLES **/

  const mainStyle = /*css*/`
  .flex:has(+.pu_edit_bookmark_container) {
    flex-grow: 1;
  }

  .pu_edit_bookmark {
    color: rgb(245, 245, 245);
    background: rgba(0, 0, 0, 0.32);
    display: block;
    box-sizing: border-box;
    padding: 0px 6px;
    margin-top: 7px;
    margin-right: 2px;
    border-radius: 10px;
    font-weight: bold;
    font-size: 10px;
    line-height: 20px;
    height: 20px;
  }

  ${SELECTORS_EXPANDED_VIEW_CONTROLS.split(', ').map(s => `${s} .pu_edit_bookmark`).join(', ')},
  ${SELECTORS_MULTI_VIEW_CONTROLS.split(', ').map(s => `${s} .pu_edit_bookmark`).join(', ')} {
    font-size: 12px;
    height: 24px;
    line-height: 24px;
    margin-top: 5px;
    margin-right: 7px;
  }

  ._layout-thumbnail .pu_edit_bookmark {
    position: absolute;
    right: calc(50% - 71px);
    bottom: 4px;
    z-index: 2;
  }

  ${SELECTORS_IMAGE_CONTROLS} {
    display: flex;
    justify-content: flex-end;
  }
  `;

  const globalDateStyle = /*css*/`
  .dqHJfP {
    font-size: 14px;
    font-weight: bold;
    color: rgb(214, 214, 214);
  }
  `;

  const addPageDateStyle = /*css*/`
  .bookmark-detail-unit .meta {
    display: block;
    font-size: 16px;
    font-weight: bold;
    color: inherit;
    margin-left: 0;
    margin-top: 10px;
  }
  `;

  /** UTILS **/

  const convertDate = elem => {
    const date = new Date(elem.getAttribute('datetime') || elem.innerText);
    if (!date) {
      return false;
    }

    const timestamp = String(date.getTime());
    if (elem.dataset.oldTimestamp && elem.dataset.oldTimestamp === timestamp) {
      return false;
    }

    elem.dataset.oldTimestamp = timestamp;
    elem.innerText = date.toLocaleString("en-GB", {
      hour12: true,
      year: 'numeric',
      month: 'long',
      day: 'numeric',
      hour: '2-digit',
      minute: '2-digit'
    });
    return true;
  }

  /** INTERCEPT SOME PAGES **/

  const path = location.pathname;

  if (path.startsWith('/bookmark_add.php')) {
    if (DATE_CONVERSION) {
      GM_addStyle(addPageDateStyle);

      const date = document.querySelector('.bookmark-detail-unit .meta');
      convertDate(date);
    }

    log(`/bookmark_add.php path detected, disabled mutation observer.`);
    return;
  }

  /** MAIN **/

  GM_addStyle(mainStyle);

  class FunctionQueue {
    constructor() {
      this.queue = [];
      this.running = false;
    }

    async go() {
      if (this.queue.length) {
        this.running = true;
        const _func = this.queue.shift();
        await _func[0](..._func[1]);
        this.go();
      } else {
        this.running = false;
      }
    }

    add (func, ...args) {
      this.queue.push([func, [...args]]);

      if (!this.running) {
        this.go();
      }
    }
  }

  const observerFactory = function (option) {
    let options;
    if (typeof option === 'function') {
      options = {
        callback: option,
        node: document.getElementsByTagName('body')[0],
        option: { childList: true, subtree: true }
      };
    } else {
      options = $.extend({
        callback: () => {},
        node: document.getElementsByTagName('body')[0],
        option: { childList: true, subtree: true }
      }, option);
    }
    const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

    const observer = new MutationObserver((mutations, observer) => {
      options.callback.call(this, mutations, observer);
    });

    observer.observe(options.node, options.option);
    return observer;
  };

  const editBookmarkButton = (id, isNovel = false) => {
    const buttonContainer = document.createElement('div');
    buttonContainer.className = 'pu_edit_bookmark_container';

    const button = document.createElement('a');
    button.className = 'pu_edit_bookmark';
    button.innerText = 'Edit bookmark';

    if (isNovel) {
      button.href = `https://www.pixiv.net/novel/bookmark_add.php?id=${id}`;
    } else {
      button.href = `https://www.pixiv.net/bookmark_add.php?type=illust&illust_id=${id}`;
    }

    buttonContainer.appendChild(button);
    return buttonContainer;
  }

  const isElementValid = element => {
    if (!element || !element.isConnected) {
      return false;
    }

    // Skip if already modified
    if (element.querySelector('.pu_edit_bookmark')) {
      return false;
    }

    // Skip if hidden (e.g., due to page change transition)
    if (!element.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true })) {
      return false;
    }

    return true;
  }

  const findItemId = element => {
    let id = null;
    let isNovel = false;

    let link = element.querySelector('a[href*="artworks/"]');
    if (link) {
      const match = link.href.match(/artworks\/(\d+)/);
      id = match ? match[1] : null;
    } else {
      link = element.querySelector('a[href*="novel/show.php?id="]');
      if (link) {
        const match = link.href.match(/novel\/show\.php\?id=(\d+)/);
        id = match ? match[1] : null;
        isNovel = true;
      }
    }

    return { id, isNovel };
  }

  const doImage = element => {
    if (!isElementValid(element)) {
      return false;
    }

    const imageControls = element.querySelector(SELECTORS_IMAGE_CONTROLS);
    if (!imageControls) {
      return false;
    }

    const { id, isNovel } = findItemId(element);
    if (id !== null) {
      imageControls.insertBefore(editBookmarkButton(id, isNovel), imageControls.firstChild);
      return true;
    }

    return false;
  }

  const doExpandedViewControls = element => {
    if (!isElementValid(element)) {
      return false;
    }

    let id = null;
    let isNovel = false;

    let match = window.location.href.match(/artworks\/(\d+)/);
    if (match && match[1]) {
      id = match[1];
    } else {
      match = window.location.href.match(/novel\/show\.php\?id=(\d+)/);
      if (match && match[1]) {
        id = match[1];
        isNovel = true;
      }
    }

    if (id !== null) {
      element.appendChild(editBookmarkButton(id, isNovel));
      return true;
    }

    return false;
  }

  const doViewControls = element => {
    if (!isElementValid(element)) {
      return false;
    }

    const { id, isNovel } = findItemId(element);
    if (id !== null) {
      element.insertBefore(editBookmarkButton(id, isNovel), element.lastChild);
      return true;
    }

    return false;
  }

  const triggerQueue = new FunctionQueue();

  let globalDateStyleAdded = false;

  observerFactory((...args) => {
    triggerQueue.add((mutations, observer) => {
      for (let i = 0, len = mutations.length; i < len; i++) {
        const mutation = mutations[i];

        // Whether to change nodes
        if (mutation.type !== 'childList') {
          continue;
        }

        // Always attempt to query from its parent, to allow the element itself to match the queries
        const target = mutation.target.parentElement || mutation.target;

        // Images
        let _image = 0;

        const images = target.querySelectorAll(SELECTORS_IMAGE);
        for (const image of images) {
          if (doImage(image)) {
            _image++;
          }
        }

        if (_image > 0) {
          log(`Processed ${_image} image(s).`);
        }

        // Expanded View Controls
        const expandedViewControls = target.querySelector(SELECTORS_EXPANDED_VIEW_CONTROLS);
        if (expandedViewControls && doExpandedViewControls(expandedViewControls)) {
          log(`Processed expanded view controls.`);
        }

        // Multi View Controls
        let _multiViewControls = 0;

        const multiViewControls = target.querySelectorAll(SELECTORS_MULTI_VIEW_CONTROLS);
        for (const artworkControls of multiViewControls) {
          if (doViewControls(artworkControls)) {
            _multiViewControls++;
          }
        }

        if (_multiViewControls > 0) {
            log(`Processed ${_multiViewControls} multi view control(s).`);
        }

        // Dates
        if (DATE_CONVERSION) {
          let _date = 0;

          const dates = target.querySelectorAll(SELECTORS_DATE);
          for (const date of dates) {
            if (convertDate(date)) {
              _date++;
            }
          }

          if (_date > 0) {
            if (!globalDateStyleAdded) {
              GM_addStyle(globalDateStyle);
              globalDateStyleAdded = true;
            }
            log(`Processed ${_date} date element(s).`);
          }
        }
      }
    }, ...args);
  });

  /** KEYBINDS **/

  if (ENV.ENABLE_KEYBINDS) {
    let onCooldown = {
      bookmarkAdd: false,
      bookmarkEdit: false
    };

    const processKeyEvent = (id, element) => {
      if (!element) {
        return false;
      }

      if (onCooldown[id]) {
        log(`"${id}" keybind still on cooldown.`);
        return false;
      }

      onCooldown[id] = true;
      element.click();
      setTimeout(() => { onCooldown[id] = false }, 1000);
    }

    document.addEventListener('keydown', event => {
      event = event || window.event;

      // Ignore keybinds when currently focused to an input/textarea/editable element
      if (document.activeElement && (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) || document.activeElement.isContentEditable)) {
        return;
      }

      // Shift+B for Edit Bookmark
      // Pixiv has built-in keybind B for just bookmarking
      if (event.keyCode === 66) {
        if (event.ctrlKey || event.altKey) {
          // Ignore Ctrl+B or Alt+B
          return;
        }
        if (event.shiftKey) {
          event.stopPropagation();
          const element = document.querySelector('.gpoeGt .pu_edit_bookmark');
          return processKeyEvent('bookmarkEdit', element);
        }
      }
    });

    log('Listening for keybinds.');
  } else {
    log('Keybinds disabled.');
  }

})()