忘却まとめ

Blenderの中級者・上級者向けの踏み込んだ情報や、アドオン・3DCGに関する情報を記事にします

【Pixiv Search Results Blocker】Pixivの検索結果にてユーザーをブロックするユーザースクリプト【Tampermonkey】

その他

更新日:

Pixivの検索結果にて特定ユーザーをブロックするユーザースクリプトを公開します。
Pixiv Previewerでの人気順並び替えと共存できる独自のブロッカーが欲しかったので作成しました。

リンク

Pixiv Search Results Blocker

機能

各作品のユーザー名の横に×ボタンが追加されます。
×ボタンをクリックすると赤い"Block"ボタンが表示され、さらにクリックするとそのユーザー名とユーザーIDがブロックリストに保存されます。
ブロックリストにユーザーIDが含まれるユーザーの作品は、検索結果から非表示されます。

右下にオプションメニューが追加されます。
ブロックリストはjsonファイルとして書き出し・読み込みができます。

検索結果上部に出る不要なUIを非表示にする機能もついています。
(ピクシブ百科事典・関連タグリスト・人気の作品・並び順変更)

ソースコード

// ==UserScript==
// @name         Pixiv Search Results Blocker
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  検索ページにて特定のユーザーをブロックします。Pixiv Previewerでの並び替えと共存できます。名前の横に×ボタン、右下にオプションメニューが追加されます。
// @author       Bookyakuno
// @match        https://www.pixiv.net/tags/*/artworks*
// @icon         https://www.google.com/s2/favicons?bb=pixiv.net
// @license MIT 
// @grant        none
// ==/UserScript==

