diff --git a/README.md b/README.md index 73a1184..0cd577c 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,28 @@ # OpenSERP -[![Go Reference](https://pkg.go.dev/badge/github/karust/openserp?style=for-the-badge)](https://pkg.go.dev/github.com/karust/openserp) +[![Go Reference](https://pkg.go.dev/badge/github.com/karust/openserp.svg)](https://pkg.go.dev/github.com/karust/openserp) [![release](https://img.shields.io/github/v/release/karust/openserp)](https://github.com/karust/openserp/releases) [![Docker Pulls](https://img.shields.io/docker/v/karust/openserp)](https://hub.docker.com/r/karust/openserp) [![CI](https://github.com/karust/openserp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/karust/openserp/actions/workflows/ci.yml) +[![Telegram](https://img.shields.io/badge/Telegram-openserp__cloud-26A5E4?logo=telegram&logoColor=white)](https://t.me/openserp_cloud) -**OpenSERP** is a free, open-source SERP API and CLI for live search data from **Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia**. +**OpenSERP** is a free, open-source SERP API and CLI for Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia. -Use it as a search tool for **LLMs, agents, and RAG pipelines**, or as a scraper backend for **SEO rank tracking across Google, Yandex, Baidu, and more**. It is especially useful when your workflow needs RU/CN web coverage instead of another Google-only API. +No API keys, no per-search billing: one command gives you live, structured search results on localhost - including engines the paid APIs don't cover. Use it as a search tool for LLMs and agents, or as a backend for SEO rank tracking. If you'd rather not run infrastructure, there is a [hosted version](https://openserp.org/cloud) with the same API. -Run it locally, self-host it, or use the [cloud version](https://openserp.org/cloud) when you want the same public API shape without operating the server. Cloud is also the simplest way to support OpenSERP: it is where the technology gets tested at scale, and that work flows straight back into this open-source project. +![OpenSERP CLI demo](./docs/demo.gif) ## Features -- 🔍 **Multi-engine** - dedicated endpoints for Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia, with stable JSON for SEO rank pipelines -- 🌐 **Megasearch** - `/mega/search` runs one query across every selected engine, then merges and dedupes results -- 📄 **URL extraction** - return search results plus clean markdown/text target-page content in one call, for grounding and automation -- ✨ **SERP features** - AI summaries, answer boxes, people-also-ask, and related searches in a response -- 🖼 **Images** - image search is also available -- 🎯 **Advanced filters** - language, date range, file type, and site queries -- 📝 **Data formats** - JSON, Markdown, Text, NdJSON response formats -- 🌍 **Configurable** - proxy, cache, and resilient mode -- 🐳 **Docker-ready** - local and container deployment +- Dedicated endpoints for six engines, same JSON schema across all of them +- **Megasearch** - one query across several engines at once, merged and deduped +- **URL extraction** - search results plus clean markdown of the target pages in one call +- SERP features: AI summaries, answer boxes, people-also-ask, related searches +- Image search, filters (language, date range, file type, site), **JSON/Markdown/Text/NdJSON** output +- Proxies, cache, resilient mode, prebuilt Docker images -## ⚡ Quick Start +## Quick Start ### Docker @@ -58,6 +56,7 @@ go build -o openserp . ### First request ```sh +# mode=any returns the first engine that responds curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust&extract=1&mode=any" ``` @@ -86,7 +85,7 @@ curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust& "title": "The Go Programming Language", "url": "https://go.dev/", "display_url": "go.dev", - "snippet": "Get Started Playground Tour Stack Overflow Help Packages Standard Library About Go Packages About Download Blog Issue Tracker Release Notes Brand Guidelines Code of Conduct Connect …", + "snippet": "Get Started Playground Tour Stack Overflow Help Packages Standard Library …", "domain": "go.dev", "favicon": "https://go.dev/favicon.ico", "position": { @@ -101,7 +100,7 @@ curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust& "extracted": { "title": "Build simple, secure, scalable systems with Go", "format": "markdown", - "content": "## Build simple, secure, scalable systems with Go\n\n![Go Gopher climbing a ladder.](https://go.dev/images/gophers/ladder.svg)\n\n- “At the time, no single team member knew Go, but **within a month, everyone was writing in Go** and we were building out the endpoints. It was the flexibility, how easy it was to use, and the really cool concept behind Go (how Go handles native concurrency, garbage collection, and of course safety+speed.) that helped engage us during the build. Also, who can beat that cute mascot!”\n ........", + "content": "## Build simple, secure, scalable systems with Go\n\n![Go Gopher climbing a ladder.](https://go.dev/images/gophers/ladder.svg)\n\n- “At the time, no single team member knew Go, but **within a month, everyone was writing in Go** and we were building out the endpoints. ........", "mode_used": "fast", "fetched_at": "2026-06-16T00:06:56Z" } @@ -113,7 +112,7 @@ curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust& "title": "Go (programming language) - Wikipedia", "url": "https://en.wikipedia.org/wiki/Go_(programming_language)", "display_url": "en.wikipedia.org › wiki › Go_(programming_language)", - "snippet": "In Go's package system, each package has a path (e.g., \"compress/bzip2\" or \"golang.org/x/net/html\") and a name (e.g., bzip2 or html). By default other packages' definitions must always be prefixed with …", + "snippet": "In Go's package system, each package has a path (e.g., \"compress/bzip2\" or \"golang.org/x/net/html\") and a name (e.g., bzip2 or html). …", "domain": "en.wikipedia.org", "favicon": "https://en.wikipedia.org/favicon.ico", "position": { @@ -162,24 +161,6 @@ curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust& -## Deployment Options - -- **Self-hosted (this repo)** - free, MIT-licensed, with full control over runtime, proxies, cache, and scaling. -- **[OpenSERP Cloud](https://openserp.org/cloud)** - the same public API, run and maintained for you by the people who build OpenSERP. - -Same endpoints, same response schema, and client code migrates either direction, so you are never locked in. - -If OpenSERP is useful to you and you would rather not run the infrastructure yourself, using Cloud (or topping up an account) is a direct way to support the project. It is where the technology gets exercised against real traffic at scale, which is how the engines get hardened, new features get tested, and the open-source core keeps improving. Every request you send through Cloud helps fund and shape what lands back in this repository. - -## API Docs - -Once the server is running, the interactive docs are available locally: - -- Swagger UI: `http://127.0.0.1:7000/docs` -- OpenAPI YAML: `http://127.0.0.1:7000/openapi.yaml` - -To browse the spec without running the server, see [docs/openapi.yaml](./docs/openapi.yaml). For a higher-level overview of how OpenSERP works internally, see the [architecture docs](https://openserp.org/docs/architecture/). - ## SDKs & Examples Official client packages. Each works against your self-hosted server (set `baseUrl`) or the [hosted API](https://openserp.org/cloud) (set `apiKey`): @@ -223,6 +204,8 @@ Megasearch: curl "http://127.0.0.1:7000/mega/search?text=golang&limit=10" ``` +`/mega/search` returns the same envelope as engine endpoints plus `clusters`: results are deduplicated by normalized URL, and clusters keep the per-engine occurrences and ranks. + | Mode | Best for | Behavior | | ---------- | ------------------------------------ | ---------------------------------------------- | | `balanced` | Most multi-engine SERP workflows | Queries engines in parallel and merges results | @@ -266,43 +249,57 @@ curl "http://127.0.0.1:7000/extract?url=https://example.com&mode=auto" # Return clean page markdown curl "http://127.0.0.1:7000/extract?url=https://example.com&format=markdown" +# Extract several URLs at once - returns a bare [{page_content, metadata}] array +# (Open WebUI external loader compatible); failed URLs become items with metadata.error +curl -X POST "http://127.0.0.1:7000/extract/batch" \ + -H "Content-Type: application/json" \ + -d '{"urls":["https://example.com","https://go.dev"],"mode":"fast"}' + # Embed extracted content under the top search results curl "http://127.0.0.1:7000/google/search?text=llm+observability&extract=2&format=markdown" ``` -## 🖥 CLI Search +## CLI Search No server required - query an engine straight from the terminal. The CLI shares the same engines, formats, and filters as the API. ```sh -openserp search duckduckgo "free open source serp" --format markdown +openserp search ecosia "weather in london" --format markdown ```
CLI output and more examples ```markdown -# Search results for "free open source serp" +# Search results for "weather in london" -**Query:** free open source serp - **Engines:** duckduckgo - **Took:** 1794ms +**Query:** weather in london - **Engines:** ecosia - **Took:** 866ms ## Results -### 1. OpenSERP: Open-Source, Self-Hosted & Free SERP API +### 1. London - BBC Weather -**openserp.org** - organic +**bbc.com › weather › 2643743** - organic -OpenSERP is a free, open-source and self-hosted SERP API for Google, Bing, Yandex, Baidu, DuckDuckGo and Ecosia, with an optional managed Cloud path. +Latest forecast for London ... Tonight will continue dry, and there will be mainly clear skies. Just a few patches of cloud drifting in from the north at times. --> https://openserp.org/ +-> https://www.bbc.com/weather/2643743 -### 2. GitHub - karust/openserp: Open-source SERP API for AI, SEO & automation ... +### 2. London (Greater London) weather - Met Office -**github.com › karust › openserp** - organic +**weather.metoffice.gov.uk › forecast › gcpvj0v07** - organic -OpenSERP is a free, open-source API and CLI for accessing normalized search engine results from Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia. Run it locally, self-host it, or use the optional hosted API when you do not want to manage infrastructure. +Remaining warm with light winds and dry. Possibly cloudy at times Monday and Tuesday, then Wednesday sunnier conditions are likely. --> https://github.com/karust/openserp +-> https://weather.metoffice.gov.uk/forecast/gcpvj0v07 + +### 3. London, London, United Kingdom Weather Forecast + +**accuweather.com › en › gb › london › ec4a-2 › wea…** - organic + +London, London, United Kingdom Weather Forecast, with current conditions, wind, air quality, and what to expect for the next 3 days. + +-> https://www.accuweather.com/en/gb/london/ec4a-2/weather-forecast/328328 ``` More CLI examples: @@ -321,14 +318,15 @@ openserp search bing "release notes" --site github.com --format ndjson openserp search google "llm observability" --extract 2 --format markdown # Browserless (raw HTTP) mode through a proxy -openserp search duckduckgo "free open source serp" --raw --proxy http://user:pass@127.0.0.1:8080 +# (raw mode: google, yandex, baidu, ecosia) +openserp search ecosia "weather in london" --raw --proxy http://user:pass@127.0.0.1:8080 ```
Run `openserp search --help` for the full flag list. Engine names: `google`, `yandex`, `baidu`, `bing`, `duckduckgo`, `ecosia`. -## 🔍 Query Parameters +## Query Parameters Common parameters: @@ -353,132 +351,7 @@ Engine-specific parameters: | `filter` | `google` | Duplicate filter: `true` hides similar results, `false` includes them. | | `features` | browser `Search` | Populate `serp_features[]` from the live page. Defaults to `true`. | -## Search Response Example - -
-Search response example - -```json -{ - "query": { - "text": "golang", - "engines_requested": ["google"] - }, - "meta": { - "request_id": "019dc6c1-da45-706e-a57c-d671fa2862ee", - "requested_at": "2026-04-25T22:27:52Z", - "took_ms": 6410, - "engines_failed": [], - "version": "2.1" - }, - "results": [ - { - "id": "s_78341aa47c336101", - "rank": 1, - "type": "organic", - "title": "Documentation - The Go Programming Language", - "url": "https://go.dev/doc/", - "display_url": "go.dev > doc", - "snippet": "Official Go documentation, tutorials, references, and release notes.", - "domain": "go.dev", - "favicon": "https://go.dev/favicon.ico", - "position": { - "absolute": 1 - }, - "engine": "google", - "domain_info": { - "tld": "dev", - "sld": "go", - "category": "" - } - } - ], - "pagination": { - "page": 1, - "has_more": true, - "next_start": 25 - } -} -``` - -
- -## Mega Response Notes - -`/mega/search` returns the same envelope plus `clusters`. Results are deduplicated by normalized URL; clusters keep the per-engine occurrences. - -
-Cluster example - -```json -{ - "id": "c_a1b2c3d4e5f6a1b2", - "canonical_url": "https://go.dev/", - "domain": "go.dev", - "title": "The Go Programming Language", - "occurrences": [ - { "engine": "google", "rank": 1, "result_id": "s_78341aa47c336101" }, - { "engine": "bing", "rank": 2, "result_id": "s_20f9f15f0c3d9f6d" } - ], - "engines_count": 2, - "best_rank": 1, - "score": 0.75 -} -``` - -
- -## Image Response Example - -
-Image result example - -```json -{ - "id": "i_a1b2c3d4e5f6a1b2", - "rank": 1, - "type": "image", - "title": "Go Gopher Logo", - "image": { - "url": "https://example.com/images/go-logo.png", - "thumbnail": "https://example.com/images/go-logo-thumb.png", - "width": 1200, - "height": 800 - }, - "source": { - "page_url": "https://go.dev/brand/", - "domain": "go.dev" - }, - "engine": "bing" -} -``` - -
- -## Error Responses - -`400 Bad Request`: - -```json -{ - "error": "bad_request", - "code": 400, - "message": "EMPTY_QUERY: query cannot be empty: provide text, site, or file parameter", - "reason": "EMPTY_QUERY" -} -``` - -`503 Service Unavailable`: - -```json -{ - "error": "service_unavailable", - "code": 503, - "message": "captcha found, please stop sending requests for a while: captcha detected" -} -``` - -## 🌍 Proxy Support +## Proxy Support OpenSERP supports HTTP and SOCKS5 proxies. @@ -491,18 +364,21 @@ Simple global proxy: Advanced proxy configuration is available in [config.yaml](./config.yaml). You can enable tagged proxy pools and per-request override via `X-Use-Proxy: ` or `X-Use-Proxy: direct`. -A [managed API](https://openserp.org/cloud) is also available for teams that do not want to operate infrastructure. +## API Docs -## Health & Stats +Once the server is running, the interactive docs are available locally: -```bash -curl -i "http://127.0.0.1:7000/health" -curl "http://127.0.0.1:7000/ready" -curl "http://127.0.0.1:7000/stats" -curl "http://127.0.0.1:7000/stats/cache" -curl "http://127.0.0.1:7000/stats/proxy" -curl "http://127.0.0.1:7000/stats/cb" -``` +- Swagger UI: `http://127.0.0.1:7000/docs` - full schemas, error shapes, and the `/health`, `/ready`, `/stats/*` endpoints +- OpenAPI YAML: `http://127.0.0.1:7000/openapi.yaml` + +To browse the spec without running the server, see [docs/openapi.yaml](./docs/openapi.yaml). For a higher-level overview of how OpenSERP works internally, see the [architecture docs](https://openserp.org/docs/architecture/). + +## Self-Hosted or Cloud + +- **Self-hosted (this repo)** - free, MIT-licensed, full control over runtime, proxies, cache, and scaling. +- **[OpenSERP Cloud](https://openserp.org/cloud)** - same endpoints and response schema, no infrastructure to run. + +Client code migrates in either direction, so you are never locked in. ## License @@ -516,6 +392,6 @@ Contributions are welcome. See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md). - [GitHub Issues](https://github.com/karust/openserp/issues) - bugs, feature ideas, and reproducible issues. - [feedback@openserp.org](mailto:feedback@openserp.org) - private notes, longer feedback, or anything that does not fit GitHub Issues. -- [Telegram Channel](https://t.me/+RJEKspw3mUlhZDMy) - OpenSERP news, release notes, and project updates. +- [Telegram](https://t.me/openserp_cloud) - OpenSERP news, release notes, and project updates. > OpenSERP is free and open-source. Only links listed in this repository and on [openserp.org](https://openserp.org) are associated with the project. diff --git a/cmd/root.go b/cmd/root.go index 59c4182..7104116 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,7 +17,7 @@ import ( ) const ( - version = "0.8.10" + version = "0.8.11" defaultConfigFilename = "config" envPrefix = "OPENSERP" ) diff --git a/core/server_extract.go b/core/server_extract.go index ac30944..c1bb9ba 100644 --- a/core/server_extract.go +++ b/core/server_extract.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -20,9 +21,10 @@ type extractPayload struct { Mode string `json:"mode"` // Clean defaults to true (article-only). Pointer so we can tell "omitted" // (use default) from an explicit false (full-page extraction). - Clean *bool `json:"clean"` - UseLLMSTxt bool `json:"use_llms_txt"` - MinRunes int `json:"min_runes"` + Clean *bool `json:"clean"` + UseLLMSTxt bool `json:"use_llms_txt"` + MinRunes int `json:"min_runes"` + Lang string `json:"lang"` } func (s *Server) handleExtract(c *fiber.Ctx) error { @@ -57,11 +59,10 @@ func (s *Server) handleExtract(c *fiber.Ctx) error { return sendExtractResult(c, format, result) } -func (s *Server) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (extractpkg.ExtractRequest, error) { - var body extractPayload - if len(c.Body()) > 0 { - _ = c.BodyParser(&body) - } +// baseExtractRequest builds the URL-independent part of an extract request +// from proxy headers, query params, and the parsed body. Shared by /extract +// and /extract/batch so both accept the same knobs. +func (s *Server) baseExtractRequest(c *fiber.Ctx, body extractPayload, cfg extractpkg.Config) (extractpkg.ExtractRequest, error) { proxyOverride, err := NormalizeProxyRequestOverride(c.Get("X-Use-Proxy")) if err != nil { return extractpkg.ExtractRequest{}, errInvalidParam(fmt.Sprintf("X-Use-Proxy: %v", err)) @@ -78,7 +79,12 @@ func (s *Server) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (e if err := s.validateRequestProxyURL(&q); err != nil { return extractpkg.ExtractRequest{}, err } - mode := firstNonEmpty(c.Query("mode"), body.Mode, cfg.DefaultMode) + mode := extractpkg.Mode(strings.ToLower(firstNonEmpty(c.Query("mode"), body.Mode, cfg.DefaultMode))) + switch mode { + case extractpkg.ModeAuto, extractpkg.ModeFast, extractpkg.ModeRendered: + default: + return extractpkg.ExtractRequest{}, errInvalidParam("mode must be one of auto, fast, rendered") + } // Default clean=true (article-only). FullPage is the inverse: full-readable-body // extraction, opted in via clean=false on the query string or body. bodyClean := true @@ -90,15 +96,10 @@ func (s *Server) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (e if err != nil { return extractpkg.ExtractRequest{}, errInvalidParam("min_runes must be a non-negative integer") } - targetURL := extractpkg.NormalizeURL(strings.TrimSpace(firstNonEmpty(c.Query("url"), body.URL))) - if err := validateExtractTargetURL(c.UserContext(), targetURL, cfg.AllowPrivateNetworks); err != nil { - return extractpkg.ExtractRequest{}, errInvalidParam(err.Error()) - } return extractpkg.ExtractRequest{ - URL: targetURL, - Mode: extractpkg.Mode(mode), + Mode: mode, ProxyURL: proxyURL, - LangCode: strings.TrimSpace(c.Query("lang")), + LangCode: firstNonEmpty(body.Lang, c.Query("lang")), Timeout: cfg.Timeout, MaxBytes: cfg.MaxBytes, FullPage: !clean, @@ -107,6 +108,22 @@ func (s *Server) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (e }, nil } +func (s *Server) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (extractpkg.ExtractRequest, error) { + var body extractPayload + if len(c.Body()) > 0 { + _ = c.BodyParser(&body) + } + req, err := s.baseExtractRequest(c, body, cfg) + if err != nil { + return extractpkg.ExtractRequest{}, err + } + req.URL = extractpkg.NormalizeURL(strings.TrimSpace(firstNonEmpty(c.Query("url"), body.URL))) + if err := validateExtractTargetURL(c.UserContext(), req.URL, cfg.AllowPrivateNetworks); err != nil { + return extractpkg.ExtractRequest{}, errInvalidParam(err.Error()) + } + return req, nil +} + func (s *Server) newExtractor() extractpkg.Extractor { return extractpkg.Extractor{ RawFetch: s.rawExtractFetch, @@ -422,12 +439,13 @@ func SanitizeExtractError(err error) string { const maxBatchExtractURLs = 20 type batchExtractPayload struct { + extractPayload URLs []string `json:"urls"` - Mode string `json:"mode"` } -// batchExtractItem is a response item for a single extracted URL, using -// page_content/metadata keys +// batchExtractItem is one entry of the bare-array /extract/batch response. +// The {page_content, metadata} shape is the Open WebUI ExternalWebLoader +// contract - do not wrap it in the Envelope. type batchExtractItem struct { PageContent string `json:"page_content"` Metadata map[string]string `json:"metadata"` @@ -444,49 +462,34 @@ func (s *Server) handleBatchExtract(c *fiber.Ctx) error { return &APIError{HTTPStatus: fiber.StatusNotFound, ErrorCode: "not_found", Message: "Extraction is disabled"} } - var body batchExtractPayload if len(c.Body()) == 0 { return errInvalidParam("request body is required") } + var body batchExtractPayload if err := c.BodyParser(&body); err != nil { return errInvalidParam("invalid JSON body") } - - // Deduplicate and normalize URLs. - seen := make(map[string]struct{}, len(body.URLs)) - var urls []string - for _, raw := range body.URLs { - u := extractpkg.NormalizeURL(strings.TrimSpace(raw)) - if u == "" { - continue - } - if _, dup := seen[u]; dup { - continue - } - seen[u] = struct{}{} - urls = append(urls, u) - } + urls := dedupeBatchURLs(body.URLs) if len(urls) == 0 { return errInvalidParam("urls array is required and must contain at least one valid URL") } if len(urls) > maxBatchExtractURLs { return errInvalidParam(fmt.Sprintf("urls array exceeds maximum of %d", maxBatchExtractURLs)) } - - // Validate all URLs upfront. - for _, u := range urls { - if err := validateExtractTargetURL(c.UserContext(), u, cfg.AllowPrivateNetworks); err != nil { - return &APIError{HTTPStatus: fiber.StatusBadRequest, ErrorCode: "invalid_extract_url", Message: err.Error()} - } + baseReq, err := s.baseExtractRequest(c, body.extractPayload, cfg) + if err != nil { + return err } - mode := firstNonEmpty(body.Mode, cfg.DefaultMode) + // Target URLs are validated in the fetch path, inside the workers - a bad + // URL becomes an error item instead of failing the whole batch (Open WebUI + // drops every doc on a non-2xx). 400 is reserved for malformed requests. extractor := s.newExtractor() results := make([]batchExtractItem, len(urls)) - // Concurrent extraction with bounded parallelism (same pattern as - // EnrichEnvelopeWithExtraction). - ctx, cancel := context.WithTimeout(c.UserContext(), cfg.BatchTimeout(len(urls))) + // Bounded parallelism plus an aggregate deadline, same pattern as + // EnrichEnvelopeWithExtraction. + ctx, cancel := context.WithTimeout(requestCtx, cfg.BatchTimeout(len(urls))) defer cancel() sem := make(chan struct{}, cfg.MaxConcurrent) @@ -497,50 +500,58 @@ func (s *Server) handleBatchExtract(c *fiber.Ctx) error { go func(idx int, url string) { defer wg.Done() defer func() { <-sem }() - - if err := ctx.Err(); err != nil { - results[idx] = batchExtractItem{ - PageContent: "", - Metadata: map[string]string{"source": url, "error": "batch timeout"}, - } - return - } - - req := extractpkg.ExtractRequest{ - URL: url, - Mode: extractpkg.Mode(mode), - ProxyURL: "", - LangCode: strings.TrimSpace(c.Query("lang")), - Timeout: cfg.Timeout, - MaxBytes: cfg.MaxBytes, - } - result, err := extractor.Extract(ctx, req) - if err != nil { - results[idx] = batchExtractItem{ - PageContent: "", - Metadata: map[string]string{ - "source": url, - "error": SanitizeExtractError(err), - }, - } - return - } - results[idx] = batchExtractItem{ - PageContent: result.Markdown, - Metadata: map[string]string{ - "source": url, - "title": result.Title, - "description": result.Description, - "lang": result.Lang, - "canonical": result.Canonical, - "mode_used": result.Meta.ModeUsed, - "fetched_at": result.Meta.FetchedAt, - "took_ms": fmt.Sprintf("%d", result.Meta.TookMs), - }, - } + results[idx] = batchExtractOne(ctx, extractor, baseReq, url) }(i, u) } wg.Wait() return c.JSON(results) } + +// dedupeBatchURLs normalizes, drops empties, and keeps first occurrence order. +func dedupeBatchURLs(raw []string) []string { + seen := make(map[string]struct{}, len(raw)) + var urls []string + for _, r := range raw { + u := extractpkg.NormalizeURL(strings.TrimSpace(r)) + if u == "" { + continue + } + if _, dup := seen[u]; dup { + continue + } + seen[u] = struct{}{} + urls = append(urls, u) + } + return urls +} + +// batchExtractOne extracts a single URL, folding failures into the item. +func batchExtractOne(ctx context.Context, extractor extractpkg.Extractor, req extractpkg.ExtractRequest, url string) batchExtractItem { + fail := func(err error) batchExtractItem { + WithRequest(ctx).WithError(err).WithField("url", url).Warn("Batch extract failed") + return batchExtractItem{Metadata: map[string]string{"source": url, "error": SanitizeExtractError(err)}} + } + // Skip the fetch once the batch budget is spent. + if ctx.Err() != nil { + return fail(errors.New("batch timeout")) + } + req.URL = url + result, err := extractor.Extract(ctx, req) + if err != nil { + return fail(err) + } + return batchExtractItem{ + PageContent: result.Markdown, + Metadata: map[string]string{ + "source": url, + "title": result.Title, + "description": result.Description, + "lang": result.Lang, + "canonical": result.Canonical, + "mode_used": result.Meta.ModeUsed, + "fetched_at": result.Meta.FetchedAt, + "took_ms": strconv.FormatInt(result.Meta.TookMs, 10), + }, + } +} diff --git a/core/server_extract_test.go b/core/server_extract_test.go index 8baad7f..4698ecc 100644 --- a/core/server_extract_test.go +++ b/core/server_extract_test.go @@ -6,8 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" - "reflect" "strings" + "sync/atomic" "testing" "time" @@ -115,61 +115,48 @@ func TestValidateExtractTargetURLNormalizesBarePublicIP(t *testing.T) { func TestBatchExtractSingleURL(t *testing.T) { target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`

