Created
December 11, 2025 01:29
-
-
Save schroneko/e2eada2fa3672ac102988bcad5b44f98 to your computer and use it in GitHub Desktop.
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
| const CONFIG = { | |
| baseUrl: "https://x.com/", | |
| followingPagePath: "/following", | |
| unfollowButtonSelector: 'button[data-testid$="-unfollow"]', | |
| followIndicatorSelector: 'div[data-testid="userFollowIndicator"]', | |
| confirmUnfollowButtonSelector: | |
| 'button[data-testid="confirmationSheetConfirm"]', | |
| scrollAmount: 0.8, | |
| scrollRetryDelay: 800, | |
| dryRunScrollDelay: 200, | |
| minUnfollowDelay: 5000, | |
| maxUnfollowDelay: 7000, | |
| postClickDelay: 3000, | |
| maxScrollRetries: 5, | |
| dryRun: true, // true: 対象者リストのみ収集、false: 実際にアンフォロー | |
| }; | |
| const state = { | |
| isRunning: true, | |
| unfollowCount: 0, | |
| lastScrollPosition: -1, | |
| scrollRetryCount: 0, | |
| unfollowedUsers: [], | |
| processedUsernames: new Set(), | |
| }; | |
| function log(message) { | |
| console.log(`[unfollow-non-followers] ${message}`); | |
| } | |
| function stopUnfollow() { | |
| state.isRunning = false; | |
| log(`停止しました。合計 ${state.unfollowCount} 人をアンフォローしました。`); | |
| if (state.unfollowedUsers.length > 0) { | |
| downloadUrls(); | |
| } | |
| } | |
| function downloadUrls() { | |
| const content = state.unfollowedUsers.join("\n"); | |
| const blob = new Blob([content], { type: "text/plain" }); | |
| const blobUrl = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = blobUrl; | |
| const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-"); | |
| a.download = `unfollowed-${timestamp}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(blobUrl); | |
| log("URL リストをダウンロードしました。"); | |
| } | |
| window.stopUnfollow = stopUnfollow; | |
| function processUnfollow() { | |
| if (!state.isRunning) { | |
| return; | |
| } | |
| const currentUrl = window.location.href; | |
| if (isOnFollowingPage(currentUrl)) { | |
| findAndHandleUnfollowElement(); | |
| } else { | |
| log("フォロー中ページではありません。処理を終了します。"); | |
| stopUnfollow(); | |
| } | |
| } | |
| function isOnFollowingPage(url) { | |
| return ( | |
| url.startsWith(CONFIG.baseUrl) && url.endsWith(CONFIG.followingPagePath) | |
| ); | |
| } | |
| function findAndHandleUnfollowElement() { | |
| if (!state.isRunning) { | |
| return; | |
| } | |
| const unfollowButtons = document.querySelectorAll( | |
| CONFIG.unfollowButtonSelector, | |
| ); | |
| if (CONFIG.dryRun) { | |
| let foundAny = false; | |
| for (const button of unfollowButtons) { | |
| const parentElement = button.closest('div[data-testid="cellInnerDiv"]'); | |
| if (!parentElement) continue; | |
| const profileLink = parentElement.querySelector( | |
| 'a[role="link"][href^="/"]', | |
| ); | |
| const username = profileLink?.getAttribute("href")?.slice(1); | |
| if (username && state.processedUsernames.has(username)) continue; | |
| const followIndicator = parentElement.querySelector( | |
| CONFIG.followIndicatorSelector, | |
| ); | |
| if (!followIndicator && username) { | |
| state.processedUsernames.add(username); | |
| state.unfollowedUsers.push(`https://x.com/${username}`); | |
| state.unfollowCount++; | |
| log(`[ドライラン] 対象: ${username} (${state.unfollowCount} 人目)`); | |
| foundAny = true; | |
| } | |
| } | |
| if (foundAny) { | |
| state.scrollRetryCount = 0; | |
| } | |
| scrollAndRetry(); | |
| return; | |
| } | |
| for (const button of unfollowButtons) { | |
| const parentElement = button.closest('div[data-testid="cellInnerDiv"]'); | |
| if (!parentElement) { | |
| continue; | |
| } | |
| const profileLink = parentElement.querySelector( | |
| 'a[role="link"][href^="/"]', | |
| ); | |
| const username = profileLink?.getAttribute("href")?.slice(1); | |
| if (username && state.processedUsernames.has(username)) { | |
| continue; | |
| } | |
| const followIndicator = parentElement.querySelector( | |
| CONFIG.followIndicatorSelector, | |
| ); | |
| if (!followIndicator) { | |
| state.scrollRetryCount = 0; | |
| handleUnfollow(button, username); | |
| return; | |
| } | |
| } | |
| scrollAndRetry(); | |
| } | |
| function scrollAndRetry() { | |
| if (!state.isRunning) { | |
| return; | |
| } | |
| const htmlElement = document.querySelector("html"); | |
| const currentScrollPosition = htmlElement.scrollTop; | |
| if (currentScrollPosition === state.lastScrollPosition) { | |
| state.scrollRetryCount++; | |
| log( | |
| `スクロール位置が変わりません (${state.scrollRetryCount}/${CONFIG.maxScrollRetries})`, | |
| ); | |
| if (state.scrollRetryCount >= CONFIG.maxScrollRetries) { | |
| log("ページ末尾に到達しました。"); | |
| stopUnfollow(); | |
| return; | |
| } | |
| } else { | |
| state.scrollRetryCount = 0; | |
| } | |
| state.lastScrollPosition = currentScrollPosition; | |
| htmlElement.scrollBy({ | |
| top: window.innerHeight * CONFIG.scrollAmount, | |
| behavior: "smooth", | |
| }); | |
| const delay = CONFIG.dryRun | |
| ? CONFIG.dryRunScrollDelay | |
| : CONFIG.scrollRetryDelay; | |
| setTimeout(() => { | |
| findAndHandleUnfollowElement(); | |
| }, delay); | |
| } | |
| function handleUnfollow(targetElement, username) { | |
| if (!state.isRunning) { | |
| return; | |
| } | |
| if (username) { | |
| state.processedUsernames.add(username); | |
| state.unfollowedUsers.push(`https://x.com/${username}`); | |
| state.unfollowCount++; | |
| if (CONFIG.dryRun) { | |
| log(`[ドライラン] 対象: ${username} (${state.unfollowCount} 人目)`); | |
| setTimeout(() => { | |
| findAndHandleUnfollowElement(); | |
| }, 100); | |
| return; | |
| } | |
| } | |
| targetElement.scrollIntoView({ | |
| behavior: "smooth", | |
| block: "center", | |
| }); | |
| targetElement.click(); | |
| setTimeout(() => { | |
| confirmUnfollow(); | |
| }, CONFIG.postClickDelay); | |
| } | |
| function confirmUnfollow() { | |
| if (!state.isRunning) { | |
| return; | |
| } | |
| const confirmButton = document.querySelector( | |
| CONFIG.confirmUnfollowButtonSelector, | |
| ); | |
| if (confirmButton) { | |
| confirmButton.click(); | |
| log(`アンフォロー完了 (${state.unfollowCount} 人目)`); | |
| } else { | |
| log("確認ダイアログが見つかりませんでした。スキップします。"); | |
| } | |
| const randomDelay = | |
| Math.floor( | |
| Math.random() * (CONFIG.maxUnfollowDelay - CONFIG.minUnfollowDelay), | |
| ) + CONFIG.minUnfollowDelay; | |
| setTimeout(() => { | |
| processUnfollow(); | |
| }, randomDelay); | |
| } | |
| if (CONFIG.dryRun) { | |
| log( | |
| "【ドライランモード】対象者リストのみ収集します(実際のアンフォローは行いません)", | |
| ); | |
| } else { | |
| log("【本番モード】実際にアンフォローを実行します"); | |
| } | |
| log("停止するには window.stopUnfollow() を実行してください。"); | |
| processUnfollow(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment