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

140 lines
3.5 KiB
Go

package bing
import (
"io"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/karust/openserp/core"
)
// ParseHTML parses a Bing SERP HTML document and returns search results.
// Mirrors the rod-based parser in search.go but operates on a goquery doc.
// No network I/O.
func ParseHTML(r io.Reader) ([]core.SearchResult, error) {
doc, err := goquery.NewDocumentFromReader(r)
if err != nil {
return nil, err
}
return parseBingDocument(doc), nil
}
func parseBingDocument(doc *goquery.Document) []core.SearchResult {
var results []core.SearchResult
rank := 1
adRank := 1
absoluteRank := 1
doc.Find(Selectors.ResultItems).Each(func(_ int, item *goquery.Selection) {
isAd := item.Is(Selectors.Ads)
isOrganic := item.Is(Selectors.Results)
if !isAd && !isOrganic {
return
}
titleSelector := Selectors.Title
if isAd {
titleSelector = Selectors.AdTitle
}
titleTag := item.Find(titleSelector).First()
if titleTag.Length() == 0 {
return
}
link, exists := titleTag.Attr("href")
if !exists || link == "" || link == "#" {
return
}
title := firstNonEmptyAttr(titleTag, "aria-label", "title")
if title == "" {
title = normalizeWhitespace(titleTag.Text())
}
if title == "" {
title = extractFirstText(item, Selectors.TitleFallbacks)
}
if title == "" {
return
}
desc := descriptionFromItem(item, title)
resultRank := rank
if isAd {
resultRank = adRank
adRank++
} else {
rank++
}
results = append(results, core.SearchResult{
Rank: resultRank,
AbsoluteRank: absoluteRank,
URL: link,
Title: title,
Description: desc,
Ad: isAd,
})
absoluteRank++
})
return core.AttachFeaturesToFirstResult(core.DeduplicateResults(results), extractBingFeatures(doc))
}
func extractFirstText(item *goquery.Selection, selectors []string) string {
for _, selector := range selectors {
if tag := item.Find(selector).First(); tag.Length() > 0 {
if text := normalizeWhitespace(tag.Text()); text != "" {
return text
}
if label := firstNonEmptyAttr(tag, "aria-label", "title"); label != "" {
return label
}
}
}
return ""
}
func firstNonEmptyAttr(item *goquery.Selection, attrs ...string) string {
for _, attr := range attrs {
value, exists := item.Attr(attr)
if !exists {
continue
}
if value = normalizeWhitespace(value); value != "" {
return value
}
}
return ""
}
// descriptionFromItem extracts a description using the same 4-step fallback
// chain as the rod-based browser parser. Bing renders snippet text with heavy
// source-indentation whitespace, so each candidate is whitespace-collapsed.
func descriptionFromItem(item *goquery.Selection, title string) string {
if descTag := item.Find(Selectors.DescPrimary).First(); descTag.Length() > 0 {
if text := normalizeWhitespace(descTag.Text()); text != "" {
return text
}
}
if descTag := item.Find(Selectors.DescFallback).First(); descTag.Length() > 0 {
if text := normalizeWhitespace(descTag.Text()); text != "" {
return text
}
}
if descTag := item.Find(Selectors.DescAny).First(); descTag.Length() > 0 {
if text := normalizeWhitespace(descTag.Text()); text != "" {
return text
}
}
// Structural fallback: strip title from full text
return normalizeWhitespace(strings.Replace(item.Text(), title, "", 1))
}
// normalizeWhitespace collapses Bing's snippet-markup whitespace into single
// spaces (see core.NormalizeWhitespace).
func normalizeWhitespace(s string) string {
return core.NormalizeWhitespace(s)
}