// ==UserScript==
// @name Bluesky Content Manager
// @namespace https://gf.qytechs.cn/en/users/567951-stuart-saddler
// @version 3.4
// @description Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts.
// @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';
/***** CONFIGURATION & GLOBALS *****/
const filteredTerms = (JSON.parse(GM_getValue('filteredTerms', '[]')) || []).map(t => t.trim().toLowerCase());
const whitelistedUsers = new Set((JSON.parse(GM_getValue('whitelistedUsers', '[]')) || []).map(u => normalizeUsername(u)));
let altTextEnforcementEnabled = GM_getValue('altTextEnforcementEnabled', false);
let blockedCount = 0;
let menuCommandId = null;
/***** CSS INJECTION *****/
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 label {
display: block;
margin-top: 10px;
font-size: 0.9em;
color: #333;
}
.bluesky-filter-dialog input[type="checkbox"] {
margin-right: 6px;
}
.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;
}
`;
GM_addStyle(CSS);
/***** UTILITY FUNCTIONS *****/
function normalizeUsername(username) {
return username.toLowerCase().replace(/[\u200B-\u200F\u202A-\u202F]/g, '').trim();
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function cleanText(text) {
return text.normalize('NFKD').replace(/\s+/g, ' ').toLowerCase().trim();
}
function getPostContainer(node) {
let current = node;
while (current && current !== document.body) {
if (current.matches('[data-testid="post"], div[role="link"], article')) {
return current;
}
current = current.parentElement;
}
return null;
}
// Exclude profile pages and notifications.
function shouldProcessPage() {
const path = window.location.pathname;
return !path.startsWith('/profile/') && path !== '/notifications';
}
/***** HELPER: Identify Content Images *****/
function isContentImage(img) {
// Exclude images that are likely avatars.
// If the image is inside an element with data-testid="avatar", has a class "avatar",
// or has the class "css-9pa8cd", then skip it.
if (img.closest('[data-testid="avatar"]') || img.classList.contains('avatar') || img.classList.contains('css-9pa8cd')) {
return false;
}
return true;
}
/***** MENU & CONFIG UI *****/
function updateMenuCommand() {
if (menuCommandId) {
GM_unregisterMenuCommand(menuCommandId);
}
menuCommandId = GM_registerMenuCommand(`Configure Filters (${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 Content Manager</h2>
<p>Blocklist Keywords (one per line). Filtering is case-insensitive and matches common plural forms.</p>
<textarea spellcheck="false">${filteredTerms.join('\n')}</textarea>
<label>
<input type="checkbox" ${altTextEnforcementEnabled ? 'checked' : ''}>
Enable Alt-Text Enforcement (delete posts with content images missing alt-text/aria-labels or with banned words)
</label>
<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 textareaValue = dialog.querySelector('textarea').value;
const newKeywords = textareaValue.split('\n').map(k => k.trim().toLowerCase()).filter(k => k.length > 0);
await GM_setValue('filteredTerms', JSON.stringify(newKeywords));
const checkbox = dialog.querySelector('input[type="checkbox"]');
altTextEnforcementEnabled = checkbox.checked;
await GM_setValue('altTextEnforcementEnabled', altTextEnforcementEnabled);
blockedCount = 0;
closeDialog();
location.reload();
});
dialog.querySelector('.cancel-btn').addEventListener('click', closeDialog);
overlay.addEventListener('click', closeDialog);
}
function showConfigUI() {
createConfigUI();
}
/***** AUTHENTICATION & PROFILE FETCHING *****/
let sessionToken = null;
let currentUserDid = null;
const profileCache = new Map();
function waitForAuth() {
return new Promise((resolve, reject) => {
const maxAttempts = 30;
let attempts = 0;
const checkAuth = () => {
attempts++;
const 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;
currentUserDid = parsed.session.accounts[0].did;
resolve(true);
return;
}
} catch (e) {}
}
if (attempts >= maxAttempts) {
reject('Authentication timeout');
return;
}
setTimeout(checkAuth, 1000);
};
checkAuth();
});
}
async function fetchProfile(did) {
if (!sessionToken) return null;
if (profileCache.has(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);
profileCache.set(did, data);
resolve(data);
} catch (e) {
reject(e);
}
} else if (response.status === 401) {
sessionToken = null;
reject('Auth expired');
} else {
reject(`HTTP ${response.status}`);
}
},
onerror: function(error) {
reject(error);
}
});
});
}
/***** AUTO‑WHITELIST FOLLOWED ACCOUNTS (with Pagination) *****/
async function fetchAllFollows(cursor = null, accumulated = []) {
let url = `https://bsky.social/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(currentUserDid)}`;
if (cursor) url += `&cursor=${cursor}`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Accept': 'application/json'
},
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const newAccumulated = accumulated.concat(data.follows || []);
if (data.cursor) {
fetchAllFollows(data.cursor, newAccumulated).then(resolve).catch(reject);
} else {
resolve(newAccumulated);
}
} catch (e) {
reject(e);
}
} else {
reject(`HTTP ${response.status}`);
}
},
onerror: function(err) {
reject(err);
}
});
});
}
async function autoWhitelistFollowedAccounts() {
if (!sessionToken || !currentUserDid) return;
try {
const follows = await fetchAllFollows();
follows.forEach(follow => {
let handle = (follow.subject && follow.subject.handle) || follow.handle;
if (handle) {
if (!handle.startsWith('@')) handle = '@' + handle;
whitelistedUsers.add(normalizeUsername(handle));
}
});
} catch (err) {}
}
/***** COMBINED POST PROCESSING *****/
async function processPost(post) {
// 1. Whitelist check: bypass filtering for followed accounts.
if (isWhitelisted(post)) {
post.classList.add('bluesky-processed');
return;
}
if (!shouldProcessPage() || post.classList.contains('bluesky-processed')) return;
const postContainer = getPostContainer(post);
if (!postContainer) return;
// Determine if the post contains images.
const imageElements = post.querySelectorAll('img');
// Filter out images that are likely avatars.
const contentImages = Array.from(imageElements).filter(isContentImage);
// 2. Alt‑text Enforcement: if enabled and at least one content image is present.
if (altTextEnforcementEnabled && contentImages.length > 0) {
for (const img of contentImages) {
let altText = img.alt || "";
let ariaLabel = img.getAttribute("aria-label") || "";
let effectiveText = "";
if (altText.trim() !== "") {
effectiveText = altText;
} else if (ariaLabel.trim() !== "") {
effectiveText = ariaLabel;
} else {
// Neither attribute contains text – remove the post.
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
// Scan the effective text for banned words.
let cleanedEffectiveText = cleanText(effectiveText);
if (filteredTerms.some(term => {
const pattern = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i');
return pattern.test(effectiveText) || pattern.test(cleanedEffectiveText);
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
}
// 3. Blocklist Check: scan author names and post text for banned words.
const authorLink = post.querySelector('a[href^="/profile/"]');
if (authorLink) {
const nameElement = authorLink.querySelector('span');
const rawAuthorName = nameElement ? nameElement.textContent : authorLink.textContent;
const cleanedAuthorName = cleanText(rawAuthorName);
if (filteredTerms.some(term => {
const pattern = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i');
return pattern.test(rawAuthorName.toLowerCase()) || pattern.test(cleanedAuthorName);
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
const postContentElement = post.querySelector('div[data-testid="postText"]');
if (postContentElement) {
const rawPostText = postContentElement.textContent;
const cleanedPostText = cleanText(rawPostText);
if (filteredTerms.some(term => {
const pattern = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i');
return pattern.test(rawPostText.toLowerCase()) || pattern.test(cleanedPostText);
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
post.classList.add('bluesky-processed');
}
// Utility: Skip posts from whitelisted accounts.
function isWhitelisted(post) {
const authorLink = post.querySelector('a[href^="/profile/"]');
if (!authorLink) return false;
const profileIdentifier = authorLink.href.split('/profile/')[1].split(/[/?#]/)[0];
return whitelistedUsers.has(normalizeUsername(`@${profileIdentifier}`));
}
/***** OBSERVER SETUP *****/
let observer = null;
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);
addedNodes.forEach(node => {
const authorLinks = node.querySelectorAll('a[href^="/profile/"]');
if (authorLinks.length > 0) {
authorLinks.forEach(authorLink => {
const container = getPostContainer(authorLink);
if (container) {
setTimeout(() => processPost(container), 100);
}
});
}
const addedImages = node.querySelectorAll('img');
if (addedImages.length > 0) {
const container = getPostContainer(node);
if (container) {
setTimeout(() => processPost(container), 100);
}
}
});
} else if (mutation.type === 'attributes' && (mutation.attributeName === 'alt' || mutation.attributeName === 'aria-label')) {
const container = getPostContainer(mutation.target);
if (container) {
setTimeout(() => processPost(container), 100);
}
}
});
});
if (shouldProcessPage()) {
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['alt', 'aria-label']
});
}
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);
}
/***** INITIALIZATION *****/
document.querySelectorAll('[data-testid="post"], article, div[role="link"]').forEach(el => processPost(el));
updateMenuCommand();
if (shouldProcessPage()) {
waitForAuth().then(() => {
autoWhitelistFollowedAccounts();
observePosts();
}).catch(() => {});
}
})();