-
- 보유 포인트 {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()}’ 검색 결과가 없어요.
)}
-
-
-
+ {!isAuthenticated && (
+
+
로그인하면 참여할 수 있어요
+
이 달의 아티스트를 직접 뽑아주세요.
+
+ )}
);
}
diff --git a/apps/web/src/app/popular/ChartGenreFilter.tsx b/apps/web/src/app/popular/ChartGenreFilter.tsx
index 331317a7..eab0e54c 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_EMOJI[type]}
{STR_TYPE_LABEL[type]}
))}
diff --git a/apps/web/src/app/search/SearchTour.tsx b/apps/web/src/app/search/SearchTour.tsx
index 0cca9232..527981c3 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 상한을 유지한다.
diff --git a/apps/web/src/components/ThumbUpModal.tsx b/apps/web/src/components/ThumbUpModal.tsx
deleted file mode 100644
index b8008b43..00000000
--- 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 139bbe55..1a37ce83 100644
--- a/apps/web/src/constants/tourDemoSong.ts
+++ b/apps/web/src/constants/tourDemoSong.ts
@@ -6,16 +6,15 @@ 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: [],
- 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 6e441f46..00000000
--- 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 3bbfd74c..00000000
--- 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 a9629d7d..895cb16d 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;
-}
diff --git a/apps/web/src/types/tjChart.ts b/apps/web/src/types/tjChart.ts
index 11409d51..72f63e1f 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;
}
diff --git a/packages/crawling/CLAUDE.md b/packages/crawling/CLAUDE.md
index c4d66800..444789fa 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 포맷
@@ -106,8 +105,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 +112,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 +160,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 +168,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` |
diff --git a/packages/crawling/package.json b/packages/crawling/package.json
index f0d54d1c..eda9c431 100644
--- a/packages/crawling/package.json
+++ b/packages/crawling/package.json
@@ -12,7 +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",
diff --git a/packages/crawling/src/cron/removeDeadTjSongs.ts b/packages/crawling/src/cron/removeDeadTjSongs.ts
index f66c7209..613a8af4 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 03c0f805..00000000
--- 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
index feba8756..254c7083 100644
--- a/packages/crawling/src/cron/translationJpn.ts
+++ b/packages/crawling/src/cron/translationJpn.ts
@@ -1,118 +1,148 @@
-// 태그 기능 비활성화로 전체 주석처리. 이 스크립트는 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}곡
-// `);
+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) 가나가 없으면 아는 일본 아티스트인지 본다 — artistAlias(수동 사전)에 있거나
+ * 이미 번역된 곡이 있는 아티스트. 米津玄師처럼 한자뿐인 이름이 여기서 걸린다.
+ * 둘 다 아니면 건드리지 않는다.
+ *
+ * 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;
+ }
+
+ // 가나가 없으면 일본곡이라는 근거를 아티스트에서 찾는다. 근거가 없으면 중국곡일 가능성이 높다.
+ // artistAlias 를 함께 보는 이유: 사람이 직접 고른 J-POP 전용 사전이라 번역 이력보다 강한 신호다.
+ // 사전에 새 아티스트를 넣고도 이름이 한자뿐이라 안 잡히는 일이 없어야 한다.
+ const knownArtistKo = dbArtistKoMap.get(song.artist);
+ const isKnownJpArtist = Boolean(knownArtistKo) || artistAliasMap.has(song.artist);
+ if (!KANA_REGEX.test(titleAndArtist) && !isKnownJpArtist) {
+ 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/deleteDB.ts b/packages/crawling/src/supabase/deleteDB.ts
index c7ede808..7d00ba75 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 09977d95..a2beb27e 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,41 +121,71 @@ export async function getSongsAllWithTjDB(max: number = 100000) {
return data;
}
-export async function getJpopSongsForTranslationDB() {
+// 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[] = [];
- 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);
+ 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;
+ if (error) throw error;
- return data;
+ rows.push(...(data as JpopTranslationTarget[]));
+ if (data.length < CJK_PAGE_SIZE) break;
+ }
+
+ return rows;
}
-// J-POP 곡 중 이미 번역된 artist → artist_ko 맵
-// DB 는 아티스트당 단일 artist_ko 로 정규화되어 있으므로 먼저 만난 값을 사용
+/**
+ * 이미 번역된 곡에서 artist → artist_ko 맵을 만든다. 아티스트당 먼저 만난 값을 쓴다.
+ *
+ * 같은 아티스트 표기를 매번 새로 번역하지 않으려는 게 1차 목적이고, 동시에
+ * "이 아티스트는 일본 아티스트"라는 판별 근거로도 쓴다 — 태그가 하던 역할을 대신한다.
+ * 米津玄師처럼 가나가 하나도 없는 이름은 이 맵에 걸려야 번역 대상으로 잡힌다.
+ */
export async function getArtistKoMapDB(): Promise