mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
refactor: centralize engine prelude, page cleanup, and rate limiter
This commit is contained in:
@@ -21,9 +21,9 @@ A developer-friendly alternative to paid SERP API services!
|
||||
- 🌐 **Megasearch** - cross-engine aggregation with deduplication
|
||||
- 🖼 **Images** - image search is also available
|
||||
- 🎯 **Advanced filters** - language, date range, file type, and site queries
|
||||
- 🌍 **Configurable** - proxy, cache, and resilient mode support
|
||||
- 🌍 **Configurable** - proxy, cache, and resilient mode
|
||||
- 🐳 **Docker-ready** - local and container deployment
|
||||
- 📝 **Data Formats** - JSON, Markdown, Text, NdJSON response format types are supported
|
||||
- 📝 **Data Formats** - JSON, Markdown, Text, NdJSON response formats
|
||||
|
||||
## Quick Start⚡️
|
||||
|
||||
@@ -267,4 +267,4 @@ This project is licensed under the MIT License. See [LICENSE](LICENSE).
|
||||
|
||||
Contributions are welcome. See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
|
||||
|
||||
###### _"OpenSERP" is the name of this open-source project. Use of the name in a way that implies affiliation, endorsement, or official status is not permitted._
|
||||
###### _"OpenSERP" is the name of this open-source project. The official [website](https://openserp.org) and [hosted](https://openserp.org/cloud) solution. Use of the name in a way that implies affiliation, endorsement, or official status is not permitted._
|
||||
|
||||
101
baidu/search.go
101
baidu/search.go
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type imageDataJson struct {
|
||||
@@ -54,12 +53,6 @@ func (baid *Baidu) Name() string {
|
||||
return "baidu"
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (baid *Baidu) GetRateLimiter() *rate.Limiter {
|
||||
ratelimit := rate.Every(baid.GetRatelimit())
|
||||
return rate.NewLimiter(ratelimit, baid.RateBurst)
|
||||
}
|
||||
|
||||
func (baid *Baidu) isCaptcha(page *rod.Page) bool {
|
||||
has, _, _ := page.Has(Selectors.Captcha)
|
||||
return has
|
||||
@@ -85,10 +78,7 @@ func (baid *Baidu) classifyBlockPage(page *rod.Page, url string) error {
|
||||
// Search executes a Baidu web search and returns normalized search results.
|
||||
// It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (baid *Baidu) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), baid.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithMinimalBrowserProfile(ctx)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, baid.Name(), true)
|
||||
scoped := *baid
|
||||
scoped.logger = baid.logger.WithRequest(ctx)
|
||||
if scoped.Browser.WaitLoadTime == 0 || scoped.Browser.WaitLoadTime > 250*time.Millisecond {
|
||||
@@ -114,21 +104,12 @@ func (baid *Baidu) Search(ctx context.Context, query core.Query) (results []core
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if baid.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
baid.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &baid.Browser)()
|
||||
|
||||
searchResults, err := baid.waitForParsedSearchResults(ctx, page, url)
|
||||
if err != nil {
|
||||
closePage()
|
||||
return nil, err
|
||||
}
|
||||
closePage()
|
||||
|
||||
for i := range searchResults {
|
||||
searchResults[i].Rank = query.Start + i + 1
|
||||
@@ -185,10 +166,7 @@ func (baid *Baidu) waitForParsedSearchResults(ctx context.Context, page *rod.Pag
|
||||
// SearchImage executes a Baidu image search and returns normalized image
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), baid.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithMinimalBrowserProfile(ctx)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, baid.Name(), true)
|
||||
scoped := *baid
|
||||
scoped.logger = baid.logger.WithRequest(ctx)
|
||||
baid = &scoped
|
||||
@@ -198,57 +176,46 @@ func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.Se
|
||||
searchResults := []core.SearchResult{}
|
||||
searchPage := 0
|
||||
|
||||
for len(searchResults) < query.Limit {
|
||||
// fetchPage loads one image-results page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop without error.
|
||||
fetchPage := func() (bool, error) {
|
||||
url, err := BuildImageURL(query, searchPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// First load often seeds anti-crawler cookies; results tend to become
|
||||
// available after one explicit reload.
|
||||
page, err := baid.Navigate(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if baid.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
baid.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &baid.Browser)()
|
||||
|
||||
jsonWaitTimeout := baid.GetSelectorTimeout()
|
||||
if reloadErr := page.Reload(); reloadErr != nil {
|
||||
closePage()
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
preElements, _, err := core.WaitForElements(ctx, page, Selectors.ImageJSONRoot, jsonWaitTimeout)
|
||||
if err != nil {
|
||||
if blockErr := baid.classifyBlockPage(page, url); blockErr != nil {
|
||||
closePage()
|
||||
return nil, blockErr
|
||||
return false, blockErr
|
||||
}
|
||||
closePage()
|
||||
baid.logger.Error("Cannot parse search results: %s", err)
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
if len(preElements) == 0 {
|
||||
if blockErr := baid.classifyBlockPage(page, url); blockErr != nil {
|
||||
closePage()
|
||||
return nil, blockErr
|
||||
return false, blockErr
|
||||
}
|
||||
closePage()
|
||||
return nil, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
jsonText, err := preElements[0].Text()
|
||||
if err != nil {
|
||||
closePage()
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
var data imageDataJson
|
||||
@@ -257,28 +224,23 @@ func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.Se
|
||||
jsonText = strings.ReplaceAll(jsonText, `\'`, "'")
|
||||
matchNewlines, err := regexp.Compile(`[\r\n\t]`)
|
||||
if err != nil {
|
||||
closePage()
|
||||
return nil, core.ErrParser
|
||||
return false, core.ErrParser
|
||||
}
|
||||
escapeNewlines := func(s string) string {
|
||||
return matchNewlines.ReplaceAllString(s, "\\n")
|
||||
}
|
||||
re, err := regexp.Compile(`"[^"\\]*(?:\\[\s\S][^"\\]*)*"`)
|
||||
if err != nil {
|
||||
closePage()
|
||||
return nil, core.ErrParser
|
||||
return false, core.ErrParser
|
||||
}
|
||||
fixedJson := re.ReplaceAllStringFunc(jsonText, escapeNewlines)
|
||||
|
||||
err = json.Unmarshal([]byte(fixedJson), &data)
|
||||
if err != nil {
|
||||
closePage()
|
||||
if err := json.Unmarshal([]byte(fixedJson), &data); err != nil {
|
||||
baid.logger.Error("Failed to unmarshal JSON: %v", err)
|
||||
return nil, core.ErrParser
|
||||
return false, core.ErrParser
|
||||
}
|
||||
if len(data.Data) == 0 {
|
||||
closePage()
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for i, img := range data.Data {
|
||||
@@ -299,23 +261,26 @@ func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.Se
|
||||
img.Type,
|
||||
img.IsCopyright,
|
||||
),
|
||||
Ad: func() bool {
|
||||
if img.AdType != "0" {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}(),
|
||||
Ad: img.AdType != "0",
|
||||
}
|
||||
searchResults = append(searchResults, res)
|
||||
if query.Limit > 0 && len(searchResults) >= query.Limit {
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
searchPage += 1
|
||||
return false, nil
|
||||
}
|
||||
|
||||
closePage()
|
||||
for len(searchResults) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchPage++
|
||||
if done {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deduped := core.DeduplicateResults(searchResults)
|
||||
|
||||
@@ -25,9 +25,7 @@ func classifyBaiduRawHTML(body []byte) error {
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.EnsureContext(ctx)
|
||||
ctx = core.WithEngine(ctx, "baidu")
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, "baidu", false)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = core.RecoverEnginePanicWithContext(ctx, "baidu", recovered, nil)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Bing implements core.SearchEngine for Bing SERP pages.
|
||||
@@ -35,12 +34,6 @@ func (bing *Bing) Name() string {
|
||||
return "bing"
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (bing *Bing) GetRateLimiter() *rate.Limiter {
|
||||
ratelimit := rate.Every(bing.GetRatelimit())
|
||||
return rate.NewLimiter(ratelimit, bing.RateBurst)
|
||||
}
|
||||
|
||||
func (bing *Bing) getTotalResults(page *rod.Page) (int, error) {
|
||||
results, err := page.Timeout(bing.GetSelectorTimeout()).Elements(Selectors.Results)
|
||||
if err != nil {
|
||||
@@ -87,9 +80,7 @@ func (bing *Bing) acceptCookies(ctx context.Context, page *rod.Page) error {
|
||||
// Search executes a Bing web search and returns normalized search results.
|
||||
// It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (bing *Bing) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), bing.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, bing.Name(), false)
|
||||
scoped := *bing
|
||||
scoped.logger = bing.logger.WithRequest(ctx)
|
||||
bing = &scoped
|
||||
@@ -113,14 +104,7 @@ func (bing *Bing) Search(ctx context.Context, query core.Query) (results []core.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if bing.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
bing.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
}()
|
||||
defer core.DeferClosePage(ctx, page, &bing.Browser)()
|
||||
|
||||
if bing.checkCaptcha(page) {
|
||||
bing.logger.Error("Captcha detected: %s", url)
|
||||
@@ -289,9 +273,7 @@ func resolveImageLinkElement(container *rod.Element) (*rod.Element, error) {
|
||||
// SearchImage executes a Bing image search and returns normalized image
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (bing *Bing) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), bing.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, bing.Name(), false)
|
||||
scoped := *bing
|
||||
scoped.logger = bing.logger.WithRequest(ctx)
|
||||
bing = &scoped
|
||||
@@ -310,14 +292,7 @@ func (bing *Bing) SearchImage(ctx context.Context, query core.Query) ([]core.Sea
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if bing.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
bing.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
}()
|
||||
defer core.DeferClosePage(ctx, page, &bing.Browser)()
|
||||
|
||||
// Check for captcha
|
||||
if bing.checkCaptcha(page) {
|
||||
|
||||
33
config.yaml
33
config.yaml
@@ -14,8 +14,9 @@ app:
|
||||
head: false # Headful mode
|
||||
leakless: false # Force browser process cleanup after request
|
||||
leave_head: false # Keep tabs open after request
|
||||
max_processes: 5 # Concurrent Chrome processes
|
||||
max_processes: 6 # Concurrent Chrome processes
|
||||
idle_ttl: 5m # close a Chrome that has not served traffic for this long
|
||||
|
||||
mega_timeout: 90s # max total wait for /mega/* requests; slow engines return partial results
|
||||
proxies:
|
||||
allow_request_proxy_url: false
|
||||
@@ -30,14 +31,14 @@ proxies:
|
||||
# - url: socks5h://127.0.0.1:1080
|
||||
# tags: [eu]
|
||||
health:
|
||||
failure_threshold: 3 # Disable proxy after this many consecutive failures
|
||||
failure_threshold: 2 # Disable proxy after this many consecutive failures
|
||||
lanes:
|
||||
enabled: true # Reuse browser profile/cookies per engine + proxy session ID
|
||||
max_lanes: 100 # LRU cap for sticky lanes kept in worker memory
|
||||
drop_cookies_on_challenge: true # Clear lane cookies on captcha/challenge only
|
||||
|
||||
cache:
|
||||
ttl_seconds: 60 # Dedicated endpoint cache TTL in seconds (0 disables cache)
|
||||
ttl_seconds: 120 # Dedicated endpoint cache TTL in seconds (0 disables cache)
|
||||
max_size: 1000 # Maximum cached dedicated responses before oldest-entry eviction
|
||||
|
||||
resilience:
|
||||
@@ -63,29 +64,29 @@ captcha:
|
||||
solver_enabled: false # Global captcha solver gate (requires 2captcha.apikey)
|
||||
|
||||
google:
|
||||
rate_requests: 4 # Allowed average requests per minute
|
||||
rate_burst: 2 # Burst requests before limiter applies
|
||||
captcha: true # Engine-level solver flag (also requires captcha.solver_enabled=true)
|
||||
rate_requests: 60 # Allowed average requests per minute
|
||||
rate_burst: 3 # Burst requests before limiter applies
|
||||
#captcha: true # Engine-level solver flag (also requires captcha.solver_enabled=true)
|
||||
|
||||
yandex:
|
||||
rate_requests: 4
|
||||
rate_burst: 2
|
||||
rate_requests: 60
|
||||
rate_burst: 3
|
||||
|
||||
baidu:
|
||||
rate_requests: 4
|
||||
rate_burst: 2
|
||||
rate_requests: 60
|
||||
rate_burst: 3
|
||||
# No proxy tag means direct traffic
|
||||
|
||||
bing:
|
||||
rate_requests: 4
|
||||
rate_burst: 2
|
||||
rate_requests: 60
|
||||
rate_burst: 3
|
||||
# No proxy tag means direct traffic
|
||||
|
||||
duckduckgo:
|
||||
rate_requests: 4
|
||||
rate_burst: 2
|
||||
rate_requests: 60
|
||||
rate_burst: 3
|
||||
|
||||
ecosia:
|
||||
rate_requests: 4
|
||||
rate_burst: 2
|
||||
rate_requests: 60
|
||||
rate_burst: 3
|
||||
# No proxy tag means direct traffic
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// ErrCaptcha is returned when the engine detects a captcha challenge page.
|
||||
@@ -322,10 +323,17 @@ func (o *SearchEngineOptions) Init() {
|
||||
}
|
||||
|
||||
// GetRatelimit returns the interval between two allowed requests.
|
||||
// Call Init() first so RateRequests / RateTime are non-zero.
|
||||
func (o *SearchEngineOptions) GetRatelimit() time.Duration {
|
||||
return (time.Duration(o.RateTime) * time.Second) / time.Duration(o.RateRequests)
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
// Call Init() first so RateBurst is non-zero.
|
||||
func (o *SearchEngineOptions) GetRateLimiter() *rate.Limiter {
|
||||
return rate.NewLimiter(rate.Every(o.GetRatelimit()), o.RateBurst)
|
||||
}
|
||||
|
||||
// GetSelectorTimeout returns the selector wait timeout as time.Duration.
|
||||
func (o *SearchEngineOptions) GetSelectorTimeout() time.Duration {
|
||||
return time.Duration(o.SelectorTimeout) * time.Second
|
||||
|
||||
@@ -36,3 +36,14 @@ func SleepContext(ctx context.Context, d time.Duration) error {
|
||||
func IsContextDone(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// PrepareEngineContext applies request-scoped metadata expected by all engine
|
||||
// search implementations.
|
||||
func PrepareEngineContext(ctx context.Context, query Query, engineName string, minimalBrowserProfile bool) context.Context {
|
||||
ctx = WithEngine(EnsureContext(ctx), engineName)
|
||||
ctx = WithProfileRegion(ctx, query.LangCode)
|
||||
if minimalBrowserProfile {
|
||||
ctx = WithMinimalBrowserProfile(ctx)
|
||||
}
|
||||
return WithQueryHash(ctx, QueryHashFromQuery(query))
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ func HasAnySelector(page *rod.Page, selectors []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DeferClosePage returns a cleanup function that closes page unless the browser
|
||||
// is configured to leave pages open for debugging.
|
||||
func DeferClosePage(ctx context.Context, page *rod.Page, browser *Browser) func() {
|
||||
return func() {
|
||||
if browser != nil && browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if err := ClosePageWithTimeout(ctx, page, time.Second); err != nil {
|
||||
WithRequest(ctx).WithError(err).Debug("Page close error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasAttribute reports whether el carries attr (regardless of value).
|
||||
func HasAttribute(el *rod.Element, attr string) bool {
|
||||
if el == nil {
|
||||
|
||||
@@ -206,10 +206,11 @@ func (rs *ResilientSearcher) searchWithProtection(ctx context.Context, engine Se
|
||||
results []SearchResult
|
||||
err error
|
||||
)
|
||||
requestCtx := proxyRequestContext(callCtx, engine.Name(), attemptQuery)
|
||||
if isImage {
|
||||
results, err = engine.SearchImage(proxyRequestContext(callCtx, engine.Name(), attemptQuery), attemptQuery)
|
||||
results, err = engine.SearchImage(requestCtx, attemptQuery)
|
||||
} else {
|
||||
results, err = engine.Search(proxyRequestContext(callCtx, engine.Name(), attemptQuery), attemptQuery)
|
||||
results, err = engine.Search(requestCtx, attemptQuery)
|
||||
}
|
||||
|
||||
if reportToRegistry {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// DuckDuckGo implements core.SearchEngine for DuckDuckGo SERP pages.
|
||||
@@ -35,12 +34,6 @@ func (ddg *DuckDuckGo) Name() string {
|
||||
return "duckduckgo"
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (ddg *DuckDuckGo) GetRateLimiter() *rate.Limiter {
|
||||
ratelimit := rate.Every(ddg.GetRatelimit())
|
||||
return rate.NewLimiter(ratelimit, ddg.RateBurst)
|
||||
}
|
||||
|
||||
func (ddg *DuckDuckGo) isCaptcha(page *rod.Page) bool {
|
||||
if core.HasAnySelector(page, Selectors.CaptchaSelectors) {
|
||||
return true
|
||||
@@ -145,9 +138,7 @@ func (ddg *DuckDuckGo) parseResults(results rod.Elements, pageNum int) []core.Se
|
||||
// Search executes a DuckDuckGo web search and returns normalized search
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), ddg.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, ddg.Name(), false)
|
||||
scoped := *ddg
|
||||
scoped.logger = ddg.logger.WithRequest(ctx)
|
||||
ddg = &scoped
|
||||
@@ -163,62 +154,54 @@ func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results []
|
||||
allResults := []core.SearchResult{}
|
||||
searchPage := 0
|
||||
|
||||
for len(allResults) < query.Limit {
|
||||
// fetchPage loads one SERP page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop without error.
|
||||
fetchPage := func() (bool, error) {
|
||||
url, err := BuildURL(query, searchPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
page, err := ddg.Navigate(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if ddg.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
ddg.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &ddg.Browser)()
|
||||
|
||||
elements, selector, err := core.WaitForElements(ctx, page, Selectors.Results, ddg.GetSelectorTimeout())
|
||||
if err != nil {
|
||||
if ddg.isNoResults(page) {
|
||||
ddg.logger.Warn("No results found")
|
||||
closePage()
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
if ddg.isCaptcha(page) {
|
||||
ddg.logger.Error("Captcha detected: %s", url)
|
||||
closePage()
|
||||
return nil, core.ErrCaptcha
|
||||
return false, core.ErrCaptcha
|
||||
}
|
||||
closePage()
|
||||
ddg.logger.Error("Cannot parse search results: %s", err)
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
ddg.logger.Debug("Found results with selector: %s", selector)
|
||||
|
||||
r := ddg.parseResults(elements, searchPage)
|
||||
|
||||
if len(r) == 0 {
|
||||
ddg.logger.Debug("No valid results found on page %d", searchPage)
|
||||
closePage()
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
allResults = append(allResults, r...)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for len(allResults) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchPage++
|
||||
|
||||
// Close tab before opening new one during the cycle
|
||||
closePage()
|
||||
|
||||
// Break if we've reached or exceeded the limit
|
||||
if len(allResults) >= query.Limit {
|
||||
if done || len(allResults) >= query.Limit {
|
||||
break
|
||||
}
|
||||
|
||||
if err := core.SleepContext(ctx, ddg.pageSleep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -239,9 +222,7 @@ func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results []
|
||||
// SearchImage executes a DuckDuckGo image search and returns normalized image
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (ddg *DuckDuckGo) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), ddg.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, ddg.Name(), false)
|
||||
scoped := *ddg
|
||||
scoped.logger = ddg.logger.WithRequest(ctx)
|
||||
ddg = &scoped
|
||||
@@ -260,13 +241,7 @@ func (ddg *DuckDuckGo) SearchImage(ctx context.Context, query core.Query) ([]cor
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ddg.Browser.LeavePageOpen {
|
||||
defer func() {
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
ddg.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &ddg.Browser)()
|
||||
|
||||
elements, selector, err := core.WaitForElements(ctx, page, Selectors.ImageResult, ddg.GetSelectorTimeout())
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// ecosiaPageSize is the organic-results-per-page count on Ecosia's web SERP.
|
||||
@@ -43,7 +42,6 @@ const (
|
||||
cfBodyMarker = "not a bot"
|
||||
)
|
||||
|
||||
|
||||
// Ecosia implements core.SearchEngine for Ecosia SERP pages. Additional
|
||||
// documentation at https://support.ecosia.org/article/447-search-features.
|
||||
type Ecosia struct {
|
||||
@@ -66,11 +64,6 @@ func New(browser core.Browser, opts core.SearchEngineOptions) *Ecosia {
|
||||
// Name returns the stable engine identifier.
|
||||
func (e *Ecosia) Name() string { return "ecosia" }
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (e *Ecosia) GetRateLimiter() *rate.Limiter {
|
||||
return rate.NewLimiter(rate.Every(e.GetRatelimit()), e.RateBurst)
|
||||
}
|
||||
|
||||
// isCaptcha reports whether the current page is a Cloudflare interstitial.
|
||||
// Checks the URL, title, then body text (cheapest first).
|
||||
func (e *Ecosia) isCaptcha(page *rod.Page) bool {
|
||||
@@ -132,9 +125,7 @@ func (e *Ecosia) parseResult(elem *rod.Element, rank int, ad bool) (core.SearchR
|
||||
// Search executes an Ecosia web search and returns normalized search results.
|
||||
// It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (e *Ecosia) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), e.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, e.Name(), false)
|
||||
scoped := *e
|
||||
scoped.logger = e.logger.WithRequest(ctx)
|
||||
e = &scoped
|
||||
@@ -155,40 +146,32 @@ func (e *Ecosia) Search(ctx context.Context, query core.Query) (results []core.S
|
||||
return nil, err
|
||||
}
|
||||
nextAdRank := -1
|
||||
for query.Limit <= 0 || len(all) < query.Limit {
|
||||
// fetchPage loads one SERP page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop without error.
|
||||
fetchPage := func() (bool, error) {
|
||||
u, err := BuildURL(query, pageNum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
page, err := e.Navigate(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if e.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
e.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &e.Browser)()
|
||||
|
||||
if err := page.WaitLoad(); err != nil {
|
||||
closePage()
|
||||
e.logger.Error("Page load wait failed: %s", err)
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
if _, err := page.Timeout(e.GetSelectorTimeout()).Element(Selectors.Mainline); err != nil {
|
||||
if e.isCaptcha(page) {
|
||||
closePage()
|
||||
e.logger.Error("Captcha detected: %s", u)
|
||||
return nil, core.ErrCaptcha
|
||||
return false, core.ErrCaptcha
|
||||
}
|
||||
closePage()
|
||||
e.logger.Warn("Mainline not found on page %d", pageNum)
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
|
||||
organic, _ := page.Elements(Selectors.Result)
|
||||
@@ -196,9 +179,8 @@ func (e *Ecosia) Search(ctx context.Context, query core.Query) (results []core.S
|
||||
if len(organic) == 0 && len(ads) == 0 {
|
||||
// Empty mainline = zero-result query or end of pagination, not
|
||||
// a parser failure; don't trip the retry path.
|
||||
closePage()
|
||||
e.logger.Debug("No results on page %d", pageNum)
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for _, r := range organic {
|
||||
@@ -213,10 +195,18 @@ func (e *Ecosia) Search(ctx context.Context, query core.Query) (results []core.S
|
||||
nextAdRank--
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
closePage()
|
||||
for query.Limit <= 0 || len(all) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageNum++
|
||||
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if query.Limit > 0 && len(all) >= query.Limit {
|
||||
break
|
||||
}
|
||||
@@ -293,9 +283,7 @@ func (e *Ecosia) parseImageResult(el *rod.Element, rank int) (core.SearchResult,
|
||||
// query.Start is ignored: per-page card count varies, so callers should
|
||||
// drive depth through query.Limit alone.
|
||||
func (e *Ecosia) SearchImage(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), e.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, e.Name(), false)
|
||||
scoped := *e
|
||||
scoped.logger = e.logger.WithRequest(ctx)
|
||||
e = &scoped
|
||||
@@ -311,47 +299,38 @@ func (e *Ecosia) SearchImage(ctx context.Context, query core.Query) (results []c
|
||||
out := []core.SearchResult{}
|
||||
pageNum := 0
|
||||
nextRank := 1
|
||||
for query.Limit <= 0 || len(out) < query.Limit {
|
||||
// fetchPage loads one image page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop without error.
|
||||
fetchPage := func() (bool, error) {
|
||||
u, err := BuildImageURL(query, pageNum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
page, err := e.Navigate(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if e.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
e.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &e.Browser)()
|
||||
|
||||
if err := page.WaitLoad(); err != nil {
|
||||
closePage()
|
||||
e.logger.Error("Page load wait failed: %s", err)
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
if _, err := page.Timeout(e.GetSelectorTimeout()).Element(Selectors.ImageResult); err != nil {
|
||||
if e.isCaptcha(page) {
|
||||
closePage()
|
||||
e.logger.Error("Captcha detected: %s", u)
|
||||
return nil, core.ErrCaptcha
|
||||
return false, core.ErrCaptcha
|
||||
}
|
||||
closePage()
|
||||
e.logger.Debug("No image results on page %d", pageNum)
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
|
||||
elements, err := page.Elements(Selectors.ImageResult)
|
||||
if err != nil {
|
||||
closePage()
|
||||
e.logger.Error("Cannot collect image results: %s", err)
|
||||
return nil, core.ErrParser
|
||||
return false, core.ErrParser
|
||||
}
|
||||
|
||||
for _, el := range elements {
|
||||
@@ -363,10 +342,18 @@ func (e *Ecosia) SearchImage(ctx context.Context, query core.Query) (results []c
|
||||
break
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
closePage()
|
||||
for query.Limit <= 0 || len(out) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageNum++
|
||||
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if query.Limit > 0 && len(out) >= query.Limit {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -71,9 +71,7 @@ func imageResultParser(response *http.Response) ([]core.SearchResult, error) {
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.EnsureContext(ctx)
|
||||
ctx = core.WithEngine(ctx, "ecosia")
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, "ecosia", false)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = core.RecoverEnginePanicWithContext(ctx, "ecosia", recovered, nil)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Google implements core.SearchEngine for Google SERP pages.
|
||||
@@ -38,12 +37,6 @@ func (gogl *Google) Name() string {
|
||||
return "google"
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (gogl *Google) GetRateLimiter() *rate.Limiter {
|
||||
ratelimit := rate.Every(gogl.GetRatelimit())
|
||||
return rate.NewLimiter(ratelimit, gogl.RateBurst)
|
||||
}
|
||||
|
||||
func (gogl *Google) getTotalResults(page *rod.Page) (int, error) {
|
||||
if gogl.rgxpGetDigits == nil {
|
||||
return 0, core.ErrParser
|
||||
@@ -178,10 +171,7 @@ func (gogl *Google) acceptCookies(page *rod.Page) {
|
||||
// Search executes a Google web search and returns normalized search results.
|
||||
// It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (gogl *Google) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), gogl.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithMinimalBrowserProfile(ctx)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, gogl.Name(), true)
|
||||
scoped := *gogl
|
||||
scoped.logger = gogl.logger.WithRequest(ctx)
|
||||
gogl = &scoped
|
||||
@@ -446,10 +436,7 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor
|
||||
// SearchImage executes a Google image search and returns normalized image
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (gogl *Google) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), gogl.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithMinimalBrowserProfile(ctx)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, gogl.Name(), true)
|
||||
scoped := *gogl
|
||||
scoped.logger = gogl.logger.WithRequest(ctx)
|
||||
gogl = &scoped
|
||||
@@ -591,10 +578,5 @@ func (gogl *Google) parseImageCell(r *rod.Element, dst map[string]core.SearchRes
|
||||
}
|
||||
|
||||
func (gogl *Google) close(ctx context.Context, page *rod.Page) {
|
||||
if !gogl.Browser.LeavePageOpen {
|
||||
err := core.ClosePageWithTimeout(ctx, page, time.Second)
|
||||
if err != nil {
|
||||
gogl.logger.Debug("Page close error: %v", err)
|
||||
}
|
||||
}
|
||||
core.DeferClosePage(ctx, page, &gogl.Browser)()
|
||||
}
|
||||
|
||||
@@ -109,9 +109,7 @@ func classifyGoogleRawHTML(body []byte) error {
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.EnsureContext(ctx)
|
||||
ctx = core.WithEngine(ctx, "google")
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, "google", false)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = core.RecoverEnginePanicWithContext(ctx, "google", recovered, nil)
|
||||
|
||||
171
logo.svg
171
logo.svg
@@ -1,94 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" role="img" aria-labelledby="title desc">
|
||||
<title id="title">OpenSERP – Multi‑Engine API Logo</title>
|
||||
<desc id="desc">Professional banner-like logo with a unified search bar and chips for Google, Bing, DuckDuckGo, Yandex and Baidu, plus an API caption.</desc>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 400" width="1280" height="400" role="img" aria-label="OpenSERP — One API for every search engine">
|
||||
<defs>
|
||||
<linearGradient id="bar" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#F5F7FB"/>
|
||||
<stop offset="100%" stop-color="#FFFFFF"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-10%" y="-30%" width="120%" height="160%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-opacity="0.18"/>
|
||||
<filter id="barShadow" x="-10%" y="-50%" width="120%" height="260%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#0E1116" flood-opacity="0.08"></feDropShadow>
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#0E1116" flood-opacity="0.1"></feDropShadow>
|
||||
</filter>
|
||||
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
<style>
|
||||
.muted{fill:#5F6B7A}
|
||||
.chip-label{font:700 14px Inter,Segoe UI,Arial,sans-serif;fill:#1F2937}
|
||||
.api{font:800 44px "Segoe UI",Inter,Arial,sans-serif;fill:#EA4335}
|
||||
.title{font:800 46px Inter,Segoe UI,Arial,sans-serif;letter-spacing:0.2px}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Top brand title similar to the provided mock but neutral -->
|
||||
<g transform="translate(640,58)" text-anchor="middle">
|
||||
<text class="title" x="0" y="0">
|
||||
<tspan fill="#4285F4">O</tspan><tspan fill="#EA4335">p</tspan><tspan fill="#FBBC04">e</tspan><tspan fill="#4285F4">n</tspan><tspan fill="#34A853">S</tspan><tspan fill="#EA4335">E</tspan><tspan fill="#4285F4">R</tspan><tspan fill="#FBBC04">P</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<text x="640" y="86" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="-2.5"><tspan fill="#E84B3C">O</tspan><tspan fill="#3D85F0">p</tspan><tspan fill="#E84B3C">e</tspan><tspan fill="#F2B22A">n</tspan><tspan fill="#2BB673">S</tspan><tspan fill="#E84B3C">E</tspan><tspan fill="#3D85F0">R</tspan><tspan fill="#E84B3C">P</tspan></text>
|
||||
|
||||
<!-- Main unified search bar -->
|
||||
<g transform="translate(160,100)">
|
||||
<rect x="0" y="0" rx="28" ry="28" width="960" height="60" fill="url(#bar)" filter="url(#shadow)"/>
|
||||
<!-- left magnifier -->
|
||||
<g class="muted" transform="translate(22,18)">
|
||||
<circle cx="14" cy="14" r="10" fill="none" stroke="#98A2B3" stroke-width="3"/>
|
||||
<rect x="22" y="22" width="12" height="4" rx="2" fill="#98A2B3" transform="rotate(45 28 24)"/>
|
||||
|
||||
<text x="640" y="116" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="14" fill="#0E1116" font-weight="500">One API · Every search engine · Free & open-source</text>
|
||||
|
||||
|
||||
<g font-family="Helvetica, Arial, sans-serif" font-size="13" font-weight="500">
|
||||
<g transform="translate(308, 148)">
|
||||
<rect width="87" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#E84B3C"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">Google</text>
|
||||
</g>
|
||||
<!-- placeholder text -->
|
||||
<text x="70" y="37" font="500 18px Inter,Segoe UI,Arial" fill="#5F6B7A">Search across Google, Bing, DuckDuckGo, Yandex, Baidu…</text>
|
||||
<!-- right icons -->
|
||||
<g transform="translate(880,13)" class="muted">
|
||||
<rect x="48" y="-2" width="1" height="36" fill="#E5E7EB"/>
|
||||
<circle cx="18" cy="17" r="8" fill="#E5E7EB"/>
|
||||
<rect x="12" y="8" width="12" height="6" rx="3" fill="#98A2B3"/>
|
||||
<circle cx="18" cy="17" r="3" fill="#98A2B3"/>
|
||||
<!-- microphone glyph -->
|
||||
<rect x="62" y="6" width="8" height="16" rx="4" fill="#98A2B3"/>
|
||||
<rect x="60" y="24" width="12" height="3" rx="1.5" fill="#98A2B3"/>
|
||||
<g transform="translate(405, 148)">
|
||||
<rect width="72" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#3D85F0"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">Bing</text>
|
||||
</g>
|
||||
<g transform="translate(487, 148)">
|
||||
<rect width="117" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#F2B22A"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">DuckDuckGo</text>
|
||||
</g>
|
||||
<g transform="translate(614, 148)">
|
||||
<rect width="87" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#E84B3C"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">Yandex</text>
|
||||
</g>
|
||||
<g transform="translate(722, 148)">
|
||||
<rect width="80" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#3D85F0"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">Baidu</text>
|
||||
</g>
|
||||
<g transform="translate(812, 148)">
|
||||
<rect width="87" height="32" rx="16" fill="#FFFFFF" stroke="#E5E2D9"></rect>
|
||||
<circle cx="16" cy="16" r="4" fill="#2BB673"></circle>
|
||||
<text x="28" y="20" fill="#0E1116">Ecosia</text>
|
||||
</g>
|
||||
<g transform="translate(908, 148)">
|
||||
<rect width="73" height="32" rx="16" fill="none" stroke="#3D85F0" stroke-dasharray="3 3"></rect>
|
||||
<text x="36.5" y="20" text-anchor="middle" fill="#3D85F0">+ more</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Engine chips (brand colored, accessible, non-trademark glyphs) -->
|
||||
<g transform="translate(640,88)" text-anchor="middle" dominant-baseline="middle">
|
||||
<!-- layout positions -->
|
||||
<g transform="translate(-420,-8)">
|
||||
<rect rx="14" ry="14" width="120" height="28" fill="#FFFFFF" stroke="#E5E7EB" filter="url(#soft)"/>
|
||||
<circle cx="16" cy="14" r="8" fill="#4285F4"/>
|
||||
<text class="chip-label" x="78" y="17">Google</text>
|
||||
</g>
|
||||
|
||||
<g stroke="#C9C4B6" stroke-width="1.3" fill="none" stroke-linecap="round">
|
||||
<line x1="351.5" y1="180" x2="351.5" y2="194"></line>
|
||||
<line x1="441" y1="180" x2="441" y2="194"></line>
|
||||
<line x1="545.5" y1="180" x2="545.5" y2="194"></line>
|
||||
<line x1="657.5" y1="180" x2="657.5" y2="194"></line>
|
||||
<line x1="762" y1="180" x2="762" y2="194"></line>
|
||||
<line x1="855.5" y1="180" x2="855.5" y2="194"></line>
|
||||
<line x1="944.5" y1="180" x2="944.5" y2="194"></line>
|
||||
<line x1="345" y1="194" x2="952" y2="194"></line>
|
||||
<line x1="640" y1="194" x2="640" y2="208"></line>
|
||||
</g>
|
||||
|
||||
<g transform="translate(-260,-8)">
|
||||
<rect rx="14" ry="14" width="110" height="28" fill="#FFFFFF" stroke="#E5E7EB" filter="url(#soft)"/>
|
||||
<circle cx="16" cy="14" r="8" fill="#008373"/>
|
||||
<text class="chip-label" x="70" y="17">Bing</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(-100,-8)">
|
||||
<rect rx="14" ry="14" width="150" height="28" fill="#FFFFFF" stroke="#E5E7EB" filter="url(#soft)"/>
|
||||
<circle cx="16" cy="14" r="8" fill="#DE5833"/>
|
||||
<text class="chip-label" x="90" y="17">DuckDuckGo</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(90,-8)">
|
||||
<rect rx="14" ry="14" width="120" height="28" fill="#FFFFFF" stroke="#E5E7EB" filter="url(#soft)"/>
|
||||
<circle cx="16" cy="14" r="8" fill="#FC3F1D"/>
|
||||
<text class="chip-label" x="78" y="17">Yandex</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(250,-8)">
|
||||
<rect rx="14" ry="14" width="115" height="28" fill="#FFFFFF" stroke="#E5E7EB" filter="url(#soft)"/>
|
||||
<circle cx="16" cy="14" r="8" fill="#2932E1"/>
|
||||
<text class="chip-label" x="72" y="17">Baidu</text>
|
||||
|
||||
<g transform="translate(280, 208)" filter="url(#barShadow)">
|
||||
<rect width="720" height="60" rx="30" fill="#FFFFFF" stroke="#E5E2D9" stroke-width="1.5"></rect>
|
||||
<circle cx="36" cy="30" r="9" fill="none" stroke="#0E1116" stroke-width="2"></circle>
|
||||
<line x1="42.5" y1="36.5" x2="50" y2="44" stroke="#0E1116" stroke-width="2" stroke-linecap="round"></line>
|
||||
<text x="62" y="35" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="14" fill="#0E1116">localhost:7000<tspan fill="#9A968A">/megasearch?q=</tspan><tspan fill="#3D85F0">…</tspan></text>
|
||||
<g transform="translate(610, 14)">
|
||||
<rect width="96" height="32" rx="16" fill="#3D85F0"></rect>
|
||||
<text x="48" y="21" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#FFFFFF" letter-spacing="0.5">Search</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- API caption -->
|
||||
<g transform="translate(640,232)" text-anchor="middle">
|
||||
<text class="api">API</text>
|
||||
|
||||
<g stroke="#C9C4B6" stroke-width="1.3" fill="none" stroke-linecap="round">
|
||||
<line x1="640" y1="268" x2="640" y2="282"></line>
|
||||
<line x1="526" y1="282" x2="758" y2="282"></line>
|
||||
<line x1="526" y1="282" x2="526" y2="296"></line>
|
||||
<line x1="610" y1="282" x2="610" y2="296"></line>
|
||||
<line x1="690" y1="282" x2="690" y2="296"></line>
|
||||
<line x1="758" y1="282" x2="758" y2="296"></line>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
|
||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="12" letter-spacing="0.5">
|
||||
<g transform="translate(494, 296)">
|
||||
<rect width="64" height="32" rx="16" fill="#E9F0FE" stroke="#3D85F0" stroke-width="1.4"></rect>
|
||||
<text x="32" y="21" text-anchor="middle" fill="#3D85F0">JSON</text>
|
||||
</g>
|
||||
<g transform="translate(570, 296)">
|
||||
<rect width="80" height="32" rx="16" fill="#E2F5EC" stroke="#2BB673" stroke-width="1.4"></rect>
|
||||
<text x="40" y="21" text-anchor="middle" fill="#2BB673">NDJSON</text>
|
||||
</g>
|
||||
<g transform="translate(662, 296)">
|
||||
<rect width="56" height="32" rx="16" fill="#FDF4E0" stroke="#F2B22A" stroke-width="1.4"></rect>
|
||||
<text x="28" y="21" text-anchor="middle" fill="#F2B22A">MD</text>
|
||||
</g>
|
||||
<g transform="translate(730, 296)">
|
||||
<rect width="56" height="32" rx="16" fill="#FDEBE9" stroke="#E84B3C" stroke-width="1.4"></rect>
|
||||
<text x="28" y="21" text-anchor="middle" fill="#E84B3C">TXT</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/karust/openserp/core"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// ImageEntity contains one image record from Yandex image search state JSON.
|
||||
@@ -60,12 +59,6 @@ func (yand *Yandex) Name() string {
|
||||
return "yandex"
|
||||
}
|
||||
|
||||
// GetRateLimiter returns a limiter configured from SearchEngineOptions.
|
||||
func (yand *Yandex) GetRateLimiter() *rate.Limiter {
|
||||
ratelimit := rate.Every(yand.GetRatelimit())
|
||||
return rate.NewLimiter(ratelimit, yand.RateBurst)
|
||||
}
|
||||
|
||||
func (yand *Yandex) isCaptcha(page *rod.Page) bool {
|
||||
has, _, _ := page.Has(Selectors.Captcha)
|
||||
return has
|
||||
@@ -146,9 +139,7 @@ func (yand *Yandex) parseImageEntities(items rod.Elements) map[string]ImageEntit
|
||||
// Search executes a Yandex web search and returns normalized search results.
|
||||
// It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), yand.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, yand.Name(), false)
|
||||
scoped := *yand
|
||||
scoped.logger = yand.logger.WithRequest(ctx)
|
||||
yand = &scoped
|
||||
@@ -172,40 +163,32 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
|
||||
}
|
||||
startPage := searchPage
|
||||
|
||||
for len(allResults) < query.Limit {
|
||||
// fetchPage loads one SERP page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop without error.
|
||||
fetchPage := func() (bool, error) {
|
||||
url, err := BuildURL(query, searchPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
page, err := yand.Navigate(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if yand.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
yand.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &yand.Browser)()
|
||||
|
||||
elements, _, err := core.WaitForElements(ctx, page, []string{Selectors.Results}, yand.GetSelectorTimeout())
|
||||
if err != nil {
|
||||
if yand.isCaptcha(page) {
|
||||
yand.logger.Error("Captcha detected: %s", url)
|
||||
closePage()
|
||||
return nil, core.ErrCaptcha
|
||||
return false, core.ErrCaptcha
|
||||
}
|
||||
if yand.isNoResults(page) {
|
||||
yand.logger.Warn("No results found")
|
||||
closePage()
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
closePage()
|
||||
yand.logger.Error("Cannot parse search results: %s", err)
|
||||
return nil, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
r := yand.parseResults(elements, searchPage)
|
||||
@@ -217,12 +200,18 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
|
||||
}
|
||||
}
|
||||
allResults = append(allResults, r...)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for len(allResults) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchPage++
|
||||
|
||||
// Close tab before opening new one during the cycle
|
||||
closePage()
|
||||
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if err := core.SleepContext(ctx, yand.pageSleep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -235,9 +224,7 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor
|
||||
// SearchImage executes a Yandex image search and returns normalized image
|
||||
// results. It may return core.ErrCaptcha or core.ErrSearchTimeout.
|
||||
func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) {
|
||||
ctx = core.WithEngine(core.EnsureContext(ctx), yand.Name())
|
||||
ctx = core.WithProfileRegion(ctx, query.LangCode)
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, yand.Name(), false)
|
||||
scoped := *yand
|
||||
scoped.logger = yand.logger.WithRequest(ctx)
|
||||
yand = &scoped
|
||||
@@ -248,25 +235,20 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
|
||||
allowPagination := query.Limit > 30
|
||||
|
||||
searchPage := 0
|
||||
for len(searchResults) < query.Limit {
|
||||
// fetchPage loads one image page and appends parsed results.
|
||||
// Returns (done, error): done=true ends the outer loop.
|
||||
fetchPage := func() (bool, error) {
|
||||
url, err := BuildImageURL(query, searchPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
searchPage += 1
|
||||
|
||||
page, err := yand.Navigate(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closePage := func() {
|
||||
if yand.Browser.LeavePageOpen {
|
||||
return
|
||||
}
|
||||
if closeErr := core.ClosePageWithTimeout(ctx, page, time.Second); closeErr != nil {
|
||||
yand.logger.Debug("Page close error: %v", closeErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
defer core.DeferClosePage(ctx, page, &yand.Browser)()
|
||||
|
||||
results, _, err := core.WaitForElements(
|
||||
ctx,
|
||||
@@ -283,14 +265,12 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
|
||||
if err != nil {
|
||||
if yand.isCaptcha(page) {
|
||||
yand.logger.Error("Captcha detected: %s", url)
|
||||
closePage()
|
||||
return searchResults, core.ErrCaptcha
|
||||
return false, core.ErrCaptcha
|
||||
}
|
||||
if yand.isNoResults(page) {
|
||||
yand.logger.Warn("No results found")
|
||||
}
|
||||
closePage()
|
||||
return searchResults, core.ErrSearchTimeout
|
||||
return false, core.ErrSearchTimeout
|
||||
}
|
||||
|
||||
pageEntities := yand.parseImageEntities(results)
|
||||
@@ -301,8 +281,7 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
|
||||
}
|
||||
}
|
||||
if len(pageEntities) == 0 {
|
||||
closePage()
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for id := range pageEntities {
|
||||
@@ -317,15 +296,22 @@ func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.S
|
||||
searchResults = append(searchResults, res)
|
||||
}
|
||||
if len(searchResults) >= query.Limit {
|
||||
closePage()
|
||||
break
|
||||
return true, nil
|
||||
}
|
||||
if searchPage == 1 && !allowPagination {
|
||||
closePage()
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for len(searchResults) < query.Limit {
|
||||
done, err := fetchPage()
|
||||
if err != nil {
|
||||
return searchResults, err
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
|
||||
closePage()
|
||||
}
|
||||
|
||||
sort.Slice(searchResults, func(i, j int) bool {
|
||||
|
||||
@@ -25,9 +25,7 @@ func classifyYandexRawHTML(body []byte) error {
|
||||
}
|
||||
|
||||
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
|
||||
ctx = core.EnsureContext(ctx)
|
||||
ctx = core.WithEngine(ctx, "yandex")
|
||||
ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query))
|
||||
ctx = core.PrepareEngineContext(ctx, query, "yandex", false)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = core.RecoverEnginePanicWithContext(ctx, "yandex", recovered, nil)
|
||||
|
||||
Reference in New Issue
Block a user