Instagram GhostList

Automatically fetch and scroll your Instagram followers/following, and download a ghost list of non-followers

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Instagram GhostList
// @namespace    @reinaldyalaratte
// @version      1.3
// @description  Automatically fetch and scroll your Instagram followers/following, and download a ghost list of non-followers
// @license      CC-BY-NC
// @match        https://www.instagram.com/*
// @icon         https://images-platform.99static.com//7hoKrIiYxL8VuIjAjvPJt8B_rbk=/63x55:1588x1580/fit-in/590x590/projects-files/66/6611/661127/3fcadb3b-493d-4b86-8b30-3d38007c3a79.png
// @grant        none
// @run-at        document-idle
// ==/UserScript==

(function () {
  'use strict';

  /* ---------- CONFIG ---------- */
  const BTN_ID = 'ig-ghost-final-btn-v1';
  const OVERLAY_ID = 'ig-ghost-final-ov';
  const SESSION_KEY = 'ig_ghost_scan_v1';
  const USER_KEY_PREFIX = 'ig_ghost_saved_v1_';
  const MAX_PHASE_MS = 180000; // 3 min per phase
  const STABLE_LOOPS = 10;
  const POLL_MS = 600;
  const DOWNLOAD_LOCK_PROP = '__ig_ghost_download_lock_v1'; // guard to prevent multi-downloads
  const DOWNLOAD_FLAG_KEY = '__ig_ghost_downloaded_flag_v1'; // sessionStorage flag to prevent repeated downloads across resume calls
  const DOWNLOAD_FLAG_TTL = 60 * 1000; // 60s tolerance window

  /* ---------- HELPERS ---------- */
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log = (...s) => console.log('[IG-GhostFinal]', ...s);

  function getProfileUsernameFromPath() {
    const parts = location.pathname.split('/').filter(Boolean);
    return parts.length ? parts[0] : null;
  }

  function saveList(username, kind, arr) {
    localStorage.setItem(`${USER_KEY_PREFIX}${username}_${kind}`, JSON.stringify({ ts: Date.now(), data: arr }));
  }
  function loadList(username, kind) {
    try {
      const raw = localStorage.getItem(`${USER_KEY_PREFIX}${username}_${kind}`);
      if (!raw) return null;
      return JSON.parse(raw).data || null;
    } catch (e) { return null; }
  }

  function showOverlay(txt) {
    // --- SUPPRESSION: don't show progress-like messages after session finished ---
    // If message looks like progress / items-collected / fetching and there is no active SESSION_KEY,
    // ignore to avoid stale "0 items collected..." left on screen.
    try {
      const isProgressLike = /items collected|fetching followers|fetching following|items collected\.\.\.|⏳/i.test(txt);
      const sessionActive = !!sessionStorage.getItem(SESSION_KEY);
      if (isProgressLike && !sessionActive) {
        // silently ignore progress messages when no scan session active
        log('Suppressing overlay (stale progress):', txt);
        return;
      }
    } catch (e) { /* ignore */ }

    let o = document.getElementById(OVERLAY_ID);
    if (!o) {
      o = document.createElement('div');
      o.id = OVERLAY_ID;
      Object.assign(o.style, {
        position: 'fixed', left: '50%', top: '12%', transform: 'translateX(-50%)',
        zIndex: 2147483647, background: 'rgba(0,0,0,0.78)', color: '#fff',
        padding: '12px 16px', borderRadius: '10px', fontFamily: 'Inter, Arial, sans-serif',
        whiteSpace: 'pre-wrap', textAlign: 'center', maxWidth: '86vw'
      });
      document.body.appendChild(o);
    }
    o.innerText = txt;
    console.log('[IG-GhostFinal]', txt);
  }
  function removeOverlay() { const e = document.getElementById(OVERLAY_ID); if (e) e.remove(); }

  /* ---------- Find clickable element (multi-strategy) ---------- */
  function findOpenElement(type) {
    const suffix = `/${type}/`;
    // 1) visible anchor ending with suffix
    const anchors = Array.from(document.querySelectorAll('a[href]'));
    const vis = anchors.find(a => (a.getAttribute('href') || '').endsWith(suffix) && a.offsetParent !== null);
    if (vis) return vis;
    // 2) any anchor ending with suffix
    const any = anchors.find(a => (a.getAttribute('href') || '').endsWith(suffix));
    if (any) return any;
    // 3) search textual labels (EN/ID) across a/button/span text
    const keywords = type === 'following' ? ['following', 'mengikuti', 'mengikuti'] : ['followers', 'pengikut', 'pengikut'];
    const candidates = Array.from(document.querySelectorAll('a, button, span, div'));
    for (const c of candidates) {
      try {
        const t = (c.innerText || '').toLowerCase().trim();
        if (!t) continue;
        for (const kw of keywords) {
          if (t === kw || t.includes(kw + ' ') || t.includes(' ' + kw)) return c;
        }
      } catch (e) { }
    }
    return null;
  }

  /* ---------- Detect list container: modal or page ---------- */
  function detectListContainer() {
    // modal dialog
    const dialog = document.querySelector('div[role="dialog"]');
    if (dialog) {
      // prefer a UL inside
      const ul = dialog.querySelector('ul');
      if (ul && ul.querySelectorAll('li').length) return { container: ul, mode: 'modal', dialog };
      // fallback: big scrollable child
      const cand = Array.from(dialog.querySelectorAll('div, section')).find(n => {
        try { return n.querySelectorAll && n.querySelectorAll('a[href^="/"]').length > 4 && n.scrollHeight > n.clientHeight + 10; } catch (e) { return false; }
      });
      if (cand) return { container: cand, mode: 'modal', dialog };
      // last fallback: dialog itself
      return { container: dialog, mode: 'modal', dialog };
    }
    // page form
    const main = document.querySelector('main');
    if (main) {
      const ul = main.querySelector('ul');
      if (ul && ul.querySelectorAll('li').length) return { container: ul, mode: 'page' };
      const cand2 = Array.from(main.querySelectorAll('div, section')).find(n => {
        try { return n.querySelectorAll && n.querySelectorAll('a[href^="/"]').length > 6 && n.scrollHeight > n.clientHeight + 10; } catch (e) { return false; }
      });
      if (cand2) return { container: cand2, mode: 'page' };
    }
    return null;
  }

  /* ---------- Fetch interceptor fallback (collects user nodes) ---------- */
  function hookFetch(collector) {
    if (window.__ig_ghost_hooked_v1) return () => { };
    window.__ig_ghost_hooked_v1 = true;
    const orig = window.fetch;
    window.fetch = function (input, init) {
      return orig(input, init).then(async resp => {
        try {
          const url = (typeof input === 'string') ? input : (input && input.url) || '';
          if (url.includes('/graphql/') || url.includes('/web/friendships/') || url.includes('/followers') || url.includes('/following')) {
            resp.clone().json().then(json => {
              try { extractUsersFromObject(json, collector); } catch (e) { }
            }).catch(() => { /* not json */ });
          }
        } catch (e) { }
        return resp;
      });
    };
    return () => { window.fetch = orig; window.__ig_ghost_hooked_v1 = false; };
  }

  function extractUsersFromObject(obj, collector) {
    if (!obj || typeof obj !== 'object') return;
    if (Array.isArray(obj)) return obj.forEach(x => extractUsersFromObject(x, collector));
    if (obj.edges && Array.isArray(obj.edges)) {
      obj.edges.forEach(e => {
        const node = e && e.node ? e.node : e;
        if (node && node.username) collector.set((node.username || '').toLowerCase(), { username: node.username, full_name: node.full_name || '', pic: node.profile_pic_url || node.profile_pic_url_hd || '' });
        else if (node && node.user && node.user.username) {
          const un = node.user.username;
          collector.set((un || '').toLowerCase(), { username: un, full_name: node.user.full_name || '', pic: node.user.profile_pic_url || '' });
        }
      });
    }
    if (obj.username && (obj.full_name || obj.profile_pic_url)) collector.set((obj.username || '').toLowerCase(), { username: obj.username, full_name: obj.full_name || '', pic: obj.profile_pic_url || '' });
    Object.keys(obj).forEach(k => {
      try { extractUsersFromObject(obj[k], collector); } catch (e) { }
    });
  }

  /* ---------- Scroll + collect (DOM + fetch-collector) ---------- */
  function findScrollableAncestor(el) {
    let cur = el;
    while (cur && cur !== document.body) {
      try {
        const cs = getComputedStyle(cur);
        if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && cur.scrollHeight > cur.clientHeight + 10) return cur;
      } catch (e) { }
      cur = cur.parentElement;
    }
    return el;
  }

  async function scrollAndCollect(container, expectedCount, fetchCollector) {
    const scrollable = findScrollableAncestor(container) || container;
    const domMap = new Map();
    function pickDom() {
      try {
        const anchors = Array.from(container.querySelectorAll('a[href^="/"]'));
        anchors.forEach(a => {
          const href = (a.getAttribute('href') || '').replace(/\?.*$/, '').trim();
          const m = href.match(/^\/([A-Za-z0-9._]+)\/$/);
          if (m && m[1]) {
            const u = m[1].toLowerCase();
            if (!domMap.has(u)) {
              let display = '';
              const li = a.closest('li') || a.closest('div');
              if (li) {
                const spans = Array.from(li.querySelectorAll('span'));
                for (const s of spans) {
                  const t = (s.textContent || '').trim();
                  if (!t) continue;
                  if (t.replace(/\s/g, '').toLowerCase() === u) continue;
                  if (t.includes('@')) continue;
                  display = t; break;
                }
              }
              domMap.set(u, { username: u, displayName: display });
            }
          }
        });
      } catch (e) { log('pickDom err', e); }
    }

    pickDom();
    const mo = new MutationObserver(() => pickDom());
    try { mo.observe(container, { childList: true, subtree: true }); } catch (e) { }

    let lastCount = -1, stable = 0;
    const start = Date.now();
    while (true) {
      try {
        for (let i = 0; i < 3; i++) {
          try { scrollable.scrollBy({ top: 800 + Math.floor(domMap.size / 8) * 80, left: 0, behavior: 'auto' }); } catch (e) { try { scrollable.scrollTop = scrollable.scrollHeight; } catch (e) { } }
          await sleep(220);
        }
        try { scrollable.dispatchEvent(new WheelEvent('wheel', { deltaY: 1200, bubbles: true })); } catch (e) { }
      } catch (e) { log('scroll err', e); }

      await sleep(POLL_MS + Math.min(800, Math.floor(domMap.size / 12) * 60));
      pickDom();

      const mergedKeys = new Set([...domMap.keys(), ...fetchCollector.keys()]);
      const count = mergedKeys.size;
      const pct = expectedCount ? Math.round((count / expectedCount) * 100) : null;
      showOverlay(pct ? `⏳ ${count}/${expectedCount} (${pct}%)` : `⏳ ${count} items collected...`);

      if (count === lastCount) stable++; else stable = 0;
      lastCount = count;

      if (expectedCount && count >= expectedCount) { log('expected reached', count, expectedCount); break; }
      if (stable >= STABLE_LOOPS && count > 0) { log('stable reached', count); break; }
      if (Date.now() - start > MAX_PHASE_MS) { log('timeout break', count); break; }
    }

    try { mo.disconnect(); } catch (e) { }
    // merge prefer fetchCollector for completeness
    const merged = new Map();
    fetchCollector.forEach((v, k) => merged.set(k, v));
    domMap.forEach((v, k) => { if (!merged.has(k)) merged.set(k, v); });
    return Array.from(merged.values());
  }

  /* ---------- Open list robustly (click or navigate) ---------- */
  async function openListAndGetContainer(type) {
    // If current URL already ends with /type/ use page detection
    if (location.pathname.endsWith(`/${type}/`)) {
      await sleep(500);
      const p = detectListContainer();
      if (p) return p;
    }

    // find clickable element and click, else navigate
    const el = findOpenElement(type);
    if (el) {
      try { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } catch (e) { }
      try { el.click(); } catch (e) { try { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); } catch (e) { } }
      // wait for container
      const start = Date.now();
      while (Date.now() - start < 15000) {
        const p = detectListContainer();
        if (p) return p;
        await sleep(300);
      }
    }

    // fallback: navigate to /username/type/
    const username = getProfileUsernameFromPath();
    if (username) {
      location.href = `https://www.instagram.com/${username}/${type}/`;
      // wait for page render
      const t0 = Date.now();
      while (Date.now() - t0 < 20000) {
        const p = detectListContainer();
        if (p) return p;
        await sleep(350);
      }
    }
    throw new Error(`[OpenList] ${type} container not found`);
  }

  /* ---------- Orchestrator (session across navigation) ---------- */
  async function startFullScan({ force = true } = {}) {
    // Determine username
    const username = getProfileUsernameFromPath();
    if (!username) {
      alert('Buka profil Instagram-mu (https://www.instagram.com/USERNAME/) lalu klik Ghost List.');
      return;
    }

    // session object to carry across navigations (following -> followers)
    const session = { username, step: 'start', origin: location.href, ts: Date.now() };
    sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
    // navigate to following (openListAndGetContainer will handle if already on page)
    location.href = `https://www.instagram.com/${username}/following/`;
  }

  async function resumeIfScanning() {
    const ss = sessionStorage.getItem(SESSION_KEY);
    if (!ss) return;
    let s;
    try { s = JSON.parse(ss); } catch (e) { sessionStorage.removeItem(SESSION_KEY); return; }
    const username = s.username;
    if (!username) { sessionStorage.removeItem(SESSION_KEY); return; }

    // If on following page/ modal
    if (location.pathname.startsWith(`/${username}/following`)) {
      s.step = 'following';
      sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));
      try {
        showOverlay('🔄 Fetching following — please keep this tab active...');
        // per-phase fetch collector
        const localFetch = new Map();
        const unhook = hookFetch(localFetch);
        const contInfo = await openListAndGetContainer('following');
        const expected = (function () {
          try {
            const hdr = document.querySelector('header') || document.querySelector('main header');
            if (!hdr) return null;
            let f = null;
            Array.from(hdr.querySelectorAll('a[href]')).forEach(a => {
              const href = a.getAttribute('href') || '';
              const txt = (a.innerText || '').trim();
              if (href.endsWith('/following/')) f = parseInt(txt.replace(/[^\d]/g, '')) || f;
            });
            return f;
          } catch (e) { return null; }
        })();
        const arr = await scrollAndCollect(contInfo.container, expected, localFetch);
        saveList(username, 'following', arr.map(x => (x.username || x).toString().toLowerCase()));
        try { unhook(); } catch (e) { }
      } catch (e) {
        console.warn('Error scraping following', e);
      }
      // move to followers
      s.step = 'followers';
      sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));
      location.href = `https://www.instagram.com/${username}/followers/`;
      return;
    }

    // If on followers page
    if (location.pathname.startsWith(`/${username}/followers`)) {
      s.step = 'followers';
      sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));
      try {
        showOverlay('🔄 Fetching followers — please keep this tab active...');
        const localFetch = new Map();
        const unhook = hookFetch(localFetch);
        const contInfo = await openListAndGetContainer('followers');
        const expected = (function () {
          try {
            const hdr = document.querySelector('header') || document.querySelector('main header');
            if (!hdr) return null;
            let fr = null;
            Array.from(hdr.querySelectorAll('a[href]')).forEach(a => {
              const href = a.getAttribute('href') || '';
              const txt = (a.innerText || '').trim();
              if (href.endsWith('/followers/')) fr = parseInt(txt.replace(/[^\d]/g, '')) || fr;
            });
            return fr;
          } catch (e) { return null; }
        })();
        const arr = await scrollAndCollect(contInfo.container, expected, localFetch);
        saveList(username, 'followers', arr.map(x => (x.username || x).toString().toLowerCase()));
        try { unhook(); } catch (e) { }
      } catch (e) {
        console.warn('Error scraping followers', e);
      }
      // done - compute result and immediately download
