HTMLParserBuilder

一个结果构建器,用于构建 HTML 解析器并将 HTML 元素转换为强类型结果,灵感来自 RegexBuilder。

注意: CaptureTransform.swift, TypeConstruction.swift 是从 apple/swift-experimental-string-processing 复制的。

安装

要求

dependencies: [
    // ...
    .package(name: "HTMLParserBuilder", url: "https://github.com/danny1113/html-parser-builder.git", from: "2.0.0"),
]

简介

解析 HTML 可能很复杂,例如您想解析下面简单的 HTML

<h1 id="hello">hello, world</h1>

<div id="group">
    <h1>INSIDE GROUP h1</h1>
    <h2>INSIDE GROUP h2</h2>
</div>

现有的 HTML 解析库有以下缺点

let htmlString = "<html>...</html>"
let doc: any Document = HTMLDocument(string: htmlString)
let first = doc.querySelector("#hello")?.textContent

let group = doc.querySelector("#group")
let second = group?.querySelector("h1")?.textContent
let third = group?.querySelector("h2")?.textContent

if  let first = first,
    let second = second,
    let third = third {
    
    // ...
} else {
    // ...
}

HTMLParserBuilder 具有一些非常棒的优点

您可以构建反映原始 HTML 结构的解析器

let capture = HTML {
    TryCapture("#hello") { (element: any Element?) -> String? in
        return element?.textContent
    } // => HTML<String?>
    
    Local("#group") {
        Capture("h1", transform: \.textContent) // => HTML<String>
        Capture("h2", transform: \.textContent) // => HTML<String>
    } // => HTML<(String, String)>
    
} // => HTML<(String?, (String, String))>


let htmlString = "<html>...</html>"
let doc: any Document = HTMLDocument(string: htmlString)

let output = try doc.parse(capture)
// => (String?, (String, String))
// output: (Optional("hello, world"), ("INSIDE GROUP h1", "INSIDE GROUP h2"))

注意: 您现在可以在构建器中组合最多 10 个组件,但您可以将捕获内容分组在 Local 中作为一种解决方法。

用法

使用您自己的解析器

HTMLParserBuilder 不依赖于任何 HTML 解析器,因此您可以选择您想使用的任何 HTML 解析器,只要它符合 DocumentElement 协议即可。

例如,您可以使用 SwiftSoup 作为 HTML 解析器,符合 DocumentElement 协议的示例可在 Tests/HTMLParserBuilderTests/SwiftSoup+HTMLParserBuilder.swift 中找到。

dependencies: [
    // ...
    .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"),
    .package(name: "HTMLParserBuilder", url: "https://github.com/danny1113/html-parser-builder.git", from: "2.0.0"),
],
targets: [
    .target(name: "YourTarget", dependencies: ["SwiftSoup", "HTMLParserBuilder"]),
]

解析

HTMLParserBuilder 提供了 2 个用于解析的函数

public func parse<Output>(_ html: HTML<Output>) throws -> Output
public func parse<Output>(_ html: HTML<Output>) async throws -> Output

注意: 您可以选择异步版本以获得更好的性能,因为它使用结构化并发来并行处理子任务。

HTML

您可以在 HTML 中构建您的解析器,它还可以转换为其他数据类型。

struct Group {
    let h1: String
    let h2: String
}

let capture = HTML {
    Capture("#group h1", transform: \.textContent) // => HTML<String>
    Capture("#group h2", transform: \.textContent) // => HTML<String>
    
} transform: { (output: (String, String)) -> Group in
    return Group(
        h1: output.0,
        h2: output.1
    )
} // => HTML<Group>

Capture(捕获)

使用 CapturequerySelector 相同,您传入 CSS 选择器以查找 HTML 元素,并且您可以将其转换为您想要的任何其他类型

注意: 如果 Capture 找不到与选择器匹配的 HTML 元素,它将抛出一个错误导致整个解析失败,对于可失败的捕获,请参见 TryCapture

您可以将此 API 与各种最适合您的声明一起使用