(function() {
  'use strict';

  const STORAGE_KEY = 'pixiv_custom_mute_list';
  const CONFIG_KEY = 'pixiv_custom_config';
  const MENU_ID = 'pm-menu-wrap';
  const TRIGGER_ID = 'pm-trigger-btn';

  let activeTab = 'mute';

  const DEFAULT_CONFIG = {
    hideEncyclopedia: true,
    hideRelatedTags: true,
    hidePopularWorks: true,
    hideSortOptions: true
  };

  const getMuteList = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
  const saveMuteList = (list) => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
    updateMenuContent();
    refreshDisplay();
  };

  const getConfig = () => ({
    ...DEFAULT_CONFIG,
    ...JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}')
  });
  const saveConfig = (config) => {
    localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
    refreshDisplay();
  };

  const injectStyles = () => {
    if (document.getElementById('pm-styles')) return;
    const style = document.createElement('style');
    style.id = 'pm-styles';
    style.textContent = `
            :root {
                --pm-bg: #ffffff; --pm-text: #555555; --pm-border: #dddddd;
                --pm-btn-bg: #f5f5f5; --pm-btn-hover: #e8e8e8;
                --pm-accent: #0096fa;
                --pm-x-color: #999999; --pm-x-hover: #d9534f;
                --pm-trigger-bg: rgba(0, 0, 0, 0.4); --pm-trigger-text: #ffffff;
            }
            @media (prefers-color-scheme: dark) {
                :root {
                    --pm-bg: #1e1e1e; --pm-text: #cccccc; --pm-border: #333333;
                    --pm-btn-bg: #2d2d2d; --pm-btn-hover: #3d3d3d;
                    --pm-x-color: #666666; --pm-x-hover: #ff6b6b;
                    --pm-trigger-bg: rgba(255, 255, 255, 0.15); --pm-trigger-text: #cccccc;
                }
            }
/* 1. 親要素:名前とボタンを両端に配置 */
a[href*="/users/"], a[href*="id="], .ppAuthorLink {
    position: relative !important;
    display: flex !important;
    align-items: center;
    justify-content: space-between !important;
    width: 100% !important;
    max-width: 100%;
    box-sizing: border-box !important;
    text-align: left !important;
    overflow: visible !important; /* はみ出しを防止 */
}

/* 2. 名前テキスト部分:長すぎる場合に「...」にする */
/* aタグ直下のテキストノードやスパンを対象にするための設定 */
a[href*="/users/"] > *:not(.pm-mute-btn),
.ppAuthorLink > *:not(.pm-mute-btn) {
    overflow: hidden !important;
    text-overflow: ellipsis !important; /* 三点リーダーを表示 */
    white-space: nowrap !important;    /* 折り返し禁止 */
    flex-grow: 1;                      /* 可能な限り広がる */
}

/* 3. ×ボタン:サイズを固定して絶対に隠さない */
.pm-mute-btn {
    transform: none !important;
    flex-shrink: 0 !important; /* 重要:親が狭くなっても絶対に縮まない */

    background: var(--pm-bg);
    color: var(--pm-x-color);
    cursor: pointer;
    font-size: 11px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 14px;
    height: 14px;

    border: 1px solid transparent; /* ガタつき防止 */
    border-radius: 4px;
    margin-left: 0px; /* 名前との最低限の余白 */
    z-index: 5;
    transition: all 0.1s ease-in-out;
}

.pm-mute-btn:hover {
    background: var(--pm-btn-hover);
    color: var(--pm-x-hover);
    border-color: var(--pm-x-hover);
}
/* 1回クリックした後のスタイル(Blockボタン) */
.pm-mute-btn.confirming {
    background: var(--pm-x-hover) !important;
    color: #fff !important;
    border-color: var(--pm-x-hover) !important;
    font-size: 9px;
    padding: 0 6px;
}


            #pm-menu-wrap {
                position: fixed; top: 60px; right: 20px; z-index: 10001;
                background: var(--pm-bg); color: var(--pm-text); border: 1px solid var(--pm-border);
                padding: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); border-radius: 8px;
                width: 260px; font-family: sans-serif; font-size: 12px; display: none;
            }
            .pm-tabs { display: flex; border-bottom: 1px solid var(--pm-border); margin-bottom: 12px; }
            .pm-tab { flex: 1; text-align: center; padding: 8px; cursor: pointer; opacity: 0.6; border-bottom: 2px solid transparent; font-weight: bold; }
            .pm-tab.active { opacity: 1; border-bottom: 2px solid faddMuteButtonsvar(--pm-accent); color: var(--pm-accent); }
            .pm-list-container { max-height: 250px; overflow-y: auto; margin-bottom: 10px; }
            .pm-list-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--pm-border); }
            .pm-action-row { display: flex; gap: 6px; margin-top: 10px; }
            .pm-action-btn { flex: 1; cursor: pointer; background: var(--pm-btn-bg); color: var(--pm-text); border: 1px solid var(--pm-border); border-radius: 4px; padding: 6px 4px; font-size: 11px; }
            .pm-config-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; cursor: pointer; }
            #pm-trigger-btn {
                position: fixed; bottom: 20px; right: 20px; z-index: 10000;
                background: var(--pm-trigger-bg); color: var(--pm-trigger-text);
                width: 36px; height: 36px; border-radius: 50%; display: flex;
                align-items: center; justify-content: center; cursor: pointer;
                font-size: 18px; opacity: 0.3; transition: opacity 0.3s;
            }
            #pm-trigger-btn:hover { opacity: 1; }
        `;
    document.head.appendChild(style);
  };

  const refreshDisplay = () => {
    const config = getConfig();
    const muteIds = getMuteList().map(u => String(u.id));
    const userLinks = document.querySelectorAll('a[href*="/users/"], a[href*="id="]');
    userLinks.forEach(link => {
      const userIdMatch = link.getAttribute('href').match(/(?:id=|users\/)(\d+)/);
      if (userIdMatch) {
        const article = link.closest('li, [role="presentation"]');
        if (article) article.style.display = muteIds.includes(String(userIdMatch[1])) ? 'none' : '';
      }
    });
    const encyclopediaHeader = document.querySelector('div[data-ga4-label="header"]');
    if (encyclopediaHeader && encyclopediaHeader.querySelector('a[href*="dic.pixiv.net"]')) {
      encyclopediaHeader.style.display = config.hideEncyclopedia ? 'none' : '';
    }
    document.querySelectorAll('div[title^="#"]').forEach(div => {
      const parentUl = div.closest('ul');
      if (parentUl) {
        const wrapper = parentUl.closest('nav') || parentUl.parentElement;
        const val = config.hideRelatedTags ? 'none' : '';
        parentUl.style.display = val;
        if (wrapper && wrapper.tagName !== 'BODY') wrapper.style.display = val;
      }
    });
    document.querySelectorAll('h3').forEach(h3 => {
      if (h3.textContent.includes("人気の作品")) {
        const section = h3.closest('section');
        if (section) section.style.display = config.hidePopularWorks ? 'none' : '';
      }
    });
    const sortLabels = ["並び替え", "新着順", "人気順", "古い順"];
    document.querySelectorAll('button, a, span').forEach(el => {
      if (sortLabels.includes(el.textContent.trim())) {
        const container = el.closest('div');
        if (container && container.offsetWidth < 500) container.style.display = config.hideSortOptions ? 'none' : '';
      }
    });
  };

