diff --git a/search-proxy/README.md b/search-proxy/README.md new file mode 100644 index 0000000..7d8e084 --- /dev/null +++ b/search-proxy/README.md @@ -0,0 +1,45 @@ +# search-proxy + +香港服务器上的搜索引擎代理。本应用通过 HTTP API 调用,直连 Google/Bing/DDG。 + +## 部署 + +```bash +# 1. 拷贝到服务器 +scp search-proxy user@your-server:/opt/search-proxy/ + +# 2. 编译(如果有 Go 环境) +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" + +# 4. 安装 systemd 服务(可选,开机自启) +sudo cp search-proxy.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now search-proxy +``` + +## API + +``` +GET /search?q=关键词&engine=google&n=10 + +engine: google | bing | ddg | all +n: 1-20,默认10 +缓存: 5分钟 + +GET /health +→ {"status":"ok"} +``` + +## 本应用配置 + +在设置面板的代理输入框中填入:`http://你的服务器IP:8080` + +或者通过代理设置:在 search-proxy 前挂 nginx 反代加 HTTPS。 diff --git a/search-proxy/main.go b/search-proxy/main.go new file mode 100644 index 0000000..a506b71 --- /dev/null +++ b/search-proxy/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" +) + +var cache = sync.Map{} + +type cacheEntry struct { + Results []SearchResult `json:"-"` + ExpiresAt int64 `json:"-"` +} + +type SearchResult 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"` +} + +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" + +// ── 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) + 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) + 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]*?)
`) + + var results []SearchResult + for _, m := range reBlock.FindAllStringSubmatch(html, -1) { + if len(results) >= n { + break + } + tm := reTitle.FindStringSubmatch(m[1]) + if tm == nil { + continue + } + title := clean(tm[2]) + if title == "" { + continue + } + sn := "" + if sm := reSn.FindStringSubmatch(m[1]); sm != nil { + sn = clean(sm[1]) + } + results = append(results, SearchResult{Title: title, URL: tm[1], Snippet: sn, Engine: "bing"}) + } + return results +} + +// ── DuckDuckGo Lite ── +func searchDDG(q string, n int) []SearchResult { + 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) + if err != nil { + return nil + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + html := string(body) + + reLink := regexp.MustCompile(`]*href="(https?://[^"]*)"[^>]*>([\s\S]*?)`) + + var results []SearchResult + for _, m := range reLink.FindAllStringSubmatch(html, -1) { + if len(results) >= n { + break + } + link := decodeEntities(m[1]) + title := clean(m[2]) + if title == "" || strings.Contains(link, "duckduckgo.com") { + continue + } + results = append(results, SearchResult{Title: title, URL: link, Snippet: "", Engine: "ddg"}) + } + return results +} + +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 { + repl := strings.NewReplacer( + " ", " ", "<", "<", ">", ">", "&", "&", + """, `"`, "'", "'", "'", "'", + "…", "…", "—", "—", "–", "–", + ) + return repl.Replace(s) +} + +// ── Handler ── +func handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeJSON(w, http.StatusBadRequest, SearchResponse{Success: false, Error: "missing q"}) + return + } + + engine := r.URL.Query().Get("engine") + if engine == "" { + engine = "google" + } + + n := 10 + fmt.Sscanf(r.URL.Query().Get("n"), "%d", &n) + 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}) + return + } + cache.Delete(ck) + } + + var results []SearchResult + switch engine { + case "google": + results = searchGoogle(q, n) + case "bing": + results = searchBing(q, n) + case "ddg": + results = searchDDG(q, n) + case "all": + results = parallelSearch(q, n) + default: + writeJSON(w, http.StatusBadRequest, SearchResponse{Success: false, Error: "unknown engine: " + engine}) + 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}) +} + +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 writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + 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"}) + }) + + log.Printf("search-proxy :%s (google/bing/ddg/all)", port) + log.Fatal(http.ListenAndServe(":"+port, mux)) +} diff --git a/search-proxy/search-proxy.service b/search-proxy/search-proxy.service new file mode 100644 index 0000000..32df306 --- /dev/null +++ b/search-proxy/search-proxy.service @@ -0,0 +1,13 @@ +[Unit] +Description=Search Proxy - Google/Bing/DDG API +After=network.target + +[Service] +Type=simple +ExecStart=/opt/search-proxy/search-proxy +Restart=always +RestartSec=5 +Environment=PORT=8080 + +[Install] +WantedBy=multi-user.target