[实现 N 个 API/网页爬虫] Node 的 HTTP 处理 - 请求与响应
本节目标:[各种手段请求爬取网页内容] 你请求,我应答,网络两头乐开花,Node 之所以成为服务器方案,离不开 HTTP 模块的能力之帆。
![10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图2](https://gitee.com/johnforrest/mdpbed/raw/master/imgLenov1/202204201031740.png#alt=image-20220420103137500)
在 Node 里面,起一个 HTTP Server 非常简单,如官网示例:
// 加载 http 模块const http = require('http')// 定义服务运行的主机名和端口号const hostname = '127.0.0.1'const port = 3000// 通过 http.createServer 方法创建一个服务实例// 同时传入回调函数,以接管后面进来的请求// 后面请求进来时,这个回调函数会被调用执行,同时会拿到两个参数,分别是 req 和 res// req 是可读流(通过 data 事件接收数据),res 是可写流(通过 write 写数据,end 结束输出)const server = http.createServer((req, res) => {// 设置返回的状态码 200 表示成功res.statusCode = 200// 设置返回的请求头类型 text/plain 表示普通文本res.setHeader('Content-Type', 'text/plain')// 对响应写入内容后,关闭可写流res.end('Hello World\n')})// 调用实例的 listen 方法把服务正式启动server.listen(port, hostname, () => {console.log(`Server running at http://${hostname}:${port}/`)})
HTTP 作为整个互联网数据通信中几乎最主流的协议,它本身就是巨大的知识库,无论是工作 1 年还是 10 年的工程师,每一次重温 HTTP 的整体知识相信都会有很多收获,从 HTTP/1.1 到 HTTP/2,从 HTTP 到 HTTPS,从 TCP 的握手到 cookie/session 的状态保持…,我们在接触和 HTTP 的时候,一开始很容易被吓唬到,扎进去学习的时候也确实枯燥乏味,比较好的办法,就是在工作中不断的使用它,不断的练习,随着使用中的一点点深入,我们会对 HTTP 越来越熟悉。
那么这一节,我们就挑 HTTP 模块在 Node 中的几个应用知识来学习,以代码练习为主,主要学习 HTTP 模块在 Node 中的使用。
简单的 HTTP 头常识
一个请求,通常会建立在两个角色之间,一个是客户端,一个是服务端,而且两者的身份可以互换的,比如一台服务器 A 向 服务器 B 发请求,那么 A 就是客户端,B 是服务端,反过来身份就变了,甚至如果 A 这台服务器自己向自己发一个请求,那么 A 里面发请求的程序就是客户端,响应请求的程序就是服务端了,所以大家可以打开思路,不用局限在端的形态上面。
我们简单的看下一个请求从浏览器发出,以及服务器返回,它们的头信息,我们去实现爬虫的时候,有时候需要构造假的请求头,或者解析响应头,这在特定场景下会有一定的参考作用,比如打开掘金的首页,我们针对这个网页 HTML 的 GET 请求,简单学习它里面的头信息知识:
// 请求由 A 请求行、B 请求头组成// A 请求行由 3 端组成,HTTP Verb/URL Path/HTTP Version// 1. 标明请求方法是 GET,往往用作获取资源(图片、视频、文档等等)// 2. /timeline 是请求的资源路径,由服务器来决定如何响应该地址// 3. HTTP 协议版本是 1.1GET /timeline HTTP/1.1// B 如下都是请求头// 去往哪个域名(服务器)去获取资源Host: juejin.im// 保持连接,避免连接重新建立,减少通信开销提高效率Connection: keep-alive// HTTP 1.0 时代产物,no-cache 禁用缓存Pragma: no-cache// HTTP 1.1 时代产物,与 Pragma 一样控制缓存行为Cache-Control: no-cache// 浏览器自动升级请求,告诉服务器后续会使用 HTTPS 协议请求Upgrade-Insecure-Requests: 1// 上报用户代理的版本信息User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36// 声明接收哪种格式的数据内容Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8// 声明所接受编码压缩的格式Accept-Encoding: gzip, deflate, br// 声明所接受的地区语言Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7// 捎带过去用户之前访问时候存储在客户端的 Cookie 信息,方便服务器识别记录Cookie: gr_user_id=1fa5e8a7b7c90; _ga=GA1.2.787252639.1488193773; ab={}; _gat=1; gr_session_id_89669d96c88aefbc_a009fcc=true...
当然还有 POST,HEAD, PUT, DELETE 等这些请求方法,他们甚至可以多传一些数据包,比如 POST 会多一个请求体,我们继续看下上面的这个请求头给到服务端,服务器返回的头是如何的:
// 整体上, response header 跟 request header 格式都是类似的// 响应由 3 部分组成,A 响应行; B 响应头;C 响应体// A 响应行依然是 HTTP 协议与响应状态码,200 是响应成功HTTP/1.1 200 OK// 响应的服务器类型Server: nginx// 当前响应的时间Date: Wed, 21 Nov 2018 05:45:21 GMT// 返回的数据类型,字符编码集Content-Type: text/html; charset=utf-8// 数据传输模式Transfer-Encoding: chunked// 保持连接Connection: keep-alive// HTTP 协议的内容协商,比如如何响应缓存Vary: Accept-Encoding// 内容安全策略,检测和削弱恶意共计,学问很深Content-Security-Policy: default-src * blob: data:;script-src 'self' 'unsafe-eval' *.xitu.io *.juejin.im *.baidu.com *.google-analytics.com *.meiqia.com dn-growing.qbox.me *.growingio.com *.guard.qcloud.com *.gtimg.com;style-src 'self' 'unsafe-inline' *.xitu.io *.juejin.im *.growingio.com *.guard.qcloud.com *.gtimg.com;// 设置各种 Cookie 以及何时失效,这些 cookie 会存储在发出请求的客户端本地Set-Cookie: ab={}; path=/; expires=Thu, 21 Nov 2019 05:45:21 GMT; secure; httponlySet-Cookie: auth=eyJ0b2tlbiI6...; path=/; expires=Wed, 28 Nov 2018 05:45:21 GMT; secure; httponlySet-Cookie: QINGCLOUDELB=165e4274d60907...; path=/; HttpOnly// 控制该网页在浏览器的 frame 中展示,如果是 DENY 则同域名页面中也不允许嵌套X-Frame-Options: SAMEORIGIN// 控制预检请求的结果缓存时长Access-Control-Max-Age: 86400// 在规定时间内,网站请求都会重定向走 HTTPS 协议,也属于安全策略Strict-Transport-Security: max-age=31536000// 编码压缩格式的约定,gzip 是一种比较节省资源的压缩格式Content-Encoding: gzip// 告诉所有的缓存机制是否可以缓存及哪种类型Cache-control: private// 服务器输出的标识,不同服务器不同,可以从服务器上关闭不输出X-Powered-By-Defense: from pon-wyxm-tel-qs-qssec-kd55
关于头还有很多的知识大家可以自行学习,具体场景中我们甚至会定义自己特定业务域的请求头和响应头,我们继续回到 Node 的 HTTP 模块中。
向别的服务器请求数据 - http.get
我们在 Node 里面,向另外一台服务器发请求,这个请求可能是域名/IP,请求的内容也可能五花八门,那这个请求该怎么构造呢?
这时候可以使用简单 http.get/https.get 方法,比如我们去请求一个 Node LTS JSON 文件,从浏览器里直接打开就可以自动下载,在 Node 里面就可以这样做:
// 加载 https 模块,https 的底层依然是 httpconst https = require('https')// 请求的目标资源const url = 'https://nodejs.org/dist/index.json'// 发起 GET 请求,回调函数中会拿到一个来自服务器的响应可读流 reshttps.get(url, (res) => {// 声明一个 字符串let data = ''// 每次可读流数据搬运过来,都是一个 buffer 数据块,每次通过 data 事件触发res.on('data', (chunk) => {// 把所有的 buffer 都拼一起data += chunk})res.on('end', () => {// 等待可读流接收完毕,就拿到了完整的 buffer// 通过 toString 把 buffer 转成字符串打印出来console.log(data.toString())})}).on('error', (e) => {console.log(e)})
会拿到这样的一坨 JSON 字符串:
[{"version":"v11.2.0","date":"2018-11-15","files":..},...]
通过 Promise 包装一个 http.get 请求
上面的这个请求,比较简单也比较硬,是通过回调和事件的形式来完成数据的接收,一旦有多个存在依赖关系的异步请求,就会写着难受一点,如果想让这个代码更友好一些,我们可以这样做:
const https = require('https')const url = 'https://nodejs.org/dist/index.json'// 声明一个普通函数,它接收 url 参数,执行后返回一个 Promise 实例const request = (url) => {return new Promise((resolve, reject) => {https.get(url, (res) => {let data = ''res.on('data', (chunk) => {data += chunk})res.on('end', () => {// 通过 Promise 的 resolve 来回调结果resolve(data.toString())})}).on('error', (e) => {reject(e)})})}// 在执行时候,可以使用 .then() 方法来链式调用,避免回调嵌套request(url).then(data => {console.log(data)})
通过 async function 来替代请求的链式传递
上面有了 Promise 的封装后,我们可以直接用 async function 继续完善下这个 Promise,进一步避免 then 的回调包裹,比如改成这样子:
const https = require('https')const url = 'https://nodejs.org/dist/index.json'// 改成一个 async 异步函数const request = async (url) => {return new Promise((resolve, reject) => {https.get(url, (res) => {let data = ''res.on('data', (chunk) => {data += chunk})res.on('end', () => {resolve(data.toString())})}).on('error', (e) => {reject(e)})})}// 声明一个异步函数,await 和 async 要配对使用async function run () {// 以同步的方式来写异步逻辑const data = await request(url)console.log(data)}// run 方法执行后本身也是一个 Promise,可以通过 then 链式调用run()
写法稍微改了一下,整个调用链就清晰了很多,而且 async function 的执行性能也要比 Promise 好的。
通过三方库 axios/request 来替代 http.get
以上都是用原生的 API 来直接实现,优点是不依赖三方库,拿到的响应就是天然的 Stream 流,那么一点点不方便的地方是,它回调中的响应 res 是 http.ClientRequest 流对象,需要自己监听 data 事件来手动组装 bufer 块,甚至还需要自己解析 JSON 数据格式,处理解析异常等,另外还不能原生支持 Promise,需要自己包装。在实际的工作场景中,我们为了开发效率,会考虑不深入这么底层的细节,直接采用三方库,比如 request/axios 等,我们通过 request 来实现一下:
// 首先 npm i requestconst request = require('request')const url = 'https://nodejs.org/dist/index.json'// 直接通过 request 提供的 get 方法发起请求,从回调函数中拿到结果request.get(url, (error, response, body) => {const json = JSON.parse(body)console.log(body)})
request 库可以让整个请求变得更简单,只不过它只支持回调形式,不支持 Promise,会有一点不方便,当然也可以用基于它封装的其他三方库,比如 request-promise 来实现 Promise,或者 request-promise-native 这样的原生 Promise,我们同样可以选择另外一个也有流行的库 - axios,这个库的优点是浏览器和 Node 服务端都可以通用,另外也不需要关心 JSON 化的处理,它向下依赖的包也很少,同时支持 Promise:
const axios = require('axios')const url = 'https://nodejs.org/dist/index.json'const run = async url => {try {const res = await axios.get(url)console.log(res.data)} catch (error) {}}run(url)
拿到的结果是一个 JSON 化后的数据格式,更加易用友好,简单了解下 API 和三方库,我们来做一些稍微复杂点的练习,比如爬取网页源码。
结合 http.get 和 cheerio 来爬取分析网页源码
请求一个网页就是获取一个远程 HTML 文件内容,跟上面我们获取 JSON 没有本质区别,拿到网页源码后,可以通过 cheerio 来加载源码遍历 DOM 节点,选择目标的 HTML 元素,从而获得期望的内容,比如文本或者链接,我们拿 Node 的 Code + Learn 页面为例,分析页面的 DOM 节点,左侧的菜单都是 aside 元素里的 li,每个 li 是一个 a 标签,我们只需要获取 a 标签就行了,这个通过 cheerio 可以很轻松的做到:
![10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图4](https://gitee.com/johnforrest/mdpbed/raw/master/imgLenov1/202204201032610.png#alt=image-20220420103213170)
我们拿到源码后,可以从 DOM 中拿到左侧的菜单内容,那么代码可以这样写:
const https = require('https')const $ = require('cheerio')const url = 'https://nodejs.org/en/get-involved/code-and-learn/'// 这里可以借助 request 三方库实现const request = async (url) => {return new Promise((resolve, reject) => {https.get(url, (res) => {let html = ''res.on('data', (chunk) => {html += chunk})res.on('end', () => {resolve(html.toString())})}).on('error', (e) => {reject(e)})})}async function run () {// 拿到网页源码内容const html = await request(url)// 遍历查找出目标元素节点const items = $('aside a', html)const menus = []// 遍历节点对象,调用 text 方法取出文本内容items.each(function() {menus.push($(this).text())})console.log(menus)}run()
打印结果应该就是一个数组了:
[ 'Get Involved','Code + Learn','Collab Summit','Contribute','Code of Conduct' ]
编程练习 1 - 实现 Node API 对比工具
有了上面的准备工作,我们可以挑战下难一点的任务,Node 版本之间有差异这个我们是知道的,即便是对于同一个模块的同一个 API,也会随着版本而发生变化,甚至有的 API 是逐渐废弃掉不再使用,我们实现一个爬虫,来指定某些 Node 版本,爬取它们里面会逐步废弃的 API 都有哪些,先来看下效果:
![10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图6](https://gitee.com/johnforrest/mdpbed/raw/master/imgLenov1/202204201032121.png#alt=image-20220420103233947)
代码我们可以这样实现:
const https = require('https')const Table = require('cli-table')const cheerio = require('cheerio')const link = (v, p) => `https://nodejs.org/dist/${v}/docs/api/${p}`async function crawlPage (version, path = '') {const url = link(version, path)return new Promise(function(resolve, reject) {https.get(url, (res) => {let buffer = ''res.on('data', (chunk) => {buffer += chunk})res.on('end', () => {resolve(buffer.toString())})}).on('error', function(e) {reject(e)})})}function findApiList (html) {const $ = cheerio.load(html)const items = $('#column2 ul').eq(1).find('a')const list = []items.each(function(item) {list.push({api: $(this).text(),path: $(this).attr('href')})})return list}function findDeprecatedList (html) {const $ = cheerio.load(html)const items = $('.stability_0')const list = []items.each(function(item) {list.push($(this).text().slice(0, 30))})return list}async function crawlNode (version) {const homePage = await crawlPage(version)const apiList = findApiList(homePage)let deprecatedMap = {// 'Command Line Options': ['']}const promises = apiList.map(async item => {const apiPage = await crawlPage(version, item.path)const list = findDeprecatedList(apiPage)return { api: item.api, list: list }})const deprecatedList = await Promise.all(promises)deprecatedList.forEach(item => {deprecatedMap[item.api] = item.list})return deprecatedMap}async function runTask (v1, v2, v3, v4) {const results = await Promise.all([crawlNode(v1),crawlNode(v2),crawlNode(v3),crawlNode(v4)])const table = new Table({head: ['API Version', v1, v2, v3, v4]})const v1Map = results[0]const v2Map = results[1]const v3Map = results[2]const v4Map = results[3]const keys = Object.keys(v4Map)keys.forEach(key => {if ((v1Map[key] && v1Map[key].length)|| (v2Map[key] && v2Map[key].length)|| (v3Map[key] && v3Map[key].length)|| (v4Map[key] && v4Map[key].length)) {table.push([key,(v1Map[key] || []).join('\n'),(v2Map[key] || []).join('\n'),(v3Map[key] || []).join('\n'),(v4Map[key] || []).join('\n')])}})console.log(table.toString())}runTask('v4.9.1', 'v6.14.4', 'v8.11.4', 'v10.13.0')
给大家布置个作业,可以把代码再升级一下,看如何一次性拿到所有的 Node LTS 版本,同时把它们的 API 废弃清单都打印出来。
借助 puppeteer 来爬取有状态或者异步数据页面
有了上面的训练,我们知道了一些信心,只要我想要爬取的内容,都可以写一个工具快速拿过来分析,然而有时候会事与愿违,有的目标网站会有异步的内容,甚至会有反爬策略,我们甚至拿不到正确的 HTML 源码,甚至更高级的策略中,我们即便拿到正确的 HTML,却未必能正确解析出来,比如用雪碧图错位来表示数字等等,这个要具体问题具体分析,我们打开掘金的小册页面,试下爬取这个页面内容,然后统计下一共现在上架了多少本册子,线上销售额有多少,掘金小册开创了知识分享的一种新形势,在技术圈非常流行,通过爬取这些数据,我们应该能更感同身受,大家也可以多帮掘金来宣传小册子,帮助掘金团队和平台越做越好。
那我们可以写这样一段代码:
// juejin-book.js// 在 node juejin-book.js 执行代码之前// 先 npm i cheerio request-promise 安装依赖模块const $ = require('cheerio')const rp = require('request-promise')const url = 'https://juejin.im/books'// 通过 request-promise 来爬取网页rp(url).then(function(html) {// 利用 cheerio 来分析网页内容,拿到所有小册子的描述const books = $('.info', html)let totalSold = 0let totalSale = 0let totalBooks = books.length// 遍历册子节点,分别统计它的购买人数,和销售额总和books.each(function() {const book = $(this )const price = $(book.find('.price-text')).text().replace('¥', '')const count = book.find('.message').last().find('span').text().split('人')[0]totalSale += Number(price) * Number(count)totalSold += Number(count)})// 最后打印出来console.log(`共 ${totalBooks} 本小册子`,`共 ${totalSold} 人次购买`,`约 ${Math.round(totalSale / 10000)} 万`)})
最后打印的结果是:共 0 本小册子 共 0 人次购买 约 0 万,What? 小册子怎么可能数据都是 0 呢,一定是我爬的姿势不对,我们是可以通过分析请求头和响应头来模拟一次真实的网页访问,我们也可以通过网页爬取神器 - puppeteer 来获取网页内容,puppeteer 是谷歌开源的,可以通过命令行来启动一个 chrome 实例,从而真实访问网页,并且具备与网页的交互的能力,包括不限于点击,滚动,截屏等操作。
我们来写一个小例子,来截取下掘金的首页头部,在执行之前,首先安装 puppeteer:
npm i puppeteer -S> puppeteer@1.10.0 install /Users/black/Downloads/node_modules/puppeteer> node install.jsDownloading Chromium r599821 - 82.9 Mb [ ] 1% 1287.3s# 安装可能会比较耗时,大家可以多尝试几次
// 把之前安装到 node_modules 下的 puppeteer 模块加载进来const puppeteer = require('puppeteer')// 在 getHomePage 函数里面,定制一系列任务,让他们顺序执行async function getHomePage (link) {// 启动一个 Chrome 引擎实例,加上 await 会一直等待它启动完成// 加上 headless: false 会打开一个浏览器,可以眼睁睁看这一切发生,如果是 true 则静默执行// const browser = await puppeteer.launch({headless: false})const browser = await puppeteer.launch()// 启动成功后,打开一个新页面const page = await browser.newPage()// 新页面里面输入目标网址,跳到这个网页,一直等待页面加载完成await page.goto(link)// 设置网页视窗的宽高await page.setViewport({width: 1080, height: 250})// 告诉 puppeteer 开始截图,直到截图完成,存储图片到当前目录await page.screenshot({path: Date.now() + '.png'})// 最后关闭浏览器,销毁所有变量await browser.close()return 'done!'}// 调用这个异步函数 getHomePage,传入待截图网站,任务开始执行getHomePage('https://juejin.im/books').then(v => {})
会得到这样的一个截图:
![10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图8](https://gitee.com/johnforrest/mdpbed/raw/master/imgLenov1/202204201032934.png#alt=image-20220420103253655)
编程练习 2 - 实现掘金小册的统计工具
了解 puppeteer 后,我们就可以借助它来获取小册子页面内容了,代码可以这样写:
const $ = require('cheerio')const puppeteer = require('puppeteer')const url = 'https://juejin.im/books'async function run () {const browser = await puppeteer.launch()const page = await browser.newPage()await page.goto(url, {waitUntil: 'networkidle2'})const html = await page.content()const books = $('.info', html)let totalSold = 0let totalSale = 0let totalBooks = books.lengthbooks.each(function() {const book = $(this)const price = $(book.find('.price-text')).text().replace('¥', '')const count = book.find('.message').last().find('span').text().split('人')[0]totalSale += Number(price) * Number(count)totalSold += Number(count)})console.log(`共 ${totalBooks} 本小册子`,`共 ${totalSold} 人次购买`,`约 ${Math.round(totalSale / 10000)} 万`)await browser.close()}run()
打印的结果是:共 20 本小册子 共 60800 人次购买 约 101 万,哇!截止 11 月下旬,小册的总销量已经破 100 万了,恭喜掘金,恭喜各位开发者,同时我们也期待掘金继续加油,销售额早日破千万,早日把知识传递给更多更多的开发者。
