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 dato | Accesible sin login | Notas |
|---|---|---|
| Perfiles públicos | Sí | Nombre, bio, seguidores, ubicación |
| Tweets individuales | Sí | Texto, timestamps, métricas públicas |
| Respuestas bajo un tweet | Parcial | X las carga dinámicamente; requiere scroll |
| Trending topics | Sí | Lista de tendencias por ubicación |
| Búsqueda (search) | No | Requiere login desde 2023 |
| Timeline de usuario | Limitado | Solo los últimos ~20 tweets sin auth |
| Notificaciones/DMs | No | Requiere 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
| Escenario | Recomendación | Razón |
|---|---|---|
| Monitoreo de marca profesional | API Basic/Pro | Estabilidad, soporte, cumplimiento legal |
| Investigación académica | API Academic (si disponible) | Acceso histórico, términos claros |
| Análisis puntual único | Scraping limitado | Costo-beneficio puede justificarlo |
| Dashboard comercial continuo | API Pro/Enterprise | Escalabilidad y protección legal |
| Datos de cuenta propia | API Basic | Incluido en tier básico |
Mejores prácticas éticas
- Respeta robots.txt — Aunque no legalmente vinculante, es buena práctica.
- Limita la frecuencia — No satures los servidores; compórtate como un usuario humano.
- No almacenes datos personales — Minimiza la retención de información identificable.
- Considera el impacto — ¿Tu scraping afecta negativamente a X o sus usuarios?
- 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.






