Facebook cleaner

This script hides sponsored posts (ads) on Facebook, making your feed cleaner and free from distractions.

// ==UserScript==
// @name         Facebook cleaner
// @namespace    https://lukaszmical.pl/
// @version      0.2.1
// @description  This script hides sponsored posts (ads) on Facebook, making your feed cleaner and free from distractions.
// @author       Łukasz Micał
// @match        https://*.facebook.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        window.onurlchange
// @icon         https://www.google.com/s2/favicons?sz=64&domain=facebook.com
// ==/UserScript==

// css:apps/facebook-cleaner/src/style/style-debug.css
const style_debug_default =
  '[data-fcc="@"]{position:relative;}[data-fcc="@"]:before{display:block;content:attr(data-fcc-reason);position:absolute;top:0;left:50%;border-radius:16px;padding:8px;background-color:var(--card-background);transform:translateX(-50%);color:var(--primary-text);z-index:999;border:1px solid var(--primary-button-background)}';

// css:apps/facebook-cleaner/src/style/style.css
const style_default =
  '[data-fcc="@"]{display:none !important;}[data-fcc="@"][data-fcc-type="@"]{display:block !important;max-height:24px;overflow:hidden;margin-bottom:16px;padding-top:24px;border-radius:16px;box-sizing:border-box;position:relative;background-color:var(--card-background);}[data-fcc="@"][data-fcc-type="@"] *{max-height:2px;}[data-fcc="@"][data-fcc-type="@"]:before{display:block;content:attr(title);position:absolute;top:4px;left:50%;transform:translateX(-50%);color:var(--primary-text);}';

// libs/share/src/ui/GlobalStyle.ts
const GlobalStyle = class {
  static addStyle(key, styles) {
    const style =
      document.getElementById(key) ||
      (function () {
        const style2 = document.createElement('style');
        style2.id = key;
        document.head.appendChild(style2);
        return style2;
      })();
    style.textContent = styles;
  }
};

// libs/share/src/utils/urlChangeEvent.ts
function activateUrlChangeEvents() {
  if (!window.onurlchange) {
    const dispatchUrlChangeEvent = function () {
      window.dispatchEvent(new CustomEvent('urlchange'));
    };
    window.addEventListener('popstate', dispatchUrlChangeEvent);
    const originalPushState = history.pushState;
    history.pushState = function (...args) {
      originalPushState.apply(this, args);
      dispatchUrlChangeEvent();
    };
    const originalReplaceState = history.replaceState;
    history.replaceState = function (...args) {
      originalReplaceState.apply(this, args);
      dispatchUrlChangeEvent();
    };
  }
}

// libs/share/src/ui/Observer.ts
const Observer = class {
  start(element, callback, options) {
    this.stop();
    this.observer = new MutationObserver(callback);
    this.observer.observe(
      element,
      options || {
        attributeOldValue: true,
        attributes: true,
        characterData: true,
        characterDataOldValue: true,
        childList: true,
        subtree: true,
      }
    );
  }

  stop() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
};

