利用 Kotlin DSL 编写网络爬虫

打印 上一主题 下一主题

主题 879|帖子 879|积分 2637

本博文将会通过一个网络爬虫的例子,向你介绍 Kotlin 的根本用法和其简洁有力的 DSL。
关于DSL

按照维基百科的说法,DSL(domain-specific language) 是一种专注于某一特定应用领域的计算机语言。和我们常用的通用目的型语言(类如 C,Java,Python 等)相反,DSL 并不承诺可用来解决统统可计算性问题。DSL 设计者聚焦于某一特定的场景,通过对 DSL 的经心设计,让利用者在这一场景下能够用该 DSL 简洁高效地表达出自己的想法。例如在数据库领域,SQL 就是一种被用作“查询”的 DSL;在 Web 开辟领域,用 HTML 这种 DSL 来描述一张网页的结构结构。而本文介绍的 Kotlin DSL,它是 Kotlin 提供的一种创建 DSL 的能力。我们可以很容易借助该能力创建我们自己的 DSL,例如,Jetpack ComposeGradle’s Kotlin DSL
Kotlin DSL

Kotlin DSL 的能力主要来自于 Kotlin 的如下几个语法特性:
快速开始

我们首先设计爬虫步伐的 API,即 DSL 的语法。以爬取本博客站点的全部博文为例,我们希望爬虫步伐完成后,利用者可以这么去调用:
  1. val spider = Spider("https://www.cnblogs.com/dongkuo") {
  2.     html {
  3.         // 文章详情页
  4.         follow(".postTitle2:eq(0)") {
  5.             val article = htmlExtract<Article> {
  6.                 it.url = this@follow.request.url.toString()
  7.                 it.title = css("#cb_post_title_url")?.text()
  8.             }
  9.             // 下载文章
  10.             download("./blogs/${article.title}.html")
  11.         }
  12.         // 下一页
  13.         follow("#nav_next_page a")
  14.         follow("#homepage_bottom_pager a:containsOwn(下一页)")
  15.     }
  16. }
  17. spider.start()
  18. data class Article(var url: String? = null, var title: String? = null)
复制代码
以上代码的大抵逻辑是:首先通过调用 Spider 构造方法创建一只爬虫,并指定一个初始待爬取的 url,然后启动。通过调用 html 方法或 htmlExtract 方法,可将哀求的相应体解析成 html 文档,接着可以调用 follow 方法“跟随”某些 html 标签的链接(继续爬取这些链接),也可以调用 download 方法下载相应内容到文件中。
下面按各个类去介绍怎样实现上述 DSL。
Spider 类

Spider 类代表爬虫,调用其构造函数时可以指定初始的 url 和爬虫的配置信息;Spider 构造函数的末了一个参数是一个函数,用于处理哀求初始 url 的相应或作为提交 url 时未指定 handler 的缺省 handler。其接收者,即该函数作用域内的 this 为 Response  对象。利用函数的末了一个参数是函数时的便利写法,我们可以把该函数的函数体提到参数括号的外面。因此,原本的 Spider("https://www.cnblogs.com/dongkuo", defaultHandler = {}) 变为 Spider("https://www.cnblogs.com/dongkuo"){}。
Spider 类提供 addUrls 方法,用于向爬虫提交需要爬取的网页:
  1. class Spider(
  2.     vararg startUrls: String,
  3.     private val options: Options = Options(),
  4.     private val defaultHandler: Handler<Response>
  5. ) {
  6.    
  7.      private val taskChannel: Channel<Task> = Channel(Channel.UNLIMITED)
  8.    
  9.     suspend fun addUrls(vararg urls: String, handler: Handler<Response> = defaultHandler) {
  10.             urls.forEach {
  11.                 log.debug("add url: $it")
  12.                 taskChannel.send(Task(it, handler))
  13.             }
  14.     }
  15. }
  16. typealias Handler<T> = suspend (T).() -> Unit
  17. typealias ExtraHandler<T, E> = suspend (T).(E) -> Unit
  18. data class Task(val url: String, val handler: Handler<Response>)
复制代码
Spider 的 start 方法会创建若干 Fetcher 去爬取网页,此过程用协程执行:
  1. @OptIn(ExperimentalCoroutinesApi::class)
  2. fun start(stopAfterFinishing: Boolean = true) {
  3.     updateState(State.NEW, State.RUNNING) {
  4.         // launch fetcher
  5.         val fetchers = List(options.fetcherNumber) { Fetcher(this) }
  6.         for (fetcher in fetchers) {
  7.             launch {
  8.                 fetcher.start()
  9.             }
  10.         }
  11.         // wait all fetcher idle and task channel is empty
  12.         runBlocking {
  13.             var allIdleCount = 0
  14.             while (true) {
  15.                 val isAllIdle = fetchers.all { it.isIdle }
  16.                 if (isAllIdle && taskChannel.isEmpty) {
  17.                     allIdleCount++
  18.                 } else {
  19.                     allIdleCount = 0
  20.                 }
  21.                 if (allIdleCount == 2) {
  22.                     fetchers.forEach { it.stop() }
  23.                     return@runBlocking
  24.                 }
  25.                 delay(1000)
  26.             }
  27.         }
  28.     }
  29. }
复制代码
Fetcher 类

