mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
565 lines
19 KiB
Go
565 lines
19 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// Extraction depth bounds for the unified extract=N query param. The default
|
|
// is 1 (extract=true == extract=1 == "extract one result"); callers raise it up
|
|
// to maxExtractTop. These mirror the CLI's --extract flag limits.
|
|
const (
|
|
defaultExtractTop = 1
|
|
maxExtractTop = 5
|
|
)
|
|
|
|
// ErrCaptcha is returned when the engine detects a captcha challenge page.
|
|
// This error is treated as non-retryable by resilient search policies.
|
|
var ErrCaptcha = errors.New("captcha detected")
|
|
|
|
// ErrSearchTimeout is returned when required SERP elements are not found before
|
|
// selector or page timeouts expire.
|
|
var ErrSearchTimeout = errors.New("timeout. Cannot find element on page")
|
|
|
|
// ErrParser is returned when SERP parsing selectors drift or expected fields
|
|
// cannot be extracted from an otherwise loaded page.
|
|
var ErrParser = errors.New("parser failure")
|
|
|
|
// ErrEngineInternal is returned when an engine recovered from an unexpected
|
|
// panic and converted it into a typed error.
|
|
var ErrEngineInternal = errors.New("engine internal error")
|
|
|
|
// ErrProxyConnect is returned when the proxy cannot establish a network
|
|
// connection. Proxy health is degraded on this error.
|
|
var ErrProxyConnect = errors.New("proxy_connect")
|
|
|
|
// ErrProxyAuth is returned when proxy credentials are rejected.
|
|
// Proxy health is degraded on this error.
|
|
var ErrProxyAuth = errors.New("proxy_auth")
|
|
|
|
// ErrTimeout is returned when a network-level timeout occurs on the proxy path.
|
|
// Proxy health is degraded on this error.
|
|
var ErrTimeout = errors.New("timeout")
|
|
|
|
// ErrEmptyResult signals a successful fetch that returned zero organic results.
|
|
// It is not a failure; the proxy stays healthy and no credit is charged.
|
|
var ErrEmptyResult = errors.New("empty_result")
|
|
|
|
// ErrBlocked is returned when the search engine blocks the browser request.
|
|
var ErrBlocked = errors.New("blocked")
|
|
|
|
// ErrRateLimited is returned when the search engine returns HTTP 429.
|
|
var ErrRateLimited = errors.New("rate_limited")
|
|
|
|
// IsProxyNetworkError reports whether err is a network-level error that
|
|
// indicates a faulty proxy (connect failure, auth rejection, or timeout).
|
|
// Parser drift, captcha pages, and engine errors must NOT degrade proxy health.
|
|
func IsProxyNetworkError(err error) bool {
|
|
return errors.Is(err, ErrProxyConnect) ||
|
|
errors.Is(err, ErrProxyAuth) ||
|
|
errors.Is(err, ErrTimeout)
|
|
}
|
|
|
|
// classifyProxyNetworkError wraps common transport errors with proxy-health
|
|
// sentinels while preserving the original error for callers.
|
|
func classifyProxyNetworkError(err error) error {
|
|
if err == nil || IsProxyNetworkError(err) || errors.Is(err, context.Canceled) {
|
|
return err
|
|
}
|
|
|
|
msg := strings.ToLower(err.Error())
|
|
if strings.Contains(msg, "407") || strings.Contains(msg, "proxy authentication") {
|
|
return fmt.Errorf("%w: %w", ErrProxyAuth, err)
|
|
}
|
|
|
|
var netErr net.Error
|
|
if (errors.As(err, &netErr) && netErr.Timeout()) ||
|
|
errors.Is(err, context.DeadlineExceeded) ||
|
|
strings.Contains(msg, "timeout") ||
|
|
strings.Contains(msg, "deadline exceeded") {
|
|
return fmt.Errorf("%w: %w", ErrTimeout, err)
|
|
}
|
|
|
|
if strings.Contains(msg, "proxyconnect") ||
|
|
strings.Contains(msg, "connection refused") ||
|
|
strings.Contains(msg, "connection reset") ||
|
|
strings.Contains(msg, "err_tunnel_connection_failed") ||
|
|
strings.Contains(msg, "no such host") ||
|
|
strings.Contains(msg, "network is unreachable") ||
|
|
strings.Contains(msg, "socks connect") {
|
|
return fmt.Errorf("%w: %w", ErrProxyConnect, err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// SearchResult represents one normalized result item returned by any engine.
|
|
type SearchResult struct {
|
|
// Rank is the 1-based position within this result type. For SEO callers,
|
|
// organic rank must not be shifted by ads.
|
|
Rank int `json:"rank"`
|
|
// AbsoluteRank is the 1-based position in the mixed SERP stream.
|
|
AbsoluteRank int `json:"absolute_rank,omitempty"`
|
|
// Type is the SERP block type when an engine can classify a non-standard
|
|
// SERP module without changing the public SearchEngine interface.
|
|
Type ResultType `json:"type,omitempty"`
|
|
// URL is the canonical result URL.
|
|
URL string `json:"url"`
|
|
// Title is the result headline shown on the SERP.
|
|
Title string `json:"title"`
|
|
// Description is the snippet text associated with the result.
|
|
Description string `json:"description"`
|
|
// Ad reports whether the result is sponsored.
|
|
Ad bool `json:"ad"`
|
|
// Features carries extracted SERP modules alongside the legacy result stream.
|
|
Features []SerpFeature `json:"-"`
|
|
}
|
|
|
|
// DeduplicateResults removes items with duplicate URLs and returns a result set
|
|
// sorted by rank in ascending order.
|
|
func DeduplicateResults(results []SearchResult) []SearchResult {
|
|
unique := make(map[string]bool)
|
|
var deduped []SearchResult
|
|
|
|
for _, result := range results {
|
|
if result.URL == "" {
|
|
continue
|
|
}
|
|
key := resultDedupKey(result)
|
|
if !unique[key] {
|
|
unique[key] = true
|
|
deduped = append(deduped, result)
|
|
}
|
|
}
|
|
|
|
sort.Slice(deduped, func(i, j int) bool {
|
|
return resultLess(deduped[i], deduped[j])
|
|
})
|
|
return deduped
|
|
}
|
|
|
|
// ConvertSearchResultsMap converts a map-based collection to a rank-sorted
|
|
// slice and returns it by pointer.
|
|
func ConvertSearchResultsMap(searchResultsMap map[string]SearchResult) *[]SearchResult {
|
|
searchResults := []SearchResult{}
|
|
|
|
for _, v := range searchResultsMap {
|
|
searchResults = append(searchResults, v)
|
|
}
|
|
|
|
sort.Slice(searchResults, func(i, j int) bool {
|
|
return resultLess(searchResults[i], searchResults[j])
|
|
})
|
|
return &searchResults
|
|
}
|
|
|
|
// CountOrganicResults returns the number of non-ad results in a mixed SERP.
|
|
func CountOrganicResults(results []SearchResult) int {
|
|
count := 0
|
|
for _, result := range results {
|
|
if !result.Ad {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// OrganicLimitReached reports whether enough organic results have been
|
|
// collected to satisfy limit. A non-positive limit means "no limit", so it
|
|
// is never reached and pagination continues until the engine runs out.
|
|
func OrganicLimitReached(results []SearchResult, limit int) bool {
|
|
return limit > 0 && CountOrganicResults(results) >= limit
|
|
}
|
|
|
|
// ShouldFetchResultPage reports whether a paginated engine should fetch another
|
|
// SERP page. Small/default limits should use the first SERP page as-is instead
|
|
// of chasing a target count across multiple page loads.
|
|
func ShouldFetchResultPage(collected, limit, pagesFetched int) bool {
|
|
if pagesFetched <= 0 {
|
|
return true
|
|
}
|
|
if limit > 0 && collected >= limit {
|
|
return false
|
|
}
|
|
return limit > defaultQueryLimit
|
|
}
|
|
|
|
// LimitOrganicResults keeps all ads and at most limit non-ad results.
|
|
func LimitOrganicResults(results []SearchResult, limit int) []SearchResult {
|
|
if limit <= 0 {
|
|
return results
|
|
}
|
|
out := make([]SearchResult, 0, len(results))
|
|
organicCount := 0
|
|
for _, result := range results {
|
|
if result.Ad {
|
|
out = append(out, result)
|
|
continue
|
|
}
|
|
if organicCount >= limit {
|
|
continue
|
|
}
|
|
organicCount++
|
|
out = append(out, result)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resultDedupKey(result SearchResult) string {
|
|
resultType := "organic"
|
|
if result.Ad {
|
|
resultType = "ad"
|
|
}
|
|
return resultType + "\x00" + result.URL
|
|
}
|
|
|
|
func resultLess(left, right SearchResult) bool {
|
|
leftPos := resultSortPosition(left)
|
|
rightPos := resultSortPosition(right)
|
|
if leftPos != rightPos {
|
|
return leftPos < rightPos
|
|
}
|
|
if left.Ad != right.Ad {
|
|
return left.Ad
|
|
}
|
|
if left.Rank != right.Rank {
|
|
return left.Rank < right.Rank
|
|
}
|
|
return left.URL < right.URL
|
|
}
|
|
|
|
func resultSortPosition(result SearchResult) int {
|
|
if result.AbsoluteRank > 0 {
|
|
return result.AbsoluteRank
|
|
}
|
|
if result.Rank < 0 {
|
|
return -result.Rank
|
|
}
|
|
if result.Rank > 0 {
|
|
return result.Rank
|
|
}
|
|
return int(^uint(0) >> 1)
|
|
}
|
|
|
|
// Query holds request parameters used by HTTP handlers and search engines.
|
|
// Example minimal query: Query{Text: "golang", Limit: 10}.
|
|
type Query struct {
|
|
// Text is the search phrase, for example "golang fiber tutorial".
|
|
Text string
|
|
// LangCode is an engine language hint such as "EN", "DE", or "RU".
|
|
LangCode string
|
|
// Region is an engine market/location hint. Yandex accepts numeric lr IDs;
|
|
// global engines accept country-style hints such as "RU" or "en-RU".
|
|
Region string
|
|
// DateInterval filters by date range in YYYYMMDD..YYYYMMDD format.
|
|
// Example: "20250101..20250331".
|
|
DateInterval string
|
|
// Filetype is a file extension filter, for example "pdf" or "docx".
|
|
Filetype string
|
|
// Site restricts results to a specific domain, for example "github.com".
|
|
Site string
|
|
// Limit is the maximum number of results requested by the client.
|
|
Limit int
|
|
// Start is an engine pagination offset. Values are engine-specific:
|
|
// Google commonly uses 0,10,20 while some engines use page indexes.
|
|
Start int
|
|
// Filter controls duplicate filtering when supported by the engine.
|
|
// For Google, false includes similar results and true hides them.
|
|
Filter bool
|
|
// Features enables parsing SERP feature modules (AI summaries, answer boxes,
|
|
// people-also-ask, related searches) on the browser Search path when
|
|
// supported by the engine. Such entries may be returned with non-positive
|
|
// internal rank values.
|
|
Features bool
|
|
// Extract fetches and embeds cleaned target-page content for top results.
|
|
Extract bool
|
|
// ExtractTop limits how many top results are enriched when Extract is true.
|
|
ExtractTop int
|
|
// ExtractMode selects auto, fast, or rendered extraction.
|
|
ExtractMode string
|
|
// ExtractMinRunes overrides the auto-mode escalation floor (0 = default).
|
|
ExtractMinRunes int
|
|
// ProxyURL is a direct proxy URL used by raw HTTP search paths.
|
|
ProxyURL string
|
|
// ProxyCountry identifies the proxy market country for cache/error metadata.
|
|
ProxyCountry string
|
|
// ProxyClass identifies the proxy class such as datacenter or residential.
|
|
ProxyClass string
|
|
// ProxyProvider identifies the upstream proxy provider.
|
|
ProxyProvider string
|
|
// ProxySessionID identifies a sticky balancer session/lane.
|
|
ProxySessionID string
|
|
// ProxyOverride is a request-scoped proxy policy override (tag or "direct"),
|
|
// typically parsed from the X-Use-Proxy header.
|
|
ProxyOverride string
|
|
// Insecure enables insecure TLS for request/browser execution.
|
|
Insecure bool
|
|
// GuardPrivateNetworks rejects raw HTTP targets that resolve to private,
|
|
// loopback, link-local, multicast, or otherwise non-public addresses.
|
|
GuardPrivateNetworks bool
|
|
}
|
|
|
|
// String renders Query for logs with the proxy URL credentials masked. The
|
|
// default %+v formatter calls this method, so logging Query through %v/%+v
|
|
// never leaks proxy passwords.
|
|
func (q Query) String() string {
|
|
maskedProxyURL := ""
|
|
if q.ProxyURL != "" {
|
|
maskedProxyURL = MaskProxyURL(q.ProxyURL)
|
|
}
|
|
return fmt.Sprintf(
|
|
"{Text:%s LangCode:%s Region:%s DateInterval:%s Filetype:%s Site:%s Limit:%d Start:%d Filter:%t Features:%t Extract:%t ExtractTop:%d ExtractMode:%s ProxyURL:%s ProxyCountry:%s ProxyClass:%s ProxyProvider:%s ProxySessionID:%s ProxyOverride:%s Insecure:%t}",
|
|
q.Text, q.LangCode, q.Region, q.DateInterval, q.Filetype, q.Site,
|
|
q.Limit, q.Start, q.Filter, q.Features, q.Extract, q.ExtractTop, q.ExtractMode,
|
|
maskedProxyURL, q.ProxyCountry, q.ProxyClass, q.ProxyProvider,
|
|
q.ProxySessionID, q.ProxyOverride, q.Insecure,
|
|
)
|
|
}
|
|
|
|
// ComputePagination translates an absolute start offset into page index and
|
|
// in-page offset for a fixed page size.
|
|
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
|
|
}
|
|
|
|
// IsEmpty reports whether query text operators are all absent.
|
|
func (q Query) IsEmpty() bool {
|
|
if q.Site == "" && q.Filetype == "" && q.Text == "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MaxQueryLimit is the maximum allowed value for the limit parameter.
|
|
const MaxQueryLimit = 100
|
|
|
|
// defaultQueryLimit is the assumed limit when a request omits it (InitFromContext)
|
|
// and the fallback used by pagination math for internally-built queries that
|
|
// leave Limit unset.
|
|
const defaultQueryLimit = 10
|
|
|
|
// InitFromContext populates Query from HTTP query parameters and request
|
|
// headers. It validates numeric/boolean inputs and returns an *APIError for
|
|
// invalid client input (400) or a plain error for internal failures.
|
|
func (searchQuery *Query) InitFromContext(reqCtx *fiber.Ctx) error {
|
|
searchQuery.Text = strings.TrimSpace(reqCtx.Query("text"))
|
|
searchQuery.LangCode = strings.TrimSpace(reqCtx.Query("lang"))
|
|
searchQuery.Region = strings.TrimSpace(reqCtx.Query("region"))
|
|
searchQuery.DateInterval = strings.TrimSpace(reqCtx.Query("date"))
|
|
searchQuery.Filetype = strings.TrimSpace(reqCtx.Query("file"))
|
|
searchQuery.Site = strings.TrimSpace(reqCtx.Query("site"))
|
|
|
|
limitRaw := reqCtx.Query("limit", strconv.Itoa(defaultQueryLimit))
|
|
limit, err := strconv.Atoi(limitRaw)
|
|
if err != nil {
|
|
return errInvalidLimit("limit must be an integer")
|
|
}
|
|
if limit < 1 || limit > MaxQueryLimit {
|
|
return errInvalidLimit(fmt.Sprintf("limit must be between 1 and %d", MaxQueryLimit))
|
|
}
|
|
searchQuery.Limit = limit
|
|
|
|
startRaw := reqCtx.Query("start", "0")
|
|
start, err := strconv.Atoi(startRaw)
|
|
if err != nil {
|
|
return errInvalidStart("start must be a non-negative integer")
|
|
}
|
|
if start < 0 {
|
|
return errInvalidStart("start must be >= 0")
|
|
}
|
|
searchQuery.Start = start
|
|
|
|
searchQuery.Filter, err = strconv.ParseBool(reqCtx.Query("filter", "1"))
|
|
if err != nil {
|
|
return errInvalidParam(fmt.Sprintf("filter: %v", err))
|
|
}
|
|
|
|
searchQuery.Features, err = strconv.ParseBool(reqCtx.Query("features", "1"))
|
|
if err != nil {
|
|
return errInvalidParam(fmt.Sprintf("features: %v", err))
|
|
}
|
|
// extract is a unified bool-or-int knob: extract=0/false disables, extract=N
|
|
// (or true/1) extracts the top N results. The tuning params extract_mode and
|
|
// min_runes also imply extraction (extract=0 still overrides them). The
|
|
// default depth is 1 — true == 1 == "extract one result".
|
|
if err := parseExtractParams(reqCtx, searchQuery); err != nil {
|
|
return err
|
|
}
|
|
|
|
searchQuery.ProxyOverride, err = NormalizeProxyRequestOverride(reqCtx.Get("X-Use-Proxy"))
|
|
if err != nil {
|
|
return errInvalidParam(fmt.Sprintf("X-Use-Proxy: %v", err))
|
|
}
|
|
rawProxyURL := strings.TrimSpace(reqCtx.Get("X-Proxy-URL"))
|
|
if rawProxyURL != "" {
|
|
normalized, err := NormalizeProxyURL(rawProxyURL)
|
|
if err != nil {
|
|
return errInvalidParam(fmt.Sprintf("X-Proxy-URL: %v", err))
|
|
}
|
|
searchQuery.ProxyURL = normalized
|
|
}
|
|
searchQuery.ProxyCountry = strings.ToLower(strings.TrimSpace(reqCtx.Get("X-Proxy-Country")))
|
|
searchQuery.ProxyClass = strings.ToLower(strings.TrimSpace(reqCtx.Get("X-Proxy-Class")))
|
|
searchQuery.ProxyProvider = strings.ToLower(strings.TrimSpace(reqCtx.Get("X-Proxy-Provider")))
|
|
searchQuery.ProxySessionID = strings.TrimSpace(reqCtx.Get("X-Proxy-Session-ID"))
|
|
|
|
if searchQuery.IsEmpty() {
|
|
return errEmptyQuery()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseExtractParams reads the unified extract knob plus its tuning params onto
|
|
// q. The extract param is bool-or-int:
|
|
//
|
|
// extract=0 / extract=false → extraction off
|
|
// extract=true / extract=1 → on, top 1
|
|
// extract=N (1..5) → on, top N (clamped to maxExtractTop)
|
|
//
|
|
// extract_mode and min_runes tune how extraction runs and imply extraction when
|
|
// present, unless extract is explicitly set (extract=0 wins over them). When
|
|
// extraction is on but no depth is given, ExtractTop defaults to 1.
|
|
func parseExtractParams(reqCtx *fiber.Ctx, q *Query) error {
|
|
q.ExtractTop = defaultExtractTop
|
|
|
|
// extract accepts both bool spellings (true/false/1/0) and an integer depth.
|
|
// Try bool first so legacy true/false keep working, then fall back to int.
|
|
extractExplicit := false
|
|
if raw := strings.TrimSpace(reqCtx.Query("extract")); raw != "" {
|
|
extractExplicit = true
|
|
if b, err := strconv.ParseBool(raw); err == nil {
|
|
q.Extract = b
|
|
if b {
|
|
q.ExtractTop = 1
|
|
}
|
|
} else if n, err := strconv.Atoi(raw); err == nil {
|
|
q.Extract = n > 0
|
|
if n > 0 {
|
|
q.ExtractTop = clampExtractTop(n)
|
|
}
|
|
} else {
|
|
return errInvalidParam("extract must be a boolean or an integer (0 disables, N extracts top N)")
|
|
}
|
|
}
|
|
|
|
q.ExtractMode = strings.ToLower(strings.TrimSpace(reqCtx.Query("extract_mode", "auto")))
|
|
switch q.ExtractMode {
|
|
case "auto", "fast", "rendered":
|
|
default:
|
|
return errInvalidParam("extract_mode must be one of auto, fast, rendered")
|
|
}
|
|
if !extractExplicit && strings.TrimSpace(reqCtx.Query("extract_mode")) != "" {
|
|
q.Extract = true
|
|
}
|
|
|
|
minRunes, err := parseNonNegativeIntQuery(reqCtx.Query("min_runes"), 0)
|
|
if err != nil {
|
|
return errInvalidParam("min_runes must be a non-negative integer")
|
|
}
|
|
q.ExtractMinRunes = minRunes
|
|
if !extractExplicit && strings.TrimSpace(reqCtx.Query("min_runes")) != "" {
|
|
q.Extract = true
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// clampExtractTop bounds a requested extraction depth to [1, maxExtractTop].
|
|
func clampExtractTop(n int) int {
|
|
if n < 1 {
|
|
return 1
|
|
}
|
|
if n > maxExtractTop {
|
|
return maxExtractTop
|
|
}
|
|
return n
|
|
}
|
|
|
|
// SearchEngineOptions controls engine pacing, selector waits, and captcha
|
|
// handling behavior shared by browser and raw implementations.
|
|
type SearchEngineOptions struct {
|
|
// RateRequests is the allowed number of requests within RateTime seconds.
|
|
RateRequests int `mapstructure:"rate_requests"`
|
|
// RateTime defines the rate-limiting window size in seconds.
|
|
RateTime int64 `mapstructure:"rate_seconds"`
|
|
// RateBurst is the token bucket burst size for short spikes.
|
|
RateBurst int `mapstructure:"rate_burst"`
|
|
// SelectorTimeout is the per-selector wait timeout in seconds.
|
|
SelectorTimeout int64 `mapstructure:"selector_timeout"`
|
|
// IsSolveCaptcha enables automatic captcha solving when engine support and
|
|
// solver credentials are configured.
|
|
IsSolveCaptcha bool `mapstructure:"captcha"`
|
|
|
|
limiterState *rateLimiterState
|
|
}
|
|
|
|
type rateLimiterState struct {
|
|
limiter *rate.Limiter
|
|
every time.Duration
|
|
burst int
|
|
}
|
|
|
|
var searchEngineOptionsLimiterMu sync.Mutex
|
|
|
|
// Init sets default option values when fields are zero.
|
|
func (o *SearchEngineOptions) Init() {
|
|
if o.RateRequests == 0 {
|
|
o.RateRequests = 6
|
|
}
|
|
if o.RateTime == 0 {
|
|
o.RateTime = 60
|
|
}
|
|
if o.RateBurst == 0 {
|
|
o.RateBurst = 1
|
|
}
|
|
if o.SelectorTimeout == 0 {
|
|
o.SelectorTimeout = 5
|
|
}
|
|
}
|
|
|
|
// 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 cached limiter configured from SearchEngineOptions.
|
|
// Call Init() first so RateBurst is non-zero. Do not copy SearchEngineOptions
|
|
// after first use; the limiter state is intentionally shared by each engine.
|
|
func (o *SearchEngineOptions) GetRateLimiter() *rate.Limiter {
|
|
every := o.GetRatelimit()
|
|
burst := o.RateBurst
|
|
searchEngineOptionsLimiterMu.Lock()
|
|
defer searchEngineOptionsLimiterMu.Unlock()
|
|
if o.limiterState == nil {
|
|
o.limiterState = &rateLimiterState{}
|
|
}
|
|
if o.limiterState.limiter == nil || o.limiterState.every != every || o.limiterState.burst != burst {
|
|
o.limiterState.limiter = rate.NewLimiter(rate.Every(every), burst)
|
|
o.limiterState.every = every
|
|
o.limiterState.burst = burst
|
|
}
|
|
return o.limiterState.limiter
|
|
}
|
|
|
|
// GetSelectorTimeout returns the selector wait timeout as time.Duration.
|
|
func (o *SearchEngineOptions) GetSelectorTimeout() time.Duration {
|
|
return time.Duration(o.SelectorTimeout) * time.Second
|
|
}
|