// ==UserScript==
// @name Bobby's Pixiv Utils
// @namespace https://github.com/BobbyWibowo
// @match *://www.pixiv.net/*
// @exclude-match *://www.pixiv.net/setting*
// @exclude-match *://www.pixiv.net/manage*
// @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant GM_addStyle
// @grant GM_getValue
// @run-at document-end
// @version 1.3.7
// @author Bobby Wibowo
// @license MIT
// @description 7/2/2024, 8:37:14 PM
// @noframes
// ==/UserScript==
(function () {
'use strict'
/** CONFIG **/
const log = (message, ...args) => {
return console.log(`[${Date.now()}]: ${message}`, ...args);
}
const logError = (message, ...args) => {
return console.error(`[${Date.now()}]: ${message}`, ...args);
}
const ENV = {
MODE: GM_getValue('MODE'),
TEXT_EDIT_BOOKMARK: GM_getValue('EDIT_BOOKMARK_TEXT', 'Edit bookmark'),
// The following options have preset values. Scroll further to find them.
// Specifiying custom values will extend instead of replacing them.
SELECTORS_IMAGE: GM_getValue('SELECTORS_IMAGE'),
SELECTORS_IMAGE_TITLE: GM_getValue('SELECTORS_IMAGE_TITLE'),
SELECTORS_IMAGE_ARTIST_AVATAR: GM_getValue('SELECTORS_IMAGE_ARTIST_AVATAR'),
SELECTORS_IMAGE_ARTIST_NAME: GM_getValue('SELECTORS_IMAGE_ARTIST_NAME'),
SELECTORS_IMAGE_CONTROLS: GM_getValue('SELECTORS_IMAGE_CONTROLS'),
SELECTORS_EXPANDED_VIEW_CONTROLS: GM_getValue('SELECTORS_EXPANDED_VIEW_CONTROLS'),
SELECTORS_MULTI_VIEW: GM_getValue('SELECTORS_MULTI_VIEW'),
SELECTORS_MULTI_VIEW_CONTROLS: GM_getValue('SELECTORS_MULTI_VIEW_CONTROLS'),
DATE_CONVERSION: GM_getValue('DATE_CONVERSION', true),
DATE_CONVERSION_LOCALES: GM_getValue('DATE_CONVERSION_LOCALES', 'en-GB'),
DATE_CONVERSION_OPTIONS: GM_getValue('DATE_CONVERSION_OPTIONS', {
hour12: true,
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}),
// This has a preset value. Specifiying a custom value will extend instead of replacing it.
SELECTORS_DATE: GM_getValue('SELECTORS_DATE'),
REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME: GM_getValue('REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME', false),
ENABLE_KEYBINDS: GM_getValue('ENABLE_KEYBINDS', true),
UTAGS_INTEGRATION: GM_getValue('UTAGS_INTEGRATION', true),
// Presets "block" and "hide" tags. Specifying custom values will extend instead of replacing them.
UTAGS_BLOCKED_TAGS: GM_getValue('UTAGS_BLOCKED_TAGS'),
// Instead of merely hiding them à la Pixiv's built-in tags mute.
UTAGS_REMOVE_BLOCKED: GM_getValue('UTAGS_REMOVE_BLOCKED', false)
}
/* DOCUMENTATION
* -------------
* 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
*
* Home's recommended works grid:
* Image: .fhUcsb > li
* Title: [data-ga4-label="title_link"]
* Artist avatar: [data-ga4-label="user_icon_link"]
* Artist name: [data-ga4-label="user_name_link"]
* Controls: .kmCXcW
*
* Home's latest works grid:
* Image: li[data-ga4-label="thumbnail"]
*
* Discovery page's grid:
* Title: .gtm-illust-recommend-title
* Controls: .dVtEKY
*
* Artist page's grid:
* Image: .jtUPOE > li
* Controls: .iHfghO
*
* Expanded view's artist works bottom row:
* Image: .boBnlf > div
*
* Expanded view's related works grid:
* Artist avatar: .eMfHJB
* Artist name: .gtm-illust-recommend-user-name
*
* Artist page's featured works:
* Image: .gmoaNn > li
* Controls: .cGfNRT
*
* Bookmarks page's grid:
* Title: .bOcolJ
* Artist name: .IYOBi
*
* Tag page's grid:
* Image: .hdRpMN > li
*
* Rankings page:
* Image: .ranking-item
* Title: .title
* Artist avatar: ._user-icon
* Artist name: .user-name
* Controls: ._layout-thumbnail
*/
const CONFIG = {
MODE: 'PROD',
SELECTORS_IMAGE: '.fhUcsb > li, li[data-ga4-label="thumbnail"], .jtUPOE > li, .boBnlf > div, .gmoaNn > li, .hdRpMN > li, .ranking-item', // .hjtPnz > li, .hkzusx > div, .iXWLAI > li, .cgtmvA li
SELECTORS_IMAGE_TITLE: '[data-ga4-label="title_link"], .gtm-illust-recommend-title, .bOcolJ, .title', // .hQOtRd
SELECTORS_IMAGE_ARTIST_AVATAR: '[data-ga4-label="user_icon_link"], .eMfHJB, ._user-icon', // .bwTmGA
SELECTORS_IMAGE_ARTIST_NAME: '[data-ga4-label="user_name_link"], .gtm-illust-recommend-user-name, .IYOBi, .user-name', // .jNkIXf
SELECTORS_IMAGE_CONTROLS: '.kmCXcW, .dVtEKY, .iHfghO, .cGfNRT, ._layout-thumbnail',
SELECTORS_EXPANDED_VIEW_CONTROLS: '.gMEAWM',
SELECTORS_MULTI_VIEW: '[data-ga4-label="work_content"]',
SELECTORS_MULTI_VIEW_CONTROLS: '& > .w-full:last-child > .flex:first-child > .flex-row:first-child',
SELECTORS_DATE: '.dqHJfP',
UTAGS_BLOCKED_TAGS: ['block', 'hide']
}
// Extend preset values with user-defined custom values if applicable.
for (const key of Object.keys(ENV)) {
if (key.startsWith('SELECTORS_')) {
if (ENV[key]) {
CONFIG[key] += `, ${ENV[key]}`;
}
} else if (Array.isArray(CONFIG[key])) {
if (ENV[key]) {
const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim())
CONFIG[key].push(...customValues);
}
} else if (ENV[key] !== undefined) {
CONFIG[key] = ENV[key];
}
}
let logKeys = Object.keys(CONFIG);
if (CONFIG.MODE === 'PROD') {
// In PROD mode, only print some.
logKeys = ['DATE_CONVERSION', 'REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME', 'ENABLE_KEYBINDS', 'UTAGS_INTEGRATION'];
}
for (const key of logKeys) {
log(`${key}: ${CONFIG[key]}`);
}
/** GLOBAL UTILS **/
const addPageDateStyle = /*css*/`
.bookmark-detail-unit .meta {
display: block;
font-size: 16px;
font-weight: bold;
color: inherit;
margin-left: 0;
margin-top: 10px;
}
`;
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(CONFIG.DATE_CONVERSION_LOCALES, CONFIG.DATE_CONVERSION_OPTIONS);
return true;
}
/** INTERCEPT EARLY FOR CERTAIN ROUTES **/
const path = location.pathname;
// Codes beyond this block will not execute for this route (mainly for efficiency).
if (path.startsWith('/bookmark_add.php')) {
if (CONFIG.DATE_CONVERSION) {
GM_addStyle(addPageDateStyle);
const date = document.querySelector('.bookmark-detail-unit .meta');
convertDate(date);
}
log(`/bookmark_add.php path detected. Excluding date conversion, script has terminated early.`);
return;
}
/** MAIN STYLES **/
// To properly handle "&" CSS keyword, in context of also having to support user-defined custom values.
// Somewhat overkill, but I'm out of ideas.
const _formatSelectorsMultiViewControls = () => {
const multiViews = CONFIG.SELECTORS_MULTI_VIEW.split(', ');
const multiViewsControls = CONFIG.SELECTORS_MULTI_VIEW_CONTROLS.split(', ');
const formatted = [];
for (const x of multiViews) {
for (const y of multiViewsControls) {
let z = y;
if (y.startsWith('&')) {
z = y.substring(1)
}
formatted.push(`${x} ${z.trim()}`);
}
}
return formatted;
}
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;
}
${CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS.split(', ').map(s => `${s} .pu_edit_bookmark`).join(', ')},
${_formatSelectorsMultiViewControls().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;
}
.ranking-item.muted .pu_edit_bookmark {
display: none;
}
${CONFIG.SELECTORS_IMAGE_CONTROLS} {
display: flex;
justify-content: flex-end;
}
`;
const mainDateStyle = /*css*/`
.dqHJfP {
font-size: 14px !important;
font-weight: bold;
color: rgb(214, 214, 214) !important;
}
`;
/** UTAGS INTEGRATION INIT **/
const mainUtagsStyle = /*css*/`
.pu_blocked_image {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border-radius: 4px;
color: rgb(92, 92, 92);
background-color: rgb(0, 0, 0);
}
.pu_blocked_image svg {
fill: currentcolor;
}
.pu_image_is_blocked .earAVC {
width: 184px;
height: 184px;
}
.ranking-item.pu_image_is_blocked .work {
width: 150px;
height: 150px;
}
${CONFIG.SELECTORS_IMAGE_TITLE.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
color: rgb(133, 133, 133) !important;
}
${CONFIG.SELECTORS_IMAGE_ARTIST_AVATAR.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
display: none;
}
${CONFIG.SELECTORS_IMAGE_CONTROLS.split(', ').map(s => `.pu_image_is_blocked ${s}`).join(', ')} {
display: none;
}
`;
const SELECTORS_UTAGS = CONFIG.UTAGS_BLOCKED_TAGS.map(s => `[data-utags_tag="${s}"]`).join(', ');
log(`SELECTORS_UTAGS: ${SELECTORS_UTAGS}`);
const BLOCKED_IMAGE_HTML = `
<div radius="4" class="pu_blocked_image">
<svg viewBox="0 0 24 24" style="width: 48px; height: 48px;">
<path d="M5.26763775,4 L9.38623853,11.4134814 L5,14.3684211 L5,18 L13.0454155,18 L14.1565266,20 L5,20
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
C20.1045695,4 21,4.8954305 21,6 L21,18 C21,19.1045695 20.1045695,20 19,20 L18.7323623,20 L17.6212511,18
L19,18 L19,13 L16,15 L15.9278695,14.951913 L9.84347336,4 Z M16,7 C14.8954305,7 14,7.8954305 14,9
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
M7.38851434,1.64019979 L18.3598002,21.3885143 L16.6114857,22.3598002 L5.64019979,2.61148566
L7.38851434,1.64019979 Z"></path>
</svg>
</div>
`;
/** MAIN **/
GM_addStyle(mainStyle);
if (CONFIG.DATE_CONVERSION) {
GM_addStyle(mainDateStyle);
}
if (CONFIG.UTAGS_INTEGRATION) {
GM_addStyle(mainUtagsStyle);
}
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 = CONFIG.TEXT_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 findLink = element => {
return element.querySelector('a[href*="artworks/"]');
}
const findNovelLink = element => {
return element.querySelector('a[href*="novel/show.php?id="]');
}
const findItemId = element => {
let id = null;
let isNovel = false;
let link = findLink(element);
if (link) {
const match = link.href.match(/artworks\/(\d+)/);
id = match ? match[1] : null;
} else {
link = findNovelLink(element);
if (link) {
const match = link.href.match(/novel\/show\.php\?id=(\d+)/);
id = match ? match[1] : null;
isNovel = true;
}
}
return { id, isNovel };
}
const isElementVisible = element => {
if (!element || !element.isConnected) {
return false;
}
return element.checkVisibility();
}
const doImage = (element, isHome = false) => {
if (!isElementVisible(element)) {
return false;
}
if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
if (findNovelLink(element)) {
element.style.display = 'none';
return true;
}
}
// Skip if edit bookmark button already inserted.
if (element.querySelector('.pu_edit_bookmark')) {
return false;
}
const imageControls = element.querySelector(CONFIG.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 doMultiView = (element, isHome = false) => {
if (!isElementVisible(element)) {
return false;
}
if (CONFIG.REMOVE_NOVEL_RECOMMENDATIONS_FROM_HOME && isHome) {
if (findNovelLink(element)) {
element.parentNode.style.display = 'none';
return true;
}
}
// Skip if edit bookmark button already inserted.
if (element.querySelector('.pu_edit_bookmark')) {
return false;
}
const multiViewControls = element.querySelector(CONFIG.SELECTORS_MULTI_VIEW_CONTROLS);
if (!multiViewControls) {
return false;
}
const { id, isNovel } = findItemId(element);
if (id !== null) {
multiViewControls.insertBefore(editBookmarkButton(id, isNovel), multiViewControls.lastChild);
return true;
}
return false;
}
const doExpandedViewControls = element => {
if (!isElementVisible(element)) {
return false;
}
// Skip if edit bookmark button already inserted.
if (element.querySelector('.pu_edit_bookmark')) {
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 doUtagsImage = element => {
if (!isElementVisible(element)) {
return false;
}
const image = element.closest(CONFIG.SELECTORS_IMAGE);
if (image) {
const imageLink = image.querySelector('a[href*="artworks/"], a[href*="novel/"]');
if (!imageLink) {
return false;
}
if (CONFIG.UTAGS_REMOVE_BLOCKED) {
image.style.display = 'none';
return true;
}
// Skip if already blocked.
if (image.classList.contains('pu_image_is_blocked')) {
return false;
}
image.classList.add('pu_image_is_blocked');
imageLink.innerHTML = BLOCKED_IMAGE_HTML;
const imageTitle = image.querySelector(CONFIG.SELECTORS_IMAGE_TITLE);
if (imageTitle) {
if (element.dataset.utags_tag === "hide") {
imageTitle.innerText = 'Hidden';
} else {
// block tag and custom tags
imageTitle.innerText = 'Blocked';
}
}
const artistLink = image.querySelector(CONFIG.SELECTORS_IMAGE_ARTIST_NAME);
if (artistLink) {
artistLink.innerText = '';
}
return true;
}
const multiView = element.closest('[data-ga4-label="work_content"]');
if (multiView) {
// For multi view artwork, just hide the whole entry instead.
multiView.parentNode.style.display = 'none';
return true;
}
const artistHeader = element.closest('.ggHNyV');
if (artistHeader) {
const followButton = artistHeader.querySelector('.irfecv:not([disabled])');
if (followButton) {
// This does not disable Pixiv's built-in "F" keybind.
followButton.disabled = true;
return true;
}
}
return false;
}
const triggerQueue = new FunctionQueue();
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;
const isHome = Boolean(target.closest('[data-ga4-label="page_root"]'));
// Expanded View Controls
const expandedViewControls = target.querySelector(CONFIG.SELECTORS_EXPANDED_VIEW_CONTROLS);
if (expandedViewControls && doExpandedViewControls(expandedViewControls)) {
log(`Processed expanded view controls.`);
}
// Images
let _image = 0;
const images = target.querySelectorAll(CONFIG.SELECTORS_IMAGE);
for (const image of images) {
if (doImage(image, isHome)) {
_image++;
}
}
if (_image > 0) {
log(`Processed ${_image} image(s).`);
}
// Multi Views
let _multiView = 0;
const multiViews = target.querySelectorAll(CONFIG.SELECTORS_MULTI_VIEW);
for (const multiView of multiViews) {
if (doMultiView(multiView, isHome)) {
_multiView++;
}
}
if (_multiView > 0) {
log(`Processed ${_multiView} multi view(s).`);
}
// Dates
if (CONFIG.DATE_CONVERSION) {
let _date = 0;
const dates = target.querySelectorAll(CONFIG.SELECTORS_DATE);
for (const date of dates) {
if (convertDate(date)) {
_date++;
}
}
if (_date > 0) {
log(`Processed ${_date} date element(s).`);
}
}
// UTags integration
if (CONFIG.UTAGS_INTEGRATION) {
let _utag = 0;
const utags = target.querySelectorAll(SELECTORS_UTAGS);
for (const utag of utags) {
if (doUtagsImage(utag)) {
_utag++;
}
}
if (_utag > 0) {
log(`Processed ${_utag} UTag(s).`);
}
}
}
}, ...args);
});
/** KEYBINDS **/
if (CONFIG.ENABLE_KEYBINDS) {
let onCooldown = {};
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.');
}
})()