☰

e621 Downvotes & Blacklist Manager

Manage e621/e926 blacklist and downvoted posts via Tampermonkey menu

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();