Files
Agent-System/internal/mineru/extract.go
T
weichenandCursor 32cda4486c feat(mineru): add MinerU precision parse client
Implement signed batch upload, poll, zip extract, and real page
anchor rebuild from content_list.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 08:29:10 +00:00

154 lines
3.8 KiB
Go

package mineru
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"unicode/utf8"
)
// contentListItem is a subset of MinerU content_list.json entries.
type contentListItem struct {
Type string `json:"type"`
Text string `json:"text"`
PageIdx int `json:"page_idx"`
TextLevel int `json:"text_level"`
TableBody string `json:"table_body"`
ImgCaption json.RawMessage `json:"img_caption"`
TableCaption json.RawMessage `json:"table_caption"`
}
// ExtractFromZip reads full.md and content_list.json from a MinerU result zip.
// Prefer rebuilding markdown from content_list so page anchors use real page_idx.
func ExtractFromZip(zipBytes []byte) (markdown string, contentList json.RawMessage, totalPages int, err error) {
r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return "", nil, 0, fmt.Errorf("mineru: open zip: %w", err)
}
var fullMD []byte
var listRaw []byte
for _, f := range r.File {
base := strings.ToLower(filepath.Base(f.Name))
rc, err := f.Open()
if err != nil {
continue
}
data, readErr := io.ReadAll(io.LimitReader(rc, 32<<20))
_ = rc.Close()
if readErr != nil {
continue
}
switch {
case base == "full.md":
fullMD = data
case strings.HasSuffix(base, "_content_list.json") || base == "content_list.json":
listRaw = data
}
}
if len(listRaw) > 0 {
md, pages, buildErr := MarkdownFromContentList(listRaw)
if buildErr == nil && strings.TrimSpace(md) != "" {
return md, json.RawMessage(listRaw), pages, nil
}
}
if len(fullMD) == 0 {
return "", nil, 0, fmt.Errorf("mineru: zip missing full.md and usable content_list")
}
md := strings.TrimSpace(string(fullMD))
pages := countPageAnchors(md)
if pages == 0 {
pages = 1
}
return md, json.RawMessage(listRaw), pages, nil
}
// MarkdownFromContentList rebuilds markdown with <!-- page N --> anchors from page_idx.
// page_idx in MinerU is 0-based; anchors are 1-based for agent citations.
func MarkdownFromContentList(raw []byte) (string, int, error) {
var items []contentListItem
if err := json.Unmarshal(raw, &items); err != nil {
return "", 0, fmt.Errorf("mineru: decode content_list: %w", err)
}
if len(items) == 0 {
return "", 0, fmt.Errorf("mineru: empty content_list")
}
var sb strings.Builder
currentPage := -1
maxPage := 0
for _, it := range items {
page := it.PageIdx + 1
if page < 1 {
page = 1
}
if page > maxPage {
maxPage = page
}
if page != currentPage {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("<!-- page %d -->\n", page))
currentPage = page
}
text := strings.TrimSpace(it.Text)
if text == "" && it.TableBody != "" {
text = strings.TrimSpace(it.TableBody)
}
if text == "" {
continue
}
switch strings.ToLower(it.Type) {
case "title", "text":
if it.TextLevel == 1 {
sb.WriteString("# ")
} else if it.TextLevel == 2 {
sb.WriteString("## ")
} else if it.TextLevel >= 3 {
sb.WriteString("### ")
}
sb.WriteString(text)
sb.WriteString("\n\n")
case "table":
sb.WriteString(text)
sb.WriteString("\n\n")
default:
sb.WriteString(text)
sb.WriteString("\n\n")
}
}
out := strings.TrimSpace(sb.String())
if out == "" {
return "", 0, fmt.Errorf("mineru: content_list produced empty markdown")
}
if maxPage == 0 {
maxPage = 1
}
return out, maxPage, nil
}
func countPageAnchors(md string) int {
n := 0
for _, line := range strings.Split(md, "\n") {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "<!-- page ") && strings.HasSuffix(trim, "-->") {
n++
}
}
return n
}
// EnsureUTF8 returns s if valid UTF-8, otherwise strips invalid sequences.
func EnsureUTF8(s string) string {
if utf8.ValidString(s) {
return s
}
return strings.ToValidUTF8(s, "")
}