11 Commits

Author SHA1 Message Date
Rustem Kamalov
10decd5740 Fix config argument, update deault config. 2026-04-04 19:32:23 +03:00
Rustem Kamalov
78a9f1e264 Merge pull request #22 from karust/dev
Add runtime proxy override, update readme
2026-04-04 18:57:56 +03:00
Rustem Kamalov
9f84a648f4 Update config and readme 2026-04-04 18:54:10 +03:00
Rustem Kamalov
3c07c7789a Runtime proxy override 2026-04-01 17:38:13 +03:00
Rustem Kamalov
3daa48e6a3 Proxy policy wiring and fail-closed execution
- replace proxy wiring with global and per-engine tag policies
- split stats endpoints and lock fail-closed proxy behavior with tests
2026-04-01 00:54:21 +03:00
Rustem Kamalov
8bec5578c0 Implement proxy support with configuration, pooling, and integration tests
- Added ProxyConfig and ProxyPool structures to manage proxy settings and rotation.
- Implemented normalization functions for proxy URLs and configurations.
- Created a new ProxyPool that supports failure tracking and round-robin selection.
- Integrated proxy handling into the ResilientSearcher for search queries.
- Added integration tests for various proxy scenarios including SOCKS5 and HTTP proxies.
- Updated server and resilience stats to include proxy information.
- Refactored search functions to utilize the new proxy client.
- Removed direct proxy handling from search_raw.go and yandex/search_raw.go, using the new core proxy client instead.
- Added unit tests for proxy configuration and pool behavior.
2026-03-30 22:46:03 +03:00
Rustem Kamalov
0146891076 Add bounded TTL cache for dedicated and mega endpoints 2026-03-30 00:31:49 +03:00
Rustem Kamalov
f219c78d84 Add retry/circuit breaker, configurable fallback and CORS.
Based in part on work from PR #21 by @Sai-Prashanth123, adapted and integrated with project-specific fixes.
2026-03-25 23:31:41 +03:00
Rustem Kamalov
0fcfc06baa Add healthcheck endpoint 2026-03-25 04:09:37 +03:00
Rustem Kamalov
a4d459761e Add custom browser path support 2026-03-17 02:18:51 +03:00
Rustem Kamalov
7cdd358cad Add filter param to Google. Add start param for supporting engines 2026-03-17 01:54:25 +03:00
41 changed files with 5381 additions and 691 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ logs.txt
.release .release
core/test/ core/test/
.aider* .aider*
.gocache/
openserp
.gomodcache/

View File

@@ -16,8 +16,13 @@ RUN go build -o /app/openserp .
FROM zenika/alpine-chrome:with-chromedriver FROM zenika/alpine-chrome:with-chromedriver
WORKDIR /usr/src/app
COPY --from=builder /app/openserp /usr/local/bin/openserp COPY --from=builder /app/openserp /usr/local/bin/openserp
ADD config.yaml /usr/src/app COPY config.yaml ./config.yaml
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:7000/health || exit 1
ENTRYPOINT ["openserp"] ENTRYPOINT ["openserp"]

145
README.md
View File

@@ -1,4 +1,4 @@
# OpenSERP (Search Engine Results Page) # OpenSERP (Search Engine Results)
![OpenSERP](/logo.svg) ![OpenSERP](/logo.svg)
@@ -6,18 +6,18 @@
[![Go Reference](https://pkg.go.dev/badge/github/karust/openserp?style=for-the-badge)](https://pkg.go.dev/github.com/karust/openserp) [![Go Reference](https://pkg.go.dev/badge/github/karust/openserp?style=for-the-badge)](https://pkg.go.dev/github.com/karust/openserp)
[![release](https://img.shields.io/github/release/karust/openserp)](https://github.com/karust/openserp/releases) [![release](https://img.shields.io/github/release/karust/openserp)](https://github.com/karust/openserp/releases)
<!--[![Docker Pulls](https://img.shields.io/docker/pulls/karust/openserp)](https://hub.docker.com/repository/docker/karust/openserp)--> <!-- [![Docker Pulls](https://img.shields.io/docker/pulls/karust/openserp)](https://hub.docker.com/repository/docker/karust/openserp) -->
**OpenSERP** provides free API access to multiple search engines including **[Google, Yandex, Baidu, Bing, DuckDuckGo]**. Get comprehensive search results without expensive API subscriptions! **OpenSERP** provides free API and CLI access to multiple search engines including **Google, Yandex, Baidu, Bing, and DuckDuckGo**. Get comprehensive search results without expensive API subscriptions!
## Features ## Features
- 🔍 **Multi-Engine Support**: Google, Yandex, Baidu, Bing, DuckDuckGo... - 🔍 **Multi-engine** - search with dedicated endpoints for each engine
- 🌐 **Megasearch**: Aggregate results from multiple engines simultaneously - 🌐 **Megasearch** - cross-engine aggregation with deduplication
- 🖼 **Images**: Image search is also available! - 🖼 **Images** - image search is also available
- 🎯 **Advanced Filtering**: Language, date range, file type, site-specific searches - 🎯 **Advanced filters** - language, date range, file type, and site queries
- 🌍 **Proxy Support**: HTTP/SOCKS5 proxy support - 🌍 **Configurable** - proxy, cache, and resilient mode support
- 🐳 **Docker Ready**: Easy deployment with Docker - 🐳 **Docker-ready** - local and container deployment
## Quick Start⚡ ## Quick Start⚡
@@ -31,36 +31,36 @@ docker run -p 127.0.0.1:7000:7000 -it karust/openserp serve -a 0.0.0.0 -p 7000
docker compose up --build docker compose up --build
``` ```
### From Source ### From source
```bash ```bash
# Clone and build
git clone https://github.com/karust/openserp.git git clone https://github.com/karust/openserp.git
cd openserp cd openserp
go build -o openserp . go build -o openserp .
# Run the server
./openserp serve ./openserp serve
``` ```
## 🌐 Megasearch & Megaimage - Search Everything at Once! ## 🌐 Megasearch & Megaimage
**Megasearch** aggregates results from multiple engines simultaneously with automatic deduplication. **Megaimage** does the same for image searches! Search all engines at once:
### Megasearch (Web Results)
```bash ```bash
# Search ALL engines at once curl "http://127.0.0.1:7000/mega/search?text=golang&limit=10"
curl "http://localhost:7000/mega/search?text=golang&limit=10"
# Pick specific engines
curl "http://localhost:7000/mega/search?text=golang&engines=duckduckgo,bing&limit=15"
# Advanced filtering
curl "http://localhost:7000/mega/search?text=Donald+Trump&engines=duckduckgo,bing&limit=20&date=20251005..20251005&lang=EN"
``` ```
- API response example: Search only selected engines:
```bash
curl "http://127.0.0.1:7000/mega/search?text=golang&engines=duckduckgo,bing&limit=15"
```
Advanced filtering:
```bash
curl "http://127.0.0.1:7000/mega/search?text=Donald+Trump&engines=duckduckgo,bing&limit=20&date=20251005..20251005&lang=EN"
```
API response example:
```json ```json
[ [
@@ -68,48 +68,46 @@ curl "http://localhost:7000/mega/search?text=Donald+Trump&engines=duckduckgo,bin
"rank": 1, "rank": 1,
"url": "https://en.wikipedia.org/wiki/Golden_Retriever", "url": "https://en.wikipedia.org/wiki/Golden_Retriever",
"title": "Golden Retriever - Wikipedia", "title": "Golden Retriever - Wikipedia",
"description": "The Golden Retriever is a Scottish breed of retriever dog of medium size. It is characterised by a gentle and affectionate nature and a striking golden coat. It is a working dog, and registration is subject to successful completion of a working trial. [2] It is commonly kept as a companion dog and is among the most frequently registered breeds in several Western countries; some may compete in ...", "description": "The Golden Retriever is a Scottish breed of retriever dog of medium size. It is characterised by a gentle and affectionate nature and a striking golden coat.",
"ad": false, "ad": false,
"engine": "duckduckgo" "engine": "duckduckgo"
}, },
{ {
"rank": 2, "rank": 2,
"url": "https://www.bing.com/ck/a?!&&p=6f15ac4589858d0a104cd6f55cc8e91e8d8d6da91f905b626921f67f2323a467JmltdHM9MTc1OTE5MDQwMA&ptn=3&ver=2&hsh=4&fclid=2357c2f4-6131-68de-359f-d48c607c691d&u=a1aHR0cHM6Ly93d3cuZ29sZGVucmV0cmlldmVyZm9ydW0uY29tL3RocmVhZHMvdW5kZXJzdGFuZGluZy13aHktZ29sZGVuLXJldHJpZXZlciVFMiU4MCU5OXMtbGlmZXNwYW4taGFsdmVkLWluLXRoZS1sYXN0LTM1LXllYXJzLjM1NzMyMi8&ntb=1", "url": "https://www.bing.com/ck/a?!&&p=6f15ac4589858d0a104cd6f55cc8",
"title": "Golden Retriever Dog Forums\nhttps://www.goldenretrieverforum.com threads understanding-why-g…", "title": "Golden Retriever Dog Forums",
"description": "Oct 20, 2024 · Back in the 1970s, Golden Retrievers routinely lived until 16 and 17 years old, they are now living until 9 or 10 years old. Golden Retrievers seem to be dying mostly of bone …", "description": "Oct 20, 2024 · Back in the 1970s, Golden Retrievers routinely lived until 16 and 17 years old, they are now...",
"ad": false, "ad": false,
"engine": "bing" "engine": "bing"
}, },
{ {
"rank": 3, "rank": 3,
"url": "http://www.baidu.com/link?url=2544q3ugc68j0scVxdpWCSX-gl2AmuCy1l7uRR3loIfS1hmJWMiJKW4MDGWoZrLE7X-ybu1L7T8PspoL7iy_dK", "url": "http://www.baidu.com/link?url==2544q3ugc68j0scVxdpWCSX-gl2AmuCy1l7uRR3loIfS1",
"title": "golden retrievers是什么意思_golden retrievers怎么读_解释_用法...", "title": "golden retrievers是什么意思",
"description": "\n\n2025年9月21日golden retrievers 读音:美英 golden retrievers基本解释 金毛猎犬 分词解释 golden金(黄)色的 retrievers寻猎物犬( retriever的名词复数 ) 词组短语 golden retrieversfor sale出售金毛寻回犬 golden retrieversnear me我附近的金毛寻回犬 golden retrieverspuppies金毛寻回犬幼犬...\ndanci.gei6.com/golden...retrievers...", "description": "2025年9月21日golden retrievers 读音:美英 golden retrievers基本解释 金毛猎犬 分词解释 golden金(黄)色的...",
"ad": false, "ad": false,
"engine": "baidu" "engine": "baidu"
} }
] ]
``` ```
### Megaimage (Image Results) Image search:
```bash ```bash
# Search images across ALL engines curl "http://127.0.0.1:7000/mega/image?text=golang logo&limit=20"
curl "http://localhost:7000/mega/image?text=golang logo&limit=20"
``` ```
### Available Engines List available engines:
```bash ```bash
# Check which engines are available curl "http://127.0.0.1:7000/mega/engines"
curl "http://localhost:7000/mega/engines"
``` ```
**Available engines:** `google`, `yandex`, `baidu`, `bing`, `duckduckgo` **Available engines:** `google`, `yandex`, `baidu`, `bing`, `duckduckgo`
## 🔍 Individual Engine APIs ## 🔍 Individual Engine APIs
### Search Parameters Common query parameters:
| Parameter | Description | Example | | Parameter | Description | Example |
| --------- | -------------------- | --------------------------------- | | --------- | -------------------- | --------------------------------- |
@@ -119,52 +117,63 @@ curl "http://localhost:7000/mega/engines"
| `file` | File extension | `PDF`, `DOC`, `XLS` | | `file` | File extension | `PDF`, `DOC`, `XLS` |
| `site` | Site-specific search | `github.com`, `stackoverflow.com` | | `site` | Site-specific search | `github.com`, `stackoverflow.com` |
| `limit` | Number of results | `10`, `25`, `50` | | `limit` | Number of results | `10`, `25`, `50` |
| `answers` | Include Q&A results | `true`, `false` |
### Individual Engine Examples Engine-specific parameters:
| Parameter | Supported engines | Notes |
| --------- | ----------------------------------- | ------------------------------------------------------------------ |
| `start` | `google`, `bing`, `yandex`, `baidu` | Web search pagination offset. |
| `filter` | `google` | Duplicate filter (`true` hides similar, `false` includes similar). |
| `answers` | `google` | Include Google answer boxes in output with negative ranks. |
Examples:
```bash ```bash
# DuckDuckGo search curl "http://127.0.0.1:7000/duck/search?text=golang&limit=7"
curl "http://localhost:7000/duck/search?text=golang&limit=7" curl "http://127.0.0.1:7000/google/search?text=golang&lang=EN&limit=10"
curl "http://127.0.0.1:7000/bing/search?text=golang&limit=10&start=20"
# Google search curl "http://127.0.0.1:7000/yandex/search?text=golang&limit=10&start=10"
curl "http://localhost:7000/google/search?text=golang&lang=EN&limit=10" curl "http://127.0.0.1:7000/bing/image?text=golang&limit=20"
``` ```
### Image Search ## 🌍 Proxy Support
OpenSERP supports HTTP and SOCKS5 proxies.
Simple global proxy:
```bash ```bash
# Bing Images
curl "http://localhost:7000/bing/image?text=golang&limit=20"
# Baidu Images
curl "http://localhost:7000/baidu/image?text=golang&limit=15"
```
## 🌐 Proxy Support
OpenSERP supports HTTP and SOCKS5 proxies with authentication:
```bash
# SOCKS5 proxy
./openserp serve --proxy socks5://127.0.0.1:1080 ./openserp serve --proxy socks5://127.0.0.1:1080
# HTTP proxy with authentication
./openserp search bing "query" --proxy http://user:pass@127.0.0.1:8080 ./openserp search bing "query" --proxy http://user:pass@127.0.0.1:8080
``` ```
Advanced proxy configuration is available in [config.yaml](./config.yaml).
You can enable tagged proxy pools and per-request override via `X-Use-Proxy: <tag>` or `X-Use-Proxy: direct`.
## Health & Stats
```bash
curl -i "http://127.0.0.1:7000/health"
curl "http://127.0.0.1:7000/stats"
curl "http://127.0.0.1:7000/stats/cache"
curl "http://127.0.0.1:7000/stats/proxy"
curl "http://127.0.0.1:7000/stats/cb"
```
Useful response headers in server mode: `X-Cache`, `X-Fallback-Engine`,`X-Proxy-Mode`, `X-Proxy-Tag`, `X-Proxy-Used`
## License ## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License. See [LICENSE](LICENSE).
## 🤝 Contributing ## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome. Please feel free to submit a pull request.
## 👾 Issues & Support ## 👾 Issues & Support
If you encounter any issues or have questions: If you encounter issues or have questions:
- Open an issue on GitHub - Open an issue on GitHub
- Check existing issues for solutions - Check existing issues for similar reports
- Review the documentation above - Review the documentation and example config

View File

@@ -131,7 +131,7 @@ func (baid *Baidu) Search(query core.Query) ([]core.SearchResult, error) {
} }
desc = strings.ReplaceAll(desc, title, "") desc = strings.ReplaceAll(desc, title, "")
gR := core.SearchResult{Rank: i + 1, URL: linkText.String(), Title: title, Description: desc} gR := core.SearchResult{Rank: query.Start + i + 1, URL: linkText.String(), Title: title, Description: desc}
searchResults = append(searchResults, gR) searchResults = append(searchResults, gR)
} }

View File

@@ -1,65 +1,20 @@
package baidu package baidu
import ( import (
"context"
"crypto/tls"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/corpix/uarand" "github.com/corpix/uarand"
"github.com/karust/openserp/core" "github.com/karust/openserp/core"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
utls "github.com/refraction-networking/utls"
) )
func baiduRequest(searchURL string, query core.Query) (*http.Response, error) { func baiduRequest(searchURL string, query core.Query) (*http.Response, error) {
// Create HTTP transport with proxy baseClient, err := core.NewRawHTTPClient(query)
transport := &http.Transport{} if err != nil {
if query.ProxyURL != "" { return nil, err
proxyUrl, err := url.Parse(query.ProxyURL)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyUrl)
}
// Set insecure TLS
if query.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
hostname := strings.Split(addr, ":")[0]
config := &utls.Config{
ServerName: hostname,
InsecureSkipVerify: query.Insecure,
}
uconn := utls.UClient(rawConn, config, utls.HelloChrome_Auto)
if err := uconn.Handshake(); err != nil {
rawConn.Close()
return nil, err
}
return uconn, nil
}
baseClient := &http.Client{
Transport: transport,
Timeout: time.Second * 10,
} }
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
@@ -138,6 +93,11 @@ func Search(query core.Query) ([]core.SearchResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if query.Start > 0 {
for i := range results {
results[i].Rank = query.Start + i + 1
}
}
logrus.Debugf("Baidu Raw results : %v", results) logrus.Debugf("Baidu Raw results : %v", results)
return core.DeduplicateResults(results), nil return core.DeduplicateResults(results), nil

View File

@@ -66,6 +66,13 @@ func BuildURL(q core.Query) (string, error) {
if q.Limit != 0 { if q.Limit != 0 {
params.Add("rn", strconv.Itoa(q.Limit)) params.Add("rn", strconv.Itoa(q.Limit))
} }
if q.Start < 0 {
return "", errors.New("incorrect start provided")
}
if q.Start > 0 {
// Baidu uses "pn" as result offset for pagination.
params.Add("pn", strconv.Itoa(q.Start))
}
if len(params.Get("wd")) == 0 { if len(params.Get("wd")) == 0 {
return "", errors.New("Empty query built") return "", errors.New("Empty query built")

View File

@@ -140,7 +140,7 @@ func (bing *Bing) Search(query core.Query) ([]core.SearchResult, error) {
} }
bing.logger.Info("Found %d results (%d ads)", totalResults, len(adElements)) bing.logger.Info("Found %d results (%d ads)", totalResults, len(adElements))
rank := 0 rank := query.Start
for _, result := range organicElements { for _, result := range organicElements {
srchRes := core.SearchResult{} srchRes := core.SearchResult{}

View File

@@ -43,8 +43,15 @@ func BuildURL(q core.Query) (string, error) {
params.Add("setlang", strings.ToLower(q.LangCode)) params.Add("setlang", strings.ToLower(q.LangCode))
} }
// Set result offset (pagination) - Bing uses "first" parameter // Set result offset (pagination) - Bing uses "first" parameter.
if q.Limit > 0 { // When first is present, Bing may ignore custom count and return default page size.
if q.Start < 0 {
return "", errors.New("incorrect start provided")
}
if q.Start > 0 {
// Bing uses 1-based first-result index for pagination.
params.Add("first", strconv.Itoa(q.Start+1))
} else if q.Limit > 0 {
params.Add("count", strconv.Itoa(q.Limit)) params.Add("count", strconv.Itoa(q.Limit))
} }

30
cmd/proxy_policy.go Normal file
View File

@@ -0,0 +1,30 @@
package cmd
import (
"strings"
"github.com/karust/openserp/core"
)
func buildEngineProxyPolicyMap() map[string]string {
return map[string]string{
"google": config.GoogleConfig.Proxy,
"yandex": config.YandexConfig.Proxy,
"baidu": config.BaiduConfig.Proxy,
"bing": config.BingConfig.Proxy,
"duckduckgo": config.DuckDuckGoConfig.Proxy,
}
}
func buildNormalizedProxyConfig(runtime string) (core.ProxyConfig, error) {
return core.NormalizeProxyConfig(core.ProxyConfig{
Runtime: runtime,
Proxies: config.Proxies,
EnginePolicies: buildEngineProxyPolicyMap(),
})
}
func resolveEngineProxyPolicy(proxyCfg core.ProxyConfig, engineName string) core.ProxyPolicy {
engineKey := strings.ToLower(strings.TrimSpace(engineName))
return core.ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, proxyCfg.EnginePolicies[engineKey])
}

View File

@@ -2,6 +2,7 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings" "strings"
@@ -13,48 +14,104 @@ import (
) )
const ( const (
version = "0.5.4" version = "0.6.0"
defaultConfigFilename = "config" defaultConfigFilename = "config"
envPrefix = "OPENSERP" envPrefix = "OPENSERP"
) )
type Config struct { type Config struct {
App AppConfig `mapstructure:"app"` Server ServerConfig `mapstructure:"server"`
Config2Capcha Config2Captcha `mapstructure:"2captcha"` App AppConfig `mapstructure:"app"`
GoogleConfig core.SearchEngineOptions `mapstructure:"google"` Proxies core.ProxiesConfig `mapstructure:"proxies"`
YandexConfig core.SearchEngineOptions `mapstructure:"yandex"` Cache CacheConfig `mapstructure:"cache"`
BaiduConfig core.SearchEngineOptions `mapstructure:"baidu"` Resilience ResilienceConfig `mapstructure:"resilience"`
BingConfig core.SearchEngineOptions `mapstructure:"bing"` CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
DuckDuckGoConfig core.SearchEngineOptions `mapstructure:"duckduckgo"` CORS CORSConfig `mapstructure:"cors"`
Config2Capcha Config2Captcha `mapstructure:"2captcha"`
GoogleConfig EngineConfig `mapstructure:"google"`
YandexConfig EngineConfig `mapstructure:"yandex"`
BaiduConfig EngineConfig `mapstructure:"baidu"`
BingConfig EngineConfig `mapstructure:"bing"`
DuckDuckGoConfig EngineConfig `mapstructure:"duckduckgo"`
} }
type Config2Captcha struct { type Config2Captcha struct {
ApiKey string `mapstructure:"apikey"` ApiKey string `mapstructure:"apikey"`
} }
type AppConfig struct { type ServerConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Timeout int `mapstructure:"timeout"`
ConfigPath string `mapstructure:"config_path"` ConfigPath string `mapstructure:"config_path"`
IsBrowserHead bool `mapstructure:"head"`
IsLeaveHead bool `mapstructure:"leave_head"`
IsLeakless bool `mapstructure:"leakless"`
IsDebug bool `mapstructure:"debug"` IsDebug bool `mapstructure:"debug"`
IsVerbose bool `mapstructure:"verbose"` IsVerbose bool `mapstructure:"verbose"`
IsRawRequests bool `mapstructure:"raw_requests"` IsRawRequests bool `mapstructure:"raw_requests"`
ProxyURL string `mapstructure:"proxy"`
Insecure bool `mapstructure:"insecure"` Insecure bool `mapstructure:"insecure"`
}
type AppConfig struct {
Timeout int `mapstructure:"timeout"`
BrowserPath string `mapstructure:"browser_path"`
IsBrowserHead bool `mapstructure:"head"`
IsLeaveHead bool `mapstructure:"leave_head"`
IsLeakless bool `mapstructure:"leakless"`
IsStealth bool `mapstructure:"stealth"` IsStealth bool `mapstructure:"stealth"`
} }
type EngineConfig struct {
core.SearchEngineOptions `mapstructure:",squash"`
Proxy string `mapstructure:"proxy"`
}
type CacheConfig struct {
TTLSeconds int `mapstructure:"ttl_seconds"`
MaxSize int `mapstructure:"max_size"`
}
type ResilienceConfig struct {
MaxRetries int `mapstructure:"max_retries"`
AllowEndpointFallback bool `mapstructure:"allow_endpoint_fallback"`
}
type CircuitBreakerConfig struct {
Failures int `mapstructure:"failures"`
RecoverySeconds int `mapstructure:"recovery_seconds"`
Successes int `mapstructure:"successes"`
}
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
AllowOrigins string `mapstructure:"allow_origins"`
AllowMethods string `mapstructure:"allow_methods"`
AllowHeaders string `mapstructure:"allow_headers"`
MaxAge int `mapstructure:"max_age"`
}
var config = Config{} var config = Config{}
var flagToConfigKey = map[string]string{ var flagToConfigKey = map[string]string{
"config": "app.config_path", "host": "server.host",
"leave": "app.leave_head", "port": "server.port",
"raw": "app.raw_requests", "timeout": "app.timeout",
"2captcha_key": "2captcha.apikey", "config": "server.config_path",
"browser-path": "app.browser_path",
"verbose": "server.verbose",
"debug": "server.debug",
"head": "app.head",
"leakless": "app.leakless",
"raw": "server.raw_requests",
"leave": "app.leave_head",
"2captcha_key": "2captcha.apikey",
"proxy": "proxies.global",
"stealth": "app.stealth",
"insecure": "server.insecure",
"cache_ttl": "cache.ttl_seconds",
"cache_max_size": "cache.max_size",
"max_retries": "resilience.max_retries",
"allow_endpoint_fallback": "resilience.allow_endpoint_fallback",
"cb_failures": "circuit_breaker.failures",
"cb_recovery": "circuit_breaker.recovery_seconds",
"cb_successes": "circuit_breaker.successes",
} }
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
@@ -64,22 +121,15 @@ var RootCmd = &cobra.Command{
Version: version, Version: version,
SilenceUsage: true, SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
core.InitLogger(config.App.IsVerbose, config.App.IsDebug)
err := initializeConfig(cmd) err := initializeConfig(cmd)
if err != nil { if err != nil {
return err return err
} }
core.InitLogger(config.Server.IsVerbose, config.Server.IsDebug)
logrus.Debugf("Final config: %+v", config) logrus.Debugf("Final config: %+v", config)
return nil return nil
}, },
// Run: func(cmd *cobra.Command, args []string) {
// // Working with OutOrStdout/OutOrStderr allows us to unit test our command easier
// //out := cmd.OutOrStdout()
// logrus.Trace("Config:", config)
// },
} }
// Bind each cobra flag to its associated viper configuration (config file and environment variable) // Bind each cobra flag to its associated viper configuration (config file and environment variable)
@@ -121,14 +171,27 @@ func parseFlagValue(flg *pflag.Flag) (interface{}, error) {
// Initialize Viper // Initialize Viper
func initializeConfig(cmd *cobra.Command) error { func initializeConfig(cmd *cobra.Command) error {
v := viper.New() v := viper.New()
setConfigDefaults(v)
// Base name of the config file, without the file extension explicitConfigPath := strings.TrimSpace(cmd.Flag("config").Value.String())
v.SetConfigName(defaultConfigFilename) if explicitConfigPath == "" {
v.AddConfigPath(".") explicitConfigPath = strings.TrimSpace(os.Getenv(envPrefix + "_SERVER_CONFIG_PATH"))
}
if explicitConfigPath != "" {
v.SetConfigFile(explicitConfigPath)
} else {
// Base name of the config file, without the file extension
v.SetConfigName(defaultConfigFilename)
v.AddConfigPath(".")
}
// 1. Config file (lowest priority). Return an error if we cannot parse the config file. // 1. Config file (lowest priority). Return an error if we cannot parse the config file.
err := v.ReadInConfig() err := v.ReadInConfig()
if err != nil { if err != nil {
if explicitConfigPath != "" {
return fmt.Errorf("cannot read config %q: %w", explicitConfigPath, err)
}
err = fmt.Errorf("cannot read config: %v", err) err = fmt.Errorf("cannot read config: %v", err)
logrus.Warn(err) logrus.Warn(err)
} }
@@ -145,32 +208,143 @@ func initializeConfig(cmd *cobra.Command) error {
// 3. Command flags (highest priority). Bind the current command's flags to viper // 3. Command flags (highest priority). Bind the current command's flags to viper
bindFlags(cmd, v) bindFlags(cmd, v)
if err := validateRemovedConfigPaths(v); err != nil {
return err
}
// Dump Viper values to config struct // Dump Viper values to config struct
if err := validateEngineProxyTags(v); err != nil {
return err
}
err = v.Unmarshal(&config) err = v.Unmarshal(&config)
if err != nil { if err != nil {
return fmt.Errorf("cannot unmarshall config: %v", err) return fmt.Errorf("cannot unmarshall config: %v", err)
} }
if config.App.IsDebug { config.Proxies, err = core.NormalizeProxiesConfig(config.Proxies)
if err != nil {
return fmt.Errorf("invalid proxies config: %w", err)
}
if config.Server.IsDebug {
logrus.Debug("Viper config:") logrus.Debug("Viper config:")
v.Debug() v.Debug()
} }
return nil return nil
} }
func validateEngineProxyTags(v *viper.Viper) error {
for _, engineName := range []string{"google", "yandex", "baidu", "bing", "duckduckgo"} {
key := engineName + ".proxy"
if !v.IsSet(key) {
continue
}
raw := v.Get(key)
tag, ok := raw.(string)
if !ok {
return fmt.Errorf("invalid %s.proxy config: proxy must be a string tag", engineName)
}
if _, err := core.NormalizeProxyTag(tag); err != nil {
return fmt.Errorf("invalid %s.proxy config: %w", engineName, err)
}
}
return nil
}
func validateRemovedConfigPaths(v *viper.Viper) error {
legacyKeys := map[string]string{
"app.proxy": "use proxies.global or proxies.entries with per-engine proxy tags instead",
"proxy_pool": "use proxies.entries and proxies.health.failure_threshold instead",
"proxy_pool.urls": "use proxies.entries instead",
"proxy_pool.failure_threshold": "use proxies.health.failure_threshold instead",
"app.host": "move to server.host",
"app.port": "move to server.port",
"app.debug": "move to server.debug",
"app.verbose": "move to server.verbose",
"app.raw_requests": "move to server.raw_requests",
"app.insecure": "move to server.insecure",
"proxies.defaults": "use proxies.global or per-engine proxy tags instead",
"proxies.defaults.mode": "use proxies.global or per-engine proxy tags instead",
"proxies.defaults.tag": "use per-engine proxy tags on each engine instead",
"google.proxy.mode": "use google.proxy: <tag> or omit it for direct mode",
"google.proxy.tag": "use google.proxy: <tag>",
"yandex.proxy.mode": "use yandex.proxy: <tag> or omit it for direct mode",
"yandex.proxy.tag": "use yandex.proxy: <tag>",
"baidu.proxy.mode": "use baidu.proxy: <tag> or omit it for direct mode",
"baidu.proxy.tag": "use baidu.proxy: <tag>",
"bing.proxy.mode": "use bing.proxy: <tag> or omit it for direct mode",
"bing.proxy.tag": "use bing.proxy: <tag>",
"duckduckgo.proxy.mode": "use duckduckgo.proxy: <tag> or omit it for direct mode",
"duckduckgo.proxy.tag": "use duckduckgo.proxy: <tag>",
}
for key, hint := range legacyKeys {
if v.IsSet(key) {
return fmt.Errorf("config key %q is removed in proxy v2: %s", key, hint)
}
}
return nil
}
func setConfigDefaults(v *viper.Viper) {
v.SetDefault("server.host", "127.0.0.1")
v.SetDefault("server.port", 7070)
v.SetDefault("server.debug", false)
v.SetDefault("server.verbose", false)
v.SetDefault("server.raw_requests", false)
v.SetDefault("server.insecure", false)
v.SetDefault("app.timeout", 30)
v.SetDefault("app.browser_path", "")
v.SetDefault("app.head", false)
v.SetDefault("app.leave_head", false)
v.SetDefault("app.leakless", false)
v.SetDefault("app.stealth", false)
v.SetDefault("proxies.entries", []interface{}{})
v.SetDefault("proxies.global", "")
v.SetDefault("proxies.health.failure_threshold", core.DefaultProxyFailureThreshold)
v.SetDefault("cache.ttl_seconds", 300)
v.SetDefault("cache.max_size", 1000)
// Keep stage2 defaults stable even when config file is absent.
v.SetDefault("resilience.max_retries", 3)
v.SetDefault("resilience.allow_endpoint_fallback", false)
v.SetDefault("circuit_breaker.failures", 5)
v.SetDefault("circuit_breaker.recovery_seconds", 60)
v.SetDefault("circuit_breaker.successes", 2)
v.SetDefault("cors.enabled", true)
v.SetDefault("cors.allow_origins", "*")
v.SetDefault("cors.allow_methods", "GET, POST, OPTIONS")
v.SetDefault("cors.allow_headers", "Origin, Content-Type, Accept, Authorization, X-Use-Proxy")
v.SetDefault("cors.max_age", 86400)
}
func init() { func init() {
RootCmd.PersistentFlags().IntVarP(&config.App.Port, "port", "p", 7070, "Port number to run server") RootCmd.PersistentFlags().IntVarP(&config.Server.Port, "port", "p", 7070, "Port number to run server")
RootCmd.PersistentFlags().StringVarP(&config.App.Host, "host", "a", "127.0.0.1", "Host address to run server") RootCmd.PersistentFlags().StringVarP(&config.Server.Host, "host", "a", "127.0.0.1", "Host address to run server")
RootCmd.PersistentFlags().IntVarP(&config.App.Timeout, "timeout", "t", 30, "Timeout to fail request") RootCmd.PersistentFlags().IntVarP(&config.App.Timeout, "timeout", "t", 30, "Timeout to fail request")
RootCmd.PersistentFlags().StringVarP(&config.App.ConfigPath, "config", "c", "", "Configuration file path") RootCmd.PersistentFlags().StringVarP(&config.Server.ConfigPath, "config", "c", "", "Configuration file path")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsVerbose, "verbose", "v", false, "Use verbose output") RootCmd.PersistentFlags().StringVarP(&config.App.BrowserPath, "browser-path", "", "", "Custom browser binary path (Chrome/Chromium/Edge/Brave..)")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsDebug, "debug", "d", false, "Use debug output. Disable headless browser") 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.App.IsBrowserHead, "head", "", false, "Enable browser UI") 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 insure browser instances are closed after search")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsRawRequests, "raw", "r", false, "Disable browser usage, use HTTP requests") 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().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") RootCmd.PersistentFlags().StringVarP(&config.Config2Capcha.ApiKey, "2captcha_key", "", "", "2 captcha api key")
RootCmd.PersistentFlags().StringVarP(&config.App.ProxyURL, "proxy", "x", "", "HTTP or Socks5 proxy URL (e.g. http://user:pass@127.0.0.1:8080)") RootCmd.PersistentFlags().StringVarP(&config.Proxies.Global, "proxy", "x", "", "Force a single proxy for all engines (same as proxies.global)")
RootCmd.PersistentFlags().BoolVarP(&config.App.IsStealth, "stealth", "s", false, "Use stealth browser plugin") RootCmd.PersistentFlags().BoolVarP(&config.App.IsStealth, "stealth", "s", false, "Use stealth browser plugin")
RootCmd.PersistentFlags().BoolVarP(&config.App.Insecure, "insecure", "k", false, "Allow insecure TLS connections") RootCmd.PersistentFlags().BoolVarP(&config.Server.Insecure, "insecure", "k", false, "Allow insecure TLS connections")
RootCmd.PersistentFlags().IntVar(&config.Cache.TTLSeconds, "cache_ttl", 300, "Cache TTL in seconds (0 to disable)")
RootCmd.PersistentFlags().IntVar(&config.Cache.MaxSize, "cache_max_size", 1000, "Maximum number of cached responses")
RootCmd.PersistentFlags().IntVar(&config.Resilience.MaxRetries, "max_retries", 3, "Max retry attempts per search engine (0 to disable)")
RootCmd.PersistentFlags().BoolVar(&config.Resilience.AllowEndpointFallback, "allow_endpoint_fallback", false, "Allow dedicated endpoints to fallback to other engines")
RootCmd.PersistentFlags().IntVar(&config.CircuitBreaker.Failures, "cb_failures", 5, "Consecutive failures before circuit breaker opens")
RootCmd.PersistentFlags().IntVar(&config.CircuitBreaker.RecoverySeconds, "cb_recovery", 60, "Seconds before retrying an engine with open circuit")
RootCmd.PersistentFlags().IntVar(&config.CircuitBreaker.Successes, "cb_successes", 2, "Consecutive successful half-open checks needed to close circuit")
} }