Capture("#hello", transform: \.textContent)
Capture("#hello") { $0.textContent }
Capture("#hello") { (e: any Element) -> String in
    return e.textContent
}

TryCapture(尝试捕获)

TryCaptureCapture 有一点不同,它也调用 querySelector 来查找 HTML 元素,但它返回一个可选 HTML 元素。

对于此示例,它将产生 String? 的结果类型,当找不到 HTML 元素时,结果将为 nil

TryCapture("#hello") { (e: (any Element)?) -> String? in
    return e?.innerHTML
}

CaptureAll(捕获全部)

使用 CaptureAllquerySelectorAll 相同,您传入 CSS 选择器以查找所有与选择器匹配的 HTML 元素,并且您可以将其转换为您想要的任何其他类型

您可以将此 API 与各种最适合您的声明一起使用

CaptureAll("h1") { $0.map(\.textContent) }
CaptureAll("h1") { (e: [any Element]) -> [String] in
    return e.map(\.textContent)
}

您还可以捕获内部的其他元素并转换为其他类型

<div class="group">
    <h1>Group 1</h1>
</div>
<div class="group">
    <h1>Group 2</h1>
</div>
CaptureAll("div.group") { (elements: [any Element]) -> [String] in
    return elements.compactMap { e in
        return e.querySelector("h1")?.textContent
    }
}
// => [String]
// output: ["Group 1", "Group 2"]

Local(本地)

Local 将查找与选择器匹配的 HTML 元素,并且内部的所有捕获都将基于 Local 找到的元素查找其元素,这在您只想捕获本地组内的元素时很有用。

就像 HTML 一样,Local 也可以通过添加 transform 将捕获结果转换为其他数据类型

struct Group {
    let h1: String
    let h2: String
}

Local("#group") {
    Capture("h1", transform: \.textContent) // => HTML<String>
    Capture("h2", transform: \.textContent) // => HTML<String>
} transform: { (output: (String, String)) -> Group in
    return Group(
        h1: output.0,
        h2: output.1
    )
} // => Group

注意: 如果 Local 找不到与选择器匹配的 HTML 元素,它将抛出一个错误导致整个解析失败,您可以将 TryCapture 用作替代方法。

LateInit(延迟初始化)

此库还带有一个方便的属性包装器:LateInit,它可以延迟初始化,直到您第一次访问它为止。

struct Container {
    @LateInit var capture = HTML {
        Capture("h1", transform: \.textContent)
    }
}

// it needs to be `var` to perform late initialization
var container = Container()
let output = doc.parse(container.capture)
// ...

总结

API 用例
Capture(捕获) 当无法捕获元素时抛出错误
TryCapture(尝试捕获) 当无法捕获元素时返回 nil
CaptureAll(捕获全部) 捕获所有与选择器匹配的元素
Local(本地) 捕获本地范围内的元素
LateInit(延迟初始化) 延迟初始化到第一次访问时

高级用例

struct Group {
    let h1: String
    let h2: String
}

//       |--------------------------------------------------------------|
let groupCapture = HTML {                                            // |
    Local("#group") {                                                // |
        Capture("h1", transform: \.textContent) // => HTML<String>   // |
        Capture("h2", transform: \.textContent) // => HTML<String>   // |
    } // => HTML<(String, String)>                                   // |
                                                                     // |
} transform: { output -> Group in                                    // |
    return Group(                                                    // |
        h1: output.0,                                                // |
        h2: output.1                                                 // |
    )                                                                // |
} // => HTML<Group>                                                  // |
                                                                     // |
let capture = HTML {                                                 // |
    TryCapture("#hello") { (element: (any Element)?) -> String? in   // |
        return element?.textContent                                  // |
    } // => HTML<String?>                                            // |
                                                                     // |
    groupCapture // => HTML<Group> -------------------------------------|
    
} // => HTML<(String?, Group)>


let htmlString = "<html>...</html>"
let doc: any Document = HTMLDocument(string: htmlString)

let output = try doc.parse(capture)
// => (String?, Group)