Версия 3.1: Исправлено вертикальное смещение кнопок (выравнивание по Flexbox).
// ==UserScript==
// @name Twitter/X: Block, Download & Not Interested (Stable Fix 3.1)
// @namespace http://tampermonkey.net/
// @version 3.1
// @description Версия 3.1: Исправлено вертикальное смещение кнопок (выравнивание по Flexbox).
// @author Expert Dev & Gemini
// @match https://twitter.com/*
// @match https://x.com/*
// @icon https://abs.twimg.com/favicons/twitter.ico
// @grant none
// ==/UserScript==
(function() {
'use strict';
const ICONS = {
BLOCK: '⛔',
SOFT_BAN: '👎',
DOWNLOAD: '🎬',
LOADING: '⏳',
DONE: '✅',
ERROR: '❌'
};
const KEYWORDS = {
notInterested: ['не интересна', 'Not interested', 'не цікавить', 'No me interesa'],
block: ['Внести', 'Block', 'Заблокувати', 'Bloquear']
};
const BTN_STYLE = `
margin-right: 4px;
cursor: pointer;
font-size: 16px;
opacity: 0.7;
transition: transform 0.1s, opacity 0.2s;
border: none;
background: transparent;
padding: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// --- REACT UTILS ---
function getReactProps(dom) {
const key = Object.keys(dom).find(key => key.startsWith("__reactFiber"));
return key ? dom[key] : null;
}
function findTweetData(node, depth = 0) {
if (!node || depth > 25) return null;
const props = node.memoizedProps;
if (props) {
if (props.tweet) return props.tweet;
if (props.data?.tweetResult?.result) return props.data.tweetResult.result;
if (props.item?.itemContent?.tweet_results?.result) return props.item.itemContent.tweet_results.result;
}
return findTweetData(node.return, depth + 1);
}
function getCombinedTweetData(tweetNode) {
let fiber = getReactProps(tweetNode);
let data = findTweetData(fiber);
if (data) return data;
const timeNode = tweetNode.querySelector('time');
if (timeNode) {
fiber = getReactProps(timeNode);
data = findTweetData(fiber);
}
return data;
}
function getMediaUrl(tweetRawData) {
if (!tweetRawData) return null;
const legacy = tweetRawData.legacy || (tweetRawData.tweet && tweetRawData.tweet.legacy) || tweetRawData;
if (!legacy?.extended_entities?.media) return null;
const media = legacy.extended_entities.media.find(m => m.type === 'video' || m.type === 'animated_gif');
if (!media) return null;
let best = null;
let maxBr = -1;
media.video_info?.variants?.forEach(v => {
if (v.content_type === 'video/mp4' && v.bitrate > maxBr) {
maxBr = v.bitrate;
best = v.url;
}
});
return best;
}
// --- ACTIONS ---
async function handleDownload(url, btn) {
const originalIcon = btn.innerHTML;
btn.innerHTML = ICONS.LOADING;
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
a.download = `twitter_video_${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
btn.innerHTML = ICONS.DONE;
} catch (e) {
window.open(url, '_blank');
btn.innerHTML = '↗️';
}
setTimeout(() => btn.innerHTML = originalIcon, 2000);
}
function findMenuOptionByText(actionType) {
const layers = document.querySelector('#layers') || document;
const items = layers.querySelectorAll('[role="menuitem"]');
const words = KEYWORDS[actionType];
for (let item of items) {
const text = item.innerText || item.textContent;
if (words.some(word => text.includes(word))) return item;
}
return null;
}
async function clickMenuOption(tweetNode, btn, actionType) {
const originalIcon = btn.innerHTML;
btn.innerHTML = ICONS.LOADING;
const testIdMap = { 'notInterested': 'notInterested', 'block': 'block' };
try {
const caret = tweetNode.querySelector('[data-testid="caret"]');
if (!caret) throw new Error("Caret missing");
caret.click();
let attempts = 0;
let option = null;
while (attempts < 40) {
await sleep(50);
const layers = document.querySelector('#layers') || document;
option = layers.querySelector(`[data-testid="${testIdMap[actionType]}"]`);
if (!option) option = findMenuOptionByText(actionType);
if (option) break;
attempts++;
}
if (!option) {
caret.click();
throw new Error("Option missing");
}
await sleep(50);
option.click();
if (actionType === 'block') {
let confirmAttempts = 0;
let confirmBtn = null;
while (confirmAttempts < 20) {
await sleep(50);
const layers = document.querySelector('#layers') || document;
confirmBtn = layers.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) break;
confirmAttempts++;
}
if (confirmBtn) {
await sleep(50);
confirmBtn.click();
}
}
tweetNode.style.opacity = '0.2';
tweetNode.style.filter = 'grayscale(100%)';
tweetNode.style.pointerEvents = 'none';
btn.innerHTML = ICONS.DONE;
} catch (e) {
btn.innerHTML = ICONS.ERROR;
await sleep(2000);
btn.innerHTML = originalIcon;
}
}
// --- UI INJECTION ---
function injectButtons(tweetNode) {
const caretSvg = tweetNode.querySelector('[data-testid="caret"]');
if (!caretSvg) return;
const menuBtn = caretSvg.closest('[role="button"]');
if (!menuBtn) return;
const menuBtnWrapper = menuBtn.parentNode;
if (!menuBtnWrapper || !menuBtnWrapper.parentNode) return;
if (tweetNode.querySelector('.xtools-container')) return;
tweetNode.dataset.xtoolsInjected = "true";
// Ключевое исправление: превращаем родителя в строку (row)
const parentContainer = menuBtnWrapper.parentNode;
parentContainer.style.display = 'flex';
parentContainer.style.flexDirection = 'row';
parentContainer.style.alignItems = 'center';
const toolsDiv = document.createElement('div');
toolsDiv.className = 'xtools-container';
toolsDiv.style.cssText = 'display: flex; flex-direction: row; align-items: center; justify-content: flex-end;';
const btnSoft = document.createElement('button');
btnSoft.innerHTML = ICONS.SOFT_BAN;
btnSoft.title = "Не интересно";
btnSoft.style.cssText = BTN_STYLE;
btnSoft.onclick = (e) => { e.preventDefault(); e.stopPropagation(); clickMenuOption(tweetNode, btnSoft, 'notInterested'); };
toolsDiv.appendChild(btnSoft);
const btnBlock = document.createElement('button');
btnBlock.innerHTML = ICONS.BLOCK;
btnBlock.title = "Блокировать";
btnBlock.style.cssText = BTN_STYLE;
btnBlock.onclick = (e) => { e.preventDefault(); e.stopPropagation(); clickMenuOption(tweetNode, btnBlock, 'block'); };
toolsDiv.appendChild(btnBlock);
const tweetData = getCombinedTweetData(tweetNode);
const videoUrl = getMediaUrl(tweetData);
if (videoUrl) {
const btnDl = document.createElement('button');
btnDl.innerHTML = ICONS.DOWNLOAD;
btnDl.title = "Скачать видео";
btnDl.style.cssText = BTN_STYLE + 'color: #1d9bf0;';
btnDl.onclick = (e) => { e.preventDefault(); e.stopPropagation(); handleDownload(videoUrl, btnDl); };
toolsDiv.appendChild(btnDl);
}
parentContainer.insertBefore(toolsDiv, menuBtnWrapper);
}
// --- ENGINE ---
setInterval(() => {
const unprocessedTweets = document.querySelectorAll('article[data-testid="tweet"]:not([data-xtools-injected="true"])');
for (let i = 0; i < unprocessedTweets.length; i++) {
injectButtons(unprocessedTweets[i]);
}
const injectedTweets = document.querySelectorAll('article[data-testid="tweet"][data-xtools-injected="true"]');
for (let i = 0; i < injectedTweets.length; i++) {
if (!injectedTweets[i].querySelector('.xtools-container')) {
injectedTweets[i].removeAttribute('data-xtools-injected');
}
}
}, 1000);
})();