View File

@@ -25,24 +25,46 @@ var searchCMD = &cobra.Command{
} }
func search(cmd *cobra.Command, args []string) { func search(cmd *cobra.Command, args []string) {
var err error engineType := normalizeEngineArg(args[0])
engineType := args[0]
query := core.Query{ query := core.Query{
Text: args[1], Text: args[1],
Limit: 10, Limit: 10,
ProxyURL: config.App.ProxyURL, Filter: true,
Insecure: config.App.Insecure, Insecure: config.Server.Insecure,
}
proxyRuntime := core.ProxyRuntimeBrowser
if config.Server.IsRawRequests {
proxyRuntime = core.ProxyRuntimeRaw
}
proxyCfg, err := buildNormalizedProxyConfig(proxyRuntime)
if err != nil {
logrus.Errorf("Error validating proxy config: %v", err)
return
}
policy := resolveEngineProxyPolicy(proxyCfg, engineType)
selectedProxy, err := selectCLIProxy(proxyCfg, policy)
if err != nil {
logrus.Errorf("Error selecting proxy for %s: %v", engineType, err)
return
}
if config.Server.IsRawRequests {
query.ProxyURL = selectedProxy
} }
logrus.Infof("Starting SERP search request using %s engine for query: %s", engineType, query.Text) logrus.Infof("Starting SERP search request using %s engine for query: %s", engineType, query.Text)
var results []core.SearchResult var results []core.SearchResult
if config.App.IsRawRequests { if config.Server.IsRawRequests {
logrus.Infof("Using raw requests mode for %s search", engineType) logrus.Infof("Using raw requests mode for %s search", engineType)
results, err = searchRaw(engineType, query) results, err = searchRaw(engineType, query)
} else { } else {
logrus.Infof("Using browser mode for %s search", engineType) logrus.Infof("Using browser mode for %s search", engineType)
results, err = searchBrowser(engineType, query) results, err = searchBrowser(engineType, query, selectedProxy)
} }
if err != nil { if err != nil {
@@ -60,42 +82,51 @@ func search(cmd *cobra.Command, args []string) {
fmt.Println(string(b)) fmt.Println(string(b))
} }
func searchBrowser(engineType string, query core.Query) ([]core.SearchResult, error) {
func searchBrowser(engineType string, query core.Query, browserProxyURL string) ([]core.SearchResult, error) {
var engine core.SearchEngine var engine core.SearchEngine
if core.IsAuthenticatedSocksProxyURL(browserProxyURL) {
return nil, fmt.Errorf(
"%w: browser runtime does not support authenticated SOCKS proxy %s",
core.ErrProxyUnavailable,
core.MaskProxyURL(browserProxyURL),
)
}
opts := core.BrowserOpts{ opts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead, // Disable headless if browser head mode is set IsHeadless: !config.App.IsBrowserHead,
IsLeakless: config.App.IsLeakless, IsLeakless: config.App.IsLeakless,
Timeout: time.Second * time.Duration(config.App.Timeout), Timeout: time.Second * time.Duration(config.App.Timeout),
LeavePageOpen: config.App.IsLeaveHead, LeavePageOpen: config.App.IsLeaveHead,
CaptchaSolverApiKey: config.Config2Capcha.ApiKey, CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
ProxyURL: config.App.ProxyURL, BrowserPath: config.App.BrowserPath,
Insecure: config.App.Insecure, ProxyURL: browserProxyURL,
Insecure: config.Server.Insecure,
UseStealth: config.App.IsStealth, UseStealth: config.App.IsStealth,
} }
if config.App.IsDebug { if config.Server.IsDebug {
opts.IsHeadless = false opts.IsHeadless = false
} }
browser, err := core.NewBrowser(opts) browser, err := core.NewBrowser(opts)
if err != nil { if err != nil {
logrus.Error(err) return nil, err
} }
switch strings.ToLower(engineType) { switch strings.ToLower(engineType) {
case "yandex": case "yandex":
engine = yandex.New(*browser, config.YandexConfig) engine = yandex.New(*browser, config.YandexConfig.SearchEngineOptions)
case "google": case "google":
engine = google.New(*browser, config.GoogleConfig) engine = google.New(*browser, config.GoogleConfig.SearchEngineOptions)
case "baidu": case "baidu":
engine = baidu.New(*browser, config.BaiduConfig) engine = baidu.New(*browser, config.BaiduConfig.SearchEngineOptions)
case "bing": case "bing":
engine = bing.New(*browser, config.BingConfig) engine = bing.New(*browser, config.BingConfig.SearchEngineOptions)
case "duck": case "duckduckgo":
engine = duckduckgo.New(*browser, config.DuckDuckGoConfig) engine = duckduckgo.New(*browser, config.DuckDuckGoConfig.SearchEngineOptions)
default: default:
logrus.Infof("No `%s` search engine found", engineType) return nil, fmt.Errorf("no %q search engine found", engineType)
} }
return engine.Search(query) return engine.Search(query)
@@ -114,13 +145,42 @@ func searchRaw(engineType string, query core.Query) ([]core.SearchResult, error)
case "bing": case "bing":
logrus.Warn("Bing does not support raw HTTP requests mode. Please use browser mode instead.") 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") return nil, fmt.Errorf("bing does not support raw requests mode")
case "duck": case "duckduckgo":
logrus.Warn("DuckDuckGo does not support raw HTTP requests mode. Please use browser mode instead.") 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") return nil, fmt.Errorf("duckduckgo does not support raw requests mode")
default: default:
logrus.Infof("No `%s` search engine found", engineType) return nil, fmt.Errorf("no %q search engine found", engineType)
}
}
func selectCLIProxy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) (string, error) {
if policy.Mode == core.ProxyModeOff {
return "", nil
}
if global := strings.TrimSpace(proxyCfg.Proxies.Global); global != "" {
return global, nil
}
if proxyCfg.Registry == nil {
return "", fmt.Errorf("%w: no proxy registry configured", core.ErrProxyUnavailable)
}
selected := proxyCfg.Registry.NextByTag(policy.Tag)
if selected == "" {
return "", fmt.Errorf("%w: no healthy proxy available for tag %q", core.ErrProxyUnavailable, policy.Tag)
}
return selected, nil
}
func normalizeEngineArg(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "duck":
return "duckduckgo"
default:
return strings.ToLower(strings.TrimSpace(raw))
} }
return nil, nil
} }
func init() { func init() {

View File

@@ -2,6 +2,8 @@ package cmd
import ( import (
"fmt" "fmt"
"strings"
"sync"
"time" "time"
"github.com/karust/openserp/baidu" "github.com/karust/openserp/baidu"
@@ -21,9 +23,7 @@ type rawEngine struct {
} }
func (r *rawEngine) Search(q core.Query) ([]core.SearchResult, error) { func (r *rawEngine) Search(q core.Query) ([]core.SearchResult, error) {
// Inject proxy settings from config q.Insecure = config.Server.Insecure
q.ProxyURL = config.App.ProxyURL
q.Insecure = config.App.Insecure
switch r.name { switch r.name {
case "google": case "google":
@@ -63,52 +63,309 @@ var serveCMD = &cobra.Command{
} }
func serve(cmd *cobra.Command, args []string) { func serve(cmd *cobra.Command, args []string) {
if config.App.IsRawRequests { corsCfg := core.DefaultCORSConfig()
corsCfg.AllowOrigins = config.CORS.AllowOrigins
corsCfg.AllowMethods = config.CORS.AllowMethods
corsCfg.AllowHeaders = config.CORS.AllowHeaders
corsCfg.MaxAge = config.CORS.MaxAge
proxyRuntime := core.ProxyRuntimeBrowser
if config.Server.IsRawRequests {
proxyRuntime = core.ProxyRuntimeRaw
}
proxyCfg, err := buildNormalizedProxyConfig(proxyRuntime)
if err != nil {
logrus.Errorf("invalid proxy configuration: %v", err)
return
}
if config.Server.IsRawRequests {
logrus.Warn("Browserless results are very inconsistent or may not even work!") logrus.Warn("Browserless results are very inconsistent or may not even work!")
serv := core.NewServer(config.App.Host, config.App.Port, serverOpts := buildServerOptions(corsCfg, proxyCfg)
serv := core.NewServerWithOptions(config.Server.Host, config.Server.Port, serverOpts,
&rawEngine{name: "google"}, &rawEngine{name: "google"},
&rawEngine{name: "yandex"}, &rawEngine{name: "yandex"},
&rawEngine{name: "baidu"}, &rawEngine{name: "baidu"},
) )
serv.Listen() if err := serv.Listen(); err != nil {
logrus.Error(err)
}
return return
} }
opts := core.BrowserOpts{ baseOpts := core.BrowserOpts{
IsHeadless: !config.App.IsBrowserHead, // Disable headless if browser head mode is set IsHeadless: !config.App.IsBrowserHead,
IsLeakless: config.App.IsLeakless, IsLeakless: config.App.IsLeakless,
Timeout: time.Second * time.Duration(config.App.Timeout), Timeout: time.Second * time.Duration(config.App.Timeout),
LeavePageOpen: config.App.IsLeaveHead, LeavePageOpen: config.App.IsLeaveHead,
CaptchaSolverApiKey: config.Config2Capcha.ApiKey, CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
ProxyURL: config.App.ProxyURL, BrowserPath: config.App.BrowserPath,
Insecure: config.App.Insecure, Insecure: config.Server.Insecure,
UseStealth: config.App.IsStealth, UseStealth: config.App.IsStealth,
} }
if config.Server.IsDebug {
if config.App.IsDebug { baseOpts.IsHeadless = false
opts.IsHeadless = false
} }
browser, err := core.NewBrowser(opts) engines, err := buildBrowserEngines(baseOpts, proxyCfg)
if err != nil { if err != nil {
logrus.Error(err) logrus.Error(err)
return return
} }
yand := yandex.New(*browser, config.YandexConfig) serverOpts := buildServerOptions(corsCfg, proxyCfg)
gogl := google.New(*browser, config.GoogleConfig) serv := core.NewServerWithOptions(config.Server.Host, config.Server.Port, serverOpts, engines...)
baidu := baidu.New(*browser, config.BaiduConfig) if err := serv.Listen(); err != nil {
bing := bing.New(*browser, config.BingConfig)
ddg := duckduckgo.New(*browser, config.DuckDuckGoConfig)
serv := core.NewServer(config.App.Host, config.App.Port, gogl, yand, baidu, bing, ddg)
err = serv.Listen()
if err != nil {
logrus.Error(err) logrus.Error(err)
} }
} }
func buildServerOptions(corsCfg core.CORSConfig, proxyCfg core.ProxyConfig) core.ServerOptions {
return core.ServerOptions{
CacheTTL: time.Duration(config.Cache.TTLSeconds) * time.Second,
CacheMaxSize: config.Cache.MaxSize,
EnableCORS: config.CORS.Enabled,
CORS: corsCfg,
AllowEndpointFallback: config.Resilience.AllowEndpointFallback,
Resilience: core.ResilientConfig{
Retry: core.RetryConfig{
MaxRetries: config.Resilience.MaxRetries,
InitialBackoff: 1 * time.Second,
MaxBackoff: 30 * time.Second,
BackoffFactor: 2.0,
},
CircuitBreaker: core.CircuitBreakerConfig{
FailureThreshold: config.CircuitBreaker.Failures,
RecoveryTimeout: time.Duration(config.CircuitBreaker.RecoverySeconds) * time.Second,
SuccessThreshold: config.CircuitBreaker.Successes,
},
Proxy: proxyCfg,
},
}
}
type browserPool struct {
mu sync.Mutex
base core.BrowserOpts
browser map[string]*core.Browser
}
func newBrowserPool(base core.BrowserOpts) *browserPool {
return &browserPool{
base: base,
browser: map[string]*core.Browser{},
}
}
func (p *browserPool) get(proxyURL string) (*core.Browser, error) {
key := strings.TrimSpace(proxyURL)
if key == "" {
key = "direct"
}
p.mu.Lock()
defer p.mu.Unlock()
if b, ok := p.browser[key]; ok {
return b, nil
}
opts := p.base
opts.ProxyURL = proxyURL
b, err := core.NewBrowser(opts)
if err != nil {
return nil, err
}
// Reuse one launched browser per unique effective proxy so startup stays lazy
// and engines with identical proxy policy don't spawn duplicate browser processes.
p.browser[key] = b
return b, nil
}
type pooledBrowserEngine struct {
name string
limiter *rate.Limiter
opts core.SearchEngineOptions
factory func(core.Browser, core.SearchEngineOptions) core.SearchEngine
pool *browserPool
mu sync.Mutex
engines map[string]core.SearchEngine
}
func (e *pooledBrowserEngine) Search(q core.Query) ([]core.SearchResult, error) {
engine, err := e.getOrCreate(q.ProxyURL)
if err != nil {
return nil, err
}
return engine.Search(q)
}
func (e *pooledBrowserEngine) SearchImage(q core.Query) ([]core.SearchResult, error) {
engine, err := e.getOrCreate(q.ProxyURL)
if err != nil {
return nil, err
}
return engine.SearchImage(q)
}
func (e *pooledBrowserEngine) IsInitialized() bool {
return true
}
func (e *pooledBrowserEngine) Name() string {
return e.name
}
func (e *pooledBrowserEngine) GetRateLimiter() *rate.Limiter {
return e.limiter
}
func (e *pooledBrowserEngine) getOrCreate(proxyURL string) (core.SearchEngine, error) {
key := strings.TrimSpace(proxyURL)
if key == "" {
key = "direct"
}
e.mu.Lock()
defer e.mu.Unlock()
if engine, ok := e.engines[key]; ok {
return engine, nil
}
browser, err := e.pool.get(proxyURL)
if err != nil {
return nil, err
}
engine := e.factory(*browser, e.opts)
e.engines[key] = engine
return engine, nil
}
type browserEngineSpec struct {
name string
opts core.SearchEngineOptions
factory func(core.Browser, core.SearchEngineOptions) core.SearchEngine
}
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)
},
},
{
name: "yandex",
opts: config.YandexConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return yandex.New(browser, opts)
},
},
{
name: "baidu",
opts: config.BaiduConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return baidu.New(browser, opts)
},
},
{
name: "bing",
opts: config.BingConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return bing.New(browser, opts)
},
},
{
name: "duckduckgo",
opts: config.DuckDuckGoConfig.SearchEngineOptions,
factory: func(browser core.Browser, opts core.SearchEngineOptions) core.SearchEngine {
return duckduckgo.New(browser, opts)
},
},
}
}
func buildBrowserEngines(baseOpts core.BrowserOpts, proxyCfg core.ProxyConfig) ([]core.SearchEngine, error) {
pool := newBrowserPool(baseOpts)
specs := browserEngineSpecs()
engines := make([]core.SearchEngine, 0, len(specs))
for _, spec := range specs {
policy := resolveEngineProxyPolicy(proxyCfg, spec.name)
if err := validateBrowserProxyPolicy(proxyCfg, policy); err != nil {
return nil, fmt.Errorf("browser proxy validation failed for engine %s: %w", spec.name, err)
}
opts := spec.opts
opts.Init()
engines = append(engines, &pooledBrowserEngine{
name: spec.name,
limiter: rate.NewLimiter(rate.Every(opts.GetRatelimit()), opts.RateBurst),
opts: opts,
factory: spec.factory,
pool: pool,
engines: map[string]core.SearchEngine{},
})
}
return engines, nil
}
func validateBrowserProxyPolicy(proxyCfg core.ProxyConfig, policy core.ProxyPolicy) error {
if policy.Mode != core.ProxyModeTagPool {
return nil
}
proxyURL := strings.TrimSpace(proxyCfg.Proxies.Global)
if proxyURL != "" {
return validateBrowserProxyURL(proxyURL)
}
for _, entry := range proxyCfg.Proxies.Entries {
if !entryHasTag(entry, policy.Tag) {
continue
}
if err := validateBrowserProxyURL(entry.URL); err != nil {
return err
}
}
return nil
}
func validateBrowserProxyURL(proxyURL string) error {
// Browser startup must stop immediately on authenticated SOCKS because Chrome
// cannot use that proxy shape reliably and retrying a different proxy hides the misconfiguration.
if core.IsAuthenticatedSocksProxyURL(proxyURL) {
return fmt.Errorf(
"%w: browser runtime does not support authenticated SOCKS proxy %s",
core.ErrProxyUnavailable,
core.MaskProxyURL(proxyURL),
)
}
return nil
}
func entryHasTag(entry core.ProxyEntryConfig, tag string) bool {
tag = strings.TrimSpace(strings.ToLower(tag))
if tag == "" {
return false
}
for _, entryTag := range entry.Tags {
if strings.TrimSpace(strings.ToLower(entryTag)) == tag {
return true
}
}
return false
}
func init() { func init() {
RootCmd.AddCommand(serveCMD) RootCmd.AddCommand(serveCMD)
} }

