feat: Поиск - with it on the assistant searches DuckDuckGo itself and answers from what it found; without it, it cannot. «Думал N сек.»
This commit is contained in:
1 parent
fa62f2c5b6
commit
9bf30b281e
8 files changed
+921
-7
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Web search for the assistant, when the user turned «Поиск» on: DuckDuckGo's
|
||||
// plain pages, no key and nothing to install. The light page first, the
|
||||
// HTML one if that fails; DuckDuckGo turns away too many requests from one
|
||||
// address, and then the assistant is told so.
|
||||
|
||||
type searchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
var (
|
||||
reLiteLink = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*class='result-link'[^>]*>(.*?)</a>`)
|
||||
reLiteSnippet = regexp.MustCompile(`(?s)<td class='result-snippet'>(.*?)</td>`)
|
||||
reHTMLLink = regexp.MustCompile(`(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
reHTMLSnippet = regexp.MustCompile(`(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
|
||||
reTags = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// text is a bit of a results page as plain text.
|
||||
func text(s string) string {
|
||||
return strings.Join(strings.Fields(html.UnescapeString(reTags.ReplaceAllString(s, ""))), " ")
|
||||
}
|
||||
|
||||
// realURL takes DuckDuckGo's redirect (/l/?uddg=…) back to the address.
|
||||
func realURL(h string) string {
|
||||
h = html.UnescapeString(h)
|
||||
if strings.HasPrefix(h, "//") {
|
||||
h = "https:" + h
|
||||
}
|
||||
if u, err := url.Parse(h); err == nil {
|
||||
if t := u.Query().Get("uddg"); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// parseDDG reads the results of the light or the HTML page.
|
||||
func parseDDG(page string) []searchResult {
|
||||
links, snips := reLiteLink.FindAllStringSubmatch(page, -1), reLiteSnippet.FindAllStringSubmatch(page, -1)
|
||||
if len(links) == 0 {
|
||||
links, snips = reHTMLLink.FindAllStringSubmatch(page, -1), reHTMLSnippet.FindAllStringSubmatch(page, -1)
|
||||
}
|
||||
var out []searchResult
|
||||
for i, l := range links {
|
||||
r := searchResult{Title: text(l[2]), URL: realURL(l[1])}
|
||||
if i < len(snips) {
|
||||
r.Snippet = text(snips[i][1])
|
||||
}
|
||||
// Ads lead back through DuckDuckGo itself: left out.
|
||||
if strings.Contains(r.URL, "duckduckgo.com/y.js") || r.Title == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var searchClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// webSearch asks DuckDuckGo for q: up to n results.
|
||||
func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
|
||||
var last error
|
||||
for _, base := range []string{"https://lite.duckduckgo.com/lite/?q=", "https://html.duckduckgo.com/html/?q="} {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+url.QueryEscape(q), nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||
req.Header.Set("Accept-Language", "ru,en;q=0.8")
|
||||
resp, err := searchClient.Do(req)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
resp.Body.Close()
|
||||
rs := parseDDG(string(b))
|
||||
if resp.StatusCode == http.StatusOK && len(rs) > 0 {
|
||||
if len(rs) > n {
|
||||
rs = rs[:n]
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
last = errors.New("ничего не нашлось")
|
||||
} else {
|
||||
last = errors.New("поисковик не ответил (" + resp.Status + "), возможно, временно ограничил запросы")
|
||||
}
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
// POST /api/panel/ai/search - {q}: the results, for a user with the
|
||||
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
|
||||
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.currentAdmin(r)
|
||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Q string `json:"q"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil || strings.TrimSpace(in.Q) == "" {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
|
||||
return
|
||||
}
|
||||
rs, err := webSearch(r.Context(), strings.TrimSpace(in.Q), 6)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "data": rs})
|
||||
}
|
||||
Reference in new issue
Block a user