// ==UserScript==
// @name Nextdoor.com - Hide annoying content in the News Feed
// @namespace Lepricon
// @version 0.3.1
// @description This is a content filter that will hide annoying content in the Nextdoor News Feed and add an instant "Hide" button to all posts that are shown. Customize with enable-flags and title phrases. It seems like about 99% of the content on Nextdoor is garbage but that 1% of useful posts are worth digging through all the junk. This script helps improve the ratio of gold to garbage.
// @author Lepricon
// @match https://nextdoor.com/*
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-start
// jshint esversion: 6
// ==/UserScript==
(() => {
'use strict';
const getValue = (settingName, defaultValue) => {
let value = undefined;
if (window.GM_getValue) {
value = GM_getValue(settingName);
}
if (value === undefined) {
value = defaultValue;
if (window.GM_setValue) {
GM_setValue(settingName, value);
}
}
return value;
}
// --- Configurations start
const enableHidePaidAds = getValue("enableHidePaidAds", true);
const enableHideSponsoredAds = getValue("enableHideSponsoredAds", true);
const enableHideNewNeighborAnnouncements = getValue("enableHideNewNeighborAnnouncements", true);
const enableHideNonFreeClassifiedAds = getValue("enableHideNonFreeClassifiedAds", true);
const enableAddHideLinkToPosts = getValue("enableAddHideLinkToPosts", true);
// The following setting will hide any posts that have a matching tag set. Must be all lower-case.
const hideTaggedInterestNames = getValue("hideTaggedInterestNames", ["dogs", "cats", "yoga", "animal adoption"]);
// The following setting will hide any posts that have titles that contain any of these key phrases. Use array for multiple words in any order. Must be all lower-case.
const hideKeyPhrases = getValue("hideKeyPhrases", [["dog", "lost"], ["dog", "missing"], ["dog", "found"], ["dog", "loose"], ["dog", "walk"], ["cat", "lost"], ["cat", "missing"], ["cat", "found"], "coyote", "handyman", "plumber", "roommate", "electrician"]);
// The following setting will hide any posts with any of these author names. Name match exactly, including case. Useful for those sneaky "pay to read" news sites like San Diego Union-Tribune that otherwise look like legitimate posts.
const hideAuthorsNames = getValue("hideAuthorsNames", ["The San Diego Union-Tribune"]);
// Set the following setting true to send a request to the nextdoor server to hide posts permanently. Only applies to key phrases, authors, and individual "new neighbor announcements".
const enablePermanentHide = getValue("enablePermanentHide", false);
// --- Configurations end
const shouldHidePostItem = (postItem) => {
if (enableHideNonFreeClassifiedAds) {
// Single classified item
if (postItem.classified && (postItem.classified.price || "").toLowerCase() !== "free") {
return true;
}
// Multiple classified items
if (postItem.classified_items && postItem.classified_items.filter((classifiedItem) => (classifiedItem.price || "").toLowerCase() === "free").length === 0) { // count of free items
return true;
}
}
return false;
}
const shouldHideFeedItem = (feedItem) => {
if (enableHideNewNeighborAnnouncements) {
// Multiple people
if (feedItem.nmas && feedItem.nmas.find((nma) => nma.presentation_features && nma.presentation_features.welcome)) {
return true;
}
// Single person
if (feedItem.presentation_features && feedItem.presentation_features.welcome) {
return true;
}
}
if (hideAuthorsNames && hideAuthorsNames.length > 0) {
if (feedItem.author && feedItem.author.name) {
if (hideAuthorsNames.includes(feedItem.author.name)) {
return true;
}
}
}
if (hideTaggedInterestNames && hideTaggedInterestNames.length > 0) {
if (feedItem.tagged_interest && feedItem.tagged_interest.name) {
if (hideTaggedInterestNames.includes(feedItem.tagged_interest.name.toLowerCase())) {
return true;
}
}
}
if (hideKeyPhrases && hideKeyPhrases.length > 0) {
if (feedItem.subject) {
const subject = feedItem.subject.toLowerCase();
const keyPhraseStrings = hideKeyPhrases.filter((keyPhrase) => typeof keyPhrase === "string");
if (keyPhraseStrings.find((keyPhraseString) => subject.includes(keyPhraseString))) {
return true;
}
const keyPhraseArrays = hideKeyPhrases.filter((keyPhrase) => Array.isArray(keyPhrase));
if (keyPhraseArrays.find((keyPhraseArray) => keyPhraseArray.filter((keyPhrase) => subject.includes(keyPhrase)).length === keyPhraseArray.length)) {
return true;
}
}
}
return false;
}
// create XMLHttpRequest proxy object
var oldXMLHttpRequest = unsafeWindow.XMLHttpRequest;
// define constructor for an proxy object to intercept all AJAX traffic for this site so we can filter it
unsafeWindow.XMLHttpRequest = function() {
var actual = new oldXMLHttpRequest();
var self = this;
this.onreadystatechange = null;
// this is the actual handler on the real XMLHttpRequest object
actual.onreadystatechange = function() {
if (this.readyState == 4) {
// Intercept the responses
// actual.responseText is the ajax result
// Only apply to the actual news_feed. Email article links contain the base news_feed URL so we do not want to block those.
const isTargetPage = unsafeWindow.location.href === "https://nextdoor.com/news_feed/"
|| unsafeWindow.location.href === "https://nextdoor.com/news_feed/?"
|| unsafeWindow.location.href.includes("nextdoor.com/news_feed/?ordering=");
if (!actual.responseText) {
self.responseText = actual.responseText;
}
else {
try {
let changes = 0;
let responseJson;
if (isTargetPage) {
responseJson = JSON.parse(actual.responseText);
if (responseJson.posts && responseJson.posts.length > 0) {
for (let i = responseJson.posts.length - 1; i >= 0; i--) {
const postItem = responseJson.posts[i];
if (postItem && shouldHidePostItem(postItem)) {
responseJson.posts.splice(i, 1);
responseJson.feed_items.splice(i, 1);
changes++;
if (enablePermanentHide) {
if (postItem.classified && postItem.classified.id) {
const postId = postItem.classified.id;
setTimeout(() => {
try {
unsafeWindow.jQuery.post(`/api/classifieds/${postId}/hide`); // tell the server to hide this post
}
catch (error) {
console.log(`Post to /api/classifieds/${postId}/hide failed.`)
}
}, 250);
}
}
}
}
}
if (responseJson.feed_items && responseJson.feed_items.length > 0) {
for (let i = responseJson.feed_items.length - 1; i >= 0; i--) {
const feedItem = responseJson.feed_items[i];
if (feedItem && shouldHideFeedItem(feedItem)) {
responseJson.posts.splice(i, 1);
responseJson.feed_items.splice(i, 1);
changes++;
if (enablePermanentHide) {
if (feedItem.nmas) {
for (let j = 0; j < feedItem.nmas.length; j++) {
const nma = feedItem.nmas[j];
if (!nma) {
continue;
}
const postId = nma.id;
if (!postId) {
continue;
}
setTimeout(() => {
try {
unsafeWindow.jQuery.post("/ajax/confirm_mute_post/", `post_id=${postId}`); // tell the server to hide this post
}
catch (error) {
console.log(`Post to /ajax/confirm_mute_post/ for post_id=${postId} failed.`)
}
}, 250);
}
}
else if (feedItem.id) {
const postId = feedItem.paged_comments.post_id;
setTimeout(() => {
try {
unsafeWindow.jQuery.post("/ajax/confirm_mute_post/", `post_id=${postId}`); // tell the server to hide this post
}
catch (error) {
console.log(`Post to /ajax/confirm_mute_post/ for post_id=${postId} failed.`)
}
}, 250);
}
}
}
}
}
}
if (changes > 0) {
self.responseText = JSON.stringify(responseJson);
}
else {
self.responseText = actual.responseText;
}
setTimeout(() => {
if (enableAddHideLinkToPosts) {
addHideLinkToPosts();
}
// Do some imperceptible micro scrolling to load more data. Nextdoor looks for scrolling to determine if it should try to load more data. Will only load if the "Load More" area is visible at the bottom of the posts.
unsafeWindow.scrollBy(0, 1);
unsafeWindow.scrollBy(0, -1);
}, 100);
}
catch (error) {
self.responseText = actual.responseText; // Let the actual response pass through
}
}
}
if (self.onreadystatechange) {
return self.onreadystatechange();
}
};
// add all proxy getters
["status", "statusText", "responseType", "response",
"readyState", "responseXML", "upload"].forEach(function(item) {
Object.defineProperty(self, item, {
get: function() {return actual[item];}
});
});
// add all proxy getters/setters
["ontimeout, timeout", "withCredentials", "onload", "onerror", "onprogress"].forEach(function(item) {
Object.defineProperty(self, item, {
get: function() {return actual[item];},
set: function(val) {actual[item] = val;}
});
});
// add all pure proxy pass-through methods
["addEventListener", "send", "open", "abort", "getAllResponseHeaders",
"getResponseHeader", "overrideMimeType", "setRequestHeader"].forEach(function(item) {
Object.defineProperty(self, item, {
value: function() {return actual[item].apply(actual, arguments);}
});
});
}
let oldXHROpen = unsafeWindow.XMLHttpRequest.prototype.open;
unsafeWindow.XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
// do something with the method, url and etc.
this.addEventListener('load', function() {
// do something with the response text
this.responseText = "";
console.log('load: ' + this.responseText);
});
return oldXHROpen.apply(this, arguments);
}
const addHideLinkToPosts = () => {
const $ = unsafeWindow.jQuery; // Use jQuery object provided by the site
const allPosts = $("article.post-container, div.classifieds-single-item-content");
allPosts.each((index, el) => {
const post = $(el);
const existingHideLink = post.find("a.addon-hide-link");
if (existingHideLink.length > 0) {
return; // The ide link already exists
}
// Add a "Hide" link since it does not already exist
const caretMenu = post.find("div.story-caret-menu").first();
const hideLink = $('<span><a class="addon-hide-link" href="javascript:void(0)">Hide</a> </span>');
hideLink.click(() => {
if (post.hasClass("classifieds-single-item-content")) {
const postId = post.parent().attr("id").substring(2);
$.post(`/api/classifieds/${postId}/hide`); // tell the server to hide this post
}
else {
const postId = post.attr("id").substring(2);
$.post("/ajax/confirm_mute_post/", `post_id=${postId}`); // tell the server to hide this post
}
post.remove(); // hide the post now
});
hideLink.insertBefore(caretMenu);
});
}
// CSS styles to hide ads
let styleTag = " div.feed-container { min-height: 2000px !important; } ";
if (enableHidePaidAds) {
styleTag += " article.gam-ad-outer-container { display: none !important; } ";
}
if (enableHideSponsoredAds) {
styleTag += " div.ad-wrapper, div.programmatic-promo-container { display: none !important; } ";
}
if (styleTag) {
styleTag = `<style>${styleTag}</style>`;
const head = document.querySelector("head").innerHTML += styleTag;
}
})();