package services import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "regexp" "stocksearch/config" "stocksearch/models" "strings" "sync" "time" "unicode" ) var ( newsSvcOnce sync.Once newsSvc *NewsService ) // NewsService 네이버 뉴스 검색 서비스 type NewsService struct { httpClient *http.Client cache *CacheService clientID string clientSecret string } // GetNewsService 싱글턴 반환 func GetNewsService() *NewsService { newsSvcOnce.Do(func() { newsSvc = &NewsService{ httpClient: &http.Client{Timeout: 5 * time.Second}, cache: GetCacheService(), clientID: config.App.NaverClientID, clientSecret: config.App.NaverClientSecret, } }) return newsSvc } // GetNews 종목명 기반 최근 뉴스 반환 (3분 캐시, 제목 중복 제거) func (s *NewsService) GetNews(stockName string) ([]models.NewsItem, error) { cacheKey := "news:" + stockName if cached, ok := s.cache.Get(cacheKey); ok { return cached.([]models.NewsItem), nil } items, err := s.fetchNaver(stockName) if err != nil { return nil, err } items = dedupByTitle(items) s.cache.Set(cacheKey, items, 3*time.Minute) log.Printf("네이버 뉴스 조회: 종목=%s, 건수=%d", stockName, len(items)) return items, nil } // fetchNaver 네이버 뉴스 검색 API 호출 (최신순 10건) func (s *NewsService) fetchNaver(query string) ([]models.NewsItem, error) { if s.clientID == "" { return nil, fmt.Errorf("NAVER_CLIENT_ID가 설정되지 않았습니다") } endpoint := "https://openapi.naver.com/v1/search/news.json" params := url.Values{} params.Set("query", query) params.Set("display", "20") // 중복 제거 후 10건 확보를 위해 여유 있게 조회 params.Set("sort", "date") req, err := http.NewRequest("GET", endpoint+"?"+params.Encode(), nil) if err != nil { return nil, fmt.Errorf("네이버 뉴스 요청 생성 실패: %w", err) } req.Header.Set("X-Naver-Client-Id", s.clientID) req.Header.Set("X-Naver-Client-Secret", s.clientSecret) resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("네이버 뉴스 API 요청 실패: %w", err) } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) var result struct { Items []struct { Title string `json:"title"` Link string `json:"link"` PubDate string `json:"pubDate"` OriginalLink string `json:"originallink"` } `json:"items"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("네이버 뉴스 API 파싱 실패: %w", err) } items := make([]models.NewsItem, 0, len(result.Items)) for _, it := range result.Items { link := it.OriginalLink if link == "" { link = it.Link } items = append(items, models.NewsItem{ Title: stripHTML(it.Title), URL: link, PublishedAt: it.PubDate, Source: extractDomain(link), }) } return items, nil } // htmlTagRe HTML 태그 제거용 정규식 var htmlTagRe = regexp.MustCompile(`<[^>]+>`) // stripHTML HTML 태그 및 엔티티 제거 func stripHTML(s string) string { s = htmlTagRe.ReplaceAllString(s, "") s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, """, "\"") s = strings.ReplaceAll(s, "'", "'") return strings.TrimSpace(s) } // extractDomain URL에서 도메인명만 추출 func extractDomain(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "" } host := u.Hostname() // www. 제거 host = strings.TrimPrefix(host, "www.") return host } // normalizeTitle 제목을 소문자·공백 제거하여 중복 비교용 키 생성 func normalizeTitle(title string) string { var b strings.Builder for _, r := range strings.ToLower(title) { if !unicode.IsSpace(r) && !unicode.IsPunct(r) { b.WriteRune(r) } } return b.String() } // dedupByTitle 정규화된 제목 기준으로 중복 기사 제거 func dedupByTitle(items []models.NewsItem) []models.NewsItem { seen := make(map[string]struct{}, len(items)) out := make([]models.NewsItem, 0, len(items)) for _, item := range items { key := normalizeTitle(item.Title) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} out = append(out, item) if len(out) == 10 { break } } return out }