// ==UserScript==
// @name X 工具箱(下载 · 去广告 · 屏蔽作者 · 作者信息)
// @namespace https://github.com/local/x-toolbox
// @version 3.5.1
// @description 推文视频下载(右下角悬浮按钮,多清晰度);时间线去广告;卡片一键屏蔽作者;悬停显示作者资料
// @author local
// @license MIT
// @reference https://greasyfork.org/scripts/529453 (TweetResultByRestId 参数参考, MIT)
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @connect video.twimg.com
// @connect cdn.syndication.twimg.com
// @connect abs.twimg.com
// @connect pbs.twimg.com
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
'use strict';
// ================================================================
// 一、接口层:GraphQL TweetResultByRestId(一次请求 = 视频直链 + 作者资料)
// ================================================================
// X 网页端公开 Bearer(写在它的前端代码里,非用户私有)
const FALLBACK_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
let TWEET_QID = '2ICDjqPd81tulZcYrtpTuQ'; // TweetResultByRestId 的 queryId(会轮换,可自动从 bundle 提取)
const authState = { authorization: '' };
function getCookie(name) {
return (document.cookie.match(new RegExp('(?:^|;\\s)' + name + '=([^;]*)')) || [])[1] || '';
}
// ---- Lucide 图标(ISC 许可,https://lucide.dev,内联 SVG 无外部依赖) ----
const LUCIDE_PATHS = {
'download': '',
'ban': '',
'calendar': '',
'activity': '',
'users': '',
'arrow-right': '',
'message-circle': '',
'triangle-alert': '',
'loader-circle': '',
'info': '',
'heart': '',
};
function lucideSvg(name, size, spin, block) {
const style = block ? ' style="display:block"' : '';
return '';
}
const DL_IDLE_HTML = lucideSvg('download', 20, false, true);
const DL_BUSY_HTML = lucideSvg('loader-circle', 20, true, true);
async function apiHeaders(json) {
const csrf = getCookie('ct0');
if (!csrf) throw new Error('未读到登录凭证(ct0),请确认已登录 X');
const h = {
'authorization': authState.authorization || FALLBACK_BEARER,
'x-csrf-token': csrf,
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes',
'x-twitter-client-language': getCookie('lang') || 'en',
};
if (json) h['content-type'] = 'application/json';
return h;
}
// ---- 前端 JS bundle 抓取(提取 Bearer / queryId 的兜底通道) ----
function gmFetchText(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 20000,
onload (res) { res.status === 200 ? resolve(res.responseText) : reject(new Error('HTTP ' + res.status)); },
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('超时')),
});
});
}
let bundleCache = null;
async function bundles() {
if (bundleCache) return bundleCache;
const srcs = [...document.querySelectorAll('script[src]')]
.map(s => s.src)
.filter(u => /abs\.twimg\.com\/responsive-web\/client-web\//.test(u));
srcs.sort((a, b) => (/\/main\./.test(a) ? -1 : 1) - (/\/main\./.test(b) ? -1 : 1));
const texts = [];
for (const u of srcs.slice(0, 8)) {
try { texts.push(await gmFetchText(u)); } catch (e) {}
}
bundleCache = texts;
return texts;
}
async function extractFromBundles(re) {
try {
for (const txt of await bundles()) {
const m = txt.match(re);
if (m) return m[1];
}
} catch (e) {}
return null;
}
// ---- 推文详情请求(带缓存) ----
const tweetCache = new Map(); // id -> Promise
function gqlUrl(qid, id) {
const variables = {
'tweetId': id,
'with_rux_injections': false,
'includePromotedContent': true,
'withCommunity': true,
'withQuickPromoteEligibilityTweetFields': true,
'withBirdwatchNotes': true,
'withVoice': true,
'withV2Timeline': true,
};
const features = {
'articles_preview_enabled': true,
'c9s_tweet_anatomy_moderator_badge_enabled': true,
'communities_web_enable_tweet_community_results_fetch': false,
'creator_subscriptions_quote_tweet_preview_enabled': false,
'creator_subscriptions_tweet_preview_api_enabled': false,
'freedom_of_speech_not_reach_fetch_enabled': true,
'graphql_is_translatable_rweb_tweet_is_translatable_enabled': true,
'longform_notetweets_consumption_enabled': false,
'longform_notetweets_inline_media_enabled': true,
'longform_notetweets_rich_text_read_enabled': false,
'premium_content_api_read_enabled': false,
'profile_label_improvements_pcf_label_in_post_enabled': true,
'responsive_web_edit_tweet_api_enabled': false,
'responsive_web_enhance_cards_enabled': false,
'responsive_web_graphql_exclude_directive_enabled': false,
'responsive_web_graphql_skip_user_profile_image_extensions_enabled': false,
'responsive_web_graphql_timeline_navigation_enabled': false,
'responsive_web_grok_analysis_button_from_backend': false,
'responsive_web_grok_analyze_button_fetch_trends_enabled': false,
'responsive_web_grok_analyze_post_followups_enabled': false,
'responsive_web_grok_image_annotation_enabled': false,
'responsive_web_grok_share_attachment_enabled': false,
'responsive_web_grok_show_grok_translated_post': false,
'responsive_web_jetfuel_frame': false,
'responsive_web_media_download_video_enabled': false,
'responsive_web_twitter_article_tweet_consumption_enabled': true,
'rweb_tipjar_consumption_enabled': true,
'rweb_video_screen_enabled': false,
'standardized_nudges_misinfo': true,
'tweet_awards_web_tipping_enabled': false,
'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': true,
'tweetypie_unmention_optimization_enabled': false,
'verified_phone_label_enabled': false,
'view_counts_everywhere_api_enabled': true,
};
return location.origin + '/i/api/graphql/' + qid + '/TweetResultByRestId?variables=' +
encodeURIComponent(JSON.stringify(variables)) + '&features=' +
encodeURIComponent(JSON.stringify(features));
}
async function fetchTweetRaw(id) {
const attempt = async () => {
const res = await fetch(gqlUrl(TWEET_QID, id), { headers: await apiHeaders(), credentials: 'include' });
return res;
};
let res = await attempt();
// queryId 轮换导致 404:从 X 前端代码提取新 id 重试
if (res.status === 404) {
const q = await extractFromBundles(/queryId:"([0-9a-zA-Z]+)",operationName:"TweetResultByRestId"/);
if (q && q !== TWEET_QID) {
TWEET_QID = q;
res = await attempt();
}
}
// Bearer 失效:从 bundle 提取真实值重试
if (res.status === 401 || res.status === 403) {
const b = await extractFromBundles(/Bearer [A-Za-z0-9%=_-]{40,}/);
if (b) {
authState.authorization = b;
res = await attempt();
}
}
if (!res.ok) throw new Error('接口 HTTP ' + res.status);
return res.json();
}
function getTweet(id) {
if (tweetCache.has(id)) return tweetCache.get(id);
const p = (async () => {
const json = await fetchTweetRaw(id);
const result = json && json.data && json.data.tweetResult && json.data.tweetResult.result;
if (!result) throw new Error('接口未返回推文数据');
return result.__typename === 'TweetWithVisibilityResults' ? result.tweet : result;
})();
p.catch(() => tweetCache.delete(id));
tweetCache.set(id, p);
return p;
}
// 推文的作者(rest_id + legacy 资料)
function tweetAuthor(t) {
const r = t && t.core && t.core.user_results && t.core.user_results.result;
if (!r || !r.legacy) return null;
return Object.assign({ id_str: r.rest_id }, r.legacy);
}
// ================================================================
// 二、共享工具
// ================================================================
function fromVariants(variants) {
return (variants || [])
.filter(v => v && (v.src || v.url) && (v.content_type === 'video/mp4' || /\.mp4(\?|$)/.test(v.src || v.url)))
.map(v => ({ url: v.src || v.url, bitrate: v.bitrate || 0 }))
.sort((a, b) => b.bitrate - a.bitrate);
}
// 收集推文全部媒体:视频(mp4 各码率)+ 图片(:orig 原图直链)
function tweetMediaItems(t) {
const out = [];
for (const m of ((t.legacy && t.legacy.extended_entities && t.legacy.extended_entities.media) || [])) {
if (m.video_info) {
for (const v of fromVariants(m.video_info.variants)) out.push({ kind: 'video', url: v.url, bitrate: v.bitrate });
} else if (m.type === 'photo' && m.media_url_https) {
out.push({ kind: 'photo', url: m.media_url_https + ':orig', bitrate: 0 });
}
}
return out;
}
function extOf(url, fallback) {
const m = /\.(jpg|jpeg|png|webp|gif|mp4)(?:\?|:|$)/.exec(url);
return m ? m[1] : fallback;
}
// 从卡片解析作者用户名(卡片显示名,RT 时为转发者)
function authorOf(article) {
const nameBox = article.querySelector('div[data-testid="User-Name"]');
if (!nameBox) return null;
for (const a of nameBox.querySelectorAll('a[href^="/"]')) {
const href = a.getAttribute('href') || '';
if (/^\/[A-Za-z0-9_]{1,20}$/.test(href)) return href.slice(1);
}
return null;
}
// 从卡片解析推文 ID(比 URL 更通用:时间线上每张卡片都有自己的推文链接)
function statusIdOf(article) {
const a = article.querySelector('a[href*="/status/"]');
if (!a) return null;
const m = (a.getAttribute('href') || '').match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
}
function tweetIdFromUrl() {
const m = location.pathname.match(/\/status(?:es)?\/(\d+)/);
return m ? m[1] : null;
}
// ================================================================
// 三、功能 1:视频下载(右下角悬浮按钮)
// ================================================================
// 兜底:免登录的 syndication 公共接口(token 不校验,随机数即可)
function fetchBySyndication(id) {
const url = 'https://cdn.syndication.twimg.com/tweet-result?id=' + id +
'&token=' + Math.floor(Math.random() * 1e6) + '&lang=zh';
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
timeout: 15000,
onload (res) {
if (res.status !== 200) return reject(new Error('HTTP ' + res.status));
try {
const data = JSON.parse(res.responseText);
const out = [];
for (const m of (data.mediaDetails || [])) {
if (m.video_info) {
for (const v of fromVariants(m.video_info.variants)) out.push({ kind: 'video', url: v.url, bitrate: v.bitrate });
} else if (m.type === 'photo' && m.media_url_https) {
out.push({ kind: 'photo', url: m.media_url_https + ':orig', bitrate: 0 });
}
}
if (out.length) resolve(out.sort((a, b) => b.bitrate - a.bitrate));
else reject(new Error('该接口未返回媒体'));
} catch (e) { reject(new Error('返回内容无法解析')); }
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('超时')),
});
});
}
// 文件名基础段:作者_日期_id(缺项自动跳过)
function mediaBaseName(meta, id) {
const parts = [];
if (meta && meta.author) parts.push(meta.author);
if (meta && meta.date) parts.push(meta.date);
parts.push(id);
return parts.join('_');
}
function mediaMeta(t) {
const author = t && t.core && t.core.user_results && t.core.user_results.result &&
t.core.user_results.result.legacy && t.core.user_results.result.legacy.screen_name;
const created = parseCreatedAt(t && t.legacy && t.legacy.created_at);
let date = null;
if (created) {
date = created.getFullYear() +
String(created.getMonth() + 1).padStart(2, '0') +
String(created.getDate()).padStart(2, '0');
}
return { author: author || null, date: date };
}
async function resolveMedia(id) {
try {
const t = await getTweet(id);
const items = tweetMediaItems(t);
if (items.length) return { items: items, meta: mediaMeta(t) };
throw new Error('该推文没有可下载的媒体');
} catch (e1) {
const items = await fetchBySyndication(id);
return { items: items, meta: null };
}
}
function downloadViaBlob(url, filename, btn) {
const old = btn.innerHTML;
btn.innerHTML = DL_BUSY_HTML;
GM_xmlhttpRequest({
method: 'GET', url, responseType: 'blob',
headers: { 'Referer': 'https://x.com/' },
timeout: 300000,
onload (res) {
btn.innerHTML = old;
if (res.status !== 200) { alert('下载失败: HTTP ' + res.status + '\n' + url); return; }
const blobUrl = URL.createObjectURL(res.response);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
},
onerror () { btn.innerHTML = old; alert('下载出错,请重试'); },
ontimeout () { btn.innerHTML = old; alert('下载超时'); },
});
}
// 全部媒体打包为 ZIP 下载(JSZip 不可用时退化为逐个下载)
function downloadAllAsZip(items, meta, id, btn) {
const base = mediaBaseName(meta, id);
if (typeof JSZip === 'undefined') {
items.forEach((v, i) => {
const ext = v.kind === 'photo' ? extOf(v.url, 'jpg') : 'mp4';
downloadViaBlob(v.url, base + '_' + (i + 1) + '.' + ext, btn);
});
return;
}
const old = btn.innerHTML;
btn.innerHTML = DL_BUSY_HTML;
const zip = new JSZip();
let failed = 0;
const tasks = items.map((v, i) => new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET', url: v.url, responseType: 'blob',
headers: { 'Referer': 'https://x.com/' },
timeout: 300000,
onload (res) {
if (res.status === 200) {
const ext = v.kind === 'photo' ? extOf(v.url, 'jpg') : 'mp4';
zip.file(String(i + 1) + '.' + ext, res.response);
} else { failed++; }
resolve();
},
onerror () { failed++; resolve(); },
ontimeout () { failed++; resolve(); },
});
}));
Promise.all(tasks).then(() => {
zip.generateAsync({ type: 'blob' }).then((blob) => {
btn.innerHTML = old;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = base + '_all.zip';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
if (failed) alert(failed + ' 个文件下载失败,其余已打包');
}).catch(() => { btn.innerHTML = old; alert('打包失败'); });
});
}
function showQualityMenu(media, meta, id) {
document.getElementById('xt-menu')?.remove();
const menu = document.createElement('div');
menu.id = 'xt-menu';
Object.assign(menu.style, {
position: 'fixed', zIndex: 99999, background: '#16202c', color: '#fff',
border: '1px solid #536471', borderRadius: '10px', padding: '6px',
font: '13px/1.6 system-ui, sans-serif', boxShadow: '0 4px 16px rgba(0,0,0,.5)',
minWidth: '200px',
});
const label = document.createElement('div');
label.textContent = '选择要下载的媒体:';
label.style.cssText = 'padding:2px 8px;color:#8b98a5';
menu.appendChild(label);
if (media.length > 1) {
const all = document.createElement('div');
all.textContent = '全部下载(ZIP,共 ' + media.length + ' 个)';
all.style.cssText = 'padding:5px 12px;border-radius:6px;cursor:pointer;color:#1d9bf0;font-weight:bold';
all.onmouseenter = () => all.style.background = '#173244';
all.onmouseleave = () => all.style.background = '';
all.onclick = () => {
menu.remove();
downloadAllAsZip(media, meta, id, document.getElementById('xt-dl-btn') || document.body);
};
menu.appendChild(all);
}
let photoIdx = 0, videoIdx = 0;
media.forEach((v) => {
const item = document.createElement('div');
let labelText, ext, seq;
if (v.kind === 'photo') {
photoIdx++;
labelText = '图片 ' + photoIdx + ' · 原图';
ext = extOf(v.url, 'jpg');
seq = photoIdx;
} else {
videoIdx++;
labelText = (videoIdx === 1 ? '最高画质 · ' : '') + 'MP4 ' + (v.bitrate ? Math.round(v.bitrate / 1000) + ' kbps' : '默认');
ext = 'mp4';
seq = videoIdx;
}
item.textContent = labelText;
Object.assign(item.style, { padding: '5px 12px', borderRadius: '6px', cursor: 'pointer' });
item.onmouseenter = () => item.style.background = '#1d9bf0';
item.onmouseleave = () => item.style.background = '';
item.onclick = () => {
menu.remove();
const btn = document.getElementById('xt-dl-btn');
downloadViaBlob(v.url, mediaBaseName(meta, id) + '_' + seq + '.' + ext, btn || document.body);
};
menu.appendChild(item);
});
document.body.appendChild(menu);
// 菜单弹在按钮上方
const btn = document.getElementById('xt-dl-btn');
const r = btn ? btn.getBoundingClientRect() : null;
menu.style.right = r ? (innerWidth - r.right) + 'px' : '24px';
menu.style.bottom = r ? (innerHeight - r.top + 8) + 'px' : '80px';
setTimeout(() => document.addEventListener('click', function h(e) {
if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', h); }
}), 0);
}
let dlBtn = null;
function ensureDownloadButton() {
if (!dlBtn) {
dlBtn = document.createElement('button');
dlBtn.id = 'xt-dl-btn';
dlBtn.innerHTML = DL_IDLE_HTML;
dlBtn.title = '下载视频 / 图片';
dlBtn.setAttribute('aria-label', '下载媒体');
Object.assign(dlBtn.style, {
position: 'fixed', right: '24px', bottom: '24px', zIndex: 9999,
width: '48px', height: '48px', display: 'flex',
alignItems: 'center', justifyContent: 'center',
padding: '0', margin: '0', lineHeight: '1',
background: '#1d9bf0', color: '#fff', border: 'none', borderRadius: '50%',
cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.35)',
transition: 'transform .15s ease, filter .15s ease',
});
dlBtn.onmouseenter = () => { dlBtn.style.filter = 'brightness(1.12)'; dlBtn.style.transform = 'scale(1.06)'; };
dlBtn.onmouseleave = () => { dlBtn.style.filter = ''; dlBtn.style.transform = ''; };
dlBtn.onclick = async () => {
const id = tweetIdFromUrl(); // 点击时实时取,兼容 SPA 跳转
if (!id) { dlBtn.style.display = 'none'; return; }
if (document.getElementById('xt-menu')) { document.getElementById('xt-menu').remove(); return; }
dlBtn.innerHTML = DL_BUSY_HTML;
try {
const r = await resolveMedia(id);
showQualityMenu(r.items, r.meta, id);
} catch (e) {
alert('解析失败:' + (e.message || e));
} finally {
dlBtn.innerHTML = DL_IDLE_HTML;
}
};
document.body.appendChild(dlBtn);
}
// 只在推文详情页显示;显式 flex 而非空串,避免抹掉居中布局
dlBtn.style.display = tweetIdFromUrl() ? 'flex' : 'none';
}
// 爱发电支持按钮(常驻,下载按钮上方)
let sponsorBtn = null;
function ensureSponsorButton() {
if (sponsorBtn) return;
sponsorBtn = document.createElement('button');
sponsorBtn.id = 'xt-sponsor-btn';
sponsorBtn.title = '爱发电 · 支持作者';
sponsorBtn.setAttribute('aria-label', '爱发电 · 支持作者');
sponsorBtn.innerHTML = lucideSvg('heart', 18, false, true);
Object.assign(sponsorBtn.style, {
position: 'fixed', right: '28px', bottom: '84px', zIndex: 9999,
width: '40px', height: '40px', display: 'flex',
alignItems: 'center', justifyContent: 'center',
padding: '0', margin: '0', lineHeight: '1',
background: '#f91880', color: '#fff', border: 'none', borderRadius: '50%',
cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.35)',
transition: 'transform .15s ease, filter .15s ease',
});
sponsorBtn.onmouseenter = () => { sponsorBtn.style.filter = 'brightness(1.12)'; sponsorBtn.style.transform = 'scale(1.08)'; };
sponsorBtn.onmouseleave = () => { sponsorBtn.style.filter = ''; sponsorBtn.style.transform = ''; };
sponsorBtn.onclick = () => window.open('https://afdian.com/a/wenjings', '_blank', 'noopener');
document.body.appendChild(sponsorBtn);
}
// ================================================================
// 四、功能 2:去广告
// ================================================================
const AD_CSS = `
div[data-testid="cellInnerDiv"]:has(div[data-testid="placementTracking"]) { display: none !important; }
article:has(div[data-testid="placementTracking"]) { display: none !important; }
div[data-testid="sidebarColumn"] div[data-testid="placementTracking"],
div[data-testid="sidebarColumn"] [data-testid="promoted"] { display: none !important; }
aside[aria-label="Who to follow"],
aside[aria-label*="推荐关注"],
aside[aria-label*="おすすめユーザー"],
aside[aria-label*="Suggested"],
aside[aria-label*="Qui suivre"],
aside[aria-label*="A quien seguir"],
aside[aria-label*="Kimi takip etmeli"] { display: none !important; }
a[href*="/i/premium_signup"],
a[href*="/i/twitter_blue_signup"],
a[href*="/i/flow/premium_signup"],
[data-testid="PremiumUpsell"],
[data-testid="DASH30DayFreeTrialLink"] { display: none !important; }
/* 右下角 Grok / 聊天 抽屉按钮 */
[data-testid="GrokDrawer"],
[data-testid="chat-drawer-root"] { display: none !important; }
/* 敏感内容遮罩(配合 JS 兜底点击展开) */
[data-testid="sensitive"] { display: none !important; }
@keyframes xt-rot { to { transform: rotate(360deg); } }
.xt-spin { animation: xt-rot .8s linear infinite; }
`;
const style = document.createElement('style');
style.id = 'xt-ad-style';
style.textContent = AD_CSS;
(document.head || document.documentElement).appendChild(style);
const PROMOTED_TEXT = ['promoted', 'sponsored', '推广', '广告', 'プロモーション', 'gesponsert', 'sponso', 'patrocinado', 'sponsorisé'];
function scanAds() {
const cells = document.querySelectorAll('div[data-testid="cellInnerDiv"]:not([data-xt-ad-checked])');
for (const cell of cells) {
cell.dataset.xtAdChecked = '1';
let hit = !!cell.querySelector('div[data-testid="placementTracking"]');
if (!hit && cell.querySelector('span')) {
for (const s of cell.querySelectorAll('span')) {
const t = (s.textContent || '').trim().toLowerCase();
if (!t || t.length > 20) continue;
if (PROMOTED_TEXT.some(k => t === k || t === k + '·' || t.startsWith(k + ' '))) { hit = true; break; }
}
}
if (!hit && cell.querySelector('button[data-testid="UserCell"]')) hit = true;
if (hit) {
cell.style.display = 'none';
console.debug('[X工具箱] 隐藏了一条推广/推荐内容');
}
}
}
// 自动展开敏感内容遮罩:CSS 隐藏遮罩层 + JS 兜底点击"显示"
const SENSITIVE_HINTS = ['敏感', 'sensitive', 'センシティブ', 'sensibel', 'sensible'];
function scanSensitive() {
document.querySelectorAll('div[data-testid="sensitive"]').forEach(el => el.remove());
document.querySelectorAll('article div[aria-labelledby] div[role="button"][tabindex="0"]:not([data-testid])').forEach(b => {
if (b.dataset.xtSensitive) return;
const t = (b.textContent || '').trim().toLowerCase();
if (!t || t.length > 40) return;
if (SENSITIVE_HINTS.some(k => t.includes(k))) {
b.dataset.xtSensitive = '1';
b.click();
}
});
}
// ================================================================
// 五、功能 3:卡片一键屏蔽作者(屏蔽推文作者)
// ================================================================
async function blockUser(userId, screenName) {
// 主通道:1.1 屏蔽接口
try {
const headers = await apiHeaders();
headers['content-type'] = 'application/x-www-form-urlencoded';
const res = await fetch('https://x.com/i/api/1.1/blocks/create.json', {
method: 'POST', headers, credentials: 'include',
body: 'user_id=' + userId,
});
if (res.ok) return;
} catch (e) {}
// 兜底:GraphQL 屏蔽 mutation(queryId 从前端代码提取)
const qid = await extractFromBundles(/queryId:"([0-9a-zA-Z]+)",operationName:"UserBlock"/)
|| await extractFromBundles(/queryId:"([0-9a-zA-Z]+)",operationName:"BlockUser"/);
if (!qid) throw new Error('屏蔽接口暂不可用(1.1 已下线且未能定位新接口),请反馈给开发者');
const op = 'UserBlock';
const res2 = await fetch(location.origin + '/i/api/graphql/' + qid + '/' + op, {
method: 'POST',
headers: await apiHeaders(true),
credentials: 'include',
body: JSON.stringify({ variables: { user_id: userId } }),
});
if (!res2.ok) {
// 换一种变量命名再试
const res3 = await fetch(location.origin + '/i/api/graphql/' + qid + '/' + op, {
method: 'POST',
headers: await apiHeaders(true),
credentials: 'include',
body: JSON.stringify({ variables: { userId: userId } }),
});
if (!res3.ok) throw new Error('屏蔽接口返回 HTTP ' + res3.status);
}
}
function hideSameAuthorCards(screenName) {
const href = '/' + screenName;
document.querySelectorAll('article').forEach(a => {
const box = a.querySelector('div[data-testid="User-Name"]');
if (box && box.querySelector('a[href="' + href + '"]')) {
(a.closest('div[data-testid="cellInnerDiv"]') || a).style.display = 'none';
}
});
}
function insertActionButtons() {
document.querySelectorAll('article div[role="group"]').forEach(group => {
if (group.dataset.xtBlock) return;
if (!group.querySelector('[data-testid="reply"]')) return;
group.dataset.xtBlock = '1';
const info = document.createElement('button');
info.dataset.xtInfoBtn = '1';
info.title = '显示作者信息';
info.innerHTML = lucideSvg('info', 16);
Object.assign(info.style, {
background: 'transparent', border: 'none', cursor: 'pointer',
fontSize: '14px', lineHeight: '1', color: '#71767b', padding: '0 4px',
});
info.onmouseenter = () => info.style.color = '#1d9bf0';
info.onmouseleave = () => info.style.color = '#71767b';
group.appendChild(info);
const b = document.createElement('button');
b.dataset.xtBlockBtn = '1';
b.innerHTML = lucideSvg('ban', 16);
b.title = '屏蔽作者(点击立即生效)';
Object.assign(b.style, {
background: 'transparent', border: 'none', cursor: 'pointer',
fontSize: '14px', lineHeight: '1', color: '#71767b', padding: '0 4px',
});
b.onmouseenter = () => b.style.color = '#f4212e';
b.onmouseleave = () => b.style.color = '#71767b';
group.appendChild(b);
});
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-xt-block-btn]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const article = btn.closest('article');
const id = article ? statusIdOf(article) : null;
if (!id) { alert('未能识别该卡片的推文'); return; }
btn.innerHTML = lucideSvg('loader-circle', 16, true);
try {
const t = await getTweet(id);
const author = tweetAuthor(t);
if (!author || !author.id_str) throw new Error('未取得作者信息');
await blockUser(author.id_str, author.screen_name);
hideSameAuthorCards(author.screen_name);
console.log('[X工具箱] 已屏蔽 @' + author.screen_name);
} catch (err) {
alert('屏蔽失败:' + (err.message || err));
btn.innerHTML = lucideSvg('ban', 16);
}
}, true);
// ================================================================
// 六、功能 4:悬停显示作者信息(数据来自推文详情,一次请求两用)
// ================================================================
const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
function parseCreatedAt(s) {
const m = /([A-Za-z]{3}) (\d{1,2}) \d{2}:\d{2}:\d{2} \+\d{4} (\d{4})/.exec(s || '');
if (m && MONTHS[m[1]] !== undefined) return new Date(+m[3], MONTHS[m[1]], +m[2]);
return null;
}
function fmtNum(n) {
return typeof n === 'number' ? n.toLocaleString('zh-CN') : '?';
}
function iconPiece(name) {
const s = document.createElement('span');
s.style.cssText = 'display:inline-flex;align-items:center;vertical-align:-2px;margin-right:4px';
s.innerHTML = lucideSvg(name, 13);
return s;
}
// 用 Lucide 图标 + 纯文本节点填充信息条(简介来自远端数据,必须走 textContent 防注入)
function fillInfoBar(bar, u) {
bar.textContent = '';
bar.title = u.description || ''; // 悬停信息条本身可看完整简介
const l1 = document.createElement('div');
l1.style.fontWeight = 'bold';
l1.textContent = '@' + u.screen_name;
bar.appendChild(l1);
const created = parseCreatedAt(u.created_at);
if (created) {
const days = Math.max(1, Math.floor((Date.now() - created) / 86400000));
const l2 = document.createElement('div');
if (days < 180) {
l2.appendChild(iconPiece('triangle-alert'));
l2.appendChild(document.createTextNode('新账号 · '));
}
l2.appendChild(iconPiece('calendar'));
l2.appendChild(document.createTextNode((days / 365).toFixed(1) + ' 年'));
if (typeof u.statuses_count === 'number') {
l2.appendChild(document.createTextNode(' · '));
l2.appendChild(iconPiece('activity'));
l2.appendChild(document.createTextNode('日均 ' + (u.statuses_count / days).toFixed(1) + ' 条'));
}
bar.appendChild(l2);
}
const l3 = document.createElement('div');
l3.appendChild(iconPiece('users'));
l3.appendChild(document.createTextNode(fmtNum(u.followers_count) + ' 粉丝 · '));
l3.appendChild(iconPiece('arrow-right'));
l3.appendChild(document.createTextNode(fmtNum(u.friends_count) + ' 关注'));
bar.appendChild(l3);
if (u.description) {
const l4 = document.createElement('div');
l4.appendChild(iconPiece('message-circle'));
l4.appendChild(document.createTextNode(
u.description.length > 60 ? u.description.slice(0, 60) + '…' : u.description));
bar.appendChild(l4);
}
}
function removeInfoBar(article) {
article.querySelectorAll('[data-xt-info-bar]').forEach(el => el.remove());
}
async function showAuthorInfo(article) {
const cardAuthor = authorOf(article); // 卡片上显示的名字(用于防重复/复用检测)
const id = statusIdOf(article);
if (!cardAuthor || !id) return;
if (article.dataset.xtInfoFor === cardAuthor + ':' + id) return;
const nameBox = article.querySelector('div[data-testid="User-Name"]');
if (!nameBox) return;
removeInfoBar(article);
article.dataset.xtInfoFor = cardAuthor + ':' + id;
const bar = document.createElement('div');
bar.dataset.xtInfoBar = '1';
bar.textContent = '';
const loading = document.createElement('span');
loading.style.marginRight = '4px';
loading.innerHTML = lucideSvg('loader-circle', 13, true);
bar.appendChild(loading);
bar.appendChild(document.createTextNode('正在查询 @' + cardAuthor + ' …'));
Object.assign(bar.style, {
whiteSpace: 'pre-wrap', margin: '2px 0 6px', padding: '6px 10px',
fontSize: '12.5px', lineHeight: '1.6', color: '#71767b',
background: 'rgba(29,155,240,0.07)', borderRadius: '8px',
});
nameBox.insertAdjacentElement('afterend', bar);
try {
const t = await getTweet(id);
if (article.dataset.xtInfoFor !== cardAuthor + ':' + id) return; // 卡片已被复用
const author = tweetAuthor(t);
if (!author) throw new Error('响应中没有作者数据');
fillInfoBar(bar, author);
} catch (e) {
bar.textContent = '';
const warn = document.createElement('span');
warn.style.marginRight = '4px';
warn.innerHTML = lucideSvg('triangle-alert', 13);
bar.appendChild(warn);
bar.appendChild(document.createTextNode('查询失败:' + (e.message || e)));
}
}
// 点击 ⓘ 切换作者信息条(点击式,无误触发)
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-xt-info-btn]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const article = btn.closest('article');
if (!article) return;
const existing = article.querySelector('[data-xt-info-bar]');
if (existing) {
existing.remove();
article.removeAttribute('data-xt-info-for');
return;
}
showAuthorInfo(article);
}, true);
function infoStaleCheck() {
document.querySelectorAll('article[data-xt-info-for]').forEach(a => {
const cardAuthor = authorOf(a);
const id = statusIdOf(a);
if (cardAuthor && id && a.dataset.xtInfoFor !== cardAuthor + ':' + id) {
a.removeAttribute('data-xt-info-for');
removeInfoBar(a);
}
});
}
// ================================================================
// 七、统一调度(一个监听器 + 一个定时器)
// ================================================================
function periodic() {
ensureDownloadButton();
ensureSponsorButton();
scanAds();
scanSensitive();
insertActionButtons();
infoStaleCheck();
}
const observer = new MutationObserver(() => {
clearTimeout(observer._t);
observer._t = setTimeout(periodic, 300);
});
const start = () => {
if (!document.body) return setTimeout(start, 100);
observer.observe(document.body, { childList: true, subtree: true });
periodic();
setInterval(periodic, 2000);
};
start();
})();