Greasy Fork is available in English.
在Twitter用户名旁边显示基于账户位置的国家旗帜
// ==UserScript==
// @name 显示推特用户地理位置
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description 在Twitter用户名旁边显示基于账户位置的国家旗帜
// @author https://x.com/Gufii_666
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_setValue
// @grant GM_getValue
// @license GPL-3.0
// @grant GM_deleteValue
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// ==================== 国家旗帜映射 ====================
const COUNTRY_FLAGS = {
"Afghanistan": "阿富汗",
"Albania": "阿尔巴尼亚",
"Algeria": "阿尔及利亚",
"Argentina": "阿根廷",
"Australia": "澳大利亚",
"Austria": "奥地利",
"Bangladesh": "孟加拉国",
"Belgium": "比利时",
"Brazil": "巴西",
"Canada": "加拿大",
"Chile": "智利",
"China": "中国",
"Colombia": "哥伦比亚",
"Czech Republic": "捷克",
"Denmark": "丹麦",
"Egypt": "埃及",
"Europe": "欧盟",
"Finland": "芬兰",
"France": "法国",
"Germany": "德国",
"Greece": "希腊",
"Hong Kong": "中国香港",
"Hungary": "匈牙利",
"India": "印度",
"Indonesia": "印度尼西亚",
"Iran": "伊朗",
"Iraq": "伊拉克",
"Ireland": "爱尔兰",
"Israel": "以色列",
"Italy": "意大利",
"Japan": "日本",
"Kenya": "肯尼亚",
"Malaysia": "马来西亚",
"Mexico": "墨西哥",
"Netherlands": "荷兰",
"New Zealand": "新西兰",
"Nigeria": "尼日利亚",
"Norway": "挪威",
"Pakistan": "巴基斯坦",
"Philippines": "菲律宾",
"Poland": "波兰",
"Portugal": "葡萄牙",
"Romania": "罗马尼亚",
"Russia": "俄罗斯",
"Saudi Arabia": "沙特阿拉伯",
"Singapore": "新加坡",
"South Africa": "南非",
"Korea": "韩国",
"South Korea": "韩国",
"Spain": "西班牙",
"Sweden": "瑞典",
"Switzerland": "瑞士",
"Taiwan": "中国台湾",
"Thailand": "泰国",
"Turkey": "土耳其",
"Ukraine": "乌克兰",
"United Arab Emirates": "阿拉伯联合酋长国",
"United Kingdom": "英国",
"United States": "美国",
"Venezuela": "委内瑞拉",
"Vietnam": "越南"
};
// 区域名称到国家的映射(用于处理区域名称)
const REGION_TO_COUNTRY = {
"East Asia & Pacific": "China", // 默认映射到中国
"Europe": "Europe",
"Middle East & North Africa": "Saudi Arabia",
"Sub-Saharan Africa": "South Africa",
"Latin America & Caribbean": "Brazil",
"North America": "United States",
"South Asia": "India"
};
function getCountryFlag(countryName) {
if (!countryName) return null;
// 先检查是否是区域名称
if (REGION_TO_COUNTRY[countryName]) {
const mappedCountry = REGION_TO_COUNTRY[countryName];
if (COUNTRY_FLAGS[mappedCountry]) {
return COUNTRY_FLAGS[mappedCountry];
}
}
// Try exact match first
if (COUNTRY_FLAGS[countryName]) {
return COUNTRY_FLAGS[countryName];
}
// Try case-insensitive match
const normalized = countryName.trim();
for (const [country, flag] of Object.entries(COUNTRY_FLAGS)) {
if (country.toLowerCase() === normalized.toLowerCase()) {
return flag;
}
}
// 尝试部分匹配(例如 "Hong Kong" 匹配 "Hong Kong")
for (const [country, flag] of Object.entries(COUNTRY_FLAGS)) {
if (normalized.toLowerCase().includes(country.toLowerCase()) ||
country.toLowerCase().includes(normalized.toLowerCase())) {
return flag;
}
}
return null;
}
// ==================== 配置和状态 ====================
let locationCache = new Map();
const CACHE_KEY = 'twitter_location_cache';
const CACHE_EXPIRY_DAYS = 30;
const TOGGLE_KEY = 'extension_enabled';
const DEFAULT_ENABLED = true;
// Rate limiting
const requestQueue = [];
let isProcessingQueue = false;
let lastRequestTime = 0;
const MIN_REQUEST_INTERVAL = 2000;
const MAX_CONCURRENT_REQUESTS = 2;
let activeRequests = 0;
let rateLimitResetTime = 0;
// Observer for dynamically loaded content
let observer = null;
// Extension enabled state
let extensionEnabled = DEFAULT_ENABLED;
// Track usernames currently being processed
const processingUsernames = new Set();
// Debounce timer for processUsernames
let processTimer = null;
let isProcessing = false;
// ==================== 存储管理(使用GM_* API)====================
function loadEnabledState() {
try {
const value = GM_getValue(TOGGLE_KEY, DEFAULT_ENABLED);
extensionEnabled = value;
console.log('Extension enabled:', extensionEnabled);
} catch (error) {
console.error('Error loading enabled state:', error);
extensionEnabled = DEFAULT_ENABLED;
}
}
function loadCache() {
try {
const cached = GM_getValue(CACHE_KEY, null);
if (cached) {
const now = Date.now();
// Filter out expired entries and null entries
for (const [username, data] of Object.entries(cached)) {
if (data.expiry && data.expiry > now && data.location !== null) {
locationCache.set(username, data.location);
}
}
console.log(`Loaded ${locationCache.size} cached locations (excluding null entries)`);
}
} catch (error) {
console.error('Error loading cache:', error);
}
}
function saveCache() {
try {
const cacheObj = {};
const now = Date.now();
const expiry = now + (CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
for (const [username, location] of locationCache.entries()) {
cacheObj[username] = {
location: location,
expiry: expiry,
cachedAt: now
};
}
GM_setValue(CACHE_KEY, cacheObj);
} catch (error) {
console.error('Error saving cache:', error);
}
}
function saveCacheEntry(username, location) {
locationCache.set(username, location);
// Debounce saves - only save every 5 seconds
if (!saveCache.timeout) {
saveCache.timeout = setTimeout(() => {
saveCache();
saveCache.timeout = null;
}, 5000);
}
}
// ==================== 页面脚本注入 ====================
function injectPageScript() {
// 检查是否已经注入
if (unsafeWindow.__twitterLocationFlagInjected) {
console.log('Page script already injected');
return;
}
try {
// 直接在页面上下文中定义函数,避免使用 eval
const pageContext = unsafeWindow;
// 防止重复注入
if (pageContext.__twitterLocationFlagInjected) {
return;
}
pageContext.__twitterLocationFlagInjected = true;
console.log('[Page Script] Twitter Location Flag page script initialized');
// Store headers from Twitter's own API calls
let twitterHeaders = null;
let headersReady = false;
function captureHeaders(headers) {
if (!headers) return;
const headerObj = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
headerObj[key] = value;
});
} else if (headers instanceof Object) {
for (const [key, value] of Object.entries(headers)) {
headerObj[key] = value;
}
}
twitterHeaders = headerObj;
headersReady = true;
console.log('Captured Twitter API headers:', Object.keys(headerObj));
}
// Intercept fetch to capture Twitter's headers
const originalFetch = pageContext.fetch;
pageContext.fetch = function(...args) {
const url = args[0];
const options = args[1] || {};
if (typeof url === 'string' && url.includes('x.com/i/api/graphql')) {
if (options.headers) {
captureHeaders(options.headers);
console.log('Captured Twitter headers:', Object.keys(twitterHeaders || {}));
}
}
return originalFetch.apply(this, args);
};
// Also intercept XMLHttpRequest
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._url = url;
return originalXHROpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
if (this._url && this._url.includes('x.com/i/api/graphql')) {
const headers = {};
if (this._headers) {
Object.assign(headers, this._headers);
}
captureHeaders(headers);
}
return originalXHRSend.apply(this, args);
};
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
if (!this._headers) this._headers = {};
this._headers[header] = value;
return originalSetRequestHeader.apply(this, [header, value]);
};
setTimeout(() => {
if (!headersReady) {
console.log('No Twitter headers captured yet, using defaults');
twitterHeaders = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
headersReady = true;
}
}, 3000);
// 监听测试消息
pageContext.addEventListener('message', function(event) {
if (event.source !== pageContext) return;
if (event.data && event.data.type === '__testPageScript') {
console.log('[Page Script] Test message received, responding...');
pageContext.postMessage({ type: '__pageScriptReady' }, '*');
}
});
// Listen for fetch requests from content script via postMessage
console.log('[Page Script] Message listener registered');
pageContext.addEventListener('message', async function(event) {
// 只处理来自同源的消息
if (event.source !== pageContext) return;
if (event.data && event.data.type === '__fetchLocation') {
console.log('[Page Script] Received fetch request for:', event.data.screenName);
const { screenName, requestId } = event.data;
// 如果 headers 还没准备好,等待一下,但不要等太久
if (!headersReady) {
console.log('[Page Script] Headers not ready, waiting...');
let waitCount = 0;
while (!headersReady && waitCount < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
waitCount++;
}
}
try {
const variables = JSON.stringify({ screenName });
const url = `https://x.com/i/api/graphql/XRqGa7EeokUU5kppkh13EA/AboutAccountQuery?variables=${encodeURIComponent(variables)}`;
// 构建 headers,优先使用捕获的,否则使用基本 headers
let headers = {};
if (twitterHeaders && Object.keys(twitterHeaders).length > 0) {
headers = { ...twitterHeaders };
console.log('[Page Script] Using captured headers:', Object.keys(headers));
} else {
// 使用基本 headers,浏览器会自动添加必要的认证 headers(通过 cookies)
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
console.log('[Page Script] Using default headers (cookies will be included automatically)');
}
console.log('[Page Script] Making request to:', url);
const response = await fetch(url, {
method: 'GET',
credentials: 'include', // 这很重要,确保包含 cookies
headers: headers,
referrer: pageContext.location.href,
referrerPolicy: 'origin-when-cross-origin'
});
console.log('[Page Script] Response status:', response.status, response.statusText);
let location = null;
if (response.ok) {
const data = await response.json();
console.log(`API response for ${screenName}:`, data);
location = data?.data?.user_result_by_screen_name?.result?.about_profile?.account_based_in || null;
console.log(`Extracted location for ${screenName}:`, location);
if (!location && data?.data?.user_result_by_screen_name?.result) {
console.log('User result available but no location:', {
hasAboutProfile: !!data.data.user_result_by_screen_name.result.about_profile,
aboutProfile: data.data.user_result_by_screen_name.result.about_profile
});
}
} else {
const errorText = await response.text().catch(() => '');
if (response.status === 429) {
const resetTime = response.headers.get('x-rate-limit-reset');
const remaining = response.headers.get('x-rate-limit-remaining');
const limit = response.headers.get('x-rate-limit-limit');
if (resetTime) {
const resetDate = new Date(parseInt(resetTime) * 1000);
const now = Date.now();
const waitTime = resetDate.getTime() - now;
console.log(`Rate limited! Limit: ${limit}, Remaining: ${remaining}`);
console.log(`Rate limit resets at: ${resetDate.toLocaleString()}`);
console.log(`Waiting ${Math.ceil(waitTime / 1000 / 60)} minutes before retrying...`);
pageContext.postMessage({
type: '__rateLimitInfo',
resetTime: parseInt(resetTime),
waitTime: Math.max(0, waitTime)
}, '*');
}
} else {
console.log(`Twitter API error for ${screenName}:`, response.status, response.statusText, errorText.substring(0, 200));
}
}
console.log('[Page Script] Sending response for', screenName, 'location:', location);
pageContext.postMessage({
type: '__locationResponse',
screenName,
location,
requestId,
isRateLimited: response.status === 429
}, '*');
} catch (error) {
console.error('Error fetching location:', error);
pageContext.postMessage({
type: '__locationResponse',
screenName,
location: null,
requestId
}, '*');
}
}
});
console.log('Page script injection completed');
// 验证注入是否成功
setTimeout(() => {
// 通过postMessage测试页面脚本是否响应
const testId = 'test_' + Date.now();
let responded = false;
const testHandler = (event) => {
if (event.source !== window) return;
if (event.data && event.data.type === '__pageScriptReady') {
responded = true;
window.removeEventListener('message', testHandler);
console.log('Page script is ready and responding');
}
};
window.addEventListener('message', testHandler);
// 发送测试消息
window.postMessage({ type: '__testPageScript', testId }, '*');
setTimeout(() => {
if (!responded) {
console.warn('Page script may not be responding to messages');
}
window.removeEventListener('message', testHandler);
}, 2000);
}, 500);
} catch (error) {
console.error('Failed to inject page script:', error);
}
// Listen for rate limit info from page script
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data && event.data.type === '__rateLimitInfo') {
rateLimitResetTime = event.data.resetTime;
const waitTime = event.data.waitTime;
console.log(`Rate limit detected. Will resume requests in ${Math.ceil(waitTime / 1000 / 60)} minutes`);
}
});
}
// ==================== API请求处理 ====================
async function processRequestQueue() {
if (isProcessingQueue || requestQueue.length === 0) {
return;
}
if (rateLimitResetTime > 0) {
const now = Math.floor(Date.now() / 1000);
if (now < rateLimitResetTime) {
const waitTime = (rateLimitResetTime - now) * 1000;
console.log(`Rate limited. Waiting ${Math.ceil(waitTime / 1000 / 60)} minutes...`);
setTimeout(processRequestQueue, Math.min(waitTime, 60000));
return;
} else {
rateLimitResetTime = 0;
}
}
isProcessingQueue = true;
while (requestQueue.length > 0 && activeRequests < MAX_CONCURRENT_REQUESTS) {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_INTERVAL - timeSinceLastRequest));
}
const { screenName, resolve, reject } = requestQueue.shift();
activeRequests++;
lastRequestTime = Date.now();
makeLocationRequest(screenName)
.then(location => {
resolve(location);
})
.catch(error => {
reject(error);
})
.finally(() => {
activeRequests--;
setTimeout(processRequestQueue, 200);
});
}
isProcessingQueue = false;
}
function makeLocationRequest(screenName) {
return new Promise((resolve, reject) => {
const requestId = Date.now() + Math.random();
let resolved = false;
let retryCount = 0;
const maxRetries = 3;
let checkTimer = null;
const handler = (event) => {
// 接受来自页面上下文的消息(unsafeWindow)
// 在油猴脚本中,页面脚本和content script的window是不同的
// 所以我们需要接受来自任何源的消息,但验证消息内容
if (!event.data || event.data.type !== '__locationResponse') {
return;
}
// 调试:记录所有收到的消息
console.log(`[Content Script] Received response for ${event.data.screenName}, requestId: ${event.data.requestId}, expected: ${requestId}`);
// 检查是否匹配我们的请求
if (event.data.screenName === screenName &&
event.data.requestId === requestId) {
if (resolved) {
console.log(`[Content Script] Already resolved for ${screenName}, ignoring duplicate response`);
return;
}
resolved = true;
if (checkTimer) clearTimeout(checkTimer);
window.removeEventListener('message', handler);
const location = event.data.location;
const isRateLimited = event.data.isRateLimited || false;
console.log(`[Content Script] Processing response for ${screenName}, location: ${location}`);
if (!isRateLimited) {
saveCacheEntry(screenName, location || null);
} else {
console.log(`Not caching null for ${screenName} due to rate limit`);
}
resolve(location || null);
}
};
window.addEventListener('message', handler);
// 发送请求,如果失败则重试
const sendRequest = () => {
console.log(`[Content Script] Sending request for ${screenName}, requestId: ${requestId}`);
window.postMessage({
type: '__fetchLocation',
screenName,
requestId
}, '*');
};
sendRequest();
// 设置一个检查,如果3秒后还没收到响应,可能是页面脚本未准备好
checkTimer = setTimeout(() => {
if (!resolved && retryCount < maxRetries) {
retryCount++;
console.log(`Retrying request for ${screenName} (attempt ${retryCount}/${maxRetries})`);
// 重新注入页面脚本
injectPageScript();
setTimeout(sendRequest, 1000);
}
}, 3000);
// 超时处理
setTimeout(() => {
if (!resolved) {
resolved = true;
clearTimeout(checkTimer);
window.removeEventListener('message', handler);
console.log(`Request timeout for ${screenName}, not caching`);
resolve(null);
}
}, 15000); // 增加到15秒
});
}
async function getUserLocation(screenName) {
if (locationCache.has(screenName)) {
const cached = locationCache.get(screenName);
if (cached !== null) {
console.log(`Using cached location for ${screenName}: ${cached}`);
return cached;
} else {
console.log(`Found null in cache for ${screenName}, will retry API call`);
locationCache.delete(screenName);
}
}
console.log(`Queueing API request for ${screenName}`);
return new Promise((resolve, reject) => {
requestQueue.push({ screenName, resolve, reject });
processRequestQueue();
});
}
// ==================== DOM处理 ====================
function extractUsername(element) {
const usernameElement = element.querySelector('[data-testid="UserName"], [data-testid="User-Name"]');
if (usernameElement) {
const links = usernameElement.querySelectorAll('a[href^="/"]');
for (const link of links) {
const href = link.getAttribute('href');
const match = href.match(/^\/([^\/\?]+)/);
if (match && match[1]) {
const username = match[1];
const excludedRoutes = ['home', 'explore', 'notifications', 'messages', 'i', 'compose', 'search', 'settings', 'bookmarks', 'lists', 'communities'];
if (!excludedRoutes.includes(username) &&
!username.startsWith('hashtag') &&
!username.startsWith('search') &&
username.length > 0 &&
username.length < 20) {
return username;
}
}
}
}
const allLinks = element.querySelectorAll('a[href^="/"]');
const seenUsernames = new Set();
for (const link of allLinks) {
const href = link.getAttribute('href');
if (!href) continue;
const match = href.match(/^\/([^\/\?]+)/);
if (!match || !match[1]) continue;
const potentialUsername = match[1];
if (seenUsernames.has(potentialUsername)) continue;
seenUsernames.add(potentialUsername);
const excludedRoutes = ['home', 'explore', 'notifications', 'messages', 'i', 'compose', 'search', 'settings', 'bookmarks', 'lists', 'communities', 'hashtag'];
if (excludedRoutes.some(route => potentialUsername === route || potentialUsername.startsWith(route))) {
continue;
}
if (potentialUsername.includes('status') || potentialUsername.match(/^\d+$/)) {
continue;
}
const text = link.textContent?.trim() || '';
const linkText = text.toLowerCase();
const usernameLower = potentialUsername.toLowerCase();
if (text.startsWith('@')) {
return potentialUsername;
}
if (linkText === usernameLower || linkText === `@${usernameLower}`) {
return potentialUsername;
}
const parent = link.closest('[data-testid="UserName"], [data-testid="User-Name"]');
if (parent) {
if (potentialUsername.length > 0 && potentialUsername.length < 20 && !potentialUsername.includes('/')) {
return potentialUsername;
}
}
if (text && text.trim().startsWith('@')) {
const atUsername = text.trim().substring(1);
if (atUsername === potentialUsername) {
return potentialUsername;
}
}
}
const textContent = element.textContent || '';
const atMentionMatches = textContent.matchAll(/@([a-zA-Z0-9_]+)/g);
for (const match of atMentionMatches) {
const username = match[1];
const link = element.querySelector(`a[href="/${username}"], a[href^="/${username}?"]`);
if (link) {
const isInUserNameContainer = link.closest('[data-testid="UserName"], [data-testid="User-Name"]');
if (isInUserNameContainer) {
return username;
}
}
}
return null;
}
function findHandleSection(container, screenName) {
return Array.from(container.querySelectorAll('div')).find(div => {
const link = div.querySelector(`a[href="/${screenName}"]`);
if (link) {
const text = link.textContent?.trim();
return text === `@${screenName}`;
}
return false;
});
}
function createLoadingShimmer() {
const shimmer = document.createElement('span');
shimmer.setAttribute('data-twitter-flag-shimmer', 'true');
shimmer.style.display = 'inline-block';
shimmer.style.width = '20px';
shimmer.style.height = '16px';
shimmer.style.marginLeft = '4px';
shimmer.style.marginRight = '4px';
shimmer.style.verticalAlign = 'middle';
shimmer.style.borderRadius = '2px';
shimmer.style.background = 'linear-gradient(90deg, rgba(113, 118, 123, 0.2) 25%, rgba(113, 118, 123, 0.4) 50%, rgba(113, 118, 123, 0.2) 75%)';
shimmer.style.backgroundSize = '200% 100%';
shimmer.style.animation = 'shimmer 1.5s infinite';
if (!document.getElementById('twitter-flag-shimmer-style')) {
const style = document.createElement('style');
style.id = 'twitter-flag-shimmer-style';
style.textContent = `
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
`;
document.head.appendChild(style);
}
return shimmer;
}
async function addFlagToUsername(usernameElement, screenName) {
if (usernameElement.dataset.flagAdded === 'true') {
return;
}
if (processingUsernames.has(screenName)) {
await new Promise(resolve => setTimeout(resolve, 500));
if (usernameElement.dataset.flagAdded === 'true') {
return;
}
usernameElement.dataset.flagAdded = 'waiting';
return;
}
usernameElement.dataset.flagAdded = 'processing';
processingUsernames.add(screenName);
const userNameContainer = usernameElement.querySelector('[data-testid="UserName"], [data-testid="User-Name"]');
const shimmerSpan = createLoadingShimmer();
let shimmerInserted = false;
if (userNameContainer) {
const handleSection = findHandleSection(userNameContainer, screenName);
if (handleSection && handleSection.parentNode) {
try {
handleSection.parentNode.insertBefore(shimmerSpan, handleSection);
shimmerInserted = true;
} catch (e) {
try {
userNameContainer.appendChild(shimmerSpan);
shimmerInserted = true;
} catch (e2) {
console.log('Failed to insert shimmer');
}
}
} else {
try {
userNameContainer.appendChild(shimmerSpan);
shimmerInserted = true;
} catch (e) {
console.log('Failed to insert shimmer');
}
}
}
try {
console.log(`Processing flag for ${screenName}...`);
const location = await getUserLocation(screenName);
console.log(`Location for ${screenName}:`, location);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
if (!location) {
console.log(`No location found for ${screenName}, marking as failed`);
usernameElement.dataset.flagAdded = 'failed';
return;
}
const flag = getCountryFlag(location);
if (!flag) {
console.log(`No flag found for location: ${location}`);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'failed';
return;
}
console.log(`Found flag ${flag} for ${screenName} (${location})`);
let usernameLink = null;
const containerForLink = userNameContainer || usernameElement.querySelector('[data-testid="UserName"], [data-testid="User-Name"]');
if (containerForLink) {
const containerLinks = containerForLink.querySelectorAll('a[href^="/"]');
for (const link of containerLinks) {
const text = link.textContent?.trim();
const href = link.getAttribute('href');
const match = href.match(/^\/([^\/\?]+)/);
if (match && match[1] === screenName) {
if (text === `@${screenName}` || text === screenName) {
usernameLink = link;
break;
}
}
}
}
if (!usernameLink && containerForLink) {
const containerLinks = containerForLink.querySelectorAll('a[href^="/"]');
for (const link of containerLinks) {
const text = link.textContent?.trim();
if (text === `@${screenName}`) {
usernameLink = link;
break;
}
}
}
if (!usernameLink) {
const links = usernameElement.querySelectorAll('a[href^="/"]');
for (const link of links) {
const href = link.getAttribute('href');
const text = link.textContent?.trim();
if ((href === `/${screenName}` || href.startsWith(`/${screenName}?`)) &&
(text === `@${screenName}` || text === screenName)) {
usernameLink = link;
break;
}
}
}
if (!usernameLink) {
const links = usernameElement.querySelectorAll('a[href^="/"]');
for (const link of links) {
const href = link.getAttribute('href');
const match = href.match(/^\/([^\/\?]+)/);
if (match && match[1] === screenName) {
const hasVerificationBadge = link.closest('[data-testid="User-Name"]')?.querySelector('[data-testid="icon-verified"]');
if (!hasVerificationBadge || link.textContent?.trim() === `@${screenName}`) {
usernameLink = link;
break;
}
}
}
}
if (!usernameLink) {
console.error(`Could not find username link for ${screenName}`);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'failed';
return;
}
console.log(`Found username link for ${screenName}:`, usernameLink.href, usernameLink.textContent?.trim());
const existingFlag = usernameElement.querySelector('[data-twitter-flag]');
if (existingFlag) {
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'true';
return;
}
const flagSpan = document.createElement('span');
flagSpan.textContent = ` ${flag}`;
flagSpan.setAttribute('data-twitter-flag', 'true');
flagSpan.style.marginLeft = '4px';
flagSpan.style.marginRight = '4px';
flagSpan.style.display = 'inline';
flagSpan.style.color = 'inherit';
flagSpan.style.verticalAlign = 'middle';
const containerForFlag = userNameContainer || usernameElement.querySelector('[data-testid="UserName"], [data-testid="User-Name"]');
if (!containerForFlag) {
console.error(`Could not find UserName container for ${screenName}`);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'failed';
return;
}
const verificationBadge = containerForFlag.querySelector('[data-testid="icon-verified"]');
const handleSection = findHandleSection(containerForFlag, screenName);
let inserted = false;
if (handleSection && handleSection.parentNode === containerForFlag) {
try {
containerForFlag.insertBefore(flagSpan, handleSection);
inserted = true;
console.log(`✓ Inserted flag before handle section for ${screenName}`);
} catch (e) {
console.log('Failed to insert before handle section:', e);
}
}
if (!inserted && handleSection && handleSection.parentNode) {
try {
const handleParent = handleSection.parentNode;
if (handleParent !== containerForFlag && handleParent.parentNode) {
handleParent.parentNode.insertBefore(flagSpan, handleParent);
inserted = true;
console.log(`✓ Inserted flag before handle parent for ${screenName}`);
} else if (handleParent === containerForFlag) {
containerForFlag.insertBefore(flagSpan, handleSection);
inserted = true;
console.log(`✓ Inserted flag before handle section (direct child) for ${screenName}`);
}
} catch (e) {
console.log('Failed to insert before handle parent:', e);
}
}
if (!inserted && handleSection) {
try {
const displayNameLink = containerForFlag.querySelector('a[href^="/"]');
if (displayNameLink) {
const displayNameContainer = displayNameLink.closest('div');
if (displayNameContainer && displayNameContainer.parentNode) {
if (displayNameContainer.parentNode === handleSection.parentNode) {
displayNameContainer.parentNode.insertBefore(flagSpan, handleSection);
inserted = true;
console.log(`✓ Inserted flag between display name and handle (siblings) for ${screenName}`);
} else {
displayNameContainer.parentNode.insertBefore(flagSpan, displayNameContainer.nextSibling);
inserted = true;
console.log(`✓ Inserted flag after display name container for ${screenName}`);
}
}
}
} catch (e) {
console.log('Failed to insert after display name:', e);
}
}
if (!inserted) {
try {
containerForFlag.appendChild(flagSpan);
inserted = true;
console.log(`✓ Inserted flag at end of UserName container for ${screenName}`);
} catch (e) {
console.error('Failed to append flag to User-Name container:', e);
}
}
if (inserted) {
usernameElement.dataset.flagAdded = 'true';
console.log(`✓ Successfully added flag ${flag} for ${screenName} (${location})`);
const waitingContainers = document.querySelectorAll(`[data-flag-added="waiting"]`);
waitingContainers.forEach(container => {
const waitingUsername = extractUsername(container);
if (waitingUsername === screenName) {
addFlagToUsername(container, screenName).catch(() => {});
}
});
} else {
console.error(`✗ Failed to insert flag for ${screenName} - tried all strategies`);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'failed';
}
} catch (error) {
console.error(`Error processing flag for ${screenName}:`, error);
if (shimmerInserted && shimmerSpan.parentNode) {
shimmerSpan.remove();
}
usernameElement.dataset.flagAdded = 'failed';
} finally {
processingUsernames.delete(screenName);
}
}
function removeAllFlags() {
const flags = document.querySelectorAll('[data-twitter-flag]');
flags.forEach(flag => flag.remove());
const shimmers = document.querySelectorAll('[data-twitter-flag-shimmer]');
shimmers.forEach(shimmer => shimmer.remove());
const containers = document.querySelectorAll('[data-flag-added]');
containers.forEach(container => {
delete container.dataset.flagAdded;
});
console.log('Removed all flags');
}
async function processUsernames() {
if (!extensionEnabled) {
return;
}
// 防止重复处理
if (isProcessing) {
return;
}
isProcessing = true;
try {
const containers = document.querySelectorAll('article[data-testid="tweet"], [data-testid="UserCell"], [data-testid="User-Names"], [data-testid="User-Name"]');
console.log(`Processing ${containers.length} containers for usernames`);
let foundCount = 0;
let processedCount = 0;
let skippedCount = 0;
for (const container of containers) {
const screenName = extractUsername(container);
if (screenName) {
foundCount++;
const status = container.dataset.flagAdded;
if (!status || status === 'failed') {
processedCount++;
addFlagToUsername(container, screenName).catch(err => {
console.error(`Error processing ${screenName}:`, err);
container.dataset.flagAdded = 'failed';
});
} else {
skippedCount++;
}
}
}
if (foundCount > 0 && processedCount > 0) {
console.log(`Found ${foundCount} usernames, processing ${processedCount} new ones, skipped ${skippedCount} already processed`);
}
} finally {
isProcessing = false;
}
}
// 防抖版本的processUsernames
function debouncedProcessUsernames() {
if (processTimer) {
clearTimeout(processTimer);
}
processTimer = setTimeout(() => {
processUsernames();
}, 1000);
}
function initObserver() {
if (observer) {
observer.disconnect();
}
observer = new MutationObserver((mutations) => {
if (!extensionEnabled) {
return;
}
let shouldProcess = false;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
// 只处理实际的内容节点,忽略脚本和样式
if (node.nodeType === 1 &&
(node.tagName === 'ARTICLE' ||
node.querySelector?.('article[data-testid="tweet"], [data-testid="UserCell"], [data-testid="User-Name"]'))) {
shouldProcess = true;
break;
}
}
if (shouldProcess) break;
}
if (shouldProcess) {
debouncedProcessUsernames();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// ==================== 初始化 ====================
async function init() {
console.log('Twitter Location Flag userscript initialized');
loadEnabledState();
loadCache();
if (!extensionEnabled) {
console.log('Extension is disabled');
return;
}
injectPageScript();
setTimeout(() => {
processUsernames();
}, 2000);
initObserver();
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
console.log('Page navigation detected, reprocessing usernames');
// 重新注入页面脚本(SPA导航可能丢失)
injectPageScript();
setTimeout(() => {
processUsernames();
}, 2000);
}
}).observe(document, { subtree: true, childList: true });
setInterval(saveCache, 30000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();