105
cmd/serve_test.go Normal file
View File

@@ -0,0 +1,105 @@
package cmd
import (
"strings"
"testing"
"github.com/karust/openserp/core"
)
func TestValidateBrowserProxyPolicyRejectsAuthenticatedSocks(t *testing.T) {
tests := []struct {
name string
proxyCfg core.ProxyConfig
policy core.ProxyPolicy
}{
{
name: "global authenticated socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Global: "socks5h://user:pass@127.0.0.1:1080",
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool},
},
{
name: "tag pool authenticated socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "socks5://user:pass@127.0.0.1:1080", Tags: []string{"us"}},
},
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "us"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBrowserProxyPolicy(tt.proxyCfg, tt.policy)
if err == nil {
t.Fatal("expected browser proxy validation to fail")
}
if !strings.Contains(err.Error(), "authenticated SOCKS proxy") {
t.Fatalf("expected explicit authenticated SOCKS error, got %v", err)
}
})
}
}
func TestValidateBrowserProxyPolicyAllowsHTTPAuthAndPlainSocks(t *testing.T) {
tests := []struct {
name string
proxyCfg core.ProxyConfig
policy core.ProxyPolicy
}{
{
name: "global http auth",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Global: "http://user:pass@127.0.0.1:8080",
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool},
},
{
name: "tag pool plain socks",
proxyCfg: core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "socks5://127.0.0.1:1080", Tags: []string{"eu"}},
},
},
},
policy: core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "eu"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateBrowserProxyPolicy(tt.proxyCfg, tt.policy); err != nil {
t.Fatalf("expected browser proxy validation to succeed, got %v", err)
}
})
}
}
func TestValidateBrowserProxyPolicyRejectsTaggedAuthenticatedSocksInPool(t *testing.T) {
proxyCfg := core.ProxyConfig{
Proxies: core.ProxiesConfig{
Entries: []core.ProxyEntryConfig{
{URL: "http://127.0.0.1:8080", Tags: []string{"default"}},
{URL: "socks5://user:pass@127.0.0.1:1080", Tags: []string{"default"}},
},
},
}
err := validateBrowserProxyPolicy(proxyCfg, core.ProxyPolicy{Mode: core.ProxyModeTagPool, Tag: "default"})
if err == nil {
t.Fatal("expected browser proxy validation to fail for tag pool")
}
if !strings.Contains(err.Error(), "authenticated SOCKS proxy") {
t.Fatalf("expected explicit authenticated SOCKS error, got %v", err)
}
}

View File

@@ -1,22 +1,60 @@
app: server:
host: 0.0.0.0 host: 0.0.0.0 # API host to bind
port: 7000 port: 7000 # API port to bind
debug: false debug: false # Enable debug logs and force browser UI mode
verbose: true verbose: true # Enable info-level request logs
timeout: 15 raw_requests: false # true = raw HTTP mode, false = browser mode
head: false insecure: true # Allow insecure TLS connections
leakless: false
leave_head: false
stealth: false
insecure: true
2captcha: app:
apikey: "123123123123123" timeout: 15 # Browser/search timeout in seconds
browser_path: "" # Custom browser binary path (chrome/chromium/edge..)
head: false # Show browser UI (headful mode)
leakless: false # Force browser process cleanup after request
leave_head: false # Keep tabs open after request for debugging
stealth: false # Enable stealth browser plugin
proxies:
# Force a single proxy for all engines.
# Same behavior as passing --proxy on the CLI.
#global: http://127.0.0.1:8080
# Advanced mode: define tagged proxy pools and opt engines in with `proxy: <tag>`.
#entries:
# - url: http://127.0.0.1:8080
# tags: [default, us]
# - url: socks5h://127.0.0.1:1080
# tags: [eu]
health:
failure_threshold: 3 # Disable proxy after this many consecutive failures
cache:
ttl_seconds: 60 # Dedicated endpoint cache TTL in seconds (0 disables cache)
max_size: 1000 # Maximum cached dedicated responses before oldest-entry eviction
resilience:
max_retries: 2 # Retry attempts per engine request (0 disables retries)
allow_endpoint_fallback: false # Keep dedicated endpoints engine-pure by default
# circuit_breaker:
# failures: 5 # Consecutive failures required to open circuit
# recovery_seconds: 60 # Wait time before moving open circuit to half-open
# successes: 2 # Consecutive half-open successes required to close circuit
cors:
enabled: true
allow_origins: "*"
allow_methods: "GET, POST, OPTIONS"
allow_headers: "Origin, Content-Type, Accept, Authorization, X-Use-Proxy"
max_age: 86400
# 2captcha:
# apikey: "123123123123123"
google: google:
rate_requests: 4 # Number of requests per Minute rate_requests: 4 # Allowed average requests per minute
rate_burst: 2 # Number of non-ratelimited requests per Minute rate_burst: 2 # Burst requests before limiter applies
captcha: true captcha: true # Enable captcha solver path
yandex: yandex:
rate_requests: 4 rate_requests: 4
@@ -25,10 +63,12 @@ yandex:
baidu: baidu:
rate_requests: 4 rate_requests: 4
rate_burst: 2 rate_burst: 2
# No proxy tag means direct traffic
bing: bing:
rate_requests: 4 rate_requests: 4
rate_burst: 2 rate_burst: 2
# No proxy tag means direct traffic
duckduckgo: duckduckgo:
rate_requests: 4 rate_requests: 4

View File

