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() }