Script to filter X content by user location.
// ==UserScript== // @name Filter X.com Location // @namespace mailto:[email protected] // @version 0.2.2 // @description Script to filter X content by user location. // @match https://x.com/* // @grant GM_xmlhttpRequest // @license AGPL3.0 // ==/UserScript== (() => { // --- CONFIGURATION --- const queryUrl = "https://x.com/i/api/graphql/XRqGa7EeokUU5kppkh13EA/AboutAccountQuery"; const authToken = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; const REMOTE_DB_URL = "https://filter-x-api.phdogee.workers.dev"; // SETTINGS const READ_BATCH_DELAY = 75; // Wait 75ms to collect users for Reading const WRITE_BATCH_DELAY = 2000; // Wait 2s to collect users for Writing (Uploading) const WRITE_BATCH_SIZE = 5; // Or upload immediately if we have 5 users waiting const REQUEST_DELAY = 2000; const RANDOM_JITTER = 1500; const RATE_LIMIT_PAUSE = 60000; const MAX_RETRIES = 5; const CACHE_TTL = 1000 * 60 * 60 * 24 * 30; const csrfToken = decodeURIComponent(document.cookie.match(/(?:^|; )ct0=([^;]+)/)?.[1] || ""); const cache = new Map(); const pending = new Map(); const apiQueue = []; let processingApi = false; let isRateLimited = false; // READ Batching let readBuffer = []; let readTimeout = null; // WRITE Batching let writeBuffer = []; let writeTimeout = null; const storedValues = localStorage.getItem("tweetFilterValues"); let filterValues = storedValues ? storedValues.split("\n").filter(Boolean) : []; let filterMode = localStorage.getItem("tweetFilterMode") || "blacklist"; let filterEnabled = localStorage.getItem("tweetFilterEnabled") === "true"; const countryFlags = new Map([ ["Afghanistan", "🇦🇫"], ["Åland Islands", "🇦🇽"], ["Albania", "🇦🇱"], ["Algeria", "🇩🇿"], ["American Samoa", "🇦🇸"], ["Andorra", "🇦🇩"], ["Angola", "🇦🇴"], ["Anguilla", "🇦🇮"], ["Antarctica", "🇦🇶"], ["Antigua and Barbuda", "🇦🇬"], ["Argentina", "🇦🇷"], ["Armenia", "🇦🇲"], ["Aruba", "🇦🇼"], ["Australia", "🇦🇺"], ["Austria", "🇦🇹"], ["Azerbaijan", "🇦🇿"], ["Bahamas", "🇧🇸"], ["Bahrain", "🇧🇭"], ["Bangladesh", "🇧🇩"], ["Barbados", "🇧🇧"], ["Belarus", "🇧🇾"], ["Belgium", "🇧🇪"], ["Belize", "🇧🇿"], ["Benin", "🇧🇯"], ["Bermuda", "🇧🇲"], ["Bhutan", "🇧🇹"], ["Bolivia, Plurinational State of", "🇧🇴"], ["Bonaire, Sint Eustatius and Saba", "🇧🇶"], ["Bosnia and Herzegovina", "🇧🇦"], ["Botswana", "🇧🇼"], ["Bouvet Island", "🇧🇻"], ["Brazil", "🇧🇷"], ["British Indian Ocean Territory", "🇮🇴"], ["Brunei Darussalam", "🇧🇳"], ["Bulgaria", "🇧🇬"], ["Burkina Faso", "🇧🇫"], ["Burundi", "🇧🇮"], ["Cambodia", "🇰🇭"], ["Cameroon", "🇨🇲"], ["Canada", "🇨🇦"], ["Cape Verde", "🇨🇻"], ["Cayman Islands", "🇰🇾"], ["Central African Republic", "🇨🇫"], ["Chad", "🇹🇩"], ["Chile", "🇨🇱"], ["China", "🇨🇳"], ["Christmas Island", "🇨🇽"], ["Cocos (Keeling) Islands", "🇨🇨"], ["Colombia", "🇨🇴"], ["Comoros", "🇰🇲"], ["Congo", "🇨🇬"], ["Congo, the Democratic Republic of the", "🇨🇩"], ["Cook Islands", "🇨🇰"], ["Costa Rica", "🇨🇷"], ["Côte d'Ivoire", "🇨🇮"], ["Croatia", "🇭🇷"], ["Cuba", "🇨🇺"], ["Curaçao", "🇨🇼"], ["Cyprus", "🇨🇾"], ["Czech Republic", "🇨🇿"], ["Denmark", "🇩🇰"], ["Djibouti", "🇩🇯"], ["Dominica", "🇩🇲"], ["Dominican Republic", "🇩🇴"], ["Ecuador", "🇪🇨"], ["Egypt", "🇪🇬"], ["El Salvador", "🇸🇻"], ["Equatorial Guinea", "🇬🇶"], ["Eritrea", "🇪🇷"], ["Estonia", "🇪🇪"], ["Ethiopia", "🇪🇹"], ["Falkland Islands (Malvinas)", "🇫🇰"], ["Faroe Islands", "🇫🇴"], ["Fiji", "🇫🇯"], ["Finland", "🇫🇮"], ["France", "🇫🇷"], ["French Guiana", "🇬🇫"], ["French Polynesia", "🇵🇫"], ["French Southern Territories", "🇹🇫"], ["Gabon", "🇬🇦"], ["Gambia", "🇬🇲"], ["Georgia", "🇬🇪"], ["Germany", "🇩🇪"], ["Ghana", "🇬🇭"], ["Gibraltar", "🇬🇮"], ["Greece", "🇬🇷"], ["Greenland", "🇬🇱"], ["Grenada", "🇬🇩"], ["Guadeloupe", "🇬🇵"], ["Guam", "🇬🇺"], ["Guatemala", "🇬🇹"], ["Guernsey", "🇬🇬"], ["Guinea", "🇬🇳"], ["Guinea-Bissau", "🇬🇼"], ["Guyana", "🇬🇾"], ["Haiti", "🇭🇹"], ["Heard Island and McDonald Islands", "🇭🇲"], ["Holy See (Vatican City State)", "🇻🇦"], ["Honduras", "🇭🇳"], ["Hong Kong", "🇭🇰"], ["Hungary", "🇭🇺"], ["Iceland", "🇮🇸"], ["India", "🇮🇳"], ["Indonesia", "🇮🇩"], ["Iran, Islamic Republic of", "🇮🇷"], ["Iraq", "🇮🇶"], ["Ireland", "🇮🇪"], ["Isle of Man", "🇮🇲"], ["Israel", "🇮🇱"], ["Italy", "🇮🇹"], ["Jamaica", "🇯🇲"], ["Japan", "🇯🇵"], ["Jersey", "🇯🇪"], ["Jordan", "🇯🇴"], ["Kazakhstan", "🇰🇿"], ["Kenya", "🇰🇪"], ["Kiribati", "🇰🇮"], ["Korea, Democratic People's Republic of", "🇰🇵"], ["Korea, Republic of", "🇰🇷"], ["Kuwait", "🇰🇼"], ["Kyrgyzstan", "🇰🇬"], ["Lao People's Democratic Republic", "🇱🇦"], ["Latvia", "🇱🇻"], ["Lebanon", "🇱🇧"], ["Lesotho", "🇱🇸"], ["Liberia", "🇱🇷"], ["Libya", "🇱🇾"], ["Liechtenstein", "🇱🇮"], ["Lithuania", "🇱🇹"], ["Luxembourg", "🇱🇺"], ["Macao", "🇲🇴"], ["Macedonia, the Former Yugoslav Republic of", "🇲🇰"], ["Madagascar", "🇲🇬"], ["Malawi", "🇲🇼"], ["Malaysia", "🇲🇾"], ["Maldives", "🇲🇻"], ["Mali", "🇲🇱"], ["Malta", "🇲🇹"], ["Marshall Islands", "🇲🇭"], ["Martinique", "🇲🇶"], ["Mauritania", "🇲🇷"], ["Mauritius", "🇲🇺"], ["Mayotte", "🇾🇹"], ["Mexico", "🇲🇽"], ["Micronesia, Federated States of", "🇫🇲"], ["Moldova, Republic of", "🇲🇩"], ["Monaco", "🇲🇨"], ["Mongolia", "🇲🇳"], ["Montenegro", "🇲🇪"], ["Montserrat", "🇲🇸"], ["Morocco", "🇲🇦"], ["Mozambique", "🇲🇿"], ["Myanmar", "🇲🇲"], ["Namibia", "🇳🇦"], ["Nauru", "🇳🇷"], ["Nepal", "🇳🇵"], ["Netherlands", "🇳🇱"], ["New Caledonia", "🇳🇨"], ["New Zealand", "🇳🇿"], ["Nicaragua", "🇳🇮"], ["Niger", "🇳🇪"], ["Nigeria", "🇳🇬"], ["Niue", "🇳🇺"], ["Norfolk Island", "🇳🇫"], ["Northern Mariana Islands", "🇲🇵"], ["Norway", "🇳🇴"], ["Oman", "🇴🇲"], ["Pakistan", "🇵🇰"], ["Palau", "🇵🇼"], ["Palestine, State of", "🇵🇸"], ["Panama", "🇵🇦"], ["Papua New Guinea", "🇵🇬"], ["Paraguay", "🇵🇾"], ["Peru", "🇵🇪"], ["Philippines", "🇵🇭"], ["Pitcairn", "🇵🇳"], ["Poland", "🇵🇱"], ["Portugal", "🇵🇹"], ["Puerto Rico", "🇵🇷"], ["Qatar", "🇶🇦"], ["Réunion", "🇷🇪"], ["Romania", "🇷🇴"], ["Russian Federation", "🇷🇺"], ["Rwanda", "🇷🇼"], ["Saint Barthélemy", "🇧🇱"], ["Saint Helena, Ascension and Tristan da Cunha", "🇸🇭"], ["Saint Kitts and Nevis", "🇰🇳"], ["Saint Lucia", "🇱🇨"], ["Saint Martin (French part)", "🇲🇫"], ["Saint Pierre and Miquelon", "🇵🇲"], ["Saint Vincent and the Grenadines", "🇻🇨"], ["Samoa", "🇼🇸"], ["San Marino", "🇸🇲"], ["Sao Tome and Principe", "🇸🇹"], ["Saudi Arabia", "🇸🇦"], ["Senegal", "🇸🇳"], ["Serbia", "🇷🇸"], ["Seychelles", "🇸🇨"], ["Sierra Leone", "🇸🇱"], ["Singapore", "🇸🇬"], ["Sint Maarten (Dutch part)", "🇸🇽"], ["Slovakia", "🇸🇰"], ["Slovenia", "🇸🇮"], ["Solomon Islands", "🇸🇧"], ["Somalia", "🇸🇴"], ["South Africa", "🇿🇦"], ["South Georgia and the South Sandwich Islands", "🇬🇸"], ["South Sudan", "🇸🇸"], ["Spain", "🇪🇸"], ["Sri Lanka", "🇱🇰"], ["Sudan", "🇸🇩"], ["Suriname", "🇸🇷"], ["Svalbard and Jan Mayen", "🇸🇯"], ["Eswatini", "🇸🇿"], ["Sweden", "🇸🇪"], ["Switzerland", "🇨🇭"], ["Syrian Arab Republic", "🇸🇾"], ["Taiwan", "🇹🇼"], ["Tajikistan", "🇹🇯"], ["Tanzania, United Republic of", "🇹🇿"], ["Thailand", "🇹🇭"], ["Timor-Leste", "🇹🇱"], ["Togo", "🇹🇬"], ["Tokelau", "🇹🇰"], ["Tonga", "🇹🇴"], ["Trinidad and Tobago", "🇹🇹"], ["Tunisia", "🇹🇳"], ["Turkey", "🇹🇷"], ["Turkmenistan", "🇹🇲"], ["Turks and Caicos Islands", "🇹🇨"], ["Tuvalu", "🇹🇻"], ["Uganda", "🇺🇬"], ["Ukraine", "🇺🇦"], ["United Arab Emirates", "🇦🇪"], ["United Kingdom", "🇬🇧"], ["United States", "🇺🇸"], ["United States Minor Outlying Islands", "🇺🇲"], ["Uruguay", "🇺🇾"], ["Uzbekistan", "🇺🇿"], ["Vanuatu", "🇻🇺"], ["Venezuela, Bolivarian Republic of", "🇻🇪"], ["Viet Nam", "🇻🇳"], ["Virgin Islands, British", "🇻🇬"], ["Virgin Islands, U.S.", "🇻🇮"], ["Wallis and Futuna", "🇼🇫"], ["Western Sahara", "🇪🇭"], ["Yemen", "🇾🇪"], ["Zambia", "🇿🇲"], ["Zimbabwe", "🇿🇼"], ["Europe", "🌍"], ["East Asia & Pacific", "🌏"], ["North America", "🌎"], ["South America", "🌎"], ["Eastern Europe (Non-EU)", "🌍"], ["West Asia", "🌏"], ["South Asia", "🌏"], ["Australasia", "🌏"], ]); const dbPromise = new Promise((resolve) => { const request = indexedDB.open("aboutAccountCache", 1); request.onupgradeneeded = () => request.result.createObjectStore("countries"); request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(null); }); if (!csrfToken) return; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // --- HELPER: CSP BYPASS --- const gmFetch = (url, options) => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || "GET", url: url, headers: options.headers, data: options.body, onload: (res) => { resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, json: () => Promise.resolve(JSON.parse(res.responseText)) }); }, onerror: (e) => reject(e) }); }); }; // --- 1. LOCAL STORAGE LOGIC --- const saveCountry = (username, country) => { dbPromise.then((db) => { if (!db) return; const tx = db.transaction("countries", "readwrite"); tx.objectStore("countries").put({ country, timestamp: Date.now() }, username); }); }; const hydrateCache = () => dbPromise.then((db) => { if (!db) return; return new Promise((resolve) => { const tx = db.transaction("countries", "readonly"); const now = Date.now(); tx.objectStore("countries").openCursor().onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const val = cursor.value; const country = typeof val === 'string' ? val : val.country; const time = typeof val === 'string' ? 0 : val.timestamp; if (now - time < CACHE_TTL) cache.set(cursor.key, country); cursor.continue(); } }; tx.oncomplete = resolve; }); }); // --- 2. REMOTE INTERACTION (BATCH READ & WRITE) --- // A. BATCH READ const flushReadBatch = async () => { const batch = [...new Set(readBuffer)]; readBuffer = []; readTimeout = null; if (batch.length === 0) return; if (REMOTE_DB_URL.includes("YOUR-SUBDOMAIN")) { console.warn("[Filter X] Worker URL not set"); return; } try { // Use gmFetch to bypass CSP const res = await gmFetch(REMOTE_DB_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "batch_read", usernames: batch }) }); const results = await res.json(); batch.forEach(username => { if (results[username]) { const country = results[username]; cache.set(username, country); saveCountry(username, country); finalizeUser(username, country); } else { apiQueue.push(username); processApiQueue(); } }); } catch (e) { console.warn("[Filter X] Read Batch Failed (Likely CSP or Net Error)", e); // Fallback: send all to API queue batch.forEach(u => apiQueue.push(u)); processApiQueue(); } }; const addToReadBatch = (username) => { readBuffer.push(username); if (!readTimeout) readTimeout = setTimeout(flushReadBatch, READ_BATCH_DELAY); }; // B. BATCH WRITE const flushWriteBatch = async () => { const batch = [...writeBuffer]; writeBuffer = []; // Clear buffer immediately writeTimeout = null; if (batch.length === 0) return; if (REMOTE_DB_URL.includes("YOUR-SUBDOMAIN")) return; console.log(`[Filter X] Uploading ${batch.length} new locations to Cloudflare...`); try { // Use gmFetch to bypass CSP const res = await gmFetch(REMOTE_DB_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "batch_write", entries: batch }) }); if (!res.ok) console.warn("[Filter X] Write Upload Failed", res.status); } catch (e) { console.warn("[Filter X] Write Error", e); } }; const addToWriteBatch = (username, location) => { if (!username || !location) return; writeBuffer.push({ username, location }); if (writeBuffer.length >= WRITE_BATCH_SIZE) { if (writeTimeout) clearTimeout(writeTimeout); flushWriteBatch(); } else if (!writeTimeout) { writeTimeout = setTimeout(flushWriteBatch, WRITE_BATCH_DELAY); } }; // --- 3. X API LOGIC (Standard Fetch) --- const fetchCountry = async (username) => { for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) { try { const params = new URLSearchParams({ variables: JSON.stringify({ screenName: username }) }); // NOTE: We KEEP standard fetch here because X API is Same-Origin (or allowed) and needs browser cookies/headers automatically. const res = await fetch(`${queryUrl}?${params.toString()}`, { method: "GET", headers: { authorization: authToken, "x-csrf-token": csrfToken }, credentials: "include", }); if (res.status === 429) return "RATE_LIMIT"; if (!res.ok) throw new Error(`HTTP ${res.status}`); const about = (await res.json())?.data?.user_result_by_screen_name?.result?.about_profile; return about?.account_based_in; } catch (e) { await wait(1000 * (attempt + 1) + Math.random() * 500); } } return null; }; const processApiQueue = async () => { if (processingApi || isRateLimited) return; processingApi = true; while (apiQueue.length) { if (isRateLimited) break; const username = apiQueue.shift(); if (cache.has(username)) { finalizeUser(username, cache.get(username)); continue; } const country = await fetchCountry(username); if (country === "RATE_LIMIT") { isRateLimited = true; apiQueue.unshift(username); setTimeout(() => { isRateLimited = false; processApiQueue(); }, RATE_LIMIT_PAUSE); break; } if (country) { cache.set(username, country); saveCountry(username, country); // Add to Write Batch addToWriteBatch(username, country); finalizeUser(username, country); } else { pending.delete(username); } if (apiQueue.length > 0) { await wait(REQUEST_DELAY + Math.random() * RANDOM_JITTER); } } processingApi = false; }; // --- 4. DOM LOGIC --- const finalizeUser = (username, country) => { const targets = pending.get(username); if (!targets) return; targets.forEach(({ container, tweet }) => applyCountry(container, tweet, country)); pending.delete(username); }; const enqueue = (username, container, tweet) => { if (cache.has(username)) { applyCountry(container, tweet, cache.get(username)); return; } const existingTargets = pending.get(username); if (existingTargets) { existingTargets.push({ container, tweet }); return; } pending.set(username, [{ container, tweet }]); addToReadBatch(username); }; const applyCountry = (container, tweet, country) => { const flag = countryFlags.get(country) || country; const flagText = ` ${flag}`; const existing = container.querySelector("[data-account-based-in]"); if (existing) { existing.textContent = flagText; existing.title = country; } else { const node = document.createElement("span"); node.dataset.accountBasedIn = "true"; node.textContent = flagText; node.style.padding = "0px 10px"; node.style.cursor = "help"; node.title = country; container.appendChild(node); } const target = tweet?.parentElement?.parentElement?.parentElement; if (target) { if (filterEnabled && filterValues.length && country) { const lowerCountry = country.toLowerCase(); const match = filterValues.some((value) => lowerCountry.includes(value.toLowerCase())); const show = filterMode === "whitelist" ? match : !match; target.style.display = show ? "" : "none"; } else { target.style.display = ""; } } }; const handleTweet = (tweet) => { const container = tweet.querySelector('[data-testid="User-Name"]'); const username = container?.querySelector("a")?.getAttribute("href")?.replace(/^\//, ""); if (!username) return; enqueue(username, container, tweet); }; const observerCallback = (entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { handleTweet(entry.target); observer.unobserve(entry.target); } }); }; const tweetObserver = new IntersectionObserver(observerCallback, { rootMargin: "200px 0px" }); const findTweets = (root) => { const tweets = root?.querySelectorAll?.('[data-testid="tweet"]'); if (tweets) { tweets.forEach(tweet => { if (!tweet.dataset.filterXObserved) { tweet.dataset.filterXObserved = "true"; tweetObserver.observe(tweet); } }); } if (root?.matches?.('[data-testid="tweet"]')) { if (!root.dataset.filterXObserved) { root.dataset.filterXObserved = "true"; tweetObserver.observe(root); } } }; const findNav = (root) => { const nav = root?.querySelector?.('nav[aria-label="Primary"][role="navigation"]'); if (!nav || nav.querySelector("[data-cutoff-config]")) return; const wrapper = document.createElement("div"); wrapper.dataset.cutoffConfig = "true"; wrapper.style.margin = "8px 0"; wrapper.style.fontFamily = 'TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto'; wrapper.innerHTML = ` <button type="button" aria-haspopup="menu" aria-expanded="false" style="width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: inherit; font-size: 20px; font-weight: 400; line-height: 24px;"> <svg viewBox="0 0 24 24" aria-hidden="true" style="width: 1.75rem; height: 1.75rem; flex-shrink: 0;"><path d="M4 5h16v2l-6 6v4l-4 2v-6L4 7z" fill="currentColor"></path></svg> <span style="white-space: nowrap;">Filters</span> </button> <div style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 12px; min-width: 220px; border-radius: 12px; background: #000; display: none; z-index: 1000; border: 1px solid #333; box-shadow: 0 0 10px rgba(0,0,0,0.5);"> <label style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: white;"> <input type="checkbox" ${filterEnabled ? "checked" : ""}> Enable filter </label> <label style="display: block; color: white;"> Filter entries (one per line) <textarea style="width: 100%; margin-top: 4px; min-height: 80px; background: #222; color: white; border: 1px solid #444;">${filterValues.join("\n")}</textarea> </label> <label style="display: block; margin-top: 8px; color: white;"> Mode <select style="width: 100%; margin-top: 4px; background: #222; color: white; border: 1px solid #444;"> <option value="blacklist"${filterMode === "blacklist" ? " selected" : ""}>Blacklist</option> <option value="whitelist"${filterMode === "whitelist" ? " selected" : ""}>Whitelist</option> </select> </label> </div> `; const button = wrapper.querySelector("button"); const menu = wrapper.querySelector("div"); const checkbox = menu?.querySelector('input[type="checkbox"]'); const textarea = menu?.querySelector("textarea"); const select = menu?.querySelector("select"); if (!button || !menu || !checkbox || !textarea || !select) return; button.addEventListener("click", () => { menu.style.display = "block"; button.setAttribute("aria-expanded", "true"); }); document.addEventListener("click", (event) => { if (!wrapper.contains(event.target)) { menu.style.display = "none"; button.setAttribute("aria-expanded", "false"); } }); checkbox.addEventListener("change", () => { filterEnabled = checkbox.checked; localStorage.setItem("tweetFilterEnabled", filterEnabled.toString()); document.querySelectorAll('[data-testid="tweet"]').forEach(t => handleTweet(t)); }); textarea.addEventListener("change", () => { filterValues = textarea.value .split("\n") .map((value) => value.trim()) .filter(Boolean); localStorage.setItem("tweetFilterValues", filterValues.join("\n")); document.querySelectorAll('[data-testid="tweet"]').forEach(t => handleTweet(t)); }); select.addEventListener("change", () => { filterMode = select.value; localStorage.setItem("tweetFilterMode", filterMode); document.querySelectorAll('[data-testid="tweet"]').forEach(t => handleTweet(t)); }); nav.appendChild(wrapper); }; hydrateCache().then(() => { findNav(document); findTweets(document); }); new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { findTweets(node); findNav(node); } }); }); }).observe(document, { childList: true, subtree: true }); })();