Scraping de X/Twitter con TweetScraperR

TweetScraperR recolecta datos de X/Twitter (tweets, usuarixs y metadatos) para análisis y visualización. Esta viñeta describe el flujo de trabajo, basado en consultas a la API GraphQL interna de X (datos estructurados, sin selectores HTML frágiles). Las salidas que se muestran son capturas reales obtenidas entre el 2026-10-18 y el 2026-10-20.

Nota de versiones: desde la 0.5.0 el paquete expone una superficie única sobre el motor Node.js/Playwright (36 funciones en la 0.8, con el pool de cuentas). Las funciones de scraping por HTML (getTweetsTimeline(), getTweetsData(), getUsersData(), la familia getUrls*(), etc.) fueron eliminadas; el mapa de reemplazos está en el NEWS.md.

1. Instalar el motor (una vez por máquina)

El motor de recolección corre sobre Node.js (>= 18) + Playwright. No se instala junto con el paquete: lo descargás vos, una sola vez, en un caché persistente del usuario (tools::R_user_dir("TweetScraperR", "cache"), que sobrevive a reinstalaciones del paquete):

library(TweetScraperR)

# Instala el motor de Node.js/Playwright (una sola vez por máquina).
installPlaywrightEngine()

# Verifica que el motor quedó operativo.
checkPlaywrightEngine()
#> Motor Playwright OK — Playwright 1.64.1 | Node v24.20.0

2. Importar tu sesión

X bloquea el login automatizado (detecta el navegador por fingerprint). Por eso la sesión se importa desde tu navegador normal, donde ya estás logueadx:

  1. Iniciá sesión en https://x.com en tu navegador habitual.
  2. Abrí las DevTools (F12) → pestaña Application / Almacenamiento → Cookies → https://x.com.
  3. Copiá los valores de las cookies auth_token y ct0.
  4. Importalos:
importSessionX(auth_token = "tu_auth_token", ct0 = "tu_ct0")

La sesión queda guardada en disco y todas las funciones la reusan sin volver a loguearse. Cuando las cookies expiren, repetí el paso para reimportarlas.

Varias cuentas: el pool

X limita cada endpoint por cuenta en ventanas de 15 minutos (la búsqueda admite unas 50 consultas). Con varias cuentas, la recolección rota sola: la cuenta que agota el cupo queda en pausa hasta que X lo renueva y se sigue con la siguiente desde donde se cortó (retomando el cursor de paginación); la que X rechaza (sesión vencida o suspendida) se desactiva.

Hay tres formas de cargar cuentas:

# 1) Asistente de consola: pide nombre, auth_token y ct0 (entrada oculta) y valida
setupAccountsX()

# 2) Carga masiva desde un CSV (separado por "," o ";", como lo guarda Excel)
#    columnas: account, auth_token, ct0 y, opcional, proxy
addAccountsX("cuentas.csv")

# 3) De a una, desde código
importSessionX(cookies = "auth_token=...; ct0=...", account = "cuenta2")

Para revisar y administrar el pool:

checkAccountsX()   # valida cada sesión y registra su @usuario real
accountsX()        # estado: activas, pausas por endpoint, requests, errores
reactivateAccountX("cuenta2")
resetLocksX()      # quita las pausas de rate-limit
removeAccountX("cuenta2")

checkAccountsX() desactiva las sesiones vencidas y, si la misma cuenta de X quedó cargada dos veces con nombres distintos, conserva solo la más nueva. Con una sola cuenta, al agotarse el cupo el paquete espera a que X lo renueve, como máximo getOption("TweetScraperR.wait_timeout", 900) segundos.

Proxy (opcional)

Un proxy hace que una cuenta salga a X por otra IP. No suma cupo (eso lo hace el pool), pero sirve si X bloquea tu IP o para que las cuentas no salgan todas de la misma conexión. Conviene una IP residencial dedicada y fija (“static residential” o “ISP”), usando la dirección HTTP del proveedor: Chromium no admite SOCKS5 con usuario y clave.

