120 lines
3.8 KiB
Go
120 lines
3.8 KiB
Go
package kb
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"golang.org/x/net/html"
|
||
)
|
||
|
||
// ========================================================================
|
||
// HTML 解析器:从 HTML 字节流抽取正文文本
|
||
// ========================================================================
|
||
// 设计目标:
|
||
// - 跳过 script/style/head/nav 等非正文节点,只留可读内容
|
||
// - h1~h6 转成 markdown 井号标题,这样产出文本可以直接走 ChunkMarkdown,
|
||
// 按章节切分并把标题写进 chunk.title(检索时标题参与 FULLTEXT 加权)
|
||
// - 依赖 golang.org/x/net/html(官方扩展库,本项目已有该依赖)
|
||
// ========================================================================
|
||
|
||
// htmlSkipTags 整棵子树跳过的标签(非正文内容)
|
||
var htmlSkipTags = map[string]bool{
|
||
"script": true, "style": true, "noscript": true, "head": true,
|
||
"iframe": true, "svg": true, "template": true, "nav": true,
|
||
"footer": true, "form": true, "button": true,
|
||
}
|
||
|
||
// htmlHeadingPrefix 标题标签 → markdown 前缀
|
||
// h4~h6 统一归到 ###(ChunkMarkdown 只识别到三级,再深的层级对分段没有意义)
|
||
var htmlHeadingPrefix = map[string]string{
|
||
"h1": "# ", "h2": "## ", "h3": "### ",
|
||
"h4": "### ", "h5": "### ", "h6": "### ",
|
||
}
|
||
|
||
// htmlBlockTags 块级标签:子树遍历结束后补换行,保持段落结构
|
||
var htmlBlockTags = map[string]bool{
|
||
"p": true, "div": true, "section": true, "article": true,
|
||
"ul": true, "ol": true, "table": true, "blockquote": true,
|
||
"pre": true, "figcaption": true, "main": true, "header": true,
|
||
}
|
||
|
||
// 压缩连续 3 个以上换行为 2 个(保持「空行分段」语义又不产生大量空白)
|
||
var multiNewlineRe = regexp.MustCompile(`\n{3,}`)
|
||
|
||
// extractHTMLText 抽取 HTML 正文,返回带 markdown 标题标记的纯文本
|
||
//
|
||
// 返回文本交给 ChunkMarkdown 切分:h1~h3 成为章节边界 + chunk 标题
|
||
func extractHTMLText(data []byte) (string, error) {
|
||
doc, err := html.Parse(strings.NewReader(decodeToUTF8(data)))
|
||
if err != nil {
|
||
return "", fmt.Errorf("kb: 解析 HTML 失败: %w", err)
|
||
}
|
||
|
||
var sb strings.Builder
|
||
var walk func(n *html.Node)
|
||
walk = func(n *html.Node) {
|
||
if n.Type == html.ElementNode {
|
||
tag := strings.ToLower(n.Data)
|
||
if htmlSkipTags[tag] {
|
||
return
|
||
}
|
||
// 标题:整行输出「# 标题文本」,前后空行隔开
|
||
if prefix, ok := htmlHeadingPrefix[tag]; ok {
|
||
headText := strings.TrimSpace(plainTextOf(n))
|
||
if headText != "" {
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(prefix)
|
||
sb.WriteString(headText)
|
||
sb.WriteString("\n\n")
|
||
}
|
||
return
|
||
}
|
||
switch tag {
|
||
case "br":
|
||
sb.WriteString("\n")
|
||
case "li":
|
||
sb.WriteString("\n- ") // 列表项前置符号,保持可读性
|
||
case "tr":
|
||
sb.WriteString("\n")
|
||
case "td", "th":
|
||
sb.WriteString(" ") // 单元格之间留空格,避免不同列文字黏连
|
||
}
|
||
}
|
||
if n.Type == html.TextNode {
|
||
// HTML 源码缩进产生的纯空白文本节点直接丢弃
|
||
txt := strings.TrimSpace(n.Data)
|
||
if txt != "" {
|
||
sb.WriteString(txt)
|
||
}
|
||
}
|
||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||
walk(c)
|
||
}
|
||
// 块级元素结束 → 空行分段
|
||
if n.Type == html.ElementNode && htmlBlockTags[strings.ToLower(n.Data)] {
|
||
sb.WriteString("\n\n")
|
||
}
|
||
}
|
||
walk(doc)
|
||
|
||
text := multiNewlineRe.ReplaceAllString(sb.String(), "\n\n")
|
||
return strings.TrimSpace(text), nil
|
||
}
|
||
|
||
// plainTextOf 收集节点子树内的纯文本(用于标题内容,不处理块级结构)
|
||
func plainTextOf(n *html.Node) string {
|
||
var sb strings.Builder
|
||
var walk func(node *html.Node)
|
||
walk = func(node *html.Node) {
|
||
if node.Type == html.TextNode {
|
||
sb.WriteString(strings.TrimSpace(node.Data))
|
||
}
|
||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||
walk(c)
|
||
}
|
||
}
|
||
walk(n)
|
||
return sb.String()
|
||
}
|