`
+ results, err := ParseHTML(bytes.NewReader([]byte(html)))
+ if !errors.Is(err, core.ErrCaptcha) {
+ t.Fatalf("expected ErrCaptcha, got results=%d err=%v", len(results), err)
+ }
+}
+
+func TestParseBingHTMLNoResults(t *testing.T) {
+ t.Parallel()
+
+ html := `There are no results for this search.`
+ results, err := ParseHTML(bytes.NewReader([]byte(html)))
+ if err != nil {
+ t.Fatalf("ParseHTML() error = %v", err)
+ }
+ if len(results) != 0 {
+ t.Fatalf("expected zero results, got %d", len(results))
+ }
+}
+
func TestParseBingHTMLAds(t *testing.T) {
t.Parallel()
diff --git a/bing/search.go b/bing/search.go
index b0b1df4..c6fdc85 100644
--- a/bing/search.go
+++ b/bing/search.go
@@ -42,6 +42,8 @@ func (bing *Bing) getTotalResults(page *rod.Page) (int, error) {
return len(results), nil
}
+// checkCaptcha classifies the live page the same way classifyBingDocument
+// (parse_html.go) classifies raw HTML, so /bing/search and /bing/parse agree.
func (bing *Bing) checkCaptcha(page *rod.Page) bool {
if page == nil {
return false
@@ -62,6 +64,29 @@ func (bing *Bing) checkCaptcha(page *rod.Page) bool {
}
}
+ return pageTextContainsAny(page, Selectors.CaptchaMarkers)
+}
+
+// checkNoResults reports whether the page text matches Bing's no-results
+// phrasing, mirroring classifyBingDocument's text-marker check.
+func (bing *Bing) checkNoResults(page *rod.Page) bool {
+ return pageTextContainsAny(page, Selectors.NoResultsMarkers)
+}
+
+func pageTextContainsAny(page *rod.Page, markers []string) bool {
+ if page == nil || len(markers) == 0 {
+ return false
+ }
+ html, err := page.HTML()
+ if err != nil {
+ return false
+ }
+ text := strings.ToLower(html)
+ for _, marker := range markers {
+ if strings.Contains(text, marker) {
+ return true
+ }
+ }
return false
}
@@ -161,11 +186,15 @@ func (bing *Bing) Search(ctx context.Context, query core.Query) (results []core.
resultElements, _, err := core.WaitForElements(ctx, page, []string{Selectors.ResultItems, Selectors.Results}, bing.GetSelectorTimeout())
if err != nil {
- // Re-check captcha on timeout - Bing interstitials can render after WaitLoad.
+ // Re-check captcha/no-results on timeout - Bing interstitials and
+ // no-results pages can both render after WaitLoad.
if bing.checkCaptcha(page) {
bing.logger.Error("Captcha detected: %s", url)
return nil, core.ErrCaptcha
}
+ if bing.checkNoResults(page) {
+ return []core.SearchResult{}, nil
+ }
bing.logger.Error("Cannot parse organic results: %s", err)
return nil, core.ErrSearchTimeout
}
diff --git a/bing/selectors.go b/bing/selectors.go
index 7b6d5cd..22946ca 100644
--- a/bing/selectors.go
+++ b/bing/selectors.go
@@ -2,21 +2,33 @@ package bing
// Selectors is the single source of truth for Bing SERP CSS selectors.
var Selectors = struct {
- Captcha []string
- CookieBtn string
- ResultItems string
- Results string
- Ads string
- ImageResults string
- Title string
- TitleFallbacks []string
- DescPrimary string
- DescFallback string
- DescAny string
- AdTitle string
+ Captcha []string
+ CaptchaMarkers []string
+ NoResultsMarkers []string
+ CookieBtn string
+ ResultItems string
+ Results string
+ Ads string
+ ImageResults string
+ Title string
+ TitleFallbacks []string
+ DescPrimary string
+ DescFallback string
+ DescAny string
+ AdTitle string
}{
- Captcha: []string{"div.captcha", "div.captcha_header"},
- CookieBtn: "button#bnp_btn_accept",
+ Captcha: []string{"div.captcha", "div.captcha_header"},
+ // CaptchaMarkers/NoResultsMarkers are checked against lowercased page text
+ // as a fallback when the CSS selectors above don't match a challenge page.
+ CaptchaMarkers: []string{
+ "verify that you are not a robot",
+ "enter the characters you see",
+ },
+ NoResultsMarkers: []string{
+ "there are no results for",
+ "no results found for",
+ },
+ CookieBtn: "button#bnp_btn_accept",
// ResultItems matches the main-column children only, so carousels and
// "related searches" cards that reuse b_algo-style markup are excluded.
ResultItems: "#b_results > li.b_algo, #b_results > li.b_ad",
diff --git a/cmd/root.go b/cmd/root.go
index c30b604..133db56 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -17,7 +17,7 @@ import (
)
const (
- version = "0.8.6"
+ version = "0.8.7"
defaultConfigFilename = "config"
envPrefix = "OPENSERP"
)
diff --git a/config.yaml b/config.yaml
index 27e4639..1f6ad8d 100644
--- a/config.yaml
+++ b/config.yaml
@@ -27,7 +27,7 @@ extract:
max_concurrent: 2
proxies:
- allow_request_proxy_url: false
+ allow_request_proxy_url: true
# Force a single proxy for all engines.
# Same behavior as passing --proxy on the CLI.
#global: http://127.0.0.1:8080
diff --git a/core/browser.go b/core/browser.go
index 6f137c1..c412e9c 100644
--- a/core/browser.go
+++ b/core/browser.go
@@ -1446,6 +1446,16 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) {
WithRequest(ctx).WithError(derr).Debug("Dispose browser context after navigate error failed")
}
}
+ // LeavePageOpen keeps the Chrome tab open for manual inspection, but the
+ // background watchers attached to it must still stop or they leak goroutines.
+ stopWatchersOnErr := func() {
+ if b.LeavePageOpen {
+ stopWorkerPatchWatcher(page)
+ stopNetworkUsageWatcher(page)
+ } else {
+ closeOnErr()
+ }
+ }
profile, laneKey := b.laneProfile(ctx, browser)
SetBrowserProfileID(ctx, profile.ID)
@@ -1493,7 +1503,7 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) {
timedPage := page.Timeout(b.Timeout)
if err := timedPage.Navigate(URL); err != nil {
- closeOnErr()
+ stopWatchersOnErr()
return nil, classifyProxyNetworkError(err)
}
@@ -1519,7 +1529,7 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) {
}
if err := classifyMainDocumentStatus(statusWatcher.Status()); err != nil {
- closeOnErr()
+ stopWatchersOnErr()
return nil, err
}
b.saveLaneCookies(ctx, page, URL)
diff --git a/core/page_helpers.go b/core/page_helpers.go
index 8529740..9266561 100644
--- a/core/page_helpers.go
+++ b/core/page_helpers.go
@@ -64,6 +64,56 @@ func WaitForElements(ctx context.Context, page *rod.Page, selectors []string, ti
return nil, "", ErrSearchTimeout
}
+// DocSignals is the per-engine selector/text-marker config for
+// ClassifyChallengeDocument.
+type DocSignals struct {
+ CaptchaSelectors []string
+ CaptchaMarkers []string
+ EmptySelectors []string
+ EmptyMarkers []string
+}
+
+// ClassifyChallengeDocument is the shared captcha/empty-result check behind
+// every engine's classify*Document. Selectors are checked before markers
+// since they're cheaper (no doc.Text() walk).
+func ClassifyChallengeDocument(doc *goquery.Document, s DocSignals) error {
+ if anySelectorMatches(doc, s.CaptchaSelectors) {
+ return ErrCaptcha
+ }
+ var text string
+ if len(s.CaptchaMarkers)+len(s.EmptyMarkers) > 0 {
+ text = strings.ToLower(doc.Text())
+ }
+ if anyMarkerMatches(text, s.CaptchaMarkers) {
+ return ErrCaptcha
+ }
+ if anySelectorMatches(doc, s.EmptySelectors) {
+ return ErrEmptyResult
+ }
+ if anyMarkerMatches(text, s.EmptyMarkers) {
+ return ErrEmptyResult
+ }
+ return nil
+}
+
+func anySelectorMatches(doc *goquery.Document, selectors []string) bool {
+ for _, selector := range selectors {
+ if doc.Find(selector).Length() > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func anyMarkerMatches(text string, markers []string) bool {
+ for _, marker := range markers {
+ if strings.Contains(text, marker) {
+ return true
+ }
+ }
+ return false
+}
+
// HasAnySelector returns true if at least one of the supplied selectors
// currently matches in the page DOM. It does not wait — pair with
// WaitForElements when hydration may be in flight.
diff --git a/core/server.go b/core/server.go
index 3751dba..7deba6b 100644
--- a/core/server.go
+++ b/core/server.go
@@ -399,6 +399,18 @@ func (s *Server) handleParseEndpoint(c *fiber.Ctx, parser HTMLParser) error {
results, err := parser.ParseHTML(bytes.NewReader(body))
if err != nil {
+ if isParseEndpointSearchError(err) {
+ spec := mapSearchError(err)
+ return &APIError{
+ HTTPStatus: spec.status,
+ ErrorCode: spec.code,
+ Message: spec.message,
+ Meta: map[string]any{
+ "engine": parser.Name(),
+ "error_detail": err.Error(),
+ },
+ }
+ }
return &APIError{
HTTPStatus: fiber.StatusBadRequest,
ErrorCode: "parser_failure",
@@ -418,6 +430,14 @@ func (s *Server) handleParseEndpoint(c *fiber.Ctx, parser HTMLParser) error {
return sendEnvelope(c, format, env)
}
+func isParseEndpointSearchError(err error) bool {
+ return errors.Is(err, ErrCaptcha) ||
+ errors.Is(err, ErrBlocked) ||
+ errors.Is(err, ErrRateLimited) ||
+ errors.Is(err, ErrSearchTimeout) ||
+ errors.Is(err, ErrParser)
+}
+
type searchErrorSpec struct {
status int
code string
diff --git a/core/server_parse_test.go b/core/server_parse_test.go
index ec05034..7f6a4f7 100644
--- a/core/server_parse_test.go
+++ b/core/server_parse_test.go
@@ -87,6 +87,29 @@ func TestParseEndpointParserErrorReturns400(t *testing.T) {
}
}
+func TestParseEndpointSearchErrorReturnsTypedError(t *testing.T) {
+ engine := &parserMock{
+ engineMock: engineMock{name: "google", initialized: true},
+ parseHTMLFn: func(_ io.Reader) ([]SearchResult, error) {
+ return nil, ErrCaptcha
+ },
+ }
+ srv := NewServerWithOptions("127.0.0.1", 7126, DefaultServerOptions(), engine)
+
+ resp := postHTML(t, srv, "/google/parse", "captcha")
+ if resp.StatusCode != http.StatusTooManyRequests {
+ t.Fatalf("expected 429, got %d", resp.StatusCode)
+ }
+
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatalf("decode error body: %v", err)
+ }
+ if body["error"] != "captcha_detected" {
+ t.Fatalf("expected captcha_detected, got %#v", body["error"])
+ }
+}
+
func TestParseEndpointNotRegisteredForNonParserEngine(t *testing.T) {
// engineMock does NOT implement HTMLParser so no /mock/parse route is registered.
engine := &engineMock{name: "mock", initialized: true}
diff --git a/duckduckgo/parse_html.go b/duckduckgo/parse_html.go
index 7daa8fa..9d18d20 100644
--- a/duckduckgo/parse_html.go
+++ b/duckduckgo/parse_html.go
@@ -1,6 +1,7 @@
package duckduckgo
import (
+ "errors"
"io"
"strings"
@@ -15,9 +16,24 @@ func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
if err != nil {
return nil, err
}
+ pageStatus := classifyDDGDocument(doc)
+ if errors.Is(pageStatus, core.ErrEmptyResult) {
+ return []core.SearchResult{}, nil
+ }
+ if pageStatus != nil {
+ return nil, pageStatus
+ }
return parseDDGDocument(doc), nil
}
+func classifyDDGDocument(doc *goquery.Document) error {
+ return core.ClassifyChallengeDocument(doc, core.DocSignals{
+ CaptchaSelectors: Selectors.CaptchaSelectors,
+ CaptchaMarkers: Selectors.CaptchaMarkers,
+ EmptySelectors: Selectors.NoResults,
+ })
+}
+
func parseDDGDocument(doc *goquery.Document) []core.SearchResult {
var results []core.SearchResult
rank := core.NewRankState(0)
diff --git a/duckduckgo/parse_html_test.go b/duckduckgo/parse_html_test.go
index ac2bf98..6e9a4da 100644
--- a/duckduckgo/parse_html_test.go
+++ b/duckduckgo/parse_html_test.go
@@ -2,9 +2,12 @@ package duckduckgo
import (
"bytes"
+ "errors"
"os"
"strings"
"testing"
+
+ "github.com/karust/openserp/core"
)
func TestParseDDGHTML(t *testing.T) {
@@ -56,6 +59,29 @@ func TestParseDDGHTMLEmpty(t *testing.T) {
}
}
+func TestParseDDGHTMLCaptcha(t *testing.T) {
+ t.Parallel()
+
+ html := ``
+ results, err := ParseHTML(bytes.NewReader([]byte(html)))
+ if !errors.Is(err, core.ErrCaptcha) {
+ t.Fatalf("expected ErrCaptcha, got results=%d err=%v", len(results), err)
+ }
+}
+
+func TestParseDDGHTMLNoResults(t *testing.T) {
+ t.Parallel()
+
+ html := `
No results found.
`
+ results, err := ParseHTML(bytes.NewReader([]byte(html)))
+ if err != nil {
+ t.Fatalf("ParseHTML() error = %v", err)
+ }
+ if len(results) != 0 {
+ t.Fatalf("expected zero results, got %d", len(results))
+ }
+}
+
func TestParseDDGHTMLAdsDoNotConsumeOrganicRank(t *testing.T) {
t.Parallel()
diff --git a/ecosia/parse_html.go b/ecosia/parse_html.go
index d83f9c2..e778420 100644
--- a/ecosia/parse_html.go
+++ b/ecosia/parse_html.go
@@ -1,6 +1,7 @@
package ecosia
import (
+ "errors"
"fmt"
"io"
"strings"
@@ -16,9 +17,29 @@ func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
if err != nil {
return nil, err
}
+ pageStatus := classifyEcosiaDocument(doc)
+ if errors.Is(pageStatus, core.ErrEmptyResult) {
+ return []core.SearchResult{}, nil
+ }
+ if pageStatus != nil {
+ return nil, pageStatus
+ }
return parseEcosiaDocument(doc), nil
}
+func classifyEcosiaDocument(doc *goquery.Document) error {
+ if isCaptchaDoc(doc) {
+ return core.ErrCaptcha
+ }
+ if doc.Find(Selectors.NoResults).Length() > 0 ||
+ (doc.Find(Selectors.Mainline).Length() > 0 &&
+ doc.Find(Selectors.Result).Length() == 0 &&
+ doc.Find(Selectors.Ad).Length() == 0) {
+ return core.ErrEmptyResult
+ }
+ return nil
+}
+
func parseEcosiaDocument(doc *goquery.Document) []core.SearchResult {
var results []core.SearchResult
rank := 1
diff --git a/ecosia/search_raw.go b/ecosia/search_raw.go
index 5735383..e28899f 100644
--- a/ecosia/search_raw.go
+++ b/ecosia/search_raw.go
@@ -16,16 +16,11 @@ import (
// challenge rather than a SERP. Prefers the hidden Turnstile input, then falls
// back to the shared challenge-page body phrases (see cfBodyMarkers).
func isCaptchaDoc(doc *goquery.Document) bool {
- if doc.Find(Selectors.Captcha).Length() > 0 {
- return true
- }
- text := strings.ToLower(doc.Text())
- for _, m := range cfBodyMarkers {
- if strings.Contains(text, m) {
- return true
- }
- }
- return false
+ err := core.ClassifyChallengeDocument(doc, core.DocSignals{
+ CaptchaSelectors: []string{Selectors.Captcha},
+ CaptchaMarkers: cfBodyMarkers,
+ })
+ return errors.Is(err, core.ErrCaptcha)
}
func classifyEcosiaRawHTML(body []byte) error {
@@ -33,16 +28,7 @@ func classifyEcosiaRawHTML(body []byte) error {
if err != nil {
return err
}
- if isCaptchaDoc(doc) {
- return core.ErrCaptcha
- }
- if doc.Find("[data-test-id='web-no-results']").Length() > 0 ||
- (doc.Find(Selectors.Mainline).Length() > 0 &&
- doc.Find(Selectors.Result).Length() == 0 &&
- doc.Find(Selectors.Ad).Length() == 0) {
- return core.ErrEmptyResult
- }
- return nil
+ return classifyEcosiaDocument(doc)
}
// imageResultParser parses an Ecosia image SERP HTML response into search
diff --git a/ecosia/search_raw_test.go b/ecosia/search_raw_test.go
index daebb45..3044a91 100644
--- a/ecosia/search_raw_test.go
+++ b/ecosia/search_raw_test.go
@@ -75,3 +75,35 @@ func TestEcosiaClassifyRawHTML(t *testing.T) {
})
}
}
+
+func TestEcosiaParseHTMLClassifiesCaptchaAndNoResults(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ fixture string
+ wantErr error
+ }{
+ {"search_captcha.html", core.ErrCaptcha},
+ {"search_no_results.html", nil},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.fixture, func(t *testing.T) {
+ t.Parallel()
+
+ results, err := ParseHTML(testutil.ResponseFromFixture(t, tt.fixture).Body)
+ if tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("expected %v for %s, got %v", tt.wantErr, tt.fixture, err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ParseHTML() error = %v", err)
+ }
+ if len(results) != 0 {
+ t.Fatalf("expected zero results for %s, got %d", tt.fixture, len(results))
+ }
+ })
+ }
+}
diff --git a/ecosia/selectors.go b/ecosia/selectors.go
index bf0baca..8f52676 100644
--- a/ecosia/selectors.go
+++ b/ecosia/selectors.go
@@ -3,6 +3,7 @@ package ecosia
// Selectors is the single source of truth for Ecosia SERP CSS selectors.
var Selectors = struct {
Captcha string
+ NoResults string
Mainline string
Result string
Ad string
@@ -18,6 +19,7 @@ var Selectors = struct {
// cf-turnstile-response input is present on every challenge page and never
// on a real SERP, so it's a precise marker for the raw (browserless) path.
Captcha: "input[name='cf-turnstile-response']",
+ NoResults: "[data-test-id='web-no-results']",
Mainline: "[data-test-id='mainline']",
Result: "[data-test-id='mainline-result-web']",
Ad: "[data-test-id='mainline-result-ad']",
diff --git a/google/captcha_selector_test.go b/google/captcha_selector_test.go
index a6eea7a..4cdd0a6 100644
--- a/google/captcha_selector_test.go
+++ b/google/captcha_selector_test.go
@@ -18,13 +18,26 @@ func TestGooglePageTypeSelectors(t *testing.T) {
wantHit bool
}{
{"search_captcha.html", Selectors.Captcha, true},
+ {"search_captcha.html", Selectors.CaptchaPage, true},
{"search_captcha.html", Selectors.ResultStats, false},
+ {"search_captcha_new.html", Selectors.Captcha, true},
+ {"search_captcha_new.html", Selectors.CaptchaPage, true},
+ {"search_captcha_new.html", Selectors.ResultStats, false},
+
+ {"search_soft_block.html", Selectors.SoftBlock, true},
+ {"search_soft_block.html", Selectors.Captcha, false},
+ {"search_soft_block.html", Selectors.CaptchaPage, false},
+ {"search_soft_block.html", Selectors.ResultStats, false},
+
{"search_results.html", Selectors.ResultStats, true},
{"search_results.html", Selectors.Captcha, false},
+ {"search_results.html", Selectors.CaptchaPage, false},
{"search_no_results.html", Selectors.ResultStats, true},
{"search_no_results.html", Selectors.Captcha, false},
+ {"search_no_results.html", Selectors.CaptchaPage, false},
+ {"search_no_results.html", Selectors.NoResults, true},
}
for _, tt := range tests {
diff --git a/google/parse_html_test.go b/google/parse_html_test.go
index 560379c..938b180 100644
--- a/google/parse_html_test.go
+++ b/google/parse_html_test.go
@@ -2,11 +2,13 @@ package google
import (
"bytes"
+ "errors"
"os"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
+ "github.com/karust/openserp/core"
)
func TestParseHTML(t *testing.T) {
@@ -126,6 +128,40 @@ func TestParseHTMLNoResults(t *testing.T) {
}
}
+func TestParseHTMLCaptcha(t *testing.T) {
+ t.Parallel()
+
+ for _, fixture := range []string{"search_captcha.html", "search_captcha_new.html"} {
+ t.Run(fixture, func(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile("testdata/" + fixture)
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+
+ results, err := ParseHTML(bytes.NewReader(data))
+ if !errors.Is(err, core.ErrCaptcha) {
+ t.Fatalf("expected ErrCaptcha, got results=%d err=%v", len(results), err)
+ }
+ })
+ }
+}
+
+func TestParseHTMLSoftBlock(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile("testdata/search_soft_block.html")
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+
+ results, err := ParseHTML(bytes.NewReader(data))
+ if !errors.Is(err, core.ErrBlocked) {
+ t.Fatalf("expected ErrBlocked, got results=%d err=%v", len(results), err)
+ }
+}
+
func TestParseHTMLAdsDoNotConsumeOrganicRank(t *testing.T) {
t.Parallel()
diff --git a/google/search.go b/google/search.go
index d5f6193..1f46ddb 100644
--- a/google/search.go
+++ b/google/search.go
@@ -111,7 +111,7 @@ func (gogl *Google) solveCaptcha(page *rod.Page, sitekey, datas, proxyURL string
}
func (gogl *Google) checkCaptcha(page *rod.Page, queryProxyURL string) bool {
- has, _, _ := page.Has(Selectors.Captcha)
+ has, _, _ := page.Has(Selectors.CaptchaPage)
if !has {
return false
}
@@ -122,14 +122,14 @@ func (gogl *Google) checkCaptcha(page *rod.Page, queryProxyURL string) bool {
}
sitekey, err := captchaDiv.Attribute("data-sitekey")
- if err != nil {
- gogl.logger.Error("Cannot get captcha sitekey: %s", err)
+ if err != nil || sitekey == nil {
+ gogl.logger.Error("Cannot get captcha sitekey: %v", err)
return true
}
dataS, err := captchaDiv.Attribute("data-s")
- if err != nil {
- gogl.logger.Error("Cannot get captcha datas: %s", err)
+ if err != nil || dataS == nil {
+ gogl.logger.Error("Cannot get captcha datas: %v", err)
return true
}
@@ -143,6 +143,46 @@ func (gogl *Google) checkCaptcha(page *rod.Page, queryProxyURL string) bool {
return true
}
+func (gogl *Google) checkNoResults(page *rod.Page) bool {
+ if has, _, err := page.Has(Selectors.ResultStats); err == nil && has {
+ statsEl, err := page.Element(Selectors.ResultStats)
+ if err == nil {
+ stats, _ := statsEl.Text()
+ if isZeroResultStats(strings.ToLower(stats)) {
+ return true
+ }
+ }
+ }
+ if has, _, err := page.Has(Selectors.NoResults); err == nil && has {
+ noResultsEl, err := page.Element(Selectors.NoResults)
+ if err == nil {
+ text, _ := noResultsEl.Text()
+ text = strings.ToLower(text)
+ return strings.Contains(text, "did not match any documents") || isZeroResultStats(text)
+ }
+ }
+ return false
+}
+
+func (gogl *Google) checkSoftBlock(page *rod.Page) bool {
+ if has, _, err := page.Has(Selectors.Results); err == nil && has {
+ return false
+ }
+ if has, _, err := page.Has(Selectors.ResultsBroad); err == nil && has {
+ return false
+ }
+
+ htmlTimeout := gogl.GetSelectorTimeout() / 2
+ if htmlTimeout <= 0 || htmlTimeout > time.Second {
+ htmlTimeout = time.Second
+ }
+ html, err := page.Timeout(htmlTimeout).HTML()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(strings.ToLower(html), "/httpservice/retry/enablejs")
+}
+
func (gogl *Google) preparePage(page *rod.Page) {
// Remove "similar queries" lists
_, err := page.Eval(`() => {
@@ -241,6 +281,10 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor
gogl.logger.Error("Captcha detected: %s", url)
return nil, core.ErrCaptcha
}
+ if gogl.checkSoftBlock(page) {
+ gogl.logger.Error("Google soft block detected: %s", url)
+ return nil, core.ErrBlocked
+ }
// Accept cookie consent so Google renders its SERP feature modules
if query.Features {
@@ -256,12 +300,18 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor
gogl.logger.Error("Captcha detected: %s", url)
return nil, core.ErrCaptcha
}
+ if gogl.checkSoftBlock(page) {
+ gogl.logger.Error("Google soft block detected: %s", url)
+ return nil, core.ErrBlocked
+ }
if core.IsContextDone(err) {
return nil, err
}
- // Keep empty-SERP behavior for selector timeout only.
if errors.Is(err, core.ErrSearchTimeout) {
- return nil, nil
+ if gogl.checkNoResults(page) {
+ return nil, nil
+ }
+ return nil, core.ErrSearchTimeout
}
return nil, core.ErrSearchTimeout
}
@@ -463,6 +513,12 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor
if gogl.checkCaptcha(page, query.ProxyURL) {
return nil, core.ErrCaptcha
}
+ if gogl.checkSoftBlock(page) {
+ return nil, core.ErrBlocked
+ }
+ if gogl.checkNoResults(page) {
+ return nil, nil
+ }
// Result candidates were found by Selectors.Results but none parsed
// into usable rows: treat as a genuine no-results SERP rather than a
// timeout, so callers don't retry pointlessly.
diff --git a/google/search_raw.go b/google/search_raw.go
index 7530d9d..29e7a72 100644
--- a/google/search_raw.go
+++ b/google/search_raw.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
+ "regexp"
"strings"
"github.com/PuerkitoBio/goquery"
@@ -13,6 +14,10 @@ import (
"github.com/sirupsen/logrus"
)
+// Matches a literal zero result count, e.g. "about 0 results". Requires "0"
+// as its own word so it doesn't match large counts like "10,500,000 results".
+var zeroResultsPattern = regexp.MustCompile(`\b0 results\b`)
+
// ParseHTML parses a Google SERP HTML document and returns search results.
// It is the pure parser used by both raw HTTP search and parse endpoints.
func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
@@ -20,6 +25,13 @@ func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
if err != nil {
return nil, err
}
+ pageStatus := classifyGoogleDocument(doc)
+ if errors.Is(pageStatus, core.ErrEmptyResult) {
+ return []core.SearchResult{}, nil
+ }
+ if pageStatus != nil {
+ return nil, pageStatus
+ }
return parseGoogleDocument(doc), nil
}
@@ -104,18 +116,58 @@ func classifyGoogleRawHTML(body []byte) error {
if err != nil {
return err
}
- if doc.Find(Selectors.Captcha).Length() > 0 {
+ return classifyGoogleDocument(doc)
+}
+
+func classifyGoogleDocument(doc *goquery.Document) error {
+ if isGoogleCaptchaDocument(doc) {
return core.ErrCaptcha
}
-
- text := strings.ToLower(doc.Text())
- if strings.Contains(text, "did not match any documents") ||
- strings.Contains(text, "about 0 results") {
+ if isGoogleSoftBlockDocument(doc) {
+ return core.ErrBlocked
+ }
+ if isGoogleNoResultsDocument(doc) {
return core.ErrEmptyResult
}
return nil
}
+func isGoogleCaptchaDocument(doc *goquery.Document) bool {
+ err := core.ClassifyChallengeDocument(doc, core.DocSignals{
+ CaptchaSelectors: []string{Selectors.CaptchaPage},
+ CaptchaMarkers: Selectors.CaptchaMarkers,
+ })
+ return errors.Is(err, core.ErrCaptcha)
+}
+
+// isGoogleSoftBlockDocument reports the JS-retry interstitial Google serves
+// instead of a SERP.
+func isGoogleSoftBlockDocument(doc *goquery.Document) bool {
+ if doc.Find(Selectors.Results).Length() > 0 || doc.Find(Selectors.ResultsBroad).Length() > 0 {
+ return false
+ }
+ return strings.Contains(strings.ToLower(doc.Find(Selectors.SoftBlock).Text()), "/httpservice/retry/enablejs")
+}
+
+func isGoogleNoResultsDocument(doc *goquery.Document) bool {
+ stats := strings.ToLower(doc.Find(Selectors.ResultStats).Text())
+ if isZeroResultStats(stats) {
+ return true
+ }
+ text := strings.ToLower(doc.Find(Selectors.NoResults).Text())
+ if text == "" {
+ text = strings.ToLower(doc.Text())
+ }
+ return strings.Contains(text, "did not match any documents") || isZeroResultStats(text)
+}
+
+// isZeroResultStats reports whether s states a literal zero result count,
+// e.g. "about 0 results". A plain substring check on "0 results" also matches
+// large counts like "10,500,000 results", so require "0" as its own word.
+func isZeroResultStats(s string) bool {
+ return zeroResultsPattern.MatchString(s)
+}
+
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
ctx = core.PrepareEngineContext(ctx, query, "google", false)
diff --git a/google/search_raw_test.go b/google/search_raw_test.go
index 436bcad..ef28616 100644
--- a/google/search_raw_test.go
+++ b/google/search_raw_test.go
@@ -18,6 +18,7 @@ func TestGoogleParseHTMLFixtures(t *testing.T) {
minResultCount int
maxResultCount int
wantZero bool
+ wantErr error
}{
{
name: "search results",
@@ -31,9 +32,19 @@ func TestGoogleParseHTMLFixtures(t *testing.T) {
wantZero: true,
},
{
- name: "captcha page",
- fixture: "search_captcha.html",
- wantZero: true,
+ name: "captcha page",
+ fixture: "search_captcha.html",
+ wantErr: core.ErrCaptcha,
+ },
+ {
+ name: "new captcha page",
+ fixture: "search_captcha_new.html",
+ wantErr: core.ErrCaptcha,
+ },
+ {
+ name: "soft block page",
+ fixture: "search_soft_block.html",
+ wantErr: core.ErrBlocked,
},
}
@@ -42,6 +53,12 @@ func TestGoogleParseHTMLFixtures(t *testing.T) {
t.Parallel()
results, err := ParseHTML(testutil.ResponseFromFixture(t, tt.fixture).Body)
+ if tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("expected %v for %s, got %v", tt.wantErr, tt.fixture, err)
+ }
+ return
+ }
if err != nil {
t.Fatalf("ParseHTML() error = %v", err)
}
@@ -88,6 +105,8 @@ func TestGoogleClassifyRawHTML(t *testing.T) {
}{
{name: "no results", fixture: "search_no_results.html", want: core.ErrEmptyResult},
{name: "captcha page", fixture: "search_captcha.html", want: core.ErrCaptcha},
+ {name: "new captcha page", fixture: "search_captcha_new.html", want: core.ErrCaptcha},
+ {name: "soft block page", fixture: "search_soft_block.html", want: core.ErrBlocked},
}
for _, tt := range tests {
diff --git a/google/selectors.go b/google/selectors.go
index 7ed19d5..7bb61b9 100644
--- a/google/selectors.go
+++ b/google/selectors.go
@@ -4,19 +4,23 @@ package google
// Both the browser parser (search.go, rod) and HTML parser (search_raw.go,
// goquery) reference these. When Google changes their DOM, edit here only.
var Selectors = struct {
- Captcha string
- ResultStats string
- CookieBtn string
- Results string
- ResultsBroad string
- Ad string
- Link string
- Title string
- DescPrimary string
- DescFallback string
- DescAny string
- AnswerBox string
- AnswerItem string
+ Captcha string
+ CaptchaPage string
+ CaptchaMarkers []string
+ SoftBlock string
+ NoResults string
+ ResultStats string
+ CookieBtn string
+ Results string
+ ResultsBroad string
+ Ad string
+ Link string
+ Title string
+ DescPrimary string
+ DescFallback string
+ DescAny string
+ AnswerBox string
+ AnswerItem string
// Image search.
ImageResults string
@@ -24,7 +28,17 @@ var Selectors = struct {
ImageLinkFallback string
ImageTitle []string
}{
- Captcha: "div[data-sitekey]",
+ Captcha: "[data-sitekey]",
+ CaptchaPage: "form#captcha-form, [data-sitekey], .g-recaptcha, script[src*='recaptcha']",
+ // CaptchaMarkers is the page-text fallback for captcha variants whose
+ // markup doesn't match CaptchaPage.
+ CaptchaMarkers: []string{
+ "our systems have detected unusual traffic",
+ "not a robot",
+ "solve the captcha",
+ },
+ SoftBlock: "noscript",
+ NoResults: "#botstuff, #topstuff, .mnr-c",
ResultStats: "div#result-stats",
CookieBtn: "div[role='dialog'][aria-modal] button",
// Results targets the canonical organic result block. div.tF2Cxc is the
diff --git a/google/testdata/search_captcha_new.html b/google/testdata/search_captcha_new.html
new file mode 100644
index 0000000..3280d75
--- /dev/null
+++ b/google/testdata/search_captcha_new.html
@@ -0,0 +1 @@
+https://www.google.com/search?ie=UTF-8&oq=weather&pws=0&q=weather&sourceid=chrome
About this page
Our systems have detected unusual traffic from your computer network. This page checks to see if it's really you sending the requests, and not a robot.Why did this happen?
This page appears when Google automatically detects requests coming from your computer network which appear to be in violation of theTerms of Service. The block will expire shortly after those requests stop. In the meantime, solving the above CAPTCHA will let you continue to use our services.
This traffic may have been sent by malicious software, a browser plug-in, or a script that sends automated requests. If you share your network connection, ask your administrator for help — a different computer using the same IP address may be responsible.Learn more
Sometimes you may be asked to solve the CAPTCHA if you are using advanced terms that robots are known to use, or sending requests very quickly.
IP address: 107.219.149.58 Time: 2026-06-30T19:48:20Z URL: https://www.google.com/search?ie=UTF-8&oq=weather&pws=0&q=weather&sourceid=chrome
\ No newline at end of file
diff --git a/google/testdata/search_soft_block.html b/google/testdata/search_soft_block.html
new file mode 100644
index 0000000..29164d7
--- /dev/null
+++ b/google/testdata/search_soft_block.html
@@ -0,0 +1 @@
+weather - Google Search
diff --git a/yandex/parse_html.go b/yandex/parse_html.go
index 921ec08..34e23a8 100644
--- a/yandex/parse_html.go
+++ b/yandex/parse_html.go
@@ -1,6 +1,7 @@
package yandex
import (
+ "errors"
"io"
"net/url"
"strings"
@@ -16,9 +17,23 @@ func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
if err != nil {
return nil, err
}
+ pageStatus := classifyYandexDocument(doc)
+ if errors.Is(pageStatus, core.ErrEmptyResult) {
+ return []core.SearchResult{}, nil
+ }
+ if pageStatus != nil {
+ return nil, pageStatus
+ }
return parseYandexDocument(doc), nil
}
+func classifyYandexDocument(doc *goquery.Document) error {
+ return core.ClassifyChallengeDocument(doc, core.DocSignals{
+ CaptchaSelectors: []string{Selectors.Captcha},
+ EmptySelectors: []string{Selectors.NoResults},
+ })
+}
+
func parseYandexDocument(doc *goquery.Document) []core.SearchResult {
var results []core.SearchResult
rank := core.NewRankState(0)
diff --git a/yandex/search_raw.go b/yandex/search_raw.go
index dd2fbd6..c2a83b0 100644
--- a/yandex/search_raw.go
+++ b/yandex/search_raw.go
@@ -15,13 +15,7 @@ func classifyYandexRawHTML(body []byte) error {
if err != nil {
return err
}
- if doc.Find(Selectors.Captcha).Length() > 0 {
- return core.ErrCaptcha
- }
- if doc.Find(Selectors.NoResults).Length() > 0 {
- return core.ErrEmptyResult
- }
- return nil
+ return classifyYandexDocument(doc)
}
func Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) {
diff --git a/yandex/search_raw_test.go b/yandex/search_raw_test.go
index a504137..9fe8180 100644
--- a/yandex/search_raw_test.go
+++ b/yandex/search_raw_test.go
@@ -17,12 +17,12 @@ func TestYandexParseHTMLFixtures(t *testing.T) {
t.Parallel()
tests := []struct {
- name string
- fixture string
- wantZero bool
+ name string
+ fixture string
+ wantErr error
}{
- {name: "no results", fixture: "search_no_results.html", wantZero: true},
- {name: "captcha page", fixture: "search_captcha.html", wantZero: true},
+ {name: "no results", fixture: "search_no_results.html"},
+ {name: "captcha page", fixture: "search_captcha.html", wantErr: core.ErrCaptcha},
}
for _, tt := range tests {
@@ -30,10 +30,16 @@ func TestYandexParseHTMLFixtures(t *testing.T) {
t.Parallel()
results, err := ParseHTML(testutil.ResponseFromFixture(t, tt.fixture).Body)
+ if tt.wantErr != nil {
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("expected %v for %s, got %v", tt.wantErr, tt.fixture, err)
+ }
+ return
+ }
if err != nil {
t.Fatalf("ParseHTML() error = %v", err)
}
- if tt.wantZero && len(results) != 0 {
+ if len(results) != 0 {
t.Fatalf("expected zero results for %s, got %d", tt.fixture, len(results))
}
})
diff --git a/yandex/url.go b/yandex/url.go
index 2adaaea..2f71616 100644
--- a/yandex/url.go
+++ b/yandex/url.go
@@ -44,7 +44,9 @@ func BuildURL(q core.Query, page int) (string, error) {
if lr := yandexLR(q.Region); lr != "" {
params.Add("lr", lr)
- params.Add("rstr", "true")
+ // rstr (strict region) dropped - it makes Yandex captcha far more
+ // often. lr alone still ranks toward the region, just less precisely.
+ // params.Add("rstr", "true")
}
base.RawQuery = params.Encode()
@@ -83,7 +85,9 @@ func BuildImageURL(q core.Query, page int) (string, error) {
if lr := yandexLR(q.Region); lr != "" {
params.Add("lr", lr)
- params.Add("rstr", "true")
+ // rstr (strict region) dropped - it makes Yandex captcha far more
+ // often. lr alone still ranks toward the region, just less precisely.
+ // params.Add("rstr", "true")
}
base.RawQuery = params.Encode()
diff --git a/yandex/url_test.go b/yandex/url_test.go
index e80d2e4..545b264 100644
--- a/yandex/url_test.go
+++ b/yandex/url_test.go
@@ -73,12 +73,10 @@ func TestBuildURLRegionLR(t *testing.T) {
if gotLR := parsed.Query().Get("lr"); gotLR != tt.wantLR {
t.Fatalf("unexpected lr value: %q want %q", gotLR, tt.wantLR)
}
- wantRstr := ""
- if tt.wantLR != "" {
- wantRstr = "true"
- }
- if gotRstr := parsed.Query().Get("rstr"); gotRstr != wantRstr {
- t.Fatalf("unexpected rstr value: %q for lr %q", gotRstr, tt.wantLR)
+ // rstr (strict region) is intentionally never set - it makes Yandex
+ // captcha far more often. lr alone still ranks toward the region.
+ if gotRstr := parsed.Query().Get("rstr"); gotRstr != "" {
+ t.Fatalf("rstr should never be set, got %q for lr %q", gotRstr, tt.wantLR)
}
})
}
@@ -97,7 +95,7 @@ func TestBuildImageURLRegionLR(t *testing.T) {
if gotLR := parsed.Query().Get("lr"); gotLR != "2" {
t.Fatalf("unexpected lr value: %q", gotLR)
}
- if gotRstr := parsed.Query().Get("rstr"); gotRstr != "true" {
- t.Fatalf("unexpected rstr value: %q", gotRstr)
+ if gotRstr := parsed.Query().Get("rstr"); gotRstr != "" {
+ t.Fatalf("rstr should never be set, got %q", gotRstr)
}
}