# Por cuenta (valida la cuenta a través del proxy nuevo)
setProxyX("cuenta2", "http://usuario:clave@host:puerto")
setProxyX("cuenta2", NULL)   # quitarlo

# Global, para las cuentas sin proxy propio
options(TweetScraperR.proxy = "http://usuario:clave@host:puerto")
# o TWEETSCRAPERR_PROXY=... en ~/.Renviron

3. Recolectar datos (funciones *API())

3.1 Timeline de unx usuarix

tl <- getUserTweetsAPI("NASA", n_tweets = 10)
#> Recolectando timeline de @NASA...
#> Tweets únicos recolectados: 10
#> Datos procesados y guardados.

Cada función de tweets devuelve el mismo tibble de 31 columnas (salida real del 2026-10-20):

dplyr::glimpse(tl)
#> Rows: 10
#> Columns: 31
#> $ fecha            <dttm> 2026-10-20 18:00:27, 2026-10-20 18:00:06, 2026-10-20~
#> $ user             <chr> "@NASA", "@NASA", "@NASA", "@NASA", "@NASA", "@NASA",~
#> $ texto            <chr> "LIVE: Get a preview of two upcoming spacewalks set f~
#> $ idioma           <chr> "en", "en", "en", "en", "en", "en", "en", "en", "en",~
#> $ respuestas       <int> 41, 20, 151, 11, 83, 189, 169, 81, 88, 92
#> $ retweets         <int> 79, 75, 305, 52, 120, 249, 264, 168, 172, 243
#> $ citas            <int> 5, 8, 43, 3, 14, 25, 19, 10, 18, 10
#> $ megustas         <int> 497, 511, 1949, 322, 1034, 1652, 2026, 1165, 1100, 21~
#> $ views            <int> 227105, 224886, 286788, 90650, 405674, 404857, 100438~
#> $ emoticones       <list> <>, <>, <>, <>, <"🇺", "🇿">, "✈", <>, <>, <>, <>
#> $ hashtags         <list> <>, <>, <>, <>, <>, <>, <>, <>, <>, <>
#> $ menciones        <list> "astro_anil", <>, "NASAMoonBase", <"NASAMoonBase", "N~
#> $ urls_externas    <list> "https://x.com/i/broadcasts/1jxXgBNWypPJZ", "https://~
#> $ media            <list> <>, <>, "https://video.twimg.com/amplify_video/211256~
#> $ media_tipo       <list> <>, <>, "video", <>, "photo", "video", "photo", "pho~
#> $ es_retweet       <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FAL~
#> $ es_cita          <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FAL~
#> $ tweet_citado_id  <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
#> $ conversation_id  <chr> "2112604875452223824", "2112604787539865876", "21125~
#> $ url              <chr> "https://x.com/NASA/status/2112604875452223824", "ht~
#> $ tweet_id         <chr> "2112604875452223824", "2112604787539865876", "21125~
#> $ user_id          <chr> "11348282", "11348282", "11348282", "11348282", "1134~
#> $ respuesta_a_id   <chr> NA, NA, NA, "2112560723603632217", NA, NA, NA, NA, NA~
#> $ respuesta_a_user <chr> NA, NA, NA, "@NASA", NA, NA, NA, NA, NA, NA
#> $ retweet_id       <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
#> $ guardados        <int> 28, 38, 122, 25, 32, 67, 112, 50, 48, 131
#> $ fuente           <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
#> $ sensible         <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALS~
#> $ editado          <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALS~
#> $ lugar            <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
#> $ pais             <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA

Los tweets largos (más de 280 caracteres) vienen completos en texto.

3.2 Timelines combinados (curaduría de investigación)

combinado <- getTweetsTimelinesAPI(
  c("sanchezcastejon", "agusnieto77"),
  n_tweets = 40
)
#> Recolectando timelines de 2 cuentas...
#>   @sanchezcastejon: 40 tweets
#>   @agusnieto77: 40 tweets
#> Total combinado: 80 tweets de 2 cuentas.
#> Datos procesados y guardados.

3.3 Búsqueda (Latest / Top / Media) con rango de fechas

