【原创】使用Puppeteer统计纵横研究院文章数据

最近正好学习到Puppeteer,便以统计纵横研究院文章做一个练习。

Puppeteer是Google Chrome团队官方的无界面Chrome工具,它是一个Node库,提供了一个高级的 API 来控制DevTools协议上的无头版Chrome。使用Puppeteer可以模拟用户在浏览器执行的大部分操作,如截图、抓取网页渲染后的内容、页面交互等。

最终抓取的文章数据地址如下:

数据展示地址:http://47.104.205.189:30000/

接下来就看下puppeteer模拟用户操作抓取数据的过程。

一、获取纵横研究院所有专题
  1. 运行一个puppeteer浏览器
const browser = await puppeteer.launch({
  headless: false
})

headless表示是否以无头模式运行,关闭此选项可以开发一个受代码控制的浏览器,便于调试。

  1. 进入https://www.jianshu.com/u/9b797d42a0cc页面
// 页面加载参数
const pageOptions = {
  timeout: 0, 
  waitUntil: [
    'domcontentloaded',
    'networkidle0'
  ]
}
const page = await browser.newPage()
await page.goto('https://www.jianshu.com/u/9b797d42a0cc', pageOptions)
  • timeout:页面超时时间,简书的页面如果频繁加载,会出现资源加载过慢的情况,这里设置为0表示不设置超时时间
  • waitUntil:页面打开完成的时机,domcontentloaded表示页面的DOMContentLoaded事件触发,networkidle0表示至少500ms内无网络请求
  1. 点击他创建的专题中的查看更多,显示所有纵横研究院专题

页面右侧默认只显示10个专题,需要模拟点击事件查看更多

专题列表
async function safeFunc (func) {
  try {
    const res = await func()
    return [null, res]
  } catch (e) {
    return [e, null]
  }
}
await safeFunc(async () => {
  await page.click('.list .check-more')
  await delay(1000)
})

page.click方法用来模拟用户点击事件,如果选择器没有选择到元素会抛出错误,因此用safeFunc通用方法处理了下错误。

  1. 获取所有专题
const res = await page.evaluate(async () => {
  const titleDom = Array.from(document.querySelectorAll('.title'))
    .find(one => one.innerText === '他创建的专题')
  if (!titleDom) return []
 // 通过选择器和dom相关方法获取到页面中专题的数据
  return Array.from(titleDom.nextElementSibling.querySelectorAll('li'))
    .reduce((acc, current) => {
      const item = current.querySelector('.name')
      if (!item) return acc
      return acc.concat({
        topicName: item.innerText,
        topicHome: item.href
      })
    }, [])
})

page.evaluate可以在浏览器环境执行传入的函数,因此在传入的函数中可以获取到window、document对象等,能执行浏览器的dom相关方法。

二、到每个专题下获取专题中的所有文章

从专题页获取文章列表如下:

async function getArticles (page) {
  await autoScroll(page)
  const articles = await page.evaluate(async () => {
    return Array.from(document.querySelectorAll('.note-list > li'))
      .reduce((acc, current) => {
        const titleDom = current.querySelector('.title')
        const nicknameDom = current.querySelector('.nickname')
        if (!titleDom || !nicknameDom) return acc

        const starIcon = nicknameDom.parentElement.querySelector('.ic-list-like')
        const stars = (starIcon && Number.parseInt(starIcon.nextSibling.data)) || 0
        const commentIcon = nicknameDom.parentElement.querySelector('.ic-list-comments')
        const comments = (commentIcon && Number.parseInt(commentIcon.nextSibling.data)) || 0
        return acc.concat({
          authorName: nicknameDom.innerText, // 作者名称
          authorHome: nicknameDom.href, // 作者主页
          title: titleDom.innerText, // 文章标题
          url: titleDom.href, // 文章地址
          stars, // 点赞数
          comments // 评论数
        })
      }, [])
  })
  return articles
}

该方法也是在浏览器上下文中用选择器选择到对应的dom元素,挨个获取文章的数据。在获取文章之前有一个方法autoScroll是用来将页面滚动到底部的,因为专题中文章列表为懒加载,滚动到底部才能读取到所有文章。autoScroll方法如下:

async function autoScroll (page) {
  await page.evaluate(async () => {
    await new Promise((resolve, reject) => {
      let totalHeight = 0
      let distance = 100
      let timer = setInterval(() => {
        let scrollHeight = document.body.scrollHeight
        window.scrollBy(0, distance)
        totalHeight += distance
        if (totalHeight >= scrollHeight) {
          clearInterval(timer)
          resolve()
        }
      }, 100)
    })
  })
}

如上所示,通过定时器设置页面的滚动高度来加载更多文章,直到滚动高度为实际页面高度即文章加载完毕。

遍历获取到的专题列表,到每个专题页面获取文章,如下:

const topics = await getTopics(browser)
const page = await browser.newPage()
for (const topic of topics) {
  await page.goto(topic.topicHome, pageOptions)
  const articles = await getArticles(page)
  Object.assign(topic, {
    articles: articles.map(one => ({ ...topic, ...one }))
  })
}
三、到用户页面获取文章的阅读量和发布时间

如果专题页直接显示了文章的阅读量和发布时间,那么根据以上两步拿到的数据就足够统计了。接下来需要对专题内所有的文章按作者分组,再到每个作者的主页获取文章的详细信息。

按作者分组:

