👤 X/Twitter Profile

Scrapers scripts
1311 lines by @nichxbt

X/Twitter Profile — a free, open-source browser console script for X/Twitter automation. No API keys or fees required.

How to Use

  1. Navigate to x.com and log in
  2. Open DevTools Console (F12 or Cmd+Option+I)
  3. Paste the script below and press Enter

Default Configuration

const CONFIG = {

  // ==========================================
  // 📊 SCRAPING SETTINGS
  // ==========================================

  // Maximum number of the user's posts to collect from the profile feed.
  // Set to Infinity to scrape everything until the feed ends.
  targetPostCount: 50,

  // Maximum replies to collect PER POST when drilling into a thread.
  // Set higher for thorough sentiment analysis; lower for speed.
  maxRepliesPerPost: 50,

  // Maximum scroll attempts on the profile feed before giving up.
  maxFeedScrollAttempts: 200,

  // Maximum scroll attempts inside a single post's thread.
  maxThreadScrollAttempts: 30,

  // Delay between scrolls (ms). Increase if content doesn't load.
  scrollDelay: 2000,

  // Delay after navigating into/out of a post (ms). Lets the page settle.
  navigationDelay: 3000,

  // ==========================================
  // 🎯 POST RANGE FILTER
  // ==========================================
  // Limit scraping to a range of posts by ID or URL.
  // Leave null to scrape from the top of the feed.

  range: {
    // Start collecting posts AFTER this post (exclusive).
    // Accepts a post ID (e.g. '1234567890') or full URL.
    // null = start from the very first post in the feed.
    startPostId: null,

    // Stop collecting posts AFTER this post (inclusive).
    // Accepts a post ID or full URL.
    // null = scrape until targetPostCount or feed end.
    endPostId: null,
  },

  // ==========================================
  // 🔍 CONTENT FILTERS
  // ==========================================

  filters: {
    // Only include posts containing at least one of these words.
    // Empty array = include all.
    whitelist: [],

    // Exclude posts containing any of these words.
    blacklist: [],

    // Only posts from the last N days. 0 = no date limit.
    daysBack: 0,

    // Minimum engagement thresholds. 0 = no minimum.
    minLikes: 0,
    minRetweets: 0,

    // true = skip retweets from the feed
    excludeRetweets: false,
  },

  // ==========================================
  // 📤 EXPORT SETTINGS
  // ==========================================

  export: {
    json: true,
    csv: true,
    markdown: false,
    text: false,
    html: false,
  },

  // ==========================================
  // 🖥️ CONTROL PANEL
  // ==========================================

  panel: {
    // Show the floating control panel
    enabled: true,

    // Panel start position from top-right corner
    top: 20,
    right: 20,
  },

  // ==========================================
  // 🔧 GENERAL
  // ==========================================

  // Copy primary export (JSON) to clipboard on completion
  copyToClipboard: true,

  // Show verbose progress in console
  verbose: true,

  // Also scrape replies for the user's own reply-tweets
  // (when the user replied to someone else's post).
  // If false, only scrapes replies on the user's original posts.
  scrapeRepliesOnUserReplies: true,
};

Full Script

Copy and paste this entire script into your browser DevTools console on x.com.

/**
 * ============================================================
 * 🐦 X/Twitter Profile + Replies Scraper
 * ============================================================
 *
 * Scrapes a user's posts (including their replies) from the
 * /with_replies tab, then navigates into each post to collect
 * all reply comments from other users.
 *
 * Perfect for case studies and sentiment analysis.
 *
 * by nichxbt — https://github.com/nirholas/XActions
 *
 * ============================================================
 * 📖 HOW TO USE:
 * ============================================================
 *
 * 1. Open Chrome/Edge/Firefox and go to the target profile's
 *    "with_replies" page, e.g.:
 *    https://x.com/nichxbt/with_replies
 *
 * 2. Open DevTools (F12 or Cmd+Opt+I)
 *
 * 3. Go to the Console tab
 *
 * 4. If you see a warning about pasting, type:
 *    allow pasting
 *    and press Enter
 *
 * 5. Copy this ENTIRE script and paste it into the console
 *
 * 6. Press Enter to run
 *
 * 7. A floating control panel will appear. Use it to:
 *    - Monitor progress in real time
 *    - Pause / Resume scraping
 *    - Export data at any point (JSON, CSV, Markdown)
 *    - Stop scraping early
 *
 * 8. When scraping completes (or you stop it), exports are
 *    triggered automatically based on CONFIG.export settings.
 *
 * ============================================================
 * ⚙️ CONFIGURATION
 * ============================================================
 */

const CONFIG = {

  // ==========================================
  // 📊 SCRAPING SETTINGS
  // ==========================================

  // Maximum number of the user's posts to collect from the profile feed.
  // Set to Infinity to scrape everything until the feed ends.
  targetPostCount: 50,

  // Maximum replies to collect PER POST when drilling into a thread.
  // Set higher for thorough sentiment analysis; lower for speed.
  maxRepliesPerPost: 50,

  // Maximum scroll attempts on the profile feed before giving up.
  maxFeedScrollAttempts: 200,

  // Maximum scroll attempts inside a single post's thread.
  maxThreadScrollAttempts: 30,

  // Delay between scrolls (ms). Increase if content doesn't load.
  scrollDelay: 2000,

  // Delay after navigating into/out of a post (ms). Lets the page settle.
  navigationDelay: 3000,

  // ==========================================
  // 🎯 POST RANGE FILTER
  // ==========================================
  // Limit scraping to a range of posts by ID or URL.
  // Leave null to scrape from the top of the feed.

  range: {
    // Start collecting posts AFTER this post (exclusive).
    // Accepts a post ID (e.g. '1234567890') or full URL.
    // null = start from the very first post in the feed.
    startPostId: null,

    // Stop collecting posts AFTER this post (inclusive).
    // Accepts a post ID or full URL.
    // null = scrape until targetPostCount or feed end.
    endPostId: null,
  },

  // ==========================================
  // 🔍 CONTENT FILTERS
  // ==========================================

  filters: {
    // Only include posts containing at least one of these words.
    // Empty array = include all.
    whitelist: [],

    // Exclude posts containing any of these words.
    blacklist: [],

    // Only posts from the last N days. 0 = no date limit.
    daysBack: 0,

    // Minimum engagement thresholds. 0 = no minimum.
    minLikes: 0,
    minRetweets: 0,

    // true = skip retweets from the feed
    excludeRetweets: false,
  },

  // ==========================================
  // 📤 EXPORT SETTINGS
  // ==========================================

  export: {
    json: true,
    csv: true,
    markdown: false,
    text: false,
    html: false,
  },

  // ==========================================
  // 🖥️ CONTROL PANEL
  // ==========================================

  panel: {
    // Show the floating control panel
    enabled: true,

    // Panel start position from top-right corner
    top: 20,
    right: 20,
  },

  // ==========================================
  // 🔧 GENERAL
  // ==========================================

  // Copy primary export (JSON) to clipboard on completion
  copyToClipboard: true,

  // Show verbose progress in console
  verbose: true,

  // Also scrape replies for the user's own reply-tweets
  // (when the user replied to someone else's post).
  // If false, only scrapes replies on the user's original posts.
  scrapeRepliesOnUserReplies: true,
};

