// ==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 '' + (LUCIDE_PATHS[name] || '') + ''; } 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(); })();