// ==UserScript==
// @name e621 Enhanced Navigation & Shortcuts
// @namespace https://github.com/Webscratcher/UserScripts
// @version 1.3.6
// @license GPL3
// @description Enhances e621 with keyboard shortcuts for voting, favoriting, and navigation. Features auto-skip for blacklisted content, improved pool navigation, and smart behavior that disables shortcuts when typing or using video player.
// @author Webscratcher
// @match https://e621.net/posts*
// @icon https://www.google.com/s2/favicons?sz=64&domain=e621.net
// @grant unsafeWindow
// ==/UserScript==
// Configuration
const CONFIG = {
shortcuts: {
UPVOTE: { key: "KeyU", requiresShift: false },
DOWNVOTE: { key: "KeyU", requiresShift: true },
LIKE_AND_FAVORITE: { key: "KeyL", requiresShift: false },
FAVORITE_AND_CONTINUE: { key: "KeyL", requiresShift: true },
TOGGLE_FAVORITES: { key: "KeyI", requiresShift: false },
TOGGLE_BLACKLIST: { key: "KeyB", requiresShift: false },
NEXT_POST: { key: "ArrowRight", requiresShift: false },
PREV_POST: { key: "ArrowLeft", requiresShift: false },
TOGGLE_AUTOSKIP: { key: "KeyP", requiresShift: false },
},
selectors: {
voteButtons: "div.image-vote-buttons",
upvoteButton: "div.image-vote-buttons > a.post-vote-up-link > span",
downvoteButton: "div.image-vote-buttons > a.post-vote-down-link > span",
favoriteButton: "button#add-fav-button",
unvaforiteButton: "button#remove-fav-button",
imageViewer: "img#image",
navigation: {
next: "div#nav-links-top > div > ul > li > a.nav-link.next",
prev: "div#nav-links-top > div > ul > li > a.nav-link.prev",
searchField: "textarea#tags",
},
blacklist: {
box: "section#blacklist-box > a",
collapse: "#blacklist-collapse",
blacklistedContent: "#image-container.blacklisted",
},
pool: {
nav: "#nav-links-top",
link: "div.pool-nav > ul > li > span > a",
},
},
delays: {
defaultSleep: 2,
voteDelay: 1,
autoSkipDelay: 3, // 3 seconds countdown before skipping
},
};
// Utility functions
class Utils {
static async sleep(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
static isElementVisible(element) {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return !(
style.display === "none" ||
style.visibility === "hidden" ||
parseFloat(style.opacity) < 0.01 ||
(rect.width === 0 && rect.height === 0)
);
}
static isInteractiveElementFocused() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const interactiveElements = ["INPUT", "TEXTAREA", "SELECT", "VIDEO"];
return (
(interactiveElements.includes(activeElement.tagName) ||
activeElement.isContentEditable) &&
Utils.isElementVisible(activeElement)
);
}
static async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error("Failed to copy to clipboard:", error);
return false;
}
}
}
// DOM Manager for caching and managing DOM elements
class DOMManager {
constructor() {
this.elements = new Map();
this.initializeElements();
}
initializeElements() {
Object.entries(CONFIG.selectors).forEach(([key, selector]) => {
if (typeof selector === "string") {
this.elements.set(key, document.querySelector(selector));
}
});
}
getElement(key) {
if (!this.elements.has(key) && key.split(".").length == 2) {
let selector = CONFIG.selectors[key.split(".")[0]];
const secondKey = key.split(".")[1];
if (selector && selector[secondKey] !== null) {
selector = selector[secondKey];
this.elements.set(key, document.querySelector(selector));
}
}
return this.elements.get(key);
}
getMetaData(key) {
const metaTag = document.querySelector(`meta[name="${key}"]`);
if (!metaTag) return null;
return metaTag.getAttribute("content");
}
}
// Vote Manager
class VoteManager {
constructor(domManager) {
this.dom = domManager;
}
isAlreadyUpvoted() {
const upvoteButton = this.dom.getElement("upvoteButton");
return upvoteButton.classList.contains("score-positive");
}
isAlreadyDownvoted() {
const downvoteButton = this.dom.getElement("downvoteButton");
return downvoteButton.classList.contains("score-negative");
}
isAlreadyFavorited() {
const favoriteButton = this.dom.getElement("favoriteButton");
return !Utils.isElementVisible(favoriteButton);
}
async upvote() {
if (!this.isAlreadyUpvoted()) {
const upvoteButton = this.dom
.getElement("voteButtons")
?.querySelector(CONFIG.selectors.upvoteButton);
upvoteButton?.click();
await Utils.sleep(CONFIG.delays.voteDelay);
return;
}
return;
}
async downvote() {
if (!this.isAlreadyDownvoted()) {
const downvoteButton = this.dom
.getElement("voteButtons")
?.querySelector(CONFIG.selectors.downvoteButton);
downvoteButton?.click();
await Utils.sleep(CONFIG.delays.voteDelay);
return;
}
return;
}
async favorite(disableUnfavorite = false) {
if (!this.isAlreadyFavorited()) {
const favoriteButton = this.dom.getElement("favoriteButton");
favoriteButton?.click();
await Utils.sleep(CONFIG.delays.voteDelay);
return;
} else if (this.isAlreadyFavorited() && !disableUnfavorite) {
const unfavoritedButton = this.dom.getElement("unfavoriteButton");
unfavoritedButton?.click();
await Utils.sleep(CONFIG.delays.voteDelay);
return;
}
return;
}
}
// Navigation Manager
class NavigationManager {
constructor(domManager) {
this.dom = domManager;
}
goToNext() {
const nextButton = document.querySelector(CONFIG.selectors.navigation.next);
nextButton?.click();
}
goToPrev() {
const prevButton = document.querySelector(CONFIG.selectors.navigation.prev);
prevButton?.click();
}
}
class UserActions {
constructor(domManager) {
this.domManager = domManager;
this.LOCAL_STORAGE_KEY = "myCachedUsername";
this.EXPIRATION_MS = 24 * 60 * 60 * 1000; // 1 day
}
// 1. Try to read the username from the meta tag
getUsernameFromMeta() {
const metaData = this.domManager.getMetaData("current-user-name");
if (!metaData) {
return null;
} else {
return metaData;
}
}
// 2. Store username in localStorage with an expiration
storeUsername(username) {
const data = {
username,
expires: Date.now() + this.EXPIRATION_MS,
};
localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(data));
}
// 3. Retrieve username from localStorage if it's not expired
getStoredUsername() {
const raw = localStorage.getItem(this.LOCAL_STORAGE_KEY);
if (!raw) return null;
const { username, expires } = JSON.parse(raw);
if (Date.now() > expires) {
localStorage.removeItem(this.LOCAL_STORAGE_KEY);
return null;
}
return username;
}
// 4. Main function to obtain the username
getUsername() {
// First, check localStorage
let username = this.getStoredUsername();
if (username) return username;
// Then, check the meta tag
username = this.getUsernameFromMeta();
if (!username) {
// Fallback to asking the user
username = prompt("Please enter your username:")?.trim() || "";
}
if (username) this.storeUsername(username);
return username;
}
// 5. Check if "-fav:<username>" is present in the search field and remove it if found
ignoreFavoritePosts(wasShiftHeld = false, wasCtrlHeld = false) {
const searchField = document.querySelector(
CONFIG.selectors.navigation.searchField,
);
if (!searchField) return;
const username = this.getUsername(); // Assume this.getUsername() retrieves the username
if (!username) return;
const negativeFavTag = `-fav:${username}`;
const positiveFavTag = `fav:${username}`;
// Regex to match the exact negative tag: '-fav:<username>' preceded by start or space,
// and followed by space or end of string
const negativeRegex = new RegExp(`(^|\\s)${negativeFavTag}(?=$|\\s)`, "g");
// Regex to match the exact positive tag: 'fav:<username>' preceded by start or space,
// and followed by space or end of string
const positiveRegex = new RegExp(`(^|\\s)${positiveFavTag}(?=$|\\s)`, "g");
// Helper to remove all occurrences (positive or negative) from the input
const removeAllFavTags = (val) => {
return val
.replace(negativeRegex, "") // remove any negative
.replace(positiveRegex, "") // remove any positive
.replace(/\s+/g, " ") // normalize multiple spaces
.trim();
};
// Helper to remove only the negative tag
const removeNegativeFav = (val) => {
return val.replace(negativeRegex, "").replace(/\s+/g, " ").trim();
};
// Helper to remove only the positive tag
const removePositiveFav = (val) => {
return val.replace(positiveRegex, "").replace(/\s+/g, " ").trim();
};
let currentValue = searchField.value || "";
if (wasCtrlHeld) {
// 1) CTRL key held: remove ALL fav tags
currentValue = removeAllFavTags(currentValue);
} else if (wasShiftHeld) {
// 2) SHIFT key held => toggle "fav:<username>"
if (positiveRegex.test(currentValue)) {
// Already has 'fav:<username>', so remove it
currentValue = removePositiveFav(currentValue);
} else {
// Remove any '-fav:<username>' first, then prepend 'fav:<username>'
currentValue = removeNegativeFav(currentValue);
currentValue = currentValue
? `${positiveFavTag} ${currentValue}`.trim()
: positiveFavTag;
}
} else {
// 3) NO SHIFT => toggle "-fav:<username>"
if (negativeRegex.test(currentValue)) {
// Already has '-fav:<username>', so remove it
currentValue = removeNegativeFav(currentValue);
} else {
// Remove any 'fav:<username>' first, then prepend '-fav:<username>'
currentValue = removePositiveFav(currentValue);
currentValue = currentValue
? `${negativeFavTag} ${currentValue}`.trim()
: negativeFavTag;
}
}
// Update the input field
searchField.value = currentValue;
// Finally, find a button in the same parent container and click it to trigger the new search
const parent = searchField.parentElement;
if (parent) {
const button = parent.querySelector("button");
if (button) button.click();
}
}
}
// Blacklist Manager
class BlacklistManager {
constructor(domManager) {
this.dom = domManager;
}
async openBlacklist() {
const element = document.querySelector(CONFIG.selectors.blacklist.collapse);
if (element?.classList.contains("hidden")) {
element.click();
await Utils.sleep(CONFIG.delays.defaultSleep);
}
}
async toggleBlacklist() {
const toggles = ["disable", "re-enable"];
for (const toggle of toggles) {
const selector = `${CONFIG.selectors.blacklist.box}${toggle}-all-blacklists`;
const element = document.querySelector(selector);
if (element && Utils.isElementVisible(element)) {
element.click();
await Utils.sleep(CONFIG.delays.defaultSleep);
break;
}
}
}
isImageBlacklisted() {
// Check for the blacklisted image placeholder
return (
document.querySelector(CONFIG.selectors.blacklist.blacklistedContent) !==
null
);
}
}
// Pool Manager
class PoolManager {
constructor(domManager) {
this.dom = domManager;
}
addCopyPoolID() {
const navBar = this.dom.getElement("pool.nav");
const poolLink = navBar?.querySelector(CONFIG.selectors.pool.link);
if (!poolLink) return;
const poolId = poolLink.href.split("/").pop();
const copyButton = this.createCopyButton(poolId);
navBar.appendChild(copyButton);
}
createCopyButton(poolId) {
const container = document.createElement("div");
container.className = "pool-nav";
container.style.textAlign = "center";
const button = document.createElement("a");
button.textContent = `Copy Pool-ID: ${poolId}`;
button.style.cursor = "pointer";
button.addEventListener("click", async () => {
const success = await Utils.copyToClipboard(poolId);
alert(success ? `Copied Pool ID: ${poolId}` : "Failed to copy pool ID");
});
container.appendChild(button);
return container;
}
}
// Auto-Skip Manager
class AutoSkipManager {
constructor(domManager, navigationManager, blacklistManager) {
this.dom = domManager;
this.navigationManager = navigationManager;
this.blacklistManager = blacklistManager;
this.skipTimer = null;
this.isPaused = false;
this.progressBarElement = null;
this.progressBarContainer = null;
this.pauseIndicatorElement = null;
this.skipTimeout = CONFIG.delays.autoSkipDelay * 1000;
this.startTime = 0;
this.enabled = true;
}
createProgressBar() {
// Only create if we're on a blacklisted image
if (!this.blacklistManager.isImageBlacklisted()) {
return null;
}
const navBar = this.dom.getElement("pool.nav");
if (!navBar) return null;
// Create container for the progress bar
const container = document.createElement("div");
container.className = "pool-nav auto-skip-container";
container.style.textAlign = "center";
container.style.display = "block";
container.style.marginTop = "10px";
// Add title
const title = document.createElement("div");
title.textContent = "Auto-skipping blacklisted image";
title.style.marginBottom = "5px";
title.style.fontWeight = "bold";
container.appendChild(title);
// Create progress bar container
const progressBarContainer = document.createElement("div");
progressBarContainer.style.width = "100%";
progressBarContainer.style.height = "20px";
progressBarContainer.style.backgroundColor = "#444";
progressBarContainer.style.borderRadius = "3px";
progressBarContainer.style.overflow = "hidden";
// Create the progress bar (starts full)
this.progressBarElement = document.createElement("div");
this.progressBarElement.style.height = "100%";
this.progressBarElement.style.width = "100%";
this.progressBarElement.style.backgroundColor = "#ff0000"; // Red progress bar
this.progressBarElement.style.transition = "width 0.1s linear";
this.progressBarElement.style.float = "right"; // Right-to-left effect
progressBarContainer.appendChild(this.progressBarElement);
container.appendChild(progressBarContainer);
// Create pause indicator
this.pauseIndicatorElement = document.createElement("div");
this.pauseIndicatorElement.style.marginTop = "5px";
this.pauseIndicatorElement.style.fontSize = "12px";
this.pauseIndicatorElement.textContent = "Press P to pause";
container.appendChild(this.pauseIndicatorElement);
// Append container after the pool ID button
navBar.appendChild(container);
this.progressBarContainer = container;
return container;
}
updateProgressBar(secondsRemaining) {
if (this.progressBarElement) {
// Convert remaining seconds to percentage (from 100% to 0%)
const percentRemaining = (secondsRemaining / this.skipTimeout) * 100;
this.progressBarElement.style.width = `${percentRemaining}%`;
}
}
togglePause() {
this.isPaused = !this.isPaused;
if (this.pauseIndicatorElement) {
this.pauseIndicatorElement.textContent = this.isPaused
? "Paused (Press P to resume)"
: "Press P to pause";
}
}
toggle() {
this.enabled = !this.enabled;
// If disabled while running, clear any active timer and remove progress bar
if (!this.enabled && this.skipTimer) {
clearInterval(this.skipTimer);
this.skipTimer = null;
if (this.progressBarContainer && this.progressBarContainer.parentNode) {
this.progressBarContainer.parentNode.removeChild(
this.progressBarContainer,
);
this.progressBarContainer = null;
}
} else if (this.enabled && this.blacklistManager.isImageBlacklisted()) {
// If we're enabling and on a blacklisted image, start the skip
this.startAutoSkipCountdown();
}
// Show a notification about the state change
const status = this.enabled ? "enabled" : "disabled";
this.showNotification(`Auto-skip ${status}`);
}
showNotification(message) {
const notification = document.createElement("div");
notification.textContent = message;
notification.style.position = "fixed";
notification.style.top = "10px";
notification.style.left = "50%";
notification.style.transform = "translateX(-50%)";
notification.style.backgroundColor = "rgba(0, 0, 0, 0.7)";
notification.style.color = "white";
notification.style.padding = "10px 20px";
notification.style.borderRadius = "5px";
notification.style.zIndex = "10000";
document.body.appendChild(notification);
// Remove notification after 2 seconds
setTimeout(() => {
document.body.removeChild(notification);
}, 2000);
}
startAutoSkipCountdown() {
// If auto-skip is disabled or already a timer running, don't start
if (!this.enabled || this.skipTimer) return;
// Only create and start timer if we're on a blacklisted image
if (!this.blacklistManager.isImageBlacklisted()) return;
// Make sure we have a progress bar
if (!this.progressBarContainer) {
this.createProgressBar();
}
// Reset state
this.isPaused = false;
if (this.pauseIndicatorElement) {
this.pauseIndicatorElement.textContent = "Press P to pause";
}
this.startTime = Date.now();
this.skipTimer = setInterval(() => {
if (!this.isPaused) {
const elapsed = Date.now() - this.startTime;
const remainingTime = Math.max(this.skipTimeout - elapsed, 0);
this.updateProgressBar(remainingTime);
if (elapsed >= this.skipTimeout) {
clearInterval(this.skipTimer);
this.skipTimer = null;
// Remove the progress bar container
if (
this.progressBarContainer &&
this.progressBarContainer.parentNode
) {
this.progressBarContainer.parentNode.removeChild(
this.progressBarContainer,
);
this.progressBarContainer = null;
}
// Navigate to next post
this.navigationManager.goToNext();
}
}
}, 50); // Update every 50ms for smooth animation
}
checkAndStartSkip() {
// Check if current image is blacklisted and start timer if needed
if (this.blacklistManager.isImageBlacklisted() && this.enabled) {
// Clean up any existing progress bar
if (this.progressBarContainer && this.progressBarContainer.parentNode) {
this.progressBarContainer.parentNode.removeChild(
this.progressBarContainer,
);
this.progressBarContainer = null;
}
// Clear any existing timer
if (this.skipTimer) {
clearInterval(this.skipTimer);
this.skipTimer = null;
}
// Start a fresh countdown
this.startAutoSkipCountdown();
} else if (
!this.blacklistManager.isImageBlacklisted() &&
this.progressBarContainer
) {
// If we're not on a blacklisted image but have a progress bar, remove it
if (this.progressBarContainer.parentNode) {
this.progressBarContainer.parentNode.removeChild(
this.progressBarContainer,
);
this.progressBarContainer = null;
}
// Clear any existing timer
if (this.skipTimer) {
clearInterval(this.skipTimer);
this.skipTimer = null;
}
}
}
}
// Shortcut Manager
class ShortcutManager {
constructor(
voteManager,
navigationManager,
blacklistManager,
userActions,
autoSkipManager,
) {
this.voteManager = voteManager;
this.navigationManager = navigationManager;
this.blacklistManager = blacklistManager;
this.userActions = userActions;
this.autoSkipManager = autoSkipManager;
this.initializeShortcuts();
}
initializeShortcuts() {
document.addEventListener("keydown", this.handleKeydown.bind(this));
}
async handleKeydown(event) {
// Handle shortcuts based on configuration
const shortcuts = CONFIG.shortcuts;
const { code, shiftKey, ctrlKey, altKey } = event;
// Allow to switch pages even if an interactive element is focused
if (altKey && code === shortcuts.NEXT_POST.key) {
this.navigationManager.goToNext();
} else if (altKey && code === shortcuts.PREV_POST.key) {
this.navigationManager.goToPrev();
}
if (Utils.isInteractiveElementFocused()) return;
// Always respect the toggle auto-skip key press
if (code === shortcuts.TOGGLE_AUTOSKIP.key) {
// If a skip timer is active, just toggle pause state
if (this.autoSkipManager.skipTimer) {
this.autoSkipManager.togglePause();
} else {
// Otherwise, toggle the auto-skip feature
this.autoSkipManager.toggle();
}
return;
}
// Generic shortcuts
if (!shiftKey && code === shortcuts.UPVOTE.key) {
await this.voteManager.upvote();
} else if (shiftKey && code === shortcuts.DOWNVOTE.key) {
await this.voteManager.downvote();
} else if (code === shortcuts.NEXT_POST.key) {
this.navigationManager.goToNext();
} else if (code === shortcuts.PREV_POST.key) {
this.navigationManager.goToPrev();
} else if (code === shortcuts.TOGGLE_BLACKLIST.key) {
await this.blacklistManager.toggleBlacklist();
} else if (code === shortcuts.TOGGLE_FAVORITES.key) {
this.userActions.ignoreFavoritePosts(shiftKey, ctrlKey);
} else if (!shiftKey && code === shortcuts.LIKE_AND_FAVORITE.key) {
await this.voteManager.favorite();
await this.voteManager.upvote();
} else if (shiftKey && code === shortcuts.FAVORITE_AND_CONTINUE.key) {
await this.voteManager.favorite();
await this.voteManager.upvote();
this.navigationManager.goToNext();
}
}
}
// Site function modifier
class SiteModifier {
constructor(domManager) {
this.dom = domManager;
}
poolLinkOpenInNewTab() {
const navBar = this.dom.getElement("pool.nav");
let poolLink = navBar?.querySelector(CONFIG.selectors.pool.link);
if (!poolLink) return;
poolLink.target = "_blank";
}
}
// Main App
class App {
constructor() {
this.domManager = new DOMManager();
this.voteManager = new VoteManager(this.domManager);
this.navigationManager = new NavigationManager(this.domManager);
this.blacklistManager = new BlacklistManager(this.domManager);
this.poolManager = new PoolManager(this.domManager);
this.userActions = new UserActions(this.domManager);
this.autoSkipManager = new AutoSkipManager(
this.domManager,
this.navigationManager,
this.blacklistManager,
);
this.shortcutManager = new ShortcutManager(
this.voteManager,
this.navigationManager,
this.blacklistManager,
this.userActions,
this.autoSkipManager,
);
this.siteModifier = new SiteModifier(this.domManager);
// Set up mutation observer to detect page content changes
this.setupMutationObserver();
}
setupMutationObserver() {
// Only observe the specific container that would indicate a blacklisted image
const imageContainer = document.querySelector("#image-container");
if (!imageContainer) return;
// Create a focused mutation observer that only watches for blacklist class changes
const observer = new MutationObserver((mutations) => {
// Only check for actual class changes
const hasClassChange = mutations.some(
(mutation) =>
mutation.type === "attributes" && mutation.attributeName === "class",
);
if (hasClassChange) {
// Check if we're on a blacklisted image and should start the auto-skip
this.autoSkipManager.checkAndStartSkip();
}
});
// Only observe the specific element and only for class changes
observer.observe(imageContainer, {
attributes: true,
attributeFilter: ["class"],
});
// Also observe for URL changes, which indicate navigation between posts
let lastUrl = location.href;
const urlObserver = setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// Small delay to ensure the page has updated
setTimeout(() => {
this.autoSkipManager.checkAndStartSkip();
}, 100);
}
}, 500);
}
async initialize() {
await this.blacklistManager.openBlacklist();
this.poolManager.addCopyPoolID();
this.siteModifier.poolLinkOpenInNewTab();
// Create the progress bar if not already created
this.autoSkipManager.createProgressBar();
// Check for blacklisted image on initial load
this.autoSkipManager.checkAndStartSkip();
}
}
// Initialize the application when the window loads
window.addEventListener("load", () => {
const app = new App();
app.initialize().catch(console.error);
console.log("Extended Shortcuts with Auto-Skip loaded :)");
});