// ==UserScript==
// @name Tumblr Savior
// @namespace bjornstar
// @description Saves you from ever having to see another post about certain things ever again (idea by bjornstar, rewritten by Vindicar).
// @version 3.1.4
// @require https://greasyfork.org/scripts/1884-gm-config/code/GM_config.js?version=4836
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @include http://www.tumblr.com/*
// @include https://www.tumblr.com/*
// ==/UserScript==
(function(){
'use strict';
//preparing the config file
//If we have no access to Greasemonkey methods, we will need dummy replacements
if (typeof GM_getValue !== 'function') GM_getValue = function (target, deflt) { return deflt; };
// >>> YOU CAN SPECIFY DEFAULT VALUES BELOW <<<
var cfg = {
//posts matching black list will be hidden completely
blacklist : parseList(GM_getValue('blacklist', '')),
//posts matching gray list will be hidden under spoiler and can be revealed with a single click
graylist : parseList(GM_getValue('graylist', '')),
//posts matching white list will never be affected by black or gray lists.
whitelist : parseList(GM_getValue('whitelist', '')),
//which action to take against sponsored posts
sponsored_action : GM_getValue('sponsored', '0'),
//which action to take against recommended posts
recommended_action : GM_getValue('recommended', '0'),
//if set to true, black and white lists will affect notifications and post notes as well.
process_notifications_and_notes : GM_getValue('notes', false),
//if true, script will search post HTML for triggerwords instead of it's visible text content
search_html : GM_getValue('inhtml', false),
// settings below this point are internal and have no GUI
//if post removal should simply hide it
soft_removal : true,
post_selector : 'li.post_container:not(#new_post_buttons)',
notification_selector : 'li.notification',
note_selector : '.notes li.note',
post_body_selector : '.post_body',
//constants for the sake of code simplicity
actions : {
PROCESS : '0',
WHITELIST : '1',
HIDE : '2',
SPOILER : '3',
REMOVE : '4',
},
};
//=======================================================================
//Main Tumblr Saviour object (maybe it's a god-object antipattern, I don't care)
//=======================================================================
var TumblrSaviour = {
config : cfg, //configuration object from above
//helper function that looks up keywords from supplied array in a string and returns array of found keywords
findKeywords : function (data, list) {
var result = [];
for (var i = 0; i < list.length; i++)
if (data.indexOf(list[i]) >= 0)
result.push(list[i]);
return result;
},
//helper function that strips specified attributes from the root element and all its descendants
stripAttrs : function (root, attrs) {
//make sure we got an array
if (typeof attrs == 'undefined')
attrs = [];
else if (typeof attrs == 'string')
attrs = [attrs];
for (var a=0; a<attrs.length; a++) {
//stripping the node itself
if (root.hasAttribute(attrs[a]))
root.removeAttribute(attrs[a]);
//finding all descendants that have this attribute
var nodes = root.querySelectorAll('*['+attrs[a]+']');
//and stripping them all
for (var i=0; i<nodes.length; i++)
nodes[i].removeAttribute(attrs[a]);
}
},
//helper function that removes all matching descendants of the root element
stripNodes : function (root, items) {
if (typeof items == 'string')
items = [items];
for (var i=0; i<items.length; i++) {
var nodes = root.querySelectorAll(items[i]);
for (var j=0; j<nodes.length; j++)
nodes[j].parentNode.removeChild(nodes[j]);
}
},
//converts a post element into string for keyword lookup
extractPostData : function (post) {
var data = '';
var clone = post.cloneNode(true);
this.stripNodes(clone, ['script', '.post_footer']);
if (this.config.search_html) {
//these attributes may have fragments of text from blog description, which can lead to false positives
this.stripAttrs(clone, ['data-tumblelog-popover', 'data-json']);
data = clone.innerHTML;
} else {
recoursiveWalk(clone, function(el) {
if (el.nodeType == el.TEXT_NODE)
data += ' '+el.nodeValue;
});
}
data = data.toLowerCase();
return data;
},
//converts a notification element into string for keyword lookup
extractNotificationData : function (notification) {
return this.extractPostData(notification);
},
//converts a note element into string for keyword lookup
extractNoteData : function (note) {
return this.extractPostData(note);
},
//post and notification processing routines - they do actual work of hiding/removing posts
//returns a previous non-blacklisted post element or null
getPreviousPost : function (post) {
var prev = post.previousSibling;
while ( (prev !== null) && ( (prev.nodeType != 1) || (prev.querySelector('.post:not(.new_post)') === null) ) )
prev = prev.previousSibling;
return prev;
},
//returns post author name or empty string
getPostAuthor : function (post) {
if (post === null) return '';
var actual_post = post.querySelector('.post');
if (actual_post !== null)
return actual_post.getAttribute('data-tumblelog');
else
return '';
},
//if there are several posts from the same author in a row, all but first will have "same_user_as_last" class applied.
//back in the day such posts had their author icon hidden
//currently it seems to be unused, but we will adjust the class nonetheless
adjustPost : function (post) {
var actual_post = post.querySelector('.post');
if (actual_post === null) return; //is it even a post?
var prev = this.getPreviousPost(post); //look up the previous one
if (prev === null) {
//we're dealing with the first visible post on dashboard - just make sure it has no "same user" class applied
actual_post.className = actual_post.className.replace(/\bsame_user_as_last\b/, '');
} else {
//there is a previous post - let's check the authors
if (this.getPostAuthor(post) == this.getPostAuthor(prev))
//same author - setting the class
actual_post.className += ' same_user_as_last';
else
//different authors - removing the class
actual_post.className = actual_post.className.replace(/\bsame_user_as_last\b/, '');
}
},
//for those who didn't trigger any list
ignorePost : function (post, reason) {
post.setAttribute('data-tumblr-saviour-status', 'unaffected');
post.setAttribute('data-tumblr-saviour-reason', reason);
this.adjustPost(post);
},
ignoreNotification : function (notification, reason) {
notification.setAttribute('data-tumblr-saviour-status', 'unaffected');
notification.setAttribute('data-tumblr-saviour-reason', reason);
},
//for those that triggered whitelist
whiteListPost : function (post, reason) {
post.setAttribute('data-tumblr-saviour-status', 'whitelisted');
post.setAttribute('data-tumblr-saviour-reason', reason);
this.adjustPost(post);
},
whiteListNotification : function (notification, reason) {
notification.setAttribute('data-tumblr-saviour-status', 'whitelisted');
notification.setAttribute('data-tumblr-saviour-reason', reason);
},
whiteListNote : function (note, reason) {
note.setAttribute('data-tumblr-saviour-status', 'whitelisted');
note.setAttribute('data-tumblr-saviour-reason', reason);
},
//for those that triggered graylist
hidePostSpoiler : function (post, reason) {
post.setAttribute('data-tumblr-saviour-status', 'graylisted');
post.setAttribute('data-tumblr-saviour-reason', reason);
var content = post.querySelector('.post_content_inner');
var contentstyle = content.style.display;
content.style.display = 'none';
if (!content) return;
var placeholder = document.createElement('div');
placeholder.className = 'tumblr_saviour_placeholder';
placeholder.innerHTML = '<span>You have been saved from this post because of: '+reason+'. </span>';
var trigger = document.createElement('span');
trigger.innerHTML = '[<span class="tumblr_saviour_trigger">Show</span>]';
placeholder.appendChild(trigger);
content.parentNode.insertBefore(placeholder, content);
trigger.addEventListener('click', function(e) {
e.preventDefault();
content.style.display = contentstyle;
placeholder.style.display = 'none';
placeholder.parentNode.removeChild(placeholder);
});
this.adjustPost(post);
},
//for those that triggered blacklist
hidePost : function (post, reason) {
//soft removal - just hiding the post
post.setAttribute('data-tumblr-saviour-status', 'blacklisted');
post.setAttribute('data-tumblr-saviour-reason', reason);
post.style.display = 'none';
//we have to strip it of "post" class to ensure that keyboard navigation won't see it
var actual_post = post.querySelector('.post');
if (actual_post !== null)
actual_post.className = actual_post.className.replace(/\bpost\b/, '');
//we should tell Tumblr to update it's keyboard navigation, if possible
checkIfExists('Tumblr.KeyCommands.update_post_positions', function (update_post_positions) {
try {
update_post_positions();
} catch (e) {
//we ignore any errors that might have happened
}
});
},
removePost : function (post, reason) {
post.parentNode.removeChild(post);
//we should tell Tumblr to update it's keyboard navigation, if possible
checkIfExists('Tumblr.KeyCommands.update_post_positions', function (update_post_positions) {
try {
update_post_positions();
} catch (e) {
//we ignore any errors that might have happened
}
});
},
hideNotification : function (notification, reason) {
notification.setAttribute('data-tumblr-saviour-status', 'blacklisted');
notification.setAttribute('data-tumblr-saviour-reason', reason);
notification.style.display = 'none';
},
removeNotification : function (notification, reason) {
notification.parentNode.removeChild(notification);
},
removeNote : function (note, reason) {
if (this.config.soft_removal) {
note.setAttribute('data-tumblr-saviour-status', 'blacklisted');
note.setAttribute('data-tumblr-saviour-reason', reason);
note.style.display = 'none';
} else {
note.parentNode.removeChild(note);
}
},
//post and notification analysis routines - in case Tumblr changes something
isMyPost : function (post) {
return (post.querySelector('.not_mine') === null);
},
isSponsoredPost : function (post) {
return (post.querySelector('.sponsored_post') !== null);
},
isSponsoredNotification : function (notification) {
return (notification.querySelector('.sponsor') !== null);
},
isRecommendedPost : function (post) {
return (post.querySelector('.is_recommended') !== null) || (post.querySelector('.recommendation-reason-footer') !== null);
},
isRecommendedNotification : function (notification) {
return checkSelectorMatch(notification,'.takeover-container');
},
//main post analysis routine
analyzePost : function (post) {
if (this.isMyPost(post)) {
//user's own posts are always whitelisted
this.whiteListPost(post, 'my post');
return;
}
//check if it's a sponsored post
if (this.isSponsoredPost(post))
switch (this.config.sponsored_action){
case this.config.actions.WHITELIST: {
this.whiteListPost(post,'sponsored post');
return;
}; break;
case this.config.actions.HIDE: {
this.hidePost(post,'sponsored post');
return;
}; break;
case this.config.actions.REMOVE: {
this.removePost(post,'sponsored post');
return;
}; break;
case this.config.actions.SPOILER: {
this.hidePostSpoiler(post,'sponsored post');
return;
}; break;
default: break;
}
//check if it's a recommended post
if (this.isRecommendedPost(post))
switch (this.config.recommended_action){
case this.config.actions.WHITELIST: {
this.whiteListPost(post,'recommended post');
return;
}; break;
case this.config.actions.HIDE: {
this.hidePost(post,'recommended post');
return;
}; break;
case this.config.actions.REMOVE: {
this.removePost(post,'recommended post');
return;
}; break;
case this.config.actions.SPOILER: {
this.hidePostSpoiler(post,'recommended post');
return;
}; break;
default: break;
}
//white list takes priority
var data = this.extractPostData(post);
var keywords;
keywords = this.findKeywords(data, this.config.whitelist);
if (keywords.length) {
this.whiteListPost(post, keywords.join(';'));
return;
}
//black list
keywords = this.findKeywords(data, this.config.blacklist);
if (keywords.length) {
this.hidePost(post, keywords.join(';'));
return;
}
//check the gray list
keywords = this.findKeywords(data, this.config.graylist);
if (keywords.length) {
this.hidePostSpoiler(post, keywords.join(';'));
return;
}
//if nothing triggered, we mark post as such
this.ignorePost(post, '');
},
//main notification analysis routine
analyzeNotification : function (notification) {
if (this.config.process_notifications_and_notes) {
var data = this.extractNotificationData(notification);
var keywords;
keywords = this.findKeywords(data, this.config.whitelist);
if (keywords.length) {
this.whiteListNotification(notification, keywords.join(';'));
return;
}
keywords = this.findKeywords(data, this.config.blacklist);
if (keywords.length) {
this.hideNotification(notification, keywords.join(';'));
return;
}
if (this.isSponsoredNotification(notification))
switch (this.config.sponsored_action){
case this.config.actions.WHITELIST: {
this.whiteListNotification(notification,'sponsored notification');
return;
}; break;
case this.config.actions.SPOILER:
case this.config.actions.HIDE: {
this.hideNotification(notification,'sponsored notification');
return;
}; break;
case this.config.actions.REMOVE: {
this.removeNotification(notification,'sponsored notification');
return;
}; break;
default: break;
}
if (this.isRecommendedNotification(notification))
switch (this.config.recommended_action){
case this.config.actions.WHITELIST: {
this.whiteListNotification(notification,'recommended notification');
return;
}; break;
case this.config.actions.SPOILER:
case this.config.actions.HIDE: {
this.hideNotification(notification,'recommended notification');
return;
}; break;
case this.config.actions.REMOVE: {
this.removeNotification(notification,'recommended notification');
return;
}; break;
default: break;
}
}
this.ignoreNotification(notification,'');
},
//main note analysis routine
analyzeNote : function (note) {
if (this.config.process_notifications_and_notes) {
var data = this.extractNoteData(note);
var keywords;
keywords = this.findKeywords(data, this.config.whitelist);
if (keywords.length) {
this.whiteListNote(note, keywords.join(';'));
return;
}
keywords = this.findKeywords(data, this.config.blacklist);
if (keywords.length) {
this.removeNote(note, keywords.join(';'));
return;
}
}
},
};
//=======================================================================
//Function definitions (don't worry, JS will lift them to the beginning of the block)
//=======================================================================
//iterate through the node's descendants
function recoursiveWalk(element, fn) {
if (!fn(element) && (element.nodeType == element.ELEMENT_NODE))
for (var i=0; i<element.childNodes.length; i++)
recoursiveWalk(element.childNodes[i], fn);
}
//parsing semicolon-separated lists into sorted arrays
function parseList(list) {
var lst = list.split(';');
var res = [];
for (var i=lst.length-1;i>=0;i--) {
if (lst[i].trim().length>0)
res.push(lst[i].toLowerCase());
}
res.sort();
return res;
}
//helper function that checks if specified object hierarchy exists in the page scope and returns boolean flag/runs a callback if it does.
function checkIfExists(objects, callback) {
if (typeof objects === 'string')
objects = objects.split('.');
var obj = unsafeWindow;
for (var index = 0; index<objects.length; index++) {
if (typeof obj[objects[index]] === 'undefined')
return false;
else
obj = obj[objects[index]];
}
if (typeof callback !== 'undefined')
callback(obj);
return true;
}
//helper function to determine if specified node matches specified selector
function checkSelectorMatch(node, selector) {
if (typeof node[checkSelectorMatch.method] == 'function') //not all nodes have required methods
return node[checkSelectorMatch.method](selector);
else //in that case, we simply assume it doesn't match
return false;
}
//determining matching method supported by the browser
checkSelectorMatch.method = (function(){
var methods = ['matches', 'matchesSelector', 'mozMatchesSelector', 'webkitMatchesSelector'];
for (var i=0; i<methods.length; i++)
if (typeof Element.prototype[methods[i]] == 'function')
return methods[i]; //match found, remember it for future use
throw "No way to match selector found."; //no match - we have to fail miserably.
})();
//waits for a node specified by selector to appear/disappear
function waitForSelector(selector, must_exist, callback, root) {
if (typeof root == 'undefined') //we search the whole document unless told otherwise
root = document;
//we check if the node has been added/removed already
var prequery = root.querySelector(selector);
if ( (prequery !== null) == must_exist ) {
callback(prequery);
return;
}
//it hasn't - we set up MutationObserver on root element to find it
var mutation_callback = function(mutations) {
//checking the list of mutations
for (var i=0; i<mutations.length; i++) {
//make sure the event is of correct type
if (mutations[i].type == 'childList')
if (must_exist) { //we're waiting for the node to appear, so we look for added nodes that match our selector
for (var j=0; j<mutations[i].addedNodes.length; j++)
if (checkSelectorMatch(mutations[i].addedNodes[j], selector))
try {
callback(mutations[i].addedNodes[j]);
}
finally {
mutation_callback.docobserver.disconnect();
delete mutation_callback.docobserver;
return;
}
} else { //we're waiting for the node to disappear, so we look for removed nodes that match our selector
for (var j=0; j<mutations[i].removedNodes.length; j++)
if (checkSelectorMatch(mutations[i].removedNodes[j], selector))
try {
callback(mutations[i].removedNodes[j]);
}
finally {
mutation_callback.docobserver.disconnect();
delete mutation_callback.docobserver;
return;
}
}
}
};
mutation_callback.docobserver = new MutationObserver(mutation_callback);
mutation_callback.docobserver.observe(root, {
attributes: false,
childList: true,
characterData: false,
subtree: true,
});
}
//=======================================================================
//Main script
//=======================================================================
//we prepare the DOM observers
//observer for any new posts coming up
var new_post_observer = new MutationObserver(function(mutations){
for (var i=0; i<mutations.length; i++) { //looking through mutations list
if (mutations[i].type == 'childList')
for (var j = 0; j<mutations[i].addedNodes.length; j++) { //only checking additions
//is it a post?
if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.post_selector)) {
TumblrSaviour.analyzePost.call(TumblrSaviour, mutations[i].addedNodes[j]);
post_update_observer.observe(mutations[i].addedNodes[j], post_update_observer_config);
}
//is it a notification?
else if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.notification_selector))
TumblrSaviour.analyzeNotification.call(TumblrSaviour, mutations[i].addedNodes[j]);
}
}
});
//configuration: interested only in immediates descendants being added/removed
var new_post_observer_config = {
attributes: false,
childList: true,
characterData: false,
subtree: false,
};
//some post don't have post body initially - we have to schedule a check later.
var post_update_observer = new MutationObserver(function(mutations){
for (var i=0; i<mutations.length; i++) //looking through mutations list
for (var j=0; j<mutations[i].addedNodes.length; j++)
if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.post_body_selector)) {
//looking for post node containing this body
var node = mutations[i].addedNodes[j];
while ((node !== null) && !checkSelectorMatch(node, TumblrSaviour.config.post_selector))
node = node.parentNode;
if (node !== null) //post node found
TumblrSaviour.analyzePost.call(TumblrSaviour, node);
}
});
//configuration: interested in any changes to DOM tree
var post_update_observer_config = {
attributes: false,
childList: true,
characterData: false,
subtree: true,
};
//we wait for #posts to appear in the DOM tree
waitForSelector('#posts', true, function(posts){
//we immediately set an observer on it, so we can catch not-yet-loaded posts, as well as ones added dynamically by the paginator
new_post_observer.observe(posts, new_post_observer_config);
//then we check for items already loaded
var notifylist = posts.querySelectorAll(TumblrSaviour.config.notification_selector);
for (var i=0; i<notifylist.length; i++)
TumblrSaviour.analyzeNotification.call(TumblrSaviour, notifylist[i]);
var postlist = posts.querySelectorAll(TumblrSaviour.config.post_selector);
for (var i=0; i<postlist.length; i++)
//some posts don't initially have a body
if (postlist[i].querySelector(TumblrSaviour.config.post_body_selector) !== null)
//if they do, we check them immediately
TumblrSaviour.analyzePost.call(TumblrSaviour, postlist[i]);
else
//if they don't, we observe them so they will get checked once it appears
post_update_observer.observe(postlist[i], post_update_observer_config);
});
//if we want to filter post notes, we will have to get our hands dirty
if (TumblrSaviour.config.process_notifications_and_notes) {
//once document is loaded and Tumblr scripts have been set up, we set a hook to catch the moment post notes are being loaded.
window.addEventListener("load", function() {
//we remember old function that handles notes loading
var old_load_notes = unsafeWindow.Tumblr.Notes.prototype.load_notes;
//and replace it with ours
unsafeWindow.Tumblr.Notes.prototype.load_notes = exportFunction(function($post,options,fn){
//the idea is to allow Tumblr engine to load notes...
old_load_notes.call(this, $post, options, exportFunction(function(data){
//...and render those notes...
var res = fn(data);
//...but also to filter them immediately afterwards
var notes = $post[0].querySelectorAll(TumblrSaviour.config.note_selector);
for (var i=0; i<notes.length; i++)
TumblrSaviour.analyzeNote.call(TumblrSaviour, notes[i]);
return res;
}, unsafeWindow));
}, unsafeWindow);
});
}
//we set up the configuration panel if possible
if ( (typeof GM_config !== 'undefined') && (typeof GM_setValue === 'function') && (typeof GM_registerMenuCommand === 'function') ) {
var fields = {
"blacklist" : {
"label" : "Blacklisted words",
"title" : "Semicolon-separated list of words that will cause the post to disappear.",
"type" : "text",
"default" : GM_getValue('blacklist', ''),
},
"graylist" : {
"label" : "Graylisted words",
"title" : "Semicolon-separated list of words that will cause the post content to be hidden under spoiler.",
"type" : "text",
"default" : GM_getValue('graylist', ''),
},
"whitelist" : {
"label" : "Whitelisted words",
"title" : "Semicolon-separated list of words that will prevent post from being hidden for any reason. Your own posts are always whitelisted.",
"type" : "text",
"default" : GM_getValue('whitelist', ''),
},
"sponsored" : {
"label" : "Action for sponsored posts",
"title" : "If set to anything but 'process like any other post', this setting overrides the effect of lists above.",
"type" : "select",
"options" : {
"0" : "process like any other post",
"1" : "whitelist post",
"2" : "blacklist post",
"3" : "hide post under spoiler",
"4" : "remove from the page",
},
"default" : GM_getValue('sponsored', '0'),
},
"recommended" : {
"label" : "Action for recommended posts",
"title" : "If set to anything but 'process like any other post', this setting overrides the effect of lists above.",
"type" : "select",
"options" : {
"0" : "process like any other post",
"1" : "whitelist post",
"2" : "blacklist post",
"3" : "hide post under spoiler",
"4" : "remove from the page",
},
"default" : GM_getValue('recommended', '0'),
},
"notes" : {
"label" : "Process notifications and notes as well",
"type" : "checkbox",
"default" : !!GM_getValue('notes', 0),
},
"inhtml" : {
"label" : "Check HTML code of the post instead of its text",
"type" : "checkbox",
"default" : !!GM_getValue('inhtml', 0),
},
save: function() {
GM_config.values['blacklist'] = parseList(GM_config.values['blacklist']).join(";");
GM_config.values['graylist'] = parseList(GM_config.values['graylist']).join(";");
GM_config.values['whitelist'] = parseList(GM_config.values['whitelist']).join(";");
for (var key in GM_config.values)
GM_setValue(key,GM_config.values[key]);
},
};
var CSS = [
'.section_header,.reset_holder { display: none !important; }',
'body {background-color: #FFF;}',
'* {font-family: "Helvetica Neue","HelveticaNeue",Helvetica,Arial,sans-serif; color: #444;}',
'#header {border-bottom: 2px solid #E5E5E5; font-size: 24px; font-weight: normal; line-height: 1; margin: 0px; padding-bottom: 28px;}',
'.config_var {padding: 2px 0px 2px 200px;}',
'.config_var>* {vertical-align:middle;}',
'.config_var .field_label {font-size: 14px !important;line-height: 1.2; display:inline-block; width:200px; margin: 0 0 0 -200px;}',
'#field_blacklist,#field_graylist,#field_whitelist {width: 100%}',
'button {padding: 4px 7px 5px; font-weight: 700; border-width: 1px; border-style: solid; text-decoration: none; border-radius: 2px; cursor: pointer; display: inline-block; height: 30px; line-height: 20px;}',
'#saveBtn {color: #FFF; border-color: #529ECC; background: #529ECC none repeat scroll 0% 0%;}',
'#cancelBtn {color: #FFF; border-color: #9DA6AF; background: #9DA6AF none repeat scroll 0% 0%;}',
""].join("\n");
GM_addStyle([
'#GM_config {border-radius: 3px !important; border: 0px none !important;}',
'.tumblr_saviour_placeholder { display: block; padding: 20px;}',
'.tumblr_saviour_trigger { cursor: pointer !important; text-decoration: underline !important; }',
""].join("\n"));
GM_config.init("Tumblr Saviour Settings", fields, CSS);
GM_registerMenuCommand("Tumblr Saviour Settings", function() {GM_config.open();});
}
})();