From 633be511eb34217941bebe275631cf2896147b64 Mon Sep 17 00:00:00 2001 From: sham <72376700+GulSam00@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:30:27 +0900 Subject: [PATCH 01/13] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 80d5bf4..97e2a3b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한 sing-code/ ├── apps/ # 실제 서비스 앱이 위치하는 디렉토리 │ └── web/ # 웹 애플리케이션 (Next.js) +│ └── twa/ # bubblewrap을 활용해 PWA 사이트를 WebView 로 감싼 Android App 번들링 ├── packages/ # 앱에서 공통으로 사용하는 패키지 모음 │ ├── crawling/ # DB에 입력 데이터 크롤링 패키지 │ ├── open-api/ # 노래방 번호 제공하는 OPEN API 모듈 패키지 (국내 곡 한정) From fc4f06e53d594b2308c415251169ad1b02be19c2 Mon Sep 17 00:00:00 2001 From: sham <72376700+GulSam00@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:33:36 +0900 Subject: [PATCH 02/13] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 97e2a3b..2a9cfc9 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,11 @@ sing-code/ - 2026.4.19 : 버전 2.5.0 배포. 일본어 곡 한국어 번역 기능 추가. 크롤링으로 곡 국가 태깅, 번역 업데이트 진행. - 2026.5.19 : 버전 2.6.0 배포. 패치노트, 댓글, 오류 신고, 곡 홍보, 포인트 사용내역 조회, 등 다수의 기능 추가. - 2026.6.8 : 버전 2.7.0 배포. 마스코트 추가. 일본어 곡 한글로 검색 가능. +- 2026.6.12 : 버전 2.8.0 배포. TJ노래방 홈페이지에 1 ~ 99999 곡 전체 신규 크롤링. +- 2026.7.16 : 버전 2.9.0 배포. 노래 검색 시 태그 필터링 추가. TWA 세팅. +- 2026.7.31 : 버전 2.10.0 배포. 가이드 투어 기능 추가. +- 2026.8.15 : 버전 2.11.0 배포. 노래방 공식 차트 추가. +- 2026.8.27 : 버전 2.12.0 배포. 이 달의 아티스트 투표 기능 추가. ## 📝 회고 From 5adfbf45a5337c818d6a423c405d0f3026b8e27d Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 29 Aug 2026 01:26:03 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix=20:=20=EA=B2=80=EC=83=89=20=ED=88=AC?= =?UTF-8?q?=EC=96=B4=20X=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=88=AC=EC=96=B4=EA=B0=80=20=EC=A2=85=EB=A3=8C=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LrweuC2EWY9Tbee43P62dM --- apps/web/src/app/search/SearchTour.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/app/search/SearchTour.tsx b/apps/web/src/app/search/SearchTour.tsx index 0cca923..527981c 100644 --- a/apps/web/src/app/search/SearchTour.tsx +++ b/apps/web/src/app/search/SearchTour.tsx @@ -136,6 +136,9 @@ export default function SearchTour({ zIndex: 10000, showProgress: true, buttons: TOOLTIP_BUTTONS, + // 라이브러리 기본값 'close'는 continuous 모드에서 X를 눌러도 다음 단계로 넘어갈 뿐 투어가 끝나지 않는다. + // 'skip'이어야 상태가 SKIPPED가 되어 투어가 종료되고 '다시 보지 않기'까지 저장된다. + closeButtonAction: 'skip' as const, skipBeacon: true, // 라이브러리 기본값(고정 380px)은 375px 이하 모바일 화면에서 좌우로 넘친다. // 뷰포트보다 넓어지지 않되, 넓은 화면에서는 기존 380px 상한을 유지한다. From ec820b7b03c7e4d124dfd84f31b0e15a605a479b Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 29 Aug 2026 01:26:03 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix=20:=20=ED=88=AC=EC=96=B4=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=20=EA=B3=A1=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LrweuC2EWY9Tbee43P62dM --- apps/web/src/constants/tourDemoSong.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/constants/tourDemoSong.ts b/apps/web/src/constants/tourDemoSong.ts index 139bbe5..61fc1a4 100644 --- a/apps/web/src/constants/tourDemoSong.ts +++ b/apps/web/src/constants/tourDemoSong.ts @@ -6,12 +6,12 @@ import { SearchSong } from '@/types/song'; * 실제 검색 결과로 투어를 진행하면 곡 데이터가 바뀌거나 검색이 실패할 때 투어가 깨진다. * 네트워크 없이 항상 같은 카드를 보여주도록 고정값을 쓴다. */ -export const TOUR_DEMO_SEARCH_TERM = '밤이 깊었나'; +export const TOUR_DEMO_SEARCH_TERM = '밤이 깊었네'; export const TOUR_DEMO_SONG: SearchSong = { id: 'tour-demo-song', - title: '밤이 깊었나', - artist: '스판텍스', + title: '밤이 깊었네', + artist: '크라잉넛', num_tj: '10101', num_ky: '20202', badges: [], From 3320111eff0c0492141f20e4e23e0b928d951335 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 29 Aug 2026 01:26:03 +0900 Subject: [PATCH 05/13] =?UTF-8?q?chore=20:=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=9E=A5=EB=A5=B4=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=B1=83=EC=A7=80=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LrweuC2EWY9Tbee43P62dM --- apps/web/src/app/popular/ChartGenreFilter.tsx | 3 +-- apps/web/src/types/tjChart.ts | 18 ------------------ 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/apps/web/src/app/popular/ChartGenreFilter.tsx b/apps/web/src/app/popular/ChartGenreFilter.tsx index 331317a..eab0e54 100644 --- a/apps/web/src/app/popular/ChartGenreFilter.tsx +++ b/apps/web/src/app/popular/ChartGenreFilter.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { STR_TYPE_EMOJI, STR_TYPE_LABEL, StrType } from '@/types/tjChart'; +import { STR_TYPE_LABEL, StrType } from '@/types/tjChart'; // 장르가 12종이라 줄바꿈 없이 한 줄로 두고 가로 스크롤로 훑게 한다. // TabsTrigger 기본 스타일의 flex-1(균등 분할)은 flex-none으로 해제해야 칩 너비가 라벨에 맞는다. @@ -57,7 +57,6 @@ export default function ChartGenreFilter({ value, onChange }: ChartGenreFilterPr {Object.values(StrType).map(type => ( - {STR_TYPE_LABEL[type]} ))} diff --git a/apps/web/src/types/tjChart.ts b/apps/web/src/types/tjChart.ts index 11409d5..72f63e1 100644 --- a/apps/web/src/types/tjChart.ts +++ b/apps/web/src/types/tjChart.ts @@ -32,24 +32,6 @@ export const STR_TYPE_LABEL: Record = { [StrType.RnbUrban]: 'R&B/어반', }; -// 장르 뱃지 앞에 붙는 아이콘. -// Windows의 Segoe UI Emoji에 글리프가 없으면 두부(tofu)로 깨지므로, -// 국기(regional indicator)와 최신 이모지는 쓰지 않는다. 아코디언(U+1FA97)도 여기서 깨진다. -export const STR_TYPE_EMOJI: Record = { - [StrType.All]: '🏆', - [StrType.Kpop]: '🎤', - [StrType.Pop]: '🌍', - [StrType.Jpop]: '🌸', - [StrType.Ballad]: '💗', - [StrType.Dance]: '💃', - [StrType.Trot]: '📻', - [StrType.Folk]: '🎸', - [StrType.Ost]: '🎬', - [StrType.RockMetal]: '🤘', - [StrType.RapHiphop]: '🔥', - [StrType.RnbUrban]: '🎷', -}; - export interface TjChartRankingSong extends Song { rank: number; } From a28d03b9d2e3d0bf3e5fbae074aa3d5703f8f835 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 29 Aug 2026 01:26:03 +0900 Subject: [PATCH 06/13] =?UTF-8?q?chore=20:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=8B=AC=EC=9D=98=20=EC=95=84=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=88=AC=ED=91=9C=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LrweuC2EWY9Tbee43P62dM --- apps/web/src/app/popular/ArtistVotePanel.tsx | 202 +++++++++---------- 1 file changed, 101 insertions(+), 101 deletions(-) diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx index 95c1972..74ca962 100644 --- a/apps/web/src/app/popular/ArtistVotePanel.tsx +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -137,118 +137,118 @@ export default function ArtistVotePanel() { ); }; - if (!isAuthenticated) { - return ( -
-

로그인하고 투표해보세요

-

- 보유 포인트로 이달의 아티스트를 뽑을 수 있어요. -