s.step = 'done';
sessionStorage.setItem(SESSION_KEY, JSON.stringify(s));

const following = loadList(username, 'following') || [];
const followers = loadList(username, 'followers') || [];
const notBack = following.filter(u => !followers.includes(u));

if (notBack.length === 0) {
  showOverlay('✅ Tidak ada akun yang tidak followback.');
  await sleep(1500);
  removeOverlay();
} else {
  // ---------- DOWNLOAD GUARD & SINGLE-DOWNLOAD LOGIC ----------
  // Check sessionStorage recent flag (prevents duplicates across resumeIfScanning invocations)
  const flagRaw = sessionStorage.getItem(DOWNLOAD_FLAG_KEY);
  if (flagRaw) {
    try {
      const parsed = JSON.parse(flagRaw);
      const age = Date.now() - (parsed.ts || 0);
      if (age < DOWNLOAD_FLAG_TTL) {
        showOverlay('🔁 Download already triggered recently — skipping duplicate.');
        await sleep(800);
        removeOverlay();
        // cleanup session to prevent repeated attempts
        sessionStorage.removeItem(SESSION_KEY);
        return;
      }
    } catch (e) { /* ignore parse errors */ }
  }

  if (window[DOWNLOAD_LOCK_PROP]) {
    // already downloading in another call — skip
    showOverlay('🔁 Download already in progress, skipping duplicate.');
    await sleep(800);
    removeOverlay();
  } else {
    try {
      // set locks/flags early to prevent concurrent resume calls from reaching here
      window[DOWNLOAD_LOCK_PROP] = true; // global in-memory lock
      sessionStorage.setItem(DOWNLOAD_FLAG_KEY, JSON.stringify({ ts: Date.now() })); // mark in sessionStorage (survives small reloads)

      let text = 'Daftar akun tidak follback kamu:\n\n';
      notBack.forEach(u => text += `${u}\n`);

      // reuse anchor if exists
      const linkId = 'ig-ghost-download-link-v1';
      let a = document.getElementById(linkId);
      if (!a) {
        a = document.createElement('a');
        a.id = linkId;
        a.style.display = 'none';
        document.body.appendChild(a);
      }
      const blob = new Blob([text], { type: 'text/plain' });
      const blobUrl = URL.createObjectURL(blob);
      a.href = blobUrl;
      a.download = 'ghost_list.txt';

      // give browser a brief moment, then click once, revoke after short delay
      setTimeout(() => {
        try { a.click(); } catch (e) { console.warn('click failed', e); }
        setTimeout(() => {
          try { URL.revokeObjectURL(blobUrl); } catch (e) {}
          // remove anchor node to keep DOM clean
          try { a.remove(); } catch (e) {}
          // clear lock after revoke
          try { delete window[DOWNLOAD_LOCK_PROP]; } catch (e) { window[DOWNLOAD_LOCK_PROP] = false; }
          // leave sessionStorage flag for TTL window to avoid immediate duplicates
        }, 1500);
      }, 200);

      showOverlay(`✅ Selesai — ${notBack.length} akun. File ghost_list.txt diunduh.`);
      await sleep(2000);
      removeOverlay();
    } catch (e) {
      console.error('Download error', e);
      try { delete window[DOWNLOAD_LOCK_PROP]; } catch (ex) { window[DOWNLOAD_LOCK_PROP] = false; }
      // remove session flag to permit retry later
      try { sessionStorage.removeItem(DOWNLOAD_FLAG_KEY); } catch (e2) {}
      showOverlay('❌ Gagal mengunduh file.');
      await sleep(1400);
      removeOverlay();
    }
  }
}

