// ==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.');
}
})()