diff --git a/build.gradle.kts b/build.gradle.kts index 57642f05..b0fea44e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -69,6 +69,9 @@ dependencies { // XML 파싱 implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + + // Spotify + implementation("se.michaelthelin.spotify:spotify-web-api-java:8.4.1") } tasks.withType { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java index 3c6577c7..a753332a 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/controller/ArtistsController.java @@ -1,13 +1,34 @@ package com.back.web7_9_codecrete_be.domain.artists.controller; -import com.back.web7_9_codecrete_be.domain.artists.service.ArtistsService; +import com.back.web7_9_codecrete_be.domain.artists.service.ArtistService; +import com.back.web7_9_codecrete_be.domain.artists.service.ArtistEnrichService; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/artists") @RequiredArgsConstructor +@Tag(name = "Artists", description = "공연에 대한 정보를 제공하는 API 입니다.") public class ArtistsController { - private final ArtistsService artistsService; + private final ArtistService artistService; + private final ArtistEnrichService enrichService; + + @Operation(summary = "아티스트 저장", description = "임의의 가수 300명(or 팀)을 DB에 저장합니다.") + @GetMapping("/saved") + public RsData saveArtist() { + int saved = artistService.setArtist(); + return RsData.success("아티스트 저장에 성공하였습니다.", saved); + } + + @Operation(summary = "아티스트 정보 보완", description = "아티스트 한국어 이름, 그룹 여부, 소속 그룹 정보 보완") + @PostMapping("/enrich") + public RsData enrich( + @RequestParam(required = false, defaultValue = "100") int limit + ) { + int updated = enrichService.enrichArtist(limit); + return RsData.success("enrich 성공", updated); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/dummy.txt b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/dummy.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java new file mode 100644 index 00000000..ee3035c3 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/CreateRequest.java @@ -0,0 +1,12 @@ +package com.back.web7_9_codecrete_be.domain.artists.dto.request; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; + + +public record CreateRequest( + String artistName, + String artistGroup, + String artistType, + Genre genre +) { +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/UpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/UpdateRequest.java new file mode 100644 index 00000000..eae63073 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/request/UpdateRequest.java @@ -0,0 +1,12 @@ +package com.back.web7_9_codecrete_be.domain.artists.dto.request; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; + + +public record UpdateRequest( + String artistName, + String artistGroup, + String artistType, + Genre genre +) { +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java index 10aa5ed2..0a5c2c41 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Artist.java @@ -1,27 +1,54 @@ package com.back.web7_9_codecrete_be.domain.artists.entity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + @Entity @Getter -@Table(name = "arist") +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "artist") public class Artist { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "artist_id") private long id; - @Column(name = "artist_name", nullable = false, length = 30) + @Column(name = "artist_name", nullable = false) private String artistName; - @Column(name = "artist_group", length = 30) + @Column(name = "artist_group") private String artistGroup; - @Column(name = "artist_type", nullable = false, length = 20) + @Column(name = "artist_type") private String artistType; @ManyToOne(fetch = FetchType.LAZY) private Genre genre; + @Column(name = "spotify_artist_id", unique = true) + private String spotifyArtistId; + + @Column(name = "name_ko", length = 200) + private String nameKo; + + public Artist(String spotifyArtistId, String artistName, String artistGroup, String artistType, Genre genre) { + this.spotifyArtistId = spotifyArtistId; + this.artistName = artistName; + this.artistGroup = artistGroup; // 옵션 B: seed에서는 null + this.artistType = artistType; // 옵션 B: seed에서는 "SINGER" + this.genre = genre; + } + + public void updateProfile(String nameKo, String artistGroup, String artistType) { + this.nameKo = nameKo; + this.artistGroup = artistGroup; // nullable + this.artistType = artistType; // "SOLO" / "GROUP" + } + + } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java index 4fdc4a14..5089a420 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/entity/Genre.java @@ -1,10 +1,13 @@ package com.back.web7_9_codecrete_be.domain.artists.entity; import jakarta.persistence.*; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "genre") public class Genre { @Id @@ -19,4 +22,10 @@ public class Genre { @Column(name = "genre_memo") private String genreMemo; + + public Genre(String genreName, String genreGroup, String genreMemo) { + this.genreName = genreName; + this.genreGroup = genreGroup; + this.genreMemo = genreMemo; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java index 6e940387..1113fb4c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java @@ -1,9 +1,17 @@ package com.back.web7_9_codecrete_be.domain.artists.repository; import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ArtistRepository extends JpaRepository { + boolean existsBySpotifyArtistId(String spotifyArtistId); + + @Query("SELECT a FROM Artist a WHERE a.nameKo IS NULL ORDER BY a.id ASC") + List findByNameKoIsNullOrderByIdAsc(Pageable pageable); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java index 3b07d64b..af356f18 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/GenreRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface GenreRepository extends JpaRepository { + Optional findByGenreName(String genreName); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java new file mode 100644 index 00000000..8cdc4d04 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistEnrichService.java @@ -0,0 +1,248 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; +import com.back.web7_9_codecrete_be.global.musicbrainz.MusicBrainzClient; +import com.back.web7_9_codecrete_be.global.wikidata.WikidataClient; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ArtistEnrichService { + + private final ArtistRepository artistRepository; + private final MusicBrainzClient musicBrainzClient; + private final WikidataClient wikidataClient; + + + // Wikidata + Wikipedia + MusicBrainz를 통합하여 아티스트 정보를 가져와 enrich를 수행 + public int enrichArtist(int limit) { + int actualLimit = limit > 0 ? Math.min(limit, 300) : 100; + List targets = artistRepository.findByNameKoIsNullOrderByIdAsc( + PageRequest.of(0, actualLimit) + ); + log.info("통합 enrich 시작 (Wikidata + Wikipedia + MusicBrainz): 요청 limit={}, 실제 limit={}, 대상 {}명", + limit, actualLimit, targets.size()); + + if (targets.isEmpty()) { + log.warn("⚠️ enrich할 대상 아티스트가 없습니다. (모두 이미 enrich되었거나 DB에 아티스트가 없습니다)"); + return 0; + } + int updated = 0; + int failedNotFound = 0; + int failedException = 0; + + for (Artist artist : targets) { + try { + // 각 아티스트마다 별도 트랜잭션으로 처리하여 즉시 커밋 + enrichSingleArtist(artist); + updated++; + + // API rate limit 고려 (가장 느린 MusicBrainz 기준) + // InterruptedException을 안전하게 처리 + try { + Thread.sleep(1100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("⚠️ Enrich 중 sleep 중단됨 (서버 종료 가능성): 처리된 개수={}", updated); + // 이미 처리된 것은 저장되었으므로 break + break; + } + + } catch (RuntimeException e) { + // 아티스트 정보를 찾을 수 없는 경우 + if (e.getMessage() != null && e.getMessage().contains("아티스트 정보를 찾을 수 없음")) { + failedNotFound++; + } else { + log.error("❌ Enrich 예외 발생: artistId={}, name={}, spotifyId={}, error={}", + artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId(), e.getMessage(), e); + failedException++; + } + } catch (Exception e) { + log.error("❌ Enrich 예외 발생: artistId={}, name={}, spotifyId={}, error={}", + artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId(), e.getMessage(), e); + failedException++; + } + } + + int totalFailed = failedNotFound + failedException; + log.info("📊 통합 enrich 완료: 성공={}, 실패={} (정보없음={}, 예외={}), 총={}", + updated, totalFailed, failedNotFound, failedException, targets.size()); + return updated; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected void enrichSingleArtist(Artist artist) { + log.debug("Enrich 처리 중: artistId={}, name={}, spotifyId={}", + artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId()); + + EnrichResult result = enrichArtist(artist); + + if (result == null) { + log.warn("❌ 아티스트 정보를 찾을 수 없음: artistId={}, name={}, spotifyId={}", + artist.getId(), artist.getArtistName(), artist.getSpotifyArtistId()); + throw new RuntimeException("아티스트 정보를 찾을 수 없음"); + } + + // 기존 artistType이 있으면 유지, 없으면 가져온 값 사용 + String artistType = result.artistType != null ? result.artistType : artist.getArtistType(); + + // ✅ 기존 row를 "보강" + artist.updateProfile(result.nameKo, result.artistGroup, artistType); + // 명시적으로 save하여 변경사항을 DB에 즉시 반영 + artistRepository.save(artist); + log.info("✅ Enrich 성공: artistId={}, name={}, nameKo={}, group={}, type={}, source={}", + artist.getId(), artist.getArtistName(), result.nameKo, + result.artistGroup, artistType, result.source); + } + + private EnrichResult enrichArtist(Artist artist) { + String nameKo = null; + String artistGroup = null; + String artistType = null; + String source = ""; + + // 1단계: Spotify ID로 Wikidata 찾기 (가장 정확) + Optional qidOpt = wikidataClient.searchWikidataIdBySpotifyId(artist.getSpotifyArtistId()); + if (qidOpt.isEmpty()) { + // Spotify ID로 못 찾으면 이름으로 시도 + qidOpt = wikidataClient.searchWikidataId(artist.getArtistName()); + } + + if (qidOpt.isPresent()) { + String qid = qidOpt.get(); + Optional entityOpt = wikidataClient.getEntityInfo(qid); + + if (entityOpt.isPresent()) { + JsonNode entity = entityOpt.get(); + + // Wikipedia에서 한국어 이름 가져오기 + Optional nameKoOpt = wikidataClient.getKoreanNameFromWikipedia(entity); + if (nameKoOpt.isPresent()) { + nameKo = nameKoOpt.get(); + source += "Wikipedia "; + } + + // Wikidata에서 아티스트 타입 추출 + artistType = inferArtistTypeFromWikidata(entity); + if (artistType != null) { + source += "Wikidata "; + } + + // Wikidata에서 소속 그룹 추출 + artistGroup = resolveGroupNameFromWikidata(entity); + if (artistGroup != null) { + source += "Wikidata "; + } + } + } + + // 2단계: Wikipedia에서 직접 검색 (Wikidata 실패 시) + if (nameKo == null) { + Optional nameKoOpt = wikidataClient.searchKoreanNameFromWikipedia(artist.getArtistName()); + if (nameKoOpt.isPresent()) { + nameKo = nameKoOpt.get(); + source += "Wikipedia "; + } + } + + // 3단계: MusicBrainz에서 추가 정보 가져오기 (보완, 실패해도 계속 진행) + try { + Optional mbInfoOpt = musicBrainzClient.searchArtist(artist.getArtistName()); + if (mbInfoOpt.isPresent()) { + MusicBrainzClient.ArtistInfo mbInfo = mbInfoOpt.get(); + + // 한국어 이름이 없으면 MusicBrainz에서 가져오기 + if (nameKo == null && mbInfo.getNameKo() != null) { + nameKo = mbInfo.getNameKo(); + source += "MusicBrainz "; + } + + // 소속 그룹이 없으면 MusicBrainz에서 가져오기 + if (artistGroup == null && mbInfo.getArtistGroup() != null) { + artistGroup = mbInfo.getArtistGroup(); + source += "MusicBrainz "; + } + + // 아티스트 타입이 없으면 MusicBrainz에서 가져오기 + if (artistType == null && mbInfo.getArtistType() != null) { + artistType = mbInfo.getArtistType(); + source += "MusicBrainz "; + } + } + } catch (Exception e) { + // MusicBrainz 실패해도 Wikidata/Wikipedia 정보로는 계속 진행 + log.debug("MusicBrainz 정보 가져오기 실패 (무시하고 계속 진행): name={}, error={}", + artist.getArtistName(), e.getMessage()); + } + + // 최소한 한국어 이름은 있어야 성공으로 간주 + if (nameKo == null) { + return null; + } + + return new EnrichResult(nameKo, artistGroup, artistType, source.trim()); + } + + //Wikidata 엔티티에서 아티스트 타입 추출 + private String inferArtistTypeFromWikidata(JsonNode entity) { + // P31 instance of: human(Q5), musical group(Q215380) + List instanceOfList = wikidataClient.getAllEntityIdClaims(entity, "P31"); + + // Q215380 (musical group)이 있으면 GROUP + if (instanceOfList.contains("Q215380")) { + return "GROUP"; + } + + // P463 (member of) 속성이 있으면 그룹 멤버이므로 SOLO + Optional memberOf = wikidataClient.getEntityIdClaim(entity, "P463"); + if (memberOf.isPresent()) { + return "SOLO"; + } + + // Q5 (human)만 있으면 SOLO + if (instanceOfList.contains("Q5") && instanceOfList.size() == 1) { + return "SOLO"; + } + + return null; + } + + // Wikidata 엔티티에서 소속 그룹 이름 추출 + private String resolveGroupNameFromWikidata(JsonNode artistEntity) { + // P463 member of + Optional groupQid = wikidataClient.getEntityIdClaim(artistEntity, "P463"); + if (groupQid.isEmpty()) return null; + + Optional groupEntityOpt = wikidataClient.getEntityInfo(groupQid.get()); + if (groupEntityOpt.isEmpty()) return null; + + // Wikipedia에서 그룹 이름 가져오기 + return wikidataClient.getKoreanNameFromWikipedia(groupEntityOpt.get()).orElse(null); + } + + private static class EnrichResult { + final String nameKo; + final String artistGroup; + final String artistType; + final String source; + + EnrichResult(String nameKo, String artistGroup, String artistType, String source) { + this.nameKo = nameKo; + this.artistGroup = artistGroup; + this.artistType = artistType; + this.source = source; + } + } + +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java new file mode 100644 index 00000000..6dc05896 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -0,0 +1,17 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ArtistService { + + private final SpotifyService spotifyService; + + @Transactional + public int setArtist() { + return spotifyService.seedKoreanArtists300(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistsService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistsService.java deleted file mode 100644 index 389bedf3..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistsService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.artists.service; - -import com.back.web7_9_codecrete_be.domain.artists.repository.*; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ArtistsService { - private final ArtistRepository artistRepository; - private final ArtistLikeRepository artistLikeRepository; - private final ConcertArtistRepository concertArtistRepository; - private final GenreLikeRepository genreLikeRepository; - private final GenreRepository genreRepository; -} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java new file mode 100644 index 00000000..259cc207 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -0,0 +1,167 @@ +package com.back.web7_9_codecrete_be.domain.artists.service; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; +import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; +import com.back.web7_9_codecrete_be.domain.artists.repository.GenreRepository; +import com.back.web7_9_codecrete_be.global.error.code.ArtistErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import se.michaelthelin.spotify.SpotifyApi; +import se.michaelthelin.spotify.model_objects.specification.Paging; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpotifyService { + + private final ArtistRepository artistRepository; + private final GenreRepository genreRepository; + private final SpotifyClient spotifyClient; + + @Transactional + public int seedKoreanArtists300() { + try { + SpotifyApi api = spotifyClient.getAuthorizedApi(); + + final int targetCount = 300; + final int limit = 50; + int totalSaved = 0; + + List queries = List.of( + "k-pop", "korean pop", + "BTS", "BLACKPINK", "NewJeans", "LE SSERAFIM", "aespa", "IVE", "NCT", + "Stray Kids", "TWICE", "Red Velvet", "IU", "태연", + "korean hip hop", "korean r&b", "korean ballad", "korean ost", + "korean indie", "korean rock", "trot" + ); + + for (String q : queries) { + if (totalSaved >= targetCount) break; + + int offset = 0; + + while (totalSaved < targetCount) { + Paging paging = + api.searchArtists(q) + .limit(limit) + .offset(offset) + .build() + .execute(); + + var items = paging.getItems(); + if (items == null || items.length == 0) break; + + for (var spotifyArtist : items) { + if (totalSaved >= targetCount) break; + + String spotifyId = spotifyArtist.getId(); + String name = spotifyArtist.getName(); + + if (spotifyId == null || name == null || name.isBlank()) continue; + if (artistRepository.existsBySpotifyArtistId(spotifyId)) continue; + if (!isLikelyKoreanMusic(spotifyArtist)) continue; + + String mainGenreName = pickMainGenreName(spotifyArtist); + Genre genre = findOrCreateGenreByName(mainGenreName, null); + + String artistType = inferArtistType(spotifyArtist); + + // ✅ seed 단계에서는 Wikidata 호출 금지 (속도/실패 리스크) + Artist artist = new Artist( + spotifyId, + name.trim(), + null, // artistGroup + artistType, + genre + ); + + artistRepository.save(artist); + totalSaved++; + } + + offset += limit; + if (offset >= paging.getTotal()) break; + + Thread.sleep(200); + } + } + + if (totalSaved == 0) { + throw new BusinessException(ArtistErrorCode.ARTIST_SEED_FAILED); + } + + return totalSaved; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(ArtistErrorCode.SPOTIFY_API_ERROR); + } + } + + private static final List KOREAN_GENRE_HINTS = List.of( + "k-pop", "korean", "trot", + "k-hip hop", "k-rap", "k-ballad", "k-r&b", "k-indie", "k-rock", + "korean hip hop", "korean r&b", "korean ballad", "korean ost", + "korean indie", "korean rock" + ); + + private boolean isLikelyKoreanMusic(se.michaelthelin.spotify.model_objects.specification.Artist a) { + String[] genres = a.getGenres(); + if (genres != null) { + for (String g : genres) { + if (g == null) continue; + String s = g.toLowerCase(); + for (String hint : KOREAN_GENRE_HINTS) { + if (s.contains(hint)) return true; + } + } + } + // 한글 이름 보조 필터 + String name = a.getName(); + return name != null && name.matches(".*[가-힣].*"); + } + + private String pickMainGenreName(se.michaelthelin.spotify.model_objects.specification.Artist a) { + String[] genres = a.getGenres(); + if (genres == null || genres.length == 0) return "k-pop"; + + for (String g : genres) { + if (g == null) continue; + String s = g.toLowerCase(); + if (s.contains("trot")) return "trot"; + if (s.contains("ballad")) return "ballad"; + if (s.contains("hip hop")) return "hiphop"; + if (s.contains("r&b") || s.contains("rb")) return "rnb"; + if (s.contains("ost")) return "ost"; + if (s.contains("rock")) return "rock"; + if (s.contains("indie")) return "indie"; + if (s.contains("k-pop") || s.contains("korean")) return "k-pop"; + } + return "k-pop"; + } + + private String inferArtistType(se.michaelthelin.spotify.model_objects.specification.Artist a) { + String[] genres = a.getGenres(); + if (genres != null) { + for (String g : genres) { + if (g == null) continue; + String s = g.toLowerCase(); + if (s.contains("boy group") || s.contains("girl group")) return "GROUP"; + } + } + return "SOLO"; + } + + private Genre findOrCreateGenreByName(String genreName, String genreGroup) { + return genreRepository.findByGenreName(genreName) + .orElseGet(() -> genreRepository.save(new Genre(genreName, genreGroup, null))); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/config/WebClientConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/config/WebClientConfig.java index 6e64f813..dcf7200a 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/config/WebClientConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/config/WebClientConfig.java @@ -5,10 +5,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; +import java.util.Collections; + import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -43,7 +46,14 @@ public WebClient mailgunClient() { @Bean public RestTemplate restTemplate() { - return new RestTemplate(); + RestTemplate restTemplate = new RestTemplate(); + // Wikidata API는 User-Agent 헤더를 필수로 요구 + ClientHttpRequestInterceptor userAgentInterceptor = (request, body, execution) -> { + request.getHeaders().add("User-Agent", "CodecreteBE/1.0 (Educational Project; +https://github.com/your-repo)"); + return execution.execute(request, body); + }; + restTemplate.setInterceptors(Collections.singletonList(userAgentInterceptor)); + return restTemplate; } @Bean diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/ArtistErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/ArtistErrorCode.java new file mode 100644 index 00000000..795fbd24 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/ArtistErrorCode.java @@ -0,0 +1,19 @@ +package com.back.web7_9_codecrete_be.global.error.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + + +@Getter +@RequiredArgsConstructor +public enum ArtistErrorCode implements ErrorCode { + + ARTIST_SEED_FAILED(HttpStatus.BAD_REQUEST, "AT-100", "아티스트 정보 저장에 실패했습니다."), + SPOTIFY_API_ERROR(HttpStatus.BAD_GATEWAY, "AT-101", "Spotify API 연동 중 오류가 발생했습니다."), + ARTIST_NOT_FOUND(HttpStatus.NOT_FOUND, "AT-102", "아티스트를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/musicbrainz/MusicBrainzClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/musicbrainz/MusicBrainzClient.java new file mode 100644 index 00000000..42705a63 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/musicbrainz/MusicBrainzClient.java @@ -0,0 +1,218 @@ +package com.back.web7_9_codecrete_be.global.musicbrainz; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MusicBrainzClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String MUSICBRAINZ_API_BASE = "https://musicbrainz.org/ws/2"; + private static final String USER_AGENT = "CodecreteBE/1.0 (Educational Project; +https://github.com/your-repo)"; + + // 아티스트 이름으로 MusicBrainz에서 아티스트 정보 검색 + public Optional searchArtist(String artistName) { + try { + String encodedName = URLEncoder.encode(artistName, StandardCharsets.UTF_8); + String url = String.format( + "%s/artist/?query=artist:\"%s\"&fmt=json&limit=5&inc=aliases+artist-rels", + MUSICBRAINZ_API_BASE, encodedName + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("User-Agent", USER_AGENT); + headers.set("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + int statusCode = response.getStatusCode().value(); + // 503 Service Unavailable은 서버가 일시적으로 사용 불가능한 경우 + if (statusCode == 503) { + log.debug("MusicBrainz 서버 일시적 사용 불가: name={}, status=503 (나중에 다시 시도 필요)", artistName); + } else { + log.debug("MusicBrainz 검색 API 응답 실패: name={}, status={}", artistName, statusCode); + } + return Optional.empty(); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode artists = root.path("artists"); + + if (!artists.isArray() || artists.isEmpty()) { + log.debug("MusicBrainz 검색 결과 없음: {}", artistName); + return Optional.empty(); + } + + // 첫 번째 결과 사용 (가장 관련성 높은 결과) + JsonNode firstArtist = artists.get(0); + return parseArtistInfo(firstArtist); + + } catch (org.springframework.web.client.HttpServerErrorException e) { + // 503 Service Unavailable 등 서버 에러 명시적 처리 + int statusCode = e.getStatusCode().value(); + if (statusCode == 503) { + log.debug("MusicBrainz 서버 일시적 사용 불가: name={}, status=503 (나중에 다시 시도 필요)", artistName); + } else { + log.warn("MusicBrainz 검색 서버 에러: name={}, status={}", artistName, statusCode); + } + return Optional.empty(); + } catch (org.springframework.web.client.ResourceAccessException e) { + // Connection reset, timeout 등 네트워크 에러 처리 + log.debug("MusicBrainz 네트워크 에러 (Connection reset/timeout): name={}, error={}", + artistName, e.getMessage()); + return Optional.empty(); + } catch (Exception e) { + log.warn("MusicBrainz 검색 실패: name={}", artistName, e); + return Optional.empty(); + } + } + + // MusicBrainz MBID로 상세 아티스트 정보 조회 + public Optional getArtistByMbid(String mbid) { + try { + String url = String.format( + "%s/artist/%s?fmt=json&inc=aliases+artist-rels", + MUSICBRAINZ_API_BASE, mbid + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("User-Agent", USER_AGENT); + headers.set("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.debug("MusicBrainz 조회 API 응답 실패: mbid={}, status={}", mbid, response.getStatusCode()); + return Optional.empty(); + } + + JsonNode artist = objectMapper.readTree(response.getBody()); + return parseArtistInfo(artist); + + } catch (org.springframework.web.client.HttpServerErrorException e) { + int statusCode = e.getStatusCode().value(); + if (statusCode == 503) { + log.debug("MusicBrainz 서버 일시적 사용 불가: mbid={}, status=503", mbid); + } else { + log.warn("MusicBrainz 조회 서버 에러: mbid={}, status={}", mbid, statusCode); + } + return Optional.empty(); + } catch (org.springframework.web.client.ResourceAccessException e) { + log.debug("MusicBrainz 네트워크 에러 (Connection reset/timeout): mbid={}, error={}", + mbid, e.getMessage()); + return Optional.empty(); + } catch (Exception e) { + log.warn("MusicBrainz 조회 실패: mbid={}", mbid, e); + return Optional.empty(); + } + } + + private Optional parseArtistInfo(JsonNode artist) { + try { + String nameKo = extractKoreanName(artist); + String artistGroup = extractGroupName(artist); + String artistType = extractArtistType(artist); + + return Optional.of(new ArtistInfo(nameKo, artistGroup, artistType)); + + } catch (Exception e) { + log.warn("MusicBrainz 아티스트 정보 파싱 실패", e); + return Optional.empty(); + } + } + + /** + * 한국어 이름 추출 (aliases에서 locale="ko"인 것) + */ + private String extractKoreanName(JsonNode artist) { + JsonNode aliases = artist.path("aliases"); + if (aliases.isArray()) { + for (JsonNode alias : aliases) { + String locale = alias.path("locale").asText(); + if ("ko".equals(locale)) { + String name = alias.path("name").asText(); + if (name != null && !name.isBlank()) { + return name; + } + } + } + } + return null; + } + + // 소속 그룹 이름 추출 (relations에서 type="member of band"인 것) + private String extractGroupName(JsonNode artist) { + JsonNode relations = artist.path("relations"); + if (relations.isArray()) { + for (JsonNode relation : relations) { + String type = relation.path("type").asText(); + if ("member of band".equals(type) || "member of".equals(type)) { + JsonNode group = relation.path("artist"); + if (!group.isMissingNode()) { + String groupName = group.path("name").asText(); + if (groupName != null && !groupName.isBlank()) { + return groupName; + } + } + } + } + } + return null; + } + + // 아티스트 타입 추출 (type 필드: Person -> SOLO, Group -> GROUP) + private String extractArtistType(JsonNode artist) { + String type = artist.path("type").asText(); + if ("Group".equals(type) || "Orchestra".equals(type) || "Choir".equals(type)) { + return "GROUP"; + } else if ("Person".equals(type)) { + return "SOLO"; + } + // type이 없거나 다른 경우 null 반환 (기존 값 유지) + return null; + } + + public static class ArtistInfo { + private final String nameKo; + private final String artistGroup; + private final String artistType; + + public ArtistInfo(String nameKo, String artistGroup, String artistType) { + this.nameKo = nameKo; + this.artistGroup = artistGroup; + this.artistType = artistType; + } + + public String getNameKo() { + return nameKo; + } + + public String getArtistGroup() { + return artistGroup; + } + + public String getArtistType() { + return artistType; + } + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java index b88226c8..b67f2133 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java @@ -43,7 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/v3/api-docs/**", // Swagger "/swagger-ui/**", // Swagger UI "/h2-console/**", // H2 Console - "/api/v1/concerts/**" // concert 정보 조회 도메인 + "/api/v1/concerts/**", // concert 정보 조회 도메인 + "/api/v1/artists/**" // artist 정보 저장 도메인(임시) ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java new file mode 100644 index 00000000..86c85e19 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java @@ -0,0 +1,32 @@ +package com.back.web7_9_codecrete_be.global.spotify; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import se.michaelthelin.spotify.SpotifyApi; +import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials; +import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest; + +@Component +@RequiredArgsConstructor +public class SpotifyClient { + + private final SpotifyApi spotifyApi; + + public String getAccessToken() { + try { + ClientCredentialsRequest request = spotifyApi.clientCredentials().build(); + ClientCredentials credentials = request.execute(); + spotifyApi.setAccessToken(credentials.getAccessToken()); + return credentials.getAccessToken(); + } catch (Exception e) { + throw new RuntimeException("Spotify 토큰 발급 실패", e); + } + } + + public SpotifyApi getAuthorizedApi() { + if (spotifyApi.getAccessToken() == null) { + getAccessToken(); + } + return spotifyApi; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyConfig.java new file mode 100644 index 00000000..860c595a --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyConfig.java @@ -0,0 +1,24 @@ +package com.back.web7_9_codecrete_be.global.spotify; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import se.michaelthelin.spotify.SpotifyApi; + +@Configuration +public class SpotifyConfig { + + @Value("${spotify.client-id}") + private String clientId; + + @Value("${spotify.client-secret}") + private String clientSecret; + + @Bean + public SpotifyApi spotifyApi() { + return new SpotifyApi.Builder() + .setClientId(clientId) + .setClientSecret(clientSecret) + .build(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/wikidata/WikidataClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/wikidata/WikidataClient.java new file mode 100644 index 00000000..0e790d0d --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/wikidata/WikidataClient.java @@ -0,0 +1,541 @@ +package com.back.web7_9_codecrete_be.global.wikidata; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WikidataClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String WIKIDATA_SEARCH_API = "https://www.wikidata.org/w/api.php"; + private static final String WIKIDATA_ENTITY_API = "https://www.wikidata.org/wiki/Special:EntityData/"; + private static final String WIKIPEDIA_KO_API = "https://ko.wikipedia.org/api/rest_v1/page/summary/"; + + // Spotify Artist ID(P345)로 QID 찾기 + public Optional searchWikidataIdBySpotifyId(String spotifyId) { + try { + // P345 = Spotify artist ID + // P1902 = Spotify album ID (잘못된 속성) + String escapedSpotifyId = spotifyId.replace("\\", "\\\\").replace("\"", "\\\""); + String sparqlQuery = String.format( + "SELECT ?item WHERE { ?item wdt:P345 \"%s\" } LIMIT 1", + escapedSpotifyId + ); + + String url = "https://query.wikidata.org/sparql"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("User-Agent", "CodecreteBE/1.0 (Educational Project; +https://github.com/your-repo)"); + + String requestBody = "query=" + URLEncoder.encode(sparqlQuery, StandardCharsets.UTF_8) + "&format=json"; + + HttpEntity request = new HttpEntity<>(requestBody, headers); + + log.debug("Wikidata SPARQL 쿼리 실행: {}", sparqlQuery); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.warn("Wikidata SPARQL API 응답 실패: status={}", response.getStatusCode()); + return Optional.empty(); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode bindings = root.path("results").path("bindings"); + + if (!bindings.isArray() || bindings.isEmpty()) { + log.debug("Spotify ID로 Wikidata 결과 없음: {}", spotifyId); + return Optional.empty(); + } + + String itemUri = bindings.get(0).path("item").path("value").asText(); + if (itemUri == null || itemUri.isBlank()) { + log.warn("Wikidata URI가 비어있음: spotifyId={}", spotifyId); + return Optional.empty(); + } + + String qid = itemUri.substring(itemUri.lastIndexOf("/") + 1); + log.info("Spotify ID로 Wikidata ID 찾음: {} -> {}", spotifyId, qid); + return Optional.of(qid); + + } catch (Exception e) { + log.error("Spotify ID로 Wikidata 검색 중 예외 발생: spotifyId={}", spotifyId, e); + return Optional.empty(); + } + } + + // 이름으로 QID 찾기 (fallback) + public Optional searchWikidataId(String artistName) { + try { + Map params = new HashMap<>(); + params.put("action", "wbsearchentities"); + params.put("search", URLEncoder.encode(artistName, StandardCharsets.UTF_8)); + params.put("language", "ko"); // ko 우선 + params.put("format", "json"); + params.put("type", "item"); + params.put("limit", "10"); // 여러 후보 확인 + + String url = WIKIDATA_SEARCH_API + "?" + buildQueryString(params); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + return Optional.empty(); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode search = root.path("search"); + if (!search.isArray() || search.isEmpty()) return Optional.empty(); + + // 여러 후보 중 가장 적합한 것 선택 + return findBestMatch(search, artistName); + } catch (Exception e) { + log.warn("이름으로 Wikidata 검색 실패: {}", artistName, e); + return Optional.empty(); + } + } + + // 여러 검색 결과 중 가장 적합한 Wikidata ID 선택 + private Optional findBestMatch(JsonNode searchResults, String originalName) { + String bestQid = null; + int bestScore = -1; + String bestMatchInfo = null; + + for (JsonNode result : searchResults) { + String qid = result.path("id").asText(); + if (qid == null || qid.isBlank()) continue; + + String label = result.path("label").asText(); + String description = result.path("description").asText(""); + + // 엔티티 정보 가져와서 상세 검증 + Optional entityOpt = getEntityInfo(qid); + if (entityOpt.isEmpty()) continue; + + JsonNode entity = entityOpt.get(); + int score = calculateMatchScore(entity, originalName, label, description); + + if (score > bestScore) { + bestScore = score; + bestQid = qid; + bestMatchInfo = String.format("label=%s, score=%d", label, score); + } + } + + if (bestQid != null && bestScore >= 30) { // 최소 점수 기준 + log.debug("이름 검색 결과 중 최적 매칭: {} -> {} ({})", originalName, bestQid, bestMatchInfo); + return Optional.of(bestQid); + } + + if (bestQid != null) { + log.debug("이름 검색 결과 점수가 낮아 제외: {} -> {} (score={}, 최소=30)", originalName, bestQid, bestScore); + } + + return Optional.empty(); + } + + private int calculateMatchScore(JsonNode entity, String originalName, String label, String description) { + int score = 0; + String originalLower = originalName.toLowerCase().trim(); + String labelLower = label.toLowerCase().trim(); + + // 1. 정확히 일치 (50점) + if (originalLower.equals(labelLower)) { + score += 50; + } + // 2. 부분 일치 (30점) + else if (originalLower.contains(labelLower) || labelLower.contains(originalLower)) { + score += 30; + } + // 3. 단어 단위 일치 (20점) + else if (hasWordMatch(originalLower, labelLower)) { + score += 20; + } + + // 4. 영어 레이블 확인 + JsonNode enLabel = entity.path("labels").path("en").path("value"); + if (!enLabel.isMissingNode()) { + String enValue = enLabel.asText().toLowerCase().trim(); + if (originalLower.equals(enValue)) { + score += 40; // 영어 이름 정확 일치 + } else if (originalLower.contains(enValue) || enValue.contains(originalLower)) { + score += 25; // 영어 이름 부분 일치 + } + } + + // 5. Spotify ID가 있으면 보너스 (가장 확실한 방법) + JsonNode claims = entity.path("claims").path("P345"); + if (claims.isArray() && claims.size() > 0) { + score += 30; // Spotify ID가 있으면 높은 신뢰도 + } + + // 6. Wikipedia 한국어 이름이 있으면 보너스 + Optional wikiKoNameOpt = getKoreanNameFromWikipedia(entity); + if (wikiKoNameOpt.isPresent()) { + String wikiKoName = wikiKoNameOpt.get(); + // Wikipedia 한국어 이름이 일반 단어가 아니면 보너스 + if (!isCommonKoreanWord(wikiKoName)) { + score += 15; // Wikipedia는 더 신뢰도가 높으므로 점수 증가 + } else { + score -= 20; // 일반 단어면 감점 + } + } + + // 7. 설명(description)에 아티스트 관련 키워드가 있으면 보너스 + String descLower = description.toLowerCase(); + if (descLower.contains("singer") || descLower.contains("musician") || + descLower.contains("artist") || descLower.contains("group") || + descLower.contains("가수") || descLower.contains("음악") || descLower.contains("아티스트")) { + score += 10; + } + + return Math.min(score, 100); // 최대 100점 + } + + private boolean hasWordMatch(String str1, String str2) { + String[] words1 = str1.split("[\\s\\-_]+"); + String[] words2 = str2.split("[\\s\\-_]+"); + + for (String w1 : words1) { + for (String w2 : words2) { + if (w1.length() >= 3 && w2.length() >= 3 && + (w1.equals(w2) || w1.contains(w2) || w2.contains(w1))) { + return true; + } + } + } + return false; + } + + public Optional getEntityInfo(String qid) { + try { + String url = WIKIDATA_ENTITY_API + qid + ".json"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + return Optional.empty(); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode entity = root.path("entities").path(qid); + if (entity.isMissingNode() || entity.isNull()) return Optional.empty(); + + return Optional.of(entity); + } catch (Exception e) { + log.warn("Wikidata entity 조회 실패: {}", qid, e); + return Optional.empty(); + } + } + + + private boolean isCommonKoreanWord(String word) { + if (word == null || word.length() <= 2) return true; // 2자 이하는 일반 단어로 간주 + + String[] commonWords = {"보물", "사랑", "빛", "별", "꿈", "하늘", "바다", "땅", "물", "불", "바람"}; + for (String common : commonWords) { + if (word.equals(common)) { + return true; + } + } + + if (word.length() <= 3 && word.matches("^[가-힣]+$")) { + return true; + } + + return false; + } + + public Optional getEntityIdClaim(JsonNode entity, String propertyId) { + JsonNode claims = entity.path("claims").path(propertyId); + if (!claims.isArray() || claims.isEmpty()) return Optional.empty(); + + JsonNode value = claims.get(0) + .path("mainsnak") + .path("datavalue") + .path("value"); + + JsonNode idNode = value.path("id"); + if (!idNode.isMissingNode() && !idNode.asText().isBlank()) { + return Optional.of(idNode.asText()); + } + return Optional.empty(); + } + + // claims[propertyId]의 모든 QID 값 반환 + public List getAllEntityIdClaims(JsonNode entity, String propertyId) { + List results = new java.util.ArrayList<>(); + JsonNode claims = entity.path("claims").path(propertyId); + if (!claims.isArray() || claims.isEmpty()) return results; + + for (JsonNode claim : claims) { + JsonNode value = claim + .path("mainsnak") + .path("datavalue") + .path("value"); + + JsonNode idNode = value.path("id"); + if (!idNode.isMissingNode() && !idNode.asText().isBlank()) { + results.add(idNode.asText()); + } + } + return results; + } + + public Optional getWikipediaKoreanTitle(JsonNode entity) { + try { + // sitelinks에서 한국어 Wikipedia 페이지 제목 가져오기 + JsonNode sitelinks = entity.path("sitelinks"); + JsonNode koWiki = sitelinks.path("kowiki"); + + if (!koWiki.isMissingNode()) { + JsonNode title = koWiki.path("title"); + if (!title.isMissingNode() && !title.asText().isBlank()) { + return Optional.of(title.asText()); + } + } + + return Optional.empty(); + } catch (Exception e) { + log.warn("Wikipedia 한국어 제목 가져오기 실패", e); + return Optional.empty(); + } + } + + public Optional getWikipediaKoreanSummary(String title) { + try { + // URLEncoder는 공백을 +로 변환하지만, Wikipedia API는 %20을 선호 + // 공백을 %20으로 변환하기 위해 replace 사용 + String encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8) + .replace("+", "%20"); + String url = WIKIPEDIA_KO_API + encodedTitle; + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + int statusCode = response.getStatusCode().value(); + // 403, 404는 정상적인 실패 케이스 (페이지 없음, 접근 불가 등) + if (statusCode == 403 || statusCode == 404) { + log.debug("Wikipedia 한국어 API 응답 실패: title={}, status={} (페이지 없음 또는 접근 불가)", title, statusCode); + } else { + log.debug("Wikipedia 한국어 API 응답 실패: title={}, status={}", title, statusCode); + } + return Optional.empty(); + } + + JsonNode summary = objectMapper.readTree(response.getBody()); + return Optional.of(summary); + } catch (org.springframework.web.client.HttpClientErrorException e) { + // 403, 404는 정상적인 실패 케이스 (페이지 없음, 접근 불가 등) + int statusCode = e.getStatusCode().value(); + if (statusCode == 403 || statusCode == 404) { + log.debug("Wikipedia 한국어 API HTTP 에러: title={}, status={} (페이지 없음 또는 접근 불가)", title, statusCode); + } else { + log.warn("Wikipedia 한국어 summary 가져오기 실패: title={}, status={}", title, statusCode, e); + } + return Optional.empty(); + } catch (Exception e) { + log.warn("Wikipedia 한국어 summary 가져오기 실패: title={}", title, e); + return Optional.empty(); + } + } + + public Optional extractKoreanNameFromSummary(JsonNode summary) { + try { + // 1. title 필드 확인 (페이지 제목) + JsonNode title = summary.path("title"); + if (!title.isMissingNode() && !title.asText().isBlank()) { + String titleText = title.asText().trim(); + // 제목에서 괄호 내용 제거 (예: "방탄소년단 (음악 그룹)" -> "방탄소년단") + String cleanTitle = titleText.replaceAll("\\([^)]*\\)", "").trim(); + if (!cleanTitle.isBlank()) { + return Optional.of(cleanTitle); + } + return Optional.of(titleText); + } + + // 2. extract 필드에서 첫 문장 확인 + JsonNode extract = summary.path("extract"); + if (!extract.isMissingNode() && !extract.asText().isBlank()) { + String extractText = extract.asText(); + // 첫 문장에서 이름 추출 시도 + // 예: "방탄소년단은..." -> "방탄소년단" + String firstSentence = extractText.split("[。\\.]")[0].trim(); + if (firstSentence.length() > 0 && firstSentence.length() <= 20) { + // 한글이 포함되어 있고 적절한 길이면 사용 + if (firstSentence.matches(".*[가-힣].*")) { + return Optional.of(firstSentence); + } + } + } + + return Optional.empty(); + } catch (Exception e) { + log.warn("Wikipedia summary에서 한국어 이름 추출 실패", e); + return Optional.empty(); + } + } + + public Optional getKoreanNameFromWikipedia(JsonNode entity) { + try { + // 1. Wikipedia 한국어 페이지 제목 가져오기 + Optional wikiTitleOpt = getWikipediaKoreanTitle(entity); + if (wikiTitleOpt.isEmpty()) { + log.debug("Wikipedia 한국어 페이지 제목 없음"); + return Optional.empty(); + } + + String wikiTitle = wikiTitleOpt.get(); + log.debug("Wikipedia 한국어 페이지 제목: {}", wikiTitle); + + // 2. Wikipedia summary 가져오기 + Optional summaryOpt = getWikipediaKoreanSummary(wikiTitle); + if (summaryOpt.isEmpty()) { + log.debug("Wikipedia 한국어 summary 없음: {}", wikiTitle); + // summary가 없어도 제목 자체를 사용 + return Optional.of(wikiTitle); + } + + JsonNode summary = summaryOpt.get(); + + // 3. summary에서 한국어 이름 추출 + Optional nameOpt = extractKoreanNameFromSummary(summary); + if (nameOpt.isPresent()) { + log.debug("Wikipedia summary에서 한국어 이름 추출: {}", nameOpt.get()); + return nameOpt; + } + + // 4. summary에서 추출 실패하면 제목 사용 + log.debug("Wikipedia summary에서 이름 추출 실패, 제목 사용: {}", wikiTitle); + return Optional.of(wikiTitle); + + } catch (Exception e) { + log.warn("Wikipedia에서 한국어 이름 가져오기 실패", e); + return Optional.empty(); + } + } + + public Optional searchKoreanNameFromWikipedia(String artistName) { + try { + // 1. 아티스트 이름으로 직접 Wikipedia summary API 호출 시도 + Optional summaryOpt = getWikipediaKoreanSummary(artistName); + if (summaryOpt.isPresent()) { + Optional nameOpt = extractKoreanNameFromSummary(summaryOpt.get()); + if (nameOpt.isPresent()) { + log.debug("Wikipedia에서 직접 검색 성공: {} -> {}", artistName, nameOpt.get()); + return nameOpt; + } + } + + // 2. Wikipedia Search API로 검색 + // https://ko.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&format=json + String encodedQuery = URLEncoder.encode(artistName, StandardCharsets.UTF_8) + .replace("+", "%20"); + String searchUrl = String.format( + "https://ko.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&srlimit=5&format=json", + encodedQuery + ); + + ResponseEntity response = restTemplate.getForEntity(searchUrl, String.class); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.debug("Wikipedia 검색 API 응답 실패: name={}, status={}", artistName, response.getStatusCode()); + return Optional.empty(); + } + + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode searchResults = root.path("query").path("search"); + + if (!searchResults.isArray() || searchResults.isEmpty()) { + log.debug("Wikipedia 검색 결과 없음: {}", artistName); + return Optional.empty(); + } + + // 검색 결과들을 순회하며 유효한 제목 찾기 + // snippet과 title을 확인하여 아티스트와 관련이 있는지 검증 + for (JsonNode result : searchResults) { + String title = result.path("title").asText(); + if (title == null || title.isBlank()) { + continue; + } + + // snippet 확인 (아티스트 관련 키워드가 있는지) + String snippet = result.path("snippet").asText(""); + String lowerSnippet = snippet.toLowerCase(); + String lowerArtistName = artistName.toLowerCase(); + + // snippet에 아티스트 이름이 포함되어 있거나, 음악 관련 키워드가 있는지 확인 + boolean isRelevant = lowerSnippet.contains(lowerArtistName) || + lowerSnippet.contains("가수") || lowerSnippet.contains("음악") || + lowerSnippet.contains("singer") || lowerSnippet.contains("musician") || + lowerSnippet.contains("artist") || lowerSnippet.contains("group"); + + // 제목 자체가 아티스트 이름과 유사한지 확인 + String lowerTitle = title.toLowerCase(); + boolean titleMatches = lowerTitle.contains(lowerArtistName) || + lowerArtistName.contains(lowerTitle.split("\\s+")[0]); // 첫 단어 매칭 + + // 관련성이 낮으면 스킵 (너무 많은 결과를 필터링하지 않도록 완화) + if (!isRelevant && !titleMatches && searchResults.size() > 1) { + log.debug("Wikipedia 검색 결과 관련성 낮음, 스킵: title={}, snippet={}", title, snippet); + continue; + } + + // 각 검색 결과의 제목으로 summary 가져오기 시도 + summaryOpt = getWikipediaKoreanSummary(title); + if (summaryOpt.isPresent()) { + Optional nameOpt = extractKoreanNameFromSummary(summaryOpt.get()); + if (nameOpt.isPresent()) { + log.debug("Wikipedia 검색 후 summary에서 이름 추출: {} -> {} (제목: {})", + artistName, nameOpt.get(), title); + return nameOpt; + } + // summary에서 추출 실패하면 제목 사용 + log.debug("Wikipedia 검색 결과 제목 사용: {} -> {} (제목: {})", + artistName, title, title); + return Optional.of(title); + } else { + // 404 등으로 summary를 가져오지 못한 경우, 다음 결과 시도 + log.debug("Wikipedia 검색 결과 summary 가져오기 실패, 다음 결과 시도: title={}", title); + } + } + + // 모든 검색 결과를 시도했지만 실패한 경우 + log.debug("Wikipedia 검색 결과 모두 실패: artistName={}, 검색 결과 수={}", artistName, searchResults.size()); + + return Optional.empty(); + + } catch (org.springframework.web.client.HttpClientErrorException e) { + log.warn("Wikipedia 검색 API HTTP 에러: name={}, status={}", artistName, e.getStatusCode()); + return Optional.empty(); + } catch (Exception e) { + log.warn("Wikipedia에서 직접 한국어 이름 검색 실패: {}", artistName, e); + return Optional.empty(); + } + } + + private String buildQueryString(Map params) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : params.entrySet()) { + if (sb.length() > 0) sb.append("&"); + sb.append(entry.getKey()).append("=").append(entry.getValue()); + } + return sb.toString(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9bfca2fd..af1db18b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,3 +45,6 @@ kakao: #Kakao map REST API 키 restapi-key: ${KAKAOMAP_API_KEY} kopis: api-key: ${KOPIST_API_KEY} +spotify: + client-id: ${SPOTIFY_CLIENT_ID} + client-secret: ${SPOTIFY_CLIENT_SECRET} \ No newline at end of file