0849557725
kage parquet export turns a packed archive into a columnar Parquet table, one row per entry, and kage parquet import rebuilds the archive from it. The table follows the open-index/open-markdown field names (doc_id, url, host, crawl_date, content_length, text_length, text) so a kage export sits next to other web-crawl datasets on Hugging Face and reads straight into DuckDB or pandas. Alongside those columns it keeps the raw content bytes and the ZIM structure (namespace, redirect target), so the round trip is lossless: a ZIM exported and reimported is byte-identical. doc_id is a deterministic UUID v5 of the page URL. HTML pages also get a derived plain-text column for full-text search and training use; it plays no part in the round trip, which rebuilds pages from the stored content.
401 lines
10 KiB
Go
401 lines
10 KiB
Go
package zim
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// ErrNotFound is returned by Get when no entry matches the namespace and url.
|
|
// Callers (such as the HTTP handler) test for it with errors.Is to map a miss
|
|
// to a 404.
|
|
var ErrNotFound = errors.New("zim: not found")
|
|
|
|
// Reader provides random access to a ZIM file's entries. Open one with Open or
|
|
// NewReader, then look entries up by namespace and url, or fetch the main page.
|
|
// Decompressed clusters are cached so repeated reads from one cluster are cheap.
|
|
type Reader struct {
|
|
ra io.ReaderAt
|
|
closer io.Closer
|
|
size int64
|
|
hdr header
|
|
mimes []string
|
|
|
|
mu sync.Mutex
|
|
cache map[uint32][]byte // cluster index -> decompressed data section
|
|
cacheExtended map[uint32]bool // cluster index -> uint64-offset cluster
|
|
}
|
|
|
|
// Blob is the result of a lookup: the resolved entry's bytes and metadata.
|
|
type Blob struct {
|
|
Namespace byte
|
|
URL string
|
|
Title string
|
|
MimeType string
|
|
Data []byte
|
|
}
|
|
|
|
// Open opens a ZIM file on disk. Close the returned reader when done.
|
|
func Open(path string) (*Reader, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
r, err := NewReader(f, fi.Size())
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
r.closer = f
|
|
return r, nil
|
|
}
|
|
|
|
// NewReader reads the header and MIME list from ra, which must hold size bytes.
|
|
func NewReader(ra io.ReaderAt, size int64) (*Reader, error) {
|
|
r := &Reader{ra: ra, size: size, cache: map[uint32][]byte{}}
|
|
hb, err := r.at(0, headerLen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zim: read header: %w", err)
|
|
}
|
|
r.hdr, err = parseHeader(hb)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.hdr.mimeListPos > r.hdr.urlPtrPos || r.hdr.urlPtrPos > uint64(size) {
|
|
return nil, fmt.Errorf("zim: inconsistent header offsets")
|
|
}
|
|
mb, err := r.at(r.hdr.mimeListPos, int(r.hdr.urlPtrPos-r.hdr.mimeListPos))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zim: read mime list: %w", err)
|
|
}
|
|
for _, part := range bytes.Split(mb, []byte{0}) {
|
|
if len(part) == 0 {
|
|
break
|
|
}
|
|
r.mimes = append(r.mimes, string(part))
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Close releases the underlying file, if Open created one.
|
|
func (r *Reader) Close() error {
|
|
if r.closer != nil {
|
|
return r.closer.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Count returns the number of directory entries.
|
|
func (r *Reader) Count() uint32 { return r.hdr.articleCount }
|
|
|
|
// MimeTypes returns the archive's MIME-type list.
|
|
func (r *Reader) MimeTypes() []string { return r.mimes }
|
|
|
|
// MainPage returns the archive's entry point, or an error if none is set.
|
|
func (r *Reader) MainPage() (Blob, error) {
|
|
if r.hdr.mainPage == noMainPage {
|
|
return Blob{}, fmt.Errorf("zim: no main page")
|
|
}
|
|
return r.blobAtIndex(r.hdr.mainPage, 0)
|
|
}
|
|
|
|
// Entry is one directory entry as stored, returned by EntryAt. A redirect keeps
|
|
// Data nil and names its target in RedirectNamespace/RedirectURL; any other
|
|
// entry carries its bytes in Data and its type in MimeType. Unlike Get, EntryAt
|
|
// does not follow redirects, so a caller can round-trip every entry, the
|
|
// redirects included.
|
|
type Entry struct {
|
|
Namespace byte
|
|
URL string
|
|
Title string
|
|
MimeType string
|
|
Redirect bool
|
|
RedirectNamespace byte
|
|
RedirectURL string
|
|
Data []byte
|
|
}
|
|
|
|
// EntryAt returns the directory entry at idx, where 0 <= idx < Count, in the
|
|
// archive's URL order. It is the iteration counterpart to Get: it exposes every
|
|
// entry exactly as stored, including metadata and redirects, which is what an
|
|
// exporter needs to reproduce the archive.
|
|
func (r *Reader) EntryAt(idx uint32) (Entry, error) {
|
|
d, err := r.direntAtIndex(idx)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
e := Entry{Namespace: d.namespace, URL: d.url, Title: d.title}
|
|
if d.redirect {
|
|
e.Redirect = true
|
|
td, err := r.direntAtIndex(d.targetIndex)
|
|
if err != nil {
|
|
return Entry{}, fmt.Errorf("zim: redirect target of %c/%s: %w", d.namespace, d.url, err)
|
|
}
|
|
e.RedirectNamespace = td.namespace
|
|
e.RedirectURL = td.url
|
|
return e, nil
|
|
}
|
|
data, err := r.blobData(d.cluster, d.blob)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
if int(d.mimeIdx) < len(r.mimes) {
|
|
e.MimeType = r.mimes[d.mimeIdx]
|
|
}
|
|
e.Data = data
|
|
return e, nil
|
|
}
|
|
|
|
// MainPageRef returns the namespace and url of the archive's entry point and
|
|
// whether one is set, so an exporter can record which entry is the main page
|
|
// without following the W/mainPage redirect.
|
|
func (r *Reader) MainPageRef() (byte, string, bool) {
|
|
if r.hdr.mainPage == noMainPage {
|
|
return 0, "", false
|
|
}
|
|
d, err := r.direntAtIndex(r.hdr.mainPage)
|
|
if err != nil {
|
|
return 0, "", false
|
|
}
|
|
return d.namespace, d.url, true
|
|
}
|
|
|
|
// Get resolves the entry at (namespace, url), following one or more redirects.
|
|
func (r *Reader) Get(namespace byte, url string) (Blob, error) {
|
|
target := key(namespace, url)
|
|
lo, hi := uint32(0), r.hdr.articleCount
|
|
for lo < hi {
|
|
mid := lo + (hi-lo)/2
|
|
d, err := r.direntAtIndex(mid)
|
|
if err != nil {
|
|
return Blob{}, err
|
|
}
|
|
switch k := key(d.namespace, d.url); {
|
|
case k < target:
|
|
lo = mid + 1
|
|
case k > target:
|
|
hi = mid
|
|
default:
|
|
return r.blobAtIndex(mid, 0)
|
|
}
|
|
}
|
|
return Blob{}, fmt.Errorf("%w: %c/%s", ErrNotFound, namespace, url)
|
|
}
|
|
|
|
const maxRedirectHops = 16
|
|
|
|
func (r *Reader) blobAtIndex(idx uint32, hop int) (Blob, error) {
|
|
if hop > maxRedirectHops {
|
|
return Blob{}, fmt.Errorf("zim: redirect loop")
|
|
}
|
|
d, err := r.direntAtIndex(idx)
|
|
if err != nil {
|
|
return Blob{}, err
|
|
}
|
|
if d.redirect {
|
|
return r.blobAtIndex(d.targetIndex, hop+1)
|
|
}
|
|
data, err := r.blobData(d.cluster, d.blob)
|
|
if err != nil {
|
|
return Blob{}, err
|
|
}
|
|
mime := ""
|
|
if int(d.mimeIdx) < len(r.mimes) {
|
|
mime = r.mimes[d.mimeIdx]
|
|
}
|
|
return Blob{Namespace: d.namespace, URL: d.url, Title: d.title, MimeType: mime, Data: data}, nil
|
|
}
|
|
|
|
type dirent struct {
|
|
mimeIdx uint16
|
|
namespace byte
|
|
url, title string
|
|
cluster uint32
|
|
blob uint32
|
|
redirect bool
|
|
targetIndex uint32
|
|
}
|
|
|
|
func (r *Reader) direntAtIndex(idx uint32) (dirent, error) {
|
|
pb, err := r.at(r.hdr.urlPtrPos+8*uint64(idx), 8)
|
|
if err != nil {
|
|
return dirent{}, err
|
|
}
|
|
return r.direntAt(binary.LittleEndian.Uint64(pb))
|
|
}
|
|
|
|
func (r *Reader) direntAt(off uint64) (dirent, error) {
|
|
// Read a window large enough for the fixed head plus url and title; grow if
|
|
// either string is not terminated within it.
|
|
window := 512
|
|
for {
|
|
b, err := r.at(off, window)
|
|
if err != nil && len(b) == 0 {
|
|
return dirent{}, err
|
|
}
|
|
var d dirent
|
|
le := binary.LittleEndian
|
|
d.mimeIdx = le.Uint16(b[0:])
|
|
d.namespace = b[3]
|
|
var p int
|
|
if d.mimeIdx == redirectEntry {
|
|
d.redirect = true
|
|
d.targetIndex = le.Uint32(b[8:])
|
|
p = 12
|
|
} else {
|
|
d.cluster = le.Uint32(b[8:])
|
|
d.blob = le.Uint32(b[12:])
|
|
p = 16
|
|
}
|
|
url, n1, ok := readCString(b, p)
|
|
if !ok {
|
|
if window >= 1<<20 || off+uint64(window) >= uint64(r.size) {
|
|
return dirent{}, fmt.Errorf("zim: unterminated url at %d", off)
|
|
}
|
|
window *= 4
|
|
continue
|
|
}
|
|
title, _, ok := readCString(b, n1)
|
|
if !ok {
|
|
if window >= 1<<20 || off+uint64(window) >= uint64(r.size) {
|
|
return dirent{}, fmt.Errorf("zim: unterminated title at %d", off)
|
|
}
|
|
window *= 4
|
|
continue
|
|
}
|
|
d.url, d.title = url, title
|
|
return d, nil
|
|
}
|
|
}
|
|
|
|
// blobData returns one blob's bytes, decompressing and caching its cluster.
|
|
func (r *Reader) blobData(cluster, blob uint32) ([]byte, error) {
|
|
data, extended, err := r.clusterData(cluster)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w := uint32(4)
|
|
if extended {
|
|
w = 8
|
|
}
|
|
need := int((blob + 2) * w)
|
|
if need > len(data) {
|
|
return nil, fmt.Errorf("zim: blob %d out of range in cluster %d", blob, cluster)
|
|
}
|
|
o0 := readUint(data[blob*w:], w)
|
|
o1 := readUint(data[(blob+1)*w:], w)
|
|
if o0 > o1 || int(o1) > len(data) {
|
|
return nil, fmt.Errorf("zim: bad blob offsets in cluster %d", cluster)
|
|
}
|
|
out := make([]byte, o1-o0)
|
|
copy(out, data[o0:o1])
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Reader) clusterData(cluster uint32) (data []byte, extended bool, err error) {
|
|
r.mu.Lock()
|
|
if c, ok := r.cache[cluster]; ok {
|
|
r.mu.Unlock()
|
|
// extended-ness is recoverable from the info byte, but the cache stores
|
|
// already-decoded data whose offsets we re-read with the recorded width.
|
|
return c, r.cacheExtended[cluster], nil
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
start, err := r.clusterOffset(cluster)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
end := r.hdr.checksumPos
|
|
if cluster+1 < r.hdr.clusterCount {
|
|
if end, err = r.clusterOffset(cluster + 1); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
if start >= end || end > uint64(r.size) {
|
|
return nil, false, fmt.Errorf("zim: bad cluster bounds for %d", cluster)
|
|
}
|
|
raw, err := r.at(start, int(end-start))
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
info := raw[0]
|
|
comp := info & 0x0f
|
|
extended = info&extendedFlag != 0
|
|
body := raw[1:]
|
|
switch comp {
|
|
case compNone:
|
|
data = body
|
|
case compZstd:
|
|
if data, err = zstdDecode(body); err != nil {
|
|
return nil, false, fmt.Errorf("zim: zstd cluster %d: %w", cluster, err)
|
|
}
|
|
case compXZ:
|
|
return nil, false, fmt.Errorf("zim: xz clusters are not supported for reading")
|
|
default:
|
|
return nil, false, fmt.Errorf("zim: unknown compression %d in cluster %d", comp, cluster)
|
|
}
|
|
r.mu.Lock()
|
|
r.cache[cluster] = data
|
|
if r.cacheExtended == nil {
|
|
r.cacheExtended = map[uint32]bool{}
|
|
}
|
|
r.cacheExtended[cluster] = extended
|
|
r.mu.Unlock()
|
|
return data, extended, nil
|
|
}
|
|
|
|
func (r *Reader) clusterOffset(cluster uint32) (uint64, error) {
|
|
b, err := r.at(r.hdr.clusterPtrPos+8*uint64(cluster), 8)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return binary.LittleEndian.Uint64(b), nil
|
|
}
|
|
|
|
// at reads n bytes at off, clamped to the file size.
|
|
func (r *Reader) at(off uint64, n int) ([]byte, error) {
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("zim: negative read length")
|
|
}
|
|
if off > uint64(r.size) {
|
|
return nil, io.EOF
|
|
}
|
|
if off+uint64(n) > uint64(r.size) {
|
|
n = int(uint64(r.size) - off)
|
|
}
|
|
b := make([]byte, n)
|
|
if n == 0 {
|
|
return b, nil
|
|
}
|
|
_, err := r.ra.ReadAt(b, int64(off))
|
|
return b, err
|
|
}
|
|
|
|
func readCString(b []byte, start int) (string, int, bool) {
|
|
if start > len(b) {
|
|
return "", start, false
|
|
}
|
|
i := bytes.IndexByte(b[start:], 0)
|
|
if i < 0 {
|
|
return "", start, false
|
|
}
|
|
return string(b[start : start+i]), start + i + 1, true
|
|
}
|
|
|
|
func readUint(b []byte, width uint32) uint32 {
|
|
if width == 8 {
|
|
return uint32(binary.LittleEndian.Uint64(b))
|
|
}
|
|
return binary.LittleEndian.Uint32(b)
|
|
}
|