-
- ); - } - return ( -
-
- 보유 포인트 {point}P - {changedRows.length > 0 && ( - - {' '} - → 저장 후 {remainingPoint}P - - )} -
- -
- handleChangeQuery(event.target.value)} - onFocus={() => setIsFocusAuto(true)} - onBlur={() => setIsFocusAuto(false)} - /> - {isFocusAuto && ( - - )} -
- - {isSearchEmpty && ( -

- ‘{query.trim()}’ 검색 결과가 없어요. -

- )} +
+ {/* 비로그인이어도 투표 화면이 어떤 모습인지는 그대로 보여주고 안내 문구만 위에 덮는다. + inert는 아래 UI의 클릭·포커스·접근성 트리 노출을 한 번에 막아, 로그인 없이 조작되는 일이 없다. */} +
+
+ 보유 포인트 {point}P + {changedRows.length > 0 && ( + + {' '} + → 저장 후 {remainingPoint}P + + )} +
- -
- {rows.length === 0 ? ( -

- 검색해서 투표할 아티스트를 담아보세요. -

- ) : ( - rows.map(row => ( - handleChange(row.artist, amount)} - onSubmit={handleSubmit} - onDelete={() => handleDelete(row.artist)} - /> - )) +
+ handleChangeQuery(event.target.value)} + onFocus={() => setIsFocusAuto(true)} + onBlur={() => setIsFocusAuto(false)} + /> + {isFocusAuto && ( + )}
- -
- {isOverPoint && ( -

- 포인트가 {-remainingPoint}P 부족해요. 투표값을 줄여주세요. + {isSearchEmpty && ( +

+ ‘{query.trim()}’ 검색 결과가 없어요.

)} - -
- - - - 투표를 반영할까요? - - 저장하면 차액만큼 포인트가 차감되거나 환불돼요. 이번 달에는 언제든 다시 조정할 수 - 있어요. - - - -
- {changedRows.map(row => ( -
- - - {row.savedAmount}P → {row.amount}P - -
- ))} + +
+ {rows.length === 0 ? ( +

+ 검색해서 투표할 아티스트를 담아보세요. +

+ ) : ( + rows.map(row => ( + handleChange(row.artist, amount)} + onSubmit={handleSubmit} + onDelete={() => handleDelete(row.artist)} + /> + )) + )}
+
-
- {spending >= 0 ? '차감 포인트' : '환불 포인트'} - {Math.abs(spending)}P -
+
+ {isOverPoint && ( +

+ 포인트가 {-remainingPoint}P 부족해요. 투표값을 줄여주세요. +

+ )} + +
+ + + + + 투표를 반영할까요? + + 저장하면 차액만큼 포인트가 차감되거나 환불돼요. 이번 달에는 언제든 다시 조정할 수 + 있어요. + + + +
+ {changedRows.map(row => ( +
+ + + {row.savedAmount}P → {row.amount}P + +
+ ))} +
+ +
+ {spending >= 0 ? '차감 포인트' : '환불 포인트'} + {Math.abs(spending)}P +
+ + + + + +
+
+
- - - - -
-
+ {!isAuthenticated && ( +
+

로그인하면 참여할 수 있어요

+

이 달의 아티스트를 직접 뽑아주세요.

+
+ )}
); } From 80ea61d1abe710f8bbcd6d20dae28e66fdb22730 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sat, 29 Aug 2026 01:26:04 +0900 Subject: [PATCH 07/13] =?UTF-8?q?chore=20:=20sitemap=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LrweuC2EWY9Tbee43P62dM --- apps/web/public/sitemap-0.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index d99207b..9a8cc02 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ -https://www.singcode.kr/manifest.webmanifest2026-08-25T16:04:17.180Zweekly0.7 -https://www.singcode.kr/patch-notes2026-08-25T16:04:17.182Zweekly0.7 -https://www.singcode.kr2026-08-25T16:04:17.182Zweekly0.7 +https://www.singcode.kr/manifest.webmanifest2026-08-28T16:24:56.791Zweekly0.7 +https://www.singcode.kr/patch-notes2026-08-28T16:24:56.793Zweekly0.7 +https://www.singcode.kr2026-08-28T16:24:56.794Zweekly0.7 \ No newline at end of file From ee9998817e6044d5879fb68e255b271f1d4f457a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 01:11:18 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor=20:=20=EC=B6=94=EC=B2=9C(thumb)?= =?UTF-8?q?=20=EC=A3=BD=EC=9D=80=20=EC=BD=94=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EC=A1=B0=EC=9D=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #295에서 검색 화면의 추천 진입점이 사라진 뒤 ThumbUpModal이 어디서도 import되지 않는 고아 컴포넌트로 남아 있었다. 화면 로직은 이미 없었지만 api/search가 여전히 thumb_logs(*)를 select 절에 조인하고 있어, 테이블만 지우면 PGRST200으로 검색 전체가 깨지는 상태였다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VS4JWxSV5DBwcgB9zQfY95 --- apps/web/src/app/api/search/route.ts | 10 +- apps/web/src/app/api/songs/thumb-up/route.ts | 103 ------------- apps/web/src/components/ThumbUpModal.tsx | 146 ------------------- apps/web/src/constants/tourDemoSong.ts | 1 - apps/web/src/lib/api/thumbSong.ts | 14 -- apps/web/src/queries/songThumbQuery.ts | 34 ----- apps/web/src/types/song.ts | 5 - 7 files changed, 2 insertions(+), 311 deletions(-) delete mode 100644 apps/web/src/app/api/songs/thumb-up/route.ts delete mode 100644 apps/web/src/components/ThumbUpModal.tsx delete mode 100644 apps/web/src/lib/api/thumbSong.ts delete mode 100644 apps/web/src/queries/songThumbQuery.ts diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts index a662a57..43fdab3 100644 --- a/apps/web/src/app/api/search/route.ts +++ b/apps/web/src/app/api/search/route.ts @@ -6,11 +6,6 @@ import { SearchSong, Song } from '@/types/song'; import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; interface DBSong extends Song { - thumb_logs: - | { - thumb_count: number; - }[] - | null; tosings: { user_id: string; }[]; @@ -235,8 +230,8 @@ export async function GET(request: Request): Promise save.user_id === userId) ?? false) : false, - thumb: song.thumb_logs?.reduce((sum, log) => sum + log.thumb_count, 0) ?? 0, })); return NextResponse.json({ diff --git a/apps/web/src/app/api/songs/thumb-up/route.ts b/apps/web/src/app/api/songs/thumb-up/route.ts deleted file mode 100644 index db5d6a6..0000000 --- a/apps/web/src/app/api/songs/thumb-up/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NextResponse } from 'next/server'; - -import createClient from '@/lib/supabase/server'; -import { ApiResponse } from '@/types/apiRoute'; -import { Song } from '@/types/song'; -import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; - -interface ThumbUpSong extends Song { - thumb_count: number; -} - -export async function GET(): Promise>> { - try { - const supabase = await createClient(); - - // 1) thumb_logs 전체 조회 - const { data: thumbData, error: thumbError } = await supabase - .from('thumb_logs') - .select('song_id, thumb_count'); - - if (thumbError) throw thumbError; - if (!thumbData || thumbData.length === 0) { - return NextResponse.json({ success: true, data: [] }); - } - - // 2) 앱에서 song_id별 합계 집계 - const thumbMap = new Map(); - for (const row of thumbData) { - thumbMap.set(row.song_id, (thumbMap.get(row.song_id) ?? 0) + row.thumb_count); - } - - // 3) 상위 50개 song_id 추출 - const sorted = [...thumbMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50); - - const songIds = sorted.map(([songId]) => songId); - - // 4) 해당 song 상세 정보 조회 - const { data: songs, error: songError } = await supabase - .from('songs') - .select('*') - .in('id', songIds); - - if (songError) throw songError; - - // 5) 병합 후 thumb_count 내림차순 정렬 - const songMap = new Map(songs?.map(song => [song.id, song])); - const data = sorted - .filter(([songId]) => songMap.has(songId)) - .map(([songId, thumbCount]) => ({ - ...songMap.get(songId)!, - thumb_count: thumbCount, - })); - - return NextResponse.json({ success: true, data }); - } catch (error) { - if (error instanceof Error && error.cause === 'auth') { - return NextResponse.json( - { - success: false, - error: 'User not authenticated', - }, - { status: 401 }, - ); - } - console.error('Error in thumb-up API:', error); - return NextResponse.json( - { success: false, error: 'Failed to get thumb-up songs' }, - { status: 500 }, - ); - } -} - -export async function POST(request: Request): Promise>> { - try { - const supabase = await createClient(); - const userId = await getAuthenticatedUser(supabase); - - const { point, songId } = await request.json(); - - const { error: insertError } = await supabase - .from('thumb_logs') - .insert({ song_id: songId, user_id: userId, thumb_count: point }); - - if (insertError) throw insertError; - - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof Error && error.cause === 'auth') { - return NextResponse.json( - { - success: false, - error: 'User not authenticated', - }, - { status: 401 }, - ); - } - console.error('Error in thumb-up API:', error); - return NextResponse.json( - { success: false, error: 'Failed to post thumb-up song' }, - { status: 500 }, - ); - } -} diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx deleted file mode 100644 index b8008b4..0000000 --- a/apps/web/src/components/ThumbUpModal.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import CountUp from '@/components/reactBits/CountUp'; -import GradientText from '@/components/reactBits/GradientText'; -import SplitText from '@/components/reactBits/SplitText'; -import { Button } from '@/components/ui/button'; -import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Slider } from '@/components/ui/slider'; -import { useSongThumbMutation } from '@/queries/songThumbQuery'; -import { useUserQuery } from '@/queries/userQuery'; -import { usePatchSetPointMutation } from '@/queries/userQuery'; -import useFooterAnimateStore from '@/stores/useFooterAnimateStore'; - -import FallingIcons from './FallingIcons'; - -interface ThumbUpModalProps { - songId: string; - title: string; - artist: string; - title_ko?: string; - artist_ko?: string; - thumb: number; - handleClose: () => void; -} - -export default function ThumbUpModal({ - songId, - title, - artist, - title_ko, - artist_ko, - thumb, - handleClose, -}: ThumbUpModalProps) { - const [value, setValue] = useState([0]); - - const { data: user } = useUserQuery(); - - const point = user?.point ?? 0; - - const { mutate: patchSongThumb, isPending: isPendingSongThumb } = useSongThumbMutation(); - const { mutate: patchSetPoint, isPending: isPendingSetPoint } = usePatchSetPointMutation(); - - const { setFooterAnimateKey } = useFooterAnimateStore(); - - const handleClickThumb = () => { - patchSongThumb({ songId, point: value[0] }); - patchSetPoint({ - amount: -value[0], - description: `노래 추천: ${title} - ${artist}`, - }); - - setFooterAnimateKey('POPULAR'); - - handleClose(); - }; - - const isPending = isPendingSongThumb || isPendingSetPoint; - - return ( -
- - 노래 추천하기 - - - - -
-
- {/* 레이블 추가로 가독성 향상 */} - - Total Points - - -
- - - - -
- - + - - -
-
-
- - - -
- { - const parsed = Number(e.target.value); - if (isNaN(parsed)) return; - const clamped = Math.min(Math.max(parsed, 0), point); - setValue([clamped]); - }} - className="text-center text-lg font-bold" - /> - P -
- -
- -
-
{title}
- {title_ko && title_ko !== title && ( -
{title_ko}
- )} -
{artist}
- {artist_ko && artist_ko !== artist && ( -
{artist_ko}
- )} -
-
- - -
-
- ); -} diff --git a/apps/web/src/constants/tourDemoSong.ts b/apps/web/src/constants/tourDemoSong.ts index 139bbe5..f6b7f91 100644 --- a/apps/web/src/constants/tourDemoSong.ts +++ b/apps/web/src/constants/tourDemoSong.ts @@ -15,7 +15,6 @@ export const TOUR_DEMO_SONG: SearchSong = { num_tj: '10101', num_ky: '20202', badges: [], - thumb: 0, isToSing: false, isLike: false, isSave: false, diff --git a/apps/web/src/lib/api/thumbSong.ts b/apps/web/src/lib/api/thumbSong.ts deleted file mode 100644 index 6e441f4..0000000 --- a/apps/web/src/lib/api/thumbSong.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiResponse } from '@/types/apiRoute'; -import { ThumbUpSong } from '@/types/song'; - -import { instance } from './client'; - -export async function getSongThumbList() { - const response = await instance.get>('/songs/thumb-up'); - return response.data; -} - -export async function postSongThumb(body: { songId: string; point: number }) { - const response = await instance.post>('/songs/thumb-up', body); - return response.data; -} diff --git a/apps/web/src/queries/songThumbQuery.ts b/apps/web/src/queries/songThumbQuery.ts deleted file mode 100644 index 3bbfd74..0000000 --- a/apps/web/src/queries/songThumbQuery.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { getSongThumbList, postSongThumb } from '@/lib/api/thumbSong'; - -export const useSongThumbQuery = () => { - return useQuery({ - queryKey: ['songThumb'], - queryFn: async () => { - const response = await getSongThumbList(); - - if (!response.success) { - return []; - } - return response.data; - }, - staleTime: 0, // 데이터를 받자마자 "상한 것(Stale)"으로 취급 -> 다시 조회할 명분 생성 - gcTime: 0, // (구 cacheTime) 언마운트 되는 즉시 메모리에서 삭제 -> 캐시가 없으니 무조건 새로 요청 }); - }); -}; - -export const useSongThumbMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (body: { songId: string; point: number }) => postSongThumb(body), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['songThumb'] }); - queryClient.invalidateQueries({ queryKey: ['searchSong'] }); - }, - onError: error => { - console.error('error', error); - alert(error.message ?? 'POST 실패'); - }, - }); -}; diff --git a/apps/web/src/types/song.ts b/apps/web/src/types/song.ts index a9629d7..895cb16 100644 --- a/apps/web/src/types/song.ts +++ b/apps/web/src/types/song.ts @@ -44,7 +44,6 @@ export interface Song { // 값: 'MV' | 'MR' | 'LV' | '60' (packages/crawling 과 의도적으로 중복 정의) badges?: string[] | null; - thumb?: number; release?: string; created_at?: string; } @@ -110,7 +109,3 @@ export interface AddListModalSong extends Song { song_id: string; user_id: string; } - -export interface ThumbUpSong extends Song { - thumb_count: number; -} From 03dc0746fd2e9bcbbd46e7ba7129c48032934314 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 01:11:27 +0900 Subject: [PATCH 09/13] =?UTF-8?q?chore=20:=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=9E=94=EC=9E=AC=20=EB=B0=8F=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=A0=9C=EA=B1=B0=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 태그 기능을 걷어낼 때 삭제 대신 전체 주석처리만 해뒀던 스크립트와 전용 유틸·DB 헬퍼를 마저 지웠다. removeDeadTjSongs는 유일하게 살아 있는 song_tags 참조였다 — 백업·삭제 블록을 제거해 백업 JSON이 { songs }만 남는다. translateJpnToKo는 translationJpn 전용이라 함께 고아가 됐다. openai 의존성은 validateSongMatch가 계속 쓰므로 유지한다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VS4JWxSV5DBwcgB9zQfY95 --- .github/workflows/tagging_song.yml | 44 ----- .github/workflows/translation_jpn.yml | 44 ----- packages/crawling/package.json | 2 - .../crawling/src/cron/removeDeadTjSongs.ts | 27 +-- packages/crawling/src/cron/taggingSongs.ts | 65 ------- packages/crawling/src/cron/translationJpn.ts | 118 ------------ packages/crawling/src/supabase/deleteDB.ts | 5 - packages/crawling/src/supabase/getDB.ts | 48 ----- packages/crawling/src/supabase/postDB.ts | 12 -- packages/crawling/src/utils/getSongFullTag.ts | 171 ------------------ packages/crawling/src/utils/getSongTag.ts | 122 ------------- .../crawling/src/utils/translateJpnToKo.ts | 54 ------ 12 files changed, 4 insertions(+), 708 deletions(-) delete mode 100644 .github/workflows/tagging_song.yml delete mode 100644 .github/workflows/translation_jpn.yml delete mode 100644 packages/crawling/src/cron/taggingSongs.ts delete mode 100644 packages/crawling/src/cron/translationJpn.ts delete mode 100644 packages/crawling/src/utils/getSongFullTag.ts delete mode 100644 packages/crawling/src/utils/getSongTag.ts delete mode 100644 packages/crawling/src/utils/translateJpnToKo.ts diff --git a/.github/workflows/tagging_song.yml b/.github/workflows/tagging_song.yml deleted file mode 100644 index efb142c..0000000 --- a/.github/workflows/tagging_song.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Tagging Songs - -on: - # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. - # schedule: - # - cron: "0 7 * * *" # 매일 한국 시간 16:00 실행 (UTC+9 → UTC 7:00) - workflow_dispatch: - -permissions: - contents: write # push 권한을 위해 필요 - -jobs: - run-npm-task: - runs-on: ubuntu-latest - - steps: - - name: Checkout branch - uses: actions/checkout@v4 - - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 - run_install: false - - - name: Install dependencies - working-directory: packages/crawling - run: pnpm install - - - name: Create .env file - working-directory: packages/crawling - run: | - echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env - echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env - echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env - - - name: run tagging script - taggingSongs.ts - working-directory: packages/crawling - run: pnpm run tag-songs diff --git a/.github/workflows/translation_jpn.yml b/.github/workflows/translation_jpn.yml deleted file mode 100644 index e610962..0000000 --- a/.github/workflows/translation_jpn.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Translation J-POP Songs - -on: - # 태그 기능 비활성화로 스케줄 실행 중단. 필요하면 workflow_dispatch로 수동 실행. - # schedule: - # - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) - workflow_dispatch: - -permissions: - contents: write - -jobs: - run-npm-task: - runs-on: ubuntu-latest - - steps: - - name: Checkout branch - uses: actions/checkout@v4 - - - name: Use Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 - run_install: false - - - name: Install dependencies - working-directory: packages/crawling - run: pnpm install - - - name: Create .env file - working-directory: packages/crawling - run: | - echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env - echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env - echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env - - - name: Run translation script - translationJpn.ts - working-directory: packages/crawling - run: pnpm run trans-jpn diff --git a/packages/crawling/package.json b/packages/crawling/package.json index f0d54d1..a2f1650 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -12,8 +12,6 @@ "ky-verify": "tsx src/cron/crawlYoutubeVerify.ts", "ky-update": "pnpm run ky-youtube & pnpm run ky-verify", "recent-tj": "tsx src/cron/crawlRecentTJ.ts", - "tag-songs": "tsx src/cron/taggingSongs.ts", - "trans-jpn": "tsx src/cron/translationJpn.ts", "tj-all-number": "tsx src/cron/crawlAllTJSongByNumber.ts", "tj-badges": "tsx src/cron/crawlTjBadges.ts", "remove-dead-songs": "tsx src/cron/removeDeadTjSongs.ts", diff --git a/packages/crawling/src/cron/removeDeadTjSongs.ts b/packages/crawling/src/cron/removeDeadTjSongs.ts index f66c720..613a8af 100644 --- a/packages/crawling/src/cron/removeDeadTjSongs.ts +++ b/packages/crawling/src/cron/removeDeadTjSongs.ts @@ -2,11 +2,7 @@ import dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; -import { - deleteKyLogsBySongIdsDB, - deleteSongTagsBySongIdsDB, - deleteSongsByIdsDB, -} from '@/supabase/deleteDB'; +import { deleteKyLogsBySongIdsDB, deleteSongsByIdsDB } from '@/supabase/deleteDB'; import { getClient } from '@/supabase/getClient'; import { updateSongBadgesDB } from '@/supabase/updateDB'; import { fetchBadgeRow } from '@/utils/tjBadge'; @@ -29,7 +25,7 @@ dotenv.config(); */ const APPLY = process.env.DEAD_SONGS_APPLY === 'true'; // 실행마다 다른 파일에 쓴다. 삭제 도중 실패해 재실행할 때 앞선 백업을 덮어쓰면 -// 이미 지워진 참조 테이블(song_tags 등)의 기록이 사라진다. +// 이미 지워진 참조 테이블(invalid_ky_songs 등)의 기록이 사라진다. const BACKUP_FILE = path.join( 'src', 'assets', @@ -119,25 +115,10 @@ if (targets.length === 0) { const ids = targets.map(song => song.id as string); // 삭제 전 백업. 되돌릴 일이 생기면 이 파일로 복구한다. -// in() 필터는 쿼리스트링에 실리므로 id를 100개씩 나눠 모은다. -const tagRows: { song_id: string; tag_id: number }[] = []; -for (let i = 0; i < ids.length; i += 100) { - const { data } = await supabase - .from('song_tags') - .select('song_id, tag_id') - .in('song_id', ids.slice(i, i + 100)); - if (data) tagRows.push(...(data as { song_id: string; tag_id: number }[])); -} - -fs.writeFileSync(BACKUP_FILE, JSON.stringify({ songs: targets, songTags: tagRows }, null, 1)); -console.log(`💾 백업 저장: ${BACKUP_FILE} (곡 ${targets.length} / 태그 ${tagRows.length})`); +fs.writeFileSync(BACKUP_FILE, JSON.stringify({ songs: targets }, null, 1)); +console.log(`💾 백업 저장: ${BACKUP_FILE} (곡 ${targets.length})`); // songs를 참조하는 테이블을 먼저 비운다. -const tagResult = await deleteSongTagsBySongIdsDB(ids); -console.log( - `🏷️ song_tags 삭제: ${tagResult.deleted}건${tagResult.failed ? `, 실패 ${tagResult.failed}` : ''}`, -); - const kyResult = await deleteKyLogsBySongIdsDB(ids); console.log( `🧾 invalid_ky_songs ${kyResult.invalid.deleted}건 / verify_ky_songs ${kyResult.verified.deleted}건 삭제`, diff --git a/packages/crawling/src/cron/taggingSongs.ts b/packages/crawling/src/cron/taggingSongs.ts deleted file mode 100644 index 03c0f80..0000000 --- a/packages/crawling/src/cron/taggingSongs.ts +++ /dev/null @@ -1,65 +0,0 @@ -// 태그 기능 비활성화로 전체 주석처리. tags/song_tags 테이블 자체는 남아있지만 -// 이 스크립트로 새로 태그를 등록하지는 않는다. -// -// import { getSongTagSongIdsDB, getSongsAllDB } from '@/supabase/getDB'; -// import { postSongTagsDB } from '@/supabase/postDB'; -// import { autoTagSong, getTagsForPrompt } from '@/utils/getSongTag'; -// -// const resultsLog = { -// success: 0, -// failed: 0, -// skipped: 0, -// }; -// -// // 1. 전체 곡 조회 + 이미 태그된 곡 ID + 태그 프롬프트 로드 -// const [allSongs, taggedSongIds, tagsPrompt] = await Promise.all([ -// getSongsAllDB(), -// getSongTagSongIdsDB(), -// getTagsForPrompt(), -// ]); -// -// console.log('전체 곡 수:', allSongs.length); -// console.log('이미 태그된 곡 수:', taggedSongIds.size); -// -// let processedCount = 0; -// for (const song of allSongs) { -// if (processedCount >= 20000) break; -// if (taggedSongIds.has(song.id)) { -// resultsLog.skipped++; -// continue; -// } -// -// try { -// const tagId = await autoTagSong(song.title, song.artist, tagsPrompt); -// -// if (tagId === null) { -// resultsLog.failed++; -// console.log(`[FAIL] ${song.title} - ${song.artist}: 태그 없음`); -// continue; -// } -// -// const success = await postSongTagsDB(song.id, [tagId]); -// if (success) { -// resultsLog.success++; -// console.log(`[OK] ${song.title} - ${song.artist}: [${tagId}]`); -// } else { -// resultsLog.failed++; -// } -// } catch (error) { -// resultsLog.failed++; -// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); -// } -// -// processedCount++; -// -// // OpenAI rate limit 대비 딜레이 -// await new Promise(resolve => setTimeout(resolve, 200)); -// } -// -// // 3. 결과 출력 -// console.log(` -// 총 ${allSongs.length}곡 중: -// - 스킵 (이미 태그됨): ${resultsLog.skipped}곡 -// - 성공: ${resultsLog.success}곡 -// - 실패: ${resultsLog.failed}곡 -// `); diff --git a/packages/crawling/src/cron/translationJpn.ts b/packages/crawling/src/cron/translationJpn.ts deleted file mode 100644 index feba875..0000000 --- a/packages/crawling/src/cron/translationJpn.ts +++ /dev/null @@ -1,118 +0,0 @@ -// 태그 기능 비활성화로 전체 주석처리. 이 스크립트는 song_tags.tag_id=101(일본어)로 -// J-POP 곡을 찾는데, 태그 기능을 더 이상 쓰지 않으므로 함께 멈춘다. -// -// import { artistAlias } from '@repo/constants'; -// -// import { getArtistKoMapDB, getJpopSongsForTranslationDB } from '@/supabase/getDB'; -// import { updateSongKoTranslationDB } from '@/supabase/updateDB'; -// import { translateJpnToKo } from '@/utils/translateJpnToKo'; -// -// const resultsLog = { -// success: 0, -// failed: 0, -// skipped: 0, -// usedAlias: 0, -// usedDbArtist: 0, -// }; -// -// // 히라가나, 카타카나, CJK 한자 범위로 일본어 포함 여부 판단 -// const JAPANESE_REGEX = /[぀-ゟ゠-ヿ＀-゚一-龯㐀-䶿]/; -// -// function containsJapanese(text: string): boolean { -// return JAPANESE_REGEX.test(text); -// } -// -// // artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 -// const artistAliasMap = new Map( -// Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), -// ); -// -// const songs = await getJpopSongsForTranslationDB(); -// -// console.log('J-POP 곡 수:', songs.length); -// -// // DB 에 이미 번역된 artist → artist_ko 맵 (동일 아티스트 번역 일관성 유지 목적) -// // DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으며, 번역 성공 시 런타임에도 동기화한다 -// const dbArtistKoMap = await getArtistKoMapDB(); -// -// let processedCount = 0; -// for (const song of songs) { -// if (processedCount >= 10000) break; -// -// // 이미 번역된 곡 스킵 -// if (song.title_ko && song.artist_ko) { -// resultsLog.skipped++; -// continue; -// } -// -// // 일본어가 포함되지 않은 곡 스킵 -// // 영어로만 이루어진 경우 번역하지 않음 -// if (!containsJapanese(song.title) && !containsJapanese(song.artist)) { -// resultsLog.skipped++; -// continue; -// } -// -// try { -// const result = await translateJpnToKo(song.title, song.artist); -// -// if (!result) { -// resultsLog.failed++; -// console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); -// continue; -// } -// -// // artist_ko 우선순위: -// // 1) artistAlias (수동 큐레이션된 고정 값) -// // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) -// // 3) AI 번역 결과 -// // title_ko 는 AI 번역 결과를 그대로 사용 -// const aliasArtistKo = artistAliasMap.get(song.artist); -// const dbArtistKo = dbArtistKoMap.get(song.artist); -// const finalArtistKo = aliasArtistKo ?? dbArtistKo ?? result.artist_ko; -// -// const success = await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); -// if (!success) { -// resultsLog.failed++; -// continue; -// } -// -// let logPrefix: string; -// if (aliasArtistKo) { -// resultsLog.usedAlias++; -// logPrefix = '[ALIAS]'; -// } else if (dbArtistKo) { -// resultsLog.usedDbArtist++; -// logPrefix = '[DB]'; -// } else { -// resultsLog.success++; -// logPrefix = '[OK]'; -// } -// -// // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) -// if (!dbArtistKoMap.has(song.artist)) { -// dbArtistKoMap.set(song.artist, finalArtistKo); -// } -// -// console.log( -// `${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, -// ); -// } catch (error) { -// resultsLog.failed++; -// console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); -// } -// -// processedCount++; -// -// // OpenAI rate limit 대비 딜레이 -// await new Promise(resolve => setTimeout(resolve, 200)); -// } -// -// // 결과 출력 -// console.log(` -// 총 ${songs.length}곡 중: -// - 스킵 (이미 번역됨): ${resultsLog.skipped}곡 -// - 성공 (AI 번역): ${resultsLog.success}곡 -// - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 -// - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 -// - 실패: ${resultsLog.failed}곡 -// `); diff --git a/packages/crawling/src/supabase/deleteDB.ts b/packages/crawling/src/supabase/deleteDB.ts index c7ede80..7d00ba7 100644 --- a/packages/crawling/src/supabase/deleteDB.ts +++ b/packages/crawling/src/supabase/deleteDB.ts @@ -22,11 +22,6 @@ async function deleteByIds(table: string, column: string, ids: string[]) { return { deleted, failed: 0 }; } -/** song_tags는 songs를 참조하므로 곡보다 먼저 지워야 한다. */ -export async function deleteSongTagsBySongIdsDB(songIds: string[]) { - return deleteByIds('song_tags', 'song_id', songIds); -} - // invalid_ky_songs / verify_ky_songs는 별도 song_id 컬럼 없이 // PK인 id가 곧 songs.id를 참조한다 (FK: invalid_ky_song_id_fkey). export async function deleteKyLogsBySongIdsDB(songIds: string[]) { diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index 09977d9..f466126 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -116,44 +116,6 @@ export async function getSongsAllWithTjDB(max: number = 100000) { return data; } -export async function getJpopSongsForTranslationDB() { - const supabase = getClient(); - - const { data, error } = await supabase - .from('songs') - .select('id, title, artist, title_ko, artist_ko, song_tags!inner(tag_id)') - .eq('song_tags.tag_id', 101) - .limit(100000); - - if (error) throw error; - - return data; -} - -// J-POP 곡 중 이미 번역된 artist → artist_ko 맵 -// DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으므로 먼저 만난 값을 사용 -export async function getArtistKoMapDB(): Promise> { - const supabase = getClient(); - - const { data, error } = await supabase - .from('songs') - .select('artist, artist_ko, song_tags!inner(tag_id)') - .eq('song_tags.tag_id', 101) - .not('artist_ko', 'is', null) - .limit(100000); - - if (error) throw error; - - const map = new Map(); - for (const row of data) { - if (!row.artist || !row.artist_ko) continue; - if (!map.has(row.artist)) { - map.set(row.artist, row.artist_ko); - } - } - return map; -} - // 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록되었거나 수정된 곡만 // (월간 증분 갱신 — updated_at도 봐야 기존 곡의 artist 오타 수정 같은 걸 놓치지 않는다), // 없으면 전체 곡을 대상으로 한다(최초 백필). @@ -176,16 +138,6 @@ export async function getSongsForArtistBackfillDB( return data as ArtistBackfillSongRow[]; } -export async function getSongTagSongIdsDB(): Promise> { - const supabase = getClient(); - - const { data, error } = await supabase.from('song_tags').select('song_id').limit(100000); - - if (error) throw error; - - return new Set(data.map(row => row.song_id)); -} - // 뱃지를 아직 수집하지 않은 곡을 청크 단위로 조회한다. // badges가 null이면 미수집, 빈 배열이면 "수집했으나 뱃지가 없는 곡"이라 둘을 구분해야 한다. // 이 조건 자체가 재개 지점 역할을 하므로 별도 체크포인트 파일이 필요 없다. diff --git a/packages/crawling/src/supabase/postDB.ts b/packages/crawling/src/supabase/postDB.ts index 53cfcf4..00e0655 100644 --- a/packages/crawling/src/supabase/postDB.ts +++ b/packages/crawling/src/supabase/postDB.ts @@ -82,18 +82,6 @@ export async function postVerifyKySongsDB(song: Song) { } } -export async function postSongTagsDB(songId: string, tagIds: number[]) { - const supabase = getClient(); - const rows = tagIds.map(tagId => ({ song_id: songId, tag_id: tagId })); - - const { error } = await supabase.from('song_tags').insert(rows); - if (error) { - console.error('postSongTagsDB error:', error); - return false; - } - return true; -} - export async function postTjChartRankingsDB(rows: TjChartRankingInsert[]) { const supabase = getClient(); diff --git a/packages/crawling/src/utils/getSongFullTag.ts b/packages/crawling/src/utils/getSongFullTag.ts deleted file mode 100644 index 1eac0f3..0000000 --- a/packages/crawling/src/utils/getSongFullTag.ts +++ /dev/null @@ -1,171 +0,0 @@ -import dotenv from 'dotenv'; -import OpenAI from 'openai'; - -import { getClient } from '@/supabase/getClient'; - -dotenv.config(); - -const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); - -// 태그 카테고리별 범위 -const LANGUAGE_TAG_MIN = 100; -const LANGUAGE_TAG_MAX = 200; -const GENRE_TAG_MIN = 200; -const GENRE_TAG_MAX = 300; -const ORIGIN_TAG_MIN = 300; -const ORIGIN_TAG_MAX = 400; - -// 확정 분류용 언어 태그 ID -const TAG_KOREAN = 100; -const TAG_JAPANESE = 101; - -// 태그 정보를 담을 타입 정의 -interface Tag { - id: number; - name: string; - category: string; -} - -interface LLMTagResult { - language: number; - genre: number; - origin: number[]; -} - -/** - * DB에서 태그 목록(100~399)을 읽어와 AI 프롬프트용 텍스트로 변환한다. - */ -export const getTagsForPrompt = async (): Promise => { - const supabase = getClient(); - const { data: tags, error } = await supabase - .from('tags') - .select('id, name, category') - .order('id'); - - if (error) { - console.error('Error fetching tags:', error); - return ''; - } - - return tags.map((tag: Tag) => `${tag.id}: ${tag.name} (${tag.category})`).join('\n'); -}; - -const isValidTagId = (id: number, min: number, max: number) => id >= min && id < max; - -/** - * 같은 아티스트의 기존 태그를 전체(language/genre/origin) 조회한다. - */ -const getExistingArtistTags = async (artist: string) => { - const supabase = getClient(); - const { data } = await supabase - .from('songs') - .select('song_tags!inner(tag_id)') - .eq('artist', artist) - .gte('song_tags.tag_id', LANGUAGE_TAG_MIN) - .lt('song_tags.tag_id', ORIGIN_TAG_MAX) - .limit(1); - - if (!data || data.length === 0) return null; - - const tagIds = (data as { song_tags: { tag_id: number }[] }[])[0].song_tags.map(t => t.tag_id); - - const language = tagIds.find(id => isValidTagId(id, LANGUAGE_TAG_MIN, LANGUAGE_TAG_MAX)); - const genre = tagIds.find(id => isValidTagId(id, GENRE_TAG_MIN, GENRE_TAG_MAX)); - const origin = tagIds.filter(id => isValidTagId(id, ORIGIN_TAG_MIN, ORIGIN_TAG_MAX)); - - // 세 카테고리 모두 있어야 재사용 - if (language && genre && origin.length > 0) { - return [language, genre, ...origin]; - } - return null; -}; - -/** - * LLM으로 language/genre/origin 태그를 한 번에 판별한다. - */ -const getTagsFromLLM = async ( - title: string, - artist: string, - tagsPrompt: string, - knownLanguageTag?: number, -): Promise => { - const languageInstruction = knownLanguageTag - ? `Language tag is already determined as ${knownLanguageTag}. Return it as-is.` - : `Select EXACTLY 1 language tag (100~199).`; - - const response = await client.chat.completions.create({ - model: 'gpt-5.4-mini', - messages: [ - { - role: 'system', - content: ` -You are a music database expert. Tag the given song with: -1. Language (100~199): EXACTLY 1 tag. ${languageInstruction} -2. Genre (200~299): EXACTLY 1 tag. -3. Origin (300~399): 1 to 3 tags. - -[Language Rules] -- K-Pop/Korean artist → 100 (한국노래) -- J-Pop/Japanese artist → 101 (일본노래) -- Western/English-speaking → 102 (팝송) -- Otherwise → 103 (글로벌) - -[Output] -Return JSON: {"language": , "genre": , "origin": [, ...]} -Example: {"language": 100, "genre": 201, "origin": [300, 302]} - -Allowed Tags: -${tagsPrompt} - `, - }, - { - role: 'user', - content: `Title: "${title}", Artist: "${artist}"`, - }, - ], - response_format: { type: 'json_object' }, - temperature: 0, - }); - - const content = response.choices[0].message.content; - if (!content) return null; - - const result: LLMTagResult = JSON.parse(content); - - const languageTag = knownLanguageTag ?? result.language; - if (!isValidTagId(languageTag, LANGUAGE_TAG_MIN, LANGUAGE_TAG_MAX)) return null; - if (!isValidTagId(result.genre, GENRE_TAG_MIN, GENRE_TAG_MAX)) return null; - - const validOrigin = result.origin.filter(id => isValidTagId(id, ORIGIN_TAG_MIN, ORIGIN_TAG_MAX)); - if (validOrigin.length === 0) return null; - - return [languageTag, result.genre, ...validOrigin.slice(0, 3)]; -}; - -export const autoTagSong = async ( - title: string, - artist: string, - tagsPrompt: string, -): Promise => { - try { - if (!tagsPrompt) return null; - - // 1단계: 같은 아티스트의 기존 태그가 모두 있으면 재사용 (API 호출 생략) - const existingTags = await getExistingArtistTags(artist); - if (existingTags) return existingTags; - - // 2단계: 한글/가나 감지로 언어 태그만 사전 결정 - const titleAndArtist = title + artist; - const hasHangul = /[ㄱ-ㅎㅏ-ㅣ가-힣]/.test(titleAndArtist); - const hasKana = /[ぁ-んァ-ヶ]/.test(titleAndArtist); - const knownLanguageTag = hasHangul ? TAG_KOREAN : hasKana ? TAG_JAPANESE : undefined; - - // 3단계: LLM으로 genre + origin (+ 필요 시 language) 판별 - return await getTagsFromLLM(title, artist, tagsPrompt, knownLanguageTag); - } catch (error) { - console.error('Error auto-tagging song:', error); - return null; - } -}; diff --git a/packages/crawling/src/utils/getSongTag.ts b/packages/crawling/src/utils/getSongTag.ts deleted file mode 100644 index acd8bb6..0000000 --- a/packages/crawling/src/utils/getSongTag.ts +++ /dev/null @@ -1,122 +0,0 @@ -import dotenv from 'dotenv'; -import OpenAI from 'openai'; - -import { getClient } from '@/supabase/getClient'; - -dotenv.config(); - -const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); - -// 언어 태그 ID 범위 (100~199) -const LANGUAGE_TAG_MIN = 100; -const LANGUAGE_TAG_MAX = 200; - -// 확정 분류용 언어 태그 ID -const TAG_KOREAN = 100; -const TAG_JAPANESE = 101; - -// 태그 정보를 담을 타입 정의 -interface Tag { - id: number; - name: string; - category: string; -} - -/** - * DB에서 전체 태그 목록을 읽어와 AI 프롬프트용 텍스트로 변환한다. - */ -export const getTagsForPrompt = async (): Promise => { - const supabase = getClient(); - const { data: tags, error } = await supabase - .from('tags') - .select('id, name, category') - .gte('id', LANGUAGE_TAG_MIN) - .lt('id', LANGUAGE_TAG_MAX) - .order('id'); - - if (error) { - console.error('Error fetching tags:', error); - return ''; - } - - // AI가 읽기 편하게 "ID: 이름 (카테고리)" 형식으로 변환 - return tags.map((tag: Tag) => `${tag.id}: ${tag.name} (${tag.category})`).join('\n'); -}; - -export const autoTagSong = async ( - title: string, - artist: string, - tagsPrompt: string, -): Promise => { - try { - if (!tagsPrompt) return null; - - const titleAndArtist = title + artist; - const hasHangul = /[ㄱ-ㅎㅏ-ㅣ가-힣]/.test(titleAndArtist); - const hasKana = /[ぁ-んァ-ヶ]/.test(titleAndArtist); - - // 1단계: 확실한 케이스는 API 호출 없이 바로 분류 - if (hasHangul) return TAG_KOREAN; - if (hasKana) return TAG_JAPANESE; - - // 2단계: 같은 아티스트의 기존 태그가 있으면 재사용 - const supabase = getClient(); - const { data: existingTags } = await supabase - .from('songs') - .select('song_tags!inner(tag_id)') - .eq('artist', artist) - .gte('song_tags.tag_id', LANGUAGE_TAG_MIN) - .lt('song_tags.tag_id', LANGUAGE_TAG_MAX) - .limit(1); - - const existingTagId = (existingTags as { song_tags: { tag_id: number }[] }[] | null)?.[0] - ?.song_tags?.[0]?.tag_id; - if (existingTagId) return existingTagId; - - // 3단계: 영문 전용 곡만 LLM으로 판별 - const response = await client.chat.completions.create({ - model: 'gpt-5.4-mini', - messages: [ - { - role: 'system', - content: ` - You are a music database expert. Select EXACTLY 1 language tag for the given song. - The title and artist are in English/Latin script only. - - [Rules] - - If the artist is a K-Pop/Korean artist: Select 100 (한국노래). - - If the artist is a J-Pop/Japanese artist: Select 101 (일본노래). - - If the artist is from Western/English-speaking regions: Select 102 (팝송). - - Otherwise: Select 103 (글로벌). - - [Output] - Return JSON: {"tag_id": } - Example: {"tag_id": 102} - - Allowed Tags: - ${tagsPrompt} - `, - }, - { - role: 'user', - content: `Title: "${title}", Artist: "${artist}"`, - }, - ], - response_format: { type: 'json_object' }, - temperature: 0, - }); - - const content = response.choices[0].message.content; - if (!content) return null; - - const result: { tag_id: number } = JSON.parse(content); - return result.tag_id >= LANGUAGE_TAG_MIN && result.tag_id < LANGUAGE_TAG_MAX - ? result.tag_id - : null; - } catch (error) { - console.error('Error auto-tagging song:', error); - return null; - } -}; diff --git a/packages/crawling/src/utils/translateJpnToKo.ts b/packages/crawling/src/utils/translateJpnToKo.ts deleted file mode 100644 index 1b4405b..0000000 --- a/packages/crawling/src/utils/translateJpnToKo.ts +++ /dev/null @@ -1,54 +0,0 @@ -import dotenv from 'dotenv'; -import OpenAI from 'openai'; - -dotenv.config(); - -const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); - -interface TranslationResult { - title_ko: string; - artist_ko: string; -} - -export async function translateJpnToKo( - title: string, - artist: string, -): Promise { - try { - const response = await client.chat.completions.create({ - model: 'gpt-5.4-mini', - messages: [ - { - role: 'system', - content: `You are a Japanese music translator specializing in song/artist name translation to Korean. - -Rules: -1. Provide the most common Korean name used in Korean music streaming services (Melon, YouTube Music). -2. Artist Name: Use the standard Korean phonetic transcription (e.g., "Official髭男dism" -> "오피셜히게단디즘", "米津玄師" -> "요네즈 켄시"). Do NOT translate the meaning of artist names. -3. Song Title: - - If there is a widely used Korean translation, use it (e.g., "夜에 駆ける" -> "밤을 달리다"). - - If not, provide the phonetic transcription (e.g., "アイドル" -> "아이돌"). -4. If the input is already in Korean or English and no distinct Korean name exists, return it as-is. -5. Return ONLY JSON: {"title_ko": "...", "artist_ko": "..."}`, - }, - { - role: 'user', - content: `title: "${title}", artist: "${artist}"`, - }, - ], - response_format: { type: 'json_object' }, - temperature: 0, - }); - - const content = response.choices[0].message.content; - if (!content) return null; - - const result: TranslationResult = JSON.parse(content); - return result; - } catch (error) { - console.error('translateJpnToKo error:', error); - return null; - } -} From 706cd5212433ed37b5cd3b4da91828110c25879a Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 01:11:33 +0900 Subject: [PATCH 10/13] =?UTF-8?q?doc=20:=20=ED=83=9C=EA=B7=B8=C2=B7?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B0=8F=20=EB=82=A8=EC=9D=80=20DB=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EA=B8=B0=EB=A1=9D=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드에는 참조가 없어졌으니 Supabase 대시보드에서 tags·song_tags· thumb_logs 테이블과 artists.language_tag_id 컬럼을 지우면 된다. thumb_logs 드롭은 api/search 수정이 배포된 뒤여야 한다는 순서를 남긴다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VS4JWxSV5DBwcgB9zQfY95 --- CLAUDE.md | 8 ++++++-- packages/crawling/CLAUDE.md | 19 +------------------ 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f93438..6f0d091 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,9 +158,13 @@ chore : 버전 2.3.0 (#61) Vercel의 `SUPABASE_SERVICE_ROLE_KEY`가 모두 없어졌다. **확정 로직을 웹으로 되돌리지 말 것** — 공개 엔드포인트가 다시 생긴다. -### 태그 기능 제거 (완료 — 참고용) +### 태그·추천(thumb) 기능 제거 (완료 — 참고용) -검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다: 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조(추후 대시보드에서 컬럼 수동 삭제 예정)를 제거했고, `packages/crawling`의 `taggingSongs.ts`/`translationJpn.ts`는 삭제 대신 전체 주석처리했다. `tagging_song.yml`/`translation_jpn.yml`은 `schedule` 트리거만 비활성화(주석)했고 `workflow_dispatch`로 수동 실행은 여전히 가능하다. `tags`/`song_tags` 테이블 자체는 DB에 남아있지만 앱은 더 이상 참조하지 않는다. +**태그** — 검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다. 1차로 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조를 제거하고 배치 스크립트는 주석처리만 해뒀다가, 2차로 주석 잔재까지 삭제했다: `taggingSongs.ts`/`translationJpn.ts`와 전용 유틸(`getSongTag.ts`, `getSongFullTag.ts`, `translateJpnToKo.ts`), DB 헬퍼(`postSongTagsDB`, `deleteSongTagsBySongIdsDB`, `getSongTagSongIdsDB`, `getJpopSongsForTranslationDB`, `getArtistKoMapDB`), `tag-songs`/`trans-jpn` 스크립트, `tagging_song.yml`/`translation_jpn.yml` 워크플로. + +**추천(thumb)** — `#295`에서 검색 화면의 추천 진입점이 사라진 뒤 `ThumbUpModal`이 아무 데서도 import되지 않는 고아 컴포넌트로 남아 있었다. 화면 로직은 이미 없었지만 `api/search`가 여전히 `thumb_logs(*)`를 select 절에 조인하고 있어, 테이블만 지우면 검색 전체가 `PGRST200`으로 깨지는 상태였다. 조인·모달·훅·API 라우트(`/api/songs/thumb-up`)·타입(`Song.thumb`, `ThumbUpSong`)을 모두 제거했다. + +**남은 일** — 코드에는 참조가 없으니 Supabase 대시보드에서 `tags` · `song_tags` · `thumb_logs` 테이블과 `artists.language_tag_id` 컬럼을 수동 삭제하면 된다. `thumb_logs` 드롭은 **위 `api/search` 수정이 배포된 뒤에** 해야 한다. ## Self-Maintenance diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index c4d6680..cf83b5c 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -106,8 +106,6 @@ findKYByOpen.ts | ------------------ | ------------------------------------------ | | `songs` | 메인 곡 데이터 (TJ/KY 번호 포함) | | `invalid_ky_songs` | KY 번호 수집 실패 목록 | -| `tags` | 태그 마스터 (id, name, category) | -| `song_tags` | 곡-태그 매핑 (song_id, tag_id) | | `verify_ky_songs` | KY 번호 검증 완료 목록 | | `chart_rankings` | TJ 공식 차트 월별/장르별 순위 | | `artists` | 아티스트 마스터 (name, name_ko, image_url) | @@ -115,19 +113,6 @@ findKYByOpen.ts ### AI 유틸 - `utils/validateSongMatch.ts` — `gpt-4o-mini`로 두 (제목, 아티스트) 쌍이 같은 곡인지 판단. `temperature: 0`, 완전 일치 시 API 호출 생략. -- `utils/transChatGPT.ts` — `gpt-4-turbo`로 일본어 → 한국어 번역. -- `utils/translateJpnToKo.ts` — `gpt-5.4-mini`로 J-POP 곡 제목/아티스트 한국어 번역. -- `utils/getSongTag.ts` — 곡에 언어 태그(100~199) 1개를 자동 할당. 한글/가나 감지 시 즉시 분류, 동일 아티스트 태그 재사용, 영문 전용 곡만 `gpt-5.4-mini`로 판별. - -### 곡 태깅 파이프라인 - -``` -taggingSongs.ts - └─ getSongsAllDB() # 전체 곡 조회 - └─ getSongTagSongIdsDB() # 이미 태그된 곡 ID Set 로드 (스킵 처리) - └─ autoTagSong(title, artist, tagsPrompt) # 언어 태그 1개 반환 (한글/가나 → 즉시, 영문 → LLM) - └─ postSongTagsDB(songId, [tagId]) # song_tags 테이블에 insert -``` ### TJ 공식 차트 파이프라인 @@ -176,7 +161,7 @@ crawlTjBadges.ts (pnpm tj-badges) `removeDeadTjSongs.ts`는 후보를 `badges is null`로 추리되 **삭제 직전에 TJ로 다시 조회**한다. 살아 있으면 지우지 않고 뱃지를 채워주고, 조회 자체가 실패하면 건드리지 않는다. 재확인 전에 **차트에 오른 곡**(번호 검색에는 없어도 차트에는 오른다)과 **`num_ky` 보유 곡**(금영으로는 부를 수 있다)은 아예 제외한다. -`songs`를 참조하는 `song_tags` · `invalid_ky_songs` · `verify_ky_songs`를 먼저 지워야 FK 제약에 걸리지 않는다. `invalid_ky_songs`와 `verify_ky_songs`는 별도 `song_id` 컬럼 없이 PK인 `id`가 곧 `songs.id`다. +`songs`를 참조하는 `invalid_ky_songs` · `verify_ky_songs`를 먼저 지워야 FK 제약에 걸리지 않는다. `invalid_ky_songs`와 `verify_ky_songs`는 별도 `song_id` 컬럼 없이 PK인 `id`가 곧 `songs.id`다. ### GitHub Actions 워크플로우 @@ -184,8 +169,6 @@ crawlTjBadges.ts (pnpm tj-badges) | ------------------------------ | ----------------- | ------------------------------------------------------ | | `crawl_recent_tj.yml` | 매일 14:00 | `pnpm recent-tj` | | `crawl_tj_chart.yml` | 매달 1일 01:00 | `pnpm tj-chart` | -| `tagging_song.yml` | 매일 07:00 | `pnpm tag-songs` | -| `translation_jpn.yml` | 매일 10:00 | `pnpm trans-jpn` | | `update_ky_youtube.yml` | 매일 14:00 | `pnpm ky-youtube` | | `verify_ky_youtube.yml` | 매주 월요일 14:00 | `pnpm ky-verify` | | `crawl_tj_all_number.yml` | 수동 전용 | `pnpm tj-all-number` | From fc0b2c2f130e59ec0821dcf0404f143c3980a07b Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 18:55:30 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix=20:=20=EC=9D=BC=EB=B3=B8=EA=B3=A1=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EB=B0=B0=EC=B9=98=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 태그 기능을 걷어내면서 song_tags 조인에 묶여 있던 translationJpn 배치가 함께 사라졌다. title_ko·artist_ko는 검색이 함께 훑는 컬럼이라 신규 유입곡에 계속 채워져야 해 태그 없이 동작하도록 복구한다. - 대상 선정: song_tags.tag_id=101 조인 대신 미번역(title_ko is null) + 가나·한자 포함을 PostgREST match(정규식)로 서버에서 거른다 - 일본곡 판별은 스크립트가 한 번 더 한다 — ① 한글 있으면 한국곡 스킵 ② 가나 있으면 일본곡 ③ 가나가 없으면 이미 번역된 아티스트일 때만 (米津玄師처럼 한자뿐인 이름). ③을 넘기면 한자 범위에 함께 걸린 중국곡이 일본어 프롬프트로 들어가 엉뚱한 표기가 DB에 박힌다 - 워크플로는 삭제 전 주석 처리돼 있던 스케줄을 다시 켠다 (매일 19:00 KST) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XVzzjKPZnXmRL5s34wG8mj --- .github/workflows/translation_jpn.yml | 49 ++++++ packages/crawling/package.json | 1 + packages/crawling/src/cron/translationJpn.ts | 144 ++++++++++++++++++ packages/crawling/src/supabase/getDB.ts | 75 ++++++++- packages/crawling/src/types.ts | 9 ++ .../crawling/src/utils/translateJpnToKo.ts | 54 +++++++ turbo.json | 4 +- 7 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/translation_jpn.yml create mode 100644 packages/crawling/src/cron/translationJpn.ts create mode 100644 packages/crawling/src/utils/translateJpnToKo.ts diff --git a/.github/workflows/translation_jpn.yml b/.github/workflows/translation_jpn.yml new file mode 100644 index 0000000..760e42b --- /dev/null +++ b/.github/workflows/translation_jpn.yml @@ -0,0 +1,49 @@ +name: Translation J-POP Songs + +on: + schedule: + - cron: "0 10 * * *" # 매일 한국 시간 19:00 실행 (UTC+9 → UTC 10:00) + workflow_dispatch: + inputs: + limit: + description: "이번 실행에서 번역할 최대 곡 수 (비워두면 10000)" + required: false + +permissions: + contents: read + +jobs: + run-npm-task: + runs-on: ubuntu-latest + + steps: + - name: Checkout branch + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + run_install: false + + - name: Install dependencies + working-directory: packages/crawling + run: pnpm install + + - name: Create .env file + working-directory: packages/crawling + run: | + echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env + echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env + echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env + + - name: Run translation script - translationJpn.ts + working-directory: packages/crawling + env: + TRANS_JPN_LIMIT: ${{ inputs.limit }} + run: pnpm run trans-jpn diff --git a/packages/crawling/package.json b/packages/crawling/package.json index a2f1650..eda9c43 100644 --- a/packages/crawling/package.json +++ b/packages/crawling/package.json @@ -12,6 +12,7 @@ "ky-verify": "tsx src/cron/crawlYoutubeVerify.ts", "ky-update": "pnpm run ky-youtube & pnpm run ky-verify", "recent-tj": "tsx src/cron/crawlRecentTJ.ts", + "trans-jpn": "tsx src/cron/translationJpn.ts", "tj-all-number": "tsx src/cron/crawlAllTJSongByNumber.ts", "tj-badges": "tsx src/cron/crawlTjBadges.ts", "remove-dead-songs": "tsx src/cron/removeDeadTjSongs.ts", diff --git a/packages/crawling/src/cron/translationJpn.ts b/packages/crawling/src/cron/translationJpn.ts new file mode 100644 index 0000000..140f235 --- /dev/null +++ b/packages/crawling/src/cron/translationJpn.ts @@ -0,0 +1,144 @@ +import { artistAlias } from '@repo/constants'; + +import { getArtistKoMapDB, getJpopCandidateSongsDB } from '@/supabase/getDB'; +import { updateSongKoTranslationDB } from '@/supabase/updateDB'; +import { translateJpnToKo } from '@/utils/translateJpnToKo'; + +/** + * 일본곡의 title_ko / artist_ko 를 채운다. 검색이 두 컬럼을 함께 훑기 때문에 + * ("요네즈 켄시"로 米津玄師가 잡히는 이유) 신규 유입곡에도 계속 돌아야 한다. + * + * 대상 선정이 예전과 다르다. 원래는 song_tags.tag_id=101(일본어 태그)로 골랐는데 + * 태그 기능을 걷어내면서 그 조인이 사라졌다. 지금은 DB에서 가나·한자가 섞인 + * 미번역 곡을 후보로 받아 온 뒤, 아래 세 단계로 일본곡만 남긴다. + * + * 1) 한글이 있으면 한국곡 — 태그 배치(autoTagSong)가 쓰던 첫 규칙 그대로다. + * 2) 가나가 있으면 일본곡 — 확정 신호. + * 3) 가나가 없으면 이미 번역된 아티스트인지 본다. 米津玄師처럼 한자뿐인 이름이 + * 여기서 걸린다. 모르는 아티스트면 건드리지 않는다. + * + * 3단계를 넘기지 않는 게 핵심이다. 한자 범위에는 중국어가 함께 들어와서 + * (刘德华·费玉清 같은 이름이 후보의 대부분이다) 일본어 번역 프롬프트에 그대로 넣으면 + * 엉뚱한 한국어 표기가 DB에 박힌다. + */ + +const resultsLog = { + success: 0, + failed: 0, + skippedKorean: 0, + skippedUnknownLang: 0, + usedAlias: 0, + usedDbArtist: 0, +}; + +// 가나만 본다. 중점(・)과 장음(ー)은 한국곡 제목에도 쓰여 범위에서 뺀다. +const KANA_REGEX = /[ぁ-んァ-ヶ]/; +const HANGUL_REGEX = /[ㄱ-ㅎㅏ-ㅣ가-힣]/; + +// 한 번 실행에서 처리할 곡 수. OpenAI 호출이 곡당 1회라 상한을 둔다. +// 스케줄 실행에서는 워크플로 입력이 빈 문자열로 들어와 Number('')=0 이 되므로 ??가 아니라 ||를 쓴다. +const LIMIT = Number(process.env.TRANS_JPN_LIMIT) || 10000; + +// '1'이면 DB에 쓰지 않고 무엇이 번역될지만 찍는다. 판별 규칙을 손볼 때 쓴다. +const DRY_RUN = process.env.TRANS_JPN_DRY_RUN === '1'; + +// artistAlias 로부터 artist 원어 → 한국어 대표 표기(별명 배열의 0번째) 맵 생성 +const artistAliasMap = new Map( + Object.entries(artistAlias).map(([artist, aliases]) => [artist, aliases[0]]), +); + +const songs = await getJpopCandidateSongsDB(); + +console.log('번역 후보(미번역 + 가나/한자 포함) 곡 수:', songs.length); + +// DB 에 이미 번역된 artist → artist_ko 맵. +// 번역 일관성 유지 + 한자뿐인 아티스트의 일본곡 판별 근거로 함께 쓴다. +const dbArtistKoMap = await getArtistKoMapDB(); + +console.log('이미 번역된 아티스트 수:', dbArtistKoMap.size); + +let processedCount = 0; +for (const song of songs) { + if (processedCount >= LIMIT) break; + + const titleAndArtist = `${song.title} ${song.artist}`; + + // 한글이 섞여 있으면 한국곡으로 본다 (예: 박효신 '戀人(연인)', 그리즐리 '있잖아(あのね.)') + if (HANGUL_REGEX.test(titleAndArtist)) { + resultsLog.skippedKorean++; + continue; + } + + // 가나도 없고 번역 이력도 없는 아티스트면 일본곡이라는 근거가 없다 — 중국곡일 가능성이 높다 + const knownArtistKo = dbArtistKoMap.get(song.artist); + if (!KANA_REGEX.test(titleAndArtist) && !knownArtistKo) { + resultsLog.skippedUnknownLang++; + continue; + } + + try { + const result = await translateJpnToKo(song.title, song.artist); + + if (!result) { + resultsLog.failed++; + console.log(`[FAIL] ${song.title} - ${song.artist}: 번역 실패`); + continue; + } + + // artist_ko 우선순위: + // 1) artistAlias (수동 큐레이션된 고정 값) + // 2) DB 에 이미 번역된 동일 아티스트의 artist_ko (번역 일관성 유지) + // 3) AI 번역 결과 + // title_ko 는 AI 번역 결과를 그대로 사용 + const aliasArtistKo = artistAliasMap.get(song.artist); + const finalArtistKo = aliasArtistKo ?? knownArtistKo ?? result.artist_ko; + + const success = DRY_RUN + ? true + : await updateSongKoTranslationDB(song.id, result.title_ko, finalArtistKo); + if (!success) { + resultsLog.failed++; + continue; + } + + let logPrefix: string; + if (aliasArtistKo) { + resultsLog.usedAlias++; + logPrefix = '[ALIAS]'; + } else if (knownArtistKo) { + resultsLog.usedDbArtist++; + logPrefix = '[DB]'; + } else { + resultsLog.success++; + logPrefix = '[OK]'; + } + + // DB 업데이트 성공 시 런타임 맵도 동기화 (first-seen 원칙 — 기존 값 덮어쓰지 않음) + if (!dbArtistKoMap.has(song.artist)) { + dbArtistKoMap.set(song.artist, finalArtistKo); + } + + console.log( + `${DRY_RUN ? '[DRY]' : ''}${logPrefix} ${song.title} → ${result.title_ko} / ${song.artist} → ${finalArtistKo}`, + ); + } catch (error) { + resultsLog.failed++; + console.error(`[ERROR] ${song.title} - ${song.artist}:`, error); + } + + processedCount++; + + // OpenAI rate limit 대비 딜레이 + await new Promise(resolve => setTimeout(resolve, 200)); +} + +// 결과 출력 +console.log(` + 후보 ${songs.length}곡 중: + - 스킵 (한글 포함 — 한국곡): ${resultsLog.skippedKorean}곡 + - 스킵 (가나 없음 + 번역 이력 없는 아티스트): ${resultsLog.skippedUnknownLang}곡 + - 성공 (AI 번역): ${resultsLog.success}곡 + - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 + - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡 + - 실패: ${resultsLog.failed}곡 +`); diff --git a/packages/crawling/src/supabase/getDB.ts b/packages/crawling/src/supabase/getDB.ts index f466126..a2beb27 100644 --- a/packages/crawling/src/supabase/getDB.ts +++ b/packages/crawling/src/supabase/getDB.ts @@ -1,6 +1,11 @@ import { VoteRow } from '@repo/constants'; -import { ArtistBackfillSongRow, ArtistImageTarget, TransSong } from '@/types'; +import { + ArtistBackfillSongRow, + ArtistImageTarget, + JpopTranslationTarget, + TransSong, +} from '@/types'; import { containsJapanese } from '@/utils/parseString'; import { getClient } from './getClient'; @@ -116,6 +121,74 @@ export async function getSongsAllWithTjDB(max: number = 100000) { return data; } +// PostgREST의 match(정규식) 연산자에 그대로 넘기는 CJK 범위 — 가나와 한자. +// 예전에는 song_tags.tag_id=101(일본어 태그)로 J-POP을 골랐지만 태그 기능을 걷어내면서 +// 그 조인이 사라져, 문자 범위로 후보를 좁히는 방식으로 바꿨다. +const CJK_PATTERN = '[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9faf]'; +const CJK_FILTER = `title.match.${CJK_PATTERN},artist.match.${CJK_PATTERN}`; + +// PostgREST 한 번에 받아오는 행 수. 후보가 수천 건이라 페이지로 끊어 받는다. +const CJK_PAGE_SIZE = 1000; + +/** + * 아직 번역되지 않은(title_ko가 비어 있는) 곡 중 제목이나 아티스트에 가나·한자가 있는 곡. + * + * 한자까지 포함하는 범위라 중국어 곡도 함께 딸려 온다. 실제 일본어 판별은 호출부 + * (translationJpn)가 가나 여부와 기존 번역 이력으로 한 번 더 하므로 여기서는 넓게 가져온다. + */ +export async function getJpopCandidateSongsDB(): Promise { + const supabase = getClient(); + const rows: JpopTranslationTarget[] = []; + + for (let from = 0; ; from += CJK_PAGE_SIZE) { + const { data, error } = await supabase + .from('songs') + .select('id, title, artist, title_ko, artist_ko') + .is('title_ko', null) + .or(CJK_FILTER) + .range(from, from + CJK_PAGE_SIZE - 1); + + if (error) throw error; + + rows.push(...(data as JpopTranslationTarget[])); + if (data.length < CJK_PAGE_SIZE) break; + } + + return rows; +} + +/** + * 이미 번역된 곡에서 artist → artist_ko 맵을 만든다. 아티스트당 먼저 만난 값을 쓴다. + * + * 같은 아티스트 표기를 매번 새로 번역하지 않으려는 게 1차 목적이고, 동시에 + * "이 아티스트는 일본 아티스트"라는 판별 근거로도 쓴다 — 태그가 하던 역할을 대신한다. + * 米津玄師처럼 가나가 하나도 없는 이름은 이 맵에 걸려야 번역 대상으로 잡힌다. + */ +export async function getArtistKoMapDB(): Promise> { + const supabase = getClient(); + const map = new Map(); + + for (let from = 0; ; from += CJK_PAGE_SIZE) { + const { data, error } = await supabase + .from('songs') + .select('artist, artist_ko') + .not('artist_ko', 'is', null) + .or(CJK_FILTER) + .range(from, from + CJK_PAGE_SIZE - 1); + + if (error) throw error; + + for (const row of data) { + if (!row.artist || !row.artist_ko) continue; + if (!map.has(row.artist)) map.set(row.artist, row.artist_ko); + } + + if (data.length < CJK_PAGE_SIZE) break; + } + + return map; +} + // 아티스트 백필용 조회. sinceIso가 있으면 그 시각 이후 등록되었거나 수정된 곡만 // (월간 증분 갱신 — updated_at도 봐야 기존 곡의 artist 오타 수정 같은 걸 놓치지 않는다), // 없으면 전체 곡을 대상으로 한다(최초 백필). diff --git a/packages/crawling/src/types.ts b/packages/crawling/src/types.ts index c1c1bad..5c87bd6 100644 --- a/packages/crawling/src/types.ts +++ b/packages/crawling/src/types.ts @@ -119,3 +119,12 @@ export interface ArtistUpsert { name: string; name_ko: string | null; } + +/** 일본어 번역 후보 곡. title_ko가 비어 있고 제목이나 아티스트에 가나·한자가 섞인 곡이다. */ +export interface JpopTranslationTarget { + id: string; + title: string; + artist: string; + title_ko: string | null; + artist_ko: string | null; +} diff --git a/packages/crawling/src/utils/translateJpnToKo.ts b/packages/crawling/src/utils/translateJpnToKo.ts new file mode 100644 index 0000000..1b4405b --- /dev/null +++ b/packages/crawling/src/utils/translateJpnToKo.ts @@ -0,0 +1,54 @@ +import dotenv from 'dotenv'; +import OpenAI from 'openai'; + +dotenv.config(); + +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +interface TranslationResult { + title_ko: string; + artist_ko: string; +} + +export async function translateJpnToKo( + title: string, + artist: string, +): Promise { + try { + const response = await client.chat.completions.create({ + model: 'gpt-5.4-mini', + messages: [ + { + role: 'system', + content: `You are a Japanese music translator specializing in song/artist name translation to Korean. + +Rules: +1. Provide the most common Korean name used in Korean music streaming services (Melon, YouTube Music). +2. Artist Name: Use the standard Korean phonetic transcription (e.g., "Official髭男dism" -> "오피셜히게단디즘", "米津玄師" -> "요네즈 켄시"). Do NOT translate the meaning of artist names. +3. Song Title: + - If there is a widely used Korean translation, use it (e.g., "夜에 駆ける" -> "밤을 달리다"). + - If not, provide the phonetic transcription (e.g., "アイドル" -> "아이돌"). +4. If the input is already in Korean or English and no distinct Korean name exists, return it as-is. +5. Return ONLY JSON: {"title_ko": "...", "artist_ko": "..."}`, + }, + { + role: 'user', + content: `title: "${title}", artist: "${artist}"`, + }, + ], + response_format: { type: 'json_object' }, + temperature: 0, + }); + + const content = response.choices[0].message.content; + if (!content) return null; + + const result: TranslationResult = JSON.parse(content); + return result; + } catch (error) { + console.error('translateJpnToKo error:', error); + return null; + } +} diff --git a/turbo.json b/turbo.json index 8b4fadb..05381fa 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,9 @@ "ARTIST_IMAGE_LIMIT", "ARTIST_IMAGE_NAMES", "ARTIST_IMAGE_PODIUM", - "ARTIST_IMAGE_DRY_RUN" + "ARTIST_IMAGE_DRY_RUN", + "TRANS_JPN_LIMIT", + "TRANS_JPN_DRY_RUN" ], "tasks": { "build": { From a4d7cb9750f0c0885cc8fe69f131f7d6bbbbea9c Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 18:55:35 +0900 Subject: [PATCH 12/13] =?UTF-8?q?doc=20:=20=EC=9D=BC=EB=B3=B8=EA=B3=A1=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EB=B0=B0=EC=B9=98=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EB=B0=98=EC=98=81=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 태그 제거 기록에서 잘못 적힌 삭제 목록(translationJpn 관련 파일)을 바로잡고, 태그 없이 대상을 고르는 방식과 중국곡 혼입 위험을 남긴다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XVzzjKPZnXmRL5s34wG8mj --- CLAUDE.md | 4 +++- packages/crawling/CLAUDE.md | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f0d091..4902cb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,9 @@ Vercel의 `SUPABASE_SERVICE_ROLE_KEY`가 모두 없어졌다. **확정 로직을 ### 태그·추천(thumb) 기능 제거 (완료 — 참고용) -**태그** — 검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다. 1차로 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조를 제거하고 배치 스크립트는 주석처리만 해뒀다가, 2차로 주석 잔재까지 삭제했다: `taggingSongs.ts`/`translationJpn.ts`와 전용 유틸(`getSongTag.ts`, `getSongFullTag.ts`, `translateJpnToKo.ts`), DB 헬퍼(`postSongTagsDB`, `deleteSongTagsBySongIdsDB`, `getSongTagSongIdsDB`, `getJpopSongsForTranslationDB`, `getArtistKoMapDB`), `tag-songs`/`trans-jpn` 스크립트, `tagging_song.yml`/`translation_jpn.yml` 워크플로. +**태그** — 검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다. 1차로 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조를 제거하고 배치 스크립트는 주석처리만 해뒀다가, 2차로 주석 잔재까지 삭제했다: `taggingSongs.ts`와 전용 유틸(`getSongTag.ts`, `getSongFullTag.ts`), DB 헬퍼(`postSongTagsDB`, `deleteSongTagsBySongIdsDB`, `getSongTagSongIdsDB`), `tag-songs` 스크립트, `tagging_song.yml` 워크플로. + +**일본곡 번역은 유지한다** — 태그를 걷어낼 때 `translationJpn.ts`도 같이 지웠다가 복구했다. `title_ko`·`artist_ko`는 검색이 함께 훑는 컬럼이라("요네즈 켄시"로 米津玄師가 잡히는 이유) 신규 유입곡에 계속 채워져야 한다. 대상 선정만 바뀌었다: `song_tags.tag_id=101` 조인 대신 **미번역(`title_ko is null`) + 가나·한자 포함**을 PostgREST `match`(정규식) 연산자로 서버에서 거르고, 일본곡 판별은 스크립트가 한 번 더 한다 — ① 한글 있으면 한국곡 스킵 ② 가나 있으면 일본곡 ③ 가나가 없으면 이미 번역된 아티스트일 때만(米津玄師처럼 한자뿐인 이름) 번역. ③을 넘기면 안 된다. 한자 범위에 중국곡이 함께 걸려(후보 2,312곡 중 2,120곡이 刘德华·费玉清류다) 일본어 프롬프트에 넣으면 엉뚱한 표기가 DB에 박힌다. **추천(thumb)** — `#295`에서 검색 화면의 추천 진입점이 사라진 뒤 `ThumbUpModal`이 아무 데서도 import되지 않는 고아 컴포넌트로 남아 있었다. 화면 로직은 이미 없었지만 `api/search`가 여전히 `thumb_logs(*)`를 select 절에 조인하고 있어, 테이블만 지우면 검색 전체가 `PGRST200`으로 깨지는 상태였다. 조인·모달·훅·API 라우트(`/api/songs/thumb-up`)·타입(`Song.thumb`, `ThumbUpSong`)을 모두 제거했다. diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md index cf83b5c..444789f 100644 --- a/packages/crawling/CLAUDE.md +++ b/packages/crawling/CLAUDE.md @@ -22,8 +22,7 @@ pnpm tj-chart-backfill # TJ 공식 차트 과거 월 일괄 백필 (기간은 pnpm backfill-artists # songs.artist에서 artists 마스터 테이블 백필 pnpm backfill-artist-images # artists.image_url을 Deezer 공개 API로 채움 (키 불필요) pnpm finalize-artist # 이달의 아티스트 월간 확정 (지난달 투표 집계 → 순위 저장) -pnpm tag-songs # AI 기반 곡 자동 태깅 -pnpm trans-jpn # J-POP 곡 제목/아티스트 한국어 번역 +pnpm trans-jpn # 일본곡 제목/아티스트 한국어 번역 (TRANS_JPN_DRY_RUN=1로 미리보기) pnpm test # vitest 실행 pnpm lint # ESLint pnpm format # Prettier 포맷 From 0f02fbcfa1c5a2349838e9801703e4250289d427 Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Sun, 30 Aug 2026 19:28:35 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix=20:=20=EC=9D=BC=EB=B3=B8=EA=B3=A1=20?= =?UTF-8?q?=ED=8C=90=EB=B3=84=EC=97=90=20artistAlias=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3단계(가나 없음) 게이트가 dbArtistKoMap(번역 이력)만 보고 있어, 수동 큐레이션된 artistAlias 사전에 있는 아티스트라도 이름이 한자뿐이면 통과하지 못했다. 사전은 사람이 직접 고른 J-POP 전용이라 번역 이력보다 강한 신호인데 정작 판별에는 약한 신호만 쓰던 셈이다. 지금 데이터에서는 그렇게 떨어지는 곡이 0건이라 동작이 바뀌지 않는다 (alias 포함/제외 모두 번역 대상 17 · 한국곡 175 · 스킵 2,120으로 동일). 사전에 새 아티스트를 추가했을 때 이름이 한자뿐이라 안 잡히는 일을 막는 변경이다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XVzzjKPZnXmRL5s34wG8mj --- CLAUDE.md | 2 +- packages/crawling/src/cron/translationJpn.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4902cb6..b6b8e7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,7 @@ Vercel의 `SUPABASE_SERVICE_ROLE_KEY`가 모두 없어졌다. **확정 로직을 **태그** — 검색 언어 태그 필터가 오작동해 관련 코드를 전부 걷어냈다. 1차로 프론트(`LanguageTagFilter.tsx` 등)와 `api/search`의 `song_tags` 조인, `artists.language_tag_id` 참조를 제거하고 배치 스크립트는 주석처리만 해뒀다가, 2차로 주석 잔재까지 삭제했다: `taggingSongs.ts`와 전용 유틸(`getSongTag.ts`, `getSongFullTag.ts`), DB 헬퍼(`postSongTagsDB`, `deleteSongTagsBySongIdsDB`, `getSongTagSongIdsDB`), `tag-songs` 스크립트, `tagging_song.yml` 워크플로. -**일본곡 번역은 유지한다** — 태그를 걷어낼 때 `translationJpn.ts`도 같이 지웠다가 복구했다. `title_ko`·`artist_ko`는 검색이 함께 훑는 컬럼이라("요네즈 켄시"로 米津玄師가 잡히는 이유) 신규 유입곡에 계속 채워져야 한다. 대상 선정만 바뀌었다: `song_tags.tag_id=101` 조인 대신 **미번역(`title_ko is null`) + 가나·한자 포함**을 PostgREST `match`(정규식) 연산자로 서버에서 거르고, 일본곡 판별은 스크립트가 한 번 더 한다 — ① 한글 있으면 한국곡 스킵 ② 가나 있으면 일본곡 ③ 가나가 없으면 이미 번역된 아티스트일 때만(米津玄師처럼 한자뿐인 이름) 번역. ③을 넘기면 안 된다. 한자 범위에 중국곡이 함께 걸려(후보 2,312곡 중 2,120곡이 刘德华·费玉清류다) 일본어 프롬프트에 넣으면 엉뚱한 표기가 DB에 박힌다. +**일본곡 번역은 유지한다** — 태그를 걷어낼 때 `translationJpn.ts`도 같이 지웠다가 복구했다. `title_ko`·`artist_ko`는 검색이 함께 훑는 컬럼이라("요네즈 켄시"로 米津玄師가 잡히는 이유) 신규 유입곡에 계속 채워져야 한다. 대상 선정만 바뀌었다: `song_tags.tag_id=101` 조인 대신 **미번역(`title_ko is null`) + 가나·한자 포함**을 PostgREST `match`(정규식) 연산자로 서버에서 거르고, 일본곡 판별은 스크립트가 한 번 더 한다 — ① 한글 있으면 한국곡 스킵 ② 가나 있으면 일본곡 ③ 가나가 없으면 아는 일본 아티스트일 때만(米津玄師처럼 한자뿐인 이름) 번역 — `artistAlias` 사전에 있거나 이미 번역된 곡이 있는 아티스트. ③을 넘기면 안 된다. 한자 범위에 중국곡이 함께 걸려(후보 2,312곡 중 2,120곡이 刘德华·费玉清류다) 일본어 프롬프트에 넣으면 엉뚱한 표기가 DB에 박힌다. **추천(thumb)** — `#295`에서 검색 화면의 추천 진입점이 사라진 뒤 `ThumbUpModal`이 아무 데서도 import되지 않는 고아 컴포넌트로 남아 있었다. 화면 로직은 이미 없었지만 `api/search`가 여전히 `thumb_logs(*)`를 select 절에 조인하고 있어, 테이블만 지우면 검색 전체가 `PGRST200`으로 깨지는 상태였다. 조인·모달·훅·API 라우트(`/api/songs/thumb-up`)·타입(`Song.thumb`, `ThumbUpSong`)을 모두 제거했다. diff --git a/packages/crawling/src/cron/translationJpn.ts b/packages/crawling/src/cron/translationJpn.ts index 140f235..254c708 100644 --- a/packages/crawling/src/cron/translationJpn.ts +++ b/packages/crawling/src/cron/translationJpn.ts @@ -14,8 +14,9 @@ import { translateJpnToKo } from '@/utils/translateJpnToKo'; * * 1) 한글이 있으면 한국곡 — 태그 배치(autoTagSong)가 쓰던 첫 규칙 그대로다. * 2) 가나가 있으면 일본곡 — 확정 신호. - * 3) 가나가 없으면 이미 번역된 아티스트인지 본다. 米津玄師처럼 한자뿐인 이름이 - * 여기서 걸린다. 모르는 아티스트면 건드리지 않는다. + * 3) 가나가 없으면 아는 일본 아티스트인지 본다 — artistAlias(수동 사전)에 있거나 + * 이미 번역된 곡이 있는 아티스트. 米津玄師처럼 한자뿐인 이름이 여기서 걸린다. + * 둘 다 아니면 건드리지 않는다. * * 3단계를 넘기지 않는 게 핵심이다. 한자 범위에는 중국어가 함께 들어와서 * (刘德华·费玉清 같은 이름이 후보의 대부분이다) 일본어 번역 프롬프트에 그대로 넣으면 @@ -69,9 +70,12 @@ for (const song of songs) { continue; } - // 가나도 없고 번역 이력도 없는 아티스트면 일본곡이라는 근거가 없다 — 중국곡일 가능성이 높다 + // 가나가 없으면 일본곡이라는 근거를 아티스트에서 찾는다. 근거가 없으면 중국곡일 가능성이 높다. + // artistAlias 를 함께 보는 이유: 사람이 직접 고른 J-POP 전용 사전이라 번역 이력보다 강한 신호다. + // 사전에 새 아티스트를 넣고도 이름이 한자뿐이라 안 잡히는 일이 없어야 한다. const knownArtistKo = dbArtistKoMap.get(song.artist); - if (!KANA_REGEX.test(titleAndArtist) && !knownArtistKo) { + const isKnownJpArtist = Boolean(knownArtistKo) || artistAliasMap.has(song.artist); + if (!KANA_REGEX.test(titleAndArtist) && !isKnownJpArtist) { resultsLog.skippedUnknownLang++; continue; } @@ -136,7 +140,7 @@ for (const song of songs) { console.log(` 후보 ${songs.length}곡 중: - 스킵 (한글 포함 — 한국곡): ${resultsLog.skippedKorean}곡 - - 스킵 (가나 없음 + 번역 이력 없는 아티스트): ${resultsLog.skippedUnknownLang}곡 + - 스킵 (가나 없음 + 모르는 아티스트): ${resultsLog.skippedUnknownLang}곡 - 성공 (AI 번역): ${resultsLog.success}곡 - 성공 (artist_ko alias 적용): ${resultsLog.usedAlias}곡 - 성공 (artist_ko DB 재사용): ${resultsLog.usedDbArtist}곡