first commit
This commit is contained in:
171
services/news_service.go
Normal file
171
services/news_service.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user