// apps/facebook-cleaner/src/dictionary/locales.ts
const locales = {
  id: {
    hiddenPost: 'Postingan tersembunyi',
    feed: 'Postingan Kabar Beranda',
    follow: 'Ikuti',
    join: 'Gabung',
    reels: 'Reels',
    sponsored: 'Bersponsor',
  },
  cs: {
    hiddenPost: 'Skryt\xE9 p\u0159\xEDsp\u011Bvky',
    feed: 'P\u0159\xEDsp\u011Bvku v kan\xE1lu vybran\xFDch p\u0159\xEDsp\u011Bvk\u016F',
    follow: 'Sledovat',
    join: 'P\u0159idat se',
    reels: 'Reels',
    sponsored: 'Sponzorov\xE1no',
  },
  de: {
    hiddenPost: 'Versteckte Beitr\xE4ge',
    feed: 'News Feed-Beitr\xE4ge',
    follow: 'Folgen',
    join: 'Beitreten',
    reels: 'Reels',
    sponsored: 'Anzeige',
  },
  en: {
    hiddenPost: 'Hidden posts',
    feed: 'News Feed posts',
    follow: 'Follow',
    join: 'Join',
    reels: 'Reels',
    sponsored: 'Sponsored',
  },
  es: {
    hiddenPost: 'Publicaciones ocultas',
    feed: 'Publicaciones de la secci\xF3n de noticias',
    follow: 'Seguir',
    join: 'Unirte',
    reels: 'Reels',
    sponsored: 'Publicidad',
  },
  fr: {
    hiddenPost: 'Messages masqu\xE9s',
    feed: 'Nouvelles publications du fil d\u2019actualit\xE9',
    follow: 'Suivre',
    join: 'Rejoindre',
    reels: 'Reels',
    sponsored: 'Sponsoris\xE9',
  },
  it: {
    hiddenPost: 'Post nascosti',
    feed: 'Post della sezione Notizie',
    follow: 'Segui',
    join: 'Iscriviti',
    reels: 'Reels',
    sponsored: 'Sponsorizzato',
  },
  pl: {
    hiddenPost: 'Ukryte posty',
    feed: 'Posty w Aktualno\u015Bciach',
    follow: 'Obserwuj',
    join: 'Do\u0142\u0105cz',
    reels: 'Rolki',
    sponsored: 'Sponsorowane',
  },
  pt: {
    hiddenPost: 'Postagens ocultas',
    feed: 'Publica\xE7\xF5es do Feed de Not\xEDcias',
    follow: 'Seguir',
    join: 'Participar',
    reels: 'Reels',
    sponsored: 'Patrocinado',
  },
  sk: {
    hiddenPost: 'Skryt\xE9 pr\xEDspevky',
    feed: 'Pr\xEDspevky v Novink\xE1ch',
    follow: 'Sledova\u0165',
    join: 'Prida\u0165 sa',
    reels: 'Reels',
    sponsored: 'Sponzorovan\xE9',
  },
  sl: {
    hiddenPost: 'Skrite objave',
    feed: 'Objave v viru novic',
    follow: 'Sledi',
    join: 'Pridru\u017Ei se',
    reels: 'Interaktivni videi',
    sponsored: 'Sponzorirano',
  },
  szl: {
    hiddenPost: 'Skryte posty',
    feed: 'Posty w Aktualno\u015Bciach',
    follow: 'Obserwuj',
    join: 'Do\u0142\u0105cz',
    reels: 'Rolki',
    sponsored: 'Szp\u014Dnzorowane',
  },
  tr: {
    hiddenPost: 'Gizli g\xF6nderiler',
    feed: 'Haber Kayna\u011F\u0131 g\xF6nderileri',
    follow: 'Takip Et',
    join: 'Kat\u0131l',
    reels: 'Reels',
    sponsored: 'Sponsorlu',
  },
  uk: {
    hiddenPost:
      '\u041F\u0440\u0438\u0445\u043E\u0432\u0430\u043D\u0456 \u043F\u043E\u0441\u0442\u0438/Prykhovani posty',
    feed: '\u0414\u043E\u043F\u0438\u0441\u0438 \u0437\u0456 \u0441\u0442\u0440\u0456\u0447\u043A\u0438 \u043D\u043E\u0432\u0438\u043D',
    follow: '\u0421\u0442\u0435\u0436\u0438\u0442\u0438',
    join: '\u041F\u0440\u0438\u0454\u0434\u043D\u0430\u0442\u0438\u0441\u044F',
    reels: '\u0412\u0456\u0434\u0435\u043E Reels',
    sponsored: '\u0420\u0435\u043A\u043B\u0430\u043C\u0430',
  },
  'zh-Hans': {
    hiddenPost: '\u9690\u85CF\u5E16\u5B50',
    feed: '\u52A8\u6001\u6D88\u606F\u5E16\u5B50',
    follow: '\u5173\u6CE8',
    join: '\u52A0\u5165',
    reels: 'Reels',
    sponsored: '\u8D5E\u52A9\u5185\u5BB9',
  },
  'zh-Hant': {
    hiddenPost: '\u96B1\u85CF\u8CBC\u6587',
    feed: '\u52D5\u614B\u6D88\u606F\u5E16\u5B50',
    follow: '\u8FFD\u8E64',
    join: '\u52A0\u5165',
    reels: 'Reels',
    sponsored: '\u8D0A\u52A9',
  },
};
const languages = Object.keys(locales);

// apps/facebook-cleaner/src/dictionary/Dictionary.ts
const Dictionary = class {
  constructor() {
    this.lang = this.detectLanguage();
    this.dictionary = this.getDictionary();
  }

  getFeedLabel() {
    return this.dictionary.feed;
  }

  getFollowLabel() {
    return this.dictionary.follow;
  }

  getJoinLabel() {
    return this.dictionary.join;
  }

  getReelsLabel() {
    return this.dictionary.reels;
  }

  getSponsoredLabel() {
    return this.dictionary.sponsored;
  }

  hiddenPostLabel(count) {
    return `${this.dictionary.hiddenPost} (${count})`;
  }

  detectLanguage() {
    const pageLang = window.document.documentElement.lang;
    if (pageLang && languages.includes(pageLang)) {
      return pageLang;
    }
    return void 0;
  }

  getDictionary() {
    if (this.lang) {
      return locales[this.lang];
    }
    return void 0;
  }
};

