Skip to content

Instantly share code, notes, and snippets.

@Isaac12x
Created September 15, 2025 11:46
Show Gist options
  • Select an option

  • Save Isaac12x/df31cced1049914b8f6b600d2e25e2cf to your computer and use it in GitHub Desktop.

Select an option

Save Isaac12x/df31cced1049914b8f6b600d2e25e2cf to your computer and use it in GitHub Desktop.

Revisions

  1. Isaac12x created this gist Sep 15, 2025.
    162 changes: 162 additions & 0 deletions xUnfollow.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,162 @@
    // Modified script from: https://github.com/nirholas/UnfollowX


    (() => {
    // Collect usernames from the "Following" / "Followers" list without clicking anything.
    // It keeps scrolling, extracting unique handles + URLs, then downloads a CSV at the end.

    const $followButtons = '[data-testid$="-unfollow"], [data-testid$="-follow"]'; // works on Following/Followers
    const $userCell = '[data-testid="UserCell"]';

    const retry = { count: 0, limit: 3 };
    const seen = new Set(); // track handles to avoid duplicates
    const rows = []; // [{username, url}]
    let lastCount = 0;

    const scrollToTheBottom = () => window.scrollTo(0, document.body.scrollHeight);
    const retryLimitReached = () => retry.count >= retry.limit;
    const resetRetry = () => (retry.count = 0);
    const addNewRetry = () => retry.count++;

    const sleep = (seconds) =>
    new Promise((proceed) => {
    console.log(`WAITING FOR ${seconds} SECONDS...`);
    setTimeout(proceed, seconds * 1000);
    });

    // Try to extract the profile anchor for a given user cell or button
    const findProfileAnchor = (rootEl) => {
    if (!rootEl) return null;
    // Common profile link pattern: /username (1-15 chars, letters/digits/underscore), not /status/... etc.
    const anchors = rootEl.querySelectorAll('a[role="link"][href^="/"]');
    const profileRe = /^\/([A-Za-z0-9_]{1,15})\/?$/;

    for (const a of anchors) {
    const href = a.getAttribute('href') || '';
    if (profileRe.test(href)) return a;
    }
    return null;
    };

    const asAbsoluteUrl = (path) => {
    try {
    return new URL(path, location.origin).toString();
    } catch {
    return `${location.origin}${path}`;
    }
    };

    const recordUser = (username, url) => {
    const handle = username.replace(/^@/, '');
    if (handle && !seen.has(handle)) {
    seen.add(handle);
    rows.push({ username: `@${handle}`, url });
    }
    };

    const extractFromUserCells = () => {
    const cells = Array.from(document.querySelectorAll($userCell));
    let found = 0;

    for (const cell of cells) {
    const a = findProfileAnchor(cell);
    if (!a) continue;
    const href = a.getAttribute('href');
    const match = href && href.match(/^\/([A-Za-z0-9_]{1,15})\/?$/);
    if (!match) continue;

    const handle = match[1];
    const abs = asAbsoluteUrl(`/${handle}`);
    if (!seen.has(handle)) {
    recordUser(handle, abs);
    found++;
    }
    }
    return found;
    };

    const extractFromFollowButtons = () => {
    // Fallback path: for each follow/unfollow button, climb to its user cell and grab the anchor
    const buttons = Array.from(document.querySelectorAll($followButtons));
    let found = 0;

    for (const btn of buttons) {
    const cell = btn.closest($userCell) || btn.parentElement;
    const a = findProfileAnchor(cell || btn);
    if (!a) continue;
    const href = a.getAttribute('href');
    const match = href && href.match(/^\/([A-Za-z0-9_]{1,15})\/?$/);
    if (!match) continue;

    const handle = match[1];
    const abs = asAbsoluteUrl(`/${handle}`);
    if (!seen.has(handle)) {
    recordUser(handle, abs);
    found++;
    }
    }
    return found;
    };

    const downloadCsv = () => {
    // Build CSV
    const header = 'username,url';
    const lines = rows.map(({ username, url }) => {
    // Escape quotes if any (unlikely)
    const u = `"${username.replace(/"/g, '""')}"`;
    const l = `"${url.replace(/"/g, '""')}"`;
    return `${u},${l}`;
    });
    const csv = [header, ...lines].join('\n');

    const stamp = new Date()
    .toISOString()
    .replace(/[-:]/g, '')
    .replace(/\..+/, '');
    const filename = `x_usernames_${stamp}.csv`;

    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);

    console.log(`Downloaded ${rows.length} records to ${filename}`);
    };

    const nextBatch = async () => {
    // Extract what's currently in view
    const foundCells = extractFromUserCells();
    const foundBtns = extractFromFollowButtons();
    const newCount = seen.size;

    if (newCount > lastCount) {
    console.log(`Collected ${newCount} unique profiles so far...`);
    lastCount = newCount;
    resetRetry();
    } else {
    addNewRetry();
    console.log(`No new profiles found (retry ${retry.count}/${retry.limit})`);
    }

    // Scroll to load more
    scrollToTheBottom();
    await sleep(1.5);

    if (retryLimitReached()) {
    console.log(`No more new profiles detected. Finishing up...`);
    downloadCsv();
    return;
    } else {
    await sleep(1.0);
    return nextBatch();
    }
    };

    console.log('Starting username/url collection...');
    nextBatch();
    })();