Files
metona-ollama-desktop/search-proxy/main.go
T

432 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// ── 限流器 ──
type RateLimiter struct {
tokens atomic.Int64
capacity int64
rate int64 // tokens/sec
lastFill atomic.Int64
}
func newRateLimiter(rate, cap int64) *RateLimiter {
rl := &RateLimiter{rate: rate, capacity: cap}
rl.tokens.Store(cap)
rl.lastFill.Store(time.Now().UnixNano())
return rl
}
func (rl *RateLimiter) Allow() bool {
now := time.Now().UnixNano()
last := rl.lastFill.Load()
elapsed := float64(now-last) / 1e9
newTokens := int64(elapsed * float64(rl.rate))
if newTokens > 0 {
rl.lastFill.Store(now)
current := rl.tokens.Load()
next := current + newTokens
if next > rl.capacity {
next = rl.capacity
}
rl.tokens.Store(next)
}
for {
current := rl.tokens.Load()
if current <= 0 {
return false
}
if rl.tokens.CompareAndSwap(current, current-1) {
return true
}
}
}
// ── 缓存 ──
var cache sync.Map
type cacheEntry struct {
Results []Result `json:"-"`
ExpiresAt int64 `json:"-"`
}
type Result struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Engine string `json:"engine"`
}
type SearchResp struct {
Success bool `json:"success"`
Query string `json:"query"`
Total int `json:"total"`
Results []Result `json:"results"`
Engine string `json:"engine,omitempty"`
Error string `json:"error,omitempty"`
FromCache bool `json:"from_cache,omitempty"`
}
// ── 全局配置 ──
var (
httpClient = &http.Client{
Timeout: 12 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
}
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
acceptLg = "zh-CN,zh;q=0.9,en;q=0.8"
limiter = newRateLimiter(30, 60) // 30 req/s, burst 60
)
// ── recovery middleware ──
func recoverWrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
writeJSON(w, http.StatusInternalServerError, SearchResp{Error: "internal error"})
}
}()
h(w, r)
}
}
// ── 引擎实现 ──
func doGet(u string) (*http.Response, error) {
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept-Language", acceptLg)
return httpClient.Do(req)
}
func searchGoogle(q string, n int) []Result {
u := fmt.Sprintf("https://www.google.com/search?q=%s&num=%d&hl=zh-CN", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
body, _ := io.ReadAll(resp.Body)
html := string(body)
reB := regexp.MustCompile(`<div class="g"[^>]*>([\s\S]*?)(?=<div class="g"|</div>\s*</div>\s*</div>)`)
reT := regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)</h3>`)
reS := regexp.MustCompile(`<(?:div|span)[^>]*(?:data-sncf|class="[^"]*st[^"]*"|class="[^"]*VwiC3b[^"]*")[^>]*>([\s\S]*?)</(?:div|span)>`)
var out []Result
for _, m := range reB.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reT.FindStringSubmatch(m[1])
if tm == nil || strings.Contains(tm[1], "google.com") {
continue
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reS.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "google"})
}
return out
}
func searchBing(q string, n int) []Result {
u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
reB := regexp.MustCompile(`<li class="b_algo"[^>]*>([\s\S]*?)</li>`)
reT := regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)</a>`)
reS := regexp.MustCompile(`<p[^>]*>([\s\S]*?)</p>`)
var out []Result
for _, m := range reB.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reT.FindStringSubmatch(m[1])
if tm == nil {
continue
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reS.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "bing"})
}
return out
}
func searchDDG(q string, n int) []Result {
u := fmt.Sprintf("https://lite.duckduckgo.com/lite/?q=%s", url.QueryEscape(q))
resp, err := doGet(u)
if err != nil {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
reL := regexp.MustCompile(`<a[^>]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)</a>`)
var out []Result
for _, m := range reL.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
link := decodeEntities(m[1])
title := clean(m[2])
if title == "" || strings.Contains(link, "duckduckgo.com") {
continue
}
out = append(out, Result{Title: title, URL: link, Snippet: "", Engine: "ddg"})
}
return out
}
func searchBaidu(q string, n int) []Result {
u := fmt.Sprintf("https://www.baidu.com/s?wd=%s&rn=%d", url.QueryEscape(q), n)
resp, err := doGet(u)
if err != nil {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html := string(body)
reB := regexp.MustCompile(`<div class="result[^"]*"[^>]*>([\s\S]*?)(?=<div class="result|<div id="content_right|$)`)
reT := regexp.MustCompile(`<h3[^>]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)</a>`)
reS := regexp.MustCompile(`<div class="c-abstract"[^>]*>([\s\S]*?)</div>`)
var out []Result
for _, m := range reB.FindAllStringSubmatch(html, -1) {
if len(out) >= n {
break
}
tm := reT.FindStringSubmatch(m[1])
if tm == nil {
continue
}
link := tm[1]
if !strings.HasPrefix(link, "http") {
if dm := regexp.MustCompile(`data-url="(https?://[^"]*)"`).FindStringSubmatch(m[1]); dm != nil {
link = dm[1]
}
}
title := clean(tm[2])
if title == "" {
continue
}
snip := ""
if sm := reS.FindStringSubmatch(m[1]); sm != nil {
snip = clean(sm[1])
}
out = append(out, Result{Title: title, URL: link, Snippet: snip, Engine: "baidu"})
}
return out
}
// ── 工具 ──
func clean(s string) string {
s = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(s, "")
s = decodeEntities(s)
return strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
}
func decodeEntities(s string) string {
r := strings.NewReplacer(
"&nbsp;", " ", "&lt;", "<", "&gt;", ">", "&amp;", "&",
"&quot;", `"`, "&#39;", "'", "&apos;", "'",
"&hellip;", "…", "&mdash;", "—", "&ndash;", "",
)
return r.Replace(s)
}
func parallelSearch(q string, n int) []Result {
type engResult struct {
results []Result
}
ch := make(chan engResult, 4)
engines := []func(string, int) []Result{searchGoogle, searchBing, searchBaidu, searchDDG}
for _, fn := range engines {
go func(f func(string, int) []Result) {
r := f(q, n)
if r == nil {
r = []Result{}
}
ch <- engResult{r}
}(fn)
}
seen := map[string]bool{}
var out []Result
for range engines {
for _, r := range (<-ch).results {
key := strings.TrimRight(r.URL, "/")
if !seen[key] {
seen[key] = true
out = append(out, r)
}
}
}
if len(out) > n {
out = out[:n]
}
return out
}
// ── Handler ──
func handleSearch(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
writeJSON(w, http.StatusTooManyRequests, SearchResp{Error: "rate limited"})
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusBadRequest, SearchResp{Error: "missing q"})
return
}
engine := r.URL.Query().Get("engine")
if engine == "" {
engine = "all"
}
n := 10
fmt.Sscanf(r.URL.Query().Get("n"), "%d", &n)
if n < 1 {
n = 10
}
if n > 20 {
n = 20
}
// 缓存
ck := fmt.Sprintf("%s|%s|%d", q, engine, n)
if v, ok := cache.Load(ck); ok {
ce := v.(cacheEntry)
if time.Now().Unix() < ce.ExpiresAt {
writeJSON(w, http.StatusOK, SearchResp{
Success: true, Query: q, Engine: engine,
Total: len(ce.Results), Results: ce.Results, FromCache: true,
})
return
}
cache.Delete(ck)
}
var results []Result
switch engine {
case "google":
results = searchGoogle(q, n)
case "bing":
results = searchBing(q, n)
case "ddg":
results = searchDDG(q, n)
case "baidu":
results = searchBaidu(q, n)
case "all":
results = parallelSearch(q, n)
default:
writeJSON(w, http.StatusBadRequest, SearchResp{Error: "unknown engine: " + engine + " (use google/bing/ddg/baidu/all)"})
return
}
cache.Store(ck, cacheEntry{Results: results, ExpiresAt: time.Now().Add(5 * time.Minute).Unix()})
writeJSON(w, http.StatusOK, SearchResp{
Success: true, Query: q, Engine: engine,
Total: len(results), Results: results,
})
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"engines": []string{"google", "bing", "ddg", "baidu", "all"},
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "18080"
}
mux := http.NewServeMux()
mux.HandleFunc("/search", recoverWrap(handleSearch))
mux.HandleFunc("/health", recoverWrap(handleHealth))
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Printf("search-proxy :%s engines: google/bing/ddg/baidu/all cache:5min rate:%d/s", port, 30)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
log.Println("stopped")
}