2 Commits

Author SHA1 Message Date
Rustem Kamalov
9cc69da758 feat(cli): clean search, add extract/format flags; harden engines & proxy rotation; fix bugs; update docs
- Add structured `search [engine] [query]` CLI: --limit/--lang/--region/--site/--file, --format (json|text|markdown|ndjson), --extract N, --search-timeout; Envelope and route logs to stderr with a --quiet default (fixes stdout pollution)
- Unify engines behind a single engineSpec registry (CLI + serve share it)
- Unify the extract knob to bool-or-int `extract=N` (drop extract_top); CLI and HTTP share core batch extraction, raw/rendered fetch, and clamp helpers
- Engines: Ecosia CF captcha detection (raw + browser), Yandex progressive-result wait, Google PAA poll + Has() existence probes, Bing title/desc attribute fallbacks
- Proxy: rotate challenged proxies out of the tag pool for one retry (X-Proxy-Attempts); browser health-ping skip window; opt-in WaitStable
2026-06-16 03:51:37 +03:00
Rustem Kamalov
ca3143ccd1 Merge pull request #32 from karust/stability-safety-improvements
Stability and safety improvements
2026-06-12 19:14:29 +03:00
33 changed files with 1613 additions and 383 deletions

255
README.md
View File

@@ -1,6 +1,6 @@
![OpenSERP](./logo.svg)
# OpenSERP (Search Engine Results)
# OpenSERP
[![Go Report Card](https://goreportcard.com/badge/github.com/karust/openserp)](https://goreportcard.com/report/github.com/karust/openserp)
[![Go Reference](https://pkg.go.dev/badge/github/karust/openserp?style=for-the-badge)](https://pkg.go.dev/github.com/karust/openserp)
@@ -8,42 +8,47 @@
[![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)
**OpenSERP** is a free, open-source API and CLI for accessing normalized search engine results from **Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia**.
**OpenSERP** is a free, open-source SERP API and CLI for live search data 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.
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.
Run it locally, self-host it, or use [OpenSERP Cloud](https://openserp.org/cloud) when you want the same public API shape without operating the server.
**Official website:** [openserp.org](https://openserp.org)
**Feedback:** [GitHub Issues](https://github.com/karust/openserp/issues) or [feedback@openserp.org](mailto:feedback@openserp.org)
**Latest updates, usage examples**: [Telegram](https://t.me/+RJEKspw3mUlhZDMy)
> 💡 OpenSERP is free and open-source. Only links listed in this repository and on the official website are associated with the project.
## Features
- 🔍 **Multi-engine** - search with dedicated endpoints for each engine
- 🌐 **Megasearch** - cross-engine aggregation with deduplication
- 🔍 **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
- **SERP features** - AI summaries, answer boxes, people-also-ask, and related searches in a response
- 📄 **URL extraction** - turn target pages into clean markdown/text for grounding and automation
- 📝 **Data formats** - JSON, Markdown, Text, NdJSON response formats
- 🌍 **Configurable** - proxy, cache, and resilient mode
- 🐳 **Docker-ready** - local and container deployment
- 📝 **Data Formats** - JSON, Markdown, Text, NdJSON response formats
## ⚡ Quick Start
### Docker
Prebuilt images are published to [docker hub: `karust/openserp`](https://hub.docker.com/r/karust/openserp).
```sh
# Run the API server via prebuilt image
docker run --rm -p 127.0.0.1:7000:7000 karust/openserp:latest serve -a 0.0.0.0 -p 7000
# Or use docker-compose (pulls the prebuilt image)
# Or
docker compose up
```
### Go install
```sh
go install github.com/karust/openserp@latest
openserp search duckduckgo "open source serp api" --format markdown
```
### From Source
```sh
@@ -53,10 +58,117 @@ go build -o openserp .
./openserp serve
```
### First request
```sh
curl "http://127.0.0.1:7000/mega/search?engines=bing,google&text=golang+vs+rust&extract=1&mode=any"
```
<details>
<summary>Example JSON response</summary>
```json
{
"query": {
"text": "golang vs rust",
"engines_requested": ["bing", "google"]
},
"meta": {
"request_id": "019ecdc0-a66d-79a4-9d2b-9e9b480d495e",
"requested_at": "2026-06-16T00:06:55Z",
"took_ms": 720,
"engines_responded": ["bing"],
"engines_failed": [],
"version": "2.1"
},
"results": [
{
"id": "s_5a8273f16b19ab64",
"rank": 1,
"type": "organic",
"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 …",
"domain": "go.dev",
"favicon": "https://go.dev/favicon.ico",
"position": {
"absolute": 1
},
"engine": "bing",
"domain_info": {
"tld": "dev",
"sld": "go",
"category": ""
},
"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 ........",
"mode_used": "fast",
"fetched_at": "2026-06-16T00:06:56Z"
}
},
{
"id": "s_1a364ebcb3035539",
"rank": 2,
"type": "organic",
"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 …",
"domain": "en.wikipedia.org",
"favicon": "https://en.wikipedia.org/favicon.ico",
"position": {
"absolute": 2
},
"engine": "bing",
"domain_info": {
"tld": "org",
"sld": "wikipedia",
"category": ""
},
"classification": {
"content_type": "article",
"source_hint": "encyclopedia"
}
},
...
],
"serp_features": [],
"pagination": {
"page": 1,
"has_more": false,
"next_start": 10
},
"clusters": [
{
"id": "c_f20b23a020101dce",
"canonical_url": "https://go.dev/",
"domain": "go.dev",
"title": "The Go Programming Language",
"occurrences": [
{
"engine": "bing",
"rank": 1,
"result_id": "s_5a8273f16b19ab64"
}
],
"engines_count": 1,
"best_rank": 1,
"score": 0.5
},
...
]
}
```
</details>
## Deployment Options
- **Self-hosted (this repo)** - free, MIT-licensed, with full control over runtime, proxies, cache, and scaling.
- **[Hosted API](https://openserp.org/cloud)** - optional managed version from the project maintainers, with the same API shape.
- **[OpenSERP Cloud](https://openserp.org/cloud)** - optional managed version from the project maintainers, with the same API shape.
The hosted API helps fund continued development of the open-source project. Same endpoints, same response schema, and client code can migrate either direction.
@@ -109,16 +221,26 @@ curl "http://127.0.0.1:7000/bing/image?text=golang+logo&limit=10"
Megasearch:
```bash
# Search all configured engines
curl "http://127.0.0.1:7000/mega/search?text=golang&limit=10"
```
# Fast mode: only one fastest engine is queried
| Mode | Best for | Behavior |
| ---------- | ------------------------------------ | ---------------------------------------------- |
| `balanced` | Most multi-engine SERP workflows | Queries engines in parallel and merges results |
| `fast` | Lowest latency | Uses the fastest available engine |
| `any` | Fallback-style availability checking | Tries engines sequentially until one responds |
<details>
<summary>More megasearch examples</summary>
```bash
# Fast mode
curl "http://127.0.0.1:7000/mega/search?text=golang&mode=fast&engines=google,bing,yandex"
# Any mode: sequential fallback in provided order (default order if none provided)
# Any mode
curl "http://127.0.0.1:7000/mega/search?text=golang&mode=any&engines=google,yandex,bing"
# Balanced mode (default): parallel all engines with aggregation controls
# Balanced mode with aggregation controls
curl "http://127.0.0.1:7000/mega/search?text=golang&mode=balanced&dedupe=true&merge=true"
# Advanced filtering
@@ -128,6 +250,8 @@ curl "http://127.0.0.1:7000/mega/search?text=golang&engines=google,bing&limit=20
curl "http://127.0.0.1:7000/mega/image?text=golang+logo&limit=20"
```
</details>
List engines:
```bash
@@ -144,27 +268,84 @@ curl "http://127.0.0.1:7000/extract?url=https://example.com&mode=auto"
curl "http://127.0.0.1:7000/extract?url=https://example.com&format=markdown"
# Embed extracted content under the top search results
curl "http://127.0.0.1:7000/google/search?text=llm+observability&extract=true&extract_top=2&format=markdown"
curl "http://127.0.0.1:7000/google/search?text=llm+observability&extract=2&format=markdown"
```
## 🖥 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
```
<details>
<summary>CLI output and more examples</summary>
```markdown
# Search results for "free open source serp"
**Query:** free open source serp - **Engines:** duckduckgo - **Took:** 1794ms
## Results
### 1. OpenSERP: Open-Source, Self-Hosted & Free SERP API
**openserp.org** - 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.
-> https://openserp.org/
### 2. GitHub - karust/openserp: Open-source SERP API for AI, SEO & automation ...
**github.com karust openserp** - 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.
-> https://github.com/karust/openserp
```
More CLI examples:
```sh
# JSON is the default format
openserp search google "golang generics" --limit 20
# Plain text, German results
openserp search yandex "wetter berlin" --format text --lang DE --region DE
# Restrict to a site and stream NdJSON
openserp search bing "release notes" --site github.com --format ndjson
# Embed clean page content from the top 2 results
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
```
</details>
Run `openserp search --help` for the full flag list. Engine names: `google`, `yandex`, `baidu`, `bing`, `duckduckgo`, `ecosia`.
## 🔍 Query Parameters
Common parameters:
| Parameter | Description | Example |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
| `text` | Search query | `golang programming` |
| `lang` | Language code | `EN`, `DE`, `RU`, `ES` |
| `region` | Market/location hint. Countries/locales work across engines; Google also accepts city names via `uule`; Yandex accepts numeric `lr`. | `DE`, `en-GB`, `Berlin`, `213` |
| `date` | Date range | `20250101..20251231` |
| `file` | File extension | `pdf`, `doc`, `xls` |
| `site` | Site-specific search | `github.com` |
| `limit` | Number of organic results, max 100. When omitted or `<=10`, only the first SERP page is parsed. | `25`, `50` |
| `start` | Pagination offset | `0`, `10`, `20` |
| `format` | Output format | `json`, `markdown`, `text`, `ndjson` |
| `extract` | Fetch and embed target-page content for top web results | `true` |
| `extract_top` | Number of top web results to extract, clamped to 1-5 | `3` |
| `extract_mode` | Extraction strategy: raw HTTP first, raw only, or browser-rendered | `auto`, `fast`, `rendered` |
| Parameter | Description | Example |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `text` | Search query | `golang programming` |
| `lang` | Language code | `EN`, `DE`, `RU`, `ES` |
| `region` | Market/location hint. Countries/locales work across engines; Google also accepts city names via `uule`; Yandex accepts numeric `lr`. | `DE`, `en-GB`, `Berlin`, `213` |
| `date` | Date range | `20250101..20251231` |
| `file` | File extension | `pdf`, `doc`, `xls` |
| `site` | Site-specific search | `github.com` |
| `limit` | Number of organic results, max 100. When omitted or `<=10`, only the first SERP page is parsed. | `25`, `50` |
| `start` | Pagination offset | `0`, `10`, `20` |
| `format` | Output format | `json`, `markdown`, `text`, `ndjson` |
| `extract` | Fetch and embed target-page content for top web results. Bool or int depth: `0`/`false` off, `true`/`1` top result, `N` top N (1-5). `extract_mode`/`min_runes` imply `extract=true` unless `extract=0` | `1`, `3`, `true` |
| `extract_mode` | Extraction strategy: raw HTTP first, raw only, or browser-rendered | `auto`, `fast`, `rendered` |
Engine-specific parameters:
@@ -335,7 +516,7 @@ Contributions are welcome. See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
## Feedback & Updates
- [GitHub Issues](https://github.com/karust/openserp/issues) - bugs, feature ideas, and reproducible issues.
- [Telegram channel](https://t.me/openserp_cloud) - OpenSERP news, release notes, and project updates. Direct messages are open for quick feedback and hosted API questions.
- [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. Direct messages are open for quick feedback and hosted API questions.
###### _"OpenSERP" is the name of this open-source project. The official website is [openserp.org](https://openserp.org). Resources not linked on this page are not affiliated with the project._
> OpenSERP is free and open-source. Only links listed in this repository and on [openserp.org](https://openserp.org) are associated with the project.

View File

@@ -47,7 +47,10 @@ func parseBingDocument(doc *goquery.Document) []core.SearchResult {
return
}
title := titleTag.Text()
title := firstNonEmptyAttr(titleTag, "aria-label", "title")
if title == "" {
title = normalizeWhitespace(titleTag.Text())
}
if title == "" {
title = extractFirstText(item, Selectors.TitleFallbacks)
}
@@ -82,19 +85,30 @@ func parseBingDocument(doc *goquery.Document) []core.SearchResult {
func extractFirstText(item *goquery.Selection, selectors []string) string {
for _, selector := range selectors {
if tag := item.Find(selector).First(); tag.Length() > 0 {
if text := strings.TrimSpace(tag.Text()); text != "" {
if text := normalizeWhitespace(tag.Text()); text != "" {
return text
}
if label, exists := tag.Attr("aria-label"); exists {
if label = strings.TrimSpace(label); label != "" {
return label
}
if label := firstNonEmptyAttr(tag, "aria-label", "title"); label != "" {
return label
}
}
}
return ""
}
func firstNonEmptyAttr(item *goquery.Selection, attrs ...string) string {
for _, attr := range attrs {
value, exists := item.Attr(attr)
if !exists {
continue
}
if value = normalizeWhitespace(value); value != "" {
return value
}
}
return ""
}
// descriptionFromItem extracts a description using the same 4-step fallback
// chain as the rod-based browser parser. Bing renders snippet text with heavy
// source-indentation whitespace, so each candidate is whitespace-collapsed.
@@ -118,8 +132,8 @@ func descriptionFromItem(item *goquery.Selection, title string) string {
return normalizeWhitespace(strings.Replace(item.Text(), title, "", 1))
}
// normalizeWhitespace collapses runs of whitespace (including the newlines and
// indentation Bing leaves in snippet markup) into single spaces.
// normalizeWhitespace collapses Bing's snippet-markup whitespace into single
// spaces (see core.NormalizeWhitespace).
func normalizeWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
return core.NormalizeWhitespace(s)
}

View File

@@ -145,3 +145,49 @@ func TestParseBingHTMLTitleFallback(t *testing.T) {
t.Fatalf("title = %q, want fallback", results[0].Title)
}
}
func TestParseBingHTMLPrefersTitleAttribute(t *testing.T) {
t.Parallel()
html := `
<ol id="b_results">
<li class="b_algo">
<h2><a aria-label="Real SERP Title" href="https://example.com/result">example.com</a></h2>
<div class="b_caption"><p>Snippet</p></div>
</li>
</ol>`
results, err := ParseHTML(bytes.NewReader([]byte(html)))
if err != nil {
t.Fatalf("ParseHTML() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Title != "Real SERP Title" {
t.Fatalf("title = %q, want attribute title", results[0].Title)
}
}
func TestParseBingHTMLDescriptionFallsThroughEmptyPrimary(t *testing.T) {
t.Parallel()
html := `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/result">Result title</a></h2>
<div class="b_caption"><p> </p><div>Useful snippet text</div></div>
</li>
</ol>`
results, err := ParseHTML(bytes.NewReader([]byte(html)))
if err != nil {
t.Fatalf("ParseHTML() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Description != "Useful snippet text" {
t.Fatalf("description = %q, want fallback snippet", results[0].Description)
}
}

View File

@@ -66,6 +66,11 @@ func (bing *Bing) checkCaptcha(page *rod.Page) bool {
}
func (bing *Bing) acceptCookies(ctx context.Context, page *rod.Page) error {
// Probe first so a banner-less SERP returns immediately instead of blocking
// .Element for the full Timeout/10.
if has, _, err := page.Has(Selectors.CookieBtn); err != nil || !has {
return nil
}
consentBtn, err := page.Timeout(bing.Timeout / 10).Element(Selectors.CookieBtn)
if err != nil {
return nil
@@ -107,8 +112,10 @@ func (bing *Bing) parseResultElement(el *rod.Element, isAd bool, rank, absoluteR
return core.SearchResult{}, false
}
title, _ := titleElem.Text()
title = strings.TrimSpace(title)
title := core.ElementAttribute(titleElem, "aria-label", "title")
if title == "" {
title = core.ElementText(titleElem)
}
if title == "" {
title = core.FirstNonEmptyText(el, Selectors.TitleFallbacks...)
}
@@ -120,16 +127,10 @@ func (bing *Bing) parseResultElement(el *rod.Element, isAd bool, rank, absoluteR
return core.SearchResult{}, false
}
desc := ""
if descElem, err := el.Element(Selectors.DescPrimary); err == nil {
desc, _ = descElem.Text()
} else if descElem, err := el.Element(Selectors.DescFallback); err == nil {
desc, _ = descElem.Text()
} else if descElem, err := el.Element(Selectors.DescAny); err == nil {
desc, _ = descElem.Text()
} else {
desc := core.FirstNonEmptyText(el, Selectors.DescPrimary, Selectors.DescFallback, Selectors.DescAny)
if desc == "" {
fullText, _ := el.Text()
desc = strings.TrimSpace(strings.Replace(fullText, title, "", 1))
desc = core.NormalizeWhitespace(strings.Replace(fullText, title, "", 1))
}
return core.SearchResult{
@@ -137,7 +138,7 @@ func (bing *Bing) parseResultElement(el *rod.Element, isAd bool, rank, absoluteR
AbsoluteRank: absoluteRank,
URL: url,
Title: title,
Description: strings.TrimSpace(desc),
Description: desc,
Ad: isAd,
}, true
}

78
cmd/engines.go Normal file
View File

@@ -0,0 +1,78 @@
package cmd
import (
"context"
"io"
"github.com/karust/openserp/baidu"
"github.com/karust/openserp/bing"
"github.com/karust/openserp/core"
"github.com/karust/openserp/duckduckgo"
"github.com/karust/openserp/ecosia"
"github.com/karust/openserp/google"
"github.com/karust/openserp/yandex"
)
// engineSpec is the single registry row for a search engine, driving CLI search,
// raw dispatch, serve's browserEngineSpecs, and the alias/validation strings.
// cfg points into the live config global; rawSearchFn is nil when an engine has
// no browserless mode.
type engineSpec struct {
name string
aliases []string
factory func(core.Browser, core.SearchEngineOptions) core.SearchEngine
rawSearchFn func(context.Context, core.Query) ([]core.SearchResult, error)
parseHTMLFn func(io.Reader) ([]core.SearchResult, error)
cfg *EngineConfig
}
func (s engineSpec) opts() core.SearchEngineOptions {
return s.cfg.SearchEngineOptions
}
func engineSpecs() []engineSpec {
return []engineSpec{
{name: "google", factory: newEngine(google.New), rawSearchFn: google.Search, parseHTMLFn: google.ParseHTML, cfg: &config.GoogleConfig},
{name: "yandex", factory: newEngine(yandex.New), rawSearchFn: yandex.Search, parseHTMLFn: yandex.ParseHTML, cfg: &config.YandexConfig},
{name: "baidu", factory: newEngine(baidu.New), rawSearchFn: baidu.Search, parseHTMLFn: baidu.ParseHTML, cfg: &config.BaiduConfig},
{name: "bing", factory: newEngine(bing.New), parseHTMLFn: bing.ParseHTML, cfg: &config.BingConfig},
{name: "duckduckgo", aliases: []string{"duck", "ddg"}, factory: newEngine(duckduckgo.New), parseHTMLFn: duckduckgo.ParseHTML, cfg: &config.DuckDuckGoConfig},
{name: "ecosia", factory: newEngine(ecosia.New), rawSearchFn: ecosia.Search, parseHTMLFn: ecosia.ParseHTML, cfg: &config.EcosiaConfig},
}
}
// newEngine adapts a concrete pkg.New (returning *Engine) to the
// core.SearchEngine-typed factory the registry stores.
func newEngine[T core.SearchEngine](ctor func(core.Browser, core.SearchEngineOptions) T) func(core.Browser, core.SearchEngineOptions) core.SearchEngine {
return func(b core.Browser, o core.SearchEngineOptions) core.SearchEngine {
return ctor(b, o)
}
}
// engineValidArgs returns every accepted engine token (canonical names +
// aliases) for cobra's OnlyValidArgs validation.
func engineValidArgs() []string {
specs := engineSpecs()
args := make([]string, 0, len(specs))
for _, s := range specs {
args = append(args, s.name)
args = append(args, s.aliases...)
}
return args
}
// resolveEngineSpec returns the spec whose canonical name or alias matches raw
// (case/space already normalized by the caller), or false when unknown.
func resolveEngineSpec(raw string) (engineSpec, bool) {
for _, s := range engineSpecs() {
if s.name == raw {
return s, true
}
for _, alias := range s.aliases {
if alias == raw {
return s, true
}
}
}
return engineSpec{}, false
}

View File

@@ -17,7 +17,7 @@ import (
)
const (
version = "0.8.3"
version = "0.8.4"
defaultConfigFilename = "config"
envPrefix = "OPENSERP"
)
@@ -51,6 +51,7 @@ type ServerConfig struct {
ConfigPath string `mapstructure:"config_path"`
IsDebug bool `mapstructure:"debug"`
IsVerbose bool `mapstructure:"verbose"`
IsQuiet bool `mapstructure:"quiet"`
IsRawRequests bool `mapstructure:"raw_requests"`
Insecure bool `mapstructure:"insecure"`
}
@@ -115,6 +116,7 @@ var flagToConfigKey = map[string]string{
"profiles-json": "app.profiles",
"verbose": "server.verbose",
"debug": "server.debug",
"quiet": "server.quiet",
"head": "app.head",
"leakless": "app.leakless",
"raw": "server.raw_requests",
@@ -154,12 +156,24 @@ var RootCmd = &cobra.Command{
}
config.App.LogFormat = logFormat
core.InitLogger(config.Server.IsVerbose, config.Server.IsDebug, config.App.LogFormat)
// One-shot CLI commands default to quiet so stdout is payload-only.
// Server mode keeps request logs unless server.quiet is set.
quiet := config.Server.IsQuiet
if commandDefaultsToQuiet(cmd) && !cmd.Flags().Changed("quiet") {
quiet = true
}
config.Server.IsQuiet = quiet
core.InitLogger(config.Server.IsVerbose, config.Server.IsDebug, quiet, config.App.LogFormat)
logrus.WithField("config", sanitizedConfigForLog(config)).Debug("Final config")
return nil
},
}
func commandDefaultsToQuiet(cmd *cobra.Command) bool {
return cmd != nil && cmd.Name() != serveCMD.Name()
}
func sanitizedConfigForLog(cfg Config) map[string]interface{} {
return map[string]interface{}{
"server": cfg.Server,
@@ -380,6 +394,7 @@ func setConfigDefaults(v *viper.Viper) {
v.SetDefault("server.port", 7070)
v.SetDefault("server.debug", false)
v.SetDefault("server.verbose", false)
v.SetDefault("server.quiet", false)
v.SetDefault("server.raw_requests", false)
v.SetDefault("server.insecure", false)
v.SetDefault("app.log_format", "")
@@ -436,8 +451,9 @@ func init() {
RootCmd.PersistentFlags().StringVar(&config.App.ProfilesJSON, "profiles", "", "Path to browser profile catalog JSON")
RootCmd.PersistentFlags().BoolVarP(&config.Server.IsVerbose, "verbose", "v", false, "Use verbose output")
RootCmd.PersistentFlags().BoolVarP(&config.Server.IsDebug, "debug", "d", false, "Use debug output. Disable headless browser")
RootCmd.PersistentFlags().BoolVarP(&config.Server.IsQuiet, "quiet", "q", false, "Suppress info logs on stderr (default for CLI commands)")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsBrowserHead, "head", "", false, "Enable browser UI")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeakless, "leakless", "l", false, "Use leakless mode to insure browser instances are closed after search")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeakless, "leakless", "l", false, "Use leakless mode to ensure browser instances are closed after search")
RootCmd.PersistentFlags().BoolVarP(&config.Server.IsRawRequests, "raw", "r", false, "Disable browser usage, use HTTP requests")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeaveHead, "leave", "", false, "Leave browser and tabs opened after search is made")
RootCmd.PersistentFlags().StringVarP(&config.Config2Capcha.ApiKey, "2captcha_key", "", "", "2 captcha api key")

View File

@@ -4,42 +4,91 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"net/url"
"strings"
"sync"
"time"
"github.com/karust/openserp/baidu"
"github.com/karust/openserp/bing"
"github.com/google/uuid"
"github.com/karust/openserp/core"
"github.com/karust/openserp/duckduckgo"
"github.com/karust/openserp/ecosia"
"github.com/karust/openserp/google"
"github.com/karust/openserp/yandex"
extractpkg "github.com/karust/openserp/extract"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var searchCMD = &cobra.Command{
Use: "search",
Aliases: []string{"find"},
Short: "Search results using chosen web search engine (google, yandex, baidu, bing, duckduckgo, ecosia)",
Args: cobra.MatchAll(cobra.OnlyValidArgs, cobra.ExactArgs(2)),
Run: search,
// searchFlags holds the per-invocation CLI flags for the search command.
type searchFlags struct {
limit int
lang string
region string
start int
site string
filetype string
format string
full bool
features bool
extract int
timeout int
}
func search(cmd *cobra.Command, args []string) {
var searchOpts searchFlags
var searchCMD = &cobra.Command{
Use: "search [engine] [query]",
Aliases: []string{"find"},
Short: "Search results using chosen web search engine (google, yandex, baidu, bing, duckduckgo, ecosia)",
// Validate the engine ourselves; cobra.OnlyValidArgs would also reject the
// query arg. ValidArgs still feeds shell completion.
Args: cobra.MatchAll(cobra.ExactArgs(2), validateEngineArg),
ValidArgs: engineValidArgs(),
RunE: search,
}
// validateEngineArg checks args[0] against the registry with a clear error,
// without rejecting the query arg.
func validateEngineArg(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
if _, ok := resolveEngineSpec(normalizeEngineArg(args[0])); !ok {
return fmt.Errorf("unknown engine %q; valid: %s", args[0], strings.Join(engineValidArgs(), ", "))
}
return nil
}
func search(cmd *cobra.Command, args []string) error {
// Already validated by validateEngineArg, so this can't miss.
engineType := normalizeEngineArg(args[0])
spec, _ := resolveEngineSpec(engineType)
format, err := normalizeSearchFormat(searchOpts.format)
if err != nil {
return err
}
limit := searchOpts.limit
if limit <= 0 {
limit = 10
}
query := core.Query{
Text: args[1],
Limit: 10,
LangCode: searchOpts.lang,
Region: searchOpts.region,
Site: searchOpts.site,
Filetype: searchOpts.filetype,
Limit: limit,
Start: searchOpts.start,
Filter: true,
Features: searchOpts.features,
Insecure: config.Server.Insecure,
}
if err := applyCLIExtractFlag(&query, searchOpts.extract); err != nil {
return err
}
captchaSolverEnabled, captchaSolverAPIKey, err := resolveCaptchaSolverConfig()
if err != nil {
logrus.WithError(err).Error(fmt.Sprintf("Error validating captcha solver config: %v", err))
os.Exit(1)
return fmt.Errorf("validate captcha solver config: %w", err)
}
proxyRuntime := core.ProxyRuntimeBrowser
@@ -49,39 +98,45 @@ func search(cmd *cobra.Command, args []string) {
proxyCfg, err := buildNormalizedProxyConfig(proxyRuntime)
if err != nil {
logrus.WithError(err).Error(fmt.Sprintf("Error validating proxy config: %v", err))
return
return fmt.Errorf("validate proxy config: %w", err)
}
policy := resolveEngineProxyPolicy(proxyCfg, engineType)
selectedProxy, err := selectCLIProxy(proxyCfg, policy)
if err != nil {
logrus.WithError(err).Error(fmt.Sprintf("Error selecting proxy for %s: %v", engineType, err))
return
return fmt.Errorf("select proxy for %s: %w", engineType, err)
}
if config.Server.IsRawRequests {
query.ProxyURL = selectedProxy
}
// Bound the whole search so a wedged Chrome can't hang the CLI forever.
timeoutSec := searchOpts.timeout
if timeoutSec <= 0 {
timeoutSec = 60
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
logrus.WithFields(logrus.Fields{
"engine": engineType,
"query_hash": core.QueryHashFromQuery(query),
}).Info(fmt.Sprintf("Starting SERP search request using %s engine for query: %s", engineType, query.Text))
startedAt := time.Now()
var results []core.SearchResult
if config.Server.IsRawRequests {
logrus.WithField("engine", engineType).Info(fmt.Sprintf("Using raw requests mode for %s search", engineType))
results, err = searchRaw(engineType, query)
results, err = searchRaw(ctx, spec, query)
} else {
logrus.WithField("engine", engineType).Info(fmt.Sprintf("Using browser mode for %s search", engineType))
results, err = searchBrowser(engineType, query, selectedProxy, captchaSolverEnabled, captchaSolverAPIKey)
results, err = searchBrowser(ctx, spec, query, selectedProxy, captchaSolverEnabled, captchaSolverAPIKey)
}
if err != nil {
logrus.WithError(err).WithField("engine", engineType).Error(fmt.Sprintf("Error during %s search: %s", engineType, err))
return
return fmt.Errorf("%s search: %w", engineType, err)
}
logrus.WithFields(logrus.Fields{
@@ -89,17 +144,203 @@ func search(cmd *cobra.Command, args []string) {
"results_count": len(results),
}).Info(fmt.Sprintf("Successfully completed SERP search using %s engine, returned %d results", engineType, len(results)))
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
logrus.Error(err)
return
env := buildCLIEnvelope(spec.name, query, results, startedAt)
if query.Extract {
if err := enrichCLIEnvelopeWithExtraction(ctx, env, query, format, selectedProxy, captchaSolverEnabled, captchaSolverAPIKey); err != nil {
return fmt.Errorf("extract search results: %w", err)
}
}
fmt.Println(string(b))
payload := renderCLIEnvelope(env, format, searchOpts.full)
fmt.Println(strings.TrimRight(string(payload), "\n"))
return nil
}
func searchBrowser(engineType string, query core.Query, browserProxyURL string, captchaSolverEnabled bool, captchaSolverAPIKey string) ([]core.SearchResult, error) {
var engine core.SearchEngine
func buildCLIEnvelope(engineName string, query core.Query, results []core.SearchResult, startedAt time.Time) *core.Envelope {
env := core.NewEnvelope(query, uuid.NewString(), startedAt, []string{engineName})
ectx := core.EnrichContext{Engine: engineName, Query: query}
for _, r := range results {
core.AppendEnrichedSearchResult(env, r, ectx, startedAt)
}
env.Finalize(startedAt, query)
return env
}
// renderCLIEnvelope renders a v2.1 envelope. JSON/ndjson always carry the full
// envelope; text/markdown omit serp_features unless --full.
func renderCLIEnvelope(env *core.Envelope, format string, full bool) []byte {
if !full && format != "json" && format != "ndjson" {
env.SerpFeatures = nil
}
switch format {
case "text":
return core.RenderText(env)
case "markdown":
return core.RenderMarkdown(env)
case "ndjson":
return core.RenderNDJSON(env)
default: // json
b, err := json.MarshalIndent(env, "", " ")
if err != nil {
logrus.WithError(err).Error("marshal envelope")
return nil
}
return b
}
}
const maxCLIExtractTop = 5
func applyCLIExtractFlag(query *core.Query, extractTop int) error {
top, err := normalizeCLIExtractTop(extractTop)
if err != nil {
return err
}
if top == 0 {
return nil
}
if !config.Extract.Enabled {
return fmt.Errorf("extraction is disabled in config")
}
query.Extract = true
query.ExtractTop = top
query.ExtractMode = string(extractpkg.ModeAuto)
return nil
}
func normalizeCLIExtractTop(raw int) (int, error) {
if raw < 0 {
return 0, fmt.Errorf("--extract must be a non-negative integer")
}
if raw > maxCLIExtractTop {
return maxCLIExtractTop, nil
}
return raw, nil
}
func enrichCLIEnvelopeWithExtraction(ctx context.Context, env *core.Envelope, query core.Query, format string, proxyURL string, captchaSolverEnabled bool, captchaSolverAPIKey string) error {
if env == nil || !query.Extract {
return nil
}
query.ProxyURL = proxyURL
extractor, closeExtractor, err := newCLIExtractor(captchaSolverEnabled, captchaSolverAPIKey)
if err != nil {
return err
}
defer closeExtractor()
// Same depth bounds, batch deadline, and candidate fill-in as the HTTP server.
core.EnrichEnvelopeWithExtraction(ctx, env, query, format, extractor, config.Extract)
return nil
}
// newCLIExtractor builds an Extractor backed by a lazily-created, single-use
// browser. The raw path delegates to core.RawExtractFetch; the rendered path
// validates the target, gates auth'd SOCKS, then reuses core.RenderExtractHTML.
func newCLIExtractor(captchaSolverEnabled bool, captchaSolverAPIKey string) (extractpkg.Extractor, func(), error) {
cfg := config.Extract.Normalized()
var browserMu sync.Mutex
var browser *core.Browser
closeExtractor := func() {
browserMu.Lock()
defer browserMu.Unlock()
if browser == nil {
return
}
if err := browser.Close(); err != nil {
logrus.WithError(err).Debug("Extraction browser close error")
}
browser = nil
}
extractor := extractpkg.Extractor{
Cfg: cfg,
RawFetch: func(ctx context.Context, req extractpkg.ExtractRequest) (*extractpkg.FetchResponse, error) {
return core.RawExtractFetch(ctx, req, cfg, config.Server.Insecure)
},
RenderedFetch: func(ctx context.Context, req extractpkg.ExtractRequest) (*extractpkg.FetchResponse, error) {
if err := validateCLIExtractTargetURL(ctx, req.URL, cfg.AllowPrivateNetworks); err != nil {
return nil, err
}
if core.IsAuthenticatedSocksProxyURL(req.ProxyURL) {
return nil, fmt.Errorf(
"%w: browser runtime does not support authenticated SOCKS proxy %s",
core.ErrProxyUnavailable,
core.MaskProxyURL(req.ProxyURL),
)
}
browserMu.Lock()
if browser == nil {
created, err := newCLIExtractBrowser(cfg, req.ProxyURL, captchaSolverEnabled, captchaSolverAPIKey)
if err != nil {
browserMu.Unlock()
return nil, err
}
browser = created
}
current := browser
browserMu.Unlock()
return core.RenderExtractHTML(ctx, current, req)
},
}
return extractor, closeExtractor, nil
}
func newCLIExtractBrowser(cfg extractpkg.Config, proxyURL string, captchaSolverEnabled bool, captchaSolverAPIKey string) (*core.Browser, error) {
blockedResourceTypes, err := core.ParseBlockedResourceTypes(config.App.BlockResources)
if err != nil {
return nil, fmt.Errorf("invalid block_resources config: %w", err)
}
opts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead && !config.Server.IsDebug,
IsLeakless: config.App.IsLeakless,
Timeout: cfg.Timeout,
LeavePageOpen: false,
CaptchaSolverEnabled: captchaSolverEnabled,
CaptchaSolverApiKey: captchaSolverAPIKey,
BrowserPath: config.App.BrowserPath,
ProxyURL: proxyURL,
Insecure: config.Server.Insecure,
BlockResourceTypes: blockedResourceTypes,
BlockTrackers: config.App.BlockTrackers,
}
return core.NewBrowser(opts)
}
func validateCLIExtractTargetURL(ctx context.Context, rawURL string, allowPrivateNetworks bool) error {
targetURL := extractpkg.NormalizeURL(strings.TrimSpace(rawURL))
if allowPrivateNetworks {
parsed, err := url.ParseRequestURI(targetURL)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("url must use http or https")
}
return nil
}
return core.ValidatePublicHTTPURL(ctx, targetURL)
}
func normalizeSearchFormat(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "json":
return "json", nil
case "text", "txt":
return "text", nil
case "markdown", "md":
return "markdown", nil
case "ndjson", "jsonl":
return "ndjson", nil
default:
return "", fmt.Errorf("invalid --format %q; valid: json, text, markdown, ndjson", raw)
}
}
func searchBrowser(ctx context.Context, spec engineSpec, query core.Query, browserProxyURL string, captchaSolverEnabled bool, captchaSolverAPIKey string) ([]core.SearchResult, error) {
blockedResourceTypes, err := core.ParseBlockedResourceTypes(config.App.BlockResources)
if err != nil {
return nil, fmt.Errorf("invalid block_resources config: %w", err)
@@ -134,49 +375,24 @@ func searchBrowser(engineType string, query core.Query, browserProxyURL string,
if err != nil {
return nil, err
}
// Close the browser so Chromium never outlives the CLI run.
defer func() {
if closeErr := browser.Close(); closeErr != nil {
logrus.WithError(closeErr).Debug("Browser close error")
}
}()
switch strings.ToLower(engineType) {
case "yandex":
engine = yandex.New(*browser, config.YandexConfig.SearchEngineOptions)
case "google":
engine = google.New(*browser, config.GoogleConfig.SearchEngineOptions)
case "baidu":
engine = baidu.New(*browser, config.BaiduConfig.SearchEngineOptions)
case "bing":
engine = bing.New(*browser, config.BingConfig.SearchEngineOptions)
case "duckduckgo":
engine = duckduckgo.New(*browser, config.DuckDuckGoConfig.SearchEngineOptions)
case "ecosia":
engine = ecosia.New(*browser, config.EcosiaConfig.SearchEngineOptions)
default:
return nil, fmt.Errorf("no %q search engine found", engineType)
}
return engine.Search(context.Background(), query)
engine := spec.factory(*browser, spec.opts())
return engine.Search(ctx, query)
}
func searchRaw(engineType string, query core.Query) ([]core.SearchResult, error) {
func searchRaw(ctx context.Context, spec engineSpec, query core.Query) ([]core.SearchResult, error) {
logrus.Warn("Browserless results are very inconsistent or may not even work!")
ctx := context.Background()
switch strings.ToLower(engineType) {
case "yandex":
return yandex.Search(ctx, query)
case "google":
return google.Search(ctx, query)
case "baidu":
return baidu.Search(ctx, query)
case "ecosia":
return ecosia.Search(ctx, query)
case "bing":
logrus.Warn("Bing does not support raw HTTP requests mode. Please use browser mode instead.")
return nil, fmt.Errorf("bing does not support raw requests mode")
case "duckduckgo":
logrus.Warn("DuckDuckGo does not support raw HTTP requests mode. Please use browser mode instead.")
return nil, fmt.Errorf("duckduckgo does not support raw requests mode")
default:
return nil, fmt.Errorf("no %q search engine found", engineType)
if spec.rawSearchFn == nil {
logrus.Warnf("%s does not support raw HTTP requests mode. Please use browser mode instead.", spec.name)
return nil, fmt.Errorf("%s does not support raw requests mode", spec.name)
}
return spec.rawSearchFn(ctx, query)
}
func selectCLIProxy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) (string, error) {
@@ -201,14 +417,20 @@ func selectCLIProxy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) (string,
}
func normalizeEngineArg(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "duck":
return "duckduckgo"
default:
return strings.ToLower(strings.TrimSpace(raw))
}
return strings.ToLower(strings.TrimSpace(raw))
}
func init() {
searchCMD.Flags().IntVar(&searchOpts.limit, "limit", 10, "Maximum number of results")
searchCMD.Flags().StringVar(&searchOpts.lang, "lang", "", "Language hint (e.g. EN, DE, RU)")
searchCMD.Flags().StringVar(&searchOpts.region, "region", "", "Region/market hint (e.g. RU, en-US)")
searchCMD.Flags().IntVar(&searchOpts.start, "start", 0, "Pagination start offset")
searchCMD.Flags().StringVar(&searchOpts.site, "site", "", "Restrict results to a domain (e.g. github.com)")
searchCMD.Flags().StringVar(&searchOpts.filetype, "file", "", "File type filter (e.g. pdf)")
searchCMD.Flags().StringVar(&searchOpts.format, "format", "json", "Output format: json, text, markdown, ndjson")
searchCMD.Flags().BoolVar(&searchOpts.full, "full", false, "Include SERP features in text/markdown output")
searchCMD.Flags().BoolVar(&searchOpts.features, "features", false, "Parse SERP feature modules (browser mode)")
searchCMD.Flags().IntVar(&searchOpts.extract, "extract", 0, "Extract clean content from the top N results using auto mode (1-5)")
searchCMD.Flags().IntVar(&searchOpts.timeout, "search-timeout", 60, "Overall search timeout in seconds")
RootCmd.AddCommand(searchCMD)
}

82
cmd/search_test.go Normal file
View File

@@ -0,0 +1,82 @@
package cmd
import (
"strings"
"testing"
"github.com/karust/openserp/core"
extractpkg "github.com/karust/openserp/extract"
)
func TestNormalizeCLIExtractTop(t *testing.T) {
tests := []struct {
name string
raw int
want int
wantErr bool
}{
{name: "disabled", raw: 0, want: 0},
{name: "one", raw: 1, want: 1},
{name: "clamped", raw: 20, want: maxCLIExtractTop},
{name: "negative", raw: -1, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeCLIExtractTop(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("normalizeCLIExtractTop(%d) = %d, want %d", tc.raw, got, tc.want)
}
})
}
}
func TestApplyCLIExtractFlagSetsAutoMode(t *testing.T) {
previous := config
config.Extract = extractpkg.DefaultConfig()
defer func() { config = previous }()
query := core.Query{Text: "weather today"}
if err := applyCLIExtractFlag(&query, 2); err != nil {
t.Fatalf("applyCLIExtractFlag() error = %v", err)
}
if !query.Extract {
t.Fatal("expected query.Extract")
}
if query.ExtractTop != 2 {
t.Fatalf("ExtractTop = %d, want 2", query.ExtractTop)
}
if query.ExtractMode != string(extractpkg.ModeAuto) {
t.Fatalf("ExtractMode = %q, want auto", query.ExtractMode)
}
}
func TestApplyCLIExtractFlagRequiresEnabledConfig(t *testing.T) {
previous := config
config.Extract = extractpkg.Config{Enabled: false}
defer func() { config = previous }()
query := core.Query{Text: "weather today"}
err := applyCLIExtractFlag(&query, 1)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("error = %q, want disabled message", err.Error())
}
}
func TestSearchCommandHasExtractFlag(t *testing.T) {
if searchCMD.Flags().Lookup("extract") == nil {
t.Fatal("expected search command to expose --extract")
}
}

View File

@@ -13,13 +13,7 @@ import (
"syscall"
"time"
"github.com/karust/openserp/baidu"
"github.com/karust/openserp/bing"
"github.com/karust/openserp/core"
"github.com/karust/openserp/duckduckgo"
"github.com/karust/openserp/ecosia"
"github.com/karust/openserp/google"
"github.com/karust/openserp/yandex"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
@@ -35,18 +29,11 @@ type rawEngine struct {
func (r *rawEngine) Search(ctx context.Context, q core.Query) ([]core.SearchResult, error) {
q.Insecure = config.Server.Insecure
switch r.name {
case "google":
return google.Search(ctx, q)
case "yandex":
return yandex.Search(ctx, q)
case "baidu":
return baidu.Search(ctx, q)
case "ecosia":
return ecosia.Search(ctx, q)
default:
spec, ok := resolveEngineSpec(r.name)
if !ok || spec.rawSearchFn == nil {
return nil, fmt.Errorf("unsupported engine: %s", r.name)
}
return spec.rawSearchFn(ctx, q)
}
func (r *rawEngine) SearchImage(_ context.Context, _ core.Query) ([]core.SearchResult, error) {
@@ -626,56 +613,17 @@ type browserEngineSpec struct {
}
func browserEngineSpecs() []browserEngineSpec {
return []browserEngineSpec{
{
name: "google",
opts: config.GoogleConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return google.New(browser, opts)
},
parseHTMLFn: google.ParseHTML,
},
{
name: "yandex",
opts: config.YandexConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return yandex.New(browser, opts)
},
parseHTMLFn: yandex.ParseHTML,
},
{
name: "baidu",
opts: config.BaiduConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return baidu.New(browser, opts)
},
parseHTMLFn: baidu.ParseHTML,
},
{
name: "bing",
opts: config.BingConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return bing.New(browser, opts)
},
parseHTMLFn: bing.ParseHTML,
},
{
name: "duckduckgo",
opts: config.DuckDuckGoConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return duckduckgo.New(browser, opts)
},
parseHTMLFn: duckduckgo.ParseHTML,
},
{
name: "ecosia",
opts: config.EcosiaConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return ecosia.New(browser, opts)
},
parseHTMLFn: ecosia.ParseHTML,
},
specs := engineSpecs()
out := make([]browserEngineSpec, 0, len(specs))
for _, s := range specs {
out = append(out, browserEngineSpec{
name: s.name,
opts: s.opts(),
factory: s.factory,
parseHTMLFn: s.parseHTMLFn,
})
}
return out
}
func buildBrowserEngines(baseOpts core.BrowserOpts, proxyCfg core.ProxyConfig) ([]core.SearchEngine, func() error, core.BrowserResolver, error) {

View File

@@ -19,6 +19,15 @@ func TestRawEngineCachesRateLimiter(t *testing.T) {
}
}
func TestCommandDefaultsToQuiet(t *testing.T) {
if !commandDefaultsToQuiet(searchCMD) {
t.Fatal("expected search command to default to quiet")
}
if commandDefaultsToQuiet(serveCMD) {
t.Fatal("expected serve command to keep server logging by default")
}
}
func TestBrowserPoolKey(t *testing.T) {
cases := []struct {
name string

View File

@@ -2,7 +2,7 @@ server:
host: 0.0.0.0 # API host to bind
port: 7000 # API port to bind
debug: false # Enable debug logs and force browser UI mode
verbose: true # Enable info-level request logs
verbose: false # Enable debug-level request logs
raw_requests: false # true = raw HTTP mode, false = browser mode
insecure: true # Allow insecure TLS connections
@@ -18,8 +18,6 @@ app:
idle_ttl: 5m # close a Chrome that has not served traffic for this long
mega_timeout: 90s # max total wait for /mega/* requests; slow engines return partial results
block_trackers: true
block_resources: "image,font,css,media"
extract:
enabled: true

View File

@@ -252,8 +252,19 @@ type browserConnection struct {
laneProfiles map[string]browserprofile.Profile
authCancel context.CancelFunc
authStopped chan struct{}
// lastOK is when a CDP call last succeeded; the health ping is skipped while
// it is within healthPingSkipWindow.
lastOK time.Time
}
const (
// healthPingTimeout bounds the per-call connection ping so a wedged Chrome
// can't stall navigations while holding the connection lock.
healthPingTimeout = 3 * time.Second
// healthPingSkipWindow skips the ping when a CDP call succeeded this recently.
healthPingSkipWindow = 5 * time.Second
)
// NewBrowser launches a new Chromium process via Rod launcher and returns a
// Browser wrapper configured with proxy and captcha solver settings.
func NewBrowser(opts BrowserOpts) (*Browser, error) {
@@ -272,6 +283,9 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) {
// headless=new uses the full Chrome renderer; legacy --headless disables the
// GPU process entirely, making WebGL context creation fail even with SwiftShader.
// use-angle=swiftshader-webgl (Chrome ≥112) enables a software WebGL renderer.
// Rod enables leakless by default, so always pass the configured value
// through. OpenSERP defaults it to false because the helper binary is
// commonly flagged by antivirus on Windows.
l := launcher.New().Leakless(opts.IsLeakless).
Set("disable-blink-features", "AutomationControlled").
Delete("enable-automation").
@@ -557,18 +571,18 @@ func (b *Browser) ensureConnectedBrowser(ctx context.Context, forceReconnect boo
return nil, err
}
state.browser = connected
state.lastOK = time.Now()
return state.browser, nil
}
// Bound just this health-ping with a per-call timeout so a wedged browser
// can't block the connection lock forever. Use a fresh derived context each
// call (not browser.Timeout, which would bake a permanent deadline into the
// persistent connection — see newRodBrowser).
pingTimeout := b.Timeout
if pingTimeout <= 0 {
pingTimeout = 30 * time.Second
// A recent successful CDP call means the connection is alive; skip the ping.
if !state.lastOK.IsZero() && time.Since(state.lastOK) < healthPingSkipWindow {
return state.browser, nil
}
pingCtx, cancelPing := context.WithTimeout(EnsureContext(ctx), pingTimeout)
// Fresh derived context per call (not browser.Timeout, which would bake a
// permanent deadline into the persistent connection — see newRodBrowser).
pingCtx, cancelPing := context.WithTimeout(EnsureContext(ctx), healthPingTimeout)
_, pingErr := state.browser.Context(pingCtx).Version()
cancelPing()
if pingErr != nil {
@@ -579,6 +593,7 @@ func (b *Browser) ensureConnectedBrowser(ctx context.Context, forceReconnect boo
}
state.browser = connected
}
state.lastOK = time.Now()
return state.browser, nil
}
@@ -1503,20 +1518,24 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) {
wait()
}
// WaitStable internally waits for page load too, so cap it separately.
// Selector parsing still decides whether a partially loaded page is usable.
stableWaitTimeout := minPositiveDuration(b.Timeout, b.WaitLoadTime+time.Second)
if err := page.Timeout(stableWaitTimeout).WaitStable(800 * time.Millisecond); err != nil {
WithRequest(ctx).WithError(err).Debug("WaitStable returned early; continuing")
}
if err := classifyMainDocumentStatus(statusWatcher.Status()); err != nil {
closeOnErr()
return nil, err
}
b.saveLaneCookies(ctx, page, URL)
b.markConnectionOK()
return page, nil
}
// markConnectionOK records a successful CDP round trip so the next
// ensureConnectedBrowser can skip its health ping (see healthPingSkipWindow).
func (b *Browser) markConnectionOK() {
state := b.connectionState()
state.mu.Lock()
state.lastOK = time.Now()
state.mu.Unlock()
}
// Close closes the active browser connection.
func (b *Browser) Close() error {
if b == nil || b.browserAddr == "" {

View File

@@ -15,6 +15,14 @@ import (
"golang.org/x/time/rate"
)
// Extraction depth bounds for the unified extract=N query param. The default
// is 1 (extract=true == extract=1 == "extract one result"); callers raise it up
// to maxExtractTop. These mirror the CLI's --extract flag limits.
const (
defaultExtractTop = 1
maxExtractTop = 5
)
// ErrCaptcha is returned when the engine detects a captcha challenge page.
// This error is treated as non-retryable by resilient search policies.
var ErrCaptcha = errors.New("captcha detected")
@@ -385,33 +393,12 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error {
if err != nil {
return errInvalidParam(fmt.Sprintf("features: %v", err))
}
searchQuery.Extract, err = strconv.ParseBool(reqCtx.Query("extract", "0"))
if err != nil {
return errInvalidParam(fmt.Sprintf("extract: %v", err))
}
searchQuery.ExtractTop = 3
if raw := strings.TrimSpace(reqCtx.Query("extract_top")); raw != "" {
extractTop, err := strconv.Atoi(raw)
if err != nil {
return errInvalidParam("extract_top must be an integer")
}
if extractTop < 1 {
extractTop = 1
}
if extractTop > 5 {
extractTop = 5
}
searchQuery.ExtractTop = extractTop
}
searchQuery.ExtractMode = strings.ToLower(strings.TrimSpace(reqCtx.Query("extract_mode", "auto")))
switch searchQuery.ExtractMode {
case "auto", "fast", "rendered":
default:
return errInvalidParam("extract_mode must be one of auto, fast, rendered")
}
searchQuery.ExtractMinRunes, err = parseNonNegativeIntQuery(reqCtx.Query("min_runes"), 0)
if err != nil {
return errInvalidParam("min_runes must be a non-negative integer")
// extract is a unified bool-or-int knob: extract=0/false disables, extract=N
// (or true/1) extracts the top N results. The tuning params extract_mode and
// min_runes also imply extraction (extract=0 still overrides them). The
// default depth is 1 — true == 1 == "extract one result".
if err := parseExtractParams(reqCtx, searchQuery); err != nil {
return err
}
searchQuery.ProxyOverride, err = NormalizeProxyRequestOverride(reqCtx.Get("X-Use-Proxy"))
@@ -437,6 +424,72 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error {
return nil
}
// parseExtractParams reads the unified extract knob plus its tuning params onto
// q. The extract param is bool-or-int:
//
// extract=0 / extract=false → extraction off
// extract=true / extract=1 → on, top 1
// extract=N (1..5) → on, top N (clamped to maxExtractTop)
//
// extract_mode and min_runes tune how extraction runs and imply extraction when
// present, unless extract is explicitly set (extract=0 wins over them). When
// extraction is on but no depth is given, ExtractTop defaults to 1.
func parseExtractParams(reqCtx *fiber.Ctx, q *Query) error {
q.ExtractTop = defaultExtractTop
// extract accepts both bool spellings (true/false/1/0) and an integer depth.
// Try bool first so legacy true/false keep working, then fall back to int.
extractExplicit := false
if raw := strings.TrimSpace(reqCtx.Query("extract")); raw != "" {
extractExplicit = true
if b, err := strconv.ParseBool(raw); err == nil {
q.Extract = b
if b {
q.ExtractTop = 1
}
} else if n, err := strconv.Atoi(raw); err == nil {
q.Extract = n > 0
if n > 0 {
q.ExtractTop = clampExtractTop(n)
}
} else {
return errInvalidParam("extract must be a boolean or an integer (0 disables, N extracts top N)")
}
}
q.ExtractMode = strings.ToLower(strings.TrimSpace(reqCtx.Query("extract_mode", "auto")))
switch q.ExtractMode {
case "auto", "fast", "rendered":
default:
return errInvalidParam("extract_mode must be one of auto, fast, rendered")
}
if !extractExplicit && strings.TrimSpace(reqCtx.Query("extract_mode")) != "" {
q.Extract = true
}
minRunes, err := parseNonNegativeIntQuery(reqCtx.Query("min_runes"), 0)
if err != nil {
return errInvalidParam("min_runes must be a non-negative integer")
}
q.ExtractMinRunes = minRunes
if !extractExplicit && strings.TrimSpace(reqCtx.Query("min_runes")) != "" {
q.Extract = true
}
return nil
}
// clampExtractTop bounds a requested extraction depth to [1, maxExtractTop].
func clampExtractTop(n int) int {
if n < 1 {
return 1
}
if n > maxExtractTop {
return maxExtractTop
}
return n
}
// SearchEngineOptions controls engine pacing, selector waits, and captcha
// handling behavior shared by browser and raw implementations.
type SearchEngineOptions struct {

View File

@@ -251,7 +251,7 @@ func quoteIfNeeded(s string) string {
return s
}
func InitLogger(isVerbose, isDebug bool, format string) {
func InitLogger(isVerbose, isDebug, isQuiet bool, format string) {
switch format {
case LogFormatText:
logrus.SetFormatter(&bracketFormatter{TimestampFormat: "2006-01-02 15:04:05"})
@@ -261,16 +261,22 @@ func InitLogger(isVerbose, isDebug bool, format string) {
})
}
if isDebug {
logrus.SetOutput(io.MultiWriter(os.Stdout))
// Logs go to stderr (+ optional file) so stdout carries only the payload.
switch {
case isDebug:
logrus.SetOutput(io.MultiWriter(os.Stderr))
logrus.SetReportCaller(true)
} else {
case isQuiet:
// One-shot CLI default: stderr only, no ./logs.txt in the user's CWD.
logrus.SetOutput(os.Stderr)
logrus.SetReportCaller(false)
default:
f, err := os.OpenFile("./logs.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open logs file ./logs.txt: %v\n", err)
logrus.SetOutput(io.MultiWriter(os.Stdout))
logrus.SetOutput(io.MultiWriter(os.Stderr))
} else {
logrus.SetOutput(io.MultiWriter(f, os.Stdout))
logrus.SetOutput(io.MultiWriter(f, os.Stderr))
}
logrus.SetReportCaller(false)
}
@@ -282,5 +288,9 @@ func InitLogger(isVerbose, isDebug bool, format string) {
if isDebug {
level = logrus.TraceLevel
}
if isQuiet && !isVerbose && !isDebug {
// Quiet keeps only warnings/errors on stderr.
level = logrus.WarnLevel
}
logrus.SetLevel(level)
}

View File

@@ -102,8 +102,49 @@ func HasAttribute(el *rod.Element, attr string) bool {
return err == nil && v != nil
}
// FirstNonEmptyText returns the trimmed text of the first selector under root
// that yields non-empty content. Empty string if none match.
// NormalizeWhitespace collapses runs of whitespace (newlines, source
// indentation) into single spaces and trims the result.
func NormalizeWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// ElementText returns el's visible text, falling back to textContent (for nodes
// rod's Text() leaves empty), normalized. Empty string if el is nil or blank.
func ElementText(el *rod.Element) string {
if el == nil {
return ""
}
if text, err := el.Text(); err == nil {
if normalized := NormalizeWhitespace(text); normalized != "" {
return normalized
}
}
if value, err := el.Property("textContent"); err == nil {
return NormalizeWhitespace(value.String())
}
return ""
}
// ElementAttribute returns the first non-empty value among attrs on el,
// normalized. Empty string if el is nil or none are set.
func ElementAttribute(el *rod.Element, attrs ...string) string {
if el == nil {
return ""
}
for _, attr := range attrs {
value, err := el.Attribute(attr)
if err != nil || value == nil {
continue
}
if normalized := NormalizeWhitespace(*value); normalized != "" {
return normalized
}
}
return ""
}
// FirstNonEmptyText returns the text (see ElementText) of the first selector
// under root that yields non-empty content. Empty string if none match.
func FirstNonEmptyText(root *rod.Element, selectors ...string) string {
if root == nil {
return ""
@@ -113,12 +154,8 @@ func FirstNonEmptyText(root *rod.Element, selectors ...string) string {
if err != nil {
continue
}
text, err := el.Text()
if err != nil {
continue
}
if trimmed := strings.TrimSpace(text); trimmed != "" {
return trimmed
if text := ElementText(el); text != "" {
return text
}
}
return ""

View File

@@ -24,6 +24,10 @@ const (
// ProxyPoolQuarantineDuration is how long an exhausted tag pool stays quarantined
// before a single probe proxy is re-enabled for recovery testing.
ProxyPoolQuarantineDuration = 5 * time.Minute
// ProxyChallengeCooldown is how long a captcha/blocked proxy is deprioritized
// in rotation. It does not degrade health, so the proxy is still served if
// it's the only one left.
ProxyChallengeCooldown = 2 * time.Minute
)
var supportedProxySchemes = map[string]struct{}{
@@ -99,6 +103,9 @@ type proxyState struct {
tags []string
failures int
disabled bool
// challengedUntil deprioritizes (but does not disable) this proxy in
// rotation after a captcha/block. See ReportChallenged.
challengedUntil time.Time
}
type ProxyRegistry struct {
@@ -411,21 +418,29 @@ func (r *ProxyRegistry) NextByTagWithContext(ctx context.Context, tag string) st
}
}
now := time.Now()
start := r.nextByTag[tag]
for i := 0; i < len(urls); i++ {
idx := (start + i) % len(urls)
proxyURL := urls[idx]
state := r.states[proxyURL]
if state.disabled {
continue
}
// First pass skips challenged proxies; second pass relaxes that so a
// challenged-but-healthy proxy is still served rather than failing.
for _, skipChallenged := range []bool{true, false} {
for i := 0; i < len(urls); i++ {
idx := (start + i) % len(urls)
proxyURL := urls[idx]
state := r.states[proxyURL]
if state.disabled {
continue
}
if skipChallenged && now.Before(state.challengedUntil) {
continue
}
r.nextByTag[tag] = (idx + 1) % len(urls)
WithRequest(ctx).WithFields(logrus.Fields{
"proxy_tag": tag,
"proxy": MaskProxyURL(proxyURL),
}).Debugf("Selected proxy for tag=%s: %s", tag, MaskProxyURL(proxyURL))
return proxyURL
r.nextByTag[tag] = (idx + 1) % len(urls)
WithRequest(ctx).WithFields(logrus.Fields{
"proxy_tag": tag,
"proxy": MaskProxyURL(proxyURL),
}).Debugf("Selected proxy for tag=%s: %s", tag, MaskProxyURL(proxyURL))
return proxyURL
}
}
// All proxies are disabled and no probe could be selected.
@@ -498,22 +513,50 @@ func (r *ProxyRegistry) ReportSuccess(_ context.Context, proxyURL string) {
}
}
func (r *ProxyRegistry) HasHealthyProxyForTag(tag string) bool {
tag = normalizeTag(tag)
if tag == "" {
return false
// ReportChallenged deprioritizes a captcha/blocked proxy for
// ProxyChallengeCooldown without degrading its health (unlike ReportFailure, it
// never disables the proxy or trips quarantine), so the next attempt prefers a
// different IP.
func (r *ProxyRegistry) ReportChallenged(ctx context.Context, proxyURL string) {
proxyURL, err := NormalizeProxyURL(proxyURL)
if err != nil || proxyURL == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
for _, proxyURL := range r.tagIndex[tag] {
if state, ok := r.states[proxyURL]; ok && !state.disabled {
return true
}
state, ok := r.states[proxyURL]
if !ok {
return
}
state.challengedUntil = time.Now().Add(ProxyChallengeCooldown)
WithRequest(ctx).WithField("proxy", MaskProxyURL(proxyURL)).
Debugf("Deprioritized challenged proxy for %s: %s", ProxyChallengeCooldown, MaskProxyURL(proxyURL))
}
func (r *ProxyRegistry) HasHealthyProxyForTag(tag string) bool {
return r.HealthyCountForTag(tag) > 0
}
// HealthyCountForTag returns how many non-disabled proxies the tag pool holds
// (a challenged proxy still counts — it's usable, just deprioritized).
func (r *ProxyRegistry) HealthyCountForTag(tag string) int {
tag = normalizeTag(tag)
if tag == "" {
return 0
}
return false
r.mu.Lock()
defer r.mu.Unlock()
count := 0
for _, proxyURL := range r.tagIndex[tag] {
if state, ok := r.states[proxyURL]; ok && !state.disabled {
count++
}
}
return count
}
func (r *ProxyRegistry) BuildStats() ProxyStats {

150
core/proxy_rotation_test.go Normal file
View File

@@ -0,0 +1,150 @@
package core
import (
"context"
"sync"
"testing"
"golang.org/x/time/rate"
)
// captchaThenSuccessEngine returns ErrCaptcha for every proxy URL except the
// one designated as healthy, where it succeeds. It records the proxy URL of
// each attempt so the test can assert rotation happened.
type captchaThenSuccessEngine struct {
name string
goodProxy string
mu sync.Mutex
seenProxies []string
}
func (e *captchaThenSuccessEngine) Name() string { return e.name }
func (e *captchaThenSuccessEngine) IsInitialized() bool { return true }
func (e *captchaThenSuccessEngine) GetRateLimiter() *rate.Limiter { return nil }
func (e *captchaThenSuccessEngine) Search(ctx context.Context, q Query) ([]SearchResult, error) {
e.mu.Lock()
e.seenProxies = append(e.seenProxies, q.ProxyURL)
e.mu.Unlock()
if q.ProxyURL == e.goodProxy {
return []SearchResult{{Title: "ok", URL: "https://example.com", Rank: 1}}, nil
}
return nil, ErrCaptcha
}
func (e *captchaThenSuccessEngine) SearchImage(ctx context.Context, q Query) ([]SearchResult, error) {
return e.Search(ctx, q)
}
func (e *captchaThenSuccessEngine) attempts() int {
e.mu.Lock()
defer e.mu.Unlock()
return len(e.seenProxies)
}
func tagPoolSearcher(t *testing.T, engine SearchEngine, entries []ProxyEntryConfig) *ResilientSearcher {
t.Helper()
cfg := DefaultResilientConfig()
cfg.Retry.MaxRetries = 0
cfg.CircuitBreaker.FailureThreshold = 5
cfg.Proxy = ProxyConfig{
Runtime: ProxyRuntimeBrowser,
Proxies: ProxiesConfig{Entries: entries},
EnginePolicies: map[string]string{engine.Name(): "rot"},
}
return NewResilientSearcher([]SearchEngine{engine}, cfg)
}
func TestReportChallengedDeprioritizesWithoutDisabling(t *testing.T) {
registry, err := NewProxyRegistry([]ProxyEntryConfig{
{URL: "http://proxy1:8080", Tags: []string{"rot"}},
{URL: "http://proxy2:8080", Tags: []string{"rot"}},
}, 3)
if err != nil {
t.Fatalf("new proxy registry: %v", err)
}
ctx := context.Background()
// Challenge proxy1 from a fresh index; the next selection must skip it.
registry.ReportChallenged(ctx, "http://proxy1:8080")
if got := registry.NextByTag("rot"); got != "http://proxy2:8080" {
t.Fatalf("expected challenged proxy to be skipped, got %q", got)
}
// Health is untouched: both proxies still count as healthy.
if n := registry.HealthyCountForTag("rot"); n != 2 {
t.Fatalf("challenge must not degrade health, healthy=%d", n)
}
// When both are challenged, rotation still serves one (relaxed second pass).
registry.ReportChallenged(ctx, "http://proxy2:8080")
if got := registry.NextByTag("rot"); got == "" {
t.Fatal("expected a proxy even when all are challenged")
}
}
func TestSearchWithProtection_RotatesProxyOnCaptcha(t *testing.T) {
// Two proxies in the same tag pool; the second one is the one that works.
// NextByTag serves proxy1 first, so the first attempt gets a captcha and the
// retry should pick proxy2 and succeed.
good := "http://proxy2:8080"
engine := &captchaThenSuccessEngine{name: "google", goodProxy: good}
rs := tagPoolSearcher(t, engine, []ProxyEntryConfig{
{URL: "http://proxy1:8080", Tags: []string{"rot"}},
{URL: good, Tags: []string{"rot"}},
})
results, _, meta, err := rs.SearchPrimary(context.Background(), engine, Query{Text: "rotate"})
if err != nil {
t.Fatalf("expected success after rotation, got %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if meta.Attempts != 2 {
t.Fatalf("expected 2 proxy attempts, got %d", meta.Attempts)
}
if got := engine.attempts(); got != 2 {
t.Fatalf("expected engine called twice, got %d", got)
}
}
func TestSearchWithProtection_NoRotationWithSingleProxy(t *testing.T) {
// Only one proxy in the pool: captcha must fail fast without a second
// attempt (HealthyCountForTag < 2).
engine := &captchaThenSuccessEngine{name: "google", goodProxy: "http://unused:8080"}
rs := tagPoolSearcher(t, engine, []ProxyEntryConfig{
{URL: "http://proxy1:8080", Tags: []string{"rot"}},
})
_, _, meta, err := rs.SearchPrimary(context.Background(), engine, Query{Text: "single"})
if err == nil {
t.Fatal("expected captcha failure with a single proxy")
}
if meta.Attempts != 1 {
t.Fatalf("expected exactly 1 attempt with single proxy, got %d", meta.Attempts)
}
if got := engine.attempts(); got != 1 {
t.Fatalf("expected engine called once, got %d", got)
}
}
func TestSearchWithProtection_DirectModeFailsFastOnCaptcha(t *testing.T) {
// Direct mode (no proxy config): captcha is non-retryable and rotation must
// not kick in.
engine := &captchaThenSuccessEngine{name: "google", goodProxy: "http://never:8080"}
cfg := DefaultResilientConfig()
cfg.Retry.MaxRetries = 0
rs := NewResilientSearcher([]SearchEngine{engine}, cfg)
_, _, meta, err := rs.SearchPrimary(context.Background(), engine, Query{Text: "direct"})
if err == nil {
t.Fatal("expected captcha failure in direct mode")
}
if meta.Attempts > 1 {
t.Fatalf("direct mode must not rotate proxies, attempts=%d", meta.Attempts)
}
if got := engine.attempts(); got != 1 {
t.Fatalf("expected engine called once in direct mode, got %d", got)
}
}

View File

@@ -0,0 +1,73 @@
package core
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gofiber/fiber/v2"
)
// TestInitFromContextExtractParams verifies how the unified extract knob and its
// tuning params map onto Query.Extract / Query.ExtractTop. Key behaviors:
// - extract is bool-or-int: extract=0/false off, extract=true/1 → top 1,
// extract=N → top N (clamped to [1,5]).
// - extract_mode/min_runes imply extraction (top defaults to 1), but an
// explicit extract=0 still wins over them.
func TestInitFromContextExtractParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
query string
wantExtract bool
wantTop int
}{
{"no params defaults off", "?text=q", false, 1},
{"extract=true means top 1", "?text=q&extract=true", true, 1},
{"extract=1 means top 1", "?text=q&extract=1", true, 1},
{"extract=3 means top 3", "?text=q&extract=3", true, 3},
{"extract=N clamps high", "?text=q&extract=99", true, 5},
{"extract=0 disables", "?text=q&extract=0", false, 1},
{"extract=false disables", "?text=q&extract=false", false, 1},
{"extract_mode implies extract", "?text=q&extract_mode=fast", true, 1},
{"min_runes implies extract", "?text=q&min_runes=200", true, 1},
{"explicit extract=0 overrides tuning", "?text=q&extract=0&extract_mode=fast", false, 1},
}
app := fiber.New()
app.Get("/probe", func(c *fiber.Ctx) error {
q := Query{}
if err := q.InitFromContext(c); err != nil {
return c.Status(http.StatusBadRequest).SendString(err.Error())
}
extract := "0"
if q.Extract {
extract = "1"
}
c.Set("X-Extract", extract)
c.Set("X-Extract-Top", strconv.Itoa(q.ExtractTop))
return c.SendStatus(http.StatusOK)
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/probe"+tt.query, nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status %d for %s", resp.StatusCode, tt.query)
}
gotExtract := resp.Header.Get("X-Extract") == "1"
if gotExtract != tt.wantExtract {
t.Errorf("%s: Extract = %v, want %v", tt.query, gotExtract, tt.wantExtract)
}
if got := resp.Header.Get("X-Extract-Top"); got != strconv.Itoa(tt.wantTop) {
t.Errorf("%s: ExtractTop = %s, want %d", tt.query, got, tt.wantTop)
}
})
}
}

View File

@@ -28,6 +28,9 @@ type ProxyExecutionMeta struct {
Mode string `json:"mode"`
Tag string `json:"tag,omitempty"`
Used string `json:"used"`
// Attempts is how many proxies were tried; >1 means a challenged proxy was
// rotated out in tag-pool mode.
Attempts int `json:"attempts,omitempty"`
}
type ResilientConfig struct {
@@ -171,56 +174,83 @@ func (rs *ResilientSearcher) searchWithProtection(ctx context.Context, engine Se
attemptMeta := rs.baseProxyMeta(policy)
startedAt := time.Now()
result := RetryableSearch(ctx, rs.retryCfg, engine.Name(), func(callCtx context.Context) ([]SearchResult, error) {
limiter := engine.GetRateLimiter()
if limiter != nil {
if err := limiter.Wait(callCtx); err != nil {
return nil, normalizeLimiterWaitErr(callCtx, err)
}
}
attemptQuery := q
proxyURL := ""
reportToRegistry := false
attemptMeta = rs.baseProxyMeta(policy)
switch policy.Mode {
case ProxyModeOff:
attemptQuery.ProxyURL = ""
attemptMeta.Used = "direct"
case ProxyModeRequestURL:
proxyURL = q.ProxyURL
attemptQuery.ProxyURL = proxyURL
attemptMeta.Used = MaskProxyURL(proxyURL)
case ProxyModeTagPool:
proxyURL = rs.selectProxyForQuery(policy, q, engineCtx)
if proxyURL == "" {
return nil, fmt.Errorf("%w: no healthy proxy available for tag %q", ErrProxyUnavailable, policy.Tag)
}
attemptQuery.ProxyURL = proxyURL
reportToRegistry = policy.Tag != ""
attemptMeta.Used = MaskProxyURL(proxyURL)
}
requestCtx := proxyRequestContext(callCtx, engine.Name(), attemptQuery)
results, err := invokeEngine(requestCtx, engine, attemptQuery, isImage)
if reportToRegistry {
rs.reportProxyAttempt(engineCtx, proxyURL, err)
}
if err != nil && errors.Is(err, ErrCaptcha) && rs.proxyCfg.Proxies.Lanes.DropCookiesOnChallenge {
// Recompute lane key only to gate the call: empty key means we have no
// session to drop cookies for. The dropper recomputes the key itself
// when it actually needs to mutate lane state.
if !ProxyLaneKeyForTenant(engine.Name(), TenantFromContext(callCtx), attemptQuery, attemptQuery.ProxyURL).Empty() {
if dropper, ok := engine.(proxyLaneCookieDropper); ok {
dropper.DropProxyLaneCookies(callCtx, attemptQuery)
// lastProxyURL is the unmasked proxy of the last attempt, for rotation below.
lastProxyURL := ""
runOnce := func() RetryResult {
return RetryableSearch(ctx, rs.retryCfg, engine.Name(), func(callCtx context.Context) ([]SearchResult, error) {
limiter := engine.GetRateLimiter()
if limiter != nil {
if err := limiter.Wait(callCtx); err != nil {
return nil, normalizeLimiterWaitErr(callCtx, err)
}
}
}
return results, err
})
attemptQuery := q
proxyURL := ""
reportToRegistry := false
attemptMeta = rs.baseProxyMeta(policy)
switch policy.Mode {
case ProxyModeOff:
attemptQuery.ProxyURL = ""
attemptMeta.Used = "direct"
case ProxyModeRequestURL:
proxyURL = q.ProxyURL
attemptQuery.ProxyURL = proxyURL
attemptMeta.Used = MaskProxyURL(proxyURL)
case ProxyModeTagPool:
proxyURL = rs.selectProxyForQuery(policy, q, engineCtx)
if proxyURL == "" {
return nil, fmt.Errorf("%w: no healthy proxy available for tag %q", ErrProxyUnavailable, policy.Tag)
}
attemptQuery.ProxyURL = proxyURL
reportToRegistry = policy.Tag != ""
attemptMeta.Used = MaskProxyURL(proxyURL)
}
lastProxyURL = proxyURL
requestCtx := proxyRequestContext(callCtx, engine.Name(), attemptQuery)
results, err := invokeEngine(requestCtx, engine, attemptQuery, isImage)
if reportToRegistry {
rs.reportProxyAttempt(engineCtx, proxyURL, err)
}
if err != nil && errors.Is(err, ErrCaptcha) && rs.proxyCfg.Proxies.Lanes.DropCookiesOnChallenge {
// Recompute lane key only to gate the call: empty key means we have no
// session to drop cookies for. The dropper recomputes the key itself
// when it actually needs to mutate lane state.
if !ProxyLaneKeyForTenant(engine.Name(), TenantFromContext(callCtx), attemptQuery, attemptQuery.ProxyURL).Empty() {
if dropper, ok := engine.(proxyLaneCookieDropper); ok {
dropper.DropProxyLaneCookies(callCtx, attemptQuery)
}
}
}
return results, err
})
}
result := runOnce()
attemptMeta.Attempts = 1
// On a captcha/block/rate-limit (non-retryable inside RetryableSearch), if
// the tag pool has another healthy proxy, deprioritize the burned one and
// retry once with the next. Tag-pool only — direct/request-url/global can't
// rotate.
if result.Err != nil &&
policy.Mode == ProxyModeTagPool &&
policy.Tag != "" &&
rs.proxyRegistry != nil &&
IsProxyChallengeError(result.Err) &&
rs.proxyRegistry.HealthyCountForTag(policy.Tag) >= 2 &&
ctx.Err() == nil {
rs.proxyRegistry.ReportChallenged(engineCtx, lastProxyURL)
WithRequestEngine(ctx, engine.Name()).WithError(result.Err).
Debug("Challenged proxy rotated out, retrying once with next proxy")
result = runOnce()
attemptMeta.Attempts = 2
}
if result.Err != nil {
if shouldRecordCircuitFailure(result.Err) {
@@ -233,6 +263,14 @@ func (rs *ResilientSearcher) searchWithProtection(ctx context.Context, engine Se
return result.Results, attemptMeta, nil
}
// IsProxyChallengeError reports whether err is a captcha/block/rate-limit — an
// IP-reputation problem another proxy might dodge.
func IsProxyChallengeError(err error) bool {
return errors.Is(err, ErrCaptcha) ||
errors.Is(err, ErrBlocked) ||
errors.Is(err, ErrRateLimited)
}
func shouldRecordCircuitFailure(err error) bool {
return err != nil &&
!IsContextDone(err) &&

View File

@@ -1451,6 +1451,9 @@ func (s *Server) applyProxyHeaders(c *fiber.Ctx, meta ProxyExecutionMeta) {
c.Set("X-Proxy-Tag", tag)
}
c.Set("X-Proxy-Used", used)
if meta.Attempts > 1 {
c.Set("X-Proxy-Attempts", strconv.Itoa(meta.Attempts))
}
}
func setNetworkBytesHeader(c *fiber.Ctx, ctx context.Context) {

View File

@@ -116,14 +116,21 @@ func (s *Server) newExtractor() extractpkg.Extractor {
}
func (s *Server) rawExtractFetch(ctx context.Context, req extractpkg.ExtractRequest) (*extractpkg.FetchResponse, error) {
cfg := s.opts.Extract.Normalized()
return RawExtractFetch(ctx, req, s.opts.Extract, s.opts.FingerprintBrowserOpts.Insecure)
}
// RawExtractFetch performs the browserless extraction fetch: validate the
// target, issue a guarded HTTP GET, classify the status, and return the body
// capped to the byte budget. Shared by the HTTP server and the CLI.
func RawExtractFetch(ctx context.Context, req extractpkg.ExtractRequest, cfg extractpkg.Config, insecure bool) (*extractpkg.FetchResponse, error) {
cfg = cfg.Normalized()
if err := validateExtractTargetURL(ctx, req.URL, cfg.AllowPrivateNetworks); err != nil {
return nil, err
}
resp, err := RawSearchRequest(ctx, req.URL, Query{
ProxyURL: req.ProxyURL,
LangCode: req.LangCode,
Insecure: s.opts.FingerprintBrowserOpts.Insecure,
Insecure: insecure,
GuardPrivateNetworks: !cfg.AllowPrivateNetworks,
})
if err != nil {
@@ -135,7 +142,7 @@ func (s *Server) rawExtractFetch(ctx context.Context, req extractpkg.ExtractRequ
}
limit := int64(req.MaxBytes)
if limit <= 0 {
limit = int64(s.opts.Extract.Normalized().MaxBytes)
limit = int64(cfg.MaxBytes)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
@@ -159,7 +166,15 @@ func (s *Server) renderedExtractFetch(ctx context.Context, req extractpkg.Extrac
if err != nil {
return nil, err
}
page, err := browser.Navigate(WithRequestProxyURL(ctx, req.ProxyURL), req.URL)
return RenderExtractHTML(WithRequestProxyURL(ctx, req.ProxyURL), browser, req)
}
// RenderExtractHTML navigates an already-resolved browser to the target,
// returns its rendered HTML capped to the byte budget, and always closes the
// page. Shared by the HTTP server's BrowserResolver path and the CLI's
// one-shot browser. Callers own target validation and proxy gating.
func RenderExtractHTML(ctx context.Context, browser *Browser, req extractpkg.ExtractRequest) (*extractpkg.FetchResponse, error) {
page, err := browser.Navigate(ctx, req.URL)
if err != nil {
return nil, err
}
@@ -220,7 +235,17 @@ func validateExtractTargetURL(ctx context.Context, rawURL string, allowPrivateNe
}
func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope, q Query, format string) {
cfg := s.opts.Extract.Normalized()
EnrichEnvelopeWithExtraction(ctx, env, q, format, s.newExtractor(), s.opts.Extract)
}
// EnrichEnvelopeWithExtraction fills env.Results[*].Extracted by running the
// extractor over the top organic results, with candidate fill-in when a top
// result fails. It is shared by the HTTP search handler and the CLI so both
// apply the same depth bounds, batch deadline, and result selection. The
// extractor and cfg are supplied by the caller (the server reuses its
// long-lived browser pool; the CLI builds a one-shot browser).
func EnrichEnvelopeWithExtraction(ctx context.Context, env *Envelope, q Query, format string, extractor extractpkg.Extractor, cfg extractpkg.Config) {
cfg = cfg.Normalized()
if env == nil || !q.Extract || !cfg.Enabled {
return
}
@@ -231,11 +256,7 @@ func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope
if format == "text" {
contentFormat = "text"
}
extractor := s.newExtractor()
limit := q.ExtractTop
if limit <= 0 || limit > 5 {
limit = 3
}
limit := clampExtractTop(q.ExtractTop)
if limit > len(env.Results) {
limit = len(env.Results)
}
@@ -245,7 +266,7 @@ func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope
}
// Per-fetch timeouts bound a single URL; this aggregate deadline bounds the
// whole batch so a few slow/hanging targets can't stretch the search request
// whole batch so a few slow/hanging targets can't stretch the request
// open-endedly. The ceiling is derived from the per-URL budget (see
// Config.BatchTimeout) rather than a separate knob. When it fires, in-flight
// fetches are cancelled and any not yet started record a timeout error instead
@@ -256,7 +277,7 @@ func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope
extractOne := func(idx int) {
// Skip the fetch entirely if the batch budget is already spent.
if err := ctx.Err(); err != nil {
env.Results[idx].Extracted = &ExtractedContent{Error: sanitizeExtractError(err)}
env.Results[idx].Extracted = &ExtractedContent{Error: SanitizeExtractError(err)}
return
}
req := extractpkg.ExtractRequest{
@@ -270,14 +291,14 @@ func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope
}
result, err := extractor.Extract(ctx, req)
if err != nil {
env.Results[idx].Extracted = &ExtractedContent{Error: sanitizeExtractError(err)}
env.Results[idx].Extracted = &ExtractedContent{Error: SanitizeExtractError(err)}
return
}
content := result.Markdown
if contentFormat == "text" {
content = result.Text
}
if !extractedContentLooksUseful(content) {
if !ExtractedContentLooksUseful(content) {
env.Results[idx].Extracted = &ExtractedContent{Error: "empty extracted content"}
return
}
@@ -320,7 +341,10 @@ func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope
const minUsefulExtractRunes = 80
func extractedContentLooksUseful(content string) bool {
// ExtractedContentLooksUseful reports whether extracted page content is long
// enough to keep, rather than an empty/boilerplate shell. Shared by the HTTP
// server and the CLI so both apply the same threshold.
func ExtractedContentLooksUseful(content string) bool {
return len([]rune(strings.TrimSpace(content))) >= minUsefulExtractRunes
}
@@ -337,7 +361,7 @@ func extractedSuccessCount(results []Result) int {
func extractedResultSucceeded(result Result) bool {
return result.Extracted != nil &&
result.Extracted.Error == "" &&
extractedContentLooksUseful(result.Extracted.Content)
ExtractedContentLooksUseful(result.Extracted.Content)
}
func sendExtractResult(c *fiber.Ctx, format string, result *extractpkg.ExtractResult) error {
@@ -379,7 +403,9 @@ func parseBoolDefault(raw string, fallback bool) bool {
return raw == "1" || strings.EqualFold(raw, "true") || strings.EqualFold(raw, "yes")
}
func sanitizeExtractError(err error) string {
// SanitizeExtractError trims and length-bounds an extraction error for safe
// inclusion in a response payload. Shared by the HTTP server and the CLI.
func SanitizeExtractError(err error) string {
if err == nil {
return ""
}

View File

@@ -48,7 +48,6 @@ paths:
- $ref: "#/components/parameters/FilterQuery"
- $ref: "#/components/parameters/FeaturesQuery"
- $ref: "#/components/parameters/ExtractQuery"
- $ref: "#/components/parameters/ExtractTopQuery"
- $ref: "#/components/parameters/ExtractModeQuery"
- $ref: "#/components/parameters/MinRunesQuery"
- $ref: "#/components/parameters/FormatQuery"
@@ -330,7 +329,6 @@ paths:
- $ref: "#/components/parameters/MegaDedupeQuery"
- $ref: "#/components/parameters/MegaMergeQuery"
- $ref: "#/components/parameters/ExtractQuery"
- $ref: "#/components/parameters/ExtractTopQuery"
- $ref: "#/components/parameters/ExtractModeQuery"
- $ref: "#/components/parameters/MinRunesQuery"
- $ref: "#/components/parameters/FormatQuery"
@@ -834,25 +832,27 @@ components:
name: extract
in: query
required: false
description: Fetch and embed cleaned target-page content for top web results.
description: >
Fetch and embed cleaned target-page content for the top web results.
Accepts a boolean or an integer depth: `extract=0`/`false` disables
extraction; `extract=true`/`1` enriches the top result; `extract=N`
(1-5) enriches the top N results. The tuning params `extract_mode` and
`min_runes` imply `extract=true` (top 1) when present, unless
`extract=0` is set explicitly.
schema:
type: boolean
oneOf:
- type: boolean
- type: integer
minimum: 0
maximum: 5
default: false
ExtractTopQuery:
name: extract_top
in: query
required: false
description: Number of top organic results to enrich when `extract=true`.
schema:
type: integer
minimum: 1
maximum: 5
default: 3
ExtractModeQuery:
name: extract_mode
in: query
required: false
description: Extraction strategy for target pages.
description: >
Extraction strategy for target pages. Its presence implies
`extract=true` unless `extract=0` is set explicitly.
schema:
type: string
enum: [auto, fast, rendered]

View File

@@ -0,0 +1,54 @@
package ecosia
import (
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/karust/openserp/testutil"
)
// TestEcosiaPageTypeSelectors verifies that the selectors defined in selectors.go
// match (or don't match) real fixture HTML without needing a browser.
func TestEcosiaPageTypeSelectors(t *testing.T) {
t.Parallel()
tests := []struct {
fixture string
selector string
wantHit bool
}{
{"search_captcha.html", Selectors.Captcha, true},
{"search_captcha.html", Selectors.Mainline, false},
{"search_results.html", Selectors.Mainline, true},
{"search_results.html", Selectors.Captcha, false},
{"search_no_results.html", Selectors.Captcha, false},
}
for _, tt := range tests {
t.Run(tt.fixture+"/"+tt.selector, func(t *testing.T) {
t.Parallel()
assertSelector(t, tt.fixture, tt.selector, tt.wantHit)
})
}
}
func assertSelector(t *testing.T, fixture, selector string, wantHit bool) {
t.Helper()
resp := testutil.ResponseFromFixture(t, fixture)
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
t.Fatalf("parse fixture: %v", err)
}
got := doc.Find(selector).Length() > 0
if got != wantHit {
if wantHit {
t.Fatalf("selector %q not found in %s — update selectors.go", selector, fixture)
} else {
t.Fatalf("selector %q unexpectedly present in %s", selector, fixture)
}
}
}

View File

@@ -35,13 +35,17 @@ func startPage(start int) (pageNum, startRank int, err error) {
return pageNum, startRank, nil
}
// Cloudflare interstitial markers. URL/title are CF defaults.
// Cloudflare interstitial markers. URL/title are CF defaults; cfBodyMarkers are
// challenge-page phrases absent from a real SERP, shared by the browser
// (isCaptcha) and raw (isCaptchaDoc) paths. Bare "captcha" is omitted — too
// broad (appears in SERP snippets) and not on the interstitial anyway.
const (
cfURLPath = "cdn-cgi"
cfPageTitle = "just a moment"
cfBodyMarker = "not a bot"
cfURLPath = "cdn-cgi"
cfPageTitle = "just a moment"
)
var cfBodyMarkers = []string{"not a robot", "not a bot", "unusual traffic"}
// Ecosia implements core.SearchEngine for Ecosia SERP pages. Additional
// documentation at https://support.ecosia.org/article/447-search-features.
type Ecosia struct {
@@ -80,7 +84,16 @@ func (e *Ecosia) isCaptcha(page *rod.Page) bool {
if err != nil {
return false
}
return strings.Contains(strings.ToLower(html), cfBodyMarker)
lower := strings.ToLower(html)
if strings.Contains(lower, "cf-turnstile-response") {
return true
}
for _, m := range cfBodyMarkers {
if strings.Contains(lower, m) {
return true
}
}
return false
}
func (e *Ecosia) parseResult(elem *rod.Element, rank int, ad bool) (core.SearchResult, bool) {

View File

@@ -12,12 +12,28 @@ import (
"github.com/karust/openserp/core"
)
// isCaptchaDoc reports whether a parsed Ecosia document is a Cloudflare
// challenge rather than a SERP. Prefers the hidden Turnstile input, then falls
// back to the shared challenge-page body phrases (see cfBodyMarkers).
func isCaptchaDoc(doc *goquery.Document) bool {
if doc.Find(Selectors.Captcha).Length() > 0 {
return true
}
text := strings.ToLower(doc.Text())
for _, m := range cfBodyMarkers {
if strings.Contains(text, m) {
return true
}
}
return false
}
func classifyEcosiaRawHTML(body []byte) error {
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
if err != nil {
return err
}
if strings.Contains(strings.ToLower(doc.Text()), "captcha") {
if isCaptchaDoc(doc) {
return core.ErrCaptcha
}
if doc.Find("[data-test-id='web-no-results']").Length() > 0 ||

View File

@@ -44,13 +44,34 @@ func TestEcosiaImageResultParser(t *testing.T) {
func TestEcosiaClassifyRawHTML(t *testing.T) {
t.Parallel()
body, err := io.ReadAll(testutil.ResponseFromFixture(t, "search_no_results.html").Body)
if err != nil {
t.Fatalf("read fixture body: %v", err)
tests := []struct {
fixture string
want error
}{
{"search_no_results.html", core.ErrEmptyResult},
{"search_captcha.html", core.ErrCaptcha},
{"search_results.html", nil},
}
err = classifyEcosiaRawHTML(body)
if !errors.Is(err, core.ErrEmptyResult) {
t.Fatalf("expected %v for search_no_results.html, got %v", core.ErrEmptyResult, err)
for _, tt := range tests {
t.Run(tt.fixture, func(t *testing.T) {
t.Parallel()
body, err := io.ReadAll(testutil.ResponseFromFixture(t, tt.fixture).Body)
if err != nil {
t.Fatalf("read fixture body: %v", err)
}
got := classifyEcosiaRawHTML(body)
if tt.want == nil {
if got != nil {
t.Fatalf("expected nil for %s, got %v", tt.fixture, got)
}
return
}
if !errors.Is(got, tt.want) {
t.Fatalf("expected %v for %s, got %v", tt.want, tt.fixture, got)
}
})
}
}

View File

@@ -2,6 +2,7 @@ package ecosia
// Selectors is the single source of truth for Ecosia SERP CSS selectors.
var Selectors = struct {
Captcha string
Mainline string
Result string
Ad string
@@ -13,6 +14,10 @@ var Selectors = struct {
ImageSource string
ImageDims string
}{
// Captcha matches Ecosia's Cloudflare Turnstile interstitial. The hidden
// cf-turnstile-response input is present on every challenge page and never
// on a real SERP, so it's a precise marker for the raw (browserless) path.
Captcha: "input[name='cf-turnstile-response']",
Mainline: "[data-test-id='mainline']",
Result: "[data-test-id='mainline-result-web']",
Ad: "[data-test-id='mainline-result-ad']",

1
ecosia/testdata/search_captcha.html vendored Normal file
View File

@@ -0,0 +1 @@
<html xmlns="http://test.test" dir="ltr"><head><title>Just a moment...</title></head><body><main><h1>Confirm youre not a robot</h1><p>Our system has detected unusual traffic from your network. Please solve the challenge below to show youre not a robot.</p><div><div class="main-wrapper" role="main"><div class="main-content"><h2 id="FbRG5"></h2><p id="jpqrL2"></p><div id="BbLB6" style=""><div><div><input type="hidden" name="cf-turnstile-response" id="id_1"/></div></div></div><div id="TQpKs1" style=""><div>Verification successful. Waiting for www.ecosia.org to respond</div></div><div id="ROlTq4" class="spacer loading-verifying" style=""><div class="lds-ring"><div></div><div></div><div></div><div></div></div></div></div></div></div></main></body></html>

View File

@@ -8,4 +8,4 @@ npm install
node index.js
```
Edit the `text`, `extractTop`, or `extractMode` in [index.js](index.js) to tune it.
Edit the `text`, `extract` depth, or `extractMode` in [index.js](index.js) to tune it.

View File

@@ -4,14 +4,13 @@ import { OpenSERP } from "@openserp/sdk";
// const client = new OpenSERP({ apiKey: "<YOUR_API_TOKEN>", timeoutMs: 60_000 });
const client = new OpenSERP({ baseUrl: "http://localhost:7000", timeoutMs: 60_000 });
// `extract: true` fetches the top pages and returns their cleaned content
// alongside each result, so you get the page text in a single request.
// `extractTop` (max 5) controls how many results are enriched.
// `extract: N` fetches the top N pages (max 5) and returns their cleaned
// content alongside each result, so you get the page text in a single request.
// `extract: true` is shorthand for the top result.
const { results } = await client.search({
engine: "ecosia",
text: "what is a serp api",
extract: true,
extractTop: 2,
extract: 2,
extractMode: "auto",
});

View File

@@ -42,6 +42,12 @@ func (gogl *Google) getTotalResults(page *rod.Page) (int, error) {
return 0, core.ErrParser
}
// Stats div is absent on many locales; probe first so .Search doesn't block
// the full selector timeout.
if has, _, err := page.Has(Selectors.ResultStats); err != nil || !has {
return 0, nil
}
resultsStats, err := page.Timeout(gogl.GetSelectorTimeout()).Search(Selectors.ResultStats)
if err != nil {
return 0, errors.New("Result stats not found: " + err.Error())
@@ -147,7 +153,33 @@ func (gogl *Google) preparePage(page *rod.Page) {
}
}
// waitAnswersExpanded polls until the first PAA entry has expanded to a
// title+body (≥2 text lines) or maxWait elapses, replacing a flat 2s sleep.
func (gogl *Google) waitAnswersExpanded(ctx context.Context, answers rod.Elements, maxWait time.Duration) error {
if len(answers) == 0 || maxWait <= 0 {
return nil
}
deadline := time.Now().Add(maxWait)
for {
text, err := answers[0].Text()
if err == nil && len(strings.Split(text, "\n")) >= 2 {
return nil
}
if !time.Now().Before(deadline) {
return nil
}
if err := core.SleepContext(ctx, 100*time.Millisecond); err != nil {
return err
}
}
}
func (gogl *Google) acceptCookies(page *rod.Page) {
// Probe with Has first so a banner-less SERP doesn't block on .Search's full
// timeout (AGENTS.md: use Has for existence).
if has, _, err := page.Has(Selectors.CookieBtn); err != nil || !has {
return
}
diaglogBtns, err := page.Timeout(gogl.Timeout / 10).Search(Selectors.CookieBtn)
if err != nil {
gogl.logger.Debug("Cookie consent not found: %s", err)
@@ -331,7 +363,8 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor
}
}
if err := core.SleepContext(ctx, 2*time.Second); err != nil {
// Poll for expansion (usually 200-400ms) instead of a flat 2s sleep.
if err := gogl.waitAnswersExpanded(ctx, answers, 2*time.Second); err != nil {
return nil, err
}

View File

@@ -11,7 +11,7 @@ func main() {
defer recoverPanic()
if err := cmd.RootCmd.Execute(); err != nil {
logrus.Info(err)
// Cobra already prints the error to stderr; just set the exit code.
os.Exit(1)
}
}

View File

@@ -140,6 +140,41 @@ func (yand *Yandex) parseResults(results rod.Elements, pageNum int) []core.Searc
return searchResults
}
// Yandex hydrates the results list progressively, so the first parse can be
// short. After the list selector appears, re-poll briefly until we have the
// requested number of organic results or the grace period elapses.
const (
resultHydrationGrace = 2 * time.Second
resultPollInterval = 120 * time.Millisecond
)
func (yand *Yandex) waitForParsedResults(ctx context.Context, page *rod.Page, pageNum, wantOrganic int) ([]core.SearchResult, error) {
elements, _, err := core.WaitForElements(ctx, page, []string{Selectors.Results}, yand.GetSelectorTimeout())
if err != nil {
return nil, err
}
results := yand.parseResults(elements, pageNum)
if wantOrganic <= 0 {
return results, nil
}
deadline := time.Now().Add(resultHydrationGrace)
for core.CountOrganicResults(results) < wantOrganic && time.Now().Before(deadline) {
if err := core.SleepContext(ctx, resultPollInterval); err != nil {
return results, err
}
nextElements, eerr := page.Elements(Selectors.Results)
if eerr != nil || len(nextElements) <= len(elements) {
continue
}
elements = nextElements
results = yand.parseResults(nextElements, pageNum)
}
return results, nil
}
func yandexElementHasAdMarker(el *rod.Element) bool {
if el == nil {
return false
@@ -212,7 +247,14 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
}
defer core.DeferClosePage(ctx, page, &yand.Browser)()
elements, _, err := core.WaitForElements(ctx, page, []string{Selectors.Results}, yand.GetSelectorTimeout())
wantOrganic := query.Limit
if wantOrganic <= 0 || wantOrganic > pageSize {
wantOrganic = pageSize
}
if searchPage == startPage && skipOnFirstPage > 0 {
wantOrganic += skipOnFirstPage
}
r, err := yand.waitForParsedResults(ctx, page, searchPage, wantOrganic)
if err != nil {
if yand.isCaptcha(page) {
yand.logger.Error("Captcha detected: %s", url)
@@ -226,7 +268,6 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
return false, core.ErrSearchTimeout
}
r := yand.parseResults(elements, searchPage)
if searchPage == startPage && skipOnFirstPage > 0 {
r = skipOrganicResults(r, skipOnFirstPage)
}