Instagram GhostList

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);

})();