diff --git a/search-proxy/README.md b/search-proxy/README.md index 7d8e084..0e3b323 100644 --- a/search-proxy/README.md +++ b/search-proxy/README.md @@ -1,25 +1,27 @@ # search-proxy -香港服务器上的搜索引擎代理。本应用通过 HTTP API 调用,直连 Google/Bing/DDG。 +部署在可直连 Google/Bing/DuckDuckGo 的云服务器上,通过 HTTP API 提供搜索引擎代理。 ## 部署 ```bash -# 1. 拷贝到服务器 +# 1. 上传到服务器 scp search-proxy user@your-server:/opt/search-proxy/ -# 2. 编译(如果有 Go 环境) +# 2. 编译 cd /opt/search-proxy && go build -o search-proxy . -# 或者本地交叉编译后上传: +# 本地交叉编译: # GOOS=linux GOARCH=amd64 go build -o search-proxy . -# 3. 测试 -./search-proxy & -curl "http://localhost:8080/health" -curl "http://localhost:8080/search?q=hello&engine=google&n=5" +# 3. 启动(默认端口 18080) +PORT=18080 ./search-proxy & -# 4. 安装 systemd 服务(可选,开机自启) +# 4. 测试 +curl "http://localhost:18080/health" +curl "http://localhost:18080/search?q=hello&n=5" + +# 5. 安装 systemd 服务 sudo cp search-proxy.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now search-proxy @@ -30,16 +32,15 @@ sudo systemctl enable --now search-proxy ``` GET /search?q=关键词&engine=google&n=10 -engine: google | bing | ddg | all +engine: google | bing | ddg | baidu | all (默认) n: 1-20,默认10 缓存: 5分钟 +限流: 30 req/s GET /health -→ {"status":"ok"} +→ {"status":"ok","engines":["google","bing","ddg","baidu","all"],"time":"..."} ``` ## 本应用配置 -在设置面板的代理输入框中填入:`http://你的服务器IP:8080` - -或者通过代理设置:在 search-proxy 前挂 nginx 反代加 HTTPS。 +设置面板 → 代理设置 → 填入 `http://服务器IP:18080` → 重启 diff --git a/search-proxy/main.go b/search-proxy/main.go index a506b71..26ee11c 100644 --- a/search-proxy/main.go +++ b/search-proxy/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "io" @@ -8,112 +9,176 @@ import ( "net/http" "net/url" "os" + "os/signal" "regexp" "strings" "sync" + "sync/atomic" + "syscall" "time" ) -var cache = sync.Map{} - -type cacheEntry struct { - Results []SearchResult `json:"-"` - ExpiresAt int64 `json:"-"` +// ── 限流器 ── +type RateLimiter struct { + tokens atomic.Int64 + capacity int64 + rate int64 // tokens/sec + lastFill atomic.Int64 } -type SearchResult struct { +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 SearchResponse struct { - Success bool `json:"success"` - Query string `json:"query"` - Total int `json:"total"` - Results []SearchResult `json:"results"` - Error string `json:"error,omitempty"` +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: 15 * time.Second} -var ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +// ── 全局配置 ── +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 +) -// ── Google ── -func searchGoogle(q string, n int) []SearchResult { - u := fmt.Sprintf("https://www.google.com/search?q=%s&num=%d&hl=zh-CN", url.QueryEscape(q), n) +// ── 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", "zh-CN,zh;q=0.9") + req.Header.Set("Accept-Language", acceptLg) + return httpClient.Do(req) +} - resp, err := 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 { - log.Printf("Google: %v", err) return nil } defer resp.Body.Close() - if resp.StatusCode != 200 { - log.Printf("Google HTTP %d", resp.StatusCode) return nil } - body, _ := io.ReadAll(resp.Body) html := string(body) - reBlock := regexp.MustCompile(`
]*>([\s\S]*?)(?=
\s*
\s*
)`) - reTitle := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>[\s\S]*?]*>([\s\S]*?)`) - reSn := regexp.MustCompile(`<(?:div|span)[^>]*(?:data-sncf|class="[^"]*st[^"]*"|class="[^"]*VwiC3b[^"]*")[^>]*>([\s\S]*?)`) + reB := regexp.MustCompile(`
]*>([\s\S]*?)(?=
\s*
\s*
)`) + reT := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>[\s\S]*?]*>([\s\S]*?)`) + reS := regexp.MustCompile(`<(?:div|span)[^>]*(?:data-sncf|class="[^"]*st[^"]*"|class="[^"]*VwiC3b[^"]*")[^>]*>([\s\S]*?)`) - var results []SearchResult - for _, m := range reBlock.FindAllStringSubmatch(html, -1) { - if len(results) >= n { + var out []Result + for _, m := range reB.FindAllStringSubmatch(html, -1) { + if len(out) >= n { break } - tm := reTitle.FindStringSubmatch(m[1]) - if tm == nil { + tm := reT.FindStringSubmatch(m[1]) + if tm == nil || strings.Contains(tm[1], "google.com") { continue } title := clean(tm[2]) - link := tm[1] - if title == "" || strings.Contains(link, "google.com") { + if title == "" { continue } - sn := "" - if sm := reSn.FindStringSubmatch(m[1]); sm != nil { - sn = clean(sm[1]) + snip := "" + if sm := reS.FindStringSubmatch(m[1]); sm != nil { + snip = clean(sm[1]) } - results = append(results, SearchResult{Title: title, URL: link, Snippet: sn, Engine: "google"}) + out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "google"}) } - return results + return out } -// ── Bing ── -func searchBing(q string, n int) []SearchResult { +func searchBing(q string, n int) []Result { u := fmt.Sprintf("https://www.bing.com/search?q=%s&count=%d", url.QueryEscape(q), n) - req, _ := http.NewRequest("GET", u, nil) - req.Header.Set("User-Agent", ua) - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") - - resp, err := httpClient.Do(req) + resp, err := doGet(u) if err != nil { return nil } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) html := string(body) - reBlock := regexp.MustCompile(`
  • ]*>([\s\S]*?)
  • `) - reTitle := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)`) - reSn := regexp.MustCompile(`]*>([\s\S]*?)

    `) + reB := regexp.MustCompile(`
  • ]*>([\s\S]*?)
  • `) + reT := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)`) + reS := regexp.MustCompile(`]*>([\s\S]*?)

    `) - var results []SearchResult - for _, m := range reBlock.FindAllStringSubmatch(html, -1) { - if len(results) >= n { + var out []Result + for _, m := range reB.FindAllStringSubmatch(html, -1) { + if len(out) >= n { break } - tm := reTitle.FindStringSubmatch(m[1]) + tm := reT.FindStringSubmatch(m[1]) if tm == nil { continue } @@ -121,35 +186,30 @@ func searchBing(q string, n int) []SearchResult { if title == "" { continue } - sn := "" - if sm := reSn.FindStringSubmatch(m[1]); sm != nil { - sn = clean(sm[1]) + snip := "" + if sm := reS.FindStringSubmatch(m[1]); sm != nil { + snip = clean(sm[1]) } - results = append(results, SearchResult{Title: title, URL: tm[1], Snippet: sn, Engine: "bing"}) + out = append(out, Result{Title: title, URL: tm[1], Snippet: snip, Engine: "bing"}) } - return results + return out } -// ── DuckDuckGo Lite ── -func searchDDG(q string, n int) []SearchResult { +func searchDDG(q string, n int) []Result { u := fmt.Sprintf("https://lite.duckduckgo.com/lite/?q=%s", url.QueryEscape(q)) - req, _ := http.NewRequest("GET", u, nil) - req.Header.Set("User-Agent", ua) - - resp, err := httpClient.Do(req) + resp, err := doGet(u) if err != nil { return nil } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) html := string(body) - reLink := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)`) + reL := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)`) - var results []SearchResult - for _, m := range reLink.FindAllStringSubmatch(html, -1) { - if len(results) >= n { + var out []Result + for _, m := range reL.FindAllStringSubmatch(html, -1) { + if len(out) >= n { break } link := decodeEntities(m[1]) @@ -157,11 +217,55 @@ func searchDDG(q string, n int) []SearchResult { if title == "" || strings.Contains(link, "duckduckgo.com") { continue } - results = append(results, SearchResult{Title: title, URL: link, Snippet: "", Engine: "ddg"}) + out = append(out, Result{Title: title, URL: link, Snippet: "", Engine: "ddg"}) } - return results + 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(`
    ]*>([\s\S]*?)(?=
    ]*class="[^"]*t[^"]*"[^>]*>[\s\S]*?]*href="([^"]*)"[^>]*>([\s\S]*?)`) + reS := regexp.MustCompile(`
    ]*>([\s\S]*?)
    `) + + 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) @@ -169,44 +273,89 @@ func clean(s string) string { } func decodeEntities(s string) string { - repl := strings.NewReplacer( + r := strings.NewReplacer( " ", " ", "<", "<", ">", ">", "&", "&", """, `"`, "'", "'", "'", "'", "…", "…", "—", "—", "–", "–", ) - return repl.Replace(s) + 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, SearchResponse{Success: false, Error: "missing q"}) + writeJSON(w, http.StatusBadRequest, SearchResp{Error: "missing q"}) return } engine := r.URL.Query().Get("engine") if engine == "" { - engine = "google" + 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, SearchResponse{Success: true, Query: q, Total: len(ce.Results), Results: ce.Results}) + 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 []SearchResult + var results []Result switch engine { case "google": results = searchGoogle(q, n) @@ -214,41 +363,32 @@ func handleSearch(w http.ResponseWriter, r *http.Request) { 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, SearchResponse{Success: false, Error: "unknown engine: " + engine}) + 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, SearchResponse{Success: true, Query: q, Total: len(results), Results: results}) + writeJSON(w, http.StatusOK, SearchResp{ + Success: true, Query: q, Engine: engine, + Total: len(results), Results: results, + }) } -func parallelSearch(q string, n int) []SearchResult { - ch := make(chan []SearchResult, 3) - go func() { ch <- searchGoogle(q, n) }() - go func() { ch <- searchBing(q, n) }() - go func() { ch <- searchDDG(q, n) }() - seen := map[string]bool{} - var results []SearchResult - for i := 0; i < 3; i++ { - for _, r := range <-ch { - key := strings.TrimRight(r.URL, "/") - if !seen[key] { - seen[key] = true - results = append(results, r) - } - } - } - if len(results) > n { - results = results[:n] - } - return 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") + 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) @@ -257,15 +397,35 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) { func main() { port := os.Getenv("PORT") if port == "" { - port = "8080" + port = "18080" } mux := http.NewServeMux() - mux.HandleFunc("/search", handleSearch) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - }) + mux.HandleFunc("/search", recoverWrap(handleSearch)) + mux.HandleFunc("/health", recoverWrap(handleHealth)) - log.Printf("search-proxy :%s (google/bing/ddg/all)", port) - log.Fatal(http.ListenAndServe(":"+port, mux)) + 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") } diff --git a/search-proxy/search-proxy.service b/search-proxy/search-proxy.service index 32df306..2f3e953 100644 --- a/search-proxy/search-proxy.service +++ b/search-proxy/search-proxy.service @@ -1,5 +1,5 @@ [Unit] -Description=Search Proxy - Google/Bing/DDG API +Description=Search Proxy - Google/Bing/DDG/Baidu API After=network.target [Service] @@ -7,7 +7,7 @@ Type=simple ExecStart=/opt/search-proxy/search-proxy Restart=always RestartSec=5 -Environment=PORT=8080 +Environment=PORT=18080 [Install] WantedBy=multi-user.target