// ==UserScript==
// @name Watchlist for tags Rule34
// @namespace Notification New Content Rule34
// @match https://rule34.xxx/*
// @connect https://api.rule34.xxx/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @author Ardath / GPT
// @version 1.1.4
// @description Watchlist for tags on Rule34.xxx
// @license MIT
// ==/UserScript==
// How many tags to check per seconds
const MAX_TAGS_TO_CHECK_AT_ONCE = 1;
const TIME_BETWEEN_API_CALLS_MS = 2000;
// ===========
// == Style ==
// ===========
const styleElement = document.createElement('style');
styleElement.type = 'text/css';
styleElement.innerHTML = `
.modal {
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #293129;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 20%;
height: 50%;
overflow: auto;
}
@media only screen and (max-width: 810px) {
.modal-content {
width: 90%;
height: 70%;
margin: 10% auto;
padding: 15px;
}
}
.modal-content p {
font-size: 15px;
}
.modal-content a:hover {
font-weight: bold;
}
#header #subnavbar li a:hover {
color: white !important;
}
#top-div {
display: flex;
flex-direction: column; /* Stack buttons vertically */
align-items: stretch;
margin-bottom: 5px;
}
#bottom-div {
margin-bottom: 10%;
}
button[id="check-new-content-button"] {
align-self: center;
display: block;
float: left;
margin-bottom: 15px;
}
#cooldown-div {
margin-left: auto;
margin-right: 0;
}
#cooldown-label {
display: inline-block;
float: right;
color: #c0c0c0;
}
#cooldown-input {
border: 1px solid #505050;
display: inline-block;
background-color: #303030;
color: white;
float: right;
width: 20%;
margin-left: 5px;
}
#cooldown-input:focus {
outline: none;
}
#last-checked-time {
align-self: flex-end;
color: #c0c0c0;
}
label[for="show-new-tags"] {
display: inline-block;
margin-left: 5px;
color: white;
}
button[id="reset-button"] {
display: inline;
float: right;
}
p[id="disable-all-button"] {
color: white;
cursor: pointer;
font-weight: bold;
font-size: 15px;
margin-top: 20px;
}
button[id="removeButton"] {
color: #cc0000;
border: none;
background: none;
margin-right: 10px;
font-weight: bold;
cursor: pointer;
}
button[id="disableButton"] {
color: grey;
border: none;
background: none;
margin-left: 5px;
font-size: 17px;
font-weight: bold;
cursor: pointer;
}
`;
document.head.appendChild(styleElement);
// ===================================
// == Watchlist button and Modal ==
// ===================================
// Add a Watchlist button to the navigation bar
function addWatchlistButtonToNav() {
const navbar = document.getElementById('subnavbar');
const listItem = document.createElement('li');
const watchlistButton = document.createElement('a');
watchlistButton.textContent = 'Watchlist';
watchlistButton.id = 'watchlist-button';
watchlistButton.href = '#';
watchlistButton.style.padding = '2px';
listItem.appendChild(watchlistButton);
navbar.appendChild(listItem);
}
// Create the modal to display watched tags
function createWatchlistModal() {
const modalElement = document.createElement('div');
modalElement.id = 'watchlistModal';
modalElement.className = 'modal';
const modalContentElement = document.createElement('div');
modalContentElement.className = 'modal-content';
const topDiv = document.createElement('div');
topDiv.id = 'top-div';
const bottomDiv = document.createElement('div');
bottomDiv.id = 'bottom-div';
const checkNewContentButton = document.createElement('button');
checkNewContentButton.id = 'check-new-content-button';
checkNewContentButton.textContent = 'Check for new content';
const cooldownDiv = document.createElement('div');
cooldownDiv.id = 'cooldown-div';
const cooldownLabel = document.createElement('p');
cooldownLabel.id = 'cooldown-label';
cooldownLabel.textContent = 'Cooldown : ';
const cooldownInput = document.createElement('input');
cooldownInput.id = 'cooldown-input';
cooldownInput.type = 'number';
cooldownInput.min = 0;
cooldownInput.value = GM_getValue('cooldown', '0');
const lastCheckedText = document.createElement('p');
lastCheckedText.id = 'last-checked-time';
lastCheckedText.textContent = 'Last checked: never';
const filterCheckbox = document.createElement('input');
filterCheckbox.type = 'checkbox';
filterCheckbox.id = 'show-new-tags';
filterCheckbox.checked = GM_getValue('showNewTags', false);
const filterLabel = document.createElement('label');
filterLabel.htmlFor = 'show-new-tags';
filterLabel.textContent = 'Show only tags with new content';
const clearButton = document.createElement('button');
clearButton.id = 'reset-button';
clearButton.textContent = 'Clear';
const disableAllButton = document.createElement('p');
disableAllButton.id = 'disable-all-button';
disableAllButton.textContent = '+/-';
disableAllButton.onclick = function () {
const tagKeys = GM_listValues().filter(key => key.startsWith('tag_'));
if (tagKeys.length === 0) return;
const firstTagData = JSON.parse(GM_getValue(tagKeys[0], '{}'));
const newState = !(firstTagData.disabled || false);
tagKeys.forEach(key => {
const tagData = JSON.parse(GM_getValue(key, '{}'));
tagData.disabled = newState;
GM_setValue(key, JSON.stringify(tagData));
});
displayStoredTagsInModal();
};
const modalText = document.createElement('div');
modalText.id = 'modal-content';
modalText.textContent = 'Tags will appear here.';
cooldownDiv.append(cooldownInput, cooldownLabel);
topDiv.append(checkNewContentButton, cooldownDiv, lastCheckedText);
bottomDiv.append(filterCheckbox, filterLabel, clearButton, disableAllButton);
modalContentElement.append(topDiv, bottomDiv, modalText);
modalElement.appendChild(modalContentElement);
document.body.appendChild(modalElement);
return { modalElement: modalElement,
checkNewContentButton: checkNewContentButton,
cooldownInput: cooldownInput,
filterCheckbox: filterCheckbox,
clearButton: clearButton,
modalText: modalText };
}
// Check for new content event
async function checkForNewContent() {
isChecking = true;
GM_setValue('lastCheckTime', Date.now());
updateLastCheckedTimeDisplay();
const checkButton = document.getElementById('check-new-content-button');
var blacklistedTags = await fetchBlacklistedTags();
if (blacklistedTags[0] !== '') {
GM_setValue('blacklisted', blacklistedTags);
} else {
blacklistedTags = GM_getValue('blacklisted');
}
console.log('Blacklist : ', blacklistedTags);
const tagKeys = GM_listValues().filter(key => key.startsWith('tag_'));
const totalTags = tagKeys.length;
let checkedTags = 0;
const promises = [];
for (const key of tagKeys) {
const tagData = JSON.parse(GM_getValue(key, '{}'));
const cooldown = document.getElementById('cooldown-input').value;
checkedTags++;
checkButton.textContent = `Checking for new content... [${checkedTags} out of ${totalTags}]`;
if (tagData.disabled) {
console.log("Tag disabled : " , tagData.name);
continue;
}
// Cooldown in case 'Checking for new content' is interrupted
if (tagData.cooldown && Math.floor((Date.now() - tagData.cooldown) / 1000) < cooldown * 60) {
console.log("Not enough time has elapsed");
continue;
}
// If tag is removed while 'Checking for new content'
if (!tagData.name) {
checkedTags--;
console.log("Invalid tag data");
continue;
}
// If tag is already 'green'
if (tagData.new_content && !tagData.blacklisted) {
console.log('New content found for : ' + tagData.name);
continue;
}
promises.push((async () => {
try {
const contentData = await fetchTagsAndCreatedAt(tagData.name);
if (contentData && new Date(contentData.created_at) > new Date(tagData.created_at)) {
if (!containsBlacklistedTags(contentData.tags, blacklistedTags)) {
tagData.blacklisted = false;
console.log('New content found for : ' + tagData.name);
} else {
tagData.blacklisted = true;
console.log('Blacklisted content found for : ' + tagData.name);
}
tagData.created_at = contentData.created_at;
tagData.new_content = true;
tagData.cooldown = Date.now();
GM_setValue(key, JSON.stringify(tagData));
displayStoredTagsInModal();
} else {
tagData.cooldown = Date.now();
GM_setValue(key, JSON.stringify(tagData));
console.log('No new content found for : ' + tagData.name);
}
} catch (error) {
console.error(`Error processing tag ${key}:`, error);
}
})());
// Throttle the updates at 1 API calls every 2 seconds by default
if (promises.length >= MAX_TAGS_TO_CHECK_AT_ONCE) {
await Promise.all(promises);
promises.length = 0;
await new Promise(resolve => setTimeout(resolve, TIME_BETWEEN_API_CALLS_MS));
}
}
updateWatchlistButton();
checkButton.textContent = `Check completed [${checkedTags} out of ${totalTags}]`;
isChecking = false;
}
// Set up modal behavior
function setupModalEvents(params) {
const modalElement = params.modalElement;
const checkNewContentButton = params.checkNewContentButton;
const cooldownInput = params.cooldownInput;
const filterCheckbox = params.filterCheckbox;
const clearButton = params.clearButton;
const button = document.getElementById('watchlist-button');
button.onclick = function() {
modalElement.style.display = 'block';
displayStoredTagsInModal();
};
window.onclick = function(event) {
if (event.target === modalElement) modalElement.style.display = 'none';
};
checkNewContentButton.onclick = function() {
if (!isChecking) {
checkForNewContent();
}};
cooldownInput.addEventListener('input', function() {
cooldownInput.value = cooldownInput.value.replace(/[^0-9]/g, '');
GM_setValue('cooldown', parseInt(cooldownInput.value));
});
filterCheckbox.onchange = function() {
GM_setValue('showNewTags', filterCheckbox.checked);
displayStoredTagsInModal();
};
clearButton.onclick = clearWatchlist;
}
// Display stored tags in the modal
function displayStoredTagsInModal() {
const modalContent = document.getElementById('modal-content');
const filterNewTags = document.getElementById('show-new-tags').checked;
modalContent.innerHTML = '';
// Collect all tag objects
const tags = [];
GM_listValues().forEach(function(key) {
if (!key.startsWith('tag_')) return;
const tagData = JSON.parse(GM_getValue(key, '{}'));
if (filterNewTags && !tagData.new_content) return;
if (!tagData.name) return;
tags.push({
key,
...tagData
});
});
// Sort tags alphabetically by name
tags.sort((a, b) => a.name.localeCompare(b.name));
// Create and append elements for each tag
tags.forEach(tag => {
const tagUrl = 'https://rule34.xxx/index.php?page=post&s=list&tags=' + tag.name;
const tagElement = createTagElement(tagUrl, tag.name, tag.new_content, tag.key);
modalContent.appendChild(tagElement);
});
}
// Create a tag element with a remove button and disable toggle
function createTagElement(url, tagName, hasNewContent, storageKey) {
const tagElement = document.createElement('p');
// Retrieve stored data once to avoid redundancy
const tagData = JSON.parse(GM_getValue(storageKey, '{}')) || {};
const isBlacklisted = tagData.blacklisted || false;
var isDisabled = tagData.disabled || false;
// Create the link element
const linkElement = document.createElement('a');
linkElement.href = url;
linkElement.textContent = tagName;
// Style the link based on conditions
linkElement.style.color = isBlacklisted
? hasNewContent ? '#ff8000' : 'white'
: hasNewContent ? '#00dd00' : 'white';
linkElement.style.fontWeight = hasNewContent ? 'bold' : '';
linkElement.onclick = function (event) {
markTagAsRead(storageKey, url);
};
// Create the remove button
const removeButton = document.createElement('button');
removeButton.id = 'removeButton';
removeButton.textContent = 'X';
removeButton.onclick = function () {
GM_deleteValue(storageKey);
displayStoredTagsInModal();
};
// Create the disable button
const disableButton = document.createElement('button');
disableButton.id = 'disableButton';
disableButton.textContent = isDisabled ? "-" : "+";
disableButton.onclick = function () {
isDisabled = !isDisabled;
disableButton.textContent = isDisabled ? "-" : "+";
const tagData = JSON.parse(GM_getValue(storageKey, '{}'));
if (tagData.disabled === undefined) {
tagData.disabled = false;
}
tagData.disabled = isDisabled;
GM_setValue(storageKey, JSON.stringify(tagData));
console.log(tagData);
}
tagElement.append(removeButton, linkElement, disableButton);
return tagElement;
}
// Mark tag as read and redirect
function markTagAsRead(storageKey, url) {
const tagData = JSON.parse(GM_getValue(storageKey, '{}'));
tagData.new_content = false;
GM_setValue(storageKey, JSON.stringify(tagData));
displayStoredTagsInModal();
}
function clearWatchlist() {
// Get all keys stored in Greasemonkey storage
GM_listValues().forEach(function (key) {
if (key.startsWith('tag_')) {
const tagData = JSON.parse(GM_getValue(key, '{}'));
tagData.new_content = false;
GM_setValue(key, JSON.stringify(tagData));
}
});
displayStoredTagsInModal();
updateWatchlistButton();
}
// Update watchlist button appearance based on new content
function updateWatchlistButton() {
const watchlistButton = document.getElementById('watchlist-button');
const hasNewContent = GM_listValues().some(function(key) {
return key.startsWith('tag_') && JSON.parse(GM_getValue(key, '{}')).new_content === true;
});
watchlistButton.style.backgroundColor = hasNewContent ? '#AA0000' : '';
watchlistButton.style.color = hasNewContent ? '#FFFFFF' : '';
}
// Update the last checked time display
function updateLastCheckedTimeDisplay() {
const lastCheckedText = document.getElementById('last-checked-time');
const lastCheckedTime = GM_getValue('lastCheckTime', '0');
if (lastCheckedTime) {
const minutesAgo = Math.floor((Date.now() - lastCheckedTime) / (1000 * 60));
lastCheckedText.textContent = 'Last checked: ' + minutesAgo + ' minutes ago';
}
}
// ==========================
// == Tags toggle buttons ==
// ==========================
// Add toggle button next to each tag
function addToggleFunctionalityToTags() {
const tagItems = document.querySelectorAll('#tag-sidebar li[class*="tag"]');
tagItems.forEach(function(tagItem) {
const toggleButton = document.createElement('a');
toggleButton.textContent = '[ ]';
toggleButton.href = '#';
tagItem.prepend(toggleButton);
const tagLink = Array.from(tagItem.querySelectorAll('a')).at(-1);
if (!tagLink) return;
const href = tagLink.getAttribute('href');
const params = new URLSearchParams(href.split('?')[1]);
const tagName = params.get('tags');
const storageKey = 'tag_' + tagName;
if (GM_getValue(storageKey, null)) {
toggleButton.textContent = '[X]';
}
toggleButton.onclick = function(event) {
event.preventDefault();
addTagToGMStorage(storageKey, toggleButton, tagName);
};
});
}
// Add/Remove tags from localStorage when toggle button is pressed
function addTagToGMStorage(storageKey, toggleButton, tagName) {
if (GM_getValue(storageKey, null)) {
GM_deleteValue(storageKey);
toggleButton.textContent = '[ ]';
} else {
fetchTagsAndCreatedAt(tagName).then(function(createdAt) {
if (createdAt) {
GM_setValue(storageKey, JSON.stringify({ name: tagName, created_at: createdAt.created_at, new_content: false, blacklisted: false }));
toggleButton.textContent = '[X]';
}
});
}
}
// ======================
// == Fetch functions ==
// ======================
// Fetch all tags and the date of the most recent content for a specific tag
async function fetchTagsAndCreatedAt(tagName) {
const apiUrl = `https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&tags=${tagName}&limit=1`;
try {
const response = await fetch(apiUrl);
const data = await response.text();
const xmlDoc = new DOMParser().parseFromString(data, "text/xml");
const firstPost = xmlDoc.getElementsByTagName("post")[0];
if (firstPost) {
const tags = firstPost.getAttribute("tags")
.split(' ')
.map(tag => tag.trim())
.filter(tag => tag !== "");
const createdAt = firstPost.getAttribute("created_at");
return { tags, created_at: createdAt };
} else {
return null;
}
} catch (error) {
console.error('Error fetching content data:', error);
return null;
}
}
// Fetch blacklisted tags
async function fetchBlacklistedTags() {
try {
const response = await fetch('https://rule34.xxx/index.php?page=account&s=options');
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const blacklistedTags = doc.querySelector('textarea[name="tags"]').value
.split(/[\s,]+/)
.map(tag => tag.trim());
return blacklistedTags;
} catch (error) {
console.error('Error fetching blacklisted tags:', error);
return [];
}
}
// Check if any blacklisted tag is present in the content tags
function containsBlacklistedTags(contentTags, blacklistedTags) {
return contentTags.some(tag => blacklistedTags.includes(tag));
}
// Transfer localStorage tags to Greasemonkey. Tags are now saved in Greasemonkey / Violentmonkey etc...
function migrateLocalStorageToGMStorage() {
const migratedKeys = [];
Object.keys(localStorage).forEach((key) => {
if (key.startsWith('tag_')) {
const value = localStorage.getItem(key);
if (value) {
GM_setValue(key, value);
migratedKeys.push(key);
console.log(`Migrated key: ${key}, value: ${value}`);
}
}
});
migratedKeys.forEach((key) => {
localStorage.removeItem(key);
console.log(`Removed key from localStorage: ${key}`);
});
localStorage.setItem('migrationDone', true);
console.log('Migration from localStorage to Greasemonkey completed.')
}
// =============================================================
addWatchlistButtonToNav();
const params = createWatchlistModal();
setupModalEvents(params);
addToggleFunctionalityToTags();
updateWatchlistButton();
updateLastCheckedTimeDisplay();
var isChecking = false;
if (localStorage.getItem('migrationDone') !== 'true') {
migrateLocalStorageToGMStorage();
}