@@ -1,8 +1,11 @@
package core package core
import ( import (
"context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
@@ -23,6 +26,7 @@ type BrowserOpts struct {
LeavePageOpen bool // Leave pages and browser open LeavePageOpen bool // Leave pages and browser open
WaitLoadTime time.Duration // Time to wait till page loads WaitLoadTime time.Duration // Time to wait till page loads
CaptchaSolverApiKey string // 2Captcha api key CaptchaSolverApiKey string // 2Captcha api key
BrowserPath string // Explicit browser executable path
ProxyURL string // Proxy URL ProxyURL string // Proxy URL
Insecure bool // Allow insecure TLS connections Insecure bool // Allow insecure TLS connections
UseStealth bool // Use go-rod stealth plugin UseStealth bool // Use go-rod stealth plugin
@@ -51,35 +55,45 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) {
opts.Check() opts.Check()
logrus.Debugf("Browser options: %+v", opts) logrus.Debugf("Browser options: %+v", opts)
path, has := launcher.LookPath() path, err := resolveBrowserBinaryPath(opts.BrowserPath, launcher.LookPath)
logrus.Debug("Browser found: ", has) if err != nil {
return nil, err
}
// Create launcher // Create launcher
l := launcher.New().Bin(path).Leakless(opts.IsLeakless).Headless(opts.IsHeadless).Set("disable-blink-features", "AutomationControlled"). l := launcher.New().Leakless(opts.IsLeakless).Headless(opts.IsHeadless).Set("disable-blink-features", "AutomationControlled").
Delete("enable-automation") Delete("enable-automation")
if path != "" {
logrus.Debugf("Using browser binary: %s", path)
l = l.Bin(path)
}
// Configure proxy if specified // Configure proxy if specified
if opts.ProxyURL != "" { if opts.ProxyURL != "" {
normalizedProxyURL, err := NormalizeProxyURL(opts.ProxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %v", err)
}
opts.ProxyURL = normalizedProxyURL
proxyUrl, err := url.Parse(opts.ProxyURL) proxyUrl, err := url.Parse(opts.ProxyURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %v", err) return nil, fmt.Errorf("invalid proxy URL: %v", err)
} }
// Make sure the proxy URL includes the scheme when passed to launcher // Chrome's proxy-server flag must not contain credentials.
// This ensures proper handling of SOCKS5 proxies // Auth (if needed) is handled separately via DevTools auth callbacks.
proxyStr := proxyUrl.String() proxyStr := proxyURLForBrowserLaunch(proxyUrl)
logrus.Debugf("Setting up proxy: %s", proxyStr) logrus.Debugf("Setting up proxy: %s", MaskProxyURL(proxyStr))
l = l.Proxy(proxyStr) l = l.Proxy(proxyStr)
// Check if proxy has auth credentials // Check if proxy has auth credentials
if proxyUrl.User != nil { if proxyUrl.User != nil {
username := proxyUrl.User.Username() username := proxyUrl.User.Username()
logrus.Debugf("Using proxy authentication: %s:****", username) logrus.Debugf("Proxy credentials configured for %s proxy: %s:****", proxyUrl.Scheme, username)
// We'll handle auth in the Navigate method
} }
} }
var err error
b := Browser{BrowserOpts: opts} b := Browser{BrowserOpts: opts}
b.browserAddr, err = l.Launch() b.browserAddr, err = l.Launch()
@@ -91,6 +105,52 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) {
return &b, err return &b, err
} }
func proxyURLForBrowserLaunch(u *url.URL) string {
if u == nil {
return ""
}
clone := *u
// Chrome expects socks5 scheme in --proxy-server; socks5h is not accepted.
if clone.Scheme == "socks5h" {
clone.Scheme = "socks5"
}
clone.User = nil
clone.Path = ""
clone.RawPath = ""
clone.RawQuery = ""
clone.Fragment = ""
return clone.String()
}
func validateBrowserBinaryPath(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.IsDir() {
return fmt.Errorf("path points to a directory")
}
return nil
}
// resolveBrowserBinaryPath prefers an explicit browser path. If no explicit path is provided,
// it falls back to launcher autodiscovery and lets Rod handle auto-download when no binary is found.
func resolveBrowserBinaryPath(browserPath string, lookPath func() (string, bool)) (string, error) {
if browserPath != "" {
if err := validateBrowserBinaryPath(browserPath); err != nil {
return "", fmt.Errorf("invalid browser_path %q: %w", browserPath, err)
}
return browserPath, nil
}
path, has := lookPath()
if has {
return path, nil
}
return "", nil
}
// Check whether browser instance is already created // Check whether browser instance is already created
func (b *Browser) IsInitialized() bool { func (b *Browser) IsInitialized() bool {
if b.browserAddr != "" { if b.browserAddr != "" {
@@ -104,9 +164,14 @@ func (b *Browser) IsInitialized() bool {
func (b *Browser) Navigate(URL string) (*rod.Page, error) { func (b *Browser) Navigate(URL string) (*rod.Page, error) {
logrus.Debug("Navigate to: ", URL) logrus.Debug("Navigate to: ", URL)
b.browser = rod.New().ControlURL(b.browserAddr) browser := rod.New().ControlURL(b.browserAddr).Timeout(b.Timeout)
b.browser.MustConnect() if err := browser.Connect(); err != nil {
b.browser.SetCookies(nil) return nil, fmt.Errorf("browser connect failed: %w", err)
}
b.browser = browser
if err := b.browser.SetCookies(nil); err != nil {
return nil, fmt.Errorf("browser cookie reset failed: %w", err)
}
// Handle proxy authentication before any navigations // Handle proxy authentication before any navigations
if b.ProxyURL != "" { if b.ProxyURL != "" {
@@ -114,39 +179,66 @@ func (b *Browser) Navigate(URL string) (*rod.Page, error) {
// Always ignore certificate errors when using proxies // Always ignore certificate errors when using proxies
// This fixes the ERR_CERT_AUTHORITY_INVALID error for SOCKS5 proxies // This fixes the ERR_CERT_AUTHORITY_INVALID error for SOCKS5 proxies
b.browser.MustIgnoreCertErrors(true) if err := b.browser.IgnoreCertErrors(true); err != nil {
return nil, fmt.Errorf("configure proxy cert handling failed: %w", err)
}
if proxyUrl.User != nil { if proxyUrl.User != nil && (proxyUrl.Scheme == "http" || proxyUrl.Scheme == "https") {
username := proxyUrl.User.Username() username := proxyUrl.User.Username()
password, _ := proxyUrl.User.Password() password, _ := proxyUrl.User.Password()
// Launch auth handler before any navigation occurs // Launch auth handler before any navigation occurs
go b.browser.MustHandleAuth(username, password)() go func() {
if err := b.browser.HandleAuth(username, password)(); err != nil {
logrus.Debugf("Proxy auth handler stopped: %v", err)
}
}()
} else if proxyUrl.User != nil && (proxyUrl.Scheme == "socks5" || proxyUrl.Scheme == "socks5h") {
// This callback handles HTTP proxy auth challenges; it doesn't authenticate SOCKS proxies.
logrus.Debug("SOCKS proxy credentials are not handled by browser auth callback")
} }
} else if b.Insecure { } else if b.Insecure {
// Still respect the insecure flag if no proxy is used // Still respect the insecure flag if no proxy is used
b.browser.MustIgnoreCertErrors(true) if err := b.browser.IgnoreCertErrors(true); err != nil {
return nil, fmt.Errorf("configure insecure mode failed: %w", err)
}
} }
ua := strings.ReplaceAll(b.browser.MustVersion().UserAgent, "HeadlessChrome/", "Chrome/") version, err := b.browser.Version()
if err != nil {
return nil, fmt.Errorf("read browser version failed: %w", err)
}
ua := strings.ReplaceAll(version.UserAgent, "HeadlessChrome/", "Chrome/")
var page *rod.Page var page *rod.Page
if b.UseStealth { if b.UseStealth {
page = stealth.MustPage(b.browser) page, err = stealth.Page(b.browser)
page.MustEmulate(devices.Device{ if err != nil {
return nil, fmt.Errorf("create stealth page failed: %w", err)
}
err = page.Emulate(devices.Device{
AcceptLanguage: b.LanguageCode, AcceptLanguage: b.LanguageCode,
UserAgent: ua, UserAgent: ua,
}) })
if err != nil {
return nil, fmt.Errorf("emulate stealth page failed: %w", err)
}
} else { } else {
page = b.browser.MustPage("about:blank") page, err = b.browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
if err != nil {
return nil, fmt.Errorf("create page failed: %w", err)
}
page.MustEmulate(devices.Device{ err = page.Emulate(devices.Device{
AcceptLanguage: b.LanguageCode, AcceptLanguage: b.LanguageCode,
UserAgent: ua, UserAgent: ua,
}) })
if err != nil {
return nil, fmt.Errorf("emulate page failed: %w", err)
}
proto.EmulationSetDeviceMetricsOverride{ err = proto.EmulationSetDeviceMetricsOverride{
Width: 1920, Width: 1920,
Height: 1080, Height: 1080,
DeviceScaleFactor: 1, DeviceScaleFactor: 1,
@@ -154,25 +246,37 @@ func (b *Browser) Navigate(URL string) (*rod.Page, error) {
ScreenWidth: &[]int{1920}[0], ScreenWidth: &[]int{1920}[0],
ScreenHeight: &[]int{1080}[0], ScreenHeight: &[]int{1080}[0],
}.Call(page) }.Call(page)
if err != nil {
return nil, fmt.Errorf("set device metrics failed: %w", err)
}
} }
//EnableCustomStealth(page) //EnableCustomStealth(page)
err := page.Navigate(URL) timedPage := page.Timeout(b.Timeout)
err = timedPage.Navigate(URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Avoid panics from MustWaitLoad when the target navigates/closes mid-wait // Avoid panics from MustWaitLoad when the target navigates/closes mid-wait
if werr := page.WaitLoad(); werr != nil { if werr := timedPage.WaitLoad(); werr != nil {
logrus.Debugf("WaitLoad returned early: %v", werr) if errors.Is(werr, context.DeadlineExceeded) {
// Some engines keep loading background resources while the DOM is already usable.
// Treat load timeout as non-fatal and let engine-specific selector timeouts decide.
logrus.Debugf("WaitLoad timed out after %s; continuing with partial page state", b.Timeout)
} else {
logrus.Debugf("WaitLoad returned early: %v", werr)
}
} }
wait := page.MustWaitRequestIdle()
// may cause bugs with google // may cause bugs with google
if b.WaitRequests { if b.WaitRequests {
wait := timedPage.WaitRequestIdle(300*time.Millisecond, nil, nil, nil)
wait() wait()
} }
time.Sleep(2 * time.Second) time.Sleep(b.WaitLoadTime)
return page, nil return page, nil
} }

140
core/cache.go Normal file
View File

@@ -0,0 +1,140 @@
package core
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
)
type CacheEntry struct {
Data []byte
CreatedAt time.Time
ExpiresAt time.Time
}
// ResponseCache is a bounded in-memory TTL cache for dedicated endpoint responses.
type ResponseCache struct {
mu sync.Mutex
entries map[string]CacheEntry
ttl time.Duration
maxSize int
hits int
misses int
bypasses int
evictions int
}
func NewResponseCache(ttl time.Duration, maxSize int) *ResponseCache {
return &ResponseCache{
entries: make(map[string]CacheEntry),
ttl: ttl,
maxSize: maxSize,
}
}
func BuildCacheKey(engine string, action string, q Query) string {
raw := fmt.Sprintf(
"%s|%s|%s|%s|%s|%s|%s|%d|%d|%t|%t|%s",
engine,
action,
q.Text,
q.LangCode,
q.DateInterval,
q.Filetype,
q.Site,
q.Limit,
q.Start,
q.Filter,
q.Answers,
q.ProxyOverride,
)
hash := sha256.Sum256([]byte(raw))
return hex.EncodeToString(hash[:])
}
func (c *ResponseCache) Get(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.pruneExpiredLocked(time.Now())
entry, ok := c.entries[key]
if !ok {
c.misses++
return nil, false
}
c.hits++
return entry.Data, true
}
func (c *ResponseCache) Set(key string, data []byte) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
c.pruneExpiredLocked(now)
if _, exists := c.entries[key]; !exists && len(c.entries) >= c.maxSize {
c.evictOldestLocked()
}
c.entries[key] = CacheEntry{
Data: data,
CreatedAt: now,
ExpiresAt: now.Add(c.ttl),
}
}
func (c *ResponseCache) RecordBypass() {
c.mu.Lock()
defer c.mu.Unlock()
c.bypasses++
}
func (c *ResponseCache) Stats() map[string]interface{} {
c.mu.Lock()
defer c.mu.Unlock()
c.pruneExpiredLocked(time.Now())
return map[string]interface{}{
"status": true,
"entries": len(c.entries),
"hits": c.hits,
"misses": c.misses,
"bypasses": c.bypasses,
"evictions": c.evictions,
"ttl_seconds": int(c.ttl / time.Second),
"max_size": c.maxSize,
}
}
func (c *ResponseCache) pruneExpiredLocked(now time.Time) {
for key, entry := range c.entries {
if !now.Before(entry.ExpiresAt) {
delete(c.entries, key)
}
}
}
func (c *ResponseCache) evictOldestLocked() {
var (
oldestKey string
oldestCreated time.Time
)
for key, entry := range c.entries {
if oldestKey == "" || entry.CreatedAt.Before(oldestCreated) {
oldestKey = key
oldestCreated = entry.CreatedAt
}
}
if oldestKey != "" {
delete(c.entries, oldestKey)
c.evictions++
}
}

155
core/cache_test.go Normal file
View File

@@ -0,0 +1,155 @@
package core
import (
"testing"
"time"
)
func TestResponseCacheSetAndGet(t *testing.T) {
cache := NewResponseCache(5*time.Second, 10)
key := BuildCacheKey("google", "search", Query{Text: "golang", Limit: 10})
if _, ok := cache.Get(key); ok {
t.Fatal("expected initial cache miss")
}
data := []byte(`[{"rank":1}]`)
cache.Set(key, data)
got, ok := cache.Get(key)
if !ok {
t.Fatal("expected cache hit")
}
if string(got) != string(data) {
t.Fatalf("unexpected cached value: got %s want %s", got, data)
}
}
func TestResponseCacheExpiration(t *testing.T) {
cache := NewResponseCache(40*time.Millisecond, 10)
key := BuildCacheKey("google", "search", Query{Text: "expire"})
cache.Set(key, []byte(`[]`))
if _, ok := cache.Get(key); !ok {
t.Fatal("expected cache hit before expiration")
}
time.Sleep(60 * time.Millisecond)
if _, ok := cache.Get(key); ok {
t.Fatal("expected cache miss after expiration")
}
}
func TestResponseCacheEvictsOldestEntry(t *testing.T) {
cache := NewResponseCache(time.Minute, 2)
firstKey := BuildCacheKey("google", "search", Query{Text: "first"})
secondKey := BuildCacheKey("google", "search", Query{Text: "second"})
thirdKey := BuildCacheKey("google", "search", Query{Text: "third"})
cache.Set(firstKey, []byte(`["first"]`))
time.Sleep(10 * time.Millisecond)
cache.Set(secondKey, []byte(`["second"]`))
time.Sleep(10 * time.Millisecond)
cache.Set(thirdKey, []byte(`["third"]`))
if _, ok := cache.Get(firstKey); ok {
t.Fatal("expected oldest entry to be evicted")
}
if _, ok := cache.Get(secondKey); !ok {
t.Fatal("expected newer entry to remain cached")
}
if _, ok := cache.Get(thirdKey); !ok {
t.Fatal("expected newest entry to remain cached")
}
}
func TestResponseCacheStats(t *testing.T) {
cache := NewResponseCache(time.Minute, 2)
key := BuildCacheKey("google", "search", Query{Text: "stats"})
if _, ok := cache.Get(key); ok {
t.Fatal("expected miss for empty cache")
}
cache.Set(key, []byte(`[]`))
if _, ok := cache.Get(key); !ok {
t.Fatal("expected cache hit")
}
cache.RecordBypass()
stats := cache.Stats()
if got := stats["status"]; got != true {
t.Fatalf("expected enabled status, got %v", got)
}
if got := stats["entries"].(int); got != 1 {
t.Fatalf("expected 1 entry, got %d", got)
}
if got := stats["hits"].(int); got != 1 {
t.Fatalf("expected 1 hit, got %d", got)
}
if got := stats["misses"].(int); got != 1 {
t.Fatalf("expected 1 miss, got %d", got)
}
if got := stats["bypasses"].(int); got != 1 {
t.Fatalf("expected 1 bypass, got %d", got)
}
}
func TestBuildCacheKeyChangesWithPaginationAndFlags(t *testing.T) {
base := Query{
Text: "golang",
LangCode: "EN",
Limit: 10,
Start: 0,
Filter: true,
Answers: false,
}
baseKey := BuildCacheKey("google", "search", base)
if same := BuildCacheKey("google", "search", base); same != baseKey {
t.Fatal("expected deterministic key for same query")
}
if changed := BuildCacheKey("google", "search", Query{
Text: "golang",
LangCode: "EN",
Limit: 20,
Start: 0,
Filter: true,
Answers: false,
}); changed == baseKey {
t.Fatal("expected limit to affect cache key")
}
if changed := BuildCacheKey("google", "search", Query{
Text: "golang",
LangCode: "EN",
Limit: 10,
Start: 10,
Filter: true,
Answers: false,
}); changed == baseKey {
t.Fatal("expected start to affect cache key")
}
if changed := BuildCacheKey("google", "search", Query{
Text: "golang",
LangCode: "EN",
Limit: 10,
Start: 0,
Filter: false,
Answers: false,
}); changed == baseKey {
t.Fatal("expected filter to affect cache key")
}
if changed := BuildCacheKey("google", "search", Query{
Text: "golang",
LangCode: "EN",
Limit: 10,
Start: 0,
Filter: true,
Answers: true,
}); changed == baseKey {
t.Fatal("expected answers to affect cache key")
}
}

210
core/circuit_breaker.go Normal file
View File

@@ -0,0 +1,210 @@
package core
import (
"fmt"
"sync"
"time"
"github.com/sirupsen/logrus"
)
type CircuitState int
const (
CircuitClosed CircuitState = iota
CircuitOpen
CircuitHalfOpen
)
func (s CircuitState) String() string {
switch s {
case CircuitClosed:
return "closed"
case CircuitOpen:
return "open"
case CircuitHalfOpen:
return "half-open"
default:
return "unknown"
}
}
type CircuitBreakerConfig struct {
FailureThreshold int
RecoveryTimeout time.Duration
SuccessThreshold int
}
func DefaultCircuitBreakerConfig() CircuitBreakerConfig {
return CircuitBreakerConfig{
FailureThreshold: 5,
RecoveryTimeout: 60 * time.Second,
SuccessThreshold: 2,
}
}
// CircuitBreaker tracks failure state for one engine.
type CircuitBreaker struct {
mu sync.RWMutex
name string
state CircuitState
config CircuitBreakerConfig
failureCount int
successCount int
lastFailureTime time.Time
lastStateChange time.Time
}
func NewCircuitBreaker(name string, cfg CircuitBreakerConfig) *CircuitBreaker {
return &CircuitBreaker{
name: name,
state: CircuitClosed,
config: cfg,
lastStateChange: time.Now(),
}
}
func (cb *CircuitBreaker) AllowRequest() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case CircuitClosed:
return true
case CircuitOpen:
if time.Since(cb.lastFailureTime) >= cb.config.RecoveryTimeout {
cb.setState(CircuitHalfOpen)
logrus.Infof("[CircuitBreaker][%s] Recovery timeout elapsed, moving to half-open", cb.name)
return true
}
return false
case CircuitHalfOpen:
return true
default:
return true
}
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case CircuitHalfOpen:
cb.successCount++
if cb.successCount >= cb.config.SuccessThreshold {
cb.setState(CircuitClosed)
cb.failureCount = 0
cb.successCount = 0
logrus.Infof("[CircuitBreaker][%s] Recovered, circuit closed", cb.name)
}
case CircuitClosed:
cb.failureCount = 0
}
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.lastFailureTime = time.Now()
switch cb.state {
case CircuitClosed:
cb.failureCount++
if cb.failureCount >= cb.config.FailureThreshold {
cb.setState(CircuitOpen)
logrus.Warnf("[CircuitBreaker][%s] Circuit OPENED after %d consecutive failures (will retry in %s)",
cb.name, cb.failureCount, cb.config.RecoveryTimeout)
}
case CircuitHalfOpen:
cb.setState(CircuitOpen)
cb.successCount = 0
logrus.Warnf("[CircuitBreaker][%s] Failed during half-open, circuit re-opened", cb.name)
}
}
func (cb *CircuitBreaker) State() CircuitState {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
func (cb *CircuitBreaker) Stats() map[string]interface{} {
cb.mu.RLock()
defer cb.mu.RUnlock()
stats := map[string]interface{}{
"engine": cb.name,
"state": cb.state.String(),
"failure_count": cb.failureCount,
"last_changed": cb.lastStateChange.Format(time.RFC3339),
}
if cb.state == CircuitOpen {
remaining := cb.config.RecoveryTimeout - time.Since(cb.lastFailureTime)
if remaining < 0 {
remaining = 0
}
// Expose retry_in as integer seconds for easier client-side processing.
retryInSeconds := int64(0)
if remaining > 0 {
retryInSeconds = int64((remaining + time.Second - time.Nanosecond) / time.Second)
}
stats["retry_in"] = retryInSeconds
}
return stats
}
func (cb *CircuitBreaker) setState(state CircuitState) {
cb.state = state
cb.lastStateChange = time.Now()
}
type CircuitBreakerManager struct {
mu sync.RWMutex
breakers map[string]*CircuitBreaker
config CircuitBreakerConfig
}
func NewCircuitBreakerManager(cfg CircuitBreakerConfig) *CircuitBreakerManager {
return &CircuitBreakerManager{
breakers: make(map[string]*CircuitBreaker),
config: cfg,
}
}
func (m *CircuitBreakerManager) Get(engineName string) *CircuitBreaker {
m.mu.RLock()
if cb, ok := m.breakers[engineName]; ok {
m.mu.RUnlock()
return cb
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if cb, ok := m.breakers[engineName]; ok {
return cb
}
cb := NewCircuitBreaker(engineName, m.config)
m.breakers[engineName] = cb
return cb
}
func (m *CircuitBreakerManager) AllStats() []map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
stats := make([]map[string]interface{}, 0, len(m.breakers))
for _, cb := range m.breakers {
stats = append(stats, cb.Stats())
}
return stats
}
var ErrCircuitOpen = fmt.Errorf("circuit breaker is open - engine temporarily disabled")

View File

@@ -0,0 +1,165 @@
package core
import (
"testing"
"time"
)
func newTestCircuitBreaker(t *testing.T, cfg CircuitBreakerConfig) *CircuitBreaker {
t.Helper()
return NewCircuitBreaker("test-engine", cfg)
}
// TestCircuitBreaker_OpensAfterThreshold verifies that consecutive failures in closed state
// move the breaker to open exactly on configured threshold and block new requests.
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 3,
RecoveryTimeout: time.Second,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure()
cb.RecordFailure()
if cb.State() != CircuitClosed {
t.Fatalf("expected closed after 2 failures, got: %s", cb.State())
}
cb.RecordFailure()
if cb.State() != CircuitOpen {
t.Fatalf("expected open after %d failures, got: %s", cfg.FailureThreshold, cb.State())
}
if cb.AllowRequest() {
t.Error("expected request blocked in open state")
}
}
// TestCircuitBreaker_RecoveryToHalfOpen verifies timed recovery from open to half-open
// when recovery timeout elapses and a new request is attempted.
func TestCircuitBreaker_RecoveryToHalfOpen(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 2,
RecoveryTimeout: 50 * time.Millisecond,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure()
cb.RecordFailure()
if cb.State() != CircuitOpen {
t.Fatal("expected open")
}
time.Sleep(60 * time.Millisecond)
if !cb.AllowRequest() {
t.Error("should allow request after recovery timeout")
}
if cb.State() != CircuitHalfOpen {
t.Errorf("expected half-open, got: %s", cb.State())
}
}
// TestCircuitBreaker_HalfOpenSuccessClosesCircuit verifies that half-open state closes
// only after configured number of successful probes.
func TestCircuitBreaker_HalfOpenSuccessClosesCircuit(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 1,
RecoveryTimeout: 20 * time.Millisecond,
SuccessThreshold: 2,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure()
if cb.State() != CircuitOpen {
t.Fatalf("expected open, got: %s", cb.State())
}
time.Sleep(30 * time.Millisecond)
if !cb.AllowRequest() {
t.Fatal("expected request to pass in recovery window")
}
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected half-open after recovery timeout, got: %s", cb.State())
}
cb.RecordSuccess()
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected to stay half-open until success threshold reached, got: %s", cb.State())
}
cb.RecordSuccess()
if cb.State() != CircuitClosed {
t.Fatalf("expected closed after success threshold reached, got: %s", cb.State())
}
}
// TestCircuitBreaker_HalfOpenFailureReopens verifies that a failed probe in half-open
// immediately re-opens the circuit.
func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) {
cfg := CircuitBreakerConfig{
FailureThreshold: 1,
RecoveryTimeout: 20 * time.Millisecond,
SuccessThreshold: 1,
}
cb := newTestCircuitBreaker(t, cfg)
cb.RecordFailure()
time.Sleep(30 * time.Millisecond)
if !cb.AllowRequest() {
t.Fatal("expected probe request in half-open")
}
if cb.State() != CircuitHalfOpen {
t.Fatalf("expected half-open, got: %s", cb.State())
}
cb.RecordFailure()
if cb.State() != CircuitOpen {
t.Fatalf("expected open after failed half-open probe, got: %s", cb.State())
}
}
// TestCircuitBreaker_Stats verifies stats payload fields and that retry_in is exposed
// only when breaker is open.
func TestCircuitBreaker_Stats(t *testing.T) {
cb := NewCircuitBreaker("test-engine", DefaultCircuitBreakerConfig())
cb.RecordFailure()
stats := cb.Stats()
if stats["engine"] != "test-engine" {
t.Fatalf("expected engine=test-engine, got: %v", stats["engine"])
}
if stats["state"] != "closed" {
t.Fatalf("expected state=closed, got: %v", stats["state"])
}
if stats["failure_count"].(int) != 1 {
t.Fatalf("expected failure_count=1, got: %v", stats["failure_count"])
}
if _, ok := stats["retry_in"]; ok {
t.Fatalf("did not expect retry_in in closed state, got: %v", stats["retry_in"])
}
openCfg := CircuitBreakerConfig{FailureThreshold: 1, RecoveryTimeout: time.Second, SuccessThreshold: 1}
openCB := NewCircuitBreaker("open-engine", openCfg)
openCB.RecordFailure()
openStats := openCB.Stats()
retryIn, ok := openStats["retry_in"].(int64)
if !ok {
t.Fatalf("expected retry_in int64 in open state, got: %T", openStats["retry_in"])
}
if retryIn <= 0 {
t.Fatalf("expected retry_in > 0 in open state, got: %d", retryIn)
}
}
// TestCircuitBreakerManager_AllStats verifies manager creates and reports per-engine breakers.
func TestCircuitBreakerManager_AllStats(t *testing.T) {
mgr := NewCircuitBreakerManager(DefaultCircuitBreakerConfig())
mgr.Get("google")
mgr.Get("yandex")
stats := mgr.AllStats()
if len(stats) != 2 {
t.Errorf("expected 2 entries, got: %d", len(stats))
}
}

View File

@@ -54,15 +54,28 @@ func ConvertSearchResultsMap(searchResultsMap map[string]SearchResult) *[]Search
} }
type Query struct { type Query struct {
Text string Text string
LangCode string // eg. EN, ES, RU... LangCode string // eg. EN, ES, RU...
DateInterval string // format: YYYYMMDD..YYYMMDD - 20181010..20231010 DateInterval string // format: YYYYMMDD..YYYMMDD - 20181010..20231010
Filetype string // File extension to search. Filetype string // File extension to search.
Site string // Search site Site string // Search site
Limit int // Limit the number of results Limit int // Limit the number of results
Answers bool // Include question and answers from SERP page to results with negative indexes Start int // Search offset for pagination (Google uses 0, 10, 20...)
ProxyURL string // Proxy URL for raw requests Filter bool // Filter duplicates (google) (false: include similar, true: hide similar)
Insecure bool // Allow insecure TLS connections Answers bool // Include question and answers from SERP page to results with negative indexes
ProxyURL string // Proxy URL for raw requests
ProxyOverride string // Request-scoped proxy override: tag or direct
Insecure bool // Allow insecure TLS connections
}
func ComputePagination(start int, pageSize int) (int, int, error) {
if pageSize <= 0 {
return 0, 0, errors.New("pageSize must be > 0")
}
if start < 0 {
return 0, 0, errors.New("start must be >= 0")
}
return start / pageSize, start % pageSize, nil
} }
func (q Query) IsEmpty() bool { func (q Query) IsEmpty() bool {
@@ -85,11 +98,30 @@ func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error {
} }
searchQuery.Limit = limit searchQuery.Limit = limit
start, err := strconv.Atoi(reqCtx.Query("start", "0"))
if err != nil {
return err
}
if start < 0 {
return errors.New("start must be >= 0")
}
searchQuery.Start = start
searchQuery.Filter, err = strconv.ParseBool(reqCtx.Query("filter", "1"))
if err != nil {
return err
}
searchQuery.Answers, err = strconv.ParseBool(reqCtx.Query("answers", "0")) searchQuery.Answers, err = strconv.ParseBool(reqCtx.Query("answers", "0"))
if err != nil { if err != nil {
return err return err
} }
searchQuery.ProxyOverride, err = NormalizeProxyRequestOverride(reqCtx.Get("X-Use-Proxy"))
if err != nil {
return err
}
if searchQuery.IsEmpty() { if searchQuery.IsEmpty() {
return errors.New("Query cannot be empty") return errors.New("Query cannot be empty")
} }

74
core/http_client.go Normal file
View File

@@ -0,0 +1,74 @@
package core
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/url"
"strings"
"time"
utls "github.com/refraction-networking/utls"
)
const rawHTTPTimeout = 10 * time.Second
func NewRawHTTPClient(query Query) (*http.Client, error) {
transport, err := newRawTransport(query)
if err != nil {
return nil, err
}
return &http.Client{
Transport: transport,
Timeout: rawHTTPTimeout,
}, nil
}
func newRawTransport(query Query) (*http.Transport, error) {
transport := &http.Transport{}
if query.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
proxyURL, err := NormalizeProxyURL(query.ProxyURL)
if err != nil {
return nil, err
}
if proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
// Keep proxied requests on the standard transport path so SOCKS5/SOCKS5H
// resolution and routing are handled by the configured proxy correctly.
transport.Proxy = http.ProxyURL(parsed)
return transport, nil
}
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
hostname := strings.Split(addr, ":")[0]
config := &utls.Config{
ServerName: hostname,
InsecureSkipVerify: query.Insecure,
}
uconn := utls.UClient(rawConn, config, utls.HelloChrome_Auto)
if err := uconn.Handshake(); err != nil {
rawConn.Close()
return nil, err
}
return uconn, nil
}
return transport, nil
}

144
core/middleware.go Normal file
View File

@@ -0,0 +1,144 @@
package core
import (
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
)
type JSONErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Message string `json:"message,omitempty"`
}
type CORSConfig struct {
AllowOrigins string
AllowMethods string
AllowHeaders string
MaxAge int
}
func DefaultCORSConfig() CORSConfig {
return CORSConfig{
AllowOrigins: "*",
AllowMethods: "GET, POST, OPTIONS",
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Use-Proxy",
MaxAge: 86400,
}
}
func CORSMiddleware(cfg CORSConfig) fiber.Handler {
cfg = normalizeCORSConfig(cfg)
return func(c *fiber.Ctx) error {
c.Set("Access-Control-Allow-Origin", cfg.AllowOrigins)
c.Set("Access-Control-Allow-Methods", cfg.AllowMethods)
c.Set("Access-Control-Allow-Headers", cfg.AllowHeaders)
c.Set("Access-Control-Max-Age", fmt.Sprintf("%d", cfg.MaxAge))
if c.Method() == "OPTIONS" {
return c.SendStatus(fiber.StatusNoContent)
}
return c.Next()
}
}
// normalizeCORSConfig keeps CORS behavior predictable when config provides partial values.
func normalizeCORSConfig(cfg CORSConfig) CORSConfig {
defaults := DefaultCORSConfig()
if strings.TrimSpace(cfg.AllowOrigins) == "" {
cfg.AllowOrigins = defaults.AllowOrigins
}
if strings.TrimSpace(cfg.AllowMethods) == "" {
cfg.AllowMethods = defaults.AllowMethods
}
if strings.TrimSpace(cfg.AllowHeaders) == "" {
cfg.AllowHeaders = defaults.AllowHeaders
}
if cfg.MaxAge <= 0 {
cfg.MaxAge = defaults.MaxAge
}
return cfg
}
func RequestLoggerMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
start := time.Now()
err := c.Next()
latency := time.Since(start)
status := c.Response().StatusCode()
if err != nil {
if e, ok := err.(*fiber.Error); ok {
status = e.Code
} else {
status = fiber.StatusInternalServerError
}
}
logFields := logrus.Fields{
"method": c.Method(),
"path": c.Path(),
"status": status,
"latency": latency.String(),
"ip": c.IP(),
}
if query := c.Query("text"); query != "" {
logFields["query"] = query
}
entry := logrus.WithFields(logFields)
if status >= 500 {
entry.Errorf("%s - request failed", c.Path())
} else if status >= 400 {
entry.Warnf("%s - request error", c.Path())
} else {
entry.Infof("%s - request completed", c.Path())
}
return err
}
}
func JSONErrorMiddleware() fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
resp := JSONErrorResponse{
Error: statusText(code),
Code: code,
Message: err.Error(),
}
c.Set("Content-Type", "application/json")
return c.Status(code).JSON(resp)
}
}
func statusText(code int) string {
switch {
case code == 400:
return "bad_request"
case code == 404:
return "not_found"
case code == 429:
return "rate_limited"
case code == 503:
return "service_unavailable"
case code >= 400 && code < 500:
return "client_error"
case code >= 500:
return "server_error"
default:
return "error"
}
}

