package app import ( "archive/zip" "bytes" "encoding/xml" "fmt" "io" "path/filepath" "regexp" "strings" "unicode/utf8" "github.com/extrame/xls" pdf "github.com/ledongthuc/pdf" "github.com/xuri/excelize/v2" "golang.org/x/net/html" ) var ( whitespacePattern = regexp.MustCompile(`[ \t]+`) genericKnowledgeMarkdownTitleRegex = regexp.MustCompile(`^第\s*\d+\s*部分$`) ) const maxKnowledgeSpreadsheetRows = 100000 type knowledgeParsedContent struct { Text string Markdown string } func parseKnowledgeTextInput(name, content string) *knowledgeParsedContent { text := normalizeKnowledgeText(content) return &knowledgeParsedContent{ Text: text, Markdown: buildKnowledgeMarkdownFromText(name, text), } } func extractKnowledgeContentFromFile(fileName string, content []byte) (*knowledgeParsedContent, error) { ext := strings.ToLower(filepath.Ext(fileName)) switch ext { case ".txt", ".json", ".csv": return extractUTF8KnowledgeText(fileName, content, false) case ".md", ".markdown": return extractUTF8KnowledgeText(fileName, content, true) case ".html", ".htm": text, err := extractHTMLText(content) if err != nil { return nil, err } return &knowledgeParsedContent{ Text: text, Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text), }, nil case ".docx": text, err := extractDOCXText(content) if err != nil { return nil, err } return &knowledgeParsedContent{ Text: text, Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text), }, nil case ".xlsx": return extractXLSXContent(content) case ".xls": return extractXLSContent(content) case ".pdf": text, err := extractPDFText(content) if err != nil { return nil, err } return &knowledgeParsedContent{ Text: text, Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text), }, nil default: if utf8.Valid(content) { return extractUTF8KnowledgeText(fileName, content, false) } return nil, fmt.Errorf("unsupported file type %s", ext) } } func extractUTF8KnowledgeText(fileName string, content []byte, preserveMarkdown bool) (*knowledgeParsedContent, error) { if !utf8.Valid(content) { return nil, fmt.Errorf("file %s is not valid UTF-8 text", fileName) } raw := string(content) text := normalizeKnowledgeText(raw) if text == "" { return nil, fmt.Errorf("file %s contains no readable text", fileName) } markdown := buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text) if preserveMarkdown { normalizedMarkdown := normalizeKnowledgeMarkdown(raw) if normalizedMarkdown != "" { markdown = normalizedMarkdown } } return &knowledgeParsedContent{ Text: text, Markdown: markdown, }, nil } func extractHTMLText(content []byte) (string, error) { doc, err := html.Parse(bytes.NewReader(content)) if err != nil { return "", fmt.Errorf("parse html: %w", err) } var parts []string var walk func(*html.Node, bool) walk = func(node *html.Node, skip bool) { if node == nil { return } nextSkip := skip if node.Type == html.ElementNode { switch strings.ToLower(node.Data) { case "script", "style", "noscript", "svg": nextSkip = true } } if node.Type == html.TextNode && !nextSkip { text := strings.TrimSpace(node.Data) if text != "" { parts = append(parts, text) } } for child := node.FirstChild; child != nil; child = child.NextSibling { walk(child, nextSkip) } } walk(doc, false) result := normalizeKnowledgeText(strings.Join(parts, "\n")) if result == "" { return "", fmt.Errorf("html contains no readable text") } return result, nil } func extractDOCXText(content []byte) (string, error) { reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) if err != nil { return "", fmt.Errorf("open docx: %w", err) } var parts []string for _, file := range reader.File { if file.Name != "word/document.xml" { continue } rc, err := file.Open() if err != nil { return "", fmt.Errorf("open docx entry: %w", err) } data, err := io.ReadAll(rc) rc.Close() if err != nil { return "", fmt.Errorf("read docx entry: %w", err) } decoder := xml.NewDecoder(bytes.NewReader(data)) for { token, tokenErr := decoder.Token() if tokenErr == io.EOF { break } if tokenErr != nil { return "", fmt.Errorf("decode docx xml: %w", tokenErr) } switch typed := token.(type) { case xml.CharData: text := strings.TrimSpace(string(typed)) if text != "" { parts = append(parts, text) } } } } if len(parts) == 0 { return "", fmt.Errorf("docx contains no readable text") } return normalizeKnowledgeText(strings.Join(parts, "\n")), nil } func extractXLSXContent(content []byte) (*knowledgeParsedContent, error) { book, err := excelize.OpenReader(bytes.NewReader(content)) if err != nil { return nil, fmt.Errorf("open xlsx: %w", err) } defer func() { _ = book.Close() }() markdownSections := make([]string, 0) textSections := make([]string, 0) for _, sheet := range book.GetSheetList() { rows, err := book.GetRows(sheet) if err != nil { return nil, fmt.Errorf("read xlsx rows: %w", err) } cleaned := normalizeSpreadsheetRows(rows) if len(cleaned) == 0 { continue } markdownSections = append(markdownSections, buildKnowledgeSheetMarkdown(sheet, cleaned)) textSections = append(textSections, buildKnowledgeSheetPlainText(sheet, cleaned)) } if len(markdownSections) == 0 { return nil, fmt.Errorf("xlsx contains no readable text") } return &knowledgeParsedContent{ Text: normalizeKnowledgeText(strings.Join(textSections, "\n\n")), Markdown: strings.TrimSpace(strings.Join(markdownSections, "\n\n")), }, nil } func extractXLSContent(content []byte) (*knowledgeParsedContent, error) { book, err := xls.OpenReader(bytes.NewReader(content), "utf-8") if err != nil { return nil, fmt.Errorf("open xls: %w", err) } // The upstream xls library panics for sparse rows when calling sheet.Row(i), // so we rely on its safer bulk reader here instead of random row access. rows := book.ReadAllCells(maxKnowledgeSpreadsheetRows) cleaned := normalizeSpreadsheetRows(rows) if len(cleaned) == 0 { return nil, fmt.Errorf("xls contains no readable text") } title := "XLS 工作簿" if book.NumSheets() == 1 { if sheet := book.GetSheet(0); sheet != nil && strings.TrimSpace(sheet.Name) != "" { title = strings.TrimSpace(sheet.Name) } } return &knowledgeParsedContent{ Text: normalizeKnowledgeText(buildKnowledgeSheetPlainText(title, cleaned)), Markdown: buildKnowledgeSheetMarkdown(title, cleaned), }, nil } func normalizeSpreadsheetRows(rows [][]string) [][]string { cleaned := make([][]string, 0, len(rows)) for _, row := range rows { cells := make([]string, len(row)) copy(cells, row) for index := range cells { cells[index] = normalizeKnowledgeInlineText(cells[index]) } lastNonEmpty := -1 for index, cell := range cells { if cell != "" { lastNonEmpty = index } } if lastNonEmpty < 0 { continue } cleaned = append(cleaned, cells[:lastNonEmpty+1]) } return cleaned } func buildKnowledgeSheetPlainText(sheetName string, rows [][]string) string { lines := make([]string, 0, len(rows)+1) if strings.TrimSpace(sheetName) != "" { lines = append(lines, strings.TrimSpace(sheetName)) } for _, row := range rows { lines = append(lines, strings.Join(row, " | ")) } return strings.Join(lines, "\n") } func buildKnowledgeSheetMarkdown(sheetName string, rows [][]string) string { if len(rows) == 0 { return "" } width := 0 for _, row := range rows { if len(row) > width { width = len(row) } } if width == 0 { return "" } normalizedRows := make([][]string, 0, len(rows)) for _, row := range rows { normalizedRows = append(normalizedRows, padKnowledgeRow(row, width)) } var builder strings.Builder if title := strings.TrimSpace(sheetName); title != "" { builder.WriteString("## ") builder.WriteString(title) builder.WriteString("\n\n") } writeKnowledgeMarkdownTableRow(&builder, normalizedRows[0]) writeKnowledgeMarkdownTableSeparator(&builder, width) for _, row := range normalizedRows[1:] { writeKnowledgeMarkdownTableRow(&builder, row) } return strings.TrimSpace(builder.String()) } func padKnowledgeRow(row []string, width int) []string { result := make([]string, width) copy(result, row) return result } func writeKnowledgeMarkdownTableRow(builder *strings.Builder, row []string) { builder.WriteString("|") for _, cell := range row { builder.WriteString(" ") builder.WriteString(escapeKnowledgeMarkdownTableCell(cell)) builder.WriteString(" |") } builder.WriteString("\n") } func writeKnowledgeMarkdownTableSeparator(builder *strings.Builder, width int) { builder.WriteString("|") for index := 0; index < width; index++ { builder.WriteString(" --- |") } builder.WriteString("\n") } func escapeKnowledgeMarkdownTableCell(cell string) string { cell = strings.TrimSpace(cell) cell = strings.ReplaceAll(cell, "\\", "\\\\") cell = strings.ReplaceAll(cell, "|", "\\|") cell = strings.ReplaceAll(cell, "\r", " ") cell = strings.ReplaceAll(cell, "\n", " / ") return cell } func extractPDFText(content []byte) (string, error) { reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content))) if err != nil { return "", fmt.Errorf("open pdf: %w", err) } var builder strings.Builder totalPage := reader.NumPage() for pageIndex := 1; pageIndex <= totalPage; pageIndex++ { page := reader.Page(pageIndex) if page.V.IsNull() { continue } text, err := page.GetPlainText(nil) if err != nil { return "", fmt.Errorf("extract pdf text: %w", err) } if text != "" { builder.WriteString(text) builder.WriteString("\n") } } result := normalizeKnowledgeText(builder.String()) if result == "" { return "", fmt.Errorf("pdf contains no readable text") } return result, nil } func defaultKnowledgeTitle(fileName string) string { base := filepath.Base(strings.TrimSpace(fileName)) if base == "" { return "" } return strings.TrimSuffix(base, filepath.Ext(base)) } func buildKnowledgeMarkdownFromText(title, text string) string { text = normalizeKnowledgeText(text) title = strings.TrimSpace(title) if text == "" { if title == "" { return "" } return "# " + title } lines := strings.Split(text, "\n") if title != "" && len(lines) > 0 && strings.EqualFold(strings.TrimSpace(lines[0]), title) { lines = lines[1:] } paragraphs := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line != "" { paragraphs = append(paragraphs, line) } } sections := make([]string, 0, 2) if title != "" { sections = append(sections, "# "+title) } if len(paragraphs) > 0 { sections = append(sections, strings.Join(paragraphs, "\n\n")) } return strings.TrimSpace(strings.Join(sections, "\n\n")) } func extractKnowledgeMarkdownTitle(markdown string) string { markdown = normalizeKnowledgeMarkdown(markdown) if markdown == "" { return "" } for _, line := range strings.Split(markdown, "\n") { line = strings.TrimSpace(line) if line == "" || !strings.HasPrefix(line, "#") { continue } level := 0 for level < len(line) && line[level] == '#' { level++ } if level == len(line) || line[level] != ' ' { continue } title := strings.Join(strings.Fields(strings.TrimSpace(line[level+1:])), " ") title = strings.Trim(title, "# \t") if title == "" || genericKnowledgeMarkdownTitleRegex.MatchString(title) { continue } switch title { case "摘要", "概述", "概要", "目录", "正文": continue } runes := []rune(title) if len(runes) > 200 { title = string(runes[:200]) } return title } return "" } func normalizeKnowledgeMarkdown(input string) string { input = strings.ReplaceAll(input, "\r\n", "\n") input = strings.ReplaceAll(input, "\u00a0", " ") lines := strings.Split(input, "\n") cleaned := make([]string, 0, len(lines)) lastBlank := false for _, line := range lines { line = strings.TrimRight(line, " \t") if strings.TrimSpace(line) == "" { if lastBlank { continue } lastBlank = true cleaned = append(cleaned, "") continue } lastBlank = false cleaned = append(cleaned, line) } return strings.TrimSpace(strings.Join(cleaned, "\n")) } func normalizeKnowledgeText(input string) string { input = strings.ReplaceAll(input, "\u00a0", " ") lines := strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") cleaned := make([]string, 0, len(lines)) for _, line := range lines { line = normalizeKnowledgeInlineText(line) if line != "" { cleaned = append(cleaned, line) } } return strings.TrimSpace(strings.Join(cleaned, "\n")) } func normalizeKnowledgeInlineText(input string) string { return whitespacePattern.ReplaceAllString(strings.TrimSpace(input), " ") }