☰

e621 Downvotes & Blacklist Manager

Manage e621/e926 blacklist and downvoted posts via Tampermonkey menu

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

// ==UserScript==
// @name         e621 Downvotes & Blacklist Manager
// @namespace    https://e621.net/
// @version      2.2
// @description  Manage e621/e926 blacklist and downvoted posts via Tampermonkey menu
// @author       Cinop
// @match        https://e621.net/*
// @match        https://e926.net/*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_DOWNVOTES_KEY = 'e621_downvoted_posts';
  const STORAGE_BLACKLIST_BACKUP_KEY = 'e621_clean_blacklist_backup';
  const STORAGE_BLACKLIST_LOCKED_KEY = 'e621_load_blacklist_disabled';
  const STORAGE_AUTO_UPDATE_KEY = 'e621_auto_update_enabled';
  const STORAGE_AUTO_UPDATE_INIT_KEY = 'e621_auto_update_initialized';

  let registeredMenuCommandIds = [];

  // --- UI Toast notifications ---
  function showToast(message, isError = false) {
    console.log(`[e621 Manager] ${message}`);
    const toast = document.createElement('div');
    toast.textContent = message;
    toast.style.cssText = `
      position: fixed;
      bottom: 24px;
      right: 24px;
      z-index: 999999;
      background: ${isError ? '#d32f2f' : '#1976d2'};
      color: #fff;
      padding: 12px 20px;
      border-radius: 6px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
      font-family: sans-serif;
      font-size: 14px;
      pointer-events: none;
      transition: opacity 0.3s;
    `;
    document.body.appendChild(toast);
    setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 300);
    }, 4000);
  }

  // --- User & Page helpers ---
  function getCurrentUser() {
    let userId = document.body?.dataset?.userId;
    let userName = document.body?.dataset?.userName;
    const isAnonymous = document.body?.dataset?.userIsAnonymous === 'true';

    if (!userId || !userName || isAnonymous) {
      const siteUserElem = document.getElementById('site-user');
      if (siteUserElem) {
        try {
          const data = JSON.parse(atob(siteUserElem.textContent.trim()));
          if (data && data.name && !data.is?.anonymous) {
            userId = String(data.id);
            userName = data.name;
          }
        } catch (e) {
          console.error('Failed to parse #site-user:', e);
        }
      }
    }

    if (!userId || !userName || isAnonymous) {
      alert('Error: You are not logged in. Please sign in to e621/e926 first.');
      return null;
    }

    return { userId, userName };
  }

  function getCsrfToken() {
    return document.querySelector('meta[name="csrf-token"]')?.content || '';
  }

  function getPostIdFromPage(el = null) {
    if (el) {
      const container = el.closest('[data-id], [data-post-id], article.thumbnail, article.post');
      if (container) {
        const id = container.dataset.id || container.dataset.postId;
        if (id && /^\d+$/.test(id)) return Number(id);
      }
    }
    const meta = document.querySelector('meta[name="post-id"]');
    if (meta?.content && /^\d+$/.test(meta.content)) {
      return Number(meta.content);
    }
    const imgContainer = document.getElementById('image-container');
    if (imgContainer?.dataset.id) {
      return Number(imgContainer.dataset.id);
    }
    const match = window.location.pathname.match(/\/posts\/(\d+)/);
    return match ? Number(match[1]) : null;
  }

  // --- State Accessors (GM_* natively handles booleans and arrays) ---
  const isAutoUpdateEnabled = () => GM_getValue(STORAGE_AUTO_UPDATE_KEY, false);
  const setAutoUpdateEnabled = (val) => GM_setValue(STORAGE_AUTO_UPDATE_KEY, Boolean(val));

  const isBlacklistLocked = () => GM_getValue(STORAGE_BLACKLIST_LOCKED_KEY, false);
  const setBlacklistLocked = (val) => GM_setValue(STORAGE_BLACKLIST_LOCKED_KEY, Boolean(val));

  // --- Fetch live blacklist ---
  async function fetchLiveBlacklist(userId) {
    const metaTag = document.querySelector('meta[name="blacklisted-tags"]');
    if (metaTag?.content) {
      try {
        const parsed = JSON.parse(metaTag.content);
        if (Array.isArray(parsed) && parsed.length > 0) return parsed;
      } catch (e) {}
    }

    const res = await fetch(`/users/${userId}.json`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    return (data.blacklisted_tags || '').split('\n').map(t => t.trim()).filter(Boolean);
  }

  // --- Save Blacklist and trigger native in-page UI update ---
  async function saveAndApplyBlacklist(userId, userName, blacklistArray) {
    const blacklistString = blacklistArray.join('\n');
    let savedViaDialog = false;

    try {
      document.getElementById('blacklist-edit-link')?.click();

      const textarea = document.getElementById('blacklist-edit');
      const saveBtn = document.getElementById('blacklist-save');

      if (textarea && saveBtn) {
        textarea.value = blacklistString;
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
        textarea.dispatchEvent(new Event('change', { bubbles: true }));
        saveBtn.click();
        savedViaDialog = true;

        setTimeout(() => {
          const cancelBtn = document.getElementById('blacklist-cancel');
          const uiClose = document.querySelector('.ui-dialog-titlebar-close');
          (uiClose || cancelBtn)?.click();
        }, 150);
      }
    } catch (e) {
      console.warn('[e621 Manager] Dialog interaction failed, falling back to direct API', e);
    }

    if (savedViaDialog) {
      const metaTag = document.querySelector('meta[name="blacklisted-tags"]');
      if (metaTag) metaTag.content = JSON.stringify(blacklistArray);
    } else {
      const res = await fetch(`/users/${userId}.json`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
          'X-CSRF-Token': getCsrfToken(),
          'User-Agent': `Tampermonkey-DownvotesManager/2.2 (by ${userName} on e621)`
        },
        body: new URLSearchParams({ 'user[blacklisted_tags]': blacklistString }).toString()
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
      location.reload();
    }
  }

  // ==========================================
  // 1. "load current blacklist" (Backup)
  // ==========================================
  async function loadCurrentBlacklist(silent = false) {
    if (isBlacklistLocked()) {
      const msg = 'Action disabled: downvotes are currently in the blacklist. Unload them first.';
      showToast(msg, true);
      if (!silent) alert(msg);
      return;
    }

    const user = getCurrentUser();
    if (!user) return;

    try {
      showToast('Fetching current blacklist...');
      const blacklist = await fetchLiveBlacklist(user.userId);
      GM_setValue(STORAGE_BLACKLIST_BACKUP_KEY, blacklist);

      showToast(`Blacklist backup saved (${blacklist.length} entries)`);
      if (!silent) alert(`Success!\nSaved ${blacklist.length} entries to backup.`);
    } catch (err) {
      showToast(`Error: ${err.message}`, true);
    }
  }

  // ==========================================
  // 2. "load downvotes"
  // ==========================================
  async function loadDownvotes(silent = false) {
    const user = getCurrentUser();
    if (!user) return;

    try {
      showToast('Fetching downvoted posts...');
      const allDownvotedIds = new Set();
      let lowestId = null;
      const limit = 320;

      while (true) {
        let url = `/posts.json?tags=downvoted:${encodeURIComponent(user.userName)}&limit=${limit}`;
        if (lowestId) url += `&page=b${lowestId}`;

        const res = await fetch(url, {
          headers: { 'User-Agent': `Tampermonkey-DownvotesManager/2.2 (by ${user.userName} on e621)` }
        });

        if (res.status === 429 || res.status === 503) {
          await new Promise(r => setTimeout(r, 2500));
          continue;
        }

        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data = await res.json();
        const posts = Array.isArray(data) ? data : (data.posts || []);
        if (posts.length === 0) break;

        for (const post of posts) allDownvotedIds.add(post.id);

        lowestId = posts[posts.length - 1].id;
        showToast(`Loaded ${allDownvotedIds.size} downvoted posts...`);

        if (posts.length < limit) break;
        await new Promise(r => setTimeout(r, 600));
      }

      const uniqueIds = Array.from(allDownvotedIds);
      GM_setValue(STORAGE_DOWNVOTES_KEY, uniqueIds);

      showToast(`Saved ${uniqueIds.length} downvoted posts`);
      if (!silent) alert(`Success!\nFetched and stored ${uniqueIds.length} downvoted posts.`);
    } catch (err) {
      showToast(`Error: ${err.message}`, true);
    }
  }

  // ==========================================
  // 3. "upload downvotes to blacklist"
  // ==========================================
  async function uploadDownvotesToBlacklist(silent = false) {
    const user = getCurrentUser();
    if (!user) return;

    const downvotedIds = GM_getValue(STORAGE_DOWNVOTES_KEY, []);
    if (!downvotedIds.length) {
      alert('Storage has no saved downvotes. Run "load downvotes" first.');
      return;
    }

    try {
      showToast('Comparing with live blacklist...');
      const currentList = await fetchLiveBlacklist(user.userId);

      const existingIds = new Set(
        currentList
          .map(item => item.trim().match(/^id:(\d+)$/i))
          .filter(Boolean)
          .map(m => Number(m[1]))
      );

      const toAdd = downvotedIds.filter(id => !existingIds.has(Number(id)));

      setBlacklistLocked(true);
      refreshMenu();

      if (toAdd.length === 0) {
        showToast('All downvotes already in blacklist');
        if (!silent) alert('All downvoted posts are already present in your blacklist.');
        return;
      }

      const updatedBlacklist = [...currentList, ...toAdd.map(id => `id:${id}`)];
      showToast(`Adding ${toAdd.length} posts to blacklist...`);
      await saveAndApplyBlacklist(user.userId, user.userName, updatedBlacklist);

      showToast(`Added ${toAdd.length} posts to blacklist!`);
      if (!silent) {
        alert(`Done!\nAdded ${toAdd.length} entries to blacklist.\nTotal rules: ${updatedBlacklist.length}`);
      }
    } catch (err) {
      showToast(`Error: ${err.message}`, true);
    }
  }

  // ==========================================
  // 4. "unload downvotes from blacklist"
  // ==========================================
  async function unloadDownvotesFromBlacklist(silent = false) {
    const user = getCurrentUser();
    if (!user) return;

    const downvotedIds = GM_getValue(STORAGE_DOWNVOTES_KEY, []);
    if (!downvotedIds.length) {
      alert('Storage has no saved downvotes. Nothing to match for removal.');
      return;
    }

    try {
      showToast('Fetching blacklist for cleanup...');
      const currentList = await fetchLiveBlacklist(user.userId);
      const downvotedSet = new Set(downvotedIds.map(Number));

      let removedCount = 0;
      const updatedBlacklist = currentList.filter(item => {
        const match = item.trim().match(/^id:(\d+)$/i);
        if (match && downvotedSet.has(Number(match[1]))) {
          removedCount++;
          return false;
        }
        return true;
      });

      setBlacklistLocked(false);
      refreshMenu();

      if (removedCount === 0) {
        showToast('No downvotes found in blacklist');
        if (!silent) alert('No matching downvoted posts found in your current blacklist.');
        return;
      }

      showToast(`Removing ${removedCount} posts from blacklist...`);
      await saveAndApplyBlacklist(user.userId, user.userName, updatedBlacklist);

      showToast(`Removed ${removedCount} posts`);
      if (!silent) {
        alert(`Done!\nRemoved ${removedCount} posts from blacklist.\nRemaining rules: ${updatedBlacklist.length}`);
      }
    } catch (err) {
      showToast(`Error: ${err.message}`, true);
    }
  }

  // ==========================================
  // 5. "check downvotes via API" (Sync check)
  // ==========================================
  async function checkDownvotesViaApi() {
    showToast('Syncing downvotes with blacklist via API...');
    await loadDownvotes(true);
    await uploadDownvotesToBlacklist(false);
  }

  // ==========================================
  // 6. Toggle Auto Update Switch
  // ==========================================
  async function toggleAutoUpdate() {
    const willEnable = !isAutoUpdateEnabled();

    if (willEnable) {
      const isFirstTime = !GM_getValue(STORAGE_AUTO_UPDATE_INIT_KEY, false);
      if (isFirstTime) {
        showToast('Initializing: loading downvotes...');
        await loadDownvotes(true);
        await uploadDownvotesToBlacklist(true);
        GM_setValue(STORAGE_AUTO_UPDATE_INIT_KEY, true);
      }

      setAutoUpdateEnabled(true);
      refreshMenu();
      showToast('Auto Update enabled');
    } else {
      setAutoUpdateEnabled(false);
      refreshMenu();
      showToast('Auto Update disabled');
    }
  }

  // --- Auto-update helper: Add single post ---
  async function addPostToBlacklist(postId) {
    const downvotedIds = new Set(GM_getValue(STORAGE_DOWNVOTES_KEY, []));
    downvotedIds.add(postId);
    GM_setValue(STORAGE_DOWNVOTES_KEY, Array.from(downvotedIds));

    const user = getCurrentUser();
    if (!user) return;

    try {
      const currentList = await fetchLiveBlacklist(user.userId);
      const postTag = `id:${postId}`.toLowerCase();

      const exists = currentList.some(t => t.trim().toLowerCase() === postTag);
      if (!exists) {
        currentList.push(`id:${postId}`);
        await saveAndApplyBlacklist(user.userId, user.userName, currentList);
        setBlacklistLocked(true);
        refreshMenu();
        showToast(`Auto Update: post #${postId} blacklisted`);
      }
    } catch (err) {
      showToast(`Auto Update error: ${err.message}`, true);
    }
  }

  // --- Auto-update helper: Remove single post ---
  async function removePostFromBlacklist(postId) {
    const downvotedIds = GM_getValue(STORAGE_DOWNVOTES_KEY, []).filter(id => Number(id) !== postId);
    GM_setValue(STORAGE_DOWNVOTES_KEY, downvotedIds);

    const user = getCurrentUser();
    if (!user) return;

    try {
      const currentList = await fetchLiveBlacklist(user.userId);
      const postTag = `id:${postId}`.toLowerCase();

      const updatedList = currentList.filter(item => item.trim().toLowerCase() !== postTag);
      if (updatedList.length !== currentList.length) {
        await saveAndApplyBlacklist(user.userId, user.userName, updatedList);
        showToast(`Auto Update: post #${postId} un-blacklisted`);
      }
    } catch (err) {
      showToast(`Auto Update error: ${err.message}`, true);
    }
  }

  // --- Vote Buttons Listener on Page ---
  function setupVoteListener() {
    document.addEventListener('click', async (e) => {
      if (!isAutoUpdateEnabled()) return;

      const voteBtn = e.target.closest('button[data-action="1"], button[data-action="-1"], .ptbr-vote-button');
      if (!voteBtn) return;
      if (voteBtn.classList.contains('comment-vote-down-link') || voteBtn.classList.contains('comment-vote-up-link')) return;

      const action = voteBtn.dataset.action;
      if (action !== '1' && action !== '-1') return;

      const postId = getPostIdFromPage(voteBtn);
      if (!postId) return;

      const voteContainer = voteBtn.closest('.ptbr-vote') || document.querySelector('.ptbr-vote');
      const currentVoteState = voteContainer ? String(voteContainer.dataset.vote || '0') : '0';

      if (action === '-1') {
        if (currentVoteState === '-1') {
          await removePostFromBlacklist(postId);
        } else {
          await addPostToBlacklist(postId);
        }
      } else if (action === '1' && currentVoteState === '-1') {
        await removePostFromBlacklist(postId);
      }
    }, true);
  }

  // ==========================================
  // Tampermonkey Menu Setup
  // ==========================================
  function registerCommand(label, onClick) {
    if (typeof GM_registerMenuCommand === 'function') {
      const id = GM_registerMenuCommand(label, onClick);
      if (id !== undefined) registeredMenuCommandIds.push(id);
    }
  }

  function refreshMenu() {
    if (typeof GM_unregisterMenuCommand === 'function') {
      for (const id of registeredMenuCommandIds) {
        try { GM_unregisterMenuCommand(id); } catch (e) {}
      }
    }
    registeredMenuCommandIds = [];

    if (isAutoUpdateEnabled()) {
      registerCommand('Auto Update: ON (Click to Disable)', toggleAutoUpdate);
      registerCommand('  ↳ Sync downvotes via API', checkDownvotesViaApi);
    } else {
      if (!isBlacklistLocked()) {
        registerCommand('Backup current blacklist', () => loadCurrentBlacklist(false));
      }
      registerCommand('Load downvotes', () => loadDownvotes(false));
      registerCommand('Upload downvotes to blacklist', () => uploadDownvotesToBlacklist(false));
      registerCommand('Unload downvotes from blacklist', () => unloadDownvotesFromBlacklist(false));
      registerCommand('Auto Update: OFF (Click to Enable)', toggleAutoUpdate);
    }
  }

  // --- Initialization ---
  setupVoteListener();
  refreshMenu();

})();