109
core/middleware_test.go Normal file
View File

@@ -0,0 +1,109 @@
package core
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestStatusText(t *testing.T) {
tests := []struct {
code int
expected string
}{
{400, "bad_request"},
{404, "not_found"},
{429, "rate_limited"},
{503, "service_unavailable"},
{401, "client_error"},
{500, "server_error"},
{200, "error"},
}
for _, tt := range tests {
result := statusText(tt.code)
if result != tt.expected {
t.Errorf("statusText(%d) = %s, want %s", tt.code, result, tt.expected)
}
}
}
// Middleware unit tests validate CORS behavior itself (header composition and preflight semantics).
func TestCORSMiddleware_UsesConfiguredHeaders(t *testing.T) {
app := fiber.New()
app.Use(CORSMiddleware(CORSConfig{
AllowOrigins: "https://example.com",
AllowMethods: "GET,OPTIONS",
AllowHeaders: "Authorization,Content-Type",
MaxAge: 1200,
}))
app.Get("/ping", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("unexpected allow-origin: %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET,OPTIONS" {
t.Fatalf("unexpected allow-methods: %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "Authorization,Content-Type" {
t.Fatalf("unexpected allow-headers: %q", got)
}
if got := resp.Header.Get("Access-Control-Max-Age"); got != "1200" {
t.Fatalf("unexpected max-age: %q", got)
}
}
func TestCORSMiddleware_OPTIONSReturnsNoContent(t *testing.T) {
app := fiber.New()
app.Use(CORSMiddleware(DefaultCORSConfig()))
app.Get("/ping", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(http.MethodOptions, "/ping", nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != fiber.StatusNoContent {
t.Fatalf("expected 204 for OPTIONS, got %d", resp.StatusCode)
}
}
func TestNormalizeCORSConfig_FillsMissingValues(t *testing.T) {
cfg := normalizeCORSConfig(CORSConfig{
AllowOrigins: "https://example.com",
})
def := DefaultCORSConfig()
if cfg.AllowOrigins != "https://example.com" {
t.Fatalf("expected custom allow_origins preserved, got %q", cfg.AllowOrigins)
}
if cfg.AllowMethods != def.AllowMethods {
t.Fatalf("expected default allow_methods, got %q", cfg.AllowMethods)
}
if cfg.AllowHeaders != def.AllowHeaders {
t.Fatalf("expected default allow_headers, got %q", cfg.AllowHeaders)
}
if cfg.MaxAge != def.MaxAge {
t.Fatalf("expected default max_age, got %d", cfg.MaxAge)
}
}
func TestDefaultCORSConfig_IncludesProxyOverrideHeader(t *testing.T) {
cfg := DefaultCORSConfig()
if got := cfg.AllowHeaders; !strings.Contains(got, "X-Use-Proxy") {
t.Fatalf("expected allow_headers to include X-Use-Proxy, got %q", got)
}
}

561
core/proxy.go Normal file
View File

@@ -0,0 +1,561 @@
package core
import (
"errors"
"fmt"
"net/url"
"sort"
"strings"
"sync"
"github.com/sirupsen/logrus"
)
const (
ProxyRuntimeBrowser = "browser"
ProxyRuntimeRaw = "raw"
ProxyModeOff = "off"
ProxyModeTagPool = "tag_pool"
DefaultProxyFailureThreshold = 3
ProxyOverrideDirect = "direct"
)
var supportedProxySchemes = map[string]struct{}{
"http": {},
"https": {},
"socks5": {},
"socks5h": {},
}
var ErrProxyUnavailable = errors.New("proxy unavailable")
type ProxyPolicy struct {
Mode string `json:"mode" mapstructure:"mode"`
Tag string `json:"tag,omitempty" mapstructure:"tag"`
}
type ProxyEntryConfig struct {
URL string `json:"url" mapstructure:"url"`
Tags []string `json:"tags" mapstructure:"tags"`
}
type ProxiesHealthConfig struct {
FailureThreshold int `json:"failure_threshold" mapstructure:"failure_threshold"`
}
type ProxiesConfig struct {
Global string `json:"global,omitempty" mapstructure:"global"`
Entries []ProxyEntryConfig `json:"entries" mapstructure:"entries"`
Health ProxiesHealthConfig `json:"health" mapstructure:"health"`
}
type ProxyConfig struct {
Runtime string // raw or browser runtime behavior
Proxies ProxiesConfig // canonical proxy inventory
EnginePolicies map[string]string // engine-specific proxy tags
Registry *ProxyRegistry // optional shared registry from caller
}
type ProxyTagSummary struct {
Configured int `json:"configured"`
Healthy int `json:"healthy"`
}
type ProxyStatsEntry struct {
Proxy string `json:"proxy"`
Tags []string `json:"tags"`
Healthy bool `json:"healthy"`
Failures int `json:"failures"`
Disabled bool `json:"disabled"`
}
type ProxyEngineStats struct {
Tag string `json:"tag,omitempty"`
SelectedProxy string `json:"selected_proxy"`
}
type ProxyStats struct {
ConfiguredCount int `json:"configured_count"`
HealthyCount int `json:"healthy_count"`
UnhealthyCount int `json:"unhealthy_count"`
Tags map[string]ProxyTagSummary `json:"tags"`
Entries []ProxyStatsEntry `json:"entries"`
Engines map[string]ProxyEngineStats `json:"engines,omitempty"`
}
type proxyState struct {
url string
tags []string
failures int
disabled bool
}
type ProxyRegistry struct {
mu sync.Mutex
states map[string]*proxyState
order []string
tagIndex map[string][]string
nextByTag map[string]int
failureThreshold int
}
func DefaultProxiesConfig() ProxiesConfig {
return ProxiesConfig{
Global: "",
Entries: []ProxyEntryConfig{},
Health: ProxiesHealthConfig{FailureThreshold: DefaultProxyFailureThreshold},
}
}
func DefaultProxyConfig() ProxyConfig {
return ProxyConfig{
Runtime: ProxyRuntimeBrowser,
Proxies: DefaultProxiesConfig(),
EnginePolicies: map[string]string{},
}
}
func NormalizeProxyConfig(cfg ProxyConfig) (ProxyConfig, error) {
cfg.Runtime = normalizeProxyRuntime(cfg.Runtime)
var err error
cfg.Proxies, err = NormalizeProxiesConfig(cfg.Proxies)
if err != nil {
return cfg, err
}
if cfg.EnginePolicies == nil {
cfg.EnginePolicies = map[string]string{}
}
normalizedEnginePolicies := make(map[string]string, len(cfg.EnginePolicies))
for rawEngine, rawTag := range cfg.EnginePolicies {
engine := normalizeEngineName(rawEngine)
if engine == "" {
continue
}
tag := normalizeTag(rawTag)
if tag == "" {
continue
}
normalizedEnginePolicies[engine] = tag
}
cfg.EnginePolicies = normalizedEnginePolicies
if cfg.Registry == nil {
if len(cfg.Proxies.Entries) > 0 {
registry, err := NewProxyRegistry(cfg.Proxies.Entries, cfg.Proxies.Health.FailureThreshold)
if err != nil {
return cfg, err
}
cfg.Registry = registry
}
}
return cfg, nil
}
func NormalizeProxiesConfig(cfg ProxiesConfig) (ProxiesConfig, error) {
global, err := NormalizeProxyURL(cfg.Global)
if err != nil {
return cfg, fmt.Errorf("invalid proxies.global: %w", err)
}
cfg.Global = global
failureThreshold := cfg.Health.FailureThreshold
if failureThreshold <= 0 {
failureThreshold = DefaultProxyFailureThreshold
}
normalizedEntries := make([]ProxyEntryConfig, 0, len(cfg.Entries))
entryByURL := make(map[string]int, len(cfg.Entries))
for i, rawEntry := range cfg.Entries {
proxyURL, err := NormalizeProxyURL(rawEntry.URL)
if err != nil {
return cfg, fmt.Errorf("invalid proxies.entries[%d].url: %w", i, err)
}
if proxyURL == "" {
return cfg, fmt.Errorf("invalid proxies.entries[%d].url: value is required", i)
}
tags, err := normalizeProxyTags(rawEntry.Tags)
if err != nil {
return cfg, fmt.Errorf("invalid proxies.entries[%d].tags: %w", i, err)
}
if idx, ok := entryByURL[proxyURL]; ok {
normalizedEntries[idx].Tags = mergeTags(normalizedEntries[idx].Tags, tags)
continue
}
normalizedEntries = append(normalizedEntries, ProxyEntryConfig{
URL: proxyURL,
Tags: tags,
})
entryByURL[proxyURL] = len(normalizedEntries) - 1
}
cfg.Entries = normalizedEntries
cfg.Health = ProxiesHealthConfig{FailureThreshold: failureThreshold}
return cfg, nil
}
func NormalizeProxyURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
parsed, err := url.Parse(raw)
if err != nil {
return "", err
}
if parsed.Scheme == "" {
return "", fmt.Errorf("proxy URL must include a scheme")
}
if parsed.Host == "" {
return "", fmt.Errorf("proxy URL must include a host")
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
if _, ok := supportedProxySchemes[parsed.Scheme]; !ok {
return "", fmt.Errorf("unsupported proxy scheme %q", parsed.Scheme)
}
return parsed.String(), nil
}
func NormalizeProxyURLs(rawURLs []string) ([]string, error) {
normalized := make([]string, 0, len(rawURLs))
seen := make(map[string]struct{}, len(rawURLs))
for _, raw := range rawURLs {
proxyURL, err := NormalizeProxyURL(raw)
if err != nil {
return nil, err
}
if proxyURL == "" {
continue
}
if _, ok := seen[proxyURL]; ok {
continue
}
seen[proxyURL] = struct{}{}
normalized = append(normalized, proxyURL)
}
return normalized, nil
}
func MaskProxyURL(raw string) string {
proxyURL, err := NormalizeProxyURL(raw)
if err != nil || proxyURL == "" {
return "invalid-proxy"
}
parsed, err := url.Parse(proxyURL)
if err != nil {
return "invalid-proxy"
}
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
func ResolveEffectiveProxyPolicy(globalProxyURL string, engineTag string) ProxyPolicy {
if strings.TrimSpace(globalProxyURL) != "" {
return ProxyPolicy{Mode: ProxyModeTagPool}
}
tag := normalizeTag(engineTag)
if tag == "" {
return ProxyPolicy{Mode: ProxyModeOff}
}
return ProxyPolicy{Mode: ProxyModeTagPool, Tag: tag}
}
func NormalizeProxyTag(raw string) (string, error) {
tag := normalizeTag(raw)
if tag == "" {
return "", fmt.Errorf("value is required")
}
return tag, nil
}
func NormalizeProxyRequestOverride(raw string) (string, error) {
override := normalizeTag(raw)
if override == "" {
return "", nil
}
if override == ProxyOverrideDirect {
return ProxyOverrideDirect, nil
}
return NormalizeProxyTag(override)
}
func IsAuthenticatedSocksProxyURL(raw string) bool {
normalized, err := NormalizeProxyURL(raw)
if err != nil || normalized == "" {
return false
}
parsed, err := url.Parse(normalized)
if err != nil {
return false
}
if (parsed.Scheme == "socks5" || parsed.Scheme == "socks5h") && parsed.User != nil {
return true
}
return false
}
func NewProxyRegistry(entries []ProxyEntryConfig, failureThreshold int) (*ProxyRegistry, error) {
if failureThreshold <= 0 {
failureThreshold = DefaultProxyFailureThreshold
}
states := make(map[string]*proxyState, len(entries))
order := make([]string, 0, len(entries))
tagIndex := make(map[string][]string)
for idx, entry := range entries {
proxyURL, err := NormalizeProxyURL(entry.URL)
if err != nil {
return nil, fmt.Errorf("invalid proxy registry entry[%d] url: %w", idx, err)
}
if proxyURL == "" {
return nil, fmt.Errorf("invalid proxy registry entry[%d] url: value is required", idx)
}
tags, err := normalizeProxyTags(entry.Tags)
if err != nil {
return nil, fmt.Errorf("invalid proxy registry entry[%d] tags: %w", idx, err)
}
states[proxyURL] = &proxyState{url: proxyURL, tags: tags}
order = append(order, proxyURL)
for _, tag := range tags {
tagIndex[tag] = append(tagIndex[tag], proxyURL)
}
}
return &ProxyRegistry{
states: states,
order: order,
tagIndex: tagIndex,
nextByTag: make(map[string]int, len(tagIndex)),
failureThreshold: failureThreshold,
}, nil
}
func (r *ProxyRegistry) NextByTag(tag string) string {
tag = normalizeTag(tag)
if tag == "" {
return ""
}
r.mu.Lock()
defer r.mu.Unlock()
urls := r.tagIndex[tag]
if len(urls) == 0 {
return ""
}
if r.allDisabledLocked(urls) {
logrus.Warnf("Proxy tag pool exhausted for %q, re-enabling tagged proxies", tag)
for _, proxyURL := range urls {
state := r.states[proxyURL]
state.disabled = false
state.failures = 0
}
}
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
}
r.nextByTag[tag] = (idx + 1) % len(urls)
logrus.Debugf("Selected proxy for tag=%s: %s", tag, MaskProxyURL(proxyURL))
return proxyURL
}
return ""
}
func (r *ProxyRegistry) ReportFailure(proxyURL string) {
proxyURL, err := NormalizeProxyURL(proxyURL)
if err != nil || proxyURL == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
state, ok := r.states[proxyURL]
if !ok {
return
}
state.failures++
if state.failures >= r.failureThreshold {
state.disabled = true
logrus.Warnf("Disabled proxy after %d failures: %s", state.failures, MaskProxyURL(proxyURL))
}
}
func (r *ProxyRegistry) ReportSuccess(proxyURL string) {
proxyURL, err := NormalizeProxyURL(proxyURL)
if err != nil || proxyURL == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
state, ok := r.states[proxyURL]
if !ok {
return
}
state.failures = 0
state.disabled = false
}
func (r *ProxyRegistry) HasHealthyProxyForTag(tag string) bool {
tag = normalizeTag(tag)
if tag == "" {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
for _, proxyURL := range r.tagIndex[tag] {
if state, ok := r.states[proxyURL]; ok && !state.disabled {
return true
}
}
return false
}
func (r *ProxyRegistry) BuildStats() ProxyStats {
r.mu.Lock()
defer r.mu.Unlock()
stats := ProxyStats{
Tags: map[string]ProxyTagSummary{},
Entries: make([]ProxyStatsEntry, 0, len(r.order)),
}
for _, proxyURL := range r.order {
state := r.states[proxyURL]
healthy := !state.disabled
if healthy {
stats.HealthyCount++
} else {
stats.UnhealthyCount++
}
stats.ConfiguredCount++
stats.Entries = append(stats.Entries, ProxyStatsEntry{
Proxy: MaskProxyURL(state.url),
Tags: append([]string(nil), state.tags...),
Healthy: healthy,
Failures: state.failures,
Disabled: state.disabled,
})
for _, tag := range state.tags {
summary := stats.Tags[tag]
summary.Configured++
if healthy {
summary.Healthy++
}
stats.Tags[tag] = summary
}
}
return stats
}
func (r *ProxyRegistry) allDisabledLocked(urls []string) bool {
if len(urls) == 0 {
return false
}
for _, proxyURL := range urls {
if state, ok := r.states[proxyURL]; ok && !state.disabled {
return false
}
}
return true
}
func normalizeProxyRuntime(runtime string) string {
switch strings.ToLower(strings.TrimSpace(runtime)) {
case ProxyRuntimeRaw:
return ProxyRuntimeRaw
default:
return ProxyRuntimeBrowser
}
}
func normalizeProxyTags(tags []string) ([]string, error) {
if len(tags) == 0 {
return nil, fmt.Errorf("at least one tag is required")
}
seen := make(map[string]struct{}, len(tags))
normalized := make([]string, 0, len(tags))
for _, rawTag := range tags {
tag := normalizeTag(rawTag)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
normalized = append(normalized, tag)
}
if len(normalized) == 0 {
return nil, fmt.Errorf("at least one non-empty tag is required")
}
sort.Strings(normalized)
return normalized, nil
}
func normalizeTag(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func mergeTags(base []string, additional []string) []string {
combined := make(map[string]struct{}, len(base)+len(additional))
for _, tag := range base {
combined[tag] = struct{}{}
}
for _, tag := range additional {
combined[tag] = struct{}{}
}
merged := make([]string, 0, len(combined))
for tag := range combined {
merged = append(merged, tag)
}
sort.Strings(merged)
return merged
}
func normalizeEngineName(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}

View File

@@ -0,0 +1,234 @@
//go:build integration
// +build integration
package core
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"testing"
"github.com/karust/openserp/testutil"
"golang.org/x/time/rate"
)
const proxyIntegrationEnabledEnv = "OPENSERP_PROXY_TESTS"
type proxyIntegrationURLs struct {
targetURL string
socks5hAuthURL string
socks5hPlainURL string
httpAuthURL string
httpPlainURL string
badSocks5URL string
badHTTPURL string
}
func TestIntegrationSocks5hAuthProxyDNS(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyFetchesTarget(t, cfg.targetURL, cfg.socks5hAuthURL)
}
func TestIntegrationSocks5hPlainProxyDNS(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyFetchesTarget(t, cfg.targetURL, cfg.socks5hPlainURL)
}
func TestIntegrationHTTPAuthProxy(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyFetchesTarget(t, cfg.targetURL, cfg.httpAuthURL)
}
func TestIntegrationHTTPPlainProxy(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyFetchesTarget(t, cfg.targetURL, cfg.httpPlainURL)
}
func TestIntegrationRawSOCKSProxyPoolRotation(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyPoolRotation(t, cfg.targetURL, []string{cfg.badSocks5URL, cfg.socks5hAuthURL})
}
func TestIntegrationRawHTTPProxyPoolRotation(t *testing.T) {
cfg := proxyIntegrationConfig(t)
assertProxyPoolRotation(t, cfg.targetURL, []string{cfg.badHTTPURL, cfg.httpAuthURL})
}
type proxyIntegrationEngine struct {
targetURL string
limiter *rate.Limiter
proxies []string
}
func (e *proxyIntegrationEngine) Name() string {
return "google"
}
func (e *proxyIntegrationEngine) IsInitialized() bool {
return true
}
func (e *proxyIntegrationEngine) GetRateLimiter() *rate.Limiter {
return e.limiter
}
func (e *proxyIntegrationEngine) Search(q Query) ([]SearchResult, error) {
e.proxies = append(e.proxies, q.ProxyURL)
body, err := fetchViaRawProxy(q.ProxyURL, q.Insecure, e.targetURL)
if err != nil {
return nil, err
}
return []SearchResult{{
Rank: 1,
URL: e.targetURL,
Title: "proxy-ok",
Description: body,
}}, nil
}
func (e *proxyIntegrationEngine) SearchImage(q Query) ([]SearchResult, error) {
return nil, ErrSearchTimeout
}
func proxyIntegrationConfig(t *testing.T) proxyIntegrationURLs {
t.Helper()
testutil.RequireIntegration(t)
if os.Getenv(proxyIntegrationEnabledEnv) != "1" {
t.Skipf("set %s=1 to run proxy integration tests", proxyIntegrationEnabledEnv)
}
return proxyIntegrationURLs{
targetURL: envOrDefault("OPENSERP_PROXY_TEST_TARGET_URL", "http://proxy-target:8080/"),
socks5hAuthURL: envOrDefault("OPENSERP_PROXY_TEST_SOCKS5H_AUTH_URL", "socks5h://test:test@127.0.0.1:19080"),
socks5hPlainURL: envOrDefault("OPENSERP_PROXY_TEST_SOCKS5H_PLAIN_URL", "socks5h://127.0.0.1:19082"),
httpAuthURL: envOrDefault("OPENSERP_PROXY_TEST_HTTP_AUTH_URL", "http://test:test@127.0.0.1:18888"),
httpPlainURL: envOrDefault("OPENSERP_PROXY_TEST_HTTP_PLAIN_URL", "http://127.0.0.1:18889"),
badSocks5URL: envOrDefault("OPENSERP_PROXY_TEST_BAD_SOCKS5_URL", "socks5://127.0.0.1:19081"),
badHTTPURL: envOrDefault("OPENSERP_PROXY_TEST_BAD_HTTP_URL", "http://127.0.0.1:18890"),
}
}
func assertProxyFetchesTarget(t *testing.T, targetURL, proxyURL string) {
t.Helper()
target := mustParseURL(t, targetURL)
assertHostCannotResolveTarget(t, target.Hostname())
body, err := fetchViaRawProxy(proxyURL, false, targetURL)
if err != nil {
t.Fatalf("expected proxied request via %s to succeed, got %v", proxyURL, err)
}
if !strings.Contains(body, "proxy-ok") {
t.Fatalf("expected proxy target response, got %q", body)
}
}
func assertProxyPoolRotation(t *testing.T, targetURL string, pool []string) {
t.Helper()
engine := &proxyIntegrationEngine{
targetURL: targetURL,
limiter: rate.NewLimiter(rate.Inf, 1),
}
opts := DefaultServerOptions()
opts.Resilience.Retry.MaxRetries = 1
opts.Resilience.Retry.InitialBackoff = 0
opts.Resilience.Retry.MaxBackoff = 0
opts.Resilience.Retry.BackoffFactor = 1
opts.Resilience.Proxy = ProxyConfig{
Runtime: ProxyRuntimeRaw,
Proxies: ProxiesConfig{
Health: ProxiesHealthConfig{FailureThreshold: 1},
},
EnginePolicies: map[string]string{"google": "default"},
}
for _, proxyURL := range pool {
opts.Resilience.Proxy.Proxies.Entries = append(opts.Resilience.Proxy.Proxies.Entries, ProxyEntryConfig{
URL: proxyURL,
Tags: []string{"default"},
})
}
srv := NewServerWithOptions("127.0.0.1", 7190, opts, engine)
resp := request(t, srv, "/google/search?text=proxy")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected rotated proxy request to succeed, got %d", resp.StatusCode)
}
if len(engine.proxies) != 2 {
t.Fatalf("expected 2 proxy attempts, got %d", len(engine.proxies))
}
if engine.proxies[0] != pool[0] || engine.proxies[1] != pool[1] {
t.Fatalf("unexpected proxy rotation order: %#v", engine.proxies)
}
statsResp := request(t, srv, "/stats/proxy")
var stats map[string]interface{}
if err := json.NewDecoder(statsResp.Body).Decode(&stats); err != nil {
t.Fatalf("decode stats: %v", err)
}
if got := stats["healthy_count"].(float64); got != 1 {
t.Fatalf("expected healthy_count=1, got %v", got)
}
if got := stats["unhealthy_count"].(float64); got != 1 {
t.Fatalf("expected unhealthy_count=1, got %v", got)
}
}
func fetchViaRawProxy(proxyURL string, insecure bool, targetURL string) (string, error) {
client, err := NewRawHTTPClient(Query{
ProxyURL: proxyURL,
Insecure: insecure,
})
if err != nil {
return "", err
}
resp, err := client.Get(targetURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(bodyBytes), nil
}
func assertHostCannotResolveTarget(t *testing.T, hostname string) {
t.Helper()
if _, err := net.DefaultResolver.LookupHost(context.Background(), hostname); err == nil {
t.Fatalf("expected direct host-side DNS lookup for %q to fail", hostname)
}
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse target URL %q: %v", raw, err)
}
return parsed
}
func envOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}

345
core/proxy_test.go Normal file
View File

@@ -0,0 +1,345 @@
package core
import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
socks5 "github.com/armon/go-socks5"
xcontext "golang.org/x/net/context"
)
func TestNormalizeProxyURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr bool
}{
{name: "empty", raw: "", want: ""},
{name: "http", raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080"},
{name: "https", raw: "https://127.0.0.1:8443", want: "https://127.0.0.1:8443"},
{name: "socks5", raw: "socks5://127.0.0.1:1080", want: "socks5://127.0.0.1:1080"},
{name: "socks5h upper", raw: "SOCKS5H://127.0.0.1:1080", want: "socks5h://127.0.0.1:1080"},
{name: "missing scheme", raw: "127.0.0.1:8080", wantErr: true},
{name: "missing host", raw: "http://", wantErr: true},
{name: "unsupported scheme", raw: "ftp://127.0.0.1:21", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeProxyURL(tt.raw)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tt.raw)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("expected %q, got %q", tt.want, got)
}
})
}
}
func TestNormalizeProxiesConfigDefaultsAndDeduplicates(t *testing.T) {
cfg, err := NormalizeProxiesConfig(ProxiesConfig{
Global: " HTTP://proxy-global:8080 ",
Entries: []ProxyEntryConfig{
{URL: " http://proxy-one:8080 ", Tags: []string{"default", "us"}},
{URL: "http://proxy-one:8080", Tags: []string{"de", "us"}},
{URL: "socks5://proxy-two:1080", Tags: []string{"default"}},
},
})
if err != nil {
t.Fatalf("normalize proxies config: %v", err)
}
if cfg.Global != "http://proxy-global:8080" {
t.Fatalf("expected normalized global proxy, got %q", cfg.Global)
}
if cfg.Health.FailureThreshold != DefaultProxyFailureThreshold {
t.Fatalf("expected default failure threshold %d, got %d", DefaultProxyFailureThreshold, cfg.Health.FailureThreshold)
}
if len(cfg.Entries) != 2 {
t.Fatalf("expected 2 deduplicated entries, got %d", len(cfg.Entries))
}
if cfg.Entries[0].URL != "http://proxy-one:8080" {
t.Fatalf("unexpected normalized URL for first entry: %s", cfg.Entries[0].URL)
}
if len(cfg.Entries[0].Tags) != 3 {
t.Fatalf("expected merged tags in first entry, got %#v", cfg.Entries[0].Tags)
}
}
func TestNormalizeProxiesConfigRejectsInvalidEntries(t *testing.T) {
_, err := NormalizeProxiesConfig(ProxiesConfig{
Entries: []ProxyEntryConfig{{URL: "ftp://proxy:21", Tags: []string{"default"}}},
})
if err == nil {
t.Fatal("expected invalid scheme error")
}
_, err = NormalizeProxiesConfig(ProxiesConfig{
Entries: []ProxyEntryConfig{{URL: "http://proxy:8080", Tags: []string{" "}}},
})
if err == nil {
t.Fatal("expected empty tags error")
}
_, err = NormalizeProxiesConfig(ProxiesConfig{
Global: "ftp://proxy:21",
Entries: []ProxyEntryConfig{{URL: "http://proxy:8080", Tags: []string{"default"}}},
})
if err == nil {
t.Fatal("expected invalid global proxy scheme error")
}
}
func TestResolveEffectiveProxyPolicy(t *testing.T) {
offPolicy := ResolveEffectiveProxyPolicy("", "")
if offPolicy.Mode != ProxyModeOff {
t.Fatalf("expected mode off, got %s", offPolicy.Mode)
}
if offPolicy.Tag != "" {
t.Fatalf("expected empty tag for off mode, got %q", offPolicy.Tag)
}
tagOnlyPolicy := ResolveEffectiveProxyPolicy("", "US")
if tagOnlyPolicy.Mode != ProxyModeTagPool || tagOnlyPolicy.Tag != "us" {
t.Fatalf("unexpected effective policy with tag override: %#v", tagOnlyPolicy)
}
globalPolicy := ResolveEffectiveProxyPolicy("http://proxy-global:8080", "eu")
if globalPolicy.Mode != ProxyModeTagPool || globalPolicy.Tag != "" {
t.Fatalf("expected global proxy to ignore engine tags, got %#v", globalPolicy)
}
}
func TestProxyRegistryRoundRobinAndFailureRecovery(t *testing.T) {
registry, err := NewProxyRegistry([]ProxyEntryConfig{
{URL: "http://proxy1:8080", Tags: []string{"default"}},
{URL: "http://proxy2:8080", Tags: []string{"default"}},
}, 2)
if err != nil {
t.Fatalf("new proxy registry: %v", err)
}
if got := registry.NextByTag("default"); got != "http://proxy1:8080" {
t.Fatalf("expected first proxy1, got %s", got)
}
if got := registry.NextByTag("default"); got != "http://proxy2:8080" {
t.Fatalf("expected second proxy2, got %s", got)
}
registry.ReportFailure("http://proxy1:8080")
registry.ReportFailure("http://proxy1:8080")
if got := registry.NextByTag("default"); got != "http://proxy2:8080" {
t.Fatalf("expected proxy2 while proxy1 disabled, got %s", got)
}
registry.ReportFailure("http://proxy2:8080")
registry.ReportFailure("http://proxy2:8080")
if got := registry.NextByTag("default"); got != "http://proxy1:8080" {
t.Fatalf("expected tag pool reset to proxy1 after exhaustion, got %s", got)
}
registry.ReportFailure("http://proxy1:8080")
registry.ReportSuccess("http://proxy1:8080")
stats := registry.BuildStats()
if stats.UnhealthyCount != 0 {
t.Fatalf("expected no unhealthy proxies after success recovery, got %d", stats.UnhealthyCount)
}
if stats.HealthyCount != 2 {
t.Fatalf("expected two healthy proxies, got %d", stats.HealthyCount)
}
}
func TestMaskProxyURLRedactsCredentials(t *testing.T) {
if got := MaskProxyURL("http://user:pass@127.0.0.1:8080"); got != "http://127.0.0.1:8080" {
t.Fatalf("unexpected masked proxy value: %s", got)
}
}
func TestProxyURLForBrowserLaunchStripsCredentials(t *testing.T) {
u, err := url.Parse("http://user:pass@127.0.0.1:18888")
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
got := proxyURLForBrowserLaunch(u)
want := "http://127.0.0.1:18888"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestProxyURLForBrowserLaunchNormalizesSocks5h(t *testing.T) {
u, err := url.Parse("socks5h://test:test@127.0.0.1:19080")
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
got := proxyURLForBrowserLaunch(u)
want := "socks5://127.0.0.1:19080"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestProxyStatsMaskCredentials(t *testing.T) {
registry, err := NewProxyRegistry([]ProxyEntryConfig{
{URL: "http://user:pass@proxy.example:8080", Tags: []string{"default"}},
}, 1)
if err != nil {
t.Fatalf("new proxy registry: %v", err)
}
stats := registry.BuildStats()
if len(stats.Entries) != 1 {
t.Fatalf("expected one proxy stats entry, got %d", len(stats.Entries))
}
if got := stats.Entries[0].Proxy; got != "http://proxy.example:8080" {
t.Fatalf("expected masked proxy in stats, got %q", got)
}
}
func TestNormalizeProxyTag(t *testing.T) {
tag, err := NormalizeProxyTag(" US ")
if err != nil {
t.Fatalf("normalize proxy tag: %v", err)
}
if tag != "us" {
t.Fatalf("expected normalized tag us, got %q", tag)
}
if _, err := NormalizeProxyTag(" "); err == nil {
t.Fatal("expected empty proxy tag validation error")
}
}
func TestNormalizeProxyRequestOverride(t *testing.T) {
override, err := NormalizeProxyRequestOverride(" direct ")
if err != nil {
t.Fatalf("normalize direct proxy override: %v", err)
}
if override != ProxyOverrideDirect {
t.Fatalf("expected direct override, got %q", override)
}
override, err = NormalizeProxyRequestOverride(" US ")
if err != nil {
t.Fatalf("normalize tagged proxy override: %v", err)
}
if override != "us" {
t.Fatalf("expected normalized tag override us, got %q", override)
}
override, err = NormalizeProxyRequestOverride("")
if err != nil {
t.Fatalf("normalize empty override: %v", err)
}
if override != "" {
t.Fatalf("expected empty override, got %q", override)
}
}
func TestIsAuthenticatedSocksProxyURL(t *testing.T) {
if !IsAuthenticatedSocksProxyURL("socks5h://user:pass@127.0.0.1:1080") {
t.Fatal("expected authenticated socks proxy to be detected")
}
if IsAuthenticatedSocksProxyURL("socks5://127.0.0.1:1080") {
t.Fatal("expected plain socks proxy to remain browser-compatible")
}
if IsAuthenticatedSocksProxyURL("http://user:pass@127.0.0.1:8080") {
t.Fatal("expected HTTP auth proxy to remain browser-compatible")
}
}
func TestNewRawHTTPClientSocks5hUsesProxyDNS(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("proxied"))
}))
defer target.Close()
targetAddr, err := net.ResolveTCPAddr("tcp", target.Listener.Addr().String())
if err != nil {
t.Fatalf("resolve target listener: %v", err)
}
const proxyOnlyHost = "proxy-target.invalid"
proxyAddr := startSOCKS5TestServer(t, proxyOnlyHost, targetAddr.IP)
directClient := &http.Client{Timeout: 500 * time.Millisecond}
targetURL := fmt.Sprintf("http://%s:%d/", proxyOnlyHost, targetAddr.Port)
if _, err := directClient.Get(targetURL); err == nil {
t.Fatal("expected direct request to fail without proxy DNS")
}
client, err := NewRawHTTPClient(Query{ProxyURL: "socks5h://" + proxyAddr})
if err != nil {
t.Fatalf("new raw http client: %v", err)
}
resp, err := client.Get(targetURL)
if err != nil {
t.Fatalf("expected proxied request to succeed, got %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read proxied body: %v", err)
}
if string(body) != "proxied" {
t.Fatalf("unexpected proxied body: %q", string(body))
}
}
type staticResolver struct {
host string
ip net.IP
}
func (r staticResolver) Resolve(ctx xcontext.Context, name string) (xcontext.Context, net.IP, error) {
if name == r.host {
return ctx, r.ip, nil
}
return ctx, nil, net.UnknownNetworkError(name)
}
func startSOCKS5TestServer(t *testing.T, host string, ip net.IP) string {
t.Helper()
server, err := socks5.New(&socks5.Config{
Resolver: staticResolver{host: host, ip: ip},
Logger: log.New(io.Discard, "", 0),
})
if err != nil {
t.Fatalf("create socks5 server: %v", err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen socks5: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
_ = server.Serve(listener)
}()
return listener.Addr().String()
}

436
core/resilient.go Normal file
View File

@@ -0,0 +1,436 @@
package core
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/sirupsen/logrus"
)
// ResilientSearcher wraps engines with retry and circuit breaker protection.
type ResilientSearcher struct {
engines []SearchEngine
cbManager *CircuitBreakerManager
retryCfg RetryConfig
proxyRuntime string
proxyCfg ProxyConfig
proxyRegistry *ProxyRegistry
proxyDefaults ProxyPolicy
effectivePolicies map[string]ProxyPolicy
}
type ProxyExecutionMeta struct {
Mode string `json:"mode"`
Tag string `json:"tag,omitempty"`
Used string `json:"used"`
}
type ResilientConfig struct {
Retry RetryConfig
CircuitBreaker CircuitBreakerConfig
Proxy ProxyConfig
}
func DefaultResilientConfig() ResilientConfig {
return ResilientConfig{
Retry: DefaultRetryConfig(),
CircuitBreaker: DefaultCircuitBreakerConfig(),
Proxy: DefaultProxyConfig(),
}
}
func NewResilientSearcher(engines []SearchEngine, cfg ResilientConfig) *ResilientSearcher {
proxyCfg, err := NormalizeProxyConfig(cfg.Proxy)
if err != nil {
logrus.Errorf("Invalid proxy config, using defaults: %v", err)
proxyCfg = DefaultProxyConfig()
proxyCfg, _ = NormalizeProxyConfig(proxyCfg)
}
rs := &ResilientSearcher{
engines: engines,
cbManager: NewCircuitBreakerManager(cfg.CircuitBreaker),
retryCfg: cfg.Retry,
proxyRuntime: proxyCfg.Runtime,
proxyCfg: proxyCfg,
proxyRegistry: proxyCfg.Registry,
proxyDefaults: ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, ""),
effectivePolicies: make(map[string]ProxyPolicy, len(engines)),
}
for _, engine := range engines {
engineName := normalizeEngineName(engine.Name())
override := proxyCfg.EnginePolicies[engineName]
effective := ResolveEffectiveProxyPolicy(proxyCfg.Proxies.Global, override)
rs.effectivePolicies[engineName] = effective
}
return rs
}
// SearchPrimary keeps dedicated endpoints engine-pure (no fallback).
func (rs *ResilientSearcher) SearchPrimary(primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
results, proxyMeta, err := rs.searchWithProtection(primaryEngine, q, false)
if err != nil {
return nil, primaryEngine.Name(), proxyMeta, err
}
return results, primaryEngine.Name(), proxyMeta, nil
}
// SearchWithFallback retries primary and then tries other initialized engines.
func (rs *ResilientSearcher) SearchWithFallback(primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
return rs.searchWithFallback(primaryEngine, q, false)
}
func (rs *ResilientSearcher) SearchImagePrimary(primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
results, proxyMeta, err := rs.searchWithProtection(primaryEngine, q, true)
if err != nil {
return nil, primaryEngine.Name(), proxyMeta, err
}
return results, primaryEngine.Name(), proxyMeta, nil
}
func (rs *ResilientSearcher) SearchImageWithFallback(primaryEngine SearchEngine, q Query) ([]SearchResult, string, ProxyExecutionMeta, error) {
return rs.searchWithFallback(primaryEngine, q, true)
}
func (rs *ResilientSearcher) searchWithFallback(primaryEngine SearchEngine, q Query, isImage bool) ([]SearchResult, string, ProxyExecutionMeta, error) {
results, proxyMeta, err := rs.searchWithProtection(primaryEngine, q, isImage)
if err == nil {
return results, primaryEngine.Name(), proxyMeta, nil
}
if errors.Is(err, ErrProxyUnavailable) {
logrus.Warnf("[Resilient] Primary engine %s proxy policy failed closed: %s", primaryEngine.Name(), err)
return nil, primaryEngine.Name(), proxyMeta, err
}
action := "failed"
successMessage := "Fallback to %s succeeded with %d results"
if isImage {
action = "image search failed"
successMessage = "Image fallback to %s succeeded with %d results"
}
logrus.Warnf("[Resilient] Primary engine %s %s: %s. Trying fallback engines...", primaryEngine.Name(), action, err)
for _, fallbackEngine := range rs.engines {
if fallbackEngine.Name() == primaryEngine.Name() || !fallbackEngine.IsInitialized() {
continue
}
results, fallbackMeta, fallbackErr := rs.searchWithProtection(fallbackEngine, q, isImage)
if fallbackErr == nil {
logrus.Infof("[Resilient] "+successMessage, fallbackEngine.Name(), len(results))
return results, fallbackEngine.Name(), fallbackMeta, nil
}
logrus.Warnf("[Resilient] Fallback engine %s also failed: %s", fallbackEngine.Name(), fallbackErr)
}
return nil, primaryEngine.Name(), proxyMeta, ErrAllEnginesFailed
}
func (rs *ResilientSearcher) searchWithProtection(engine SearchEngine, q Query, isImage bool) ([]SearchResult, ProxyExecutionMeta, error) {
cb := rs.cbManager.Get(engine.Name())
if !cb.AllowRequest() {
return nil, ProxyExecutionMeta{}, ErrCircuitOpen
}
policy := rs.effectivePolicyForQuery(engine.Name(), q)
attemptMeta := rs.baseProxyMeta(policy)
result := RetryableSearch(rs.retryCfg, engine.Name(), func() ([]SearchResult, error) {
limiter := engine.GetRateLimiter()
if limiter != nil {
if err := limiter.Wait(context.Background()); err != nil {
return nil, err
}
}
attemptQuery := q
proxyURL := ""
reportToRegistry := false
attemptMeta = rs.baseProxyMeta(policy)
switch policy.Mode {
case ProxyModeOff:
attemptQuery.ProxyURL = ""
attemptMeta.Used = "direct"
case ProxyModeTagPool:
proxyURL = rs.selectProxyForQuery(policy, q)
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)
}
var (
results []SearchResult
err error
)
if isImage {
results, err = engine.SearchImage(attemptQuery)
} else {
results, err = engine.Search(attemptQuery)
}
if reportToRegistry {
rs.reportProxyAttempt(proxyURL, err)
}
return results, err
})
if result.Err != nil {
if !errors.Is(result.Err, ErrProxyUnavailable) {
cb.RecordFailure()
}
return nil, attemptMeta, result.Err
}
cb.RecordSuccess()
return result.Results, attemptMeta, nil
}
// SearchAllParallel applies retry/circuit protections per engine for mega search.
func (rs *ResilientSearcher) SearchAllParallel(q Query, engines []SearchEngine) []MegaSearchResult {
var wg sync.WaitGroup
var mu sync.Mutex
var allResults []MegaSearchResult
for _, engine := range engines {
if !engine.IsInitialized() {
continue
}
if !rs.cbManager.Get(engine.Name()).AllowRequest() {
logrus.Infof("[Resilient] Skipping %s in megasearch (circuit open)", engine.Name())
continue
}
wg.Add(1)
go func(eng SearchEngine) {
defer wg.Done()
results, _, err := rs.searchWithProtection(eng, q, false)
if err != nil {
return
}
mu.Lock()
for _, r := range results {
allResults = append(allResults, MegaSearchResult{
SearchResult: r,
Engine: eng.Name(),
})
}
mu.Unlock()
}(engine)
}
wg.Wait()
return allResults
}
func (rs *ResilientSearcher) SearchAllImageParallel(q Query, engines []SearchEngine) []MegaSearchResult {
var wg sync.WaitGroup
var mu sync.Mutex
var allResults []MegaSearchResult
for _, engine := range engines {
if !engine.IsInitialized() {
continue
}
if !rs.cbManager.Get(engine.Name()).AllowRequest() {
logrus.Infof("[Resilient] Skipping %s in megaimage (circuit open)", engine.Name())
continue
}
wg.Add(1)
go func(eng SearchEngine) {
defer wg.Done()
results, _, err := rs.searchWithProtection(eng, q, true)
if err != nil {
return
}
mu.Lock()
for _, r := range results {
allResults = append(allResults, MegaSearchResult{
SearchResult: r,
Engine: eng.Name(),
})
}
mu.Unlock()
}(engine)
}
wg.Wait()
return allResults
}
func (rs *ResilientSearcher) GetCircuitBreakerStats() []map[string]interface{} {
return rs.cbManager.AllStats()
}
func (rs *ResilientSearcher) GetProxyStats() ProxyStats {
stats := ProxyStats{
ConfiguredCount: 0,
HealthyCount: 0,
UnhealthyCount: 0,
Tags: map[string]ProxyTagSummary{},
Entries: []ProxyStatsEntry{},
}
if rs.proxyRegistry != nil {
stats = rs.proxyRegistry.BuildStats()
}
engines := map[string]ProxyEngineStats{}
for _, engine := range rs.engines {
engineName := normalizeEngineName(engine.Name())
policy := rs.effectivePolicyForEngine(engineName)
engineStats := ProxyEngineStats{}
switch policy.Mode {
case ProxyModeOff:
engineStats.SelectedProxy = "direct"
case ProxyModeTagPool:
engineStats.Tag = policy.Tag
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" {
engineStats.SelectedProxy = MaskProxyURL(global)
} else {
engineStats.SelectedProxy = "pooled"
}
}
engines[engineName] = engineStats
}
if len(engines) > 0 {
stats.Engines = engines
}
return stats
}
func (rs *ResilientSearcher) ResolveMegaProxyMeta(q Query, engines []SearchEngine) ProxyExecutionMeta {
if len(engines) == 0 {
return ProxyExecutionMeta{Mode: ProxyModeOff, Used: "direct"}
}
allOff := true
proxiedTags := map[string]struct{}{}
hasOff := false
for _, engine := range engines {
policy := rs.effectivePolicyForQuery(engine.Name(), q)
if policy.Mode == ProxyModeOff {
hasOff = true
continue
}
allOff = false
if policy.Tag != "" {
proxiedTags[policy.Tag] = struct{}{}
}
}
if allOff {
return ProxyExecutionMeta{Mode: ProxyModeOff, Used: "direct"}
}
meta := ProxyExecutionMeta{Mode: ProxyModeTagPool}
if len(proxiedTags) == 1 {
for tag := range proxiedTags {
meta.Tag = tag
}
}
if q.ProxyOverride == "" {
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" && !hasOff {
meta.Used = MaskProxyURL(global)
return meta
}
}
if rs.proxyRuntime == ProxyRuntimeRaw {
meta.Used = "multiple"
if hasOff {
meta.Used = "mixed"
}
return meta
}
meta.Used = "multiple"
if hasOff {
meta.Used = "mixed"
}
return meta
}
func (rs *ResilientSearcher) baseProxyMeta(policy ProxyPolicy) ProxyExecutionMeta {
meta := ProxyExecutionMeta{Mode: policy.Mode}
if policy.Mode == ProxyModeTagPool {
meta.Tag = policy.Tag
return meta
}
meta.Used = "direct"
return meta
}
func (rs *ResilientSearcher) effectivePolicyForEngine(engineName string) ProxyPolicy {
engineName = normalizeEngineName(engineName)
if policy, ok := rs.effectivePolicies[engineName]; ok {
return policy
}
return rs.proxyDefaults
}
func (rs *ResilientSearcher) effectivePolicyForQuery(engineName string, q Query) ProxyPolicy {
switch q.ProxyOverride {
case "":
return rs.effectivePolicyForEngine(engineName)
case ProxyOverrideDirect:
return ProxyPolicy{Mode: ProxyModeOff}
default:
return ProxyPolicy{Mode: ProxyModeTagPool, Tag: q.ProxyOverride}
}
}
func (rs *ResilientSearcher) selectProxyForTag(tag string) string {
if rs.proxyRegistry == nil {
return ""
}
return rs.proxyRegistry.NextByTag(tag)
}
func (rs *ResilientSearcher) reportProxyAttempt(proxyURL string, err error) {
if rs.proxyRegistry == nil || proxyURL == "" {
return
}
if err != nil {
rs.proxyRegistry.ReportFailure(proxyURL)
return
}
rs.proxyRegistry.ReportSuccess(proxyURL)
}
func (rs *ResilientSearcher) selectProxyForQuery(policy ProxyPolicy, q Query) string {
if policy.Mode != ProxyModeTagPool {
return ""
}
if q.ProxyOverride == "" {
if global := strings.TrimSpace(rs.proxyCfg.Proxies.Global); global != "" {
return global
}
}
return rs.selectProxyForTag(policy.Tag)
}
var ErrAllEnginesFailed = fmt.Errorf("all search engines failed")

