mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
237 lines
6.9 KiB
Go
237 lines
6.9 KiB
Go
package google
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
"github.com/karust/openserp/core"
|
|
"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) {
|
|
doc, err := goquery.NewDocumentFromReader(r)
|
|
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
|
|
}
|
|
|
|
func parseGoogleDocument(doc *goquery.Document) []core.SearchResult {
|
|
results := []core.SearchResult{}
|
|
rank := core.NewRankState(0)
|
|
|
|
// Prefer the canonical organic result block (div.tF2Cxc, innermost). Fall
|
|
// back to the broad attribute selector only when no tF2Cxc blocks exist, so
|
|
// older/alternate SERP layouts still parse.
|
|
sel := doc.Find(Selectors.Results)
|
|
if sel.Length() == 0 {
|
|
sel = doc.Find(Selectors.ResultsBroad)
|
|
}
|
|
|
|
for i := range sel.Nodes {
|
|
item := sel.Eq(i)
|
|
|
|
// Skip items without an h3 element (which indicates a search result)
|
|
if item.Find(Selectors.Title).Length() == 0 {
|
|
continue
|
|
}
|
|
|
|
// Find URL - look for the anchor that contains the h3 title
|
|
linkTag := item.Find(Selectors.Title).Parent()
|
|
if !linkTag.Is("a") {
|
|
linkTag = item.Find(Selectors.Title).Closest("a")
|
|
}
|
|
|
|
link, exists := linkTag.Attr("href")
|
|
if !exists || link == "" || link == "#" {
|
|
continue
|
|
}
|
|
link = strings.Trim(link, " ")
|
|
|
|
// Find title - this is inside the h3 element
|
|
titleTag := item.Find(Selectors.Title)
|
|
title := titleTag.Text()
|
|
|
|
isAd := item.Is(Selectors.Ad) || item.Find(Selectors.Ad).Length() > 0
|
|
|
|
// Find description - find div with text content after the heading
|
|
// Using attribute selectors that match the description container
|
|
descTag := item.Find(Selectors.DescPrimary).First()
|
|
if descTag.Length() == 0 {
|
|
// Try another selector approach if the first one fails
|
|
descTag = item.Find(Selectors.DescFallback)
|
|
if descTag.Length() == 0 {
|
|
// As a last resort, look for any div after the title that might contain description
|
|
titleParent := titleTag.Parent()
|
|
if titleParent.Is("a") {
|
|
titleParent = titleParent.Parent().Parent()
|
|
}
|
|
descTag = titleParent.NextAll().First().Find("div").First()
|
|
}
|
|
}
|
|
desc := descTag.Text()
|
|
|
|
if link != "" && link != "#" {
|
|
resultRank, absoluteRank := rank.Next(isAd)
|
|
result := core.SearchResult{
|
|
Rank: resultRank,
|
|
AbsoluteRank: absoluteRank,
|
|
URL: link,
|
|
Title: title,
|
|
Description: desc,
|
|
Ad: isAd,
|
|
}
|
|
|
|
results = append(results, result)
|
|
}
|
|
}
|
|
|
|
logrus.WithField("document_size", len(doc.Text())).Trace(
|
|
fmt.Sprintf("Google search document size: %d", len(doc.Text())),
|
|
)
|
|
return core.AttachFeaturesToFirstResult(core.DeduplicateResults(results), extractGoogleFeatures(doc))
|
|
}
|
|
|
|
func classifyGoogleRawHTML(body []byte) error {
|
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return classifyGoogleDocument(doc)
|
|
}
|
|
|
|
func isGoogleSorryURL(rawURL string) bool {
|
|
return strings.Contains(strings.ToLower(rawURL), "/sorry/")
|
|
}
|
|
|
|
func classifyGoogleDocument(doc *goquery.Document) error {
|
|
if isGoogleCaptchaDocument(doc) {
|
|
return core.ErrCaptcha
|
|
}
|
|
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")
|
|
|
|
googleURL, err := BuildURL(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
core.WithRequest(ctx).WithField("url", googleURL).Debug(fmt.Sprintf("Google URL built: %s", googleURL))
|
|
|
|
res, err := core.RawSearchRequest(ctx, googleURL, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer core.DrainAndCloseResponse(res)
|
|
core.WithRequest(ctx).WithField("status_code", res.StatusCode).Debug(
|
|
fmt.Sprintf("Google Raw response: code=%d", res.StatusCode),
|
|
)
|
|
if res.Request != nil && res.Request.URL != nil && isGoogleSorryURL(res.Request.URL.String()) {
|
|
return nil, core.ErrCaptcha
|
|
}
|
|
|
|
body, err := core.ReadRawSearchBody(res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
htmlStatus := classifyGoogleRawHTML(body)
|
|
if htmlStatus != nil && !errors.Is(htmlStatus, core.ErrEmptyResult) {
|
|
return nil, htmlStatus
|
|
}
|
|
|
|
parsedResults, err := ParseHTML(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(parsedResults) == 0 {
|
|
if errors.Is(htmlStatus, core.ErrEmptyResult) {
|
|
return []core.SearchResult{}, nil
|
|
}
|
|
return nil, fmt.Errorf("%w: google raw search returned no parseable results", core.ErrParser)
|
|
}
|
|
|
|
if query.Start > 0 {
|
|
organicIdx := 0
|
|
for i := range parsedResults {
|
|
if parsedResults[i].Ad {
|
|
continue
|
|
}
|
|
organicIdx++
|
|
parsedResults[i].Rank = query.Start + organicIdx
|
|
}
|
|
for i := range parsedResults {
|
|
if parsedResults[i].AbsoluteRank > 0 {
|
|
parsedResults[i].AbsoluteRank += query.Start
|
|
}
|
|
}
|
|
}
|
|
core.WithRequest(ctx).WithField("results_count", len(parsedResults)).Debug(
|
|
fmt.Sprintf("Google Raw results : %v", parsedResults),
|
|
)
|
|
|
|
return core.StripResultFeatures(parsedResults, query.Features), nil
|
|
}
|