Skip to content

Instantly share code, notes, and snippets.

@schroneko
Created December 11, 2025 01:29
Show Gist options
  • Select an option

  • Save schroneko/e2eada2fa3672ac102988bcad5b44f98 to your computer and use it in GitHub Desktop.

Select an option

Save schroneko/e2eada2fa3672ac102988bcad5b44f98 to your computer and use it in GitHub Desktop.
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