100
core/retry.go Normal file
View File

@@ -0,0 +1,100 @@
package core
import (
"errors"
"fmt"
"math"
"time"
"github.com/sirupsen/logrus"
)
// RetryConfig controls retry behavior.
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffFactor float64
}
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: time.Second,
MaxBackoff: 30 * time.Second,
BackoffFactor: 2.0,
}
}
type RetryResult struct {
Results []SearchResult
Err error
Attempts int
Engine string
}
// RetryableSearch executes searchFn with exponential backoff retries.
// CAPTCHA and proxy-unavailable errors are not retried.
func RetryableSearch(cfg RetryConfig, engineName string, searchFn func() ([]SearchResult, error)) RetryResult {
if cfg.BackoffFactor <= 0 {
cfg.BackoffFactor = 2.0
}
var lastErr error
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
if attempt > 0 {
backoff := calculateBackoff(cfg, attempt)
logrus.Warnf("[%s] Retry attempt %d/%d after %s", engineName, attempt, cfg.MaxRetries, backoff)
time.Sleep(backoff)
}
results, err := searchFn()
if err == nil {
if attempt > 0 {
logrus.Infof("[%s] Succeeded on retry attempt %d", engineName, attempt)
}
return RetryResult{
Results: results,
Attempts: attempt + 1,
Engine: engineName,
}
}
lastErr = err
if errors.Is(err, ErrCaptcha) {
logrus.Warnf("[%s] CAPTCHA detected, skipping retries", engineName)
return RetryResult{
Err: err,
Attempts: attempt + 1,
Engine: engineName,
}
}
if errors.Is(err, ErrProxyUnavailable) {
logrus.Warnf("[%s] Proxy unavailable, skipping retries", engineName)
return RetryResult{
Err: err,
Attempts: attempt + 1,
Engine: engineName,
}
}
logrus.Warnf("[%s] Attempt %d failed: %s", engineName, attempt+1, err)
}
return RetryResult{
Err: fmt.Errorf("all %d attempts failed for %s: %w", cfg.MaxRetries+1, engineName, lastErr),
Attempts: cfg.MaxRetries + 1,
Engine: engineName,
}
}
func calculateBackoff(cfg RetryConfig, attempt int) time.Duration {
backoff := float64(cfg.InitialBackoff) * math.Pow(cfg.BackoffFactor, float64(attempt-1))
if backoff > float64(cfg.MaxBackoff) {
backoff = float64(cfg.MaxBackoff)
}
if backoff < 0 {
backoff = 0
}
return time.Duration(backoff)
}

