Last active
August 19, 2026 15:20
-
-
Save Winding6636/e4e371f954f81162d7302939ac15c066 to your computer and use it in GitHub Desktop.
Twitter-X のWeb版のUI改悪による操作性の悪さを元に戻す
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name X Profile Tab Force Redirect | |
| // @namespace https://x.com/ | |
| // @version 4.2 | |
| // @description X(Twitter)のメディアタブ→画像フィルター強制、裸プロフィールURL→/all(すべて)強制。短縮リンク形式(10文字混在)は即除外、それ以外はデバウンスで誤認防止。/home,/search,ステータスページは判定処理自体を無視 | |
| // @match https://x.com/* | |
| // @match https://twitter.com/* | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Xの単一セグメントの予約パス(ユーザー名と衝突しうるので除外する) | |
| // 網羅しきれていない場合があるので、変な挙動があれば要リストへの追加 | |
| const RESERVED_PATHS = new Set([ | |
| 'home', 'explore', 'notifications', 'messages', 'i', 'settings', 'compose', | |
| 'search', 'hashtag', 'intent', 'login', 'logout', 'tos', 'privacy', 'about', | |
| 'download', 'jobs', 'bookmarks', 'lists', 'communities', 'moments', 'share', | |
| 'who_to_follow', 'account', 'premium_sign_up', 'premium', 'grok', | |
| 'verified_organizations', 'business', 'developer', 'help', 'tfw', 'terms', | |
| 'guidelines', 'rules', 'connect_people', 'personalization', 'safety', 'upsell' | |
| ]); | |
| // 裸プロフィールURLへの強制を、この猶予時間(ms)待ってから適用する。 | |
| // x.com/ランダム文字列 のような短縮リンク(開くと別ページへ自動遷移する)を | |
| // 誤って「プロフィールの裸URL」と判定し /all を強制してしまわないための猶予。 | |
| // 猶予中にURLが変化(=実際の遷移先へリダイレクト)した場合は何もしない。 | |
| const BARE_PROFILE_FORCE_DELAY_MS = 1500; | |
| // 「/ユーザー名/media」形式で、まだ filter クエリが付いていないURLかを判定 | |
| function isPlainMediaUrl(url) { | |
| if (!/^\/[^/]+\/media\/?$/.test(url.pathname)) return false; | |
| if (url.searchParams.has('filter')) return false; | |
| return true; | |
| } | |
| // 短縮リンクのコード形式(英大文字・小文字・数字が混在した10文字、アンダースコアなし)を判定する | |
| // 例: 2bQRvThMy9, lx9WmXKyc8, yZVgoWBv5m | |
| // 実在のユーザー名がこの形式に偶然一致する可能性は低いため、該当すれば即座に除外する | |
| function looksLikeShortlinkCode(username) { | |
| if (username.length !== 10) return false; | |
| if (!/^[A-Za-z0-9]+$/.test(username)) return false; | |
| const hasUpper = /[A-Z]/.test(username); | |
| const hasLower = /[a-z]/.test(username); | |
| return hasUpper && hasLower; | |
| } | |
| // 「/ユーザー名」だけのバープロフィールURLかを判定(予約語・短縮リンク形式は除外) | |
| function isBareProfileUrl(url) { | |
| const m = /^\/([A-Za-z0-9_]{1,15})\/?$/.exec(url.pathname); | |
| if (!m) return false; | |
| const username = m[1]; | |
| if (RESERVED_PATHS.has(username.toLowerCase())) return false; | |
| if (looksLikeShortlinkCode(username)) return false; | |
| return username; | |
| } | |
| // プロフィールタブと無関係な「通過点」URL(/home, /search, ステータスページ)かどうかを判定する。 | |
| // 短縮リンクの内部的な遷移過程でこれらを一瞬経由することがあり、 | |
| // previousFullPathや保留中タイマーの状態が乱れるのを防ぐため、該当時は判定処理自体を丸ごとスキップする | |
| function isIgnorableTransientUrl(pathname) { | |
| if (pathname === '/home' || pathname.startsWith('/home/')) return true; | |
| if (pathname === '/search' || pathname.startsWith('/search/')) return true; | |
| if (/^\/[A-Za-z0-9_]{1,15}\/status\/\d+\/?$/.test(pathname)) return true; | |
| return false; | |
| } | |
| // 直前に確定していたURL(pathname+search)。 | |
| // 「今まさに強制先の状態にいて、そこから素の状態へ抜けようとした」場合だけ1回だけ許容するために使う | |
| // 例: /media?filter=photo にいる状態から /media への遷移が来たら、それは動画への手動選択とみなし許容する | |
| // 例: /all にいる状態から /(裸URL)への遷移が来たら、それは投稿タブへの手動選択とみなし許容する | |
| let previousFullPath = null; | |
| // 裸プロフィールURL用に予約している遅延強制タイマー | |
| let bareForceTimer = null; | |
| // 指定した絶対パス+クエリへSPA内遷移させる | |
| // すでに目的のURLならpushStateを呼ばない(無限ループ・無駄な処理防止) | |
| function forceNavigate(target) { | |
| if (bareForceTimer) { | |
| clearTimeout(bareForceTimer); | |
| bareForceTimer = null; | |
| } | |
| if (location.pathname + location.search === target) return; | |
| history.pushState({}, '', target); | |
| window.dispatchEvent(new PopStateEvent('popstate')); | |
| } | |
| // URLの状態を確認し、必要なら強制する。 | |
| // メディアタブは即時、裸プロフィールURLは短縮リンク誤認防止のため猶予をおいてから強制する。 | |
| function checkCurrentUrl() { | |
| const url = new URL(location.href); | |
| const currentFull = url.pathname + url.search; | |
| // /home, /search, ステータスページ等の通過点は完全に無視する | |
| // (previousFullPathや保留中タイマーの状態を一切変更しない) | |
| if (isIgnorableTransientUrl(url.pathname)) return; | |
| // URLが変わった時点で、前回分の保留中タイマーは一旦キャンセルする | |
| // (今回のcheckCurrentUrlが改めて必要な判定を行う) | |
| if (bareForceTimer) { | |
| clearTimeout(bareForceTimer); | |
| bareForceTimer = null; | |
| } | |
| // メディアタブ: 即時に強制する | |
| if (isPlainMediaUrl(url)) { | |
| const target = url.pathname.replace(/\/$/, '') + '?filter=photo'; | |
| if (previousFullPath !== target) { | |
| forceNavigate(target); | |
| previousFullPath = target; | |
| return; | |
| } | |
| previousFullPath = currentFull; | |
| return; | |
| } | |
| // 裸プロフィールURL: 短縮リンクの自動遷移である可能性があるため、 | |
| // 猶予時間の間URLが変わらなかった場合のみ強制する | |
| const username = isBareProfileUrl(url); | |
| if (username) { | |
| const target = '/' + username + '/all'; | |
| if (previousFullPath === target) { | |
| // 直前がまさに強制先だった=手動での離脱(投稿タブ選択など)とみなし許容 | |
| previousFullPath = currentFull; | |
| return; | |
| } | |
| bareForceTimer = setTimeout(function () { | |
| bareForceTimer = null; | |
| const nowUrl = new URL(location.href); | |
| // 猶予中にURLが変わっていた=短縮リンク等の自動リダイレクトとみなし、何もしない | |
| if (nowUrl.pathname + nowUrl.search !== currentFull) return; | |
| forceNavigate(target); | |
| previousFullPath = target; | |
| }, BARE_PROFILE_FORCE_DELAY_MS); | |
| return; | |
| } | |
| previousFullPath = currentFull; | |
| } | |
| window.addEventListener('popstate', checkCurrentUrl); | |
| // Xの内部SPA遷移(pushState)を監視。到達後にURLをチェックする | |
| const origPushState = history.pushState; | |
| history.pushState = function (...args) { | |
| origPushState.apply(this, args); | |
| setTimeout(checkCurrentUrl, 0); | |
| }; | |
| // 初回ロード時のチェック(コンテンツ描画待ちのため少し遅延) | |
| setTimeout(checkCurrentUrl, 500); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment