Cómo scrapear Twitter/X con proxies en 2025: Guía técnica completa

Guía práctica para extraer datos públicos de Twitter/X usando proxies residenciales. Cubre el panorama post-API, implementación en Python con Playwright, manejo de rate limits y consideraciones legales.

Cómo scrapear Twitter/X con proxies en 2025: Guía técnica completa

El ecosistema de acceso a datos de Twitter/X ha cambiado drásticamente. Lo que antes era una API relativamente abierta ahora requiere suscripciones de pago significativas, empujando a muchos equipos de desarrollo hacia el web scraping como alternativa viable. Esta guía explica cómo extraer datos públicos de X de manera responsable usando proxies residenciales.

Aviso importante: Este artículo cubre únicamente el acceso a datos públicos disponibles sin autenticación. Debes respetar los Términos de Servicio de X, robots.txt, y las leyes aplicables como CFAA (EE.UU.) y GDPR (UE). El scraping no autorizado puede resultar en acciones legales. Considera siempre la API oficial cuando sea viable.

El nuevo panorama de la API de Twitter/X

En 2023, X (anteriormente Twitter) reestructuró completamente sus tiers de API. Los cambios más relevantes para equipos de datos:

  • Eliminación del tier gratuito — El acceso gratuito que permitía hasta 500,000 tweets/mes desapareció.
  • Basic tier: $100/mes — Limitado a 10,000 posts/mes para lectura.
  • Pro tier: $5,000/mes — Hasta 1,000,000 posts/mes.
  • Enterprise tier: contacto comercial — Acceso completo con SLA garantizado.

Para contextos como monitoreo de sentimiento, análisis de tendencias, o investigación académica a escala, estos costos pueden ser prohibitivos. El resultado: muchos equipos han migrado silenciosamente al web scraping.

¿Qué datos son accesibles públicamente?

X.com funciona como una Single Page Application (SPA) que carga datos vía endpoints GraphQL internos. Sin iniciar sesión, puedes acceder a:

Tipo de datoAccesible sin loginNotas
Perfiles públicosNombre, bio, seguidores, ubicación
Tweets individualesTexto, timestamps, métricas públicas
Respuestas bajo un tweetParcialX las carga dinámicamente; requiere scroll
Trending topicsLista de tendencias por ubicación
Búsqueda (search)NoRequiere login desde 2023
Timeline de usuarioLimitadoSolo los últimos ~20 tweets sin auth
Notificaciones/DMsNoRequiere cuenta activa

La búsqueda es el mayor obstáculo. Sin una cuenta autenticada, X redirige a la página de login. Esto significa que estrategias como "monitorear menciones de marca en tiempo real" requieren enfoques alternativos: scrapear perfiles específicos, usar la API de búsqueda de terceros, o mantener sesiones autenticadas con proxies dedicados.

Por qué X requiere proxies residenciales

X mantiene uno de los sistemas anti-bot más agresivos de las redes sociales principales. Los desafíos técnicos específicos:

1. Detección de rangos IP de datacenter

Los datacenters tienen rangos IP conocidos y catalogados. ASN como DigitalOcean, AWS, Hetzner son trivialmente identificables. X aplica rate limits más estrictos y CAPTCHAs más frecuentes a estos rangos.

Los proxies residenciales usan IPs asignadas a hogares reales por ISPs, haciendo el tráfico indistinguible de un usuario legítimo.

2. Rate limits por IP y por cuenta

X implementa throttling en múltiples niveles:

  • Por IP: Requests excesivos desde una IP resultan en HTTP 429 o CAPTCHAs interactivos.
  • Por sesión/cookie: Múltiples requests con la misma cookie reciben límites progresivos.
  • Por cuenta: Si usas credenciales autenticadas, los límites son más altos pero aún existen.

3. Fingerprinting del navegador

X usa técnicas de fingerprinting para detectar automatización:

  • User-Agent, Accept-Language, headers HTTP en orden específico
  • Canvas fingerprinting, WebGL renderer
  • Comportamiento del scroll y timing de requests
  • Detección de WebDriver/Playwright/Puppeteer

Una solución robusta combina: proxies residenciales rotativos, rotación de fingerprints, y delays aleatorios entre requests.

Implementación técnica: Python + Playwright