86
core/retry_test.go Normal file
View File

@@ -0,0 +1,86 @@
package core
import (
"errors"
"testing"
"time"
)
func TestRetryableSearch_SuccessOnFirstAttempt(t *testing.T) {
cfg := RetryConfig{MaxRetries: 3, InitialBackoff: 10 * time.Millisecond, MaxBackoff: 100 * time.Millisecond, BackoffFactor: 2.0}
calls := 0
result := RetryableSearch(cfg, "test", func() ([]SearchResult, error) {
calls++
return []SearchResult{{Title: "result1"}}, nil
})
if result.Err != nil {
t.Fatalf("expected no error, got: %v", result.Err)
}
if result.Attempts != 1 {
t.Errorf("expected 1 attempt, got: %d", result.Attempts)
}
if calls != 1 {
t.Errorf("expected 1 call, got: %d", calls)
}
}
func TestRetryableSearch_AllAttemptsFail(t *testing.T) {
cfg := RetryConfig{MaxRetries: 2, InitialBackoff: 10 * time.Millisecond, MaxBackoff: 50 * time.Millisecond, BackoffFactor: 2.0}
calls := 0
result := RetryableSearch(cfg, "test", func() ([]SearchResult, error) {
calls++
return nil, errors.New("persistent failure")
})
if result.Err == nil {
t.Fatal("expected error, got nil")
}
if calls != 3 {
t.Errorf("expected 3 calls (1 + 2 retries), got: %d", calls)
}
if result.Attempts != 3 {
t.Errorf("expected 3 attempts, got: %d", result.Attempts)
}
}
func TestRetryableSearch_CaptchaNotRetried(t *testing.T) {
cfg := RetryConfig{MaxRetries: 3, InitialBackoff: 10 * time.Millisecond, MaxBackoff: 100 * time.Millisecond, BackoffFactor: 2.0}
calls := 0
result := RetryableSearch(cfg, "test", func() ([]SearchResult, error) {
calls++
return nil, ErrCaptcha
})
if !errors.Is(result.Err, ErrCaptcha) {
t.Fatalf("expected ErrCaptcha, got: %v", result.Err)
}
if calls != 1 {
t.Errorf("expected 1 call, got: %d", calls)
}
}
func TestCalculateBackoff(t *testing.T) {
cfg := RetryConfig{InitialBackoff: 1 * time.Second, MaxBackoff: 10 * time.Second, BackoffFactor: 2.0}
tests := []struct {
attempt int
expected time.Duration
}{
{1, 1 * time.Second},
{2, 2 * time.Second},
{3, 4 * time.Second},
{4, 8 * time.Second},
{5, 10 * time.Second},
}
for _, tt := range tests {
got := calculateBackoff(cfg, tt.attempt)
if got != tt.expected {
t.Errorf("attempt %d: expected %s, got %s", tt.attempt, tt.expected, got)
}
}
}

View File

