Twitter Bait Filter

Blocks bait threads on Twitter/X using Unicode, media, and keyword heuristics. Replaces with placeholder.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitter Bait Filter
// @namespace    https://github.com/zolu0/twitter-bait-filter
// @version      2.7
// @description  Blocks bait threads on Twitter/X using Unicode, media, and keyword heuristics. Replaces with placeholder.
// @author       zolu0
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const EXPLICIT_KEYWORDS = [
    'nsfw',
    'nude',
    'porn',
    'p*rn',
    'onlyfans',
    'only fans',
  ];

  const BAIT_KEYWORDS = [
    'hot scenes',
    'best scenes',
    'sexiest',
    'body count',
    'booty thread',
    'porn thread',
    'booty',
    'show more',
  ];

  const ENGAGEMENT_BAIT_KEYWORDS = [
    'follow and make my day',
    'make my day',
    'say hi',
    'my birthday',
    "i'm ugly",
    'nobody notices',
    "maybe it's because",
    'fuck it',
  ];

  const EMOJI_REGEX = /\p{Extended_Pictographic}/gu;

  const normalizeText = (input) =>
    input
      .normalize('NFKD')
      .replace(/[\u0300-\u036f]/g, '')
      .replace(/[^\x00-\x7F]/g, '')
      .toLowerCase();

  const getTweetText = (tweet) => {
    const textEl = tweet.querySelector('[data-testid="tweetText"]');
    return textEl ? textEl.innerText : '';
  };

  const isTweetBait = (rawText, tweet) => {
    const text = normalizeText(rawText);
    const hasMedia =
      tweet.querySelector(
        'img, video, [data-testid="tweetPhoto"], [data-testid="videoPlayer"]',
      ) !== null;

    if (!hasMedia) return false;

    const emojiCount = (rawText.match(EMOJI_REGEX) || []).length;
    const hasTooManyEmojis = emojiCount >= 5;

    if (EXPLICIT_KEYWORDS.some((k) => text.includes(k))) return true;
    if (BAIT_KEYWORDS.some((k) => text.includes(k))) return true;
    if (
      ENGAGEMENT_BAIT_KEYWORDS.some((k) => text.includes(k)) &&
      hasTooManyEmojis
    )
      return true;

    return false;
  };

  const blockTweet = (tweet, rawText) => {
    const originalHTML = tweet.innerHTML;
    tweet.dataset.baitFiltered = 'true';
    console.log('Blocked tweet:', rawText.slice(0, 150).replace(/\n/g, ' '));

    const div = document.createElement('div');
    div.style.cssText = `
      padding: 16px;
      margin: 8px;
      color: #888;
      background: #1e1e1e;
      border: 1px solid #333;
      border-radius: 8px;
      font-family: sans-serif;
      text-align: center;
      font-size: 14px;
    `;

    const label = document.createElement('span');
    label.textContent = 'Blocked Tweet';

    const btn = document.createElement('button');
    btn.textContent = 'Show anyway';
    btn.style.cssText = `
      margin-left: 12px;
      background: none;
      border: 1px solid #555;
      border-radius: 4px;
      color: #aaa;
      font-size: 12px;
      padding: 2px 8px;
      cursor: pointer;
    `;
    const showPlaceholder = () => {
      tweet.dataset.baitFiltered = 'true';
      tweet.innerHTML = '';
      tweet.appendChild(div);
    };

    btn.addEventListener('click', () => {
      if (tweet.dataset.baitFiltered === 'shown') {
        showPlaceholder();
      } else {
        tweet.dataset.baitFiltered = 'shown';
        tweet.innerHTML = originalHTML;
        const hideBtn = document.createElement('button');
        hideBtn.textContent = 'Hide';
        hideBtn.style.cssText = btn.style.cssText;
        hideBtn.addEventListener('click', showPlaceholder);
        tweet.prepend(hideBtn);
      }
    });

    div.appendChild(label);
    div.appendChild(btn);
    tweet.innerHTML = '';
    tweet.appendChild(div);
  };

  const processTweets = () => {
    document.querySelectorAll('article').forEach((tweet) => {
      if (tweet.dataset.baitFiltered) return;

      const rawText = getTweetText(tweet);
      if (!rawText) return;

      if (isTweetBait(rawText, tweet)) {
        blockTweet(tweet, rawText);
      }
    });
  };

  let debounceTimer;
  const observer = new MutationObserver(() => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(processTweets, 200);
  });

  console.log('Twitter Bait Filter loaded');
  processTweets();
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });
})();