dd6da78aeb
Both clients used a bare 10*time.Second literal for the http.Client timeout, unlike websearch.DefaultTimeout which carries a comment explaining the number. Naming them puts the reason (LAN ZIM read vs. a public API over the internet) next to the value; the constants equal what was there before, so behaviour is unchanged.
171 lines
5.6 KiB
Go
171 lines
5.6 KiB
Go
// Package kiwix reads a local Kiwix server (offline Wikipedia and friends).
|
|
//
|
|
// Why: the resident model is a 0.8B and invents facts. Letting her read a local
|
|
// article snippet beats letting her recall. Nothing here talks to the internet;
|
|
// the Kiwix server is on the same box.
|
|
//
|
|
// Search finds the article; Article reads it. The snippet a search returns is
|
|
// NOT usable context on its own — see the comment on Article — so the unit of
|
|
// context is the head of the article, truncated to fit a 4096 token window.
|
|
package kiwix
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/crawl"
|
|
)
|
|
|
|
// Result is one search hit.
|
|
type Result struct {
|
|
Title string // article title, e.g. "Rayleigh scattering"
|
|
Path string // e.g. /content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering
|
|
Snippet string // plain text, tags stripped, entities decoded
|
|
WordCount int // 0 if the server did not say
|
|
}
|
|
|
|
// Client is a Kiwix HTTP client. Boring on purpose: no retries, no cache.
|
|
type Client struct {
|
|
base string
|
|
http *http.Client
|
|
}
|
|
|
|
// clientTimeout — the whole request, search or article. The server is on the
|
|
// same box (see the package doc), so this is slack for a cold ZIM read, not a
|
|
// budget tuned against a flaky link the way websearch.DefaultTimeout is.
|
|
const clientTimeout = 10 * time.Second
|
|
|
|
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
base: strings.TrimRight(baseURL, "/"),
|
|
http: &http.Client{Timeout: clientTimeout},
|
|
}
|
|
}
|
|
|
|
// Search runs a keyword search in one ZIM (book) and returns up to limit hits.
|
|
//
|
|
// Ranking is keyword based, not semantic: "Rayleigh scattering" finds the right
|
|
// article, "why is the sky blue" finds a TV episode. Pass keywords, not questions.
|
|
func (c *Client) Search(ctx context.Context, pattern, book string, limit int) ([]Result, error) {
|
|
if limit <= 0 {
|
|
limit = 5
|
|
}
|
|
q := url.Values{}
|
|
q.Set("pattern", pattern)
|
|
q.Set("books.name", book)
|
|
q.Set("format", "xml")
|
|
q.Set("pageLength", strconv.Itoa(limit))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("kiwix search: http %d", resp.StatusCode)
|
|
}
|
|
return ParseSearchRSS(resp.Body)
|
|
}
|
|
|
|
// articleMaxBytes — how much of an article HTML document is read before the
|
|
// rest is discarded. A maxi Wikipedia page is around 100KB; 512KB is slack for
|
|
// the long ones and a hard stop against a ZIM entry that is really a binary.
|
|
const articleMaxBytes = 512 << 10
|
|
|
|
// Article fetches one article by the Path a search hit carries and returns it
|
|
// as extracted plain text, capped at maxRunes (0 ⇒ crawl.DefaultMaxRunes).
|
|
//
|
|
// This exists because the search snippet is not usable context. Kiwix builds
|
|
// the snippet from wherever the keyword matched, and on a Wikipedia ZIM that is
|
|
// routinely the "see also" navigation box at the foot of the page: a search for
|
|
// "photosynthesis" comes back with "Ecological economics Ecological footprint
|
|
// Ecological forecasting …" and a model handed that writes nothing worth
|
|
// hearing. The lead paragraphs are at the top of the document, so truncating an
|
|
// article from the front gets the definition the snippet was supposed to be.
|
|
//
|
|
// Nothing here reaches the internet: the path is resolved against the same
|
|
// server the search went to.
|
|
func (c *Client) Article(ctx context.Context, path string, maxRunes int) (crawl.Page, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return crawl.Page{}, fmt.Errorf("kiwix article: empty path")
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
u := c.base + path
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return crawl.Page{}, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return crawl.Page{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return crawl.Page{}, fmt.Errorf("kiwix article %s: http %d", path, resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, articleMaxBytes))
|
|
if err != nil {
|
|
return crawl.Page{}, err
|
|
}
|
|
return crawl.Extract(u, body, maxRunes), nil
|
|
}
|
|
|
|
// rss mirrors just the bits of the RSS 2.0 reply we use.
|
|
type rss struct {
|
|
Items []struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
// innerxml keeps the <b> match markers so we can strip them ourselves.
|
|
Description struct {
|
|
Inner string `xml:",innerxml"`
|
|
} `xml:"description"`
|
|
WordCount string `xml:"wordCount"`
|
|
} `xml:"channel>item"`
|
|
}
|
|
|
|
var tagRE = regexp.MustCompile(`<[^>]*>`)
|
|
|
|
// ParseSearchRSS turns a Kiwix search reply into results. Exported so the parser
|
|
// is testable from a captured response, with no server running.
|
|
func ParseSearchRSS(r io.Reader) ([]Result, error) {
|
|
var doc rss
|
|
if err := xml.NewDecoder(r).Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("kiwix search: bad xml: %w", err)
|
|
}
|
|
out := make([]Result, 0, len(doc.Items))
|
|
for _, it := range doc.Items {
|
|
n, _ := strconv.Atoi(strings.ReplaceAll(it.WordCount, ",", ""))
|
|
out = append(out, Result{
|
|
Title: strings.TrimSpace(it.Title),
|
|
Path: strings.TrimSpace(it.Link),
|
|
Snippet: plainText(it.Description.Inner),
|
|
WordCount: n,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// plainText drops markup and decodes entities, leaving text a model can read.
|
|
func plainText(s string) string {
|
|
s = tagRE.ReplaceAllString(s, "")
|
|
s = html.UnescapeString(s)
|
|
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
|
}
|