Test Page

This is a test page with enough content to pass the minimum runes threshold for extraction in batch mode.

`)) })) defer target.Close() - opts := DefaultServerOptions() - opts.Extract = extractpkg.Config{ - Enabled: true, - DefaultMode: string(extractpkg.ModeFast), - Timeout: time.Second, - MaxBytes: 256 * 1024, - MaxConcurrent: 2, - AllowPrivateNetworks: true, - } - s := NewServerWithOptions("127.0.0.1", 0, opts) - - body := fmt.Sprintf(`{"urls":["%s"]}`, target.URL) - req, err := http.NewRequest(http.MethodPost, "/extract/batch", strings.NewReader(body)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := s.app.Test(req) - if err != nil { - t.Fatal(err) - } + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, fmt.Sprintf(`{"urls":["%s"]}`, target.URL)) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) } - - var results []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - t.Fatalf("decode response: %v", err) + items := decodeBatchItems(t, resp) + if len(items) != 1 { + t.Fatalf("items count = %d, want 1", len(items)) } - if len(results) != 1 { - t.Fatalf("results count = %d, want 1", len(results)) + if items[0].PageContent == "" { + t.Fatal("expected non-empty page_content") } - if _, ok := results[0]["page_content"]; !ok { - t.Fatalf("expected page_content key, got keys: %v", reflect.ValueOf(results[0]).MapKeys()) - } - if _, ok := results[0]["metadata"]; !ok { - t.Fatalf("expected metadata key, got keys: %v", reflect.ValueOf(results[0]).MapKeys()) + if items[0].Metadata["title"] != "Test Page" { + t.Fatalf("metadata = %v, want title 'Test Page'", items[0].Metadata) } } func TestBatchExtractHandlesMultipleURLs(t *testing.T) { target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`

