Files
xk-ai-agent/internal/kb/parser_html.go
2026-08-14 21:50:48 +08:00

120 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}