@@ -1,11 +1,13 @@
package core package core
import ( import (
"context" "encoding/json"
"errors"
"fmt" "fmt"
"runtime"
"sort" "sort"
"strings" "strings"
"sync" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@@ -24,227 +26,363 @@ type Server struct {
app *fiber.App app *fiber.App
addr string addr string
searchEngines []SearchEngine searchEngines []SearchEngine
cache *ResponseCache
resilient *ResilientSearcher
startTime time.Time
opts ServerOptions
}
type ServerOptions struct {
CacheTTL time.Duration
CacheMaxSize int
EnableCORS bool
CORS CORSConfig
AllowEndpointFallback bool
Resilience ResilientConfig
}
func DefaultServerOptions() ServerOptions {
return ServerOptions{
CacheTTL: 5 * time.Minute,
CacheMaxSize: 1000,
EnableCORS: true,
CORS: DefaultCORSConfig(),
AllowEndpointFallback: false,
Resilience: DefaultResilientConfig(),
}
} }
func NewServer(host string, port int, searchEngines ...SearchEngine) *Server { func NewServer(host string, port int, searchEngines ...SearchEngine) *Server {
return NewServerWithOptions(host, port, DefaultServerOptions(), searchEngines...)
}
func NewServerWithOptions(host string, port int, opts ServerOptions, searchEngines ...SearchEngine) *Server {
addr := fmt.Sprintf("%s:%d", host, port) addr := fmt.Sprintf("%s:%d", host, port)
app := fiber.New(fiber.Config{
ErrorHandler: JSONErrorMiddleware(),
})
serv := Server{ serv := Server{
app: fiber.New(), app: app,
addr: addr, addr: addr,
searchEngines: searchEngines, searchEngines: searchEngines,
resilient: NewResilientSearcher(searchEngines, opts.Resilience),
startTime: time.Now(),
opts: opts,
} }
logrus.Info("Resilient search enabled: retry + circuit breaker")
if opts.AllowEndpointFallback {
logrus.Warn("Dedicated endpoint fallback is enabled")
}
if opts.CacheTTL > 0 && opts.CacheMaxSize > 0 {
serv.cache = NewResponseCache(opts.CacheTTL, opts.CacheMaxSize)
logrus.Infof("Response cache enabled: TTL=%s, MaxSize=%d", opts.CacheTTL, opts.CacheMaxSize)
}
if opts.EnableCORS {
app.Use(CORSMiddleware(opts.CORS))
}
app.Use(RequestLoggerMiddleware())
app.Get("/health", serv.handleHealthCheck)
app.Get("/stats", serv.handleStats)
app.Get("/stats/cache", serv.handleCacheStats)
app.Get("/stats/proxy", serv.handleProxyStats)
app.Get("/stats/cb", serv.handleCircuitBreakerStats)
for _, engine := range searchEngines { for _, engine := range searchEngines {
locEngine := engine locEngine := engine
limiter := engine.GetRateLimiter()
// Custom endpoint mapping for DuckDuckGo
endpointName := strings.ToLower(locEngine.Name()) endpointName := strings.ToLower(locEngine.Name())
if endpointName == "duckduckgo" { if endpointName == "duckduckgo" {
endpointName = "duck" endpointName = "duck"
} }
serv.app.Get(fmt.Sprintf("/%s/search", endpointName), func(c *fiber.Ctx) error { serv.app.Get(fmt.Sprintf("/%s/search", endpointName), func(c *fiber.Ctx) error {
q := Query{} return serv.handleDedicatedEndpoint(c, locEngine, false)
err := q.InitFromContext(c)
if err != nil {
logrus.Errorf("Error while setting %s query: %s", locEngine.Name(), err)
return err
}
logrus.Infof("Starting SERP search request using %s engine for query: %s", locEngine.Name(), q.Text)
err = limiter.Wait(context.Background())
if err != nil {
logrus.Errorf("Ratelimiter error during %s query: %s", locEngine.Name(), err)
}
res, err := locEngine.Search(q)
if err != nil {
switch err {
case ErrCaptcha:
err = fmt.Errorf("captcha found, please stop sending requests for a while\n%s", err)
case ErrSearchTimeout:
err = fmt.Errorf("%s", err)
}
logrus.Errorf("Error during %s search: %s", locEngine.Name(), err)
return fiber.NewError(fiber.StatusServiceUnavailable, err.Error())
}
logrus.Infof("Successfully completed SERP search using %s engine, returned %d results", locEngine.Name(), len(res))
return c.JSON(res)
}) })
serv.app.Get(fmt.Sprintf("/%s/image", endpointName), func(c *fiber.Ctx) error { serv.app.Get(fmt.Sprintf("/%s/image", endpointName), func(c *fiber.Ctx) error {
q := Query{} return serv.handleDedicatedEndpoint(c, locEngine, true)
err := q.InitFromContext(c)
if err != nil {
logrus.Errorf("Error while setting %s query: %s", locEngine.Name(), err)
return err
}
logrus.Infof("Starting SERP image search request using %s engine for query: %s", locEngine.Name(), q.Text)
err = limiter.Wait(context.Background())
if err != nil {
logrus.Errorf("Ratelimiter error during %s query: %s", locEngine.Name(), err)
}
res, err := locEngine.SearchImage(q)
if err != nil && len(res) > 0 {
logrus.Warnf("Partial results returned from %s image search despite error: %s", locEngine.Name(), err)
c.Status(503)
return c.JSON(res)
}
if err != nil {
switch err {
case ErrCaptcha:
err = fmt.Errorf("captcha found, please stop sending requests for a while: %s", err)
case ErrSearchTimeout:
err = fmt.Errorf("%s", err)
}
logrus.Errorf("Error during %s image search: %s", locEngine.Name(), err)
return fiber.NewError(fiber.StatusServiceUnavailable, err.Error())
}
logrus.Infof("Successfully completed SERP image search using [%s], returned %d results", locEngine.Name(), len(res))
return c.JSON(res)
}) })
} }
// Add megasearch endpoint
serv.app.Get("/mega/search", serv.handleMegaSearch) serv.app.Get("/mega/search", serv.handleMegaSearch)
// Add megasearch image endpoint
serv.app.Get("/mega/image", serv.handleMegaImage) serv.app.Get("/mega/image", serv.handleMegaImage)
// Add endpoint to list available engines
serv.app.Get("/mega/engines", serv.handleListEngines) serv.app.Get("/mega/engines", serv.handleListEngines)
return &serv return &serv
} }
// MegaSearchResult represents a search result with engine information func (s *Server) handleDedicatedEndpoint(c *fiber.Ctx, engine SearchEngine, isImage bool) error {
q := Query{}
if err := q.InitFromContext(c); err != nil {
logrus.Errorf("Error while setting %s query: %s", engine.Name(), err)
return err
}
action := "search"
if isImage {
action = "image"
}
logrus.Infof("Starting SERP %s request using %s engine for query: %s", action, engine.Name(), q.Text)
if hit, err := s.tryServeCacheHit(
c,
cacheHitCandidate{
key: BuildCacheKey(engine.Name(), action, q),
logMessage: fmt.Sprintf("Cache hit for %s %s: %s", engine.Name(), action, q.Text),
},
); hit || err != nil {
return err
}
var (
res []SearchResult
usedEngine string
proxyMeta ProxyExecutionMeta
searchErr error
)
if isImage {
if s.opts.AllowEndpointFallback {
res, usedEngine, proxyMeta, searchErr = s.resilient.SearchImageWithFallback(engine, q)
} else {
res, usedEngine, proxyMeta, searchErr = s.resilient.SearchImagePrimary(engine, q)
}
} else {
if s.opts.AllowEndpointFallback {
res, usedEngine, proxyMeta, searchErr = s.resilient.SearchWithFallback(engine, q)
} else {
res, usedEngine, proxyMeta, searchErr = s.resilient.SearchPrimary(engine, q)
}
}
s.applyProxyHeaders(c, proxyMeta)
if searchErr != nil {
errToReturn := searchErr
switch searchErr {
case ErrCaptcha:
errToReturn = fmt.Errorf("captcha found, please stop sending requests for a while: %w", searchErr)
case ErrSearchTimeout:
errToReturn = fmt.Errorf("%s", searchErr)
default:
if errors.Is(searchErr, ErrProxyUnavailable) {
errToReturn = fmt.Errorf("%s", searchErr)
}
}
logrus.Errorf("Error during resilient %s %s: %s", engine.Name(), action, searchErr)
return fiber.NewError(fiber.StatusServiceUnavailable, errToReturn.Error())
}
cacheStatus := ""
// Avoid caching fallback-served responses so the requested engine can recover
// without the endpoint continuing to serve another engine until TTL expiry.
if s.cache != nil {
cacheStatus = "BYPASS"
switch {
case usedEngine != engine.Name():
s.cache.RecordBypass()
case len(res) == 0:
s.cache.RecordBypass()
default:
cacheKey := BuildCacheKey(engine.Name(), action, q)
if s.cacheJSON(cacheKey, res) {
cacheStatus = "MISS"
}
}
c.Set("X-Cache", cacheStatus)
}
if usedEngine != "" && usedEngine != engine.Name() {
c.Set("X-Fallback-Engine", usedEngine)
}
logrus.Infof("Successfully completed SERP %s using %s, returned %d results", action, usedEngine, len(res))
return c.JSON(res)
}
type HealthStatus struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Engines []EngineHealth `json:"engines"`
System map[string]interface{} `json:"system"`
}
type EngineHealth struct {
Name string `json:"name"`
Initialized bool `json:"initialized"`
Status string `json:"status"`
}
// handleHealthCheck returns current service and engine status.
// Degraded state stays HTTP 200 to avoid unnecessary restarts in orchestrators.
func (s *Server) handleHealthCheck(c *fiber.Ctx) error {
engines := make([]EngineHealth, 0, len(s.searchEngines))
availableEngines := 0
for _, engine := range s.searchEngines {
status := "ready"
isAvailable := true
if !engine.IsInitialized() {
status = "not_initialized"
isAvailable = false
}
for _, cbStat := range s.resilient.GetCircuitBreakerStats() {
engineName, _ := cbStat["engine"].(string)
if engineName != engine.Name() {
continue
}
circuitState, _ := cbStat["state"].(string)
if circuitState == "open" {
status = "circuit_open"
isAvailable = false
}
break
}
if isAvailable {
availableEngines++
}
engines = append(engines, EngineHealth{
Name: engine.Name(),
Initialized: engine.IsInitialized(),
Status: status,
})
}
overallStatus := "healthy"
totalEngines := len(s.searchEngines)
switch {
case totalEngines == 0 || availableEngines == 0:
overallStatus = "unhealthy"
case availableEngines < totalEngines:
overallStatus = "degraded"
}
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
health := HealthStatus{
Status: overallStatus,
Uptime: time.Since(s.startTime).Round(time.Second).String(),
Engines: engines,
System: map[string]interface{}{
"goroutines": runtime.NumGoroutine(),
"memory_mb": memStats.Alloc / 1024 / 1024,
"go_version": runtime.Version(),
},
}
if overallStatus == "unhealthy" {
c.Status(fiber.StatusServiceUnavailable)
}
return c.JSON(health)
}
func (s *Server) handleStats(c *fiber.Ctx) error {
return c.JSON(map[string]interface{}{
"cache": s.cacheStatsPayload(),
"proxy": s.resilient.GetProxyStats(),
"circuit_breakers": s.resilient.GetCircuitBreakerStats(),
})
}
func (s *Server) handleCacheStats(c *fiber.Ctx) error {
return c.JSON(s.cacheStatsPayload())
}
func (s *Server) handleProxyStats(c *fiber.Ctx) error {
return c.JSON(s.resilient.GetProxyStats())
}
func (s *Server) handleCircuitBreakerStats(c *fiber.Ctx) error {
return c.JSON(map[string]interface{}{
"circuit_breakers": s.resilient.GetCircuitBreakerStats(),
})
}
type MegaSearchResult struct { type MegaSearchResult struct {
SearchResult SearchResult
Engine string `json:"engine"` Engine string `json:"engine"`
} }
// handleMegaSearch handles the /megasearch endpoint
func (s *Server) handleMegaSearch(c *fiber.Ctx) error { func (s *Server) handleMegaSearch(c *fiber.Ctx) error {
q := Query{} return s.handleMegaEndpoint(c, "search", s.resilient.SearchAllParallel)
err := q.InitFromContext(c)
if err != nil {
logrus.Errorf("Error while setting megasearch query: %s", err)
return err
}
// Get engines parameter to filter which engines to use
enginesParam := c.Query("engines", "")
var enginesToUse []SearchEngine
if enginesParam != "" {
// Parse comma-separated list of engines
engineNames := strings.Split(enginesParam, ",")
for _, engineName := range engineNames {
engineName = strings.TrimSpace(strings.ToLower(engineName))
for _, engine := range s.searchEngines {
if strings.ToLower(engine.Name()) == engineName {
enginesToUse = append(enginesToUse, engine)
break
}
}
}
} else {
// Use all engines if no specific engines specified
enginesToUse = s.searchEngines
}
if len(enginesToUse) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No valid search engines specified")
}
// Log which engines will be used
engineNames := make([]string, len(enginesToUse))
for i, engine := range enginesToUse {
engineNames[i] = engine.Name()
}
logrus.Infof("Starting SERP megasearch request using engines: %s for query: %s", strings.Join(engineNames, ", "), q.Text)
// Execute searches in parallel across selected engines
results := s.searchSelectedEngines(q, enginesToUse)
// Deduplicate results while preserving engine information
dedupedResults := s.deduplicateMegaResults(results)
logrus.Infof("Successfully completed SERP megasearch using %d engines, returned %d deduplicated results", len(enginesToUse), len(dedupedResults))
return c.JSON(dedupedResults)
} }
// handleMegaImage handles the /mega/image endpoint
func (s *Server) handleMegaImage(c *fiber.Ctx) error { func (s *Server) handleMegaImage(c *fiber.Ctx) error {
return s.handleMegaEndpoint(c, "image", s.resilient.SearchAllImageParallel)
}
func (s *Server) handleMegaEndpoint(c *fiber.Ctx, action string, run func(Query, []SearchEngine) []MegaSearchResult) error {
q := Query{} q := Query{}
err := q.InitFromContext(c) if err := q.InitFromContext(c); err != nil {
if err != nil { logrus.Errorf("Error while setting mega %s query: %s", action, err)
logrus.Errorf("Error while setting megasearch image query: %s", err)
return err return err
} }
// Get engines parameter to filter which engines to use enginesToUse := s.resolveEngines(c.Query("engines", ""))
enginesParam := c.Query("engines", "")
var enginesToUse []SearchEngine
if enginesParam != "" {
// Parse comma-separated list of engines
engineNames := strings.Split(enginesParam, ",")
for _, engineName := range engineNames {
engineName = strings.TrimSpace(strings.ToLower(engineName))
for _, engine := range s.searchEngines {
if strings.ToLower(engine.Name()) == engineName {
enginesToUse = append(enginesToUse, engine)
break
}
}
}
} else {
// Use all engines if no specific engines specified
enginesToUse = s.searchEngines
}
if len(enginesToUse) == 0 { if len(enginesToUse) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No valid search engines specified") return fiber.NewError(fiber.StatusBadRequest, "No valid search engines specified")
} }
// Log which engines will be used
engineNames := make([]string, len(enginesToUse)) engineNames := make([]string, len(enginesToUse))
for i, engine := range enginesToUse { for i, engine := range enginesToUse {
engineNames[i] = engine.Name() engineNames[i] = engine.Name()
} }
logrus.Infof("Starting SERP megasearch image request using engines: %s for query: %s", strings.Join(engineNames, ", "), q.Text) engineNamesJoined := strings.Join(engineNames, ",")
s.applyProxyHeaders(c, s.resilient.ResolveMegaProxyMeta(q, enginesToUse))
logrus.Infof("Starting SERP mega %s request using engines: %s for query: %s", action, engineNamesJoined, q.Text)
// Execute image searches in parallel across selected engines cacheHitCandidates := []cacheHitCandidate{
results := s.searchSelectedEnginesImage(q, enginesToUse) {
key: s.buildMegaCacheKey(action, enginesToUse, q),
logMessage: fmt.Sprintf("Cache hit for mega %s: engines=%s query=%s", action, engineNamesJoined, q.Text),
},
}
cacheableEngines := s.megaCacheableEngines(enginesToUse)
if len(cacheableEngines) > 0 && len(cacheableEngines) < len(enginesToUse) {
cacheHitCandidates = append(cacheHitCandidates, cacheHitCandidate{
key: s.buildMegaCacheKey(action, cacheableEngines, q),
logMessage: fmt.Sprintf("Cache hit for mega %s partial set: engines=%s query=%s", action, engineNamesJoined, q.Text),
})
}
if hit, err := s.tryServeCacheHit(c, cacheHitCandidates...); hit || err != nil {
return err
}
// Deduplicate results while preserving engine information results := run(q, enginesToUse)
dedupedResults := s.deduplicateMegaResults(results) dedupedResults := s.deduplicateMegaResults(results)
logrus.Infof("Successfully completed SERP megasearch image using %d engines, returned %d deduplicated results", len(enginesToUse), len(dedupedResults)) if s.cache != nil {
c.Set("X-Cache", s.cacheMegaResults(action, enginesToUse, q, dedupedResults))
}
logrus.Infof("Successfully completed SERP mega %s using %d engines, returned %d deduplicated results", action, len(enginesToUse), len(dedupedResults))
return c.JSON(dedupedResults) return c.JSON(dedupedResults)
} }
// handleListEngines lists all available search engines
func (s *Server) handleListEngines(c *fiber.Ctx) error { func (s *Server) handleListEngines(c *fiber.Ctx) error {
var engines []map[string]interface{} var engines []map[string]interface{}
for _, engine := range s.searchEngines { for _, engine := range s.searchEngines {
engines = append(engines, map[string]interface{}{ engineInfo := map[string]interface{}{
"name": engine.Name(), "name": engine.Name(),
"initialized": engine.IsInitialized(), "initialized": engine.IsInitialized(),
}) }
for _, cbStat := range s.resilient.GetCircuitBreakerStats() {
engineName, _ := cbStat["engine"].(string)
if engineName == engine.Name() {
engineInfo["circuit_state"] = cbStat["state"]
break
}
}
engines = append(engines, engineInfo)
} }
return c.JSON(map[string]interface{}{ return c.JSON(map[string]interface{}{
@@ -253,123 +391,178 @@ func (s *Server) handleListEngines(c *fiber.Ctx) error {
}) })
} }
// searchSelectedEngines performs parallel searches across selected engines func (s *Server) resolveEngines(enginesParam string) []SearchEngine {
func (s *Server) searchSelectedEngines(q Query, engines []SearchEngine) []MegaSearchResult { if enginesParam == "" {
var wg sync.WaitGroup return s.searchEngines
var mu sync.Mutex
var allResults []MegaSearchResult
for _, engine := range engines {
wg.Add(1)
go func(eng SearchEngine) {
defer wg.Done()
// Apply rate limiting
limiter := eng.GetRateLimiter()
if limiter != nil {
err := limiter.Wait(context.Background())
if err != nil {
logrus.Errorf("Ratelimiter error during %s megasearch: %s", eng.Name(), err)
}
}
// Perform search
results, err := eng.Search(q)
if err != nil {
logrus.Errorf("Error during %s megasearch: %s", eng.Name(), err)
return
}
// Convert to MegaSearchResult with engine info
mu.Lock()
for _, result := range results {
megaResult := MegaSearchResult{
SearchResult: result,
Engine: eng.Name(),
}
allResults = append(allResults, megaResult)
}
mu.Unlock()
}(engine)
} }
wg.Wait() var enginesToUse []SearchEngine
return allResults seen := make(map[string]bool)
} engineNames := strings.Split(enginesParam, ",")
for _, engineName := range engineNames {
// searchSelectedEnginesImage performs parallel image searches across selected engines engineName = strings.TrimSpace(strings.ToLower(engineName))
func (s *Server) searchSelectedEnginesImage(q Query, engines []SearchEngine) []MegaSearchResult { if engineName == "" || seen[engineName] {
var wg sync.WaitGroup continue
var mu sync.Mutex }
var allResults []MegaSearchResult for _, engine := range s.searchEngines {
if strings.ToLower(engine.Name()) == engineName {
for _, engine := range engines { enginesToUse = append(enginesToUse, engine)
wg.Add(1) seen[engineName] = true
go func(eng SearchEngine) { break
defer wg.Done()
// Apply rate limiting
limiter := eng.GetRateLimiter()
if limiter != nil {
err := limiter.Wait(context.Background())
if err != nil {
logrus.Errorf("Ratelimiter error during %s megasearch image: %s", eng.Name(), err)
}
} }
}
// Perform image search
results, err := eng.SearchImage(q)
if err != nil {
logrus.Errorf("Error during %s megasearch image: %s", eng.Name(), err)
return
}
// Convert to MegaSearchResult with engine info
mu.Lock()
for _, result := range results {
megaResult := MegaSearchResult{
SearchResult: result,
Engine: eng.Name(),
}
allResults = append(allResults, megaResult)
}
mu.Unlock()
}(engine)
} }
return enginesToUse
wg.Wait()
return allResults
} }
// deduplicateMegaResults deduplicates results while preserving engine information
func (s *Server) deduplicateMegaResults(results []MegaSearchResult) []MegaSearchResult { func (s *Server) deduplicateMegaResults(results []MegaSearchResult) []MegaSearchResult {
urlMap := make(map[string]MegaSearchResult) urlMap := make(map[string]MegaSearchResult)
// Process results and keep the first occurrence of each URL
for _, result := range results { for _, result := range results {
if result.URL == "" { if result.URL == "" {
continue continue
} }
if _, exists := urlMap[result.URL]; !exists { if _, exists := urlMap[result.URL]; !exists {
urlMap[result.URL] = result urlMap[result.URL] = result
} }
} }
// Convert map back to slice and sort by rank
var deduped []MegaSearchResult var deduped []MegaSearchResult
for _, result := range urlMap { for _, result := range urlMap {
deduped = append(deduped, result) deduped = append(deduped, result)
} }
// Sort by rank
sort.Slice(deduped, func(i, j int) bool { sort.Slice(deduped, func(i, j int) bool {
return deduped[i].Rank < deduped[j].Rank return deduped[i].Rank < deduped[j].Rank
}) })
return deduped return deduped
} }
type cacheHitCandidate struct {
key string
logMessage string
}
func (s *Server) tryServeCacheHit(c *fiber.Ctx, candidates ...cacheHitCandidate) (bool, error) {
if s.cache == nil {
return false, nil
}
for _, candidate := range candidates {
cached, ok := s.cache.Get(candidate.key)
if !ok {
continue
}
c.Set("Content-Type", "application/json")
c.Set("X-Cache", "HIT")
logrus.Info(candidate.logMessage)
return true, c.Send(cached)
}
return false, nil
}
func (s *Server) cacheJSON(cacheKey string, payload interface{}) bool {
if s.cache == nil {
return false
}
data, err := json.Marshal(payload)
if err != nil {
s.cache.RecordBypass()
return false
}
s.cache.Set(cacheKey, data)
return true
}
func (s *Server) cacheMegaResults(action string, enginesToUse []SearchEngine, q Query, dedupedResults []MegaSearchResult) string {
cacheStatus := "BYPASS"
if s.cache == nil {
return cacheStatus
}
if len(dedupedResults) == 0 {
s.cache.RecordBypass()
return cacheStatus
}
cacheEngines := s.megaCacheableEngines(enginesToUse)
if len(cacheEngines) == 0 {
s.cache.RecordBypass()
return cacheStatus
}
if s.cacheJSON(s.buildMegaCacheKey(action, cacheEngines, q), dedupedResults) {
return "MISS"
}
return cacheStatus
}
func (s *Server) buildMegaCacheKey(action string, engines []SearchEngine, q Query) string {
names := make([]string, 0, len(engines))
for _, eng := range engines {
names = append(names, strings.ToLower(strings.TrimSpace(eng.Name())))
}
sort.Strings(names)
// Deduplicate engine names in key to keep cache stable when order differs
// or repeated names are passed in the engines query parameter.
uniq := names[:0]
last := ""
for _, name := range names {
if name == last {
continue
}
uniq = append(uniq, name)
last = name
}
return BuildCacheKey("mega:"+strings.Join(uniq, ","), action, q)
}
func (s *Server) megaCacheableEngines(engines []SearchEngine) []SearchEngine {
open := make(map[string]bool)
for _, stat := range s.resilient.GetCircuitBreakerStats() {
name, _ := stat["engine"].(string)
state, _ := stat["state"].(string)
if strings.EqualFold(state, "open") {
open[strings.ToLower(strings.TrimSpace(name))] = true
}
}
cacheable := make([]SearchEngine, 0, len(engines))
for _, eng := range engines {
if open[strings.ToLower(strings.TrimSpace(eng.Name()))] {
continue
}
cacheable = append(cacheable, eng)
}
return cacheable
}
func (s *Server) cacheStatsPayload() interface{} {
if s.cache == nil {
return map[string]interface{}{"status": false}
}
return s.cache.Stats()
}
func (s *Server) applyProxyHeaders(c *fiber.Ctx, meta ProxyExecutionMeta) {
mode := meta.Mode
if mode == "" {
mode = ProxyModeOff
}
tag := meta.Tag
used := meta.Used
if mode == ProxyModeOff {
tag = ""
used = "direct"
}
c.Set("X-Proxy-Mode", mode)
c.Set("X-Proxy-Tag", tag)
c.Set("X-Proxy-Used", used)
}
func (s *Server) Listen() error { func (s *Server) Listen() error {
return s.app.Listen(s.addr) return s.app.Listen(s.addr)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,10 @@ services:
- 7000:7000 - 7000:7000
command: serve -l command: serve -l
#volumes: #volumes:
# - ./config.yaml:/usr/src/app/config.yaml # - ./config.yaml:/usr/src/app/config.yaml:ro
environment: environment:
OPENSERP_APP_HOST: "0.0.0.0" OPENSERP_SERVER_HOST: "0.0.0.0"
OPENSERP_APP_PORT: 7000 OPENSERP_SERVER_PORT: 7000
OPENSERP_BAIDU_RATE_REQUESTS: 6 # Number of requests per Minute OPENSERP_BAIDU_RATE_REQUESTS: 6 # Number of requests per Minute
OPENSERP_BAIDU_RATE_BURST: 2 # Number of non-ratelimited requests per Minute OPENSERP_BAIDU_RATE_BURST: 2 # Number of non-ratelimited requests per Minute

1
go.mod
View File

@@ -22,6 +22,7 @@ require (
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect

2
go.sum
View File

@@ -6,6 +6,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/corpix/uarand v0.2.0 h1:U98xXwud/AVuCpkpgfPF7J5TQgr7R5tqT8VZP5KWbzE= github.com/corpix/uarand v0.2.0 h1:U98xXwud/AVuCpkpgfPF7J5TQgr7R5tqT8VZP5KWbzE=
github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM= github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

View File

@@ -113,9 +113,11 @@ func (gogl *Google) checkCaptcha(page *rod.Page) bool {
func (gogl *Google) preparePage(page *rod.Page) { func (gogl *Google) preparePage(page *rod.Page) {
// Remove "similar queries" lists // Remove "similar queries" lists
_, err := page.Eval(";(() => { document.querySelectorAll(`div[data-initq]`).forEach( el => el.remove()); })();") _, err := page.Eval(`() => {
document.querySelectorAll("div[data-initq]").forEach((el) => el.remove())
}`)
if err != nil { if err != nil {
gogl.logger.Error("Page preparation failed: %s", err) gogl.logger.Debug("Page preparation skipped: %s", err)
} }
} }
@@ -185,7 +187,7 @@ func (gogl *Google) Search(query core.Query) ([]core.SearchResult, error) {
return nil, err return nil, err
} }
rank := 0 rank := query.Start
for _, resEl := range searchResultElems { for _, resEl := range searchResultElems {
srchRes := core.SearchResult{} srchRes := core.SearchResult{}

View File

@@ -1,64 +1,19 @@
package google package google
import ( import (
"context"
"crypto/tls"
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/corpix/uarand" "github.com/corpix/uarand"
"github.com/karust/openserp/core" "github.com/karust/openserp/core"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
utls "github.com/refraction-networking/utls"
) )
func googleRequest(searchURL string, query core.Query) (*http.Response, error) { func googleRequest(searchURL string, query core.Query) (*http.Response, error) {
// Create HTTP transport with proxy baseClient, err := core.NewRawHTTPClient(query)
transport := &http.Transport{} if err != nil {
if query.ProxyURL != "" { return nil, err
proxyUrl, err := url.Parse(query.ProxyURL)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyUrl)
}
// Set insecure TLS
if query.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
hostname := strings.Split(addr, ":")[0]
config := &utls.Config{
ServerName: hostname,
InsecureSkipVerify: query.Insecure,
}
uconn := utls.UClient(rawConn, config, utls.HelloChrome_Auto)
if err := uconn.Handshake(); err != nil {
rawConn.Close()
return nil, err
}
return uconn, nil
}
baseClient := &http.Client{
Transport: transport,
Timeout: time.Second * 10,
} }
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
@@ -162,6 +117,12 @@ func Search(query core.Query) ([]core.SearchResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if query.Start > 0 {
for i := range results {
results[i].Rank = query.Start + i + 1
}
}
logrus.Debugf("Google Raw results : %v", results) logrus.Debugf("Google Raw results : %v", results)
return results, nil return results, nil

View File

@@ -2,32 +2,8 @@ package google
import ( import (
"testing" "testing"
"time"
"github.com/karust/openserp/core"
) )
var browser *core.Browser
func init() {
opts := core.BrowserOpts{IsHeadless: true, IsLeakless: false, Timeout: time.Second * 5, LeavePageOpen: false}
browser, _ = core.NewBrowser(opts)
}
func TestSearchGoogle(t *testing.T) {
gogl := New(*browser, core.SearchEngineOptions{})
query := core.Query{Text: "HEY", Limit: 10}
results, err := gogl.Search(query)
if err != nil {
t.Fatalf("Cannot [SearchGoogle]: %s", err)
}
if len(results) == 0 {
t.Fatalf("[SearchGoogle] returned empty result")
}
}
func TestParseSourceImageURL(t *testing.T) { func TestParseSourceImageURL(t *testing.T) {
//href1 := `/imgres?imgurl=https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2F2%2F26%2FMarmota_marmota_Alpes2.jpg&amp;tbnid=Be_RycOe8xzlpM&amp;vet=12ahUKEwjkh6WzwIeAAxWV_yoKHRzHC9wQMygAegUIARD0AQ..i&amp;imgrefurl=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FAlpine_marmot&amp;docid=7miWbc2QiSw9uM&amp;w=801&amp;h=599&amp;q=alpine%20marmot&amp;ved=2ahUKEwjkh6WzwIeAAxWV_yoKHRzHC9wQMygAegUIARD0AQ` //href1 := `/imgres?imgurl=https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2F2%2F26%2FMarmota_marmota_Alpes2.jpg&amp;tbnid=Be_RycOe8xzlpM&amp;vet=12ahUKEwjkh6WzwIeAAxWV_yoKHRzHC9wQMygAegUIARD0AQ..i&amp;imgrefurl=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FAlpine_marmot&amp;docid=7miWbc2QiSw9uM&amp;w=801&amp;h=599&amp;q=alpine%20marmot&amp;ved=2ahUKEwjkh6WzwIeAAxWV_yoKHRzHC9wQMygAegUIARD0AQ`
href2 := `/imgres?imgurl=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnaturerules1%2Fimages%2Ff%2Ff2%2F13d79d934ccf6f7919777fcb6dbb6e6c.jpg%2Frevision%2Flatest%3Fcb%3D20210218225522&tbnid=JxC8NUyBjdNbdM&vet=12ahUKEwiHrJnN1YeAAxXvEBAIHfRADAAQMygCegUIARD4AQ..i&imgrefurl=https%3A%2F%2Fnaturerules1.fandom.com%2Fwiki%2FAlpine_Marmot&docid=XXYeDjL67badNM&w=1600&h=1200&q=alpine%20marmot&ved=2ahUKEwiHrJnN1YeAAxXvEBAIHfRADAAQMygCegUIARD4AQ` href2 := `/imgres?imgurl=https%3A%2F%2Fstatic.wikia.nocookie.net%2Fnaturerules1%2Fimages%2Ff%2Ff2%2F13d79d934ccf6f7919777fcb6dbb6e6c.jpg%2Frevision%2Flatest%3Fcb%3D20210218225522&tbnid=JxC8NUyBjdNbdM&vet=12ahUKEwiHrJnN1YeAAxXvEBAIHfRADAAQMygCegUIARD4AQ..i&imgrefurl=https%3A%2F%2Fnaturerules1.fandom.com%2Fwiki%2FAlpine_Marmot&docid=XXYeDjL67badNM&w=1600&h=1200&q=alpine%20marmot&ved=2ahUKEwiHrJnN1YeAAxXvEBAIHfRADAAQMygCegUIARD4AQ`
@@ -47,21 +23,3 @@ func TestParseSourceImageURL(t *testing.T) {
t.Fatalf("Want: %v, Got: %v", want, got) t.Fatalf("Want: %v, Got: %v", want, got)
} }
} }
func TestImageSearch(t *testing.T) {
gogl := New(*browser, core.SearchEngineOptions{})
query := core.Query{Text: "Ferrari Testarossa", Limit: 77}
results, err := gogl.SearchImage(query)
if err != nil {
t.Fatalf("Cannot search images: %s", err)
}
if len(results) < 77 {
t.Fatalf("Returned not full result")
}
if results[0].URL == "" {
t.Fatalf("First result doesn't contain URL, %v+", results[0])
}
}

View File

@@ -259,6 +259,19 @@ func BuildURL(q core.Query) (string, error) {
params.Add("num", strconv.Itoa(q.Limit)) params.Add("num", strconv.Itoa(q.Limit))
} }
// Set result offset for pagination
if q.Start < 0 {
return "", errors.New("incorrect start param provided")
}
if q.Start > 0 {
params.Add("start", strconv.Itoa(q.Start))
}
// Google default is filter=1; send only when user asks to include similar results.
if !q.Filter {
params.Add("filter", "0")
}
if q.LangCode != "" { if q.LangCode != "" {
params.Add("hl", q.LangCode) params.Add("hl", q.LangCode)
params.Add("lr", "lang_"+strings.ToLower(q.LangCode)) params.Add("lr", "lang_"+strings.ToLower(q.LangCode))

25
testutil/integration.go Normal file
View File

@@ -0,0 +1,25 @@
package testutil
import (
"os"
"strings"
"testing"
)
const IntegrationEnv = "OPENSERP_INTEGRATION_TESTS"
func RequireIntegration(t *testing.T) {
t.Helper()
if strings.TrimSpace(os.Getenv(IntegrationEnv)) != "1" {
t.Skipf("set %s=1 to run integration tests", IntegrationEnv)
}
}
func RequireEnv(t *testing.T, key string) string {
t.Helper()
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
t.Skipf("set %s to run this integration test", key)
}
return value
}

View File

@@ -115,9 +115,17 @@ func (yand *Yandex) parseResults(results rod.Elements, pageNum int) []core.Searc
func (yand *Yandex) Search(query core.Query) ([]core.SearchResult, error) { func (yand *Yandex) Search(query core.Query) ([]core.SearchResult, error) {
yand.logger.Debug("Starting search, query: %+v", query) yand.logger.Debug("Starting search, query: %+v", query)
if query.Start < 0 {
return nil, fmt.Errorf("incorrect start provided")
}
allResults := []core.SearchResult{} allResults := []core.SearchResult{}
searchPage := 0 const pageSize = 10
searchPage, skipOnFirstPage, err := core.ComputePagination(query.Start, pageSize)
if err != nil {
return nil, err
}
startPage := searchPage
for len(allResults) < query.Limit { for len(allResults) < query.Limit {
url, err := BuildURL(query, searchPage) url, err := BuildURL(query, searchPage)
@@ -158,6 +166,13 @@ func (yand *Yandex) Search(query core.Query) ([]core.SearchResult, error) {
} }
r := yand.parseResults(elements, searchPage) r := yand.parseResults(elements, searchPage)
if searchPage == startPage && skipOnFirstPage > 0 {
if skipOnFirstPage >= len(r) {
r = []core.SearchResult{}
} else {
r = r[skipOnFirstPage:]
}
}
allResults = append(allResults, r...) allResults = append(allResults, r...)
searchPage++ searchPage++

View File

@@ -1,64 +1,19 @@
package yandex package yandex
import ( import (
"context"
"crypto/tls"
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/corpix/uarand" "github.com/corpix/uarand"
"github.com/karust/openserp/core" "github.com/karust/openserp/core"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
utls "github.com/refraction-networking/utls"
) )
func yandexRequest(searchURL string, query core.Query) (*http.Response, error) { func yandexRequest(searchURL string, query core.Query) (*http.Response, error) {
// Create HTTP transport with proxy baseClient, err := core.NewRawHTTPClient(query)
transport := &http.Transport{} if err != nil {
if query.ProxyURL != "" { return nil, err
proxyUrl, err := url.Parse(query.ProxyURL)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyUrl)
}
// Set insecure TLS
if query.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
hostname := strings.Split(addr, ":")[0]
config := &utls.Config{
ServerName: hostname,
InsecureSkipVerify: query.Insecure,
}
uconn := utls.UClient(rawConn, config, utls.HelloChrome_Auto)
if err := uconn.Handshake(); err != nil {
rawConn.Close()
return nil, err
}
return uconn, nil
}
baseClient := &http.Client{
Transport: transport,
Timeout: time.Second * 10,
} }
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
@@ -120,7 +75,12 @@ func yandexResultParser(response *http.Response) ([]core.SearchResult, error) {
} }
func Search(query core.Query) ([]core.SearchResult, error) { func Search(query core.Query) ([]core.SearchResult, error) {
googleURL, err := BuildURL(query, 1) startPage, skipOnFirstPage, err := core.ComputePagination(query.Start, 10)
if err != nil {
return nil, err
}
googleURL, err := BuildURL(query, startPage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -136,6 +96,19 @@ func Search(query core.Query) ([]core.SearchResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if skipOnFirstPage > 0 {
if skipOnFirstPage >= len(results) {
results = []core.SearchResult{}
} else {
results = results[skipOnFirstPage:]
}
}
if query.Start > 0 {
for i := range results {
results[i].Rank = query.Start + i + 1
}
}
logrus.Debugf("Yandex Raw results : %v", results) logrus.Debugf("Yandex Raw results : %v", results)
return results, nil return results, nil