在 X 推文时间戳旁显示按钮,鼠标悬停自动查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号;同时在含视频的推文操作栏添加下载按钮,一键下载最高画质 MP4。
// ==UserScript==
// @name X Account Location Tagger & Video Downloader
// @namespace http://tampermonkey.net/
// @version 0.4
// @description 在 X 推文时间戳旁显示按钮,鼠标悬停自动查询"账号所在地 / App Store 区域",红色标注可能使用 VPN 的账号;同时在含视频的推文操作栏添加下载按钮,一键下载最高画质 MP4。
// @author 海空蒼
// @homepage https://github.com/SkyBlue997/X-Account-Location-Tagger
// @source https://github.com/SkyBlue997/X-Account-Location-Tagger
// @match https://x.com/*
// @match https://twitter.com/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/*****************************************************************
* 配置区
*****************************************************************/
const ABOUT_ENDPOINT =
'https://x.com/i/api/graphql/zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery';
const AUTH_BEARER =
'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
// 视频下载:TweetResultByRestId 端点(queryId 可能会变化,需定期更新)
const TWEET_DETAIL_ENDPOINT =
'https://x.com/i/api/graphql/4PdbzTmQ5PTjz9RiureISQ/TweetResultByRestId';
// 可能包含视频数据的 GraphQL 操作名
const VIDEO_GRAPHQL_ENDPOINTS = [
'TweetDetail',
'TweetResultByRestId',
'HomeTimeline',
'HomeLatestTimeline',
'SearchTimeline',
'UserTweets',
'UserTweetsAndReplies',
'ListLatestTweetsTimeline',
'Likes',
'Bookmarks',
];
const DEBUG = true;
/*****************************************************************
* 工具函数
*****************************************************************/
function log(...args) {
if (DEBUG) {
console.log('[X-AccountLocation]', ...args);
}
}
function getCsrfToken() {
const m = document.cookie.match(/(?:^|;\s*)ct0=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
function extractLocationFromResponse(data) {
if (!data || !data.data) return null;
let result = null;
// 结构 1:data.user_result.result
if (data.data.user_result && data.data.user_result.result) {
result = data.data.user_result.result;
}
// 结构 2:data.user.result
else if (data.data.user && data.data.user.result) {
result = data.data.user.result;
}
// 结构 3:data.user_result_by_screen_name.result(当前 AboutAccountQuery 返回)
else if (data.data.user_result_by_screen_name && data.data.user_result_by_screen_name.result) {
result = data.data.user_result_by_screen_name.result;
}
if (!result) {
log('未知 GraphQL 顶层结构,data =', JSON.stringify(data, null, 2));
return null;
}
// 新结构:result.about_profile
const aboutProfile =
result.about_profile ||
result.aboutProfile ||
(result.aboutModule && (result.aboutModule.about_profile || result.aboutModule.aboutProfile)) ||
null;
if (!aboutProfile) {
log('未找到 about_profile 字段,打印 result 以供检查:', result);
return null;
}
const country = aboutProfile.account_based_in || aboutProfile.accountBasedIn || null;
// 目前返回里没有明显的 countryCode,可以留空,将来若有字段再补
const countryCode = null;
// about_profile.source 是类似 "Japan App Store" / "Turkey App Store" / "Canada Android App" 的字符串
const appStoreRegion = aboutProfile.source || null;
// location_accurate 为 false 表示可能使用了 VPN
const locationAccurate = aboutProfile.location_accurate !== false; // 默认为 true
if (!country && !appStoreRegion) {
return null;
}
return {
country,
countryCode,
appStoreRegion,
locationAccurate,
};
}
/**
* 递归遍历 GraphQL 响应,提取视频 MP4 变体。
* 通过 rest_id + legacy 形状识别推文对象,
* 从 legacy.extended_entities.media[].video_info.variants 取出 MP4 链接。
* @returns {Map<string, {variants: Array, bestUrl: string}>}
*/
function extractVideoVariants(obj, results) {
if (!results) results = new Map();
if (!obj || typeof obj !== 'object') return results;
// 识别推文对象:有 rest_id 和 legacy 字段
if (obj.rest_id && obj.legacy) {
const tweetId = obj.rest_id;
const media = obj.legacy?.extended_entities?.media;
if (Array.isArray(media)) {
for (const m of media) {
if (m.type === 'video' || m.type === 'animated_gif') {
const variants = m.video_info?.variants;
if (Array.isArray(variants)) {
const mp4s = variants.filter(
v => v.content_type === 'video/mp4' && v.bitrate != null
);
if (mp4s.length > 0) {
mp4s.sort((a, b) => b.bitrate - a.bitrate);
results.set(tweetId, {
variants: mp4s,
bestUrl: mp4s[0].url,
});
}
}
}
}
}
}
// 递归子属性
if (Array.isArray(obj)) {
for (const item of obj) {
extractVideoVariants(item, results);
}
} else {
for (const key of Object.keys(obj)) {
if (key === 'url' || key === 'expanded_url' || key === 'display_url') continue;
extractVideoVariants(obj[key], results);
}
}
return results;
}
/*****************************************************************
* 调用 GraphQL:AboutAccountQuery
*****************************************************************/
async function fetchAccountLocation(screenName) {
const variables = { screenName };
const url =
ABOUT_ENDPOINT +
'?variables=' + encodeURIComponent(JSON.stringify(variables));
const csrf = getCsrfToken();
log('请求 AboutAccountQuery:', screenName);
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'authorization': AUTH_BEARER,
'x-csrf-token': csrf,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-client-language': document.documentElement.lang || 'zh-cn',
'accept': '*/*',
'content-type': 'application/json',
},
});
if (!res.ok) {
log('请求失败:', screenName, 'HTTP', res.status);
if (res.status === 429) {
log('遭遇 rate limit,请稍后再试');
}
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const loc = extractLocationFromResponse(data);
log('获得位置:', screenName, loc);
return loc;
}
/*****************************************************************
* DOM:按需查询归属地
*****************************************************************/
const locationCache = new Map();
// tweetId -> { variants: Array<{bitrate, url, content_type}>, bestUrl: string } | null
const videoCache = new Map();
function addLocationButton(timeElement, screenName) {
// 检查是否已经添加过按钮或标签
const link = timeElement.closest('a');
if (!link || link.dataset.xLocationTagged) return;
// 如果已经有缓存,直接显示标签
if (locationCache.has(screenName)) {
const info = locationCache.get(screenName);
if (info && (info.country || info.appStoreRegion)) {
showLocationLabel(timeElement, info, screenName);
return;
}
}
// 创建查询按钮
const button = document.createElement('button');
button.textContent = '📍';
button.title = '悬停显示归属地';
button.style.marginLeft = '4px';
button.style.fontSize = '12px';
button.style.border = 'none';
button.style.background = 'none';
button.style.cursor = 'pointer';
button.style.opacity = '0.6';
button.style.padding = '0 2px';
button.style.transition = 'opacity 0.2s';
button.onmouseenter = async (e) => {
button.style.opacity = '1';
// 防止重复请求
if (button.dataset.loading) return;
button.dataset.loading = 'true';
button.textContent = '⏳';
button.disabled = true;
try {
const info = await fetchAccountLocation(screenName);
locationCache.set(screenName, info || {});
if (info && (info.country || info.appStoreRegion)) {
// 移除按钮,显示标签
button.remove();
showLocationLabel(timeElement, info, screenName);
} else {
button.textContent = '❌';
button.title = '无归属地信息';
setTimeout(() => {
button.remove();
}, 2000);
}
} catch (e) {
log('查询归属地失败:', screenName, e);
button.textContent = '⚠️';
button.title = '查询失败';
button.disabled = false;
delete button.dataset.loading;
}
};
button.onmouseleave = () => {
button.style.opacity = '0.6';
};
link.dataset.xLocationTagged = '1';
timeElement.insertAdjacentElement('afterend', button);
}
function showLocationLabel(timeElement, info, screenName) {
const link = timeElement.closest('a');
if (!link) return;
link.dataset.xLocationTagged = '1';
const parts = [];
if (info.country) parts.push(info.country);
if (info.appStoreRegion) parts.push(info.appStoreRegion);
const label = parts.join(' / ');
if (!label) return;
const tag = document.createElement('span');
tag.textContent = ` [${label}]`;
tag.style.marginLeft = '4px';
tag.style.fontSize = '12px';
tag.style.opacity = '0.7';
// 根据 location_accurate 设置颜色
// false 表示可能使用了 VPN,标记为红色
if (info.locationAccurate === false) {
tag.style.color = '#f91880'; // 红色 (Twitter 警告红)
tag.title = '可能使用了 VPN';
} else {
tag.style.color = '#536471'; // Twitter 灰色
}
timeElement.insertAdjacentElement('afterend', tag);
}
function scanAndAddButtons() {
const timeLinks = document.querySelectorAll('a[href*="/status/"]');
timeLinks.forEach((link) => {
// 跳过已处理的
if (link.dataset.xLocationTagged) return;
const href = link.getAttribute('href');
const match = href?.match(/^\/([^\/]+)\/status\/\d+/);
if (!match) return;
const screenName = match[1];
const timeElement = link.querySelector('time');
if (!timeElement) return;
addLocationButton(timeElement, screenName);
});
}
/*****************************************************************
* Fetch 拦截:被动捕获视频 URL
*****************************************************************/
function installFetchInterceptor() {
const originalFetch = window.fetch;
window.fetch = function (...args) {
const request = args[0];
const url = (typeof request === 'string') ? request : request?.url;
const result = originalFetch.apply(this, args);
// 仅拦截可能含视频数据的 GraphQL 响应
if (url && typeof url === 'string' && url.includes('/i/api/graphql/')) {
const isRelevant = VIDEO_GRAPHQL_ENDPOINTS.some(ep => url.includes(ep));
if (isRelevant) {
result
.then(response => response.clone().json())
.then(data => {
const videos = extractVideoVariants(data);
if (videos.size > 0) {
log('[VideoDownload] 拦截到', videos.size, '个视频:',
[...videos.keys()]);
for (const [tweetId, info] of videos) {
videoCache.set(tweetId, info);
}
scanAndAddVideoButtons();
}
})
.catch(() => {
// 静默忽略解析失败(非 JSON 响应、rate limit 等)
});
}
}
return result;
};
log('[VideoDownload] Fetch 拦截器已安装');
}
/*****************************************************************
* 按需查询:TweetResultByRestId(视频 fallback)
*****************************************************************/
async function fetchVideoForTweet(tweetId) {
if (videoCache.has(tweetId)) {
return videoCache.get(tweetId);
}
const variables = {
tweetId: tweetId,
withCommunity: false,
includePromotedContent: false,
withVoice: false,
};
const features = {
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_enhance_cards_enabled: false,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_media_download_video_enabled: false,
};
const url =
TWEET_DETAIL_ENDPOINT +
'?variables=' + encodeURIComponent(JSON.stringify(variables)) +
'&features=' + encodeURIComponent(JSON.stringify(features));
const csrf = getCsrfToken();
log('[VideoDownload] 请求 TweetResultByRestId:', tweetId);
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'authorization': AUTH_BEARER,
'x-csrf-token': csrf,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-client-language': document.documentElement.lang || 'zh-cn',
'accept': '*/*',
'content-type': 'application/json',
},
});
if (!res.ok) {
log('[VideoDownload] 请求失败:', tweetId, 'HTTP', res.status);
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const videos = extractVideoVariants(data);
if (videos.has(tweetId)) {
const info = videos.get(tweetId);
videoCache.set(tweetId, info);
return info;
}
// 无视频,缓存 null 避免重复查询
videoCache.set(tweetId, null);
return null;
}
/*****************************************************************
* 视频下载辅助
*****************************************************************/
function downloadVideo(url, tweetId) {
log('[VideoDownload] 开始下载:', url);
// 优先 fetch + blob(可控制文件名),失败则 window.open
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Fetch failed');
return res.blob();
})
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `tweet_${tweetId}.mp4`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
a.remove();
}, 1000);
})
.catch(err => {
log('[VideoDownload] Blob 下载失败,使用 window.open:', err.message);
window.open(url, '_blank');
});
}
/*****************************************************************
* DOM:视频下载按钮
*****************************************************************/
// 下载图标 SVG path
const DOWNLOAD_ICON_PATH =
'M12 2.59L12 16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z';
// 翻转分享图标得到下载图标(上传箭头 → 下载箭头)
const DOWNLOAD_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:currentColor;transform:rotate(180deg)"><g><path d="${DOWNLOAD_ICON_PATH}"></path></g></svg>`;
// 加载中 SVG(旋转的圆圈)
const LOADING_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:currentColor"><g><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3"></path><path d="M12 2v4c4.42 0 8 3.58 8 8h4c0-6.62-5.38-12-12-12z"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></path></g></svg>`;
// 成功 SVG(打勾)
const SUCCESS_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:#00ba7c"><g><path d="M9 20l-5.447-5.46 1.42-1.42L9 17.17 19.03 7.14l1.42 1.42L9 20z"></path></g></svg>`;
// 失败 SVG(叉号)
const FAIL_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" style="width:18.75px;height:18.75px;fill:#f4212e"><g><path d="M10.59 12L4.54 5.96l1.42-1.42L12 10.59l6.04-6.05 1.42 1.42L13.41 12l6.05 6.04-1.42 1.42L12 13.41l-6.04 6.05-1.42-1.42L10.59 12z"></path></g></svg>`;
function addVideoDownloadButton(articleElement, tweetId) {
if (articleElement.dataset.xVideoTagged) return;
articleElement.dataset.xVideoTagged = '1';
// 外层 wrapper:模拟 X 原生按钮的 inline-grid 容器
const wrapper = document.createElement('div');
wrapper.style.cssText =
'display:inline-grid;justify-content:inherit;' +
'transform:rotate(0deg) scale(1) translate3d(0px,0px,0px);';
// 按钮本体
const button = document.createElement('button');
button.setAttribute('aria-label', '下载视频');
button.setAttribute('role', 'button');
button.setAttribute('type', 'button');
button.style.cssText =
'display:flex;align-items:center;justify-content:center;' +
'border:0;background:transparent;padding:0;cursor:pointer;' +
'color:#536471;transition:color 0.2s;outline:none;' +
'min-width:18.75px;min-height:20px;';
// 内部结构:图标容器 + 圆形 hover 背景
const iconContainer = document.createElement('div');
iconContainer.style.cssText =
'display:flex;align-items:center;justify-content:center;' +
'position:relative;width:18.75px;height:18.75px;';
// 圆形背景层(hover 时显示)
const hoverCircle = document.createElement('div');
hoverCircle.style.cssText =
'position:absolute;border-radius:9999px;' +
'width:34.75px;height:34.75px;' +
'transition:background-color 0.2s;';
// SVG 图标层
const iconSpan = document.createElement('div');
iconSpan.style.cssText =
'position:relative;display:flex;align-items:center;justify-content:center;';
iconSpan.innerHTML = DOWNLOAD_SVG;
iconContainer.appendChild(hoverCircle);
iconContainer.appendChild(iconSpan);
button.appendChild(iconContainer);
wrapper.appendChild(button);
// Hover 效果:与 X 原生一致的蓝色高亮
button.onmouseenter = () => {
button.style.color = '#1d9bf0';
hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.1)';
};
button.onmouseleave = () => {
button.style.color = '#536471';
hoverCircle.style.backgroundColor = 'transparent';
};
// 按下效果
button.onmousedown = () => {
hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.2)';
};
button.onmouseup = () => {
if (button.matches(':hover')) {
hoverCircle.style.backgroundColor = 'rgba(29,155,240,0.1)';
}
};
button.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
iconSpan.innerHTML = LOADING_SVG;
button.disabled = true;
button.style.color = '#536471';
try {
let info = videoCache.get(tweetId);
if (!info) {
info = await fetchVideoForTweet(tweetId);
}
if (info && info.bestUrl) {
downloadVideo(info.bestUrl, tweetId);
iconSpan.innerHTML = SUCCESS_SVG;
setTimeout(() => {
iconSpan.innerHTML = DOWNLOAD_SVG;
button.disabled = false;
}, 2000);
} else {
iconSpan.innerHTML = FAIL_SVG;
button.setAttribute('aria-label', '未找到视频');
setTimeout(() => {
iconSpan.innerHTML = DOWNLOAD_SVG;
button.setAttribute('aria-label', '下载视频');
button.disabled = false;
}, 2000);
}
} catch (err) {
log('[VideoDownload] 下载失败:', err);
iconSpan.innerHTML = FAIL_SVG;
button.setAttribute('aria-label', '下载失败');
setTimeout(() => {
iconSpan.innerHTML = DOWNLOAD_SVG;
button.setAttribute('aria-label', '下载视频');
button.disabled = false;
}, 3000);
}
};
// 放置在推文操作栏末尾
const actionBar = articleElement.querySelector('div[role="group"]');
if (actionBar) {
actionBar.appendChild(wrapper);
} else {
const timeEl = articleElement.querySelector('a[href*="/status/"] time');
if (timeEl) {
timeEl.insertAdjacentElement('afterend', wrapper);
}
}
}
function scanAndAddVideoButtons() {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
articles.forEach(article => {
if (article.dataset.xVideoTagged) return;
// 检测方式 1:DOM 中有 <video> 元素
const hasVideo = article.querySelector('video') !== null;
// 提取 tweetId
const statusLink = article.querySelector('a[href*="/status/"]');
if (!statusLink) return;
const href = statusLink.getAttribute('href');
const match = href?.match(/\/status\/(\d+)/);
if (!match) return;
const tweetId = match[1];
// 检测方式 2:videoCache 中有对应数据
const hasCachedVideo = videoCache.has(tweetId) && videoCache.get(tweetId) !== null;
if (hasVideo || hasCachedVideo) {
addVideoDownloadButton(article, tweetId);
}
});
}
/*****************************************************************
* 启动
*****************************************************************/
function init() {
log('脚本启动 - 鼠标悬停按钮自动查询归属地 + 视频下载');
// 安装 fetch 拦截器(在任何 GraphQL 请求之前)
installFetchInterceptor();
// 初始扫描
scanAndAddButtons();
scanAndAddVideoButtons();
// 监听 DOM 变化
const mo = new MutationObserver((mutations) => {
let needRescan = false;
for (const m of mutations) {
if (m.addedNodes && m.addedNodes.length > 0) {
needRescan = true;
break;
}
}
if (needRescan) {
scanAndAddButtons();
scanAndAddVideoButtons();
}
});
mo.observe(document.body, {
childList: true,
subtree: true,
});
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
init();
} else {
window.addEventListener('DOMContentLoaded', init, { once: true });
}
})();