const addMuteButtons = () => {
    const userLinks = document.querySelectorAll('a[href*="id="], a[href*="/users/"], .ppAuthorLink');
    userLinks.forEach(link => {
      if (link.textContent.trim() === "" || link.querySelector('.pm-mute-btn')) return;

      const userId = (link.getAttribute('href').match(/(?:id=|users\/)(\d+)/) || [])[1];
      if (!userId) return;

      const btn = document.createElement('span');
      btn.className = 'pm-mute-btn';
      btn.textContent = '×';

      // 状態管理用のフラグ
      let isConfirming = false;

      btn.onclick = (e) => {
        e.preventDefault();
        e.stopPropagation();

        if (!isConfirming) {
          // 1回目のクリック:Blockボタンに変化
          isConfirming = true;
          btn.textContent = 'Block';
          btn.classList.add('confirming');
        } else {
          // 2回目のクリック:実行
          const userName = Array.from(link.childNodes)
            .filter(n => n.nodeType === Node.TEXT_NODE || (n.nodeType === Node.ELEMENT_NODE && n !== btn))
            .map(n => n.textContent).join('').trim();

          const list = getMuteList();
          if (!list.find(u => String(u.id) === String(userId))) {
            list.push({ id: userId, name: userName });
            saveMuteList(list);
          }
        }
      };

      // マウスが離れたら元に戻す(キャンセル扱い)
      btn.onmouseleave = () => {
        if (isConfirming) {
          isConfirming = false;
          btn.textContent = '×';
          btn.classList.remove('confirming');
        }
      };

      link.appendChild(btn);
    });
  };








  const updateMenuContent = () => {
    const wrap = document.getElementById(MENU_ID);
    if (!wrap) return;

    const config = getConfig();
    const muteList = getMuteList();

    wrap.innerHTML = `
            <div class="pm-tabs">
              <div class="pm-tab ${activeTab === 'mute' ? 'active' : ''}" data-tab="mute">ミュート (${muteList.length})</div>
                <div class="pm-tab ${activeTab === 'config' ? 'active' : ''}" data-tab="config">表示設定</div>
            </div>
            <div id="pm-tab-content"></div>
        `;

    const contentContainer = wrap.querySelector('#pm-tab-content');
    if (activeTab === 'config') {
      contentContainer.innerHTML = '<div id="pm-config-container"></div>';
      const opts = [{
          key: 'hideEncyclopedia',
          label: 'ピクシブ百科事典'
        },
        {
          key: 'hideRelatedTags',
          label: '関連タグリスト'
        },
        {
          key: 'hidePopularWorks',
          label: '人気の作品'
        },
        {
          key: 'hideSortOptions',
          label: '並び順変更'
        }
      ];
      opts.forEach(opt => {
        const row = document.createElement('label');
        row.className = 'pm-config-item';
        row.innerHTML = `<span>${opt.label}を隠す</span><input type="checkbox" ${config[opt.key] ? 'checked' : ''}>`;
        row.querySelector('input').onchange = (e) => {
          config[opt.key] = e.target.checked;
          saveConfig(config);
        };
        contentContainer.querySelector('#pm-config-container').appendChild(row);
      });
    } else {
      contentContainer.innerHTML = `
                <div class="pm-action-row">
                    <button id="pm-export-btn" class="pm-action-btn">書き出し</button>
                    <button id="pm-import-btn" class="pm-action-btn">読み込み</button>
                </div>
                <div id="pm-list-container" class="pm-list-container"></div>
            `;
      const listContainer = contentContainer.querySelector('#pm-list-container');
      if (muteList.length === 0) {
        listContainer.innerHTML = '<p style="text-align:center; opacity:0.5; margin-top:20px;">ミュートなし</p>';
      } else {
        muteList.forEach((user, index) => {
          const item = document.createElement('div');
          item.className = 'pm-list-item';
          item.innerHTML = `<span style="flex-grow:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${user.name}</span>`;
          const delBtn = document.createElement('button');
          delBtn.textContent = '解除';
          delBtn.className = 'pm-action-btn';
          delBtn.style.flex = 'none';
          delBtn.onclick = (e) => {
            e.stopPropagation();
            muteList.splice(index, 1);
            saveMuteList(muteList);
          };
          item.appendChild(delBtn);
          listContainer.appendChild(item);
        });
      }

      // --- ボタンの存在確認をしてからイベントを設定 ---
      const exportBtn = contentContainer.querySelector('#pm-export-btn');
      if (exportBtn) {
        exportBtn.onclick = (e) => {
          e.stopPropagation();
          const blob = new Blob([JSON.stringify(muteList, null, 2)], {
            type: 'application/json'
          });
          const a = document.createElement('a');
          a.href = URL.createObjectURL(blob);
          a.download = `pixiv_mute_list_${new Date().toISOString().split('T')[0]}.json`;
          a.click();
        };
      }

      const importBtn = contentContainer.querySelector('#pm-import-btn');
      if (importBtn) {
        importBtn.onclick = (e) => {
          e.stopPropagation();
          const input = document.createElement('input');
          input.type = 'file';
          input.accept = '.json';
          input.onchange = (ev) => {
            const file = ev.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = (rev) => {
              try {
                const rawData = rev.target.result.trim(); // 空白除去
                const imported = JSON.parse(rawData);
                if (!Array.isArray(imported)) throw new Error('データが配列形式ではありません');
                if (confirm(`${imported.length}件読み込みますか?`)) {
                  const newList = [...getMuteList()];
                  imported.forEach(u => {
                    if (u.id && !newList.find(curr => String(curr.id) === String(u.id))) newList.push(u);
                  });
                  saveMuteList(newList);
                }
              } catch (err) {
                alert('読み込み失敗:' + err.message);
              }
            };
            reader.readAsText(file);
          };
          input.click();
        };
      }
    }

    wrap.querySelectorAll('.pm-tab').forEach(tab => {
      tab.onclick = (e) => {
        e.stopPropagation();
        activeTab = tab.dataset.tab;
        updateMenuContent();
      };
    });

    const closeBtn = wrap.querySelector('#pm-close-btn');
    if (closeBtn) closeBtn.onclick = () => wrap.style.display = 'none';
  };
  const createMenu = () => {
    if (document.getElementById(MENU_ID)) return;
    injectStyles();
    const menu = document.createElement('div');
    menu.id = MENU_ID;
    document.body.appendChild(menu);

    const trigger = document.createElement('div');
    trigger.id = TRIGGER_ID;
    trigger.innerHTML = '⚙️';
    trigger.onclick = (e) => {
      e.stopPropagation();
      const isVisible = menu.style.display === 'block';
      menu.style.display = isVisible ? 'none' : 'block';
      if (!isVisible) updateMenuContent();
    };
    document.body.appendChild(trigger);

    document.addEventListener('click', (e) => {
      const menuEl = document.getElementById(MENU_ID);
      const triggerEl = document.getElementById(TRIGGER_ID);
      if (menuEl && menuEl.style.display === 'block') {
        // クリックされたのがメニュー内でも、歯車ボタンでもない場合に閉じる
        if (!menuEl.contains(e.target) && !triggerEl.contains(e.target)) {
          menuEl.style.display = 'none';
        }
      }
    });
  };

  const main = () => {
    addMuteButtons();
    refreshDisplay();
  };
  createMenu();
  const observer = new MutationObserver(main);
  observer.observe(document.body, {
    childList: true,
    subtree: true
  });
  main();
  setInterval(main, 1500);
})();

経緯

ブロック機能を持つユーザースクリプトはあるにはあるが、Pixiv Previewerの人気順ソート機能と併用すると正しく動作してくれない問題があった。
Pixiv Previewer自体にもブロック機能はあるが、手動でユーザーIDを登録する必要があり面倒だった。

GeminiのAIコーディングエージェントでかなり手軽にコーディングできるようになったので今回の機能を作成した。

-その他

Copyright© 忘却まとめ , 2026 All Rights Reserved Powered by STINGER.