package services import ( "encoding/json" "fmt" "io" "net/http" neturl "net/url" "regexp" "strings" "time" ) type SearchResult struct { Title string `json:"title"` Link string `json:"link"` DisplayLink string `json:"displayLink"` Snippet string `json:"snippet"` ThumbnailURL string `json:"thumbnailUrl"` Source string `json:"source"` } type SearchService struct { APIKey string ProjectID string Location string DataStoreID string ServingConfig string Client *http.Client } func NewSearchService(apiKey, projectID, location, dataStoreID, servingConfig string) *SearchService { if location == "" { location = "global" } if servingConfig == "" { servingConfig = "default_serving_config" } return &SearchService{ APIKey: apiKey, ProjectID: projectID, Location: location, DataStoreID: dataStoreID, ServingConfig: servingConfig, Client: &http.Client{Timeout: 20 * time.Second}, } } func (s *SearchService) SearchMedia(query string) ([]SearchResult, error) { if s.APIKey == "" || s.ProjectID == "" || s.DataStoreID == "" { return nil, fmt.Errorf("vertex ai search credentials are not configured") } results, err := s.searchLite(query, true) if err != nil { results, err = s.searchLite(query, false) if err != nil { return nil, err } } return results, nil } func (s *SearchService) searchLite(query string, imageSearch bool) ([]SearchResult, error) { filteredQuery := strings.TrimSpace(query + " site:youtube.com OR site:tiktok.com OR site:envato.com OR site:artgrid.io") servingConfig := fmt.Sprintf( "projects/%s/locations/%s/dataStores/%s/servingConfigs/%s", s.ProjectID, s.Location, s.DataStoreID, s.ServingConfig, ) params := map[string]any{ "user_country_code": "us", } if imageSearch { params["searchType"] = 1 } requestBody := map[string]any{ "query": filteredQuery, "pageSize": 25, "safeSearch": false, "languageCode": "ko-KR", "params": params, "contentSearchSpec": map[string]any{ "snippetSpec": map[string]any{ "returnSnippet": true, }, }, } body, _ := json.Marshal(requestBody) endpoint := fmt.Sprintf( "https://discoveryengine.googleapis.com/v1/%s:searchLite?key=%s", servingConfig, neturl.QueryEscape(s.APIKey), ) resp, err := s.Client.Post(endpoint, "application/json", strings.NewReader(string(body))) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("vertex ai search returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) } var payload struct { Results []struct { Document struct { StructData map[string]any `json:"structData"` DerivedStructData map[string]any `json:"derivedStructData"` } `json:"document"` } `json:"results"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } results := make([]SearchResult, 0, len(payload.Results)) for _, item := range payload.Results { link := firstNonEmpty( firstString(item.Document.DerivedStructData, "link", "url", "uri"), firstString(item.Document.StructData, "link", "url", "uri"), ) title := firstNonEmpty( firstString(item.Document.DerivedStructData, "title", "name"), firstString(item.Document.StructData, "title", "name"), ) displayLink := firstNonEmpty( firstString(item.Document.DerivedStructData, "displayLink", "site_name"), firstString(item.Document.StructData, "displayLink", "site_name"), ) snippet := firstNonEmpty( firstString(item.Document.DerivedStructData, "snippets", "snippet", "extractive_answers"), firstString(item.Document.StructData, "snippets", "snippet", "description"), ) thumb := firstNonEmpty( firstString(item.Document.DerivedStructData, "thumbnail", "image", "image_url", "link"), firstString(item.Document.StructData, "thumbnail", "image", "image_url"), ) if thumb == "" { thumb = deriveThumbnail(link) } if title == "" { title = displayLink } if link == "" { continue } results = append(results, SearchResult{ Title: title, Link: link, DisplayLink: displayLink, Snippet: snippet, ThumbnailURL: thumb, Source: inferSource(displayLink + " " + link), }) } return results, nil } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func firstString(values map[string]any, keys ...string) string { for _, key := range keys { value, ok := values[key] if !ok { continue } switch typed := value.(type) { case string: if typed != "" { return typed } case []any: for _, item := range typed { if text, ok := item.(string); ok && text != "" { return text } if mapped, ok := item.(map[string]any); ok { if text := firstString(mapped, "snippet", "htmlSnippet", "url", "link", "value", "content"); text != "" { return text } } } case map[string]any: if text := firstString(typed, "snippet", "htmlSnippet", "url", "link", "value", "content"); text != "" { return text } } } return "" } func deriveThumbnail(link string) string { if link == "" { return "" } if videoID := extractYouTubeID(link); videoID != "" { return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg" } return "" } func extractYouTubeID(link string) string { patterns := []*regexp.Regexp{ regexp.MustCompile(`(?:v=|\/shorts\/|\/embed\/)([A-Za-z0-9_-]{11})`), regexp.MustCompile(`youtu\.be\/([A-Za-z0-9_-]{11})`), } for _, pattern := range patterns { matches := pattern.FindStringSubmatch(link) if len(matches) == 2 { return matches[1] } } return "" } func inferSource(displayLink string) string { switch { case strings.Contains(displayLink, "youtube"): return "YouTube" case strings.Contains(displayLink, "tiktok"): return "TikTok" case strings.Contains(displayLink, "envato"): return "Envato" case strings.Contains(displayLink, "artgrid"): return "Artgrid" default: return displayLink } }