在编写脚本之前,让我们先验证 Puppeteer 是否已正确安装:
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")
这里我们打开一个浏览器窗口,新建一个页面,导航至目标 URL,然后关闭浏览器。为了简化操作和便于可视化调试,我以非无头模式全屏打开了浏览器窗口。
现在,让我们查看一下网站的结构,并逐步提取前面的数据列表:
乍看之下,您可能已经注意到该网站的结构相当复杂。类名是随机生成的,且极少有 HTML 元素具有唯一标识。
幸运的是,当我们遍历目标数据的父元素时,发现了“data-testid”属性。在 HTML 文档中快速搜索即可确认,该属性能唯一标识我们目标的元素。
因此,要提取用户名和用户ID,我们将提取那个“data-testid”属性值为“UserName”的“div”元素。代码如下所示:
// 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])
由于个人资料名称和用户名具有相同的父元素,最终结果会显示为拼接在一起。为解决此问题,我们使用“split”方法将数据拆分。
随后,我们采用相同的逻辑来提取个人资料简介。在此情况下,“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)
最终结果由 HTML 元素的“textContent”属性描述。
继续查看个人资料数据的下一部分,我们会发现位置、网站和加入日期也遵循相同的结构。
// 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)
要获取关注数和粉丝数,我们需要采用略微不同的方法。请查看下图:
此处没有“data-testid”属性,且类名仍是随机生成的。一个解决方案是定位锚点元素,因为它们具有唯一的“href”属性。
// 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)
为了使代码适用于任何 Twitter 个人资料,我们定义了 CSS 选择器,分别定位“href”属性以“/following”或“/followers”结尾的锚点元素。
接下来是推文列表,我们仍可通过“data-testid”属性轻松识别每条推文,如下所示:
这段代码与此前所做的并无二致,唯一的区别在于使用了“querySelectorAll”方法,并将结果转换为 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)
然而,尽管 CSS 选择器无疑是正确的,您可能已经注意到生成的列表几乎总是为空。这是因为推文会在页面加载完成后几秒钟才加载。
解决此问题的简单方法是在跳转至目标 URL 后增加额外的等待时间。一种方案是尝试固定数秒的等待时间,另一种则是等待特定的 CSS 选择器出现在 DOM 中:
await page.waitForSelector('div[aria-label^="Timeline: "]')
因此,这里我们指示脚本等待,直到页面上出现一个“aria-label”属性以“Timeline:”开头的“div”元素。现在,之前的代码片段应该能完美运行。
接下来,我们可以像之前一样,通过“data-testid”属性识别推文作者的信息。
在算法中,我们将遍历 HTML 元素列表,并对每个元素应用“querySelector”方法。这样,由于目标范围更小,我们可以更好地确保所使用的选择器是唯一的。
// 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)
作者相关数据在此处也会以拼接形式呈现,因此为了确保结果合理,我们对每个部分都应用了“split”方法。
推文的文本内容处理非常简单:
const tweetText = t.querySelector('div[data-testid="tweetText"]')
对于推文中的图片,我们将提取一组“img”元素,其父元素是“data-testid”属性设置为“tweetPhoto”的“div”元素。最终结果将是这些元素的“src”属性。
const tweetPhotos = t.querySelectorAll('div[data-testid="tweetPhoto"] > img')
const tweetPhotosArray = Array.from(tweetPhotos)
const photos = tweetPhotosArray.map(p => p.getAttribute('src'))
最后是推文的统计部分。通过识别带有“data-testid”属性的元素,并获取其“aria-label”属性的值,即可同样获取回复、转发和点赞的数量。
要获取浏览量,我们需要定位那个“aria-label”属性以“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") : ''
由于最终结果中也会包含字符,因此我们使用“split”方法来提取并仅返回数值部分。提取推文数据的完整代码片段如下所示:
// 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)
运行整个脚本后,终端应显示类似以下内容:
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'
}
]