// apps/facebook-cleaner/src/services/ElementDetector.ts
const ElementDetector = class {
  constructor() {
    this.dictionary = new Dictionary();
  }

  getElement(root, query, text) {
    return this.getElements(root, query, text)[0];
  }

  getElements(root, query, text) {
    return [...root.querySelectorAll(query)].filter((element) => {
      if (!text) {
        return true;
      }
      return element.textContent.includes(text);
    });
  }

  getFeedElement() {
    const [feedHeader] = this.getElements(
      document,
      'h3.html-h3',
      this.dictionary.getFeedLabel()
    );
    if (!feedHeader) {
      return void 0;
    }
    return feedHeader.parentElement.lastElementChild;
  }
};

// apps/facebook-cleaner/src/services/UserSettings.ts
const settingsMenuLabels = {
  ['fcc-hide-reels' /* HideReels */]: 'reels',
  ['fcc-hide-sponsored' /* HideSponsored */]: 'sponsored posts',
  ['fcc-hide-suggested-groups' /* HideSuggestedGroups */]: 'suggested groups',
  ['fcc-hide-suggested-profiles' /* HideSuggestedProfiles */]:
    'suggested profiles',
};
const UserSettings = class {
  constructor() {
    this.setting = {
      ['fcc-hide-reels' /* HideReels */]: true,
      ['fcc-hide-sponsored' /* HideSponsored */]: true,
      ['fcc-hide-suggested-groups' /* HideSuggestedGroups */]: true,
      ['fcc-hide-suggested-profiles' /* HideSuggestedProfiles */]: true,
    };
    this.setting = this.readSettings();
    this.updateMenu();
  }

  getSettings() {
    return { ...this.setting };
  }

  readSettings() {
    return Object.fromEntries(
      Object.entries(this.setting).map(([key, defaultValue]) => [
        key,
        GM_getValue(key, defaultValue),
      ])
    );
  }

  setSettingValue(id, value) {
    this.setting[id] = value;
    GM_setValue(id, value);
    this.updateMenu();
  }

  settingLabel(id) {
    return [
      this.setting[id] ? 'Show' : 'Hide',
      settingsMenuLabels[id],
      'in feed news',
    ].join(' ');
  }

  updateMenu() {
    Object.keys(this.setting).forEach((id) => GM_unregisterMenuCommand(id));
    Object.entries(this.setting).forEach(([id, value]) =>
      GM_registerMenuCommand(
        this.settingLabel(id),
        () => this.setSettingValue(id, !value),
        {
          id,
          autoClose: true,
        }
      )
    );
  }
};

