使用 Kotlin DSL 编写网络爬虫
本博文将会通过一个网络爬虫的例子,向你介绍 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 Compose,Gradle’s Kotlin DSL。
Kotlin DSL
Kotlin DSL 的能力主要来自于 Kotlin 的如下几个语法特性:
快速开始
我们首先设计爬虫程序的 API,即 DSL 的语法。以爬取本博客站点的全部博文为例,我们希望爬虫程序完成后,使用者可以这么去调用:
val spider = Spider("https://www.cnblogs.com/dongkuo") {
    html {
        // 文章详情页
        follow(".postTitle2:eq(0)") {
            val article = htmlExtract<Article> {
                it.url = this@follow.request.url.toString()
                it.title = css("#cb_post_title_url")?.text()
            }
            // 下载文章
            download("./blogs/${article.title}.html")
        }
        // 下一页
        follow("#nav_next_page a")
        follow("#homepage_bottom_pager a:containsOwn(下一页)")
    }
}
spider.start()
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 方法,用于向爬虫提交需要爬取的网页:
class Spider(
    vararg startUrls: String,
    private val options: Options = Options(),
    private val defaultHandler: Handler<Response>
) {
     private val taskChannel: Channel<Task> = Channel(Channel.UNLIMITED)
    suspend fun addUrls(vararg urls: String, handler: Handler<Response> = defaultHandler) {
    	urls.forEach {
      	  log.debug("add url: $it")
      	  taskChannel.send(Task(it, handler))
    	}
    }
}
typealias Handler<T> = suspend (T).() -> Unit
typealias ExtraHandler<T, E> = suspend (T).(E) -> Unit
data class Task(val url: String, val handler: Handler<Response>)
Spider 的 start 方法会创建若干 Fetcher 去爬取网页,此过程用协程执行:
@OptIn(ExperimentalCoroutinesApi::class)
fun start(stopAfterFinishing: Boolean = true) {
    updateState(State.NEW, State.RUNNING) {
        // launch fetcher
        val fetchers = List(options.fetcherNumber) { Fetcher(this) }
        for (fetcher in fetchers) {
            launch {
                fetcher.start()
            }
        }
        // wait all fetcher idle and task channel is empty
        runBlocking {
            var allIdleCount = 0
            while (true) {
                val isAllIdle = fetchers.all { it.isIdle }
                if (isAllIdle && taskChannel.isEmpty) {
                    allIdleCount++
                } else {
                    allIdleCount = 0
                }
                if (allIdleCount == 2) {
                    fetchers.forEach { it.stop() }
                    return@runBlocking
                }
                delay(1000)
            }
        }
    }
}
Fetcher 类
Fetcher 类用于从 channel 中取出请求任务并执行,最后调用 handler 方法处理请求响应:
private class Fetcher(val spider: Spider) {
    var isIdle = true
        private set
    private var job: Job? = null
    suspend fun start() = withContext(spider.coroutineContext) {
        job = launch(CoroutineName("${spider.options.spiderName}-fetcher")) {
            while (true) {
                isIdle = true
                val task = spider.taskChannel.receive()
                isIdle = false
                spider.log.debug("fetch ${task.url}")
                val httpStatement = spider.httpClient.prepareGet(task.url) {
                    timeout {
                        connectTimeoutMillis = spider.options.connectTimeoutMillis
                        requestTimeoutMillis = spider.options.requestTimeoutMillis
                        socketTimeoutMillis = spider.options.socketTimeoutMillis
                    }
                }
                httpStatement.execute {
                    val request = Request(URI.create(task.url).toURL(), "GET")
                    task.handler.invoke(Response(request, it, spider))
                }
            }
        }
    }
    fun stop() {
        job?.cancel()
    }
}
Response 类
Response 类代表请求的响应,它有获取响应码、响应头的方法。
fun statusCode(): Int {
    TODO()
}
fun header(name: String): String? {
    TODO()
}
// ...
除此之外,我们还需要一些解析响应体的方法来方便使用者处理响应。因此提供
text方法:将响应体编码成字符串;html方法:将响应体解析成 html 文档(见Document类);htmlExtra方法:将响应体解析成 html 文档,并自动创建通过泛型指定的数据类返回。它的末尾参数是一个函数,其作用域内,it指向自动创建(通过反射创建)的数据对象,this指向Document对象。stream方法:获取响应体的输入流;download方法:保存响应体数据到文件;
具体实现代码可在文末给出的仓库中找到。
Selectable 与 Extractable 接口
Selectable 接口表示“可选择”元素的,定义了若干选择元素的方法:
interface Selectable {
    fun css(selector: String): Element?
    fun cssAll(selector: String): List<Element>
    fun xpath(selector: String): Element?
    fun xpathAll(selector: String): List<Element>
    fun firstChild(): Element?
    fun lastChild(): Element?
    fun nthChild(index: Int): Element?
    fun children(): List<Element>
}
Extractable 接口表示“可提取”信息的,定义了若干提取信息的方法:
interface Extractable {
    fun tag(): String?
    fun html(onlyInner: Boolean = false): String?
    fun text(onlyOwn: Boolean = false): String?
    fun attribute(name: String, absoluteUrl: Boolean = true): String
}
为了方便使用,还定义一个函数类型的别名 Extractor:
typealias Extractor = (Extractable?) -> String?
并提供一些便利地创建 Extractor 函数的函数(高阶函数):
fun tag(): Extractor = { it?.tag() }
fun html(): Extractor = { it?.html() }
fun attribute(name: String): Extractor = { it?.attribute(name) }
fun text(): Extractor = { it?.text() }
Document 类
Document 类代表 HTML 文档。它实现了 Selectable 接口:
class Document(
    html: String,
    baseUrl: String,
    private val spider: Spider
) : Selectable {
    fun title(): String {
        TODO()
    }
    override fun css(selector: String): Element? {
        TODO()
    }
    // ...
}
除此以外,Document 类还提供 follow 方法,便于使用者能快速跟随页面中的链接:
suspend fun follow(
    css: String? = null,
    xpath: String? = null,
    extractor: Extractor = attribute("href"),
    handler: Handler<Response>? = null
) {
    if (css != null) {
        follow(cssAll(css), extractor, handler)
    }
    if (xpath != null) {
        follow(xpathAll(xpath), extractor, handler)
    }
}
suspend fun follow(
    extractableList: List<Extractable>,
    extractor: Extractor = attribute("href"),
    responseHandler: Handler<Response>? = null
) {
    extractableList.forEach { follow(it, extractor, responseHandler) }
}
suspend fun follow(
    extractable: Extractable?,
    extractor: Extractor = attribute("href"),
    handler: Handler<Response>? = null
) {
    val url = extractable.let(extractor) ?: return
    if (handler == null) {
        spider.addUrls(url)
    } else {
        spider.addUrls(url, handler = handler)
    }
}
Element 类
Element 类代表 DOM 中的元素。它除了具有和 Document 类一样的读取 DOM  的方法外(实现 Selectable接口),还实现了Extractable 接口:
class Element(private val innerElement: InnerElement) : Selectable, Extractable {
    // ...
}
总结
本文试图通过一个简单的爬虫程序向读者展示 Kotlin 以及 其 DSL 的魅力。作为一门 JVM 语言,Kotlin 在遵守 JVM 平台规范的基础上,吸取了众多优秀的语法特性,值得大家尝试。
本文完整代码可在 kspider 仓库中找到。
使用 Kotlin DSL 编写网络爬虫的更多相关文章
- python3编写网络爬虫20-pyspider框架的使用
		