Multi

Page with sufficient content for batch extraction test that verifies concurrent processing works correctly.

`)) })) defer target.Close() + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, fmt.Sprintf(`{"urls":["%s/1","%s/2","%s/3"]}`, target.URL, target.URL, target.URL)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if items := decodeBatchItems(t, resp); len(items) != 3 { + t.Fatalf("items count = %d, want 3", len(items)) + } +} + +func batchExtractTestServer(allowPrivate bool) *Server { opts := DefaultServerOptions() opts.Extract = extractpkg.Config{ Enabled: true, @@ -177,51 +164,163 @@ func TestBatchExtractHandlesMultipleURLs(t *testing.T) { Timeout: time.Second, MaxBytes: 256 * 1024, MaxConcurrent: 2, - AllowPrivateNetworks: true, + AllowPrivateNetworks: allowPrivate, } - s := NewServerWithOptions("127.0.0.1", 0, opts) + return NewServerWithOptions("127.0.0.1", 0, opts) +} - body := fmt.Sprintf(`{"urls":["%s/1","%s/2","%s/3"]}`, target.URL, target.URL, target.URL) +func postBatchExtract(t *testing.T, s *Server, body string, header ...[2]string) *http.Response { + t.Helper() req, err := http.NewRequest(http.MethodPost, "/extract/batch", strings.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") - + for _, h := range header { + req.Header.Set(h[0], h[1]) + } resp, err := s.app.Test(req) if err != nil { t.Fatal(err) } + return resp +} + +func decodeBatchItems(t *testing.T, resp *http.Response) []batchExtractItem { + t.Helper() + var items []batchExtractItem + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + t.Fatalf("decode response: %v", err) + } + return items +} + +func TestBatchExtractReturnsPerURLErrors(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`