// apps/facebook-cleaner/src/services/BannedPost.ts
const BannedPost = class {
  constructor() {
    this.detector = new ElementDetector();
    this.dictionary = new Dictionary();
  }

  filter(posts, settings) {
    return posts.filter((post) => {
      if (post.dataset.fcc) {
        return true;
      }
      const query = '[data-ad-rendering-role="profile_name"] [role="button"]';
      if (
        settings['fcc-hide-suggested-profiles' /* HideSuggestedProfiles */] &&
        this.detector.getElement(post, query, this.dictionary.getFollowLabel())
      ) {
        post.dataset.fccReason = 'follow' /* Follow */;
        return true;
      }
      if (
        settings['fcc-hide-suggested-groups' /* HideSuggestedGroups */] &&
        this.detector.getElement(post, query, this.dictionary.getJoinLabel())
      ) {
        post.dataset.fccReason = 'join' /* Join */;
        return true;
      }
      if (
        settings['fcc-hide-reels' /* HideReels */] &&
        this.detector.getElement(
          post,
          '[role="button"]',
          this.dictionary.getReelsLabel()
        )
      ) {
        post.dataset.fccReason = 'reels' /* Reels */;
        return true;
      }
      if (
        settings['fcc-hide-sponsored' /* HideSponsored */] &&
        this.detector.getElement(post, 'a[href*="ads/about"]')
      ) {
        post.dataset.fccReason = 'sponsored-link' /* SponsoredLink */;
        return true;
      }
      if (
        settings['fcc-hide-sponsored' /* HideSponsored */] &&
        this.detector.getElement(
          post,
          'a[attributionsrc] [aria-labelledby]',
          this.dictionary.getSponsoredLabel()
        )
      ) {
        post.dataset.fccReason = 'sponsored-label' /* SponsoredLabel */;
        return true;
      }
      const items = this.detector.getElements(
        post,
        'a[attributionsrc] [aria-labelledby]'
      );
      if (
        settings['fcc-hide-sponsored' /* HideSponsored */] &&
        items.some(this.isSponsoredElement.bind(this))
      ) {
        post.dataset.fccReason =
          'sponsored-hidden-label' /* SponsoredHiddenLabel */;
        return true;
      }
      return false;
    });
  }

  hide(post) {
    post.dataset.fcc = '@';
    post.dataset.fccType = '';
  }

  isSponsoredElement(element) {
    const sponsoredLabel = this.dictionary.getSponsoredLabel();
    const items = [...element.firstElementChild.children].filter((i) =>
      sponsoredLabel.includes(i.innerText)
    );
    if (items.length < sponsoredLabel.length) {
      return false;
    }
    const elementLabel = items
      .map((item) => {
        const styles = getComputedStyle(item);
        return {
          isVisible: styles.position !== 'absolute',
          order: Number(styles.order),
          text: item.innerText,
        };
      })
      .filter((item) => item.isVisible)
      .sort((a, b) => a.order - b.order)
      .map((item) => item.text)
      .join('');
    return elementLabel.includes(sponsoredLabel);
  }

  showHiddenPostGroups() {
    const hiddenPosts = document.querySelectorAll('[data-fcc="@"]');
    const hiddenPostsCount = (post) => {
      const nextPost = post.nextElementSibling;
      if (nextPost && nextPost.dataset.fcc) {
        return 1 + hiddenPostsCount(nextPost);
      }
      return 0;
    };
    [...hiddenPosts].forEach((post) => {
      const prevPost = post.previousElementSibling;
      if (!prevPost || (prevPost && !prevPost.dataset.fcc)) {
        const count = 1 + hiddenPostsCount(post);
        post.dataset.fccType = '@';
        post.title = this.dictionary.hiddenPostLabel(count);
      }
    });
  }
};

// apps/facebook-cleaner/src/services/FeedCleaner.ts
const FeedCleaner = class {
  constructor() {
    this.bannedPostDetector = new BannedPost();
    this.settings = new UserSettings();
  }

  cleanFeed(feedElement) {
    const posts = [...feedElement.children];
    const bannedPosts = this.bannedPostDetector.filter(
      posts,
      this.settings.getSettings()
    );
    bannedPosts.forEach((post) => {
      this.bannedPostDetector.hide(post);
    });
    this.bannedPostDetector.showHiddenPostGroups();
  }
};

// apps/facebook-cleaner/src/services/FacebookCleaner.ts
const FacebookCleaner = class {
  constructor() {
    this.detector = new ElementDetector();
    this.feedCleaner = new FeedCleaner();
    this.feedElement = void 0;
    this.observer = new Observer();
    this.initEvents();
  }

  run() {
    this.initFeedElement();
    this.initObserver();
    this.feedListUpdated();
  }

  feedListUpdated() {
    if (this.isValidElement(this.feedElement)) {
      this.feedCleaner.cleanFeed(this.feedElement);
    }
  }

  initEvents() {
    window.addEventListener('urlchange', () => {
      this.run();
      window.setTimeout(this.run.bind(this), 2e3);
      window.setTimeout(this.run.bind(this), 5e3);
    });
    window.setInterval(this.run.bind(this), 20 * 1e3);
  }

  initFeedElement() {
    if (!this.isValidElement(this.feedElement)) {
      this.feedElement = this.detector.getFeedElement();
    }
  }

  initObserver() {
    if (!this.isValidElement(this.feedElement)) {
      return this.observer.stop();
    }
    if (!this.feedElement.dataset.fccReady) {
      this.feedElement.dataset.fccReady = '1';
      this.observer.start(this.feedElement, this.feedListUpdated.bind(this), {
        childList: true,
        subtree: true,
      });
    }
  }

  isValidElement(element) {
    return element && element.isConnected;
  }
};

// apps/facebook-cleaner/src/main.ts
activateUrlChangeEvents();
const isDebug = false;
GlobalStyle.addStyle(
  'fcc-style',
  isDebug ? style_debug_default : style_default
);
const service = new FacebookCleaner();
service.run();