Fetcher 类用于从 channel 中取出哀求任务并执行,末了调用 handler 方法处理哀求相应:
  1. private class Fetcher(val spider: Spider) {
  2.     var isIdle = true
  3.         private set
  4.     private var job: Job? = null
  5.     suspend fun start() = withContext(spider.coroutineContext) {
  6.         job = launch(CoroutineName("${spider.options.spiderName}-fetcher")) {
  7.             while (true) {
  8.                 isIdle = true
  9.                 val task = spider.taskChannel.receive()
  10.                 isIdle = false
  11.                 spider.log.debug("fetch ${task.url}")
  12.                 val httpStatement = spider.httpClient.prepareGet(task.url) {
  13.                     timeout {
  14.                         connectTimeoutMillis = spider.options.connectTimeoutMillis
  15.                         requestTimeoutMillis = spider.options.requestTimeoutMillis
  16.                         socketTimeoutMillis = spider.options.socketTimeoutMillis
  17.                     }
  18.                 }
  19.                 httpStatement.execute {
  20.                     val request = Request(URI.create(task.url).toURL(), "GET")
  21.                     task.handler.invoke(Response(request, it, spider))
  22.                 }
  23.             }
  24.         }
  25.     }
  26.     fun stop() {
  27.         job?.cancel()
  28.     }
  29. }
复制代码
Response 类

Response 类代表哀求的相应,它有获取相应码、相应头的方法。
  1. fun statusCode(): Int {
  2.     TODO()
  3. }
  4. fun header(name: String): String? {
  5.     TODO()
  6. }
  7. // ...
复制代码
除此之外,我们还需要一些解析相应体的方法来方便利用者处理相应。因此提供

  • text 方法:将相应体编码成字符串;
  • html 方法:将相应体解析成 html 文档(见 Document 类);
  • htmlExtra 方法:将相应体解析成 html 文档,并自动创建通过泛型指定的数据类返回。它的末尾参数是一个函数,其作用域内,it 指向自动创建(通过反射创建)的数据对象,this 指向 Document 对象。
  • stream 方法:获取相应体的输入流;
  • download 方法:保存相应体数据到文件;
具体实现代码可在文末给出的仓库中找到。
Selectable 与 Extractable 接口

Selectable 接口表示“可选择”元素的,定义了若干选择元素的方法:
  1. interface Selectable {
  2.     fun css(selector: String): Element?
  3.     fun cssAll(selector: String): List<Element>
  4.     fun xpath(selector: String): Element?
  5.     fun xpathAll(selector: String): List<Element>
  6.     fun firstChild(): Element?
  7.     fun lastChild(): Element?
  8.     fun nthChild(index: Int): Element?
  9.     fun children(): List<Element>
  10. }
复制代码
Extractable 接口表示“可提取”信息的,定义了若干提取信息的方法:
  1. interface Extractable {
  2.     fun tag(): String?
  3.     fun html(onlyInner: Boolean = false): String?
  4.     fun text(onlyOwn: Boolean = false): String?
  5.     fun attribute(name: String, absoluteUrl: Boolean = true): String
  6. }
复制代码
为了方便利用,还定义一个函数范例的别名 Extractor:
  1. typealias Extractor = (Extractable?) -> String?
复制代码
并提供一些便利地创建 Extractor 函数的函数(高阶函数):
  1. fun tag(): Extractor = { it?.tag() }
  2. fun html(): Extractor = { it?.html() }
  3. fun attribute(name: String): Extractor = { it?.attribute(name) }
  4. fun text(): Extractor = { it?.text() }
复制代码
Document 类

Document 类代表 HTML 文档。它实现了 Selectable 接口:
  1. class Document(
  2.     html: String,
  3.     baseUrl: String,
  4.     private val spider: Spider
  5. ) : Selectable {
  6.     fun title(): String {
  7.         TODO()
  8.     }
  9.     override fun css(selector: String): Element? {
  10.         TODO()
  11.     }
  12.     // ...
  13. }
复制代码
除此以外,Document 类还提供 follow 方法,便于利用者能快速跟随页面中的链接:
  1. suspend fun follow(
  2.     css: String? = null,
  3.     xpath: String? = null,
  4.     extractor: Extractor = attribute("href"),
  5.     handler: Handler<Response>? = null
  6. ) {
  7.     if (css != null) {
  8.         follow(cssAll(css), extractor, handler)
  9.     }
  10.     if (xpath != null) {
  11.         follow(xpathAll(xpath), extractor, handler)
  12.     }
  13. }
  14. suspend fun follow(
  15.     extractableList: List<Extractable>,
  16.     extractor: Extractor = attribute("href"),
  17.     responseHandler: Handler<Response>? = null
  18. ) {
  19.     extractableList.forEach { follow(it, extractor, responseHandler) }
  20. }
  21. suspend fun follow(
  22.     extractable: Extractable?,
  23.     extractor: Extractor = attribute("href"),
  24.     handler: Handler<Response>? = null
  25. ) {
  26.     val url = extractable.let(extractor) ?: return
  27.     if (handler == null) {
  28.         spider.addUrls(url)
  29.     } else {
  30.         spider.addUrls(url, handler = handler)
  31.     }
  32. }
复制代码
Element 类

Element 类代表 DOM 中的元素。它除了具有和 Document 类一样的读取 DOM  的方法外(实现 Selectable接口),还实现了Extractable 接口:
  1. class Element(private val innerElement: InnerElement) : Selectable, Extractable {
  2.     // ...
  3. }
复制代码
总结

本文试图通过一个简单的爬虫步伐向读者展示 Kotlin 以及 其 DSL 的魅力。作为一门 JVM 语言,Kotlin 在遵守 JVM 平台规范的基础上,吸取了众多优秀的语法特性,值得各人尝试。
本文完整代码可在 kspider 仓库中找到。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

天津储鑫盛钢材现货供应商

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表