Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/crawl_tj_chart.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Crawl TJ Chart

on:
schedule:
- cron: "0 1 1 * *" # 매달 1일 KST 오전 10:00 실행 (UTC+9 → UTC 01:00)
workflow_dispatch:

jobs:
run-npm-task:
runs-on: ubuntu-latest

steps:
- 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

- name: run crawl script - crawlTjChart.ts
working-directory: packages/crawling
run: pnpm run tj-chart
69 changes: 69 additions & 0 deletions apps/web/src/app/api/tj-chart/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';

import createClient from '@/lib/supabase/server';
import { ApiResponse } from '@/types/apiRoute';
import { Song } from '@/types/song';
import { StrType, TjChartResponse } from '@/types/tjChart';

interface ChartRow {
rank: number;
songs: Song | null;
}

export async function GET(
request: NextRequest,
): Promise<NextResponse<ApiResponse<TjChartResponse>>> {
try {
const supabase = await createClient();
const searchParams = request.nextUrl.searchParams;

const genreParam = searchParams.get('genre') ?? StrType.All;
const genre = Object.values(StrType).includes(genreParam as StrType)
? (genreParam as StrType)
: StrType.All;

// 1) 데이터가 존재하는 월 목록 조회
const { data: monthRows, error: monthError } = await supabase
.from('chart_rankings')
.select('chart_month')
.order('chart_month', { ascending: false });
Comment on lines +25 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Incomplete month list 🐞 Bug ≡ Correctness

GET /api/tj-chart가 chart_rankings의 모든 row에서 chart_month를 가져와 availableMonths를 만들기 때문에, Supabase 응답
row 제한/트렁케이션으로 오래된 월이 누락될 수 있습니다. 그 결과 월 선택 UI가 실제 DB에 있는 과거 월 데이터를 숨길 수 있습니다.
Agent Prompt
### Issue description
`/api/tj-chart`에서 `availableMonths`를 만들기 위해 `chart_rankings` 전체에서 `chart_month`를 조회하고 `Set`으로 중복 제거하고 있습니다. `chart_rankings`는 월/장르/순위 단위로 row 수가 많아지기 때문에, Supabase가 한 번의 select에서 반환하는 row 수 제한에 걸려 과거 월이 응답에 포함되지 않을 수 있고, 그 상태로 `availableMonths`를 계산하면 월 목록이 잘못됩니다.

### Issue Context
- 크롤러는 `StrType` 전체에 대해 TOP 차트를 수집/저장하므로 월 단위로 다수의 row가 생성됩니다.
- 레포 내 Supabase 조회 코드에서도 row 제한 존재를 인지하고 `.limit(...)`를 사용하는 패턴이 이미 있습니다.

### Fix Focus Areas
- apps/web/src/app/api/tj-chart/route.ts[25-45]

### Suggested fix
- 월 목록 조회 쿼리를 **중복이 구조적으로 발생하지 않도록** 바꾸세요. 예를 들어 월당 1개 row만 나오도록 고정 조건을 추가하면 됩니다.
  - 예: `type = StrType.All` AND `rank = 1`만 조회
  - 또는 DB view/RPC로 `select distinct chart_month ...`를 제공

예시(개념):
```ts
const { data: monthRows, error: monthError } = await supabase
  .from('chart_rankings')
  .select('chart_month')
  .eq('type', StrType.All)
  .eq('rank', 1)
  .order('chart_month', { ascending: false });

const availableMonths = (monthRows ?? []).map(r => r.chart_month as string);
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (monthError) throw monthError;

const availableMonths = [...new Set((monthRows ?? []).map(row => row.chart_month as string))];

if (availableMonths.length === 0) {
return NextResponse.json({
success: true,
data: { month: '', genre, availableMonths: [], items: [] },
});
}

const monthParam = searchParams.get('month');
const targetMonth =
monthParam && availableMonths.includes(monthParam) ? monthParam : availableMonths[0];

// 2) 해당 월 + 장르의 차트 순위 조회 (songs 테이블과 조인)
const { data, error } = await supabase
.from('chart_rankings')
.select('rank, songs(id, title, artist, title_ko, artist_ko, num_tj, num_ky)')
.eq('chart_month', targetMonth)
.eq('type', genre)
.order('rank', { ascending: true })
.returns<ChartRow[]>();

if (error) throw error;

const items = (data ?? [])
.filter((row): row is ChartRow & { songs: Song } => row.songs !== null)
.map(row => ({ ...row.songs, rank: row.rank }));

return NextResponse.json({
success: true,
data: { month: targetMonth, genre, availableMonths, items },
});
} catch (error) {
console.error('Error in tj-chart API:', error);
return NextResponse.json({ success: false, error: 'Failed to get tj chart' }, { status: 500 });
}
}
148 changes: 148 additions & 0 deletions apps/web/src/app/popular/TjChartRankingList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client';

import { Construction } from 'lucide-react';
import { useEffect, useState } from 'react';

import MarqueeText from '@/components/MarqueeText';
import StaticLoading from '@/components/StaticLoading';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTjChartQuery } from '@/queries/tjChartQuery';
import { STR_TYPE_LABEL, StrType } from '@/types/tjChart';
import { cn } from '@/utils/cn';

const getRankStyle = (rank: number) => {
switch (rank) {
case 1:
return 'bg-amber-500 text-white font-bold';
case 2:
return 'bg-gray-300 text-white font-bold';
case 3:
return 'bg-amber-700 text-white font-bold';
default:
return 'bg-muted text-muted-foreground';
}
};

const formatMonth = (month: string) => {
const [year, m] = month.split('-');
return `${year}년 ${Number(m)}월`;
};

export default function TjChartRankingList() {
const [genre, setGenre] = useState<StrType>(StrType.All);
const [month, setMonth] = useState<string | undefined>(undefined);

const { data, isPending, isError } = useTjChartQuery(month, genre);

useEffect(() => {
if (data?.month && !month) {
setMonth(data.month);
}
}, [data?.month, month]);

if (isPending) {
return <StaticLoading />;
}

const availableMonths = data?.availableMonths ?? [];
const items = data?.items ?? [];

return (
<Card className="relative flex min-h-0 flex-1 flex-col">
<CardHeader className="flex shrink-0 flex-col gap-3 pb-2">
<CardTitle className="text-xl">TJ 인기차트</CardTitle>

<div className="flex gap-2">
<Select value={month} onValueChange={setMonth} disabled={availableMonths.length === 0}>
<SelectTrigger className="w-[120px]" size="sm">
<SelectValue placeholder="월 선택" />
</SelectTrigger>
<SelectContent>
{availableMonths.map(m => (
<SelectItem key={m} value={m}>
{formatMonth(m)}
</SelectItem>
))}
</SelectContent>
</Select>

<Select value={genre} onValueChange={value => setGenre(value as StrType)}>
<SelectTrigger className="w-[110px]" size="sm">
<SelectValue placeholder="장르 선택" />
</SelectTrigger>
<SelectContent>
{Object.values(StrType).map(type => (
<SelectItem key={type} value={type}>
{STR_TYPE_LABEL[type]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>

<ScrollArea className="min-h-0 flex-1">
<CardContent className="pt-0">
<div className="space-y-0">
{isError || items.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-4">
<Construction className="text-muted-foreground h-16 w-16" />
<p className="text-muted-foreground text-xl">데이터를 준비중이에요</p>
</div>
) : (
items.map(item => (
<div key={item.id} className={cn('flex gap-4 border-b py-3 last:border-0')}>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
getRankStyle(item.rank),
)}
>
{item.rank}
</div>
<div className="flex w-full justify-between gap-2">
<div className="w-[140px] shrink-0">
<MarqueeText className="text-sm font-medium">{item.title}</MarqueeText>
{item.title_ko && item.title_ko !== item.title && (
<MarqueeText className="text-muted-foreground text-xs">
{item.title_ko}
</MarqueeText>
)}
<MarqueeText className="text-muted-foreground text-xs">
{item.artist}
</MarqueeText>
{item.artist_ko && item.artist_ko !== item.artist && (
<MarqueeText className="text-muted-foreground/70 text-xs">
{item.artist_ko}
</MarqueeText>
)}
</div>

<div>
<div className="flex items-center">
<span className="text-brand-tj mr-1 w-8 text-xs">TJ</span>
<span className="text-sm font-medium">{item.num_tj}</span>
</div>
<div className="flex items-center">
<span className="text-brand-ky mr-1 w-8 text-xs">금영</span>
<span className="text-sm font-medium">{item.num_ky}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
</CardContent>
</ScrollArea>
</Card>
);
}
6 changes: 3 additions & 3 deletions apps/web/src/app/popular/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import PopularRankingList from './PopularRankingList';
import TjChartRankingList from './TjChartRankingList';

export default function PopularPage() {
return (
<div className="flex h-full flex-col gap-4">
<h1 className="shrink-0 text-2xl font-bold">인기 노래</h1>

{/* 추천 곡 순위 */}
{/* TJ 공식 차트 기반 인기 순위 */}

<PopularRankingList />
<TjChartRankingList />
</div>
);
}
12 changes: 12 additions & 0 deletions apps/web/src/lib/api/tjChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiResponse } from '@/types/apiRoute';
import { StrType, TjChartResponse } from '@/types/tjChart';

import { instance } from './client';

export async function getTjChart(month?: string, genre?: StrType) {
const response = await instance.get<ApiResponse<TjChartResponse>>('/tj-chart', {
params: { month, genre },
});

return response.data;
}
18 changes: 18 additions & 0 deletions apps/web/src/queries/tjChartQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';

import { getTjChart } from '@/lib/api/tjChart';
import { StrType } from '@/types/tjChart';

export const useTjChartQuery = (month?: string, genre?: StrType) => {
return useQuery({
queryKey: ['tjChart', month, genre],
queryFn: async () => {
const response = await getTjChart(month, genre);

if (!response.success) {
return null;
}
return response.data;
},
});
};
44 changes: 44 additions & 0 deletions apps/web/src/types/tjChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Song } from './song';

// TJ 공식 차트(topAndHot100) 장르 구분값 (DB 저장용, 영어)
// 참고: https://www.tjmedia.com/chart/top100
export enum StrType {
All = 'all',
Kpop = 'kpop',
Pop = 'pop',
Jpop = 'jpop',
Ballad = 'ballad',
Dance = 'dance',
Trot = 'trot',
Folk = 'folk',
Ost = 'ost',
RockMetal = 'rock_metal',
RapHiphop = 'rap_hiphop',
RnbUrban = 'rnb_urban',
}

export const STR_TYPE_LABEL: Record<StrType, string> = {
[StrType.All]: '종합',
[StrType.Kpop]: '가요',
[StrType.Pop]: 'POP',
[StrType.Jpop]: 'JPOP',
[StrType.Ballad]: '발라드',
[StrType.Dance]: '댄스',
[StrType.Trot]: '트로트',
[StrType.Folk]: '포크',
[StrType.Ost]: 'OST',
[StrType.RockMetal]: '락/메탈',
[StrType.RapHiphop]: '랩/힙합',
[StrType.RnbUrban]: 'R&B/어반',
};

export interface TjChartRankingSong extends Song {
rank: number;
}

export interface TjChartResponse {
month: string;
genre: StrType;
availableMonths: string[];
items: TjChartRankingSong[];
}
2 changes: 2 additions & 0 deletions packages/crawling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"tag-songs": "tsx src/cron/taggingSongs.ts",
"trans-jpn": "tsx src/cron/translationJpn.ts",
"tj-all-number": "tsx src/cron/crawlAllTJSongByNumber.ts",
"tj-chart": "tsx src/cron/crawlTjChart.ts",
"tj-chart-backfill": "tsx src/cron/crawlTjChartBackfill.ts",
"lint": "eslint .",
"test": "vitest run",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
Expand Down
Loading