Good page

Enough body text to extract something meaningful from this page in batch mode.

`)) + })) + defer target.Close() + + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, fmt.Sprintf(`{"urls":["%s","ftp://example.com/x"]}`, target.URL)) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) } - - var results []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { - t.Fatalf("decode response: %v", err) + items := decodeBatchItems(t, resp) + if len(items) != 2 { + t.Fatalf("items count = %d, want 2", len(items)) } - if len(results) != 3 { - t.Fatalf("results count = %d, want 3", len(results)) + if !strings.Contains(items[0].PageContent, "Good page") || items[0].Metadata["error"] != "" { + t.Fatalf("first item = %+v, want successful extraction", items[0]) + } + if items[1].PageContent != "" || items[1].Metadata["error"] == "" { + t.Fatalf("second item = %+v, want error item", items[1]) + } + if items[1].Metadata["source"] != "ftp://example.com/x" { + t.Fatalf("second item source = %q", items[1].Metadata["source"]) + } +} + +func TestBatchExtractKeepsPrivateNetworkGuardPerItem(t *testing.T) { + s := batchExtractTestServer(false) + resp := postBatchExtract(t, s, `{"urls":["http://169.254.169.254/latest/meta-data/"]}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + items := decodeBatchItems(t, resp) + if len(items) != 1 { + t.Fatalf("items count = %d, want 1", len(items)) + } + if items[0].PageContent != "" || !strings.Contains(items[0].Metadata["error"], "not allowed") { + t.Fatalf("item = %+v, want blocked-target error", items[0]) + } +} + +func TestBatchExtractDedupesURLs(t *testing.T) { + var hits int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`

Dedup

Some body text long enough for the extractor to produce markdown content here.

`)) + })) + defer target.Close() + + s := batchExtractTestServer(true) + body := fmt.Sprintf(`{"urls":["%s/a","%s/a","%s/b"]}`, target.URL, target.URL, target.URL) + resp := postBatchExtract(t, s, body) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if items := decodeBatchItems(t, resp); len(items) != 2 { + t.Fatalf("items count = %d, want 2", len(items)) + } + if got := atomic.LoadInt32(&hits); got != 2 { + t.Fatalf("target hits = %d, want 2", got) + } +} + +func TestBatchExtractDisabledReturns404(t *testing.T) { + opts := DefaultServerOptions() + opts.Extract = extractpkg.DefaultConfig() + opts.Extract.Enabled = false + s := NewServerWithOptions("127.0.0.1", 0, opts) + + resp := postBatchExtract(t, s, `{"urls":["https://example.com"]}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound) + } +} + +func TestBatchExtractRejectsInvalidMode(t *testing.T) { + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, `{"urls":["https://example.com"],"mode":"turbo"}`) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } +} + +func TestBatchExtractRejectsBadProxyHeader(t *testing.T) { + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, `{"urls":["https://example.com"]}`, [2]string{"X-Proxy-URL", "not-a-proxy"}) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } +} + +func TestBatchExtractBodyLangReachesFetch(t *testing.T) { + var acceptLanguage string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + acceptLanguage = r.Header.Get("Accept-Language") + _, _ = w.Write([]byte(`

Lang

Body text long enough to satisfy extraction thresholds for this test case.

`)) + })) + defer target.Close() + + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, fmt.Sprintf(`{"urls":["%s"],"lang":"de"}`, target.URL)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if !strings.HasPrefix(acceptLanguage, "de") { + t.Fatalf("Accept-Language = %q, want de-prefixed", acceptLanguage) } } func TestBatchExtractRejectsEmptyURLs(t *testing.T) { - opts := DefaultServerOptions() - opts.Extract = extractpkg.DefaultConfig() - s := NewServerWithOptions("127.0.0.1", 0, opts) - - req, err := http.NewRequest(http.MethodPost, "/extract/batch", strings.NewReader(`{"urls":[]}`)) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := s.app.Test(req) - if err != nil { - t.Fatal(err) - } + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, `{"urls":[]}`) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -230,27 +329,15 @@ func TestBatchExtractRejectsEmptyURLs(t *testing.T) { } func TestBatchExtractRejectsURLsOverLimit(t *testing.T) { - opts := DefaultServerOptions() - opts.Extract = extractpkg.DefaultConfig() - s := NewServerWithOptions("127.0.0.1", 0, opts) - - // Build 21 URLs (limit is 20) + // 21 URLs, limit is 20 urls := make([]string, 21) - for i := 0; i < 21; i++ { + for i := range urls { urls[i] = fmt.Sprintf("https://example.com/%d", i) } body, _ := json.Marshal(map[string][]string{"urls": urls}) - req, err := http.NewRequest(http.MethodPost, "/extract/batch", strings.NewReader(string(body))) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := s.app.Test(req) - if err != nil { - t.Fatal(err) - } + s := batchExtractTestServer(true) + resp := postBatchExtract(t, s, string(body)) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..ddc1db8 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9e57353..2ce5097 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -536,6 +536,11 @@ paths: extracted-text runes than this, escalate to a browser render. `0` (default) uses the built-in floor. Ignored in `fast` and `rendered` modes. + lang: + type: string + description: > + Language hint sent as `Accept-Language` on the target fetch. + The `lang` query parameter works as a fallback. responses: "200": description: Extracted URL content @@ -550,13 +555,19 @@ paths: /extract/batch: post: - tags: [Extract] + tags: [Search] operationId: extractBatch summary: Extract content from multiple URLs description: > - Accepts an array of URLs and returns extracted page content for each - one. Each result contains `page_content` (markdown) and `metadata` - (title, source, lang, etc.). + Accepts up to 20 URLs (deduplicated) and returns a bare JSON array of + `{page_content, metadata}` items - the shape Open WebUI's external web + loader expects. A URL that fails validation or extraction yields an + item with `metadata.error` and an empty `page_content` instead of + failing the batch. `400` is reserved for malformed requests: empty or + oversized `urls`, unknown `mode`, invalid proxy headers. + parameters: + - $ref: "#/components/parameters/UseProxyHeader" + - $ref: "#/components/parameters/ProxyURLHeader" requestBody: required: true content: @@ -565,7 +576,7 @@ paths: $ref: "#/components/schemas/BatchExtractRequest" responses: "200": - description: Batch extraction results + description: Batch extraction results, one item per unique URL content: application/json: schema: @@ -2076,20 +2087,44 @@ components: items: type: string maxItems: 20 - description: URLs to extract content from (max 20) + description: URLs to extract content from (max 20 after deduplication) mode: type: string enum: [auto, fast, rendered] - description: "Extraction mode (default: auto)" + default: auto + description: Extraction mode + clean: + type: boolean + default: true + description: > + Article-only extraction (default). Set `false` for + whole-readable-body extraction. + use_llms_txt: + type: boolean + default: false + description: > + Probe `/llms-full.txt` then `/llms.txt` for site-root URLs and use + that markdown instead of scraping HTML. + min_runes: + type: integer + minimum: 0 + description: Auto-mode escalation floor, same semantics as `/extract` + lang: + type: string + description: > + Language hint sent as `Accept-Language` on target fetches. The + `lang` query parameter works as a fallback. BatchExtractItem: type: object properties: page_content: type: string - description: Extracted markdown content + description: Extracted markdown content, empty when the item failed metadata: type: object additionalProperties: type: string - description: Page metadata (title, source, lang, etc.) + description: > + Page metadata (source, title, lang, canonical, mode_used, etc.). + Failed items carry only `source` and `error`.