Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts.
目前為
// ==UserScript==
// @name Bluesky Content Manager
// @namespace https://greasyfork.org/en/users/567951-stuart-saddler
// @version 3.1
// @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', true);
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;
}
function shouldProcessPage() {
return window.location.pathname !== '/notifications';
}
/***** 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 (analyze alt-text, aria-label, and <img> alt attributes)
</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) {}
}
// Check textual alt‑text in post content for blocklisted words.
function checkAltTextTagInPost(rawPostText, filteredTerms) {
const altTextRegex = /alt-text\s*"([^"]*)"/gi;
let match;
while ((match = altTextRegex.exec(rawPostText)) !== null) {
const contentBetweenQuotes = match[1].trim();
if (!contentBetweenQuotes) return false;
const lowered = contentBetweenQuotes.toLowerCase();
for (const term of filteredTerms) {
if (lowered.includes(term)) return false;
}
}
return true;
}
// Skip posts by whitelisted (followed) users.
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}`));
}
/***** COMBINED POST PROCESSING *****/
async function processPost(post) {
if (isWhitelisted(post)) {
post.classList.add('bluesky-processed');
return;
}
if (!shouldProcessPage() || post.classList.contains('bluesky-processed')) return;
const postContainer = getPostContainer(post);
if (!postContainer) return;
// <img> Alt Check: remove if any <img> has empty alt; if non-empty, scan for blocklisted words.
const imageElements = post.querySelectorAll('img');
if (imageElements.length > 0) {
if (Array.from(imageElements).some(img => !img.alt || img.alt.trim() === '')) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
const altTexts = Array.from(imageElements).map(img => img.alt || '');
const cleanedAltTexts = altTexts.map(alt => cleanText(alt));
if (filteredTerms.some(term => {
const pattern = new RegExp(escapeRegExp(term), 'i');
return altTexts.some(alt => pattern.test(alt.toLowerCase())) ||
cleanedAltTexts.some(alt => pattern.test(alt));
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
// aria-label Check: remove if any aria-label is empty; otherwise, check for blocklisted words.
const ariaLabelElements = post.querySelectorAll('[aria-label]');
if (ariaLabelElements.length > 0) {
const ariaLabels = Array.from(ariaLabelElements).map(el => el.getAttribute('aria-label') || '');
if (ariaLabels.some(label => label.trim() === '')) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
const cleanedAriaLabels = ariaLabels.map(label => cleanText(label));
if (filteredTerms.some(term => {
const pattern = new RegExp(escapeRegExp(term), 'i');
return ariaLabels.some(label => pattern.test(label.toLowerCase())) ||
cleanedAriaLabels.some(label => pattern.test(label));
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
// Textual Alt‑Text Check in post content.
if (altTextEnforcementEnabled) {
const postContentElement = post.querySelector('div[data-testid="postText"]');
const rawPostText = postContentElement ? postContentElement.textContent : '';
if (!checkAltTextTagInPost(rawPostText, filteredTerms)) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
// Author Name Blocklist Check.
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(escapeRegExp(term), 'i');
return pattern.test(rawAuthorName.toLowerCase()) || pattern.test(cleanedAuthorName);
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
// Post Text Blocklist Check.
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(escapeRegExp(term), 'i');
return pattern.test(rawPostText.toLowerCase()) || pattern.test(cleanedPostText);
})) {
postContainer.remove();
blockedCount++;
updateMenuCommand();
return;
}
}
post.classList.add('bluesky-processed');
}
/***** 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');
const addedAria = node.querySelectorAll('[aria-label]');
if (addedImages.length > 0 || addedAria.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(() => {});
}
})();