El siguiente ejemplo muestra cómo extraer datos de un perfil público usando Playwright con proxies residenciales de ProxyHat.

Configuración del entorno

# requirements.txt
playwright==1.40.0
asyncio==3.4.3
python-dotenv==1.0.0
# config.py
import os
from dotenv import load_dotenv

load_dotenv()

PROXYHAT_USER = os.getenv('PROXYHAT_USER')
PROXYHAT_PASS = os.getenv('PROXYHAT_PASS')
PROXY_GATEWAY = 'gate.proxyhat.com'
PROXY_PORT = 8080

def get_proxy_url(country=None, session_id=None):
    """Genera URL de proxy con geo-targeting opcional"""
    username = PROXYHAT_USER
    
    if country:
        username = f'{username}-country-{country}'
    
    if session_id:
        username = f'{username}-session-{session_id}'
    
    return f'http://{username}:{PROXYHAT_PASS}@{PROXY_GATEWAY}:{PROXY_PORT}'

Scraper básico de perfil

# twitter_profile_scraper.py
import asyncio
import json
import re
from playwright.async_api import async_playwright
from config import get_proxy_url

class TwitterProfileScraper:
    def __init__(self, proxy_country='US'):
        self.proxy_country = proxy_country
        self.browser = None
        
    async def init_browser(self):
        proxy_url = get_proxy_url(country=self.proxy_country)
        
        self.playwright = await async_playwright().start()
        self.browser = await self.playwright.chromium.launch(
            headless=True,
            proxy={'server': proxy_url}
        )
        
        # Contexto con fingerprint realista
        self.context = await self.browser.new_context(
            viewport={'width': 1920, 'height': 1080},
            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            locale='en-US',
            timezone_id='America/New_York'
        )
        
        # Inyectar scripts anti-detección
        await self.context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
        """)
        
    async def scrape_profile(self, username: str) -> dict:
        """Extrae datos del perfil público de un usuario"""
        page = await self.context.new_page()
        
        try:
            url = f'https://x.com/{username}'
            response = await page.goto(url, wait_until='networkidle', timeout=30000)
            
            # Esperar a que cargue el contenido
            await page.wait_for_selector('[data-testid="UserName"]', timeout=15000)
            
            # Extraer datos del perfil
            profile_data = await page.evaluate('''() => {
                const data = {};
                
                // Nombre visible
                const nameEl = document.querySelector('[data-testid="UserName"]');
                if (nameEl) data.name = nameEl.textContent;
                
                // Handle (@username)
                const handleEl = document.querySelector('[data-testid="UserName"] + div');
                if (handleEl) data.handle = handleEl.textContent;
                
                // Bio
                const bioEl = document.querySelector('[data-testid="UserDescription"]');
                if (bioEl) data.bio = bioEl.textContent;
                
                // Métricas
                const stats = document.querySelectorAll('[data-testid="primaryColumn"] a[href*="/"] span>span');
                if (stats.length >= 3) {
                    data.following = stats[0]?.textContent || '0';
                    data.followers = stats[1]?.textContent || '0';
                }
                
                return data;
            }''')
            
            return {
                'username': username,
                'success': True,
                'data': profile_data,
                'url': url
            }
            
        except Exception as e:
            return {
                'username': username,
                'success': False,
                'error': str(e)
            }
        finally:
            await page.close()
            
    async def close(self):
        if self.browser:
            await self.browser.close()
        if hasattr(self, 'playwright'):
            await self.playwright.stop()

# Uso
async def main():
    scraper = TwitterProfileScraper(proxy_country='US')
    await scraper.init_browser()
    
    result = await scraper.scrape_profile('elonmusk')
    print(json.dumps(result, indent=2))
    
    await scraper.close()

if __name__ == '__main__':
    asyncio.run(main())

Extracción de tweets mediante GraphQL

X carga los tweets dinámicamente vía requests GraphQL internos. Puedes interceptar estos payloads:

# twitter_graphql_interceptor.py
import asyncio
import json
from playwright.async_api import async_playwright
from config import get_proxy_url

class TwitterGraphQLScraper:
    def __init__(self):
        self.tweets_data = []
        
    async def scrape_user_tweets(self, username: str, max_tweets: int = 50):
        """Intercepta payloads GraphQL de tweets de usuario"""
        
        async with async_playwright() as p:
            proxy_url = get_proxy_url(country='US', session_id=f'twitter_{username}')
            
            browser = await p.chromium.launch(
                headless=True,
                proxy={'server': proxy_url}
            )
            
            context = await browser.new_context(
                user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
            )
            
            page = await context.new_page()
            
            # Interceptar responses GraphQL
            graphql_responses = []
            
            async def handle_response(response):
                if 'graphql' in response.url and 'UserTweets' in response.url:
                    try:
                        data = await response.json()
                        graphql_responses.append(data)
                    except:
                        pass
            
            page.on('response', handle_response)
            
            # Navegar y hacer scroll para cargar más tweets
            await page.goto(f'https://x.com/{username}', wait_until='networkidle')
            
            for _ in range(min(5, max_tweets // 20)):
                await page.evaluate('window.scrollBy(0, 1000)')
                await asyncio.sleep(2 + asyncio.get_event_loop().time() % 2)
            
            # Parsear responses
            tweets = []
            for response in graphql_responses:
                try:
                    instructions = response.get('data', {}).get('user', {}).get('result', {}).get('timeline', {}).get('instructions', [])
                    for instruction in instructions:
                        entries = instruction.get('entries', [])
                        for entry in entries:
                            content = entry.get('content', {})
                            if content.get('itemContent'):
                                tweet_result = content['itemContent'].get('tweet_results', {}).get('result', {})
                                if tweet_result:
                                    legacy = tweet_result.get('legacy', {})
                                    tweets.append({
                                        'id': tweet_result.get('rest_id'),
                                        'text': legacy.get('full_text', ''),
                                        'created_at': legacy.get('created_at'),
                                        'likes': legacy.get('favorite_count', 0),
                                        'retweets': legacy.get('retweet_count', 0),
                                        'replies': legacy.get('reply_count', 0)
                                    })
                except Exception as e:
                    continue
            
            await browser.close()
            return tweets[:max_tweets]

# Uso
async def main():
    scraper = TwitterGraphQLScraper()
    tweets = await scraper.scrape_user_tweets('nasa', max_tweets=30)
    print(json.dumps(tweets, indent=2))

if __name__ == '__main__':
    asyncio.run(main())

Estrategias para manejo de rate limits

X implementa múltiples capas de protección. Una estrategia robusta debe manejar:

HTTP 429 (Too Many Requests)

# rate_limit_handler.py
import asyncio
import random
from datetime import datetime

class RateLimitHandler:
    def __init__(self, min_delay=2.0, max_delay=5.0, max_retries=3):
        self.min_delay = min_delay
        self.max_delay = max_delay
        self.max_retries = max_retries
        self.request_times = []
        
    async def acquire(self):
        """Implementa delay adaptativo entre requests"""
        now = datetime.now()
        
        # Limpiar timestamps antiguos (ventana de 15 min)
        self.request_times = [t for t in self.request_times 
                             if (now - t).total_seconds() < 900]
        
        # Si hay muchos requests recientes, aumentar delay
        recent_count = len(self.request_times)
        if recent_count > 10:
            delay = self.max_delay + random.uniform(1, 3)
        else:
            delay = random.uniform(self.min_delay, self.max_delay)
        
        await asyncio.sleep(delay)
        self.request_times.append(now)
        
    async def execute_with_retry(self, request_func, *args, **kwargs):
        """Ejecuta request con retry y backoff exponencial"""
        for attempt in range(self.max_retries):
            try:
                await self.acquire()
                result = await request_func(*args, **kwargs)
                return result
                
            except Exception as e:
                error_str = str(e).lower()
                
                if '429' in error_str or 'rate' in error_str:
                    # Backoff exponencial
                    wait_time = (2 ** attempt) * 10 + random.uniform(0, 5)
                    print(f'Rate limit hit. Waiting {wait_time:.1f}s...')
                    await asyncio.sleep(wait_time)
                    
                elif 'captcha' in error_str or 'challenge' in error_str:
                    # Rotar IP y reintentar
                    print('CAPTCHA detected. Consider rotating proxy...')
                    await asyncio.sleep(30)
                    
                else:
                    raise e
                    
        raise Exception(f'Max retries ({self.max_retries}) exceeded')

Detección de ventana deslizante

X no publica sus límites exactos, pero la observación empírica sugiere:

  • Sin login: ~100-200 requests por IP cada 15 minutos
  • Con login: ~500-900 requests por sesión cada 15 minutos
  • Ventana deslizante: Los límites se calculan sobre una ventana móvil, no fija

Rotación de IPs residenciales

# proxy_rotation_example.py
import asyncio
import random
from twitter_profile_scraper import TwitterProfileScraper

class ProxyRotatingScraper:
    def __init__(self, countries=['US', 'GB', 'DE', 'CA']):
        self.countries = countries
        
    async def scrape_profiles(self, usernames: list) -> list:
        """Scrapea múltiples perfiles rotando proxies"""
        results = []
        
        for i, username in enumerate(usernames):
            # Rotar país cada 5 requests
            country = self.countries[(i // 5) % len(self.countries)]
            
            # Nueva sesión = nueva IP residencial
            session_id = f'sess_{random.randint(100000, 999999)}'
            
            scraper = TwitterProfileScraper(proxy_country=country)
            await scraper.init_browser()
            
            try:
                result = await scraper.scrape_profile(username)
                results.append(result)
                
                # Delay aleatorio entre requests
                await asyncio.sleep(random.uniform(3, 8))
                
            finally:
                await scraper.close()
                
        return results

# Uso
async def main():
    scraper = ProxyRotatingScraper(countries=['US', 'GB', 'DE'])
    usernames = ['nasa', 'spacex', 'elonmusk', 'tesla', 'openai']
    results = await scraper.scrape_profiles(usernames)
    for r in results:
        print(f"{r['username']}: {'OK' if r['success'] else 'FAILED'}")

if __name__ == '__main__':
    asyncio.run(main())

Consideraciones legales y éticas

El scraping de X/Twitter existe en una zona legalmente compleja. Es crucial entender los riesgos:

Casos relevantes

  • hiQ Labs v. LinkedIn (2022) — La Corte de Apelaciones del 9no Circuito determinó que scraping de datos públicos no viola automáticamente la CFAA. Sin embargo, esto no es una carta blanca.
  • X Corp. v. various scrapers (2023-2024) — X ha iniciado múltiples acciones legales contra servicios de scraping comercial masivo, alegando violación de ToS y interferencia contractual.
  • GDPR y CCPA — Datos de usuarios europeos y californianos están protegidos; scraping de datos personales sin consentimiento puede violar estas regulaciones.

Términos de Servicio de X

Los ToS de X prohíben explícitamente:

  • "Scraping" sin autorización expresa
  • Uso automatizado del servicio sin permiso
  • Reproducción, modificación, o distribución del contenido de manera que compita con X

Sin embargo, también ofrecen licencias para investigación académica y uso periodístico bajo ciertas condiciones.

Cuándo usar la API oficial

EscenarioRecomendaciónRazón
Monitoreo de marca profesionalAPI Basic/ProEstabilidad, soporte, cumplimiento legal
Investigación académicaAPI Academic (si disponible)Acceso histórico, términos claros
Análisis puntual únicoScraping limitadoCosto-beneficio puede justificarlo
Dashboard comercial continuoAPI Pro/EnterpriseEscalabilidad y protección legal
Datos de cuenta propiaAPI BasicIncluido en tier básico

Mejores prácticas éticas

  1. Respeta robots.txt — Aunque no legalmente vinculante, es buena práctica.
  2. Limita la frecuencia — No satures los servidores; compórtate como un usuario humano.
  3. No almacenes datos personales — Minimiza la retención de información identificable.
  4. Considera el impacto — ¿Tu scraping afecta negativamente a X o sus usuarios?
  5. Documenta tu uso — Mantén registros de qué datos extraes y por qué.

Ejemplo en Node.js con Playwright

Para equipos que prefieren JavaScript/TypeScript:

// twitter-scraper.js
const { chromium } = require('playwright');

class TwitterScraper {
  constructor(proxyConfig) {
    this.proxyConfig = proxyConfig;
    this.browser = null;
  }

  async init() {
    const proxyUrl = `http://${this.proxyConfig.username}:${this.proxyConfig.password}@${this.proxyConfig.host}:${this.proxyConfig.port}`;
    
    this.browser = await chromium.launch({
      headless: true,
      proxy: { server: proxyUrl }
    });
  }

  async scrapeProfile(username) {
    const context = await this.browser.newContext({
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
      viewport: { width: 1920, height: 1080 }
    });
    
    const page = await context.newPage();
    
    try {
      // Interceptar responses GraphQL
      const tweets = [];
      page.on('response', async (response) => {
        if (response.url().includes('graphql') && response.url().includes('UserTweets')) {
          try {
            const data = await response.json();
            // Parsear tweets del response
            const parsedTweets = this.parseTweetsFromGraphQL(data);
            tweets.push(...parsedTweets);
          } catch (e) {}
        }
      });
      
      await page.goto(`https://x.com/${username}`, { waitUntil: 'networkidle' });
      await page.waitForSelector('[data-testid="UserName"]', { timeout: 15000 });
      
      // Scroll para cargar más tweets
      for (let i = 0; i < 3; i++) {
        await page.evaluate(() => window.scrollBy(0, 1000));
        await this.randomDelay(2000, 4000);
      }
      
      // Extraer datos del perfil
      const profile = await page.evaluate(() => {
        const nameEl = document.querySelector('[data-testid="UserName"]');
        const bioEl = document.querySelector('[data-testid="UserDescription"]');
        
        return {
          name: nameEl?.textContent || '',
          bio: bioEl?.textContent || ''
        };
      });
      
      return { username, profile, tweets, success: true };
      
    } catch (error) {
      return { username, error: error.message, success: false };
    } finally {
      await context.close();
    }
  }

  parseTweetsFromGraphQL(data) {
    const tweets = [];
    try {
      const instructions = data?.data?.user?.result?.timeline?.instructions || [];
      for (const instruction of instructions) {
        for (const entry of instruction.entries || []) {
          const content = entry.content?.itemContent?.tweet_results?.result;
          if (content?.legacy) {
            tweets.push({
              id: content.rest_id,
              text: content.legacy.full_text,
              likes: content.legacy.favorite_count,
              retweets: content.legacy.retweet_count
            });
          }
        }
      }
    } catch (e) {}
    return tweets;
  }

  randomDelay(min, max) {
    return new Promise(resolve => 
      setTimeout(resolve, Math.random() * (max - min) + min)
    );
  }

  async close() {
    if (this.browser) await this.browser.close();
  }
}

// Uso
async function main() {
  const scraper = new TwitterScraper({
    username: 'user-country-US',
    password: 'your_password',
    host: 'gate.proxyhat.com',
    port: 8080
  });
  
  await scraper.init();
  const result = await scraper.scrapeProfile('nasa');
  console.log(JSON.stringify(result, null, 2));
  await scraper.close();
}

main();

Puntos clave

  • La API de X es costosa — El tier gratuito fue eliminado; el Basic ($100/mes) ofrece solo 10K posts/mes.
  • Datos públicos vs login — Perfiles y tweets individuales son accesibles; búsqueda requiere autenticación.
  • Proxies residenciales son esenciales — X detecta y limita agresivamente IPs de datacenter.
  • Rate limits son dinámicos — Implementa backoff exponencial y rotación de IPs.
  • GraphQL es la clave técnica — X carga datos vía endpoints GraphQL internos; interceptarlos es más eficiente que parsear HTML.
  • Considera la API oficial — Para uso comercial continuo, los costos legales del scraping pueden superar el precio de la API.

Conclusión

El scraping de Twitter/X sigue siendo técnicamente viable para datos públicos, pero requiere infraestructura robusta: proxies residenciales de calidad, manejo sofisticado de rate limits, y comprensión de los riesgos legales.

Para proyectos de pequeña escala o uso puntual, el scraping puede ser costo-efectivo. Para dashboards comerciales de monitoreo continuo, la API oficial —aunque costosa— ofrece estabilidad, soporte, y protección legal que el scraping no puede igualar.

Si decides proceder con scraping, usa proxies residenciales confiables como los de ProxyHat, implementa delays humanizados, y siempre respeta los límites técnicos y legales del servicio.

Para más información sobre casos de uso de proxies para web scraping, visita nuestra guía de web scraping con proxies.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog