S1: skill recipe now says result score is merged rank. Also send non-zero --min-score to the server (N1), sort include_domains (N2), and note that search_queries counts each sub-query (N3).
257 lines
5.0 KiB
Go
257 lines
5.0 KiB
Go
package search
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
maxDomainList = 20
|
|
maxHostLen = 253
|
|
searxSiteCap = 3
|
|
)
|
|
|
|
var timeRangeLong = map[string]string{
|
|
"day": "day", "d": "day",
|
|
"week": "week", "w": "week",
|
|
"month": "month", "m": "month",
|
|
"year": "year", "y": "year",
|
|
}
|
|
|
|
// NormalizeSearch checks query-vs-queries and filter fields. errMsg is
|
|
// "invalid query" or "invalid search"; hint is empty or a field name.
|
|
func NormalizeSearch(query string, queries []string, f Filter) (qs []string, out Filter, errMsg, hint string) {
|
|
qs, errMsg = normalizeQueries(query, queries)
|
|
if errMsg != "" {
|
|
return nil, Filter{}, errMsg, ""
|
|
}
|
|
out, hint = normalizeFilter(f)
|
|
if hint != "" {
|
|
return nil, Filter{}, "invalid search", hint
|
|
}
|
|
if out.ExactMatch {
|
|
for _, q := range qs {
|
|
if !hasQuotedPhrase(q) {
|
|
return nil, Filter{}, "invalid search", "exact_match"
|
|
}
|
|
}
|
|
}
|
|
return qs, out, "", ""
|
|
}
|
|
|
|
func normalizeQueries(query string, queries []string) ([]string, string) {
|
|
q := strings.TrimSpace(query)
|
|
qs := make([]string, 0, len(queries))
|
|
for _, s := range queries {
|
|
qs = append(qs, strings.TrimSpace(s))
|
|
}
|
|
hasQ := q != ""
|
|
hasQS := len(qs) > 0
|
|
if hasQ && hasQS {
|
|
return nil, "invalid query"
|
|
}
|
|
if !hasQ && !hasQS {
|
|
return nil, "invalid query"
|
|
}
|
|
if hasQ {
|
|
qs = []string{q}
|
|
}
|
|
if len(qs) < 1 || len(qs) > 4 {
|
|
return nil, "invalid query"
|
|
}
|
|
for _, s := range qs {
|
|
if s == "" || utf8.RuneCountInString(s) > MaxQueryRunes {
|
|
return nil, "invalid query"
|
|
}
|
|
}
|
|
return qs, ""
|
|
}
|
|
|
|
func normalizeFilter(f Filter) (Filter, string) {
|
|
inc, hint := normalizeDomains(f.IncludeDomains, "include_domains")
|
|
if hint != "" {
|
|
return Filter{}, hint
|
|
}
|
|
exc, hint := normalizeDomains(f.ExcludeDomains, "exclude_domains")
|
|
if hint != "" {
|
|
return Filter{}, hint
|
|
}
|
|
mode := strings.TrimSpace(f.IncludeDomainsMode)
|
|
switch mode {
|
|
case "", "restrict", "prefer":
|
|
default:
|
|
return Filter{}, "include_domains_mode"
|
|
}
|
|
if mode != "" && len(inc) == 0 {
|
|
return Filter{}, "include_domains_mode"
|
|
}
|
|
if len(inc) > 0 && mode == "" {
|
|
mode = "restrict"
|
|
}
|
|
|
|
tr := strings.TrimSpace(f.TimeRange)
|
|
if tr != "" {
|
|
long, ok := timeRangeLong[tr]
|
|
if !ok {
|
|
return Filter{}, "time_range"
|
|
}
|
|
tr = long
|
|
}
|
|
start := strings.TrimSpace(f.StartDate)
|
|
end := strings.TrimSpace(f.EndDate)
|
|
if start != "" {
|
|
if !validDay(start) {
|
|
return Filter{}, "start_date"
|
|
}
|
|
}
|
|
if end != "" {
|
|
if !validDay(end) {
|
|
return Filter{}, "end_date"
|
|
}
|
|
}
|
|
if (start != "" || end != "") && tr != "" {
|
|
return Filter{}, "time_range"
|
|
}
|
|
if start != "" && end != "" {
|
|
st, _ := time.Parse("2006-01-02", start)
|
|
en, _ := time.Parse("2006-01-02", end)
|
|
if en.Before(st) {
|
|
return Filter{}, "end_date"
|
|
}
|
|
}
|
|
|
|
topic := strings.TrimSpace(f.Topic)
|
|
switch topic {
|
|
case "", "general", "news", "finance":
|
|
default:
|
|
return Filter{}, "topic"
|
|
}
|
|
|
|
ms := f.MinScore
|
|
if ms < 0 || ms > 1 {
|
|
return Filter{}, "min_score"
|
|
}
|
|
|
|
return Filter{
|
|
IncludeDomains: inc,
|
|
ExcludeDomains: exc,
|
|
IncludeDomainsMode: mode,
|
|
TimeRange: tr,
|
|
StartDate: start,
|
|
EndDate: end,
|
|
Topic: topic,
|
|
ExactMatch: f.ExactMatch,
|
|
MinScore: ms,
|
|
}, ""
|
|
}
|
|
|
|
func normalizeDomains(in []string, hint string) ([]string, string) {
|
|
if len(in) == 0 {
|
|
return nil, ""
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
seen := map[string]bool{}
|
|
for _, raw := range in {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
d, ok := normalizeDomain(s)
|
|
if !ok {
|
|
return nil, hint
|
|
}
|
|
if seen[d] {
|
|
continue
|
|
}
|
|
seen[d] = true
|
|
out = append(out, d)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, ""
|
|
}
|
|
sort.Strings(out)
|
|
if len(out) > maxDomainList {
|
|
return nil, hint
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
func normalizeDomain(s string) (string, bool) {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = strings.TrimSuffix(s, ".")
|
|
if s == "" || len(s) > maxHostLen {
|
|
return "", false
|
|
}
|
|
if strings.ContainsAny(s, " :/@*?#[]") {
|
|
return "", false
|
|
}
|
|
labels := strings.Split(s, ".")
|
|
if len(labels) == 0 {
|
|
return "", false
|
|
}
|
|
for _, lab := range labels {
|
|
if !validLabel(lab) {
|
|
return "", false
|
|
}
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
func validLabel(lab string) bool {
|
|
if lab == "" || len(lab) > 63 {
|
|
return false
|
|
}
|
|
if lab[0] == '-' || lab[len(lab)-1] == '-' {
|
|
return false
|
|
}
|
|
for i := 0; i < len(lab); i++ {
|
|
c := lab[i]
|
|
if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validDay(s string) bool {
|
|
t, err := time.Parse("2006-01-02", s)
|
|
return err == nil && t.Format("2006-01-02") == s
|
|
}
|
|
|
|
// hasQuotedPhrase reports a balanced ASCII "…" pair with ≥1 rune inside and no unpaired quotes.
|
|
func hasQuotedPhrase(q string) bool {
|
|
in := false
|
|
n := 0
|
|
pairs := 0
|
|
for _, r := range q {
|
|
if r != '"' {
|
|
if in {
|
|
n++
|
|
}
|
|
continue
|
|
}
|
|
if !in {
|
|
in = true
|
|
n = 0
|
|
continue
|
|
}
|
|
if n == 0 {
|
|
return false
|
|
}
|
|
in = false
|
|
pairs++
|
|
}
|
|
return !in && pairs >= 1
|
|
}
|
|
|
|
func QueryRuneLen(qs []string) int {
|
|
n := 0
|
|
for _, q := range qs {
|
|
n += utf8.RuneCountInString(q)
|
|
}
|
|
return n
|
|
}
|