From 2a57bd4248811503b4c8b623beacdaa45d270254 Mon Sep 17 00:00:00 2001 From: Rustem Kamalov Date: Sun, 19 Jul 2026 04:18:50 +0300 Subject: [PATCH] fix: /extract/batch per-URL errors, param parity, proxy headers. Update readme --- README.md | 248 +++++++++--------------------------- cmd/root.go | 2 +- core/server_extract.go | 183 +++++++++++++------------- core/server_extract_test.go | 239 +++++++++++++++++++++++----------- docs/demo.gif | Bin 0 -> 139914 bytes docs/openapi.yaml | 53 ++++++-- 6 files changed, 367 insertions(+), 358 deletions(-) create mode 100644 docs/demo.gif 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 0000000000000000000000000000000000000000..ddc1db822897af98095e3cbc0cda0c32af14d970 GIT binary patch literal 139914 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