/**
 * ============================================================
 * 🚀 SCRIPT START — DO NOT MODIFY BELOW UNLESS YOU KNOW
 *    WHAT YOU'RE DOING
 * ============================================================
 */

(async function XProfileRepliesScraper() {
  'use strict';

  // ─── State ──────────────────────────────────────────────
  const state = {
    phase: 'init',          // 'init' | 'feed' | 'replies' | 'done'
    paused: false,
    stopped: false,
    posts: [],              // collected profile posts
    currentPostIndex: -1,   // index of post currently being reply-scraped
    totalReplies: 0,
    startTime: Date.now(),
    feedScrolls: 0,
    reachedStart: !CONFIG.range.startPostId,  // if no start filter, we're already past it
    reachedEnd: false,
    originalUrl: window.location.href,
    errors: [],
  };

  const seenPostIds = new Set();

  // ─── Helpers ────────────────────────────────────────────

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /** Wait while paused, checking every 200ms. */
  async function waitWhilePaused() {
    while (state.paused && !state.stopped) {
      await sleep(200);
    }
  }

  /** Extract post ID from a URL or raw ID string. */
  function normalizePostId(input) {
    if (!input) return null;
    input = String(input).trim();
    // Full URL: https://x.com/user/status/1234567890
    const match = input.match(/\/status\/(\d+)/);
    if (match) return match[1];
    // Raw numeric ID
    if (/^\d+$/.test(input)) return input;
    return null;
  }

  /** Parse engagement strings like '1.2K', '3M'. */
  function parseEngagement(str) {
    if (!str || str === '') return 0;
    str = str.trim().toUpperCase();
    if (str.includes('K')) return Math.round(parseFloat(str) * 1_000);
    if (str.includes('M')) return Math.round(parseFloat(str) * 1_000_000);
    return parseInt(str.replace(/,/g, ''), 10) || 0;
  }

  function extractHashtags(text) {
    return (text.match(/#[\w]+/g) || []);
  }

  function extractMentions(text) {
    return (text.match(/@[\w]+/g) || []);
  }

  function extractUrls(text) {
    return (text.match(/https?:\/\/[^\s]+/g) || []);
  }

  function elapsed() {
    return ((Date.now() - state.startTime) / 1000).toFixed(1);
  }

  function log(msg) {
    if (CONFIG.verbose) console.log(msg);
  }

  // Normalize range IDs
  const startId = normalizePostId(CONFIG.range.startPostId);
  const endId = normalizePostId(CONFIG.range.endPostId);

  // ─── DOM extraction helpers ─────────────────────────────

  /**
   * Extract data from a single tweet article element.
   * Returns null if it can't be parsed.
   */
  function extractTweetFromElement(article) {
    try {
      const linkEl = article.querySelector('a[href*="/status/"]');
      if (!linkEl) return null;
      const url = linkEl.href;
      const id = url.split('/status/')[1]?.split(/[?/]/)[0];
      if (!id) return null;

      const textEl = article.querySelector('[data-testid="tweetText"]');
      const text = textEl ? textEl.innerText : '';

      const timeEl = article.querySelector('time');
      const timestamp = timeEl ? timeEl.getAttribute('datetime') : null;
      const displayTime = timeEl ? timeEl.innerText : '';

      const metric = (testId) => {
        const el = article.querySelector(`[data-testid="${testId}"]`);
        const span = el?.querySelector('span span');
        return span ? span.innerText : '0';
      };

      const replies = metric('reply');
      const retweets = metric('retweet');
      const likes = metric('like');

      const viewsEl = article.querySelector('a[href*="/analytics"]');
      const views = viewsEl ? viewsEl.innerText : '0';

      const hasImage = !!article.querySelector('[data-testid="tweetPhoto"]');
      const hasVideo = !!article.querySelector('[data-testid="videoPlayer"]');
      const hasCard = !!article.querySelector('[data-testid="card.wrapper"]');

      const socialCtx = article.querySelector('[data-testid="socialContext"]')?.innerText || '';
      const isRetweet = socialCtx.includes('reposted');
      const isReply = !!article.querySelector('[data-testid="tweetText"]')
        ?.closest('article')
        ?.querySelector('div[id] > div > div > div > div > a[href*="/status/"]');

      // Get the author handle from the tweet
      const authorEl = article.querySelector('div[data-testid="User-Name"] a[href^="/"]');
      const authorHandle = authorEl ? authorEl.getAttribute('href').replace('/', '') : '';
      const authorNameEl = article.querySelector('div[data-testid="User-Name"] span');
      const authorName = authorNameEl ? authorNameEl.innerText : '';

      return {
        id,
        url,
        text,
        author: {
          handle: authorHandle,
          name: authorName,
        },
        timestamp,
        displayTime,
        metrics: { replies, retweets, likes, views },
        media: { hasImage, hasVideo, hasCard },
        type: { isRetweet, isReply: socialCtx.includes('Replying to') },
        extracted: {
          hashtags: extractHashtags(text),
          mentions: extractMentions(text),
          urls: extractUrls(text),
        },
        scrapedAt: new Date().toISOString(),
      };
    } catch (e) {
      return null;
    }
  }

  /**
   * Check whether a post passes all content filters.
   */
  function passesFilters(tweet) {
    const text = tweet.text.toLowerCase();

    if (CONFIG.filters.whitelist.length > 0) {
      if (!CONFIG.filters.whitelist.some(w => text.includes(w.toLowerCase()))) return false;
    }
    if (CONFIG.filters.blacklist.length > 0) {
      if (CONFIG.filters.blacklist.some(w => text.includes(w.toLowerCase()))) return false;
    }
    if (CONFIG.filters.daysBack > 0 && tweet.timestamp) {
      const cutoff = new Date();
      cutoff.setDate(cutoff.getDate() - CONFIG.filters.daysBack);
      if (new Date(tweet.timestamp) < cutoff) return false;
    }
    if (parseEngagement(tweet.metrics.likes) < CONFIG.filters.minLikes) return false;
    if (parseEngagement(tweet.metrics.retweets) < CONFIG.filters.minRetweets) return false;
    if (CONFIG.filters.excludeRetweets && tweet.type.isRetweet) return false;

    return true;
  }

  // ─── Control Panel UI ──────────────────────────────────

  let panelEl = null;
  let statusEl = null;
  let postsCountEl = null;
  let repliesCountEl = null;
  let phaseEl = null;
  let elapsedEl = null;
  let progressBarEl = null;
  let currentPostEl = null;
  let logAreaEl = null;

  function createPanel() {
    if (!CONFIG.panel.enabled) return;

    // Remove existing panel if re-running
    const existing = document.getElementById('x-scraper-panel');
    if (existing) existing.remove();

    panelEl = document.createElement('div');
    panelEl.id = 'x-scraper-panel';
    panelEl.innerHTML = `
      <style>
        #x-scraper-panel {
          position: fixed;
          top: ${CONFIG.panel.top}px;
          right: ${CONFIG.panel.right}px;
          width: 360px;
          max-height: 90vh;
          background: #15202b;
          color: #e7e9ea;
          border: 1px solid #38444d;
          border-radius: 16px;
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
          font-size: 13px;
          z-index: 999999;
          box-shadow: 0 8px 30px rgba(0,0,0,0.5);
          overflow: hidden;
          user-select: none;
        }
        #x-scraper-panel * { box-sizing: border-box; }
        .xsp-header {
          background: #1d9bf0;
          padding: 12px 16px;
          cursor: move;
          display: flex;
          align-items: center;
          justify-content: space-between;
          font-weight: 700;
          font-size: 14px;
        }
        .xsp-header span { display: flex; align-items: center; gap: 6px; }
        .xsp-body { padding: 12px 16px; }
        .xsp-row {
          display: flex;
          justify-content: space-between;
          padding: 4px 0;
          border-bottom: 1px solid #38444d22;
        }
        .xsp-row .label { color: #8899a6; }
        .xsp-row .value { font-weight: 600; font-variant-numeric: tabular-nums; }
        .xsp-progress {
          margin: 10px 0;
          height: 6px;
          background: #38444d;
          border-radius: 3px;
          overflow: hidden;
        }
        .xsp-progress-bar {
          height: 100%;
          background: #1d9bf0;
          border-radius: 3px;
          transition: width 0.3s ease;
          width: 0%;
        }
        .xsp-current {
          padding: 6px 8px;
          margin: 8px 0;
          background: #192734;
          border-radius: 8px;
          font-size: 12px;
          color: #8899a6;
          max-height: 40px;
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
        }
        .xsp-buttons {
          display: grid;
          grid-template-columns: 1fr 1fr;
          gap: 6px;
          margin-top: 10px;
        }
        .xsp-btn {
          padding: 8px 12px;
          border: none;
          border-radius: 9999px;
          font-size: 12px;
          font-weight: 700;
          cursor: pointer;
          transition: opacity 0.2s;
          text-align: center;
        }
        .xsp-btn:hover { opacity: 0.85; }
        .xsp-btn:active { transform: scale(0.97); }
        .xsp-btn-pause { background: #ffd166; color: #15202b; }
        .xsp-btn-resume { background: #06d6a0; color: #15202b; }
        .xsp-btn-stop { background: #ef476f; color: #fff; }
        .xsp-btn-export { background: #1d9bf0; color: #fff; }
        .xsp-btn-download { background: #8338ec; color: #fff; }
        .xsp-btn-full { grid-column: 1 / -1; }
        .xsp-log {
          margin-top: 10px;
          max-height: 100px;
          overflow-y: auto;
          font-size: 11px;
          color: #8899a6;
          background: #192734;
          border-radius: 8px;
          padding: 6px 8px;
        }
        .xsp-log div { padding: 1px 0; }
        .xsp-log .warn { color: #ffd166; }
        .xsp-log .err { color: #ef476f; }
        .xsp-log .ok { color: #06d6a0; }
        .xsp-minimize {
          background: none;
          border: none;
          color: #fff;
          font-size: 18px;
          cursor: pointer;
          padding: 0 4px;
          line-height: 1;
        }
      </style>

      <div class="xsp-header" id="xsp-drag-handle">
        <span>🐦 X Scraper</span>
        <div style="display:flex;gap:4px;">
          <button class="xsp-minimize" id="xsp-minimize" title="Minimize">−</button>
          <button class="xsp-minimize" id="xsp-close" title="Close panel">×</button>
        </div>
      </div>

      <div class="xsp-body" id="xsp-body">
        <div class="xsp-row">
          <span class="label">Phase</span>
          <span class="value" id="xsp-phase">Initializing…</span>
        </div>
        <div class="xsp-row">
          <span class="label">Status</span>
          <span class="value" id="xsp-status">Running</span>
        </div>
        <div class="xsp-row">
          <span class="label">Posts collected</span>
          <span class="value" id="xsp-posts">0 / ${CONFIG.targetPostCount === Infinity ? '∞' : CONFIG.targetPostCount}</span>
        </div>
        <div class="xsp-row">
          <span class="label">Replies collected</span>
          <span class="value" id="xsp-replies">0</span>
        </div>
        <div class="xsp-row">
          <span class="label">Elapsed</span>
          <span class="value" id="xsp-elapsed">0s</span>
        </div>

        <div class="xsp-progress">
          <div class="xsp-progress-bar" id="xsp-progress-bar"></div>
        </div>

        <div class="xsp-current" id="xsp-current">Preparing…</div>

        <div class="xsp-buttons">
          <button class="xsp-btn xsp-btn-pause" id="xsp-pause">⏸ Pause</button>
          <button class="xsp-btn xsp-btn-stop" id="xsp-stop">⏹ Stop</button>
          <button class="xsp-btn xsp-btn-export" id="xsp-export">📦 Export Now</button>
          <button class="xsp-btn xsp-btn-download" id="xsp-download">💾 Download</button>
          <button class="xsp-btn xsp-btn-export xsp-btn-full" id="xsp-pause-export" style="background:#118ab2;">⏸ Pause & Export</button>
        </div>

        <div class="xsp-log" id="xsp-log"></div>
      </div>
    `;

    document.body.appendChild(panelEl);

    // Cache elements
    statusEl = document.getElementById('xsp-status');
    postsCountEl = document.getElementById('xsp-posts');
    repliesCountEl = document.getElementById('xsp-replies');
    phaseEl = document.getElementById('xsp-phase');
    elapsedEl = document.getElementById('xsp-elapsed');
    progressBarEl = document.getElementById('xsp-progress-bar');
    currentPostEl = document.getElementById('xsp-current');
    logAreaEl = document.getElementById('xsp-log');

    // ── Dragging ──
    let isDragging = false, dragX, dragY;
    const handle = document.getElementById('xsp-drag-handle');
    handle.addEventListener('mousedown', (e) => {
      if (e.target.tagName === 'BUTTON') return;
      isDragging = true;
      dragX = e.clientX - panelEl.offsetLeft;
      dragY = e.clientY - panelEl.offsetTop;
    });
    document.addEventListener('mousemove', (e) => {
      if (!isDragging) return;
      panelEl.style.left = (e.clientX - dragX) + 'px';
      panelEl.style.right = 'auto';
      panelEl.style.top = (e.clientY - dragY) + 'px';
    });
    document.addEventListener('mouseup', () => { isDragging = false; });

    // ── Minimize ──
    const body = document.getElementById('xsp-body');
    document.getElementById('xsp-minimize').addEventListener('click', () => {
      body.style.display = body.style.display === 'none' ? 'block' : 'none';
    });

    // ── Close ──
    document.getElementById('xsp-close').addEventListener('click', () => {
      panelEl.style.display = panelEl.style.display === 'none' ? 'block' : 'none';
    });

    // ── Buttons ──
    document.getElementById('xsp-pause').addEventListener('click', () => {
      if (state.paused) {
        state.paused = false;
        updateStatus('Running');
        panelLog('▶ Resumed', 'ok');
        document.getElementById('xsp-pause').textContent = '⏸ Pause';
        document.getElementById('xsp-pause').className = 'xsp-btn xsp-btn-pause';
      } else {
        state.paused = true;
        updateStatus('Paused');
        panelLog('⏸ Paused', 'warn');
        document.getElementById('xsp-pause').textContent = '▶ Resume';
        document.getElementById('xsp-pause').className = 'xsp-btn xsp-btn-resume';
      }
    });

    document.getElementById('xsp-stop').addEventListener('click', () => {
      state.stopped = true;
      state.paused = false;
      updateStatus('Stopped');
      panelLog('⏹ Stopped by user', 'warn');
    });

    document.getElementById('xsp-export').addEventListener('click', () => {
      panelLog('📦 Exporting…', 'ok');
      exportAllFormats();
    });

    document.getElementById('xsp-download').addEventListener('click', () => {
      panelLog('💾 Downloading…', 'ok');
      downloadAllFormats();
    });

    document.getElementById('xsp-pause-export').addEventListener('click', () => {
      state.paused = true;
      updateStatus('Paused');
      document.getElementById('xsp-pause').textContent = '▶ Resume';
      document.getElementById('xsp-pause').className = 'xsp-btn xsp-btn-resume';
      panelLog('⏸ Paused & exporting…', 'warn');
      exportAllFormats();
    });

    // Timer
    setInterval(() => {
      if (elapsedEl) elapsedEl.textContent = elapsed() + 's';
    }, 1000);
  }

  function updateStatus(s) {
    if (statusEl) statusEl.textContent = s;
  }

  function updatePhase(p) {
    if (phaseEl) phaseEl.textContent = p;
    state.phase = p;
  }

  function updatePostsCount() {
    const target = CONFIG.targetPostCount === Infinity ? '∞' : CONFIG.targetPostCount;
    if (postsCountEl) postsCountEl.textContent = `${state.posts.length} / ${target}`;
  }

  function updateRepliesCount() {
    if (repliesCountEl) repliesCountEl.textContent = String(state.totalReplies);
  }

  function updateProgress(pct) {
    if (progressBarEl) progressBarEl.style.width = Math.min(100, pct).toFixed(1) + '%';
  }

  function updateCurrent(msg) {
    if (currentPostEl) currentPostEl.textContent = msg;
  }

  function panelLog(msg, cls = '') {
    log(msg);
    if (!logAreaEl) return;
    const d = document.createElement('div');
    if (cls) d.className = cls;
    d.textContent = `[${elapsed()}s] ${msg}`;
    logAreaEl.prepend(d);
    // Keep log under 200 entries
    while (logAreaEl.children.length > 200) logAreaEl.lastChild.remove();
  }

  // ─── Export helpers ─────────────────────────────────────

  function buildResult() {
    const profileMatch = state.originalUrl.match(/x\.com\/([^\/]+)/);
    const profileName = profileMatch ? profileMatch[1] : 'unknown';

    const totalReplyCount = state.posts.reduce((sum, p) => sum + (p.replies_scraped?.length || 0), 0);

    return {
      profile: profileName,
      profileUrl: `https://x.com/${profileName}`,
      scrapedAt: new Date().toISOString(),
      duration: elapsed() + 's',
      totalPosts: state.posts.length,
      totalRepliesScraped: totalReplyCount,
      config: {
        targetPostCount: CONFIG.targetPostCount,
        maxRepliesPerPost: CONFIG.maxRepliesPerPost,
        startPostId: startId,
        endPostId: endId,
        filters: CONFIG.filters,
      },
      posts: state.posts,
    };
  }

  function downloadFile(content, filename, mimeType) {
    try {
      const blob = new Blob([content], { type: mimeType });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      return true;
    } catch (e) {
      console.error('Download failed:', e);
      return false;
    }
  }

  function toCSV(result) {
    // Posts CSV
    const postHeaders = ['PostID', 'URL', 'Author', 'Date', 'Text', 'Likes', 'Retweets', 'Replies', 'Views', 'IsRetweet', 'IsReply', 'Hashtags', 'RepliesScraped'];
    const postRows = result.posts.map(p => [
      p.id,
      p.url,
      p.author?.handle || '',
      p.displayTime,
      '"' + (p.text || '').replace(/"/g, '""').replace(/\n/g, ' ') + '"',
      parseEngagement(p.metrics.likes),
      parseEngagement(p.metrics.retweets),
      parseEngagement(p.metrics.replies),
      parseEngagement(p.metrics.views),
      p.type.isRetweet,
      p.type.isReply,
      '"' + (p.extracted?.hashtags || []).join(' ') + '"',
      (p.replies_scraped || []).length,
    ].join(','));

    // Replies CSV
    const replyHeaders = ['ParentPostID', 'ReplyID', 'URL', 'Author', 'AuthorHandle', 'Date', 'Text', 'Likes', 'Retweets', 'Replies', 'Views'];
    const replyRows = [];
    result.posts.forEach(p => {
      (p.replies_scraped || []).forEach(r => {
        replyRows.push([
          p.id,
          r.id,
          r.url,
          '"' + (r.author?.name || '').replace(/"/g, '""') + '"',
          r.author?.handle || '',
          r.displayTime,
          '"' + (r.text || '').replace(/"/g, '""').replace(/\n/g, ' ') + '"',
          parseEngagement(r.metrics?.likes),
          parseEngagement(r.metrics?.retweets),
          parseEngagement(r.metrics?.replies),
          parseEngagement(r.metrics?.views),
        ].join(','));
      });
    });

    const postsCSV = [postHeaders.join(','), ...postRows].join('\n');
    const repliesCSV = [replyHeaders.join(','), ...replyRows].join('\n');

    return { postsCSV, repliesCSV };
  }

  function toMarkdown(result) {
    let md = `# X Posts & Replies — @${result.profile}\n\n`;
    md += `> Scraped: ${result.scrapedAt}  \n`;
    md += `> Posts: ${result.totalPosts} | Replies: ${result.totalRepliesScraped}  \n`;
    md += `> Duration: ${result.duration}\n\n`;

    result.posts.forEach((p, i) => {
      md += `---\n\n`;
      md += `## Post ${i + 1} — ${p.displayTime}\n\n`;
      md += `**@${p.author?.handle || '?'}**: ${p.text}\n\n`;
      md += `❤️ ${p.metrics.likes} | 🔄 ${p.metrics.retweets} | 💬 ${p.metrics.replies} | 👁️ ${p.metrics.views}  \n`;
      md += `[View post](${p.url})\n\n`;

      if (p.replies_scraped?.length > 0) {
        md += `### Replies (${p.replies_scraped.length})\n\n`;
        p.replies_scraped.forEach((r, j) => {
          md += `> **${j + 1}. @${r.author?.handle || '?'}** (${r.displayTime}): ${r.text}  \n`;
          md += `> ❤️ ${r.metrics?.likes || 0} | 💬 ${r.metrics?.replies || 0}  \n\n`;
        });
      }
    });

    return md;
  }

  function toPlainText(result) {
    let txt = `X POSTS & REPLIES — @${result.profile}\n`;
    txt += `${'='.repeat(60)}\n`;
    txt += `Scraped: ${result.scrapedAt}\n`;
    txt += `Posts: ${result.totalPosts} | Replies: ${result.totalRepliesScraped}\n\n`;

    result.posts.forEach((p, i) => {
      txt += `${'─'.repeat(60)}\n`;
      txt += `POST ${i + 1} — @${p.author?.handle || '?'} — ${p.displayTime}\n`;
      txt += `${'─'.repeat(60)}\n`;
      txt += `${p.text}\n\n`;
      txt += `Likes: ${p.metrics.likes} | RTs: ${p.metrics.retweets} | Replies: ${p.metrics.replies} | Views: ${p.metrics.views}\n`;
      txt += `URL: ${p.url}\n\n`;

      if (p.replies_scraped?.length > 0) {
        txt += `  REPLIES (${p.replies_scraped.length}):\n`;
        p.replies_scraped.forEach((r, j) => {
          txt += `  ${j + 1}. @${r.author?.handle || '?'} (${r.displayTime}): ${r.text}\n`;
          txt += `     Likes: ${r.metrics?.likes || 0}\n\n`;
        });
      }
      txt += '\n';
    });

    return txt;
  }

  function toHTML(result) {
    const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    let html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>X Posts — @${esc(result.profile)}</title>
<style>
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 24px; background: #f7f9fa; color: #0f1419; }
  h1 { color: #1d9bf0; }
  .meta { color: #536471; margin-bottom: 24px; }
  .post { background: #fff; border: 1px solid #eff3f4; border-radius: 16px; padding: 16px; margin-bottom: 16px; }
  .post-header { display: flex; justify-content: space-between; margin-bottom: 8px; }
  .handle { font-weight: 700; color: #1d9bf0; }
  .post-text { margin: 8px 0; white-space: pre-wrap; }
  .metrics { color: #536471; font-size: 13px; }
  .replies { margin-top: 12px; padding-left: 16px; border-left: 3px solid #1d9bf0; }
  .reply { padding: 8px 0; border-bottom: 1px solid #eff3f4; }
  .reply:last-child { border-bottom: none; }
  .reply-handle { font-weight: 600; color: #536471; }
  a { color: #1d9bf0; text-decoration: none; }
  a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>🐦 X Posts &amp; Replies — @${esc(result.profile)}</h1>
<div class="meta">
  Scraped: ${esc(result.scrapedAt)} | Posts: ${result.totalPosts} | Replies: ${result.totalRepliesScraped} | Duration: ${esc(result.duration)}
</div>
`;

    result.posts.forEach((p, i) => {
      html += `<div class="post">
  <div class="post-header">
    <span class="handle">@${esc(p.author?.handle)}</span>
    <span>${esc(p.displayTime)}</span>
  </div>
  <div class="post-text">${esc(p.text)}</div>
  <div class="metrics">❤️ ${esc(p.metrics.likes)} | 🔄 ${esc(p.metrics.retweets)} | 💬 ${esc(p.metrics.replies)} | 👁️ ${esc(p.metrics.views)} — <a href="${esc(p.url)}" target="_blank">View</a></div>
`;
      if (p.replies_scraped?.length > 0) {
        html += `  <div class="replies"><strong>Replies (${p.replies_scraped.length})</strong>\n`;
        p.replies_scraped.forEach(r => {
          html += `    <div class="reply">
      <span class="reply-handle">@${esc(r.author?.handle)}</span> <span style="color:#536471;font-size:12px;">${esc(r.displayTime)}</span><br>
      ${esc(r.text)}<br>
      <span style="color:#536471;font-size:12px;">❤️ ${esc(r.metrics?.likes || '0')}</span>
    </div>\n`;
        });
        html += `  </div>\n`;
      }
      html += `</div>\n`;
    });

    html += `</body></html>`;
    return html;
  }

  function exportAllFormats() {
    const result = buildResult();
    const dateStr = new Date().toISOString().split('T')[0];
    const profileName = result.profile;

    console.log('');
    console.log('╔════════════════════════════════════════════════════════════╗');
    console.log('║  📦 EXPORTING DATA                                         ║');
    console.log('╚════════════════════════════════════════════════════════════╝');

    if (CONFIG.export.json) {
      const json = JSON.stringify(result, null, 2);
      console.log(`📎 JSON: ${(json.length / 1024).toFixed(1)} KB`);
      if (CONFIG.copyToClipboard) {
        navigator.clipboard.writeText(json).then(
          () => panelLog('📋 JSON copied to clipboard', 'ok'),
          () => panelLog('⚠ Clipboard copy failed', 'warn')
        );
      }
    }

    if (CONFIG.export.csv) {
      const { postsCSV, repliesCSV } = toCSV(result);
      console.log(`📎 Posts CSV: ${(postsCSV.length / 1024).toFixed(1)} KB`);
      console.log(`📎 Replies CSV: ${(repliesCSV.length / 1024).toFixed(1)} KB`);
    }

    console.log('✅ Export data ready! Use 💾 Download to save files.');
    panelLog('📦 Export complete', 'ok');

    // Store on window for console access
    window.__xScraperResult = result;
    console.log('💡 Tip: Access data via window.__xScraperResult');

    return result;
  }

  function downloadAllFormats() {
    const result = buildResult();
    const dateStr = new Date().toISOString().split('T')[0];
    const profileName = result.profile;
    let count = 0;

    if (CONFIG.export.json) {
      downloadFile(JSON.stringify(result, null, 2), `${profileName}-posts-replies-${dateStr}.json`, 'application/json');
      count++;
    }
    if (CONFIG.export.csv) {
      const { postsCSV, repliesCSV } = toCSV(result);
      downloadFile(postsCSV, `${profileName}-posts-${dateStr}.csv`, 'text/csv');
      downloadFile(repliesCSV, `${profileName}-replies-${dateStr}.csv`, 'text/csv');
      count += 2;
    }
    if (CONFIG.export.markdown) {
      downloadFile(toMarkdown(result), `${profileName}-posts-replies-${dateStr}.md`, 'text/markdown');
      count++;
    }
    if (CONFIG.export.text) {
      downloadFile(toPlainText(result), `${profileName}-posts-replies-${dateStr}.txt`, 'text/plain');
      count++;
    }
    if (CONFIG.export.html) {
      downloadFile(toHTML(result), `${profileName}-posts-replies-${dateStr}.html`, 'text/html');
      count++;
    }

    panelLog(`💾 Downloaded ${count} file(s)`, 'ok');
  }

  // ─── Phase 1: Scrape profile feed ──────────────────────

  async function scrapeProfileFeed() {
    updatePhase('📜 Scraping profile feed…');
    panelLog('Phase 1: Collecting posts from feed');

    let noNewCount = 0;
    let prevCount = 0;

    while (
      state.posts.length < CONFIG.targetPostCount &&
      state.feedScrolls < CONFIG.maxFeedScrollAttempts &&
      !state.stopped &&
      !state.reachedEnd
    ) {
      await waitWhilePaused();
      if (state.stopped) break;

      // Extract visible tweets
      const articles = document.querySelectorAll('article[data-testid="tweet"]');
      let newThisRound = 0;

      for (const article of articles) {
        if (state.posts.length >= CONFIG.targetPostCount || state.reachedEnd) break;

        const tweet = extractTweetFromElement(article);
        if (!tweet || seenPostIds.has(tweet.id)) continue;
        seenPostIds.add(tweet.id);

        // ── Range: Start filter ──
        if (!state.reachedStart) {
          if (startId && tweet.id === startId) {
            state.reachedStart = true;
            panelLog(`🎯 Reached start post ${startId}`, 'ok');
          }
          continue; // skip posts before startId
        }

        // ── Range: End filter ──
        if (endId && tweet.id === endId) {
          state.reachedEnd = true;
          panelLog(`🏁 Reached end post ${endId}`, 'ok');
        }

        // Apply content filters
        if (!passesFilters(tweet)) continue;

        // Initialize reply storage
        tweet.replies_scraped = [];

        state.posts.push(tweet);
        newThisRound++;
        updatePostsCount();
      }

      if (newThisRound > 0) {
        noNewCount = 0;
        const pct = CONFIG.targetPostCount === Infinity
          ? 50 // indeterminate
          : (state.posts.length / CONFIG.targetPostCount) * 50;
        updateProgress(pct);
        updateCurrent(`Collected ${state.posts.length} posts…`);
      } else {
        noNewCount++;
        if (noNewCount >= 8) {
          panelLog('⚠ No new posts after 8 scrolls. Feed may have ended.', 'warn');
          break;
        }
      }

      prevCount = state.posts.length;

      // Scroll
      window.scrollTo(0, document.body.scrollHeight);
      await sleep(CONFIG.scrollDelay);
      state.feedScrolls++;
    }

    panelLog(`✅ Feed done: ${state.posts.length} posts collected`, 'ok');
  }

  // ─── Phase 2: Scrape replies for each post ─────────────

  /**
   * Navigate to a tweet's page by simulating a click on the timestamp link,
   * which triggers X's SPA client-side routing (no full page reload).
   */
  async function navigateToTweet(postUrl) {
    // Find the tweet article with this URL and click its timestamp link
    const links = document.querySelectorAll('a[href*="/status/"]');
    let targetLink = null;

    for (const link of links) {
      if (link.href === postUrl) {
        // Prefer the time element link (more reliable for navigation)
        const time = link.querySelector('time');
        if (time) {
          targetLink = link;
          break;
        }
      }
    }

    if (targetLink) {
      targetLink.click();
    } else {
      // Fallback: direct navigation
      window.location.href = postUrl;
    }

    await sleep(CONFIG.navigationDelay);
  }

  async function navigateBack() {
    window.history.back();
    await sleep(CONFIG.navigationDelay);
  }

  /**
   * Once on a tweet detail page, extract replies from the thread.
   * Replies appear as articles below the main tweet.
   */
  async function scrapeThreadReplies(parentPostId) {
    const replies = [];
    const seenReplyIds = new Set();
    let scrollAttempts = 0;
    let noNewCount = 0;

    // Wait for tweet thread to load
    await sleep(1000);

    while (
      replies.length < CONFIG.maxRepliesPerPost &&
      scrollAttempts < CONFIG.maxThreadScrollAttempts &&
      !state.stopped
    ) {
      await waitWhilePaused();
      if (state.stopped) break;

      const articles = document.querySelectorAll('article[data-testid="tweet"]');
      let newThisRound = 0;

      // The first article is usually the main tweet itself; replies follow.
      // We identify replies as tweets whose ID differs from parentPostId.
      for (const article of articles) {
        const tweet = extractTweetFromElement(article);
        if (!tweet) continue;
        if (tweet.id === parentPostId) continue; // skip the main tweet
        if (seenReplyIds.has(tweet.id)) continue;

        seenReplyIds.add(tweet.id);
        replies.push(tweet);
        newThisRound++;
        state.totalReplies++;
        updateRepliesCount();

        if (replies.length >= CONFIG.maxRepliesPerPost) break;
      }

      if (newThisRound > 0) {
        noNewCount = 0;
      } else {
        noNewCount++;
        if (noNewCount >= 4) break; // no more replies loading
      }

      // Scroll to load more replies
      window.scrollTo(0, document.body.scrollHeight);
      await sleep(CONFIG.scrollDelay);
      scrollAttempts++;
    }

    return replies;
  }

  async function scrapeAllReplies() {
    updatePhase('💬 Scraping replies…');
    panelLog(`Phase 2: Scraping replies for ${state.posts.length} posts`);

    for (let i = 0; i < state.posts.length; i++) {
      if (state.stopped) break;
      await waitWhilePaused();

      const post = state.posts[i];
      state.currentPostIndex = i;

      // Skip user-reply tweets if configured
      if (!CONFIG.scrapeRepliesOnUserReplies && post.type.isReply) {
        panelLog(`⏭ Skipping reply-tweet ${i + 1}/${state.posts.length}`);
        continue;
      }

      const replyCountNum = parseEngagement(post.metrics.replies);
      if (replyCountNum === 0) {
        panelLog(`⏭ Post ${i + 1}/${state.posts.length} — 0 replies, skipping`);
        continue;
      }

      updateCurrent(`Post ${i + 1}/${state.posts.length}: scraping replies (${post.metrics.replies} reported)…`);
      panelLog(`💬 Post ${i + 1}/${state.posts.length} — scraping replies…`);

      const progressBase = 50;
      const progressPerPost = 50 / state.posts.length;
      updateProgress(progressBase + progressPerPost * i);

      try {
        // Navigate to the tweet
        await navigateToTweet(post.url);
        await sleep(1000);

        // Check we landed on the right page
        const onTweet = window.location.href.includes('/status/');
        if (!onTweet) {
          panelLog(`⚠ Navigation failed for post ${i + 1}, retrying direct…`, 'warn');
          window.location.href = post.url;
          await sleep(CONFIG.navigationDelay + 1000);
        }

        // Scrape replies
        const replies = await scrapeThreadReplies(post.id);
        post.replies_scraped = replies;

        panelLog(`   → ${replies.length} replies collected`, 'ok');

        // Navigate back to profile
        await navigateBack();

        // Wait for feed to re-render
        await sleep(1000);

        // We may need to scroll back to where we were. Wait for articles to load.
        let attempts = 0;
        while (attempts < 5) {
          const hasArticles = document.querySelectorAll('article[data-testid="tweet"]').length > 0;
          if (hasArticles) break;
          await sleep(1000);
          attempts++;
        }

      } catch (err) {
        panelLog(`⚠ Error on post ${i + 1}: ${err.message}`, 'err');
        state.errors.push({ postId: post.id, error: err.message });

        // Try to get back to the profile
        try {
          if (!window.location.href.includes(state.originalUrl.split('x.com/')[1]?.split('/')[0])) {
            window.location.href = state.originalUrl;
            await sleep(CONFIG.navigationDelay);
          }
        } catch (_) {
          // ignore
        }
      }
    }

    panelLog(`✅ Replies done: ${state.totalReplies} replies total`, 'ok');
  }

  // ─── Main ──────────────────────────────────────────────

  console.log('╔════════════════════════════════════════════════════════════╗');
  console.log('║  🐦 X Profile + Replies Scraper                            ║');
  console.log('║  by nichxbt — https://github.com/nirholas/XActions          ║');
  console.log('╚════════════════════════════════════════════════════════════╝');
  console.log('');

  // Verify we're on the right page
  if (!window.location.href.includes('x.com/')) {
    console.error('❌ This script must be run on x.com. Navigate to a profile page first.');
    return;
  }

  const profileMatch = window.location.pathname.match(/^\/([^\/]+)/);
  const profileName = profileMatch ? profileMatch[1] : 'unknown';

  console.log(`🎯 Profile: @${profileName}`);
  console.log(`📊 Target: ${CONFIG.targetPostCount === Infinity ? '∞' : CONFIG.targetPostCount} posts`);
  console.log(`💬 Max replies per post: ${CONFIG.maxRepliesPerPost}`);
  if (startId) console.log(`🏁 Start after post: ${startId}`);
  if (endId) console.log(`🏁 Stop at post: ${endId}`);
  console.log('');

  // Ensure we're on with_replies
  if (!window.location.href.includes('/with_replies')) {
    console.log('💡 Tip: Navigate to /with_replies to include the user\'s replies too.');
    console.log(`   https://x.com/${profileName}/with_replies`);
    console.log('');
  }

  // Create control panel
  createPanel();
  panelLog('🚀 Scraper initialized');

  try {
    // Phase 1: Collect posts from the profile feed
    await scrapeProfileFeed();

    if (state.stopped) {
      panelLog('Stopped during feed scraping', 'warn');
    }

    if (state.posts.length === 0) {
      panelLog('⚠ No posts found! Check that you\'re on the right profile page.', 'err');
      updatePhase('⚠ No posts found');
      updateStatus('Done (no posts)');
      return;
    }

    // Phase 2: Scrape replies for each post
    if (!state.stopped) {
      // Scroll back to top before navigating into posts
      window.scrollTo(0, 0);
      await sleep(1000);
      await scrapeAllReplies();
    }

    // Done
    updatePhase('✅ Complete');
    updateStatus('Done');
    updateProgress(100);

    const result = buildResult();

    console.log('');
    console.log('╔════════════════════════════════════════════════════════════╗');
    console.log('║  ✅ SCRAPING COMPLETE!                                     ║');
    console.log('╚════════════════════════════════════════════════════════════╝');
    console.log('');
    console.log(`👤 Profile: @${result.profile}`);
    console.log(`📊 Posts collected: ${result.totalPosts}`);
    console.log(`💬 Replies collected: ${result.totalRepliesScraped}`);
    console.log(`⏱️ Duration: ${result.duration}`);
    console.log('');

    // Stats
    let totalLikes = 0, totalRetweets = 0;
    result.posts.forEach(p => {
      totalLikes += parseEngagement(p.metrics.likes);
      totalRetweets += parseEngagement(p.metrics.retweets);
    });
    console.log('📈 ─── POST STATISTICS ───');
    console.log(`   Total Likes: ${totalLikes.toLocaleString()}`);
    console.log(`   Total Retweets: ${totalRetweets.toLocaleString()}`);
    console.log(`   Avg Likes/Post: ${result.totalPosts > 0 ? Math.round(totalLikes / result.totalPosts).toLocaleString() : 0}`);
    console.log(`   Avg Replies Scraped/Post: ${result.totalPosts > 0 ? (result.totalRepliesScraped / result.totalPosts).toFixed(1) : 0}`);
    console.log('');

    if (state.errors.length > 0) {
      console.log(`⚠️ ${state.errors.length} error(s) occurred:`);
      state.errors.forEach(e => console.log(`   Post ${e.postId}: ${e.error}`));
      console.log('');
    }

    // Top posts by reply count
    const byReplies = [...result.posts].sort((a, b) =>
      (b.replies_scraped?.length || 0) - (a.replies_scraped?.length || 0)
    ).slice(0, 5);

    if (byReplies.length > 0) {
      console.log('🏆 ─── TOP POSTS (by replies scraped) ───');
      byReplies.forEach((p, i) => {
        console.log(`   ${i + 1}. [${p.replies_scraped?.length || 0} replies] ${p.text?.substring(0, 60)}…`);
        console.log(`      ${p.url}`);
      });
      console.log('');
    }

    // Auto-export
    panelLog('Running auto-export…', 'ok');
    downloadAllFormats();

    // Store on window
    window.__xScraperResult = result;
    console.log('💡 Access full data: window.__xScraperResult');
    console.log('💡 Re-download: Use the 💾 Download button on the panel');

  } catch (fatalErr) {
    console.error('❌ Fatal error:', fatalErr);
    panelLog(`❌ Fatal: ${fatalErr.message}`, 'err');
    updatePhase('❌ Error');
    updateStatus('Error');

    // Still try to export whatever we have
    if (state.posts.length > 0) {
      panelLog('Attempting partial export…', 'warn');
      downloadAllFormats();
      window.__xScraperResult = buildResult();
    }
  }

})();

⚡ More XActions Scripts

Browse 300+ free browser scripts for X/Twitter automation. No API keys, no fees.

Browse All Scripts