// cleanup session
sessionStorage.removeItem(SESSION_KEY);
return;

    }

    // If session says done and we're at origin profile, show result
    if (s.step === 'done' && location.pathname.startsWith(`/${username}`)) {
      const last = JSON.parse(localStorage.getItem(`${USER_KEY_PREFIX}${username}_last_non`) || 'null') || [];
      removeOverlay();
      // if none — show message but don't download
      if (!last.length) {
        showOverlay('✅ Selesai — tidak ditemukan akun yang tidak followback. (Tidak ada file dibuat)');
        await sleep(1400);
        removeOverlay();
      } else {
        // ---------- PROFILE-PAGE DOWNLOAD (guarded) ----------
        // same download-guard logic as above
        const flagRaw = sessionStorage.getItem(DOWNLOAD_FLAG_KEY);
        if (flagRaw) {
          try {
            const parsed = JSON.parse(flagRaw);
            const age = Date.now() - (parsed.ts || 0);
            if (age < DOWNLOAD_FLAG_TTL) {
              showOverlay('🔁 Download already triggered recently — skipping duplicate.');
              await sleep(800);
              removeOverlay();
              sessionStorage.removeItem(SESSION_KEY);
              return;
            }
          } catch (e) {}
        }

        if (window[DOWNLOAD_LOCK_PROP]) {
          showOverlay('🔁 Download already in progress, skipping duplicate.');
          await sleep(800);
          removeOverlay();
        } else {
          try {
            // set locks early
            window[DOWNLOAD_LOCK_PROP] = true;
            sessionStorage.setItem(DOWNLOAD_FLAG_KEY, JSON.stringify({ ts: Date.now() }));

            let text = 'Daftar akun tidak follback kamu:\n\n';
            last.forEach(u => text += `${u}\n`);
            const linkId = 'ig-ghost-download-link-v1';
            let a = document.getElementById(linkId);
            if (!a) {
              a = document.createElement('a');
              a.id = linkId;
              a.style.display = 'none';
              document.body.appendChild(a);
            }
            const blob = new Blob([text], { type: 'text/plain' });
            const blobUrl = URL.createObjectURL(blob);
            a.href = blobUrl;
            a.download = 'ghost_list.txt';
            setTimeout(() => {
              try { a.click(); } catch (e) {}
              setTimeout(() => {
                try { URL.revokeObjectURL(blobUrl); } catch (e) {}
                try { a.remove(); } catch (e) {}
                try { delete window[DOWNLOAD_LOCK_PROP]; } catch (e) { window[DOWNLOAD_LOCK_PROP] = false; }
                // keep sessionStorage flag for TTL to avoid immediate duplicates
              }, 1500);
            }, 200);
            showOverlay(`✅ Selesai — ${last.length} akun. File ghost_list.txt diunduh.`);
            await sleep(1800);
            removeOverlay();
          } catch (e) {
            console.error('Download error', e);
            try { delete window[DOWNLOAD_LOCK_PROP]; } catch (ex) { window[DOWNLOAD_LOCK_PROP] = false; }
            try { sessionStorage.removeItem(DOWNLOAD_FLAG_KEY); } catch (er) {}
            showOverlay('❌ Gagal mengunduh file.');
            await sleep(1400);
            removeOverlay();
          }
        }
      }
      localStorage.removeItem(`${USER_KEY_PREFIX}${username}_last_non`);
      sessionStorage.removeItem(SESSION_KEY);
    }
  }

  /* ---------- UI injection & boot ---------- */
  function injectButton() {
    if (document.getElementById(BTN_ID)) return;
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.innerText = '👻 Ghost List';
    Object.assign(btn.style, {
      position: 'fixed', right: '22px', bottom: '22px', zIndex: 2147483647,
      background: '#ef4444', color: '#fff', border: 'none', padding: '10px 14px',
      borderRadius: '10px', cursor: 'pointer', fontWeight: 700, boxShadow: '0 8px 30px rgba(0,0,0,0.25)'
    });
    btn.addEventListener('click', () => startFullScan({ force: true }));
    document.body.appendChild(btn);
  }

  async function startFullScan(opts = {}) {
    // basic checks
    const username = getProfileUsernameFromPath();
    if (!username) {
      alert('Buka profil Instagram-mu (https://www.instagram.com/USERNAME/) lalu klik tombol Ghost List.');
      return;
    }
    // start session and navigate to following page — resumeIfScanning handles rest
    showOverlay('🔄 Memulai scan — membuka following...');
    sessionStorage.setItem(SESSION_KEY, JSON.stringify({ username, step: 'start', origin: location.href, ts: Date.now() }));
    location.href = `https://www.instagram.com/${username}/following/`;
  }

  // bootstrap
  const mo = new MutationObserver(() => injectButton());
  mo.observe(document.documentElement, { childList: true, subtree: true });
  injectButton();
    // if a scan is in progress, resume
  setTimeout(() => resumeIfScanning(), 800);

  // try resume periodically until done
  window.__igGhostInterval = setInterval(() => {
    const stillScanning = sessionStorage.getItem(SESSION_KEY);
    if (stillScanning) {
      resumeIfScanning();
    } else {
      clearInterval(window.__igGhostInterval);
      delete window.__igGhostInterval;
      // tunggu sedikit biar overlay terakhir sempat tampil
      setTimeout(() => removeOverlay(), 2000);
    }
  }, 1200);

})();