const authors = topics.reduce((acc, topic) => {
  topic.articles.forEach(article => {
    const { authorName, authorHome } = article
    const exsitAuthor = acc.find(one => one.authorHome === authorHome)
    if (exsitAuthor) {
      Object.assign(exsitAuthor, { articles: [...exsitAuthor.articles, article] })
    } else {
      acc.push({ authorName, authorHome, articles: [article] })
    }
  })
  return acc
}, [])

从作者的主页获取获取文章的阅读量和发布时间:

async function getArticlesDetail (page) {
  await autoScroll(page)
  const articles = await page.evaluate(async () => {
    return Array.from(document.querySelectorAll('.note-list > li')).map(one => {
      if (!one) return {}
      const titleDom = one.querySelector('.title')
      const url = titleDom && titleDom.href
      const readIcon = one.querySelector('.ic-list-read')
      const readCount = (readIcon && Number.parseInt(readIcon.nextSibling.data)) || 0
      const timeDom = one.querySelector('.time')
      const publishTime = timeDom && moment(timeDom.dataset.sharedAt).format('YYYY-MM-DD HH:mm')
      return { url, readCount, publishTime }
    })
  })
  return articles
}

遍历专题内发布过文章的用户,到每个用户页面获取文章,如下:

for (const author of authors) {
  const { authorHome, articles } = author
  await page.goto(authorHome, pageOptions)
  const authorAllArticles = await getArticlesDetail(page)
  articles.forEach(article => {
    const articleExtraInfo = authorAllArticles.find(one => article.url === one.url)
    Object.assign(article, articleExtraInfo)
  })
}
四、排序、整理数据格式,导出json
const allArticles = authors.reduce((acc, current) => acc.concat(current.articles), [])
const allReadCount = allArticles.reduce((acc, current) => (acc + current.readCount), 0)

// 保存文章列表
output({
  articleCount: allArticles.length,
  readCount: allReadCount,
  articles: allArticles.sort((a, b) => (b.readCount - a.readCount))
}, './纵横研究院文章列表.json')

// 专题文章信息补全
topics.forEach(one => {
  one.articles.forEach(article => {
    const articleExtraInfo = allArticles.find(one => article.url === one.url)
    Object.assign(article, articleExtraInfo)
  })
})

// 保存专题统计信息
output({
  articleCount: allArticles.length,
  readCount: allReadCount,
  topicCount: topics.length,
  topics: topics
    .sort((a, b) => (b.articles.length - a.articles.length))
    .map(one => ({
      articleCount: one.articles.length,
      readCount: one.articles.reduce((acc, current) => (acc + current.readCount), 0),
      ...one,
      articles: one.articles.sort((a, b) => (b.readCount - a.readCount))
    }))
}, './纵横研究院专题统计.json')

// 保存作者统计信息
output({
  articleCount: allArticles.length,
  readCount: allReadCount,
  authorCount: authors.length,
  authors: authors
    .sort((a, b) => (b.articles.length - a.articles.length))
    .map(one => ({
      articleCount: one.articles.length,
      readCount: one.articles.reduce((acc, current) => (acc + current.readCount), 0),
      ...one,
      articles: one.articles.sort((a, b) => (b.readCount - a.readCount))
    }))
}, './纵横研究院作者统计.json')

以上为所有步骤,最终代码和运行结果地址点 这里 查看。

拓展

执行以上步骤获取统计信息,每次大概会花费6分钟左右,因为需要挨个到20个专题、60多个用户主页去获取信息,对于专题或用户文章较多的页面,需要滚动页面到底部懒加载所有文章。

如果同时打开多个页面,并行去处理这些页面跳转、懒加载、获取信息等,应该可以优化执行时间。用多个页面去处理任务如下:

async function execTasks (browser, tasks, maxPageCount = 5) {
  const taskStatus = new Array(tasks.length).fill(0)
  await Promise.all(Array.from({ length: maxPageCount }).map(async (one, i) => {
    const page = await browser.newPage()
    while (true) {
      const index = findIndex(taskStatus, status => !status)
      if (index === -1) break
      taskStatus[index] = 1
      await tasks[index](page)
    }
  }))
}

const topics = await getTopics(browser)
await execTasks(browser, topics.map(topic => async (page) => {
  await page.goto(topic.topicHome, pageOptions)
  const articles = await getArticles(page)
  Object.assign(topic, {
    articles: articles.map(one => ({ ...topic, ...one }))
  })
}))

以上代码开启了5个网页,共同处理统计专题的任务,不幸的是:

image.png

可能是简书对浏览器并发请求网页有限制,实际只有一个页面正常打开了,经过尝试,就算只打开两个网页窗口并行处理任务,也会出现加载失败的情况,所以最后还是妥协了只用一个page页。

本文参考资源如下

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容

  • 问答题47 /72 常见浏览器兼容性问题与解决方案? 参考答案 (1)浏览器兼容问题一:不同浏览器的标签默认的外补...
    _Yfling阅读 13,703评论 1 92
  • 第一部分 HTML&CSS整理答案 1. 什么是HTML5? 答:HTML5是最新的HTML标准。 注意:讲述HT...
    kismetajun阅读 27,347评论 1 45
  • 1.puppeteer简介 puppeteer是一个node库,是Google chrome团队官方的无界面(he...
    伊人风采_690d阅读 7,602评论 0 11
  • Yahoo!的Exceptional Performance团队为改善Web性能带来最佳实践。他们为此进行了一系列...
    拉风的老衲阅读 1,826评论 0 1
  • 1、尽量减少HTTP请求次数 终端用户响应的时间中,有80%用于下载各项内容,这部分时间包括下载页面中的图像、样式...
    兔子不打地鼠打代码阅读 518评论 0 1