Files
xk-ai-agent/internal/kb/parser_html.go

120 lines
3.8 KiB
Go
Raw Permalink Normal View History

2026-08-14 21:50:48 +08:00
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()
}