⚡ XActions Scraper Toolbox

Other Tools scripts/twitter
v1.0.0 Updated: 2026-07-19 1310 lines by @nichxbt

Interactive on-page control panel for scraping X/Twitter

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

Full Script

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

// Copyright (c) 2024-2026 nich (@nichxbt). All rights reserved.
/**
 * ============================================================
 * 🧰 XActions Scraper Toolbox
 * ============================================================
 *
 * @name        scraper-toolbox.js
 * @description Interactive on-page control panel for scraping X/Twitter
 *              timelines. Start/pause/stop, live filters, user exclusions,
 *              keyword rules, and one-click exports (JSON/CSV/MD/TXT/HTML,
 *              clipboard). Captures data from X's own GraphQL responses,
 *              so engagement numbers are exact and long posts are complete.
 * @author      nichxbt (https://x.com/nichxbt)
 * @version     1.0.0
 * @date        2026-07-19
 * @repository  https://github.com/nirholas/XActions
 *
 * ============================================================
 * 📋 USAGE
 * ============================================================
 *
 * 1. Open x.com and go to any timeline you want to scrape:
 *    a profile, search results, a list, Likes, Bookmarks, or Home.
 *
 * 2. Open DevTools (F12 or Cmd+Option+I) and click "Console".
 *
 * 3. Paste this ENTIRE script and press Enter.
 *
 * 4. A floating toolbox panel appears (top right). Set your filters,
 *    then press ▶ Start. Pause, resume, or stop any time.
 *
 * 5. Export from the panel whenever you like. Filters are applied at
 *    export time, so you can scrape once and re-export with different
 *    filters without scraping again.
 *
 * HOW IT CAPTURES DATA
 * The toolbox listens to the GraphQL responses the X web app itself
 * receives (UserTweets, SearchTimeline, HomeTimeline, ...) while it
 * auto-scrolls. That means:
 *   - exact like/repost/reply/view/bookmark counts (not "1.2K")
 *   - full text of long posts (no "Show more" truncation)
 *   - media URLs, language, quote/reply/repost structure
 * Anything already on screen before you pasted the script is picked up
 * by a DOM sweep as a fallback (marked source:"dom", approximate counts).
 *
 * Console API (after pasting): window.XActionsToolbox
 *   .tweets()      all captured tweets
 *   .matched()     tweets passing current filters
 *   .export('json'|'csv'|'markdown'|'text'|'html')
 *   .copy('json'|'text')
 *   .start() .pause() .stop() .clear() .destroy()
 */

