// ==UserScript==
// @name Bluesky Content Manager
// @namespace https://greasyfork.org/en/users/567951-stuart-saddler
// @version 2.4
// @description Enhance your Bluesky feed with advanced content filtering. Hides posts based on a customizable blocklist with case-insensitive and plural matching.
// @license MIT
// @match https://bsky.app/*
// @icon https://i.ibb.co/YySpmDk/Bluesky-Content-Manager.png
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect bsky.social
// @run-at document-idle
// ==/UserScript==
(async function () {
'use strict';
function shouldProcessPage() {
return window.location.pathname !== '/notifications';
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const CSS = `
.content-filtered {
display: none !important;
height: 0 !important;
overflow: hidden !important;
}
.bluesky-filter-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
z-index: 1000000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 300px;
max-width: 350px;
font-family: Arial, sans-serif;
color: #333;
}
.bluesky-filter-dialog h2 {
margin-top: 0;
color: #0079d3;
font-size: 1.5em;
font-weight: bold;
}
.bluesky-filter-dialog p {
font-size: 0.9em;
margin-bottom: 10px;
color: #555;
}
.bluesky-filter-dialog textarea {
width: calc(100% - 16px);
height: 150px;
padding: 8px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 4px;
font-family: monospace;
background: #f9f9f9;
color: #000;
}
.bluesky-filter-dialog .button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.bluesky-filter-dialog button {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
text-align: center;
}
.bluesky-filter-dialog .save-btn {
background-color: #0079d3;
color: white;
}
.bluesky-filter-dialog .cancel-btn {
background-color: #f2f2f2;
color: #333;
}
.bluesky-filter-dialog button:hover {
opacity: 0.9;
}
.bluesky-filter-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 999999;
}`;
if (typeof GM_addStyle !== 'undefined') {
GM_addStyle(CSS);
} else {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
}
const filteredTerms = JSON.parse(GM_getValue('filteredTerms', '[]')).map(t => t.trim().toLowerCase());
const processedPosts = new WeakSet();
let sessionToken = null;
const profileCache = new Map();
let blockedCount = 0;
let menuCommandId = null;
let observer = null;
function updateMenuCommand() {
if (menuCommandId) {
GM_unregisterMenuCommand(menuCommandId);
}
menuCommandId = GM_registerMenuCommand(`Configure blocklist (${blockedCount} blocked)`, showConfigUI);
}
function createConfigUI() {
const overlay = document.createElement('div');
overlay.className = 'bluesky-filter-overlay';
const dialog = document.createElement('div');
dialog.className = 'bluesky-filter-dialog';
dialog.innerHTML = `
<h2>Bluesky Filter Keywords</h2>
<p>Enter keywords one per line. Filtering is case-insensitive and matches common plural forms.</p>
<textarea spellcheck="false">${filteredTerms.join('\n')}</textarea>
<div class="button-container">
<button class="cancel-btn">Cancel</button>
<button class="save-btn">Save</button>
</div>
`;
document.body.appendChild(overlay);
document.body.appendChild(dialog);
const closeDialog = () => {
dialog.remove();
overlay.remove();
};
dialog.querySelector('.save-btn').addEventListener('click', async () => {
const newKeywords = dialog.querySelector('textarea').value
.split('\n')
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0);
await GM_setValue('filteredTerms', JSON.stringify(newKeywords));
blockedCount = 0;
closeDialog();
location.reload();
});
dialog.querySelector('.cancel-btn').addEventListener('click', closeDialog);
overlay.addEventListener('click', closeDialog);
}
function showConfigUI() {
createConfigUI();
}
function debugLog(type, data = null) {
console.log(`🔍 [Bluesky Filter] ${type}:`, data || '');
}
function listStorage() {
debugLog('Listing localStorage');
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
console.log(`localStorage[${key}]:`, value);
}
}
function waitForAuth() {
return new Promise((resolve, reject) => {
const maxAttempts = 30;
let attempts = 0;
const checkAuth = () => {
attempts++;
let session = localStorage.getItem('BSKY_STORAGE');
if (session) {
try {
const parsed = JSON.parse(session);
if (parsed.session?.accounts?.[0]?.accessJwt) {
sessionToken = parsed.session.accounts[0].accessJwt;
debugLog('Auth Success', 'Token retrieved');
resolve(true);
return;
}
} catch (e) {
debugLog('Auth Error', e);
}
}
if (attempts === 1) {
listStorage();
}
if (attempts >= maxAttempts) {
reject('Authentication timeout');
return;
}
setTimeout(checkAuth, 1000);
};
checkAuth();
});
}
async function fetchProfile(did) {
if (!sessionToken) {
debugLog('Fetch Profile Error', 'No session token available');
return null;
}
if (profileCache.has(did)) {
debugLog('Fetch Profile', 'Using cached profile', did);
return profileCache.get(did);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Accept': 'application/json'
},
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
debugLog('Profile Data', {did: did, description: data.description});
profileCache.set(did, data);
resolve(data);
} catch (e) {
debugLog('Profile Parsing Error', e);
reject(e);
}
} else if (response.status === 401) {
debugLog('Auth Expired', 'Session token expired');
sessionToken = null;
reject('Auth expired');
} else {
debugLog('Profile Fetch Error', `HTTP ${response.status}`);
reject(`HTTP ${response.status}`);
}
},
onerror: function(error) {
debugLog('Fetch Profile Error', error);
reject(error);
}
});
});
}
function cleanText(text) {
return text
.normalize('NFKD')
.replace(/\s+/g, ' ')
.toLowerCase()
.trim();
}
async function processPost(post) {
if (!shouldProcessPage() || processedPosts.has(post)) return;
processedPosts.add(post);
const authorLink = post.querySelector('a[href^="/profile/"]');
if (!authorLink) {
debugLog('Process Post', 'Author link not found');
return;
}
const nameElement = authorLink.querySelector('span');
if (nameElement) {
const rawAuthorName = nameElement.textContent;
const cleanedAuthorName = cleanText(rawAuthorName);
debugLog('Raw Author Name', rawAuthorName);
debugLog('Cleaned Author Name', cleanedAuthorName);
const nameContainsFilteredTerm = filteredTerms.some(term => {
const escapedTerm = escapeRegExp(term);
const pattern = new RegExp(`${escapedTerm}`, 'i');
const matches = pattern.test(cleanedAuthorName) || pattern.test(rawAuthorName.toLowerCase());
if (matches) {
debugLog('Match Found in Author Name', { term, rawAuthorName, cleanedAuthorName });
}
return matches;
});
if (nameContainsFilteredTerm) {
const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
debugLog('Filtered Post by Name', {rawAuthorName, cleanedAuthorName});
return;
}
}
}
const didMatch = authorLink.href.match(/\/profile\/(.+)/);
if (!didMatch || !didMatch[1]) {
debugLog('Process Post', 'DID not found in URL');
return;
}
const did = decodeURIComponent(didMatch[1]);
if (!did) {
debugLog('Process Post', 'Empty DID');
return;
}
debugLog('Processing Post', {did});
const postContentElement = post.querySelector('div[data-testid="postText"]');
if (postContentElement) {
const rawPostText = postContentElement.textContent;
const cleanedPostText = cleanText(rawPostText);
debugLog('Raw Post Content', rawPostText);
debugLog('Cleaned Post Content', cleanedPostText);
const textContainsFilteredTerm = filteredTerms.some(term => {
const escapedTerm = escapeRegExp(term);
const pattern = new RegExp(`${escapedTerm}`, 'i');
const matches = pattern.test(cleanedPostText) || pattern.test(rawPostText.toLowerCase());
if (matches) {
debugLog('Match Found in Post Text', { term, rawPostText, cleanedPostText });
}
return matches;
});
if (textContainsFilteredTerm) {
const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
debugLog('Filtered Post by Text', {did, rawPostText, cleanedPostText});
return;
}
}
}
const imageElements = post.querySelectorAll('img[alt]');
if (imageElements.length > 0) {
const altTexts = Array.from(imageElements).map(img => img.alt);
const cleanedAltTexts = altTexts.map(alt => cleanText(alt));
debugLog('Alt Texts Found', altTexts);
const altTextContainsFilteredTerm = filteredTerms.some(term => {
const escapedTerm = escapeRegExp(term);
const pattern = new RegExp(`${escapedTerm}`, 'i');
const matches = altTexts.some(alt => pattern.test(alt.toLowerCase())) ||
cleanedAltTexts.some(alt => pattern.test(alt));
if (matches) {
debugLog('Match Found in Alt Text', {
term,
matchedAltTexts: altTexts.filter(alt => pattern.test(alt.toLowerCase()))
});
}
return matches;
});
if (altTextContainsFilteredTerm) {
const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
debugLog('Filtered Post by Alt Text', {altTexts});
return;
}
}
}
const ariaLabelElements = post.querySelectorAll('[aria-label]');
if (ariaLabelElements.length > 0) {
const ariaLabels = Array.from(ariaLabelElements).map(el => el.getAttribute('aria-label'));
const cleanedAriaLabels = ariaLabels.map(label => cleanText(label));
debugLog('Aria-Labels Found', ariaLabels);
const ariaLabelContainsFilteredTerm = filteredTerms.some(term => {
const escapedTerm = escapeRegExp(term);
const pattern = new RegExp(`${escapedTerm}`, 'i');
const matches = ariaLabels.some(label => pattern.test(label.toLowerCase())) ||
cleanedAriaLabels.some(label => pattern.test(label));
if (matches) {
debugLog('Match Found in Aria-Label', {
term,
matchedAriaLabels: ariaLabels.filter(label => pattern.test(label.toLowerCase()))
});
}
return matches;
});
if (ariaLabelContainsFilteredTerm) {
const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
debugLog('Filtered Post by Aria-Label', {ariaLabels});
return;
}
}
}
}
function observePosts() {
observer = new MutationObserver((mutations) => {
if (!shouldProcessPage()) {
return;
}
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes).filter(node => node.nodeType === Node.ELEMENT_NODE);
if (addedNodes.length > 0) {
debugLog('Observer', `Detected ${addedNodes.length} added node(s)`);
}
addedNodes.forEach(node => {
const authorLinks = node.querySelectorAll('a[href^="/profile/"]');
if (authorLinks.length > 0) {
debugLog('Observer', `Detected ${authorLinks.length} new post(s)`);
authorLinks.forEach(authorLink => {
const container = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (container) {
processPost(container);
} else {
debugLog('Observer', 'Post container not found for a new post');
}
});
}
const addedImages = node.querySelectorAll('img[alt]');
addedImages.forEach(img => {
debugLog('Observer', 'New Image with Alt', img.alt);
const postContainer = img.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
processPost(postContainer);
}
});
const addedAriaLabels = node.querySelectorAll('[aria-label]');
addedAriaLabels.forEach(el => {
const ariaLabel = el.getAttribute('aria-label');
debugLog('Observer', 'New Element with Aria-Label', ariaLabel);
const postContainer = el.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
processPost(postContainer);
}
});
});
} else if (mutation.type === 'attributes' && (mutation.attributeName === 'alt' || mutation.attributeName === 'aria-label')) {
const target = mutation.target;
if (mutation.attributeName === 'alt') {
const altText = target.getAttribute('alt');
debugLog('Observer', 'Alt attribute changed', altText);
} else if (mutation.attributeName === 'aria-label') {
const ariaLabel = target.getAttribute('aria-label');
debugLog('Observer', 'Aria-label attribute changed', ariaLabel);
}
const postContainer = target.closest('div[role="link"]')?.closest('div.css-175oi2r');
if (postContainer) {
processPost(postContainer);
}
}
});
});
if (shouldProcessPage()) {
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['alt', 'aria-label']
});
}
// Add URL change detection
let lastPath = window.location.pathname;
setInterval(() => {
if (window.location.pathname !== lastPath) {
lastPath = window.location.pathname;
if (!shouldProcessPage()) {
observer.disconnect();
} else {
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['alt', 'aria-label']
});
}
}
}, 1000);
}
if (shouldProcessPage()) {
waitForAuth().then(() => {
observePosts();
}).catch((err) => {
debugLog('Initialization Error', err);
});
}
updateMenuCommand();
debugLog('Script Loaded', { filteredTerms, timestamp: new Date().toISOString() });
})();