• 使用 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 ComposeGradle’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
    { 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
    ) {
        
         private val taskChannel: Channel = 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 = suspend (T).() -> Unit
    typealias ExtraHandler = suspend (T).(E) -> Unit
    data class Task(val url: String, val handler: Handler)
    

    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
        fun xpath(selector: String): Element?
        fun xpathAll(selector: String): List
        fun firstChild(): Element?
        fun lastChild(): Element?
        fun nthChild(index: Int): Element?
        fun children(): List
    }
    

    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? = 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? = null
    ) {
        extractableList.forEach { follow(it, extractor, responseHandler) }
    }
    
    suspend fun follow(
        extractable: Extractable?,
        extractor: Extractor = attribute("href"),
        handler: Handler? = 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 仓库中找到。

  • 相关阅读:
    OpenCV4之C++入门详解
    向量与矩阵范数、分布函数、 矩阵分解、随机抽样
    NoSQL 数据库之redis键值设计、批处理优化、服务端优化、集群配置优化
    QTableView对自定义的Model排序
    风控建模二、特征工程---风控
    力扣:第 304 场周赛
    Linux CentOS7安装MySQL(yum方式)
    3D开发工具HOOPS助力Eleven Dynamics加速开发QA自动化平台
    【无标题】
    Oracle/PLSQL: To_Lob Function
  • 原文地址:https://www.cnblogs.com/dongkuo/p/18097146