Antes de escrever o nosso script, vamos verificar se a instalação do Puppeteer correu bem:
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")
Aqui, abrimos uma janela do navegador, criamos uma nova página, navegamos até ao nosso URL de destino e, em seguida, fechamos o navegador. Por uma questão de simplicidade e depuração visual, abro a janela do navegador maximizada no modo não headless.
Agora, vamos dar uma olhada na estrutura do site e extrair a lista de dados anterior gradualmente:
À primeira vista, deve ter reparado que a estrutura do site é bastante complexa. Os nomes das classes são gerados aleatoriamente e muito poucos elementos HTML são identificados de forma única.
Felizmente para nós, à medida que navegamos pelos elementos pai dos dados alvo, encontramos o atributo “data-testid”. Uma pesquisa rápida no documento HTML confirma que este atributo identifica de forma única o elemento que pretendemos.
Portanto, para extrair o nome e o identificador do perfil, iremos extrair o elemento “div” que tem o atributo “data-testid” definido como “UserName”. O código ficará assim:
// 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])
Como tanto o nome do perfil como o identificador do perfil têm o mesmo pai, o resultado final aparecerá concatenado. Para corrigir isto, usamos o método “split” para separar os dados.
Em seguida, aplicamos a mesma lógica para extrair a biografia do perfil. Neste caso, o valor do atributo “data-testid” é “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)
O resultado final é descrito pela propriedade “textContent” do elemento HTML.
Passando para a secção seguinte dos dados do perfil, encontramos a localização, o site e a data de adesão sob a mesma estrutura.
// 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 obter o número de seguidores e seguidos, precisamos de uma abordagem ligeiramente diferente. Veja a captura de ecrã abaixo:
Não existe o atributo “data-testid” e os nomes das classes continuam a ser gerados aleatoriamente. Uma solução seria direcionar os elementos âncora, uma vez que estes fornecem um 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 tornar o código disponível para qualquer perfil do Twitter, definimos o seletor CSS para selecionar os elementos âncora cujo atributo “href” termina em “/following” ou “/followers”, respetivamente.
Passando para a lista de tweets, podemos novamente identificar facilmente cada um deles usando o atributo “data-testid”, conforme destacado abaixo:
O código não difere do que fizemos até este ponto, com a exceção de utilizar o método «querySelectorAll» e converter o resultado numa 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)
No entanto, embora o seletor CSS esteja certamente correto, deve ter reparado que a lista resultante está quase sempre vazia. Isso acontece porque os tweets são carregados alguns segundos após a página ter sido carregada.
A solução simples para este problema é adicionar um tempo de espera extra depois de navegarmos para o URL de destino. Uma opção é experimentar com um número fixo de segundos, enquanto outra é esperar até que um seletor CSS específico apareça no DOM:
await page.waitForSelector('div[aria-label^="Timeline: "]')
Assim, aqui instruímos o nosso script para esperar até que um elemento “div” cujo atributo “aria-label” comece por “Timeline: “ fique visível na página. E agora o trecho anterior deve funcionar na perfeição.
Continuando, podemos identificar os dados sobre o autor do tweet tal como antes, utilizando o atributo “data-testid”.
No algoritmo, iremos percorrer a lista de elementos HTML e aplicar o método “querySelector” a cada um deles. Desta forma, podemos garantir melhor que os seletores que utilizamos são únicos, uma vez que o âmbito visado é muito mais reduzido.
// 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)
Os dados sobre o autor também aparecerão concatenados aqui, por isso, para garantir que o resultado faz sentido, aplicamos o método “split” em cada secção.
O conteúdo de texto do tweet é bastante simples:
const tweetText = t.querySelector('div[data-testid="tweetText"]')
Para as fotos do tweet, iremos extrair uma lista de elementos “img”, cujos elementos pai são elementos “div” com o atributo “data-testid” definido como “tweetPhoto”. O resultado final será o atributo “src” destes elementos.
const tweetPhotos = t.querySelectorAll('div[data-testid="tweetPhoto"] > img')
const tweetPhotosArray = Array.from(tweetPhotos)
const photos = tweetPhotosArray.map(p => p.getAttribute('src'))
E, finalmente, a secção de estatísticas do tweet. O número de respostas, retweets e gostos é acessível da mesma forma, através do valor do atributo “aria-label”, depois de identificarmos o elemento com o atributo “data-testid”.
Para obter o número de visualizações, selecionamos o elemento âncora cujo atributo “aria-label” termina com a string “Visualizações. Ver análises do tweet”.
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") : ''
Como o resultado final também conterá caracteres, usamos o método “split” para extrair e devolver apenas o valor numérico. O trecho de código completo para extrair dados de tweets é mostrado abaixo:
// 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)
Após executar todo o script, o seu terminal deverá apresentar algo semelhante a isto:
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'
}
]