Watchlist for tags Rule34

Watchlist for tags on Rule34.xxx

Fra 28.12.2024. Se den seneste versjonen.

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