2026-05-05 05:07:59 +03:00
|
|
|
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
|
2026-05-13 01:30:54 +03:00
|
|
|
adRank := 1
|
2026-05-19 00:32:11 +03:00
|
|
|
absoluteRank := 1
|
2026-05-05 05:07:59 +03:00
|
|
|
|
2026-05-19 00:32:11 +03:00
|
|
|
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()
|
2026-05-05 05:07:59 +03:00
|
|
|
if titleTag.Length() == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
link, exists := titleTag.Attr("href")
|
|
|
|
|
if !exists || link == "" || link == "#" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 03:51:37 +03:00
|
|
|
title := firstNonEmptyAttr(titleTag, "aria-label", "title")
|
|
|
|
|
if title == "" {
|
|
|
|
|
title = normalizeWhitespace(titleTag.Text())
|
|
|
|
|
}
|
2026-05-05 05:07:59 +03:00
|
|
|
if title == "" {
|
2026-05-19 00:32:11 +03:00
|
|
|
title = extractFirstText(item, Selectors.TitleFallbacks)
|
2026-05-05 05:07:59 +03:00
|
|
|
}
|
2026-05-19 00:32:11 +03:00
|
|
|
if title == "" {
|
2026-05-05 05:07:59 +03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:32:11 +03:00
|
|
|
desc := descriptionFromItem(item, title)
|
2026-05-05 05:07:59 +03:00
|
|
|
|
2026-05-19 00:32:11 +03:00
|
|
|
resultRank := rank
|
|
|
|
|
if isAd {
|
|
|
|
|
resultRank = adRank
|
|
|
|
|
adRank++
|
|
|
|
|
} else {
|
|
|
|
|
rank++
|
2026-05-05 05:07:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results = append(results, core.SearchResult{
|
2026-05-19 00:32:11 +03:00
|
|
|
Rank: resultRank,
|
|
|
|
|
AbsoluteRank: absoluteRank,
|
|
|
|
|
URL: link,
|
|
|
|
|
Title: title,
|
|
|
|
|
Description: desc,
|
|
|
|
|
Ad: isAd,
|
2026-05-05 05:07:59 +03:00
|
|
|
})
|
2026-05-19 00:32:11 +03:00
|
|
|
absoluteRank++
|
2026-05-05 05:07:59 +03:00
|
|
|
})
|
|
|
|
|
|
2026-05-28 01:36:52 +03:00
|
|
|
return core.AttachFeaturesToFirstResult(core.DeduplicateResults(results), extractBingFeatures(doc))
|
2026-05-05 05:07:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:32:11 +03:00
|
|
|
func extractFirstText(item *goquery.Selection, selectors []string) string {
|
|
|
|
|
for _, selector := range selectors {
|
|
|
|
|
if tag := item.Find(selector).First(); tag.Length() > 0 {
|
2026-06-16 03:51:37 +03:00
|
|
|
if text := normalizeWhitespace(tag.Text()); text != "" {
|
2026-05-19 00:32:11 +03:00
|
|
|
return text
|
|
|
|
|
}
|
2026-06-16 03:51:37 +03:00
|
|
|
if label := firstNonEmptyAttr(tag, "aria-label", "title"); label != "" {
|
|
|
|
|
return label
|
2026-05-19 00:32:11 +03:00
|
|
|
}
|
2026-05-13 01:30:54 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-19 00:32:11 +03:00
|
|
|
return ""
|
2026-05-13 01:30:54 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 03:51:37 +03:00
|
|
|
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 ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 05:07:59 +03:00
|
|
|
// descriptionFromItem extracts a description using the same 4-step fallback
|
2026-05-28 01:36:52 +03:00
|
|
|
// chain as the rod-based browser parser. Bing renders snippet text with heavy
|
|
|
|
|
// source-indentation whitespace, so each candidate is whitespace-collapsed.
|
2026-05-05 05:07:59 +03:00
|
|
|
func descriptionFromItem(item *goquery.Selection, title string) string {
|
|
|
|
|
if descTag := item.Find(Selectors.DescPrimary).First(); descTag.Length() > 0 {
|
2026-05-28 01:36:52 +03:00
|
|
|
if text := normalizeWhitespace(descTag.Text()); text != "" {
|
2026-05-05 05:07:59 +03:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if descTag := item.Find(Selectors.DescFallback).First(); descTag.Length() > 0 {
|
2026-05-28 01:36:52 +03:00
|
|
|
if text := normalizeWhitespace(descTag.Text()); text != "" {
|
2026-05-05 05:07:59 +03:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-19 00:32:11 +03:00
|
|
|
if descTag := item.Find(Selectors.DescAny).First(); descTag.Length() > 0 {
|
2026-05-28 01:36:52 +03:00
|
|
|
if text := normalizeWhitespace(descTag.Text()); text != "" {
|
2026-05-05 05:07:59 +03:00
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Structural fallback: strip title from full text
|
2026-05-28 01:36:52 +03:00
|
|
|
return normalizeWhitespace(strings.Replace(item.Text(), title, "", 1))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 03:51:37 +03:00
|
|
|
// normalizeWhitespace collapses Bing's snippet-markup whitespace into single
|
|
|
|
|
// spaces (see core.NormalizeWhitespace).
|
2026-05-28 01:36:52 +03:00
|
|
|
func normalizeWhitespace(s string) string {
|
2026-06-16 03:51:37 +03:00
|
|
|
return core.NormalizeWhitespace(s)
|
2026-05-05 05:07:59 +03:00
|
|
|
}
|