mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-19 06:01:05 +08:00
312 lines
7.9 KiB
Go
312 lines
7.9 KiB
Go
|
|
//
|
|||
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|||
|
|
//
|
|||
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
|
|
// you may not use this file except in compliance with the License.
|
|||
|
|
// You may obtain a copy of the License at
|
|||
|
|
//
|
|||
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|||
|
|
//
|
|||
|
|
// Unless required by applicable law or agreed to in writing, software
|
|||
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
|
|
// See the License for the specific language governing permissions and
|
|||
|
|
// limitations under the License.
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
// EPUBParser extracts text content from EPUB files. EPUB is a ZIP
|
|||
|
|
// container with XHTML spine items. The parser:
|
|||
|
|
//
|
|||
|
|
// 1. Opens the EPUB as a ZIP archive.
|
|||
|
|
// 2. Reads META-INF/container.xml to locate the OPF manifest.
|
|||
|
|
// 3. Walks the OPF spine to collect content documents in order.
|
|||
|
|
// 4. Strips HTML tags from each content document to produce plain
|
|||
|
|
// text items, emitting one JSON item per spine entry.
|
|||
|
|
//
|
|||
|
|
// The output format is "json", matching the epub default in
|
|||
|
|
// ParserParam.Defaults().
|
|||
|
|
|
|||
|
|
package parser
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"archive/zip"
|
|||
|
|
"bytes"
|
|||
|
|
"encoding/xml"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"path"
|
|||
|
|
"regexp"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// EPUBParser extracts text from EPUB archives.
|
|||
|
|
type EPUBParser struct{}
|
|||
|
|
|
|||
|
|
func NewEPUBParser() *EPUBParser {
|
|||
|
|
return &EPUBParser{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (p *EPUBParser) String() string {
|
|||
|
|
return "EPUBParser"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ParseWithResult implements ParseResultProducer. It extracts XHTML
|
|||
|
|
// content from the EPUB spine and emits one JSON item per spine entry
|
|||
|
|
// with {text, doc_type_kwd:"text"}.
|
|||
|
|
func (p *EPUBParser) ParseWithResult(filename string, data []byte) ParseResult {
|
|||
|
|
if len(data) == 0 {
|
|||
|
|
return ParseResult{
|
|||
|
|
OutputFormat: "json",
|
|||
|
|
File: map[string]any{
|
|||
|
|
"name": filename,
|
|||
|
|
"size": 0,
|
|||
|
|
"encoding": "utf-8",
|
|||
|
|
},
|
|||
|
|
JSON: []map[string]any{{"text": "", "doc_type_kwd": "text"}},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|||
|
|
if err != nil {
|
|||
|
|
return ParseResult{Err: fmt.Errorf("epub: open zip: %w", err)}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
opfPath, err := readContainerXML(reader)
|
|||
|
|
if err != nil {
|
|||
|
|
return ParseResult{Err: fmt.Errorf("epub: container.xml: %w", err)}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
spineItems, err := readOPFSpine(reader, opfPath)
|
|||
|
|
if err != nil {
|
|||
|
|
return ParseResult{Err: fmt.Errorf("epub: opf: %w", err)}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
items := extractEPUBTextItems(reader, opfPath, spineItems)
|
|||
|
|
if items == nil {
|
|||
|
|
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
|
|||
|
|
}
|
|||
|
|
return ParseResult{
|
|||
|
|
OutputFormat: "json",
|
|||
|
|
File: map[string]any{
|
|||
|
|
"name": filename,
|
|||
|
|
"size": len(data),
|
|||
|
|
"encoding": "utf-8",
|
|||
|
|
},
|
|||
|
|
JSON: items,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- container.xml parsing ---
|
|||
|
|
|
|||
|
|
type containerXML struct {
|
|||
|
|
RootFiles []struct {
|
|||
|
|
FullPath string `xml:"full-path,attr"`
|
|||
|
|
} `xml:"rootfiles>rootfile"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func readContainerXML(r *zip.Reader) (string, error) {
|
|||
|
|
f, err := r.Open("META-INF/container.xml")
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("open container.xml: %w", err)
|
|||
|
|
}
|
|||
|
|
defer f.Close()
|
|||
|
|
|
|||
|
|
raw, err := io.ReadAll(f)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("read container.xml: %w", err)
|
|||
|
|
}
|
|||
|
|
var c containerXML
|
|||
|
|
if err := xml.Unmarshal(raw, &c); err != nil {
|
|||
|
|
return "", fmt.Errorf("parse container.xml: %w", err)
|
|||
|
|
}
|
|||
|
|
if len(c.RootFiles) == 0 {
|
|||
|
|
return "", fmt.Errorf("no rootfile in container.xml")
|
|||
|
|
}
|
|||
|
|
return c.RootFiles[0].FullPath, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- OPF parsing ---
|
|||
|
|
|
|||
|
|
type opfPackage struct {
|
|||
|
|
Manifest struct {
|
|||
|
|
Items []struct {
|
|||
|
|
ID string `xml:"id,attr"`
|
|||
|
|
Href string `xml:"href,attr"`
|
|||
|
|
Type string `xml:"media-type,attr"`
|
|||
|
|
} `xml:"item"`
|
|||
|
|
} `xml:"manifest"`
|
|||
|
|
Spine struct {
|
|||
|
|
ItemRefs []struct {
|
|||
|
|
IDRef string `xml:"idref,attr"`
|
|||
|
|
} `xml:"itemref"`
|
|||
|
|
} `xml:"spine"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func readOPFSpine(r *zip.Reader, opfPath string) ([]string, error) {
|
|||
|
|
f, err := r.Open(opfPath)
|
|||
|
|
if err != nil {
|
|||
|
|
// Some EPUBs use a path with a leading slash stripped.
|
|||
|
|
clean := strings.TrimPrefix(opfPath, "/")
|
|||
|
|
if clean != opfPath {
|
|||
|
|
f, err = r.Open(clean)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("open opf %q: %w", opfPath, err)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
return nil, fmt.Errorf("open opf %q: %w", opfPath, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
defer f.Close()
|
|||
|
|
|
|||
|
|
raw, err := io.ReadAll(f)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("read opf: %w", err)
|
|||
|
|
}
|
|||
|
|
var pkg opfPackage
|
|||
|
|
if err := xml.Unmarshal(raw, &pkg); err != nil {
|
|||
|
|
return nil, fmt.Errorf("parse opf: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build id → href lookup.
|
|||
|
|
byID := make(map[string]string, len(pkg.Manifest.Items))
|
|||
|
|
for _, it := range pkg.Manifest.Items {
|
|||
|
|
byID[it.ID] = it.Href
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build spine item list (ordered hrefs).
|
|||
|
|
var hrefs []string
|
|||
|
|
for _, ref := range pkg.Spine.ItemRefs {
|
|||
|
|
if href, ok := byID[ref.IDRef]; ok {
|
|||
|
|
hrefs = append(hrefs, href)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return hrefs, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- text extraction ---
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
epubScriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
|
|||
|
|
epubStyleRe = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
|
|||
|
|
epubTagRe = regexp.MustCompile(`<[^>]+>`)
|
|||
|
|
epubEntityRe = regexp.MustCompile(`&[a-zA-Z]+;`)
|
|||
|
|
epubWSRe = regexp.MustCompile(`\s+`)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func extractEPUBTextItems(r *zip.Reader, opfDir string, spineHrefs []string) []map[string]any {
|
|||
|
|
var items []map[string]any
|
|||
|
|
for _, href := range spineHrefs {
|
|||
|
|
text := readEPUBContentFile(r, opfDir, href)
|
|||
|
|
if strings.TrimSpace(text) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
items = append(items, map[string]any{
|
|||
|
|
"text": text,
|
|||
|
|
"doc_type_kwd": "text",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return items
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// readEPUBContentFile resolves a spine href (relative to the OPF
|
|||
|
|
// directory) inside the ZIP, reads the raw bytes, and strips HTML to
|
|||
|
|
// return clean text.
|
|||
|
|
func readEPUBContentFile(r *zip.Reader, opfDir, href string) string {
|
|||
|
|
// Resolve href relative to the directory containing the OPF.
|
|||
|
|
// Use path.Join/Dir (slash separator) because ZIP entry paths are always POSIX.
|
|||
|
|
resolved := path.Join(path.Dir(opfDir), href)
|
|||
|
|
|
|||
|
|
var f io.ReadCloser
|
|||
|
|
var err error
|
|||
|
|
// Try the resolved path first, then raw href.
|
|||
|
|
for _, name := range []string{resolved, href} {
|
|||
|
|
if strings.TrimSpace(name) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
f, err = r.Open(name)
|
|||
|
|
if err == nil {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
defer f.Close()
|
|||
|
|
|
|||
|
|
raw, err := io.ReadAll(f)
|
|||
|
|
if err != nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
return stripHTMLTags(string(raw))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// stripHTMLTags removes HTML markup and returns normalized plain text.
|
|||
|
|
func stripHTMLTags(s string) string {
|
|||
|
|
// Remove script/style blocks first (including their content).
|
|||
|
|
s = epubScriptRe.ReplaceAllString(s, "")
|
|||
|
|
s = epubStyleRe.ReplaceAllString(s, "")
|
|||
|
|
// Replace <br>, <p>, </p>, </div>, </tr>, </li> etc. with newlines.
|
|||
|
|
s = epubTagRe.ReplaceAllStringFunc(s, func(tag string) string {
|
|||
|
|
lower := strings.ToLower(tag)
|
|||
|
|
switch {
|
|||
|
|
case strings.HasPrefix(lower, "<br"),
|
|||
|
|
strings.HasPrefix(lower, "</p"),
|
|||
|
|
strings.HasPrefix(lower, "</div"),
|
|||
|
|
strings.HasPrefix(lower, "</h1"),
|
|||
|
|
strings.HasPrefix(lower, "</h2"),
|
|||
|
|
strings.HasPrefix(lower, "</h3"),
|
|||
|
|
strings.HasPrefix(lower, "</h4"),
|
|||
|
|
strings.HasPrefix(lower, "</h5"),
|
|||
|
|
strings.HasPrefix(lower, "</h6"),
|
|||
|
|
strings.HasPrefix(lower, "</tr"),
|
|||
|
|
strings.HasPrefix(lower, "</li"),
|
|||
|
|
strings.HasPrefix(lower, "</blockquote"):
|
|||
|
|
return "\n"
|
|||
|
|
case strings.HasPrefix(lower, "</td"),
|
|||
|
|
strings.HasPrefix(lower, "</th"):
|
|||
|
|
return " "
|
|||
|
|
}
|
|||
|
|
return ""
|
|||
|
|
})
|
|||
|
|
// Decode common XML entities.
|
|||
|
|
s = epubEntityRe.ReplaceAllStringFunc(s, xmlEntityValue)
|
|||
|
|
// Normalize whitespace.
|
|||
|
|
s = epubWSRe.ReplaceAllString(s, " ")
|
|||
|
|
return strings.TrimSpace(s)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// xmlEntityValue decodes a few common XML named entities. A full
|
|||
|
|
// entity table isn't needed for the typical EPUB vocabulary.
|
|||
|
|
func xmlEntityValue(entity string) string {
|
|||
|
|
switch entity {
|
|||
|
|
case "&":
|
|||
|
|
return "&"
|
|||
|
|
case "<":
|
|||
|
|
return "<"
|
|||
|
|
case ">":
|
|||
|
|
return ">"
|
|||
|
|
case """:
|
|||
|
|
return "\""
|
|||
|
|
case "'":
|
|||
|
|
return "'"
|
|||
|
|
case " ":
|
|||
|
|
return " "
|
|||
|
|
case "–":
|
|||
|
|
return "–"
|
|||
|
|
case "—":
|
|||
|
|
return "—"
|
|||
|
|
case "‘":
|
|||
|
|
return "'"
|
|||
|
|
case "’":
|
|||
|
|
return "'"
|
|||
|
|
case "“":
|
|||
|
|
return "\""
|
|||
|
|
case "”":
|
|||
|
|
return "\""
|
|||
|
|
case "…":
|
|||
|
|
return "…"
|
|||
|
|
default:
|
|||
|
|
return entity
|
|||
|
|
}
|
|||
|
|
}
|