使用 Kotlin DSL 編寫網路爬蟲

学数学的程序猿發表於2024-03-26

本博文將會透過一個網路爬蟲的例子,向你介紹 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 的如下幾個語法特性:

  • Lambda表示式,包括
    • 高階函式
    • 函式的最後一個引數是函式時,可以將函式提取到括號的外面
    • 單引數函式用 it 作為引數的預設名字,可不用宣告
  • 運算子過載
  • 中綴符
  • 擴充套件函式

快速開始

我們首先設計爬蟲程式的 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 倉庫中找到。

相關文章