(function xactionsScraperToolbox() {
  'use strict';

  // Re-pasting the script replaces any live instance cleanly
  if (window.XActionsToolbox && typeof window.XActionsToolbox.destroy === 'function') {
    try { window.XActionsToolbox.destroy(); } catch (e) { /* ignore */ }
  }

  const VERSION = '1.0.0';
  const LS_KEY = 'xactions_toolbox_config_v1';
  const TAG = '[XActions Toolbox]';

  // ==========================================
  // CONFIG (edited live from the panel; persisted in localStorage)
  // ==========================================

  const DEFAULT_CONFIG = {
    targetCount: 300,        // stop after this many MATCHING tweets (0 = no limit)
    scrollDelay: 1800,       // ms between scrolls
    maxScrolls: 400,         // hard cap on scroll attempts
    stallLimit: 8,           // stop after N scrolls with nothing new
    autoDownloadJson: false, // download JSON automatically when a run finishes
    filters: {
      includeKeywords: [],   // keep only tweets containing at least one (empty = all)
      excludeKeywords: [],   // drop tweets containing any
      onlyUsers: [],         // keep only tweets authored by these handles (empty = all)
      excludeUsers: [],      // drop tweets authored by (or reposted from) these handles
      minLikes: 0,
      minRetweets: 0,
      minViews: 0,
      daysBack: 0,           // 0 = no date limit
      excludeRetweets: false,
      excludeReplies: false,
      excludeQuotes: false,
      excludePinned: false,
      mediaFilter: 'all',    // 'all' | 'with-media' | 'without-media'
      lang: ''               // BCP-47 code from the tweet payload, e.g. 'en' (empty = all)
    },
    ui: { top: 80, left: null } // panel position (left null = stick to right edge)
  };

  function loadConfig() {
    try {
      const raw = localStorage.getItem(LS_KEY);
      if (!raw) return structuredClone(DEFAULT_CONFIG);
      const saved = JSON.parse(raw);
      return {
        ...structuredClone(DEFAULT_CONFIG),
        ...saved,
        filters: { ...structuredClone(DEFAULT_CONFIG.filters), ...(saved.filters || {}) },
        ui: { ...structuredClone(DEFAULT_CONFIG.ui), ...(saved.ui || {}) }
      };
    } catch (e) {
      return structuredClone(DEFAULT_CONFIG);
    }
  }

  function saveConfig() {
    try { localStorage.setItem(LS_KEY, JSON.stringify(config)); } catch (e) { /* storage full/blocked */ }
  }

  const config = loadConfig();

  // ==========================================
  // STATE
  // ==========================================

  const state = {
    status: 'idle',        // idle | running | paused | done
    startedAt: null,
    elapsedBefore: 0,      // accumulated ms across pause cycles
    scrolls: 0,
    stalls: 0,
    lastChangeCount: 0,
    destroyed: false
  };

  /** id -> normalized tweet record. Map preserves capture order. */
  const store = new Map();

  // ==========================================
  // SMALL UTILITIES
  // ==========================================

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  function csvList(str) {
    return String(str || '')
      .split(',')
      .map((s) => s.trim().replace(/^@/, ''))
      .filter(Boolean);
  }

  function escapeHtml(s) {
    return String(s ?? '')
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  }

  function csvCell(v) {
    const s = String(v ?? '');
    return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  }

  function parseApprox(str) {
    if (!str) return 0;
    const s = String(str).trim().toUpperCase().replace(/,/g, '');
    const n = parseFloat(s);
    if (Number.isNaN(n)) return 0;
    if (s.includes('K')) return Math.round(n * 1e3);
    if (s.includes('M')) return Math.round(n * 1e6);
    if (s.includes('B')) return Math.round(n * 1e9);
    return Math.round(n);
  }

  function fmtInt(n) { return Number(n || 0).toLocaleString(); }

  function fmtElapsed(ms) {
    const s = Math.floor(ms / 1000);
    const m = Math.floor(s / 60);
    return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
  }

  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (e) {
      // Clipboard API needs page focus; fall back to a hidden textarea
      try {
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
        document.body.appendChild(ta);
        ta.select();
        const ok = document.execCommand('copy');
        ta.remove();
        return ok;
      } catch (e2) {
        return false;
      }
    }
  }

  function downloadFile(content, filename, mime) {
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  function pageLabel() {
    const path = location.pathname.replace(/^\/+|\/+$/g, '');
    if (!path || path === 'home') return 'home';
    if (path.startsWith('search')) return 'search';
    if (path.startsWith('i/bookmarks')) return 'bookmarks';
    return path.split('/')[0];
  }

  // ==========================================
  // GRAPHQL INTERCEPTION
  // ==========================================
  // The X web app fetches timelines from /i/api/graphql/<hash>/<Operation>.
  // We wrap fetch (and XHR as a safety net), clone matching responses, and
  // walk the JSON for Tweet objects. No extra requests are ever made; we
  // only read what the page already downloaded.

  const GQL_URL = /\/i\/api\/graphql\//;
  const origFetch = window.fetch;
  const origXhrOpen = XMLHttpRequest.prototype.open;
  const origXhrSend = XMLHttpRequest.prototype.send;

  function installInterceptor() {
    window.fetch = async function patchedFetch(...args) {
      const res = await origFetch.apply(this, args);
      try {
        const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
        if (GQL_URL.test(url)) {
          res.clone().json().then(ingestGraphql).catch(() => { /* not JSON */ });
        }
      } catch (e) { /* never break the page's own fetch */ }
      return res;
    };

    XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
      this.__xatUrl = url;
      return origXhrOpen.call(this, method, url, ...rest);
    };
    XMLHttpRequest.prototype.send = function patchedSend(...args) {
      if (this.__xatUrl && GQL_URL.test(String(this.__xatUrl))) {
        this.addEventListener('load', () => {
          try { ingestGraphql(JSON.parse(this.responseText)); } catch (e) { /* not JSON */ }
        });
      }
      return origXhrSend.apply(this, args);
    };
  }

  function removeInterceptor() {
    window.fetch = origFetch;
    XMLHttpRequest.prototype.open = origXhrOpen;
    XMLHttpRequest.prototype.send = origXhrSend;
  }

  /**
   * Recursively collect Tweet result objects from a GraphQL payload.
   * Subtrees under a promoted entry (ads) are skipped entirely.
   * Recursion stops at each Tweet node; nested quoted/reposted tweets are
   * handled inside normalizeGraphqlTweet so they are not double-counted
   * as standalone timeline items.
   */
  function collectTweetNodes(node, out) {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { for (const item of node) collectTweetNodes(item, out); return; }
    if (typeof node.entryId === 'string' && node.entryId.includes('promoted')) return;
    const candidate = node.__typename === 'TweetWithVisibilityResults' ? node.tweet : node;
    if (candidate && candidate.rest_id && candidate.legacy && typeof candidate.legacy.full_text === 'string') {
      out.push(candidate);
      return;
    }
    for (const key of Object.keys(node)) collectTweetNodes(node[key], out);
  }

  function userFromResult(userResult) {
    const u = (userResult && (userResult.result || userResult)) || null;
    if (!u) return { handle: '', name: '', verified: false, followers: null };
    const legacy = u.legacy || {};
    const core = u.core || {};
    return {
      handle: legacy.screen_name || core.screen_name || '',
      name: legacy.name || core.name || '',
      verified: !!u.is_blue_verified,
      followers: typeof legacy.followers_count === 'number' ? legacy.followers_count : null
    };
  }

  function normalizeGraphqlTweet(raw, opts = {}) {
    const legacy = raw.legacy || {};
    const author = userFromResult(raw.core && raw.core.user_results);

    // Long posts store the untruncated body in note_tweet
    const noteText = raw.note_tweet && raw.note_tweet.note_tweet_results &&
      raw.note_tweet.note_tweet_results.result && raw.note_tweet.note_tweet_results.result.text;
    const text = noteText || legacy.full_text || '';

    const mediaEntities = (legacy.extended_entities && legacy.extended_entities.media) ||
      (legacy.entities && legacy.entities.media) || [];
    const media = mediaEntities.map((m) => {
      const entry = { type: m.type, url: m.media_url_https || m.url || '' };
      if (m.video_info && Array.isArray(m.video_info.variants)) {
        const best = m.video_info.variants
          .filter((v) => v.content_type === 'video/mp4')
          .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];
        if (best) entry.videoUrl = best.url;
      }
      return entry;
    });

    const rtRaw = legacy.retweeted_status_result && legacy.retweeted_status_result.result;
    const rtNode = rtRaw && (rtRaw.__typename === 'TweetWithVisibilityResults' ? rtRaw.tweet : rtRaw);
    const retweetOf = rtNode ? normalizeGraphqlTweet(rtNode, { nested: true }) : null;

    const qRaw = raw.quoted_status_result && raw.quoted_status_result.result;
    const qNode = qRaw && (qRaw.__typename === 'TweetWithVisibilityResults' ? qRaw.tweet : qRaw);
    const quoted = (!opts.nested && qNode && qNode.legacy)
      ? normalizeGraphqlTweet(qNode, { nested: true })
      : null;

    const record = {
      id: raw.rest_id,
      url: author.handle ? `https://x.com/${author.handle}/status/${raw.rest_id}` : `https://x.com/i/status/${raw.rest_id}`,
      author,
      text,
      lang: legacy.lang || '',
      createdAt: legacy.created_at ? new Date(legacy.created_at).toISOString() : null,
      metrics: {
        likes: legacy.favorite_count || 0,
        retweets: legacy.retweet_count || 0,
        replies: legacy.reply_count || 0,
        quotes: legacy.quote_count || 0,
        bookmarks: legacy.bookmark_count || 0,
        views: raw.views && raw.views.count ? parseInt(raw.views.count, 10) : 0
      },
      media,
      type: {
        isRetweet: !!retweetOf,
        isReply: !!legacy.in_reply_to_status_id_str,
        isQuote: !!legacy.is_quote_status && !retweetOf,
        isPinned: false // set from the DOM sweep when visible
      },
      entities: {
        hashtags: ((legacy.entities && legacy.entities.hashtags) || []).map((h) => '#' + h.text),
        mentions: ((legacy.entities && legacy.entities.user_mentions) || []).map((m) => '@' + m.screen_name),
        urls: ((legacy.entities && legacy.entities.urls) || []).map((u) => u.expanded_url || u.url).filter(Boolean)
      },
      sensitive: !!legacy.possibly_sensitive,
      source: 'graphql',
      approx: false,
      scrapedAt: new Date().toISOString()
    };
    if (retweetOf) record.retweetOf = { id: retweetOf.id, author: retweetOf.author, url: retweetOf.url, text: retweetOf.text };
    if (quoted) record.quoted = { id: quoted.id, author: quoted.author, url: quoted.url, text: quoted.text };
    return record;
  }

  function ingestGraphql(payload) {
    if (state.destroyed) return;
    const nodes = [];
    try { collectTweetNodes(payload, nodes); } catch (e) { return; }
    let added = 0;
    for (const node of nodes) {
      try {
        const rec = normalizeGraphqlTweet(node);
        if (!rec.id || (!rec.text && rec.media.length === 0)) continue;
        const existing = store.get(rec.id);
        if (!existing || existing.source === 'dom') {
          if (existing && existing.type.isPinned) rec.type.isPinned = true;
          store.set(rec.id, rec);
          added++;
        }
      } catch (e) { /* one malformed node never aborts the batch */ }
    }
    if (added > 0) {
      updateStats();
      previewLatest();
    }
  }

  // ==========================================
  // DOM SWEEP (fallback for tweets rendered before injection)
  // ==========================================

  function domSweep() {
    const articles = document.querySelectorAll('article[data-testid="tweet"]');
    let added = 0;
    articles.forEach((tweet) => {
      try {
        const timeEl = tweet.querySelector('time');
        if (!timeEl) return;
        const link = timeEl.closest('a[href*="/status/"]');
        if (!link) return;
        const match = link.href.match(/\/([^/]+)\/status\/(\d+)/);
        if (!match) return;
        const [, handle, id] = match;

        const socialContext = tweet.querySelector('[data-testid="socialContext"]');
        const isRetweet = !!socialContext && socialContext.closest('a') !== null;
        const isPinned = !!socialContext && !isRetweet;

        const existing = store.get(id);
        if (existing) {
          // GraphQL records can't see pinned status; the DOM can
          if (isPinned && !existing.type.isPinned) { existing.type.isPinned = true; }
          return;
        }

        const textEl = tweet.querySelector('[data-testid="tweetText"]');
        const text = textEl ? textEl.innerText : '';
        const metric = (testId) => {
          const el = tweet.querySelector(`[data-testid="${testId}"]`);
          const span = el && el.querySelector('span span');
          return parseApprox(span ? span.innerText : '0');
        };
        const viewsEl = tweet.querySelector('a[href*="/analytics"]');
        const nameEl = tweet.querySelector('[data-testid="User-Name"]');

        store.set(id, {
          id,
          url: `https://x.com/${handle}/status/${id}`,
          author: {
            handle,
            name: nameEl ? nameEl.innerText.split('\n')[0] : handle,
            verified: !!tweet.querySelector('[data-testid="icon-verified"]'),
            followers: null
          },
          text,
          lang: '',
          createdAt: timeEl.getAttribute('datetime'),
          metrics: {
            likes: metric('like') || metric('unlike'),
            retweets: metric('retweet') || metric('unretweet'),
            replies: metric('reply'),
            quotes: 0,
            bookmarks: 0,
            views: parseApprox(viewsEl ? viewsEl.innerText : '0')
          },
          media: [],
          type: {
            isRetweet,
            isReply: false,
            isQuote: !!tweet.querySelector('div[role="link"] time'),
            isPinned
          },
          entities: {
            hashtags: (text.match(/#[\w]+/g) || []),
            mentions: (text.match(/@[\w]+/g) || []),
            urls: (text.match(/https?:\/\/[^\s]+/g) || [])
          },
          sensitive: false,
          source: 'dom',
          approx: true,
          scrapedAt: new Date().toISOString()
        });
        added++;
      } catch (e) { /* skip malformed article */ }
    });
    if (added > 0) { updateStats(); previewLatest(); }
    return added;
  }

  // ==========================================
  // FILTERS
  // ==========================================

  function matchesFilters(t) {
    const f = config.filters;
    const text = (t.text || '').toLowerCase();
    const handle = (t.author.handle || '').toLowerCase();

    if (f.onlyUsers.length && !f.onlyUsers.some((u) => u.toLowerCase() === handle)) return false;
    if (f.excludeUsers.length) {
      const rtHandle = t.retweetOf ? (t.retweetOf.author.handle || '').toLowerCase() : '';
      if (f.excludeUsers.some((u) => {
        const lower = u.toLowerCase();
        return lower === handle || (rtHandle && lower === rtHandle);
      })) return false;
    }
    if (f.includeKeywords.length && !f.includeKeywords.some((k) => text.includes(k.toLowerCase()))) return false;
    if (f.excludeKeywords.length && f.excludeKeywords.some((k) => text.includes(k.toLowerCase()))) return false;

    if (f.daysBack > 0 && t.createdAt) {
      const cutoff = Date.now() - f.daysBack * 86400000;
      if (new Date(t.createdAt).getTime() < cutoff) return false;
    }
    if (t.metrics.likes < f.minLikes) return false;
    if (t.metrics.retweets < f.minRetweets) return false;
    if (f.minViews > 0 && t.metrics.views < f.minViews) return false;

    if (f.excludeRetweets && t.type.isRetweet) return false;
    if (f.excludeReplies && t.type.isReply) return false;
    if (f.excludeQuotes && t.type.isQuote) return false;
    if (f.excludePinned && t.type.isPinned) return false;

    if (f.mediaFilter === 'with-media' && t.media.length === 0) return false;
    if (f.mediaFilter === 'without-media' && t.media.length > 0) return false;

    if (f.lang && t.lang && t.lang !== f.lang) return false;

    return true;
  }

  function allTweets() { return Array.from(store.values()); }

  function matchedTweets() {
    return allTweets()
      .filter(matchesFilters)
      .sort((a, b) => {
        const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
        const tb = b.createdAt ? Date.parse(b.createdAt) : 0;
        return tb - ta;
      });
  }

  // ==========================================
  // EXPORTS
  // ==========================================

  function buildStats(tweets) {
    const stats = {
      total: tweets.length,
      totalLikes: 0, totalRetweets: 0, totalReplies: 0, totalViews: 0,
      avgLikes: 0, avgRetweets: 0,
      withMedia: 0, retweets: 0, replies: 0, quotes: 0,
      topHashtags: {}, topMentions: {}
    };
    for (const t of tweets) {
      stats.totalLikes += t.metrics.likes;
      stats.totalRetweets += t.metrics.retweets;
      stats.totalReplies += t.metrics.replies;
      stats.totalViews += t.metrics.views;
      if (t.media.length) stats.withMedia++;
      if (t.type.isRetweet) stats.retweets++;
      if (t.type.isReply) stats.replies++;
      if (t.type.isQuote) stats.quotes++;
      for (const h of t.entities.hashtags) stats.topHashtags[h.toLowerCase()] = (stats.topHashtags[h.toLowerCase()] || 0) + 1;
      for (const m of t.entities.mentions) stats.topMentions[m.toLowerCase()] = (stats.topMentions[m.toLowerCase()] || 0) + 1;
    }
    if (tweets.length) {
      stats.avgLikes = Math.round(stats.totalLikes / tweets.length);
      stats.avgRetweets = Math.round(stats.totalRetweets / tweets.length);
    }
    return stats;
  }

  function exportBundle() {
    const tweets = matchedTweets();
    return {
      source: pageLabel(),
      pageUrl: location.href,
      scrapedAt: new Date().toISOString(),
      toolboxVersion: VERSION,
      filters: config.filters,
      totalCaptured: store.size,
      totalMatched: tweets.length,
      statistics: buildStats(tweets),
      tweets
    };
  }

  function toJSON() { return JSON.stringify(exportBundle(), null, 2); }

  function toCSV() {
    const headers = ['id', 'date', 'author', 'text', 'likes', 'retweets', 'replies', 'quotes', 'bookmarks', 'views',
      'lang', 'isRetweet', 'isReply', 'isQuote', 'isPinned', 'mediaCount', 'mediaUrls', 'hashtags', 'url', 'source'];
    const rows = matchedTweets().map((t) => [
      t.id,
      t.createdAt || '',
      '@' + t.author.handle,
      csvCell(t.text),
      t.metrics.likes, t.metrics.retweets, t.metrics.replies, t.metrics.quotes, t.metrics.bookmarks, t.metrics.views,
      t.lang,
      t.type.isRetweet, t.type.isReply, t.type.isQuote, t.type.isPinned,
      t.media.length,
      csvCell(t.media.map((m) => m.videoUrl || m.url).join(' ')),
      csvCell(t.entities.hashtags.join(' ')),
      t.url,
      t.source
    ].join(','));
    return [headers.join(','), ...rows].join('\n');
  }

  function toMarkdown() {
    const bundle = exportBundle();
    const s = bundle.statistics;
    let md = `# X posts export (${bundle.source})\n\n`;
    md += `> Scraped ${bundle.scrapedAt} from ${bundle.pageUrl}\n`;
    md += `> ${bundle.totalMatched} matching posts (of ${bundle.totalCaptured} captured)\n\n`;
    md += `## Statistics\n\n| Metric | Value |\n|--------|-------|\n`;
    md += `| Total likes | ${fmtInt(s.totalLikes)} |\n| Total reposts | ${fmtInt(s.totalRetweets)} |\n`;
    md += `| Total views | ${fmtInt(s.totalViews)} |\n| Avg likes | ${fmtInt(s.avgLikes)} |\n| With media | ${s.withMedia} |\n\n`;
    md += `## Posts\n\n`;
    for (const [i, t] of bundle.tweets.entries()) {
      md += `### ${i + 1}. @${t.author.handle} (${t.createdAt ? t.createdAt.slice(0, 10) : 'unknown date'})\n\n`;
      md += `${t.text}\n\n`;
      md += `Likes ${fmtInt(t.metrics.likes)} | Reposts ${fmtInt(t.metrics.retweets)} | Replies ${fmtInt(t.metrics.replies)} | Views ${fmtInt(t.metrics.views)}\n\n`;
      md += `[View post](${t.url})\n\n---\n\n`;
    }
    return md;
  }

  function toPlainText() {
    const tweets = matchedTweets();
    const sep = '='.repeat(60);
    let txt = `X POSTS EXPORT (${pageLabel()})\n${sep}\n`;
    txt += `Scraped: ${new Date().toISOString()}\nMatching posts: ${tweets.length} (of ${store.size} captured)\n\n`;
    tweets.forEach((t, i) => {
      txt += `[${i + 1}] @${t.author.handle}${t.createdAt ? ' | ' + t.createdAt : ''}\n`;
      txt += `${'-'.repeat(60)}\n${t.text}\n\n`;
      txt += `Likes: ${fmtInt(t.metrics.likes)} | Reposts: ${fmtInt(t.metrics.retweets)} | Replies: ${fmtInt(t.metrics.replies)} | Views: ${fmtInt(t.metrics.views)}\n`;
      txt += `URL: ${t.url}\n${sep}\n\n`;
    });
    return txt;
  }

  function toHTML() {
    const bundle = exportBundle();
    const s = bundle.statistics;
    let html = `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>X posts export (${escapeHtml(bundle.source)})</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; color: #0f1419; }
    h1 { color: #1d9bf0; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ddd; vertical-align: top; }
    th { background: #1d9bf0; color: white; position: sticky; top: 0; }
    tr:hover { background: #f5f8fa; }
    .stats { background: #f5f8fa; padding: 16px 20px; border-radius: 10px; }
    .tweet-text { max-width: 480px; white-space: pre-wrap; }
    a { color: #1d9bf0; text-decoration: none; }
    a:hover { text-decoration: underline; }
    td.num { text-align: right; font-variant-numeric: tabular-nums; }
  </style>
</head>
<body>
  <h1>X posts export</h1>
  <p>Source: ${escapeHtml(bundle.pageUrl)}<br>Scraped: ${bundle.scrapedAt}</p>
  <div class="stats">
    <strong>Statistics:</strong>
    ${bundle.totalMatched} posts |
    Likes: ${fmtInt(s.totalLikes)} |
    Reposts: ${fmtInt(s.totalRetweets)} |
    Views: ${fmtInt(s.totalViews)} |
    Avg likes: ${fmtInt(s.avgLikes)}
  </div>
  <table>
    <thead><tr><th>#</th><th>Date</th><th>Author</th><th>Post</th><th>Likes</th><th>Reposts</th><th>Replies</th><th>Views</th><th>Link</th></tr></thead>
    <tbody>`;
    bundle.tweets.forEach((t, i) => {
      html += `
      <tr>
        <td>${i + 1}</td>
        <td>${t.createdAt ? t.createdAt.slice(0, 10) : ''}</td>
        <td>@${escapeHtml(t.author.handle)}</td>
        <td class="tweet-text">${escapeHtml(t.text)}</td>
        <td class="num">${fmtInt(t.metrics.likes)}</td>
        <td class="num">${fmtInt(t.metrics.retweets)}</td>
        <td class="num">${fmtInt(t.metrics.replies)}</td>
        <td class="num">${fmtInt(t.metrics.views)}</td>
        <td><a href="${escapeHtml(t.url)}" target="_blank" rel="noopener">View</a></td>
      </tr>`;
    });
    html += `
    </tbody>
  </table>
  <p style="margin-top: 40px; color: #888; font-size: 12px;">
    Generated by <a href="https://github.com/nirholas/XActions">XActions Scraper Toolbox</a> v${VERSION} by @nichxbt
  </p>
</body>
</html>`;
    return html;
  }

  const EXPORTERS = {
    json: { build: toJSON, ext: 'json', mime: 'application/json' },
    csv: { build: toCSV, ext: 'csv', mime: 'text/csv' },
    markdown: { build: toMarkdown, ext: 'md', mime: 'text/markdown' },
    text: { build: toPlainText, ext: 'txt', mime: 'text/plain' },
    html: { build: toHTML, ext: 'html', mime: 'text/html' }
  };

  function exportAs(format) {
    const exporter = EXPORTERS[format];
    if (!exporter) { log(`Unknown export format: ${format}`); return; }
    const count = matchedTweets().length;
    if (count === 0) { log('Nothing to export yet (0 matching posts).'); return; }
    const name = `${pageLabel()}_posts_${new Date().toISOString().slice(0, 10)}.${exporter.ext}`;
    downloadFile(exporter.build(), name, exporter.mime);
    log(`Exported ${count} posts as ${name}`);
  }

  async function copyAs(kind) {
    const count = matchedTweets().length;
    if (count === 0) { log('Nothing to copy yet (0 matching posts).'); return; }
    const content = kind === 'text' ? toPlainText() : toJSON();
    const ok = await copyText(content);
    log(ok ? `Copied ${count} posts to clipboard (${kind === 'text' ? 'plain text' : 'JSON'}).`
           : 'Clipboard copy failed. Click the page once, then retry.');
  }

  // ==========================================
  // PANEL UI
  // ==========================================

  const PANEL_ID = 'xactions-toolbox';
  let panel, els = {};

  const PANEL_HTML = `
  <style>
    #${PANEL_ID} {
      position: fixed;
      width: 340px;
      max-height: 92vh;
      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: 2147483647;
      box-shadow: 0 8px 30px rgba(0,0,0,0.5);
      display: flex;
      flex-direction: column;
      user-select: none;
    }
    #${PANEL_ID} * { box-sizing: border-box; }
    #${PANEL_ID} .xat-header {
      background: #1d9bf0; color: #fff;
      padding: 10px 14px; cursor: move;
      display: flex; align-items: center; justify-content: space-between;
      font-weight: 700; font-size: 14px;
      border-radius: 15px 15px 0 0;
      flex: 0 0 auto;
    }
    #${PANEL_ID} .xat-header .xat-hbtns { display: flex; gap: 4px; }
    #${PANEL_ID} .xat-header button {
      background: rgba(255,255,255,0.15); border: none; color: #fff;
      width: 24px; height: 24px; border-radius: 6px; cursor: pointer;
      font-size: 13px; line-height: 1; transition: background 0.15s;
    }
    #${PANEL_ID} .xat-header button:hover { background: rgba(255,255,255,0.3); }
    #${PANEL_ID} .xat-body { padding: 12px 14px; overflow-y: auto; flex: 1 1 auto; }
    #${PANEL_ID} .xat-body::-webkit-scrollbar { width: 8px; }
    #${PANEL_ID} .xat-body::-webkit-scrollbar-thumb { background: #38444d; border-radius: 4px; }

    #${PANEL_ID} .xat-statusrow { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
    #${PANEL_ID} .xat-chip {
      padding: 2px 10px; border-radius: 999px; font-weight: 700; font-size: 11px;
      background: #38444d; text-transform: uppercase; letter-spacing: 0.5px;
    }
    #${PANEL_ID} .xat-chip.running { background: #00ba7c; color: #04120c; }
    #${PANEL_ID} .xat-chip.paused { background: #ffd400; color: #1a1400; }
    #${PANEL_ID} .xat-chip.done { background: #1d9bf0; color: #041018; }
    #${PANEL_ID} .xat-elapsed { color: #8899a6; font-variant-numeric: tabular-nums; margin-left: auto; }

    #${PANEL_ID} .xat-counts {
      display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
    }
    #${PANEL_ID} .xat-count {
      background: #192734; border-radius: 10px; padding: 8px 6px; text-align: center;
    }
    #${PANEL_ID} .xat-count b { display: block; font-size: 17px; font-variant-numeric: tabular-nums; }
    #${PANEL_ID} .xat-count span { color: #8899a6; font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; }

    #${PANEL_ID} .xat-progress { height: 6px; background: #38444d; border-radius: 3px; overflow: hidden; margin: 4px 0 10px; }
    #${PANEL_ID} .xat-progress-bar { height: 100%; width: 0%; background: #1d9bf0; border-radius: 3px; transition: width 0.3s ease; }

    #${PANEL_ID} .xat-controls { display: grid; grid-template-columns: 1fr 1fr 1fr 44px; gap: 6px; margin-bottom: 10px; }
    #${PANEL_ID} .xat-btn {
      border: none; border-radius: 10px; padding: 9px 0; font-weight: 700; font-size: 13px;
      cursor: pointer; color: #fff; background: #38444d; transition: filter 0.15s, transform 0.05s;
    }
    #${PANEL_ID} .xat-btn:hover:not(:disabled) { filter: brightness(1.2); }
    #${PANEL_ID} .xat-btn:active:not(:disabled) { transform: scale(0.97); }
    #${PANEL_ID} .xat-btn:disabled { opacity: 0.4; cursor: default; }
    #${PANEL_ID} .xat-btn.xat-start { background: #00ba7c; color: #04120c; }
    #${PANEL_ID} .xat-btn.xat-pause { background: #ffd400; color: #1a1400; }
    #${PANEL_ID} .xat-btn.xat-stop { background: #f4212e; }

    #${PANEL_ID} details { margin-bottom: 8px; border: 1px solid #38444d; border-radius: 10px; }
    #${PANEL_ID} summary {
      cursor: pointer; padding: 8px 10px; font-weight: 700; color: #e7e9ea; list-style: none;
      display: flex; justify-content: space-between; align-items: center;
    }
    #${PANEL_ID} summary::after { content: '▾'; color: #8899a6; }
    #${PANEL_ID} details[open] summary::after { content: '▴'; }
    #${PANEL_ID} .xat-fields { padding: 2px 10px 10px; display: grid; gap: 7px; }
    #${PANEL_ID} .xat-field { display: grid; gap: 3px; }
    #${PANEL_ID} .xat-field label { color: #8899a6; font-size: 11px; }
    #${PANEL_ID} .xat-field input[type="text"], #${PANEL_ID} .xat-field input[type="number"], #${PANEL_ID} .xat-field select {
      background: #192734; color: #e7e9ea; border: 1px solid #38444d; border-radius: 8px;
      padding: 6px 8px; font-size: 12px; width: 100%; outline: none;
    }
    #${PANEL_ID} .xat-field input:focus, #${PANEL_ID} .xat-field select:focus { border-color: #1d9bf0; }
    #${PANEL_ID} .xat-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
    #${PANEL_ID} .xat-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 5px 8px; }
    #${PANEL_ID} .xat-checks label {
      display: flex; align-items: center; gap: 6px; color: #e7e9ea; font-size: 12px; cursor: pointer;
    }
    #${PANEL_ID} .xat-checks input { accent-color: #1d9bf0; }

    #${PANEL_ID} .xat-exports { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin-bottom: 6px; }
    #${PANEL_ID} .xat-exports .xat-btn { padding: 7px 0; font-size: 11px; background: #192734; border: 1px solid #38444d; }
    #${PANEL_ID} .xat-copyrow { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px; }
    #${PANEL_ID} .xat-copyrow .xat-btn { background: #192734; border: 1px solid #38444d; font-size: 12px; }

    #${PANEL_ID} .xat-preview {
      background: #192734; border-radius: 10px; padding: 8px 10px; margin-bottom: 8px;
      color: #8899a6; font-size: 11.5px; max-height: 62px; overflow: hidden;
    }
    #${PANEL_ID} .xat-preview b { color: #e7e9ea; }
    #${PANEL_ID} .xat-log {
      background: #0d1319; border-radius: 10px; padding: 8px 10px;
      font-size: 11px; color: #8899a6; max-height: 76px; overflow-y: auto;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    }
    #${PANEL_ID} .xat-footer {
      padding: 6px 14px 10px; color: #536471; font-size: 10.5px; text-align: center; flex: 0 0 auto;
    }
    #${PANEL_ID} .xat-footer a { color: #1d9bf0; text-decoration: none; }
    #${PANEL_ID}.xat-min .xat-body, #${PANEL_ID}.xat-min .xat-footer { display: none; }
  </style>
  <div class="xat-header" id="xat-drag">
    <span>🧰 Scraper Toolbox</span>
    <span class="xat-hbtns">
      <button id="xat-minimize" title="Minimize" aria-label="Minimize panel">▁</button>
      <button id="xat-close" title="Close and restore page (data is kept until reload)" aria-label="Close panel">✕</button>
    </span>
  </div>
  <div class="xat-body">
    <div class="xat-statusrow">
      <span class="xat-chip" id="xat-status">idle</span>
      <span id="xat-page" style="color:#8899a6"></span>
      <span class="xat-elapsed" id="xat-elapsed">0s</span>
    </div>
    <div class="xat-counts">
      <div class="xat-count"><b id="xat-captured">0</b><span>captured</span></div>
      <div class="xat-count"><b id="xat-matched">0</b><span>matching</span></div>
      <div class="xat-count"><b id="xat-scrolls">0</b><span>scrolls</span></div>
    </div>
    <div class="xat-progress"><div class="xat-progress-bar" id="xat-bar"></div></div>
    <div class="xat-controls">
      <button class="xat-btn xat-start" id="xat-start">▶ Start</button>
      <button class="xat-btn xat-pause" id="xat-pausebtn" disabled>⏸ Pause</button>
      <button class="xat-btn xat-stop" id="xat-stopbtn" disabled>⏹ Stop</button>
      <button class="xat-btn" id="xat-clear" title="Clear all captured data">🗑</button>
    </div>

    <details open>
      <summary>Scraping</summary>
      <div class="xat-fields">
        <div class="xat-2col">
          <div class="xat-field">
            <label for="xat-target">Target posts (0 = no limit)</label>
            <input type="number" id="xat-target" min="0" step="10">
          </div>
          <div class="xat-field">
            <label for="xat-delay">Scroll delay (ms)</label>
            <input type="number" id="xat-delay" min="400" step="100">
          </div>
        </div>
        <div class="xat-2col">
          <div class="xat-field">
            <label for="xat-maxscrolls">Max scrolls</label>
            <input type="number" id="xat-maxscrolls" min="1" step="10">
          </div>
          <div class="xat-field">
            <label for="xat-stall">Stop after N empty scrolls</label>
            <input type="number" id="xat-stall" min="2" step="1">
          </div>
        </div>
        <div class="xat-checks">
          <label><input type="checkbox" id="xat-autojson"> Auto-download JSON when done</label>
        </div>
      </div>
    </details>

    <details>
      <summary>Filters</summary>
      <div class="xat-fields">
        <div class="xat-field">
          <label for="xat-inc">Must contain keywords (comma separated)</label>
          <input type="text" id="xat-inc" placeholder="e.g. ai, launch, update">
        </div>
        <div class="xat-field">
          <label for="xat-exc">Exclude keywords</label>
          <input type="text" id="xat-exc" placeholder="e.g. giveaway, promo">
        </div>
        <div class="xat-field">
          <label for="xat-onlyusers">Only these users (@handles)</label>
          <input type="text" id="xat-onlyusers" placeholder="empty = everyone on this timeline">
        </div>
        <div class="xat-field">
          <label for="xat-skipusers">Skip these users (@handles)</label>
          <input type="text" id="xat-skipusers" placeholder="e.g. @spamacct, @bot123">
        </div>
        <div class="xat-2col">
          <div class="xat-field">
            <label for="xat-minlikes">Min likes</label>
            <input type="number" id="xat-minlikes" min="0">
          </div>
          <div class="xat-field">
            <label for="xat-minrts">Min reposts</label>
            <input type="number" id="xat-minrts" min="0">
          </div>
        </div>
        <div class="xat-2col">
          <div class="xat-field">
            <label for="xat-minviews">Min views</label>
            <input type="number" id="xat-minviews" min="0">
          </div>
          <div class="xat-field">
            <label for="xat-days">Last N days (0 = all)</label>
            <input type="number" id="xat-days" min="0">
          </div>
        </div>
        <div class="xat-2col">
          <div class="xat-field">
            <label for="xat-media">Media</label>
            <select id="xat-media">
              <option value="all">All posts</option>
              <option value="with-media">With media only</option>
              <option value="without-media">Text only</option>
            </select>
          </div>
          <div class="xat-field">
            <label for="xat-lang">Language code (e.g. en)</label>
            <input type="text" id="xat-lang" placeholder="empty = all">
          </div>
        </div>
        <div class="xat-checks">
          <label><input type="checkbox" id="xat-nort"> No reposts</label>
          <label><input type="checkbox" id="xat-noreply"> No replies</label>
          <label><input type="checkbox" id="xat-noquote"> No quotes</label>
          <label><input type="checkbox" id="xat-nopin"> No pinned</label>
        </div>
      </div>
    </details>

    <details open>
      <summary>Export</summary>
      <div class="xat-fields">
        <div class="xat-exports">
          <button class="xat-btn" data-export="json">JSON</button>
          <button class="xat-btn" data-export="csv">CSV</button>
          <button class="xat-btn" data-export="markdown">MD</button>
          <button class="xat-btn" data-export="text">TXT</button>
          <button class="xat-btn" data-export="html">HTML</button>
        </div>
        <div class="xat-copyrow">
          <button class="xat-btn" id="xat-copyjson">📋 Copy JSON</button>
          <button class="xat-btn" id="xat-copytext">📋 Copy clear text</button>
        </div>
      </div>
    </details>

    <div class="xat-preview" id="xat-preview">No posts captured yet. Press ▶ Start, or just scroll the page yourself.</div>
    <div class="xat-log" id="xat-log"></div>
  </div>
  <div class="xat-footer">
    <a href="https://github.com/nirholas/XActions" target="_blank" rel="noopener">XActions</a> Scraper Toolbox v${VERSION}
  </div>`;

  function buildPanel() {
    const existing = document.getElementById(PANEL_ID);
    if (existing) existing.remove();

    panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = PANEL_HTML;
    document.body.appendChild(panel);

    if (config.ui.left !== null) {
      panel.style.left = config.ui.left + 'px';
      panel.style.top = config.ui.top + 'px';
    } else {
      panel.style.right = '20px';
      panel.style.top = config.ui.top + 'px';
    }

    const $ = (id) => panel.querySelector('#' + id);
    els = {
      status: $('xat-status'), page: $('xat-page'), elapsed: $('xat-elapsed'),
      captured: $('xat-captured'), matched: $('xat-matched'), scrolls: $('xat-scrolls'),
      bar: $('xat-bar'),
      start: $('xat-start'), pause: $('xat-pausebtn'), stop: $('xat-stopbtn'), clear: $('xat-clear'),
      target: $('xat-target'), delay: $('xat-delay'), maxscrolls: $('xat-maxscrolls'), stall: $('xat-stall'),
      autojson: $('xat-autojson'),
      inc: $('xat-inc'), exc: $('xat-exc'), onlyusers: $('xat-onlyusers'), skipusers: $('xat-skipusers'),
      minlikes: $('xat-minlikes'), minrts: $('xat-minrts'), minviews: $('xat-minviews'), days: $('xat-days'),
      media: $('xat-media'), lang: $('xat-lang'),
      nort: $('xat-nort'), noreply: $('xat-noreply'), noquote: $('xat-noquote'), nopin: $('xat-nopin'),
      preview: $('xat-preview'), log: $('xat-log')
    };

    els.page.textContent = '/' + pageLabel();
    applyConfigToUI();

    // Controls
    els.start.addEventListener('click', start);
    els.pause.addEventListener('click', togglePause);
    els.stop.addEventListener('click', stop);
    els.clear.addEventListener('click', clearData);
    $('xat-minimize').addEventListener('click', () => panel.classList.toggle('xat-min'));
    $('xat-close').addEventListener('click', destroy);
    $('xat-copyjson').addEventListener('click', () => copyAs('json'));
    $('xat-copytext').addEventListener('click', () => copyAs('text'));
    panel.querySelectorAll('[data-export]').forEach((btn) =>
      btn.addEventListener('click', () => exportAs(btn.dataset.export)));

    // Any input change re-reads config, persists it, and refreshes counters
    panel.querySelectorAll('input, select').forEach((el) =>
      el.addEventListener('change', () => { readConfigFromUI(); saveConfig(); updateStats(); }));

    // Drag
    const header = $('xat-drag');
    let drag = null;
    header.addEventListener('mousedown', (e) => {
      if (e.target.tagName === 'BUTTON') return;
      const rect = panel.getBoundingClientRect();
      drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
      e.preventDefault();
    });
    document.addEventListener('mousemove', onDragMove);
    document.addEventListener('mouseup', onDragEnd);
    function onDragMove(e) {
      if (!drag) return;
      const left = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - drag.dx));
      const top = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - drag.dy));
      panel.style.left = left + 'px';
      panel.style.top = top + 'px';
      panel.style.right = 'auto';
    }
    function onDragEnd() {
      if (!drag) return;
      drag = null;
      const rect = panel.getBoundingClientRect();
      config.ui.left = Math.round(rect.left);
      config.ui.top = Math.round(rect.top);
      saveConfig();
    }
    panel.__dragCleanup = () => {
      document.removeEventListener('mousemove', onDragMove);
      document.removeEventListener('mouseup', onDragEnd);
    };
  }

  function applyConfigToUI() {
    const f = config.filters;
    els.target.value = config.targetCount;
    els.delay.value = config.scrollDelay;
    els.maxscrolls.value = config.maxScrolls;
    els.stall.value = config.stallLimit;
    els.autojson.checked = config.autoDownloadJson;
    els.inc.value = f.includeKeywords.join(', ');
    els.exc.value = f.excludeKeywords.join(', ');
    els.onlyusers.value = f.onlyUsers.map((u) => '@' + u).join(', ');
    els.skipusers.value = f.excludeUsers.map((u) => '@' + u).join(', ');
    els.minlikes.value = f.minLikes;
    els.minrts.value = f.minRetweets;
    els.minviews.value = f.minViews;
    els.days.value = f.daysBack;
    els.media.value = f.mediaFilter;
    els.lang.value = f.lang;
    els.nort.checked = f.excludeRetweets;
    els.noreply.checked = f.excludeReplies;
    els.noquote.checked = f.excludeQuotes;
    els.nopin.checked = f.excludePinned;
  }

  function readConfigFromUI() {
    const num = (el, min, fallback) => {
      const v = parseInt(el.value, 10);
      return Number.isFinite(v) && v >= min ? v : fallback;
    };
    config.targetCount = num(els.target, 0, DEFAULT_CONFIG.targetCount);
    config.scrollDelay = num(els.delay, 400, DEFAULT_CONFIG.scrollDelay);
    config.maxScrolls = num(els.maxscrolls, 1, DEFAULT_CONFIG.maxScrolls);
    config.stallLimit = num(els.stall, 2, DEFAULT_CONFIG.stallLimit);
    config.autoDownloadJson = els.autojson.checked;
    const f = config.filters;
    f.includeKeywords = csvList(els.inc.value);
    f.excludeKeywords = csvList(els.exc.value);
    f.onlyUsers = csvList(els.onlyusers.value);
    f.excludeUsers = csvList(els.skipusers.value);
    f.minLikes = num(els.minlikes, 0, 0);
    f.minRetweets = num(els.minrts, 0, 0);
    f.minViews = num(els.minviews, 0, 0);
    f.daysBack = num(els.days, 0, 0);
    f.mediaFilter = els.media.value;
    f.lang = els.lang.value.trim().toLowerCase();
    f.excludeRetweets = els.nort.checked;
    f.excludeReplies = els.noreply.checked;
    f.excludeQuotes = els.noquote.checked;
    f.excludePinned = els.nopin.checked;
  }

  // ==========================================
  // UI UPDATES
  // ==========================================

  function setStatus(status) {
    state.status = status;
    if (!els.status) return;
    els.status.textContent = status;
    els.status.className = 'xat-chip ' + status;
    const running = status === 'running';
    const paused = status === 'paused';
    els.start.disabled = running || paused;
    els.pause.disabled = !running && !paused;
    els.pause.textContent = paused ? '▶ Resume' : '⏸ Pause';
    els.stop.disabled = !running && !paused;
  }

  function updateStats() {
    if (!els.captured) return;
    const matched = matchedTweets().length;
    els.captured.textContent = fmtInt(store.size);
    els.matched.textContent = fmtInt(matched);
    els.scrolls.textContent = fmtInt(state.scrolls);
    const target = config.targetCount;
    els.bar.style.width = target > 0 ? Math.min(100, (matched / target) * 100) + '%' : (store.size > 0 ? '100%' : '0%');
  }

  function previewLatest() {
    if (!els.preview) return;
    let t = null;
    for (const v of store.values()) t = v; // last inserted record
    if (!t) return;
    const snippet = (t.text || '(media only)').replace(/\s+/g, ' ').slice(0, 120);
    els.preview.innerHTML = `<b>@${escapeHtml(t.author.handle)}</b> · ${escapeHtml(snippet)}`;
  }

  function log(msg) {
    console.log(`${TAG} ${msg}`);
    if (!els.log) return;
    const line = document.createElement('div');
    line.textContent = `${new Date().toTimeString().slice(0, 8)} ${msg}`;
    els.log.prepend(line);
    while (els.log.children.length > 40) els.log.lastChild.remove();
  }

  // ==========================================
  // SCROLL ENGINE
  // ==========================================

  let runToken = 0; // invalidates a running loop after stop/clear/destroy

  async function start() {
    if (state.status === 'running' || state.status === 'paused') return;
    readConfigFromUI();
    saveConfig();
    state.scrolls = 0;
    state.stalls = 0;
    state.startedAt = Date.now();
    state.elapsedBefore = 0;
    state.lastChangeCount = store.size;
    setStatus('running');
    log(`Run started on /${pageLabel()} (target: ${config.targetCount || 'unlimited'}).`);
    const token = ++runToken;

    domSweep(); // catch what's already rendered

    while (token === runToken && !state.destroyed) {
      if (state.status === 'paused') { await sleep(250); continue; }
      if (state.status !== 'running') break;

      const matched = matchedTweets().length;
      if (config.targetCount > 0 && matched >= config.targetCount) {
        finishRun(`Target reached: ${matched} matching posts.`);
        break;
      }
      if (state.scrolls >= config.maxScrolls) {
        finishRun(`Max scrolls (${config.maxScrolls}) reached with ${matched} matching posts.`);
        break;
      }

      // Scroll with light jitter so loading keeps up
      const el = document.scrollingElement || document.documentElement;
      el.scrollTop = el.scrollHeight;
      state.scrolls++;
      await sleep(config.scrollDelay + Math.floor(Math.random() * 400));
      if (token !== runToken || state.destroyed) break;

      domSweep();

      if (store.size === state.lastChangeCount) {
        state.stalls++;
        if (state.stalls >= config.stallLimit) {
          finishRun(`End of timeline: nothing new after ${config.stallLimit} scrolls (${matchedTweets().length} matching posts).`);
          break;
        }
      } else {
        state.stalls = 0;
        state.lastChangeCount = store.size;
      }
      updateStats();
    }
  }

  function togglePause() {
    if (state.status === 'running') {
      state.elapsedBefore += Date.now() - state.startedAt;
      setStatus('paused');
      log('Paused. Capture stays live; scrolling is halted.');
    } else if (state.status === 'paused') {
      state.startedAt = Date.now();
      setStatus('running');
      log('Resumed.');
    }
  }

  function stop() {
    if (state.status !== 'running' && state.status !== 'paused') return;
    runToken++;
    finishRun(`Stopped manually with ${matchedTweets().length} matching posts.`);
  }

  function finishRun(message) {
    if (state.status === 'paused') state.startedAt = Date.now();
    state.elapsedBefore += Date.now() - state.startedAt;
    setStatus('done');
    updateStats();
    log(message);
    log('Adjust filters freely, then export: results always reflect current filters.');
    if (config.autoDownloadJson && matchedTweets().length > 0) exportAs('json');
  }

  function clearData() {
    runToken++;
    store.clear();
    state.scrolls = 0;
    state.stalls = 0;
    state.elapsedBefore = 0;
    state.startedAt = null;
    setStatus('idle');
    updateStats();
    if (els.preview) els.preview.textContent = 'Cleared. Press ▶ Start to scrape again.';
    log('All captured data cleared.');
  }

  // ==========================================
  // TICKER
  // ==========================================

  const ticker = setInterval(() => {
    if (state.destroyed || !els.elapsed) return;
    let ms = state.elapsedBefore;
    if (state.status === 'running' && state.startedAt) ms += Date.now() - state.startedAt;
    els.elapsed.textContent = fmtElapsed(ms);
  }, 500);

  // ==========================================
  // LIFECYCLE
  // ==========================================

  function destroy() {
    state.destroyed = true;
    runToken++;
    clearInterval(ticker);
    removeInterceptor();
    if (panel) {
      if (panel.__dragCleanup) panel.__dragCleanup();
      panel.remove();
      panel = null;
    }
    delete window.XActionsToolbox;
    console.log(`${TAG} Closed. fetch/XHR restored. Paste the script again to reopen.`);
  }

  // ==========================================
  // BOOT
  // ==========================================

  if (!/(^|\.)x\.com$|(^|\.)twitter\.com$/.test(location.hostname)) {
    console.warn(`${TAG} This script must run on x.com. Open a profile or timeline there and paste again.`);
    return;
  }

  installInterceptor();
  buildPanel();
  setStatus('idle');
  updateStats();
  log(`Toolbox v${VERSION} ready on /${pageLabel()}. Capture is live even before you press Start.`);

  window.XActionsToolbox = {
    version: VERSION,
    tweets: allTweets,
    matched: matchedTweets,
    export: exportAs,
    copy: copyAs,
    start,
    pause: togglePause,
    stop,
    clear: clearData,
    destroy,
    config
  };
})();

⚡ More XActions Scripts

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

Browse All Scripts