二.pyspider框架的使用 简介 pyspider是由国人binux 编写的强大的网络爬虫系统 github地址 : https://github.com/binux/pyspider 官方文档 ...
 - 为编写网络爬虫程序安装Python3.5
		
1. 下载Python3.5.1安装包1.1 进入python官网,点击menu->downloads,网址:https://www.python.org/downloads/ 1.2 根据系统 ...
 - 利用Python编写网络爬虫下载文章
		
#coding: utf-8 #title..href... str0='blabla<a title="<论电影的七个元素>——关于我对电影的一些看法以及<后会无期 ...
 - 吴裕雄--天生自然python学习笔记:编写网络爬虫代码获取指定网站的图片
		
我们经常会在网上搜索井下载图片,然而一张一张地下载就太麻烦了,本案例 就是通过网络爬虫技术, 一次性下载该网站所有的图片并保存 . 网站图片下载并保存 将指定网站的 .jpg 和 .png 格式的图片 ...
 - python3编写网络爬虫18-代理池的维护
		
一.代理池的维护 上面我们利用代理可以解决目标网站封IP的问题 在网上有大量公开的免费代理 或者我们也可以购买付费的代理IP但是无论是免费的还是付费的,都不能保证都是可用的 因为可能此IP被其他人使用 ...
 - 用python语言编写网络爬虫
		