La búsqueda soporta los operadores de X (from:, lang:, …) y los argumentos since/until para acotar por días (formato "YYYY-MM-DD", ambos extremos inclusivos). El artículo Parámetros de búsqueda recorre todos los operadores con ejemplos verificados, incluidas las franjas horarias (since = "2026-10-20 12:00", en UTC):

top <- getTweetsSearchAPI("Milei", n_tweets = 10, product = "Top")
#> Buscando: Milei (Top)
#> Tweets únicos recolectados: 10
#> Datos procesados y guardados.
#> # A tibble: 10 × 31
#>   user           fecha               megustas retweets
#>   <chr>          <dttm>                 <int>    <int>
#> 1 @JMilei        2026-10-15 17:54:43    11639     1558
#> 2 @JMilei        2026-10-07 13:04:03    32757     4481
#> 3 @Gianferreyra_ 2026-10-17 03:26:10    17298     1028
#> 4 @PinkuDraw     2026-10-16 04:49:39       64        9
#> 5 @JMilei        2026-10-16 21:08:15     5639      778

rango <- getTweetsSearchAPI(
  "Feijoo", n_tweets = 20,
  since = "2026-10-17", until = "2026-10-18"
)
#> Buscando: Feijoo since:2026-10-17 until:2026-10-18 (Latest)
#> Tweets únicos recolectados: 20

Captura en vivo

getTweetsLiveAPI() sigue una búsqueda “hacia adelante”: repite la búsqueda Latest cada interval segundos (mínimo 60) durante duration y, desde la segunda ronda, pide solo lo publicado después del último tweet visto (since_id:). Con save = TRUE guarda lo acumulado después de cada ronda; Esc / Ctrl+C corta la captura y devuelve lo recolectado hasta ese momento:

vivo <- getTweetsLiveAPI("argentina lang:es", interval = 60, duration = 60, n_tweets = 20)
#> Captura en vivo de: argentina lang:es (cada 60 s hasta las 18:26:30)
#> 18:25:36 | ronda 1: 20 nuevos | total: 20
#> Ronda 2: se alcanzo el tope de 20 tweets; puede haber tweets sin recolectar. Conviene bajar `interval` o subir `n_tweets`.
#> 18:26:37 | ronda 2: 20 nuevos | total: 40
#> Captura terminada: 40 tweets en 2 rondas.

3.4 Respuestas / hilo de un tweet

hilo <- getTweetsRepliesAPI(
  "https://x.com/sanchezcastejon/status/2111426246517072370",
  n_tweets = 8
)
#> Recolectando respuestas de: https://x.com/sanchezcastejon/status/2111426246517072370
#> Tweets únicos recolectados: 8
#> Datos procesados y guardados.
#> # A tibble: 8 × 31 (primeras 3 filas)
#>   user             fecha               texto
#>   <chr>            <dttm>              <chr>
#> 1 @sanchezcastejon 2026-10-17 11:57:00 "Feijóo es todo un personaje.\n\nDice que va..."
#> 2 @Komainu_Ronin   2026-10-17 14:22:43 "@sanchezcastejon Habló, el galgo de Paiporta..."
#> 3 @romanocisneros  2026-10-17 12:44:36 "@sanchezcastejon Si,sigue así chaval..."

La primera fila es siempre el tweet focal; el resto son sus respuestas.

3.5 Datos de tweets a partir de sus URLs

datos <- getTweetsDataAPI(c(
  "https://x.com/sanchezcastejon/status/2111426246517072370"
))
#> Procesado: https://x.com/sanchezcastejon/status/2111426246517072370
#> Datos procesados y guardados.

3.6 Media (fotos/videos) de unx usuarix

media <- getUserMediaAPI("sanchezcastejon", n_tweets = 15)
#> Recolectando media de @sanchezcastejon...
#> Tweets de media recolectados: 15
#> Datos procesados y guardados.

Las columnas media (URLs) y media_tipo ("photo" / "video") describen cada adjunto.

3.7 Redes de usuarixs

seguidores <- getUserFollowersAPI("agusnieto77", n_users = 10)
siguiendo  <- getUserFollowingAPI("sanchezcastejon", n_users = 10)
retweeters <- getTweetsRetweetsAPI(
  "https://x.com/sanchezcastejon/status/2111426246517072370",
  n_users = 10
)

3.8 Datos de perfil

perfiles <- getUsersDataAPI("NASA")
#> Datos procesados y guardados.
#> # A tibble: 1 × 18
#>   user  nombre user_id  descripcion        seguidores siguiendo tweets favoritos
#>   <chr> <chr>  <chr>    <chr>                   <int>     <int>  <int>     <int>
#> 1 @NASA NASA   11348282 "Making the seemi…   92315363       115  74444     17032
#> # ℹ 10 more variables: verificado <lgl>, ubicacion <chr>, sitio_web <chr>,
#> #   imagen_perfil <chr>, banner <chr>, fecha_creacion <dttm>, url <chr>,
#> #   tweets_media <int>, protegida <lgl>, tipo_verificacion <chr>

3.9 “Acerca de esta cuenta”

La ficha que X muestra en x.com/<cuenta>/about: país desde el que opera la cuenta, cambios de nombre de usuarix y verificación. X no publica todos los campos para todas las cuentas.

about <- getUsersAboutAPI(c("NASA", "elonmusk"))
#> # A tibble: 2 × 13
#>   user      nombre    user_id  pais_cuenta   ubicacion_precisa cambios_usuario
#>   <chr>     <chr>     <chr>    <chr>         <lgl>                       <int>
#> 1 @NASA     NASA      11348282 <NA>          TRUE                            0
#> 2 @elonmusk Elon Musk 44196397 United States FALSE                           0
#> # ℹ 7 more variables: ultimo_cambio_usuario <dttm>, afiliada_a <chr>,
#> #   origen_cuenta <chr>, identidad_verificada <lgl>, verificada_desde <dttm>,
#> #   fecha_creacion <dttm>, url <chr>

3.10 Listas y búsqueda de cuentas

miembros <- getListMembersAPI("https://x.com/i/lists/53645372", n_users = 20)
#> Recolectando miembros de la lista 53645372...
#> Usuarios únicos recolectados: 20
lista <- getListTweetsAPI("53645372", n_tweets = 25)
#> Recolectando tweets de la lista 53645372...
#> Tweets únicos recolectados: 25
cuentas <- getUsersSearchAPI("rstats", n_users = 10)
#> Buscando usuarixs: rstats
#> Usuarios únicos recolectados: 10
cuentas[, c("user", "seguidores")]
#> # A tibble: 10 × 2
#>    user           seguidores
#>    <chr>               <int>
#>  1 @rstats              1246
#>  2 @rstatstweet        28306
#>  3 @RStatsJobs          3956

Por defecto las funciones no escriben archivos (save = FALSE). Con save = TRUE guardan un .rds con marca de tiempo en dir (por defecto, el directorio de trabajo).

4. Visualización y análisis

Las columnas tienen nombres estables, así que el post-procesamiento va directo, sin renombrar nada:

plotTime(tl)                  # serie temporal (columna fecha)
plotWords(tl, max_words = 50) # nube de palabras (columna texto)
plotEmojis(tl)                # ranking de emojis (columna emoticones)
getTweetsHashtags(tl)         # hashtags detectados (columna hashtags)

Para análisis con IA (requieren OPENAI_API_KEY):

getTweetsSentiments(tl$texto)      # sentimiento de cada texto
getTweetsImagesAnalysis(tl$media)  # qué muestran las imágenes

5. Datos legacy ya guardados

Si tenés .rds viejos con HTML crudo (columna art_html), extractTweetsData() los sigue procesando sin necesidad de volver a scrapear:

viejos <- readRDS("tweets_2024.rds")
procesados <- extractTweetsData(viejos)

Uso responsable

El scraping autenticado de X puede violar sus Términos de Servicio y derivar en la suspensión de la cuenta. El contenido recolectado está sujeto a los derechos de X y de sus autores. Usá cuentas dedicadas de investigación y respetá la normativa de protección de datos aplicable.