这一小节以 JavaScript 和函数式来讲解,下一节将通过 TypeScriptRxjs 实现。现在我们先获取一个小说的内容和章节,然后存储到 json 文件里面,以这个 斗罗大陆获取小说命令行版(上) - 图1 为例。

如何获取列表

crawl_selector

.mulu_list li 选择器下面就是我们想要的内容,比较简单,herf 是链接,text 是标题。

crawl_detail_selector

对于文章内容则是 #htmlContent

首先预想一下,需要一个什么样的文件,提供什么样的功能?于是期望输入一个小说的网址,根据内置的爬取规则获取章节信息,首先保存章节信息到文件里面,然后继续爬取章节里面的内容,存储的地址同样是一个函数输入,并且我们希望控制一下并发量。

首先把它做成命令行的版本,便于直接测试,然后在后续再进行改写。

导入模块

  • phin 是一个 node 下,基于 net 封装的一个请求模块,非常的小。
  • cherrio 则是类似于 jquerydom 选取器。
  • iconv-lite 是纯 javascript 实现的一个字符编码解码器,主要用于解码 gbk,因为比较老的网站都是 gbk 编码的。
  • jsonfile 可以方便的读取 json 文件,保存数据的时候会用到它。
  • url-join 是拼接 url 的工具,有考虑到一些有 query 的情况,所以使用库拼接,而不是直接字符串拼接。
  • make-dir 用来确保文件地址存在和创建文件。
  • ora 则用于下载进度的显示
  1. const p = require('phin').promisified
  2. const cheerio = require('cheerio')
  3. const { decode } = require('iconv-lite')
  4. const jsonfile = require('jsonfile')
  5. const urljoin = require('url-join')
  6. const { resolve } = require('path')
  7. const fs = require('fs')
  8. const mkdir = require('make-dir')
  9. const ora = require('ora')

构建选择器

pipeP,第一个参数是 Promise,之后的参数都是 then 里面的回调,这样就再也不用看见 then 链了,然后将每一个环节进行分割,具体入下。

  1. // 拉取数据并且转码
  2. const getHTML = (url, charset) => {
  3. return p(url).then(res => decode(res.body, charset))
  4. }
  5. // 装载内容,获取 $ 函数
  6. const loadContent = source => {
  7. const $ = cheerio.load(source)
  8. return selector => {
  9. return $(selector)
  10. }
  11. }
  12. // 去掉 then 链接
  13. const pipeP = (...args) => {
  14. return args.reduce((acc, prev) => acc.then(prev))
  15. }
  16. // 组合成构建 $ 函数
  17. const buildSelector = (url, charset) =>
  18. pipeP(
  19. getHTML(url, charset),
  20. loadContent
  21. )

其实这个还有一些优化的方向,有的时候我们经常写出这样的函数,我们首先来看 loadContent

  1. const loadContent = source => {
  2. const $ = cheerio.load(source)
  3. return selector => {
  4. return $(selector)
  5. }
  6. }
  1. const loadContent = cheerio.load

当一个函数的参数,原样传递给另外一个函数的时候,直接等于这个函数即可。

而对于 getHTML 也可以稍微优化一小下,到这个层次,我觉得就差不多了。

  1. const getHTML = (url, charset) =>
  2. pipeP(
  3. p(url),
  4. ({ body }) => decode(body, charset)
  5. )

那么还有没有继续优化的空间了呢,是有的。

  1. const decodeHTML = charset => body => decode(body, charset)
  2. const getBody = ({ body }) => body
  3. // 拉取数据并且转码
  4. const getHTML = (url, charset) =>
  5. pipeP(
  6. p(url),
  7. getBody,
  8. decodeHTML(charset)
  9. )

那还可不可以继续优化呢,可以,不过需要用到帮助库 ramda,这里有一个问题使用 curry 自动柯里化的时候 decodelength 是 3,即接受 3 个参数 ,所以会导致出错。笔者调试了很久,最主要还是 API 文档导致的,API 上面显示的是两个参数,当我打印 length 的时候其实是 3 ,最后看了源码,发现有一个 opts,所以必须使用 curryN 表明个数,composeP 跟 pipeP 是顺序相反的,而且 ramda 的和笔者写的 composePpipeP 有一些差异,ramda 的参数不是在第一个 Promise 里面传递的。这样优化,反而没有之前的清晰,这其实就属于过度的优化和函数式了,不要过度的函数式,也不要过度设计模式,过度的面向对象,物极必反。

  1. const { __, flip, curryN, composeP, prop } = require('ramda')
  2. const decodeHTML = flip(curryN(2, decode)) // (方式一)
  3. const decodeHTML = charset => curryN(2, decode)(__, charset) // (方式二)
  4. const getHTML = (url, charset) =>
  5. composeP(
  6. decodeHTML(charset),
  7. prop('body'),
  8. p
  9. )(url)

更多函数式知识请参考获取小说命令行版(上) - 图4