本文主要用到python3自带的urllib模块编写轻量级的简单爬虫.至于怎么定位一个网页中具体元素的url可自行百度火狐浏览器的firebug插件或者谷歌浏览器的自带方法. 1.访问一个网址 re= ...
 - python3编写网络爬虫21-scrapy框架的使用
		
一.scrapy框架的使用 前面我们讲了pyspider 它可以快速的完成爬虫的编写 不过pyspider也有一些缺点 例如可配置化不高 异常处理能力有限对于一些反爬虫程度非常强的网站 爬取显得力不从 ...
 - python3编写网络爬虫19-app爬取
		
一.app爬取 前面都是介绍爬取Web网页的内容,随着移动互联网的发展,越来越多的企业并没有提供Web页面端的服务,而是直接开发了App,更多信息都是通过App展示的 App爬取相比Web端更加容易 ...
 - Python3编写网络爬虫12-数据存储方式五-非关系型数据库存储
		
非关系型数据库存储 NoSQL 全称 Not Only SQL 意为非SQL 泛指非关系型数据库.基于键值对 不需要经过SQL层解析 数据之间没有耦合性 性能非常高. 非关系型数据库可细分如下: 键值 ...
 - 用 Python 编写网络爬虫 笔记
		
Chapter I 简介 为什么要写爬虫? 每个网站都应该提供 API,然而这是不可能的 即使提供了 API,往往也会限速,不如自己找接口 注意已知条件(robots.txt 和 sitemap.xm ...
 
随机推荐
- 2023牛客暑期多校训练营3 ABDHJ
			
比赛链接 A 题解 知识点:数学. 当 \(x = 0\) 时,当且仅当 \(y = 0\) 可行. 当 \(x \neq 0\) 时,一定可行,答案为 \(|x-y|\) . 时间复杂度 \(O(1 ...
 - NC19782 Tree
			
题目链接 题目 题目描述 修修去年种下了一棵树,现在它已经有n个结点了. 修修非常擅长数数,他很快就数出了包含每个点的连通点集的数量. 澜澜也想知道答案,但他不会数数,于是他把问题交给了你. 输入描述 ...
 - 【framework】DisplayContent简介
			
1 前言  DisplayContent 用于管理屏幕,一块屏幕对应一个 DisplayContent 对象,虽然手机只有一个显示屏,但是可以创建多个 DisplayContent 对象,如投屏时, ...
 - ORACLE cannot fetch plan for SQL_ID
			
今天做SQL执行计划测试的时候,发现sqlplus无法正常打印执行计划,根据网上资料整理如下: ..... SYS@orcl> select * 2 from table( 3 ...
 - Java I/O 教程(八) Writer和Reader
			
Java Writer Writer是一个用于写字符流的抽象类.其子类必须实现write(char[], int, int), flush(), 和 close()方法. 类定义 public abs ...
 - Go 中的反射 reflect 介绍和基本使用
			
一.什么是反射 在计算机科学中,反射(英语:reflection)是指计算机程序在运行时(runtime)可以访问.检测和修改它本身状态或行为的一种能力.用比喻来说,反射就是程序在运行的时候能够&qu ...
 - 掌握C语言指针,轻松解锁代码高效性与灵活性
			
欢迎大家来到贝蒂大讲堂 养成好习惯,先赞后看哦~ 所属专栏:C语言学习 贝蒂的主页:Betty's blog 1. 指针与地址 1.1 概念 我们都知道计算机的数据必须存储在内存里,为了正确地访问这些 ...
 - 统信UOS系统开发笔记(四):从Qt源码编译安装之编译安装QtCreator4.11.2,并配置编译测试Demo
			
前言 上一篇已经从Qt源码编译了Qt,那么Qt开发的IDE为QtCreator,本篇从源码编译安装QtCreator,并配置好构建套件,运行Demo并测试. 统信UOS系统版本 系统版本: ...
 - 【LeetCode数组#4滑动窗口】长度最小的子数组+子数组最大平均数I
			
长度最小的子数组 力扣题目链接(opens new window) 给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度.如果不存 ...
 - Java实现DES加密解密
			
DES(Data Encryption Standard)是一种对称加密算法,所谓对称加密就是加密和解密都是使用同一个密钥 加密原理: DES 使用一个 56 位的密钥以及附加的 8 位奇偶校验位,产 ...