Antes de escribir nuestro script, comprobemos que la instalación de Puppeteer se ha realizado correctamente:
import puppeteer from 'puppeteer';
async function scrapeTwitterData(twitter_url: string): Promise<void> {
// Launch Puppeteer
const browser = await puppeteer.launch({
headless: false,
args: ['--start-maximized'],
defaultViewport: null
})
// Create a new page
const page = await browser.newPage()
// Navigate to the target URL
await page.goto(twitter_url)
// Close the browser
await browser.close()
}
scrapeTwitterData("https://twitter.com/netflix")
Aquí abrimos una ventana del navegador, creamos una nueva página, navegamos a nuestra URL de destino y luego cerramos el navegador. En aras de la simplicidad y la depuración visual, abro la ventana del navegador maximizada en modo no headless.
Ahora, echemos un vistazo a la estructura del sitio web y extrayamos la lista de datos anterior poco a poco:
A primera vista, habrás notado que la estructura del sitio web es bastante compleja. Los nombres de las clases se generan aleatoriamente y muy pocos elementos HTML están identificados de forma única.
Por suerte para nosotros, al navegar por los elementos padres de los datos objetivo, encontramos el atributo «data-testid». Una búsqueda rápida en el documento HTML confirma que este atributo identifica de forma única el elemento que buscamos.
Por lo tanto, para extraer el nombre y el identificador del perfil, extraeremos el elemento «div» que tiene el atributo «data-testid» establecido en «UserName». El código tendrá este aspecto:
// Extract the profile name and handle
const profileNameHandle = await page.evaluate(() => {
const nameHandle = document.querySelector('div[data-testid="UserName"]')
return nameHandle ? nameHandle.textContent : ""
})
const profileNameHandleComponents = profileNameHandle.split('@')
console.log("Profile name:", profileNameHandleComponents[0])
console.log("Profile handle:", '@' + profileNameHandleComponents[1])
Dado que tanto el nombre del perfil como el identificador del perfil tienen el mismo elemento padre, el resultado final aparecerá concatenado. Para solucionar esto, utilizamos el método «split» para separar los datos.
A continuación, aplicamos la misma lógica para extraer la biografía del perfil. En este caso, el valor del atributo «data-testid» es «UserDescription»:
// Extract the user bio
const profileBio = await page.evaluate(() => {
const location = document.querySelector('div[data-testid="UserDescription"]')
return location ? location.textContent : ""
})
console.log("User bio:", profileBio)
El resultado final viene descrito por la propiedad «textContent» del elemento HTML.
Pasando a la siguiente sección de los datos del perfil, encontramos la ubicación, el sitio web y la fecha de registro bajo la misma estructura.
// Extract the user location
const profileLocation = await page.evaluate(() => {
const location = document.querySelector('span[data-testid="UserLocation"]')
return location ? location.textContent : ""
})
console.log("User location:", profileLocation)
// Extract the user website
const profileWebsite = await page.evaluate(() => {
const location = document.querySelector('a[data-testid="UserUrl"]')
return location ? location.textContent : ""
})
console.log("User website:", profileWebsite)
// Extract the join date
const profileJoinDate = await page.evaluate(() => {
const location = document.querySelector('span[data-testid="UserJoinDate"]')
return location ? location.textContent : ""
})
console.log("User join date:", profileJoinDate)
Para obtener el número de seguidores y seguidos, necesitamos un enfoque ligeramente diferente. Echa un vistazo a la captura de pantalla siguiente:
No hay ningún atributo «data-testid» y los nombres de las clases siguen generándose aleatoriamente. Una solución sería centrarse en los elementos de anclaje, ya que proporcionan un atributo «href» único.
// Extract the following count
const profileFollowing = await page.evaluate(() => {
const location = document.querySelector('a[href$="/following"]')
return location ? location.textContent : ""
})
console.log("User following:", profileFollowing)
// Extract the followers count
const profileFollowers = await page.evaluate(() => {
const location = document.querySelector('a[href$="/followers"]')
return location ? location.textContent : ""
})
console.log("User followers:", profileFollowers)
Para que el código sea válido para cualquier perfil de Twitter, hemos definido el selector CSS para que se centre en los elementos de anclaje cuyo atributo «href» termine en «/following» o «/followers», respectivamente.
Pasando a la lista de tuits, podemos identificar fácilmente cada uno de ellos utilizando el atributo «data-testid», tal y como se destaca a continuación:
El código no difiere de lo que hemos hecho hasta ahora, con la excepción de utilizar el método «querySelectorAll» y convertir el resultado en una matriz de JavaScript:
// Extract the user tweets
const userTweets = await page.evaluate(() => {
const tweets = document.querySelectorAll('article[data-testid="tweet"]')
const tweetsArray = Array.from(tweets)
return tweetsArray
})
console.log("User tweets:", userTweets)
Sin embargo, aunque el selector CSS es sin duda correcto, quizá hayas notado que la lista resultante está casi siempre vacía. Esto se debe a que los tuits se cargan unos segundos después de que se haya cargado la página.
La solución sencilla a este problema es añadir un tiempo de espera adicional después de navegar a la URL de destino. Una opción es probar con una cantidad fija de segundos, mientras que otra es esperar hasta que aparezca un selector CSS específico en el DOM:
await page.waitForSelector('div[aria-label^="Timeline: "]')
Así pues, aquí le indicamos a nuestro script que espere hasta que un elemento «div» cuyo atributo «aria-label» comience por «Timeline: » sea visible en la página. Y ahora el fragmento anterior debería funcionar a la perfección.
Continuando, podemos identificar los datos sobre el autor del tuit igual que antes, utilizando el atributo «data-testid».
En el algoritmo, recorreremos la lista de elementos HTML y aplicaremos el método «querySelector» a cada uno de ellos. De esta forma, podemos asegurarnos mejor de que los selectores que utilizamos sean únicos, ya que el ámbito de aplicación es mucho más reducido.
// Extract the user tweets
const userTweets = await page.evaluate(() => {
const tweets = document.querySelectorAll('article[data-testid="tweet"]')
const tweetsArray = Array.from(tweets)
return tweetsArray.map(t => {
const authorData = t.querySelector('div[data-testid="User-Names"]')
const authorDataText = authorData ? authorData.textContent : ""
const authorComponents = authorDataText.split('@')
const authorComponents2 = authorComponents[1].split('·')
return {
authorName: authorComponents[0],
authorHandle: '@' + authorComponents2[0],
date: authorComponents2[1],
}
})
})
console.log("User tweets:", userTweets)
Los datos sobre el autor también aparecerán concatenados aquí, así que, para asegurarnos de que el resultado tiene sentido, aplicamos el método «split» a cada sección.
El contenido de texto del tuit es bastante sencillo:
const tweetText = t.querySelector('div[data-testid="tweetText"]')
Para las fotos del tuit, extraeremos una lista de elementos «img», cuyos elementos padres son elementos «div» con el atributo «data-testid» establecido en «tweetPhoto». El resultado final será el atributo «src» de estos elementos.
const tweetPhotos = t.querySelectorAll('div[data-testid="tweetPhoto"] > img')
const tweetPhotosArray = Array.from(tweetPhotos)
const photos = tweetPhotosArray.map(p => p.getAttribute('src'))
Y, por último, la sección de estadísticas del tuit. Se puede acceder al número de respuestas, retuits y «me gusta» de la misma manera, a través del valor del atributo «aria-label», después de identificar el elemento con el atributo «data-testid».
Para obtener el número de visualizaciones, nos centramos en el elemento de anclaje cuyo atributo «aria-label» termina con la cadena «Views. View Tweet analytics».
const replies = t.querySelector('div[data-testid="reply"]')
const repliesText = replies ? replies.getAttribute("aria-label") : ''
const retweets = t.querySelector('div[data-testid="retweet"]')
const retweetsText = retweets ? retweets.getAttribute("aria-label") : ''
const likes = t.querySelector('div[data-testid="like"]')
const likesText = likes ? likes.getAttribute("aria-label") : ''
const views = t.querySelector('a[aria-label$="Views. View Tweet analytics"]')
const viewsText = views ? views.getAttribute("aria-label") : ''
Dado que el resultado final también contendrá caracteres, utilizamos el método «split» para extraer y devolver únicamente el valor numérico. A continuación se muestra el fragmento de código completo para extraer los datos de los tuits:
// Extract the user tweets
const userTweets = await page.evaluate(() => {
const tweets = document.querySelectorAll('article[data-testid="tweet"]')
const tweetsArray = Array.from(tweets)
return tweetsArray.map(t => {
// Extract the tweet author, handle, and date
const authorData = t.querySelector('div[data-testid="User-Names"]')
const authorDataText = authorData ? authorData.textContent : ""
const authorComponents = authorDataText.split('@')
const authorComponents2 = authorComponents[1].split('·')
// Extract the tweet content
const tweetText = t.querySelector('div[data-testid="tweetText"]')
// Extract the tweet photos
const tweetPhotos = t.querySelectorAll('div[data-testid="tweetPhoto"] > img')
const tweetPhotosArray = Array.from(tweetPhotos)
const photos = tweetPhotosArray.map(p => p.getAttribute('src'))
// Extract the tweet reply count
const replies = t.querySelector('div[data-testid="reply"]')
const repliesText = replies ? replies.getAttribute("aria-label") : ''
// Extract the tweet retweet count
const retweets = t.querySelector('div[data-testid="retweet"]')
const retweetsText = retweets ? retweets.getAttribute("aria-label") : ''
// Extract the tweet like count
const likes = t.querySelector('div[data-testid="like"]')
const likesText = likes ? likes.getAttribute("aria-label") : ''
// Extract the tweet view count
const views = t.querySelector('a[aria-label$="Views. View Tweet analytics"]')
const viewsText = views ? views.getAttribute("aria-label") : ''
return {
authorName: authorComponents[0],
authorHandle: '@' + authorComponents2[0],
date: authorComponents2[1],
text: tweetText ? tweetText.textContent : '',
media: photos,
replies: repliesText.split(' ')[0],
retweets: retweetsText.split(' ')[0],
likes: likesText.split(' ')[0],
views: viewsText.split(' ')[0],
}
})
})
console.log("User tweets:", userTweets)
Tras ejecutar todo el script, tu terminal debería mostrar algo como esto:
Profile name: Netflix
Profile handle: @netflix
User bio:
User location: California, USA
User website: netflix.com/ChangePlan
User join date: Joined October 2008
User following: 2,222 Following
User followers: 21.3M Followers
User tweets: [
{
authorName: 'best of the haunting',
authorHandle: '@bestoffhaunting',
date: '16 Jan',
text: 'the haunting of hill house.',
media: [
'https://pbs.twimg.com/media/FmnGkCNWABoEsJE?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmnGkk0WABQdHKs?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmnGlTOWABAQBLb?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmnGlw6WABIKatX?format=jpg&name=360x360'
],
replies: '607',
retweets: '37398',
likes: '170993',
views: ''
},
{
authorName: 'Netflix',
authorHandle: '@netflix',
date: '9h',
text: 'The Glory Part 2 premieres March 10 -- FIRST LOOK:',
media: [
'https://pbs.twimg.com/media/FmuPlBYagAI6bMF?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmuPlBWaEAIfKCN?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmuPlBUagAETi2Z?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmuPlBZaEAIsJM6?format=jpg&name=360x360'
],
replies: '250',
retweets: '4440',
likes: '9405',
views: '656347'
},
{
authorName: 'Kurtwood Smith',
authorHandle: '@tahitismith',
date: '14h',
text: 'Two day countdown...more stills from the show to hold you over...#That90sShow on @netflix',
media: [
'https://pbs.twimg.com/media/FmtOZTGaEAAr2DF?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmtOZTFaUAI3QOR?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmtOZTGaAAEza6i?format=jpg&name=360x360',
'https://pbs.twimg.com/media/FmtOZTGaYAEo-Yu?format=jpg&name=360x360'
],
replies: '66',
retweets: '278',
likes: '3067',
views: ''
},
{
authorName: 'Netflix',
authorHandle: '@netflix',
date: '12h',
text: 'In 2013, Kai the Hatchet-Wielding Hitchhiker became an internet sensation -- but that viral fame put his questionable past squarely on the radar of authorities. \n' +
'\n' +
'The Hatchet Wielding Hitchhiker is now on Netflix.',
media: [],
replies: '169',
retweets: '119',
likes: '871',
views: '491570'
}
]