From 94b34d5946f6bdc4b4330516bede0fc2fb8583be Mon Sep 17 00:00:00 2001 From: motaju Date: Tue, 9 Sep 2025 06:15:34 +0000 Subject: [PATCH] =?UTF-8?q?=EB=98=90=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/contents/mycontent/route.ts | 132 ++++++++++++++++++ app/components/CalenderSelector.tsx | 84 +++++++++++ app/components/Dashboard.tsx | 116 ++++++++++++--- app/constants/dateRange.ts | 10 ++ app/usr/2_mychannel/page.tsx | 2 + parsingServer/server.js | 44 +++++- .../20250909002928_dev/migration.sql | 45 ++++++ 7 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 app/api/contents/mycontent/route.ts create mode 100644 prisma/migrations/20250909002928_dev/migration.sql diff --git a/app/api/contents/mycontent/route.ts b/app/api/contents/mycontent/route.ts new file mode 100644 index 0000000..8a4b656 --- /dev/null +++ b/app/api/contents/mycontent/route.ts @@ -0,0 +1,132 @@ +export const runtime = 'nodejs' +import { NextResponse } from 'next/server' +import { PrismaClient } from '@/app/generated/prisma' +import { auth } from '@/auth' + +function parseIsoDate(value: string | null): Date | null { + if (!value) return null + const d = new Date(value) + return Number.isFinite(d.getTime()) ? d : null +} + +export async function GET(request: Request) { + const prisma = new PrismaClient() + try { + const session = await auth() + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const startParam = searchParams.get('start') + const endParam = searchParams.get('end') + + const start = parseIsoDate(startParam) + const end = parseIsoDate(endParam) + if (!start || !end) { + return NextResponse.json({ error: 'invalid start/end' }, { status: 400 }) + } + if (start > end) { + return NextResponse.json({ error: 'start after end' }, { status: 400 }) + } + + // 날짜를 UTC 기준 하루 경계로 확장 + const startDay = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0, 0)) + const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 23, 59, 59, 999)) + + // 사용자 소속 핸들 조회 (없으면 사용자 필터 생략) + const userHandles = await prisma.handle.findMany({ + where: { users: { some: { email: session.user.email } } }, + select: { id: true, handle: true }, + }) + const handleIds = userHandles.map(h => h.id) + + // 선택된 채널 필터(query): handleId, handleIds(쉼표), handle, handles(쉼표) + const handleIdParam = searchParams.get('handleId') + const handleIdsParam = searchParams.get('handleIds') + const handleParam = searchParams.get('handle') + const handlesParam = searchParams.get('handles') + let selectedHandleIds: string[] | null = null + if (handleIdParam) selectedHandleIds = [handleIdParam] + if (!selectedHandleIds && handleIdsParam) selectedHandleIds = handleIdsParam.split(',').map(s => s.trim()).filter(Boolean) + if (!selectedHandleIds && (handleParam || handlesParam)) { + const targetHandles = (handlesParam ? handlesParam.split(',') : [handleParam!]).map(s => s!.trim()).filter(Boolean) + if (targetHandles.length > 0) { + const matched = await prisma.handle.findMany({ where: { handle: { in: targetHandles } }, select: { id: true } }) + selectedHandleIds = matched.map(m => m.id) + } + } + + const where: any = { + date: { gte: startDay, lte: endDay }, + } + if (selectedHandleIds && selectedHandleIds.length > 0) { + // 지정된 채널만 + where.content = { handleId: { in: selectedHandleIds } } + } else if (handleIds.length > 0) { + // 사용자 소속 채널만 + where.content = { handleId: { in: handleIds } } + } + + const grouped = await prisma.contentDayView.groupBy({ + by: ['contentId'], + where, + _sum: { + views: true, + validViews: true, + premiumViews: true, + watchTime: true, + }, + }) + + const contentIds = grouped.map(g => g.contentId).filter(Boolean) + const contents = contentIds.length > 0 ? await prisma.content.findMany({ + where: { id: { in: contentIds } }, + select: { + id: true, + subject: true, + pubDate: true, + handle: { select: { id: true, handle: true, avatar: true, costPerView: true } }, + }, + }) : [] + const idToContent = new Map(contents.map(c => [c.id, c])) + + const items = grouped.map(g => { + const c = idToContent.get(g.contentId) as any + const h = c?.handle as (undefined | { id: string, handle: string, avatar: string | null, costPerView?: number }) + const views = g._sum?.views ?? 0 + const validViews = g._sum?.validViews ?? 0 + const premiumViews = g._sum?.premiumViews ?? 0 + const watchTime = g._sum?.watchTime ?? 0 + const cpv = typeof h?.costPerView === 'number' ? h!.costPerView! : 0 + const expectedRevenue = Math.max(0, Math.round(validViews * cpv)) + return { + id: c?.id ?? g.contentId, + subject: c?.subject ?? '', + pubDate: (c?.pubDate ?? null) as any, + views, + premiumViews, + watchTime, + handle: h?.handle ?? '', + handleId: h?.id ?? null, + icon: h?.avatar ?? '', + validViews, + expectedRevenue, + } + }) + items.sort((a, b) => { + const pa = a.pubDate ? new Date(a.pubDate as any).getTime() : 0 + const pb = b.pubDate ? new Date(b.pubDate as any).getTime() : 0 + return pb - pa + }) + + return NextResponse.json({ success: true, start: startDay.toISOString(), end: endDay.toISOString(), count: items.length, items }) + } catch (e) { + console.error('[contents/mycontent] error:', e) + return NextResponse.json({ error: 'query failed' }, { status: 500 }) + } finally { + try { await (prisma as any).$disconnect() } catch {} + } +} + + diff --git a/app/components/CalenderSelector.tsx b/app/components/CalenderSelector.tsx index 22fe515..7a119ca 100644 --- a/app/components/CalenderSelector.tsx +++ b/app/components/CalenderSelector.tsx @@ -39,6 +39,11 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}:
{selected === DateRangeEnum.DAY_RANGE && `${rangeStart.toISOString().split('T')[0]} ~ ${rangeEnd.toISOString().split('T')[0]}`} {selected === DateRangeEnum.ONE_MONTH&& '최근 1개월'} + {selected === DateRangeEnum.ONE_WEEK&& '최근 1주일'} + {selected === DateRangeEnum.TWO_MONTHS&& '최근 2개월'} + {selected === DateRangeEnum.THREE_MONTHS&& '최근 3개월'} + {selected === DateRangeEnum.SIX_MONTHS&& '최근 6개월'} + {selected === DateRangeEnum.ONE_YEAR&& '최근 1년'} {selected === DateRangeEnum.ALL&& '전체'}
@@ -65,6 +70,22 @@ export default function CalenderSelector( {dateString, is_small, onRangeChange}: 전체
+ +
+ +
+ +
+ +
+
{ diff --git a/app/components/Dashboard.tsx b/app/components/Dashboard.tsx index 471fccf..1fe99f5 100644 --- a/app/components/Dashboard.tsx +++ b/app/components/Dashboard.tsx @@ -53,14 +53,16 @@ export default function Page({user}: {user: any}) { const [myChannelList, setMyChannelList] = useState([]); const [visibleChannelIds, setVisibleChannelIds] = useState>(new Set()); const [channelsReady, setChannelsReady] = useState(false); + const fetchListChannel = useCallback(async (options?: { cache?: RequestCache; signal?: AbortSignal; }): Promise => { - const response = await fetch('/api/list_channel', { + const response = await fetch('/api/channel/list', { cache: options?.cache ?? 'no-store', signal: options?.signal, }); + console.log('list_channel response:', response); if (!response.ok) { let message = ''; @@ -141,13 +143,18 @@ export default function Page({user}: {user: any}) { setSortKey(key); }, [sortKey]); + // 그래프에 표시할 선택 지표 + const [selectedMetric, setSelectedMetric] = useState<'views'|'validViews'|'premiumViews'|'expectedRevenue'>('validViews') + const fetchMyContents = useCallback(async (s: Date, e: Date) => { const qs = `start=${encodeURIComponent(s.toISOString())}&end=${encodeURIComponent(e.toISOString())}`; - const res = await fetch(`/api/my_contents?${qs}`, { cache: 'no-store' }); + const res = await fetch(`/api/contents/mycontent?${qs}`, { cache: 'no-store' }); const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'LOAD_FAILED'); const items = data.items ?? []; setCpv(data.cpv ?? 0); + console.log(items) setRows(items); // 초기 로드 시 전체 선택 setCheckedIds(new Set(items.map((it: any) => it.id))); @@ -248,6 +255,7 @@ export default function Page({user}: {user: any}) { // }, []); + useEffect(() => { const handleResize = () => { window.resizeTo(500, 500); @@ -283,6 +291,7 @@ export default function Page({user}: {user: any}) { // 빈 집합은 "아무 채널도 선택되지 않음"으로 간주하여 행을 숨깁니다 const visibleRows = rows.filter(r => visibleChannelIds.size > 0 && r.handleId && visibleChannelIds.has(r.handleId)); + console.log(visibleRows) const sortedVisibleRows = useMemo(() => { const copy = [...visibleRows]; const factor = sortDir === 'asc' ? 1 : -1; @@ -318,10 +327,50 @@ export default function Page({user}: {user: any}) { return { views, premium, valid, revenue: Math.round(revenue) }; }, [sortedVisibleRows, checkedIds]); - // 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제 + // 그래프 데이터/옵션 + const metricLabel: Record<'views'|'validViews'|'premiumViews'|'expectedRevenue', string> = { + views: '총 조회수', + validViews: '유효 조회수', + premiumViews: '프리미엄 조회수', + expectedRevenue: '예상 수익', + } + const metricColor: Record<'views'|'validViews'|'premiumViews'|'expectedRevenue', { border: string, bg: string }> = { + views: { border: '#1f6feb', bg: 'rgba(31, 111, 235, 0.25)' }, + validViews: { border: '#F94B37', bg: 'rgba(249, 75, 55, 0.25)' }, + premiumViews: { border: '#7C3AED', bg: 'rgba(124, 58, 237, 0.25)' }, + expectedRevenue: { border: '#16A34A', bg: 'rgba(22, 163, 74, 0.25)' }, + } + const selectedRows = useMemo(() => sortedVisibleRows.filter(r => checkedIds.has(r.id)), [sortedVisibleRows, checkedIds]) + const chartData = useMemo(() => { + const labels = selectedRows.map(r => r.subject) + const dataVals = selectedRows.map(r => (r as any)[selectedMetric] as number) + const col = metricColor[selectedMetric] + return { + labels, + datasets: [{ + label: metricLabel[selectedMetric], + data: dataVals, + borderColor: col.border, + backgroundColor: col.bg, + tension: 0.35, + }] + } + }, [selectedRows, selectedMetric]) + const chartOptions = useMemo(() => ({ + responsive: true, + plugins: { legend: { display: false }, title: { display: false } }, + scales: { x: { grid: { display: false } }, y: { grid: { display: true } } }, + elements: { line: { tension: 0.35 } }, + }), []) + + // 채널 필터가 변경되면, 보이지 않는 콘텐츠는 선택 해제하되 + // 초기 로딩 등으로 가시 채널이 비어있을 땐 해제하지 않음. + // 가시 채널이 채워지고 선택이 비어있으면 전체 선택으로 초기화. useEffect(() => { + if (visibleChannelIds.size === 0) return; const visibleIdSet = new Set(visibleRows.map(r => r.id)); setCheckedIds(prev => { + if (prev.size === 0) return visibleIdSet; // 기본 전체 선택 const next = new Set(Array.from(prev).filter(id => visibleIdSet.has(id))); return next; }); @@ -440,10 +489,10 @@ export default function Page({user}: {user: any}) { handleSort('premiumViews')}>premium 조회수 {sortDir==='asc'?'▲':'▼'} - handleSort('validViews')}>유효 조회수 + handleSort('validViews')}>유효 조회수 {sortDir==='asc'?'▲':'▼'} - handleSort('expectedRevenue')}>예상수익 + handleSort('expectedRevenue')}>예상수익 {sortDir==='asc'?'▲':'▼'} @@ -471,14 +520,24 @@ export default function Page({user}: {user: any}) { }} /> - -
- {r.icon && icon} + +
+ {r.icon && icon} {r.handle}
{new Date(r.pubDate).toISOString().slice(0,10)} - {r.subject} + + + {r.subject} + + {formatNumberWithCommas(r.watchTime)} {formatNumberWithCommas(r.views)} {formatNumberWithCommas(r.premiumViews)} @@ -488,11 +547,21 @@ export default function Page({user}: {user: any}) { ))} + {sortedVisibleRows.length === 1 && ( + + +
+ + + )}
-
+
setSelectedMetric('views')} + >
총 조회수
{/*
*/} @@ -503,7 +572,10 @@ export default function Page({user}: {user: any}) {
-
+
setSelectedMetric('premiumViews')} + >
프리미엄 조회수
@@ -514,7 +586,10 @@ export default function Page({user}: {user: any}) { {/*
+ 0.24%
*/}
-
+
setSelectedMetric('validViews')} + >
유효 조회수
{/*
*/} @@ -525,7 +600,10 @@ export default function Page({user}: {user: any}) { {/*
+ 0.24%
*/}
-
+
setSelectedMetric('expectedRevenue')} + >
예상 수익
@@ -536,15 +614,19 @@ export default function Page({user}: {user: any}) { {/*
- 1.24%
*/}
-
-
유효 조회수 추이
+
+
{metricLabel[selectedMetric]} 추이
상위 테이블에서 설정한 데이터를 토대로 그래프가 표출됩니다.
-
33,500
+
{formatNumberWithCommas( + selectedMetric === 'views' ? totals.views : + selectedMetric === 'validViews' ? totals.valid : + selectedMetric === 'premiumViews' ? totals.premium : totals.revenue + )}
{/*
+ 0.24%
*/}
- +
diff --git a/app/constants/dateRange.ts b/app/constants/dateRange.ts index 56cb937..a5c9767 100644 --- a/app/constants/dateRange.ts +++ b/app/constants/dateRange.ts @@ -3,6 +3,11 @@ export enum DateRangeEnum { ALL= 1, ONE_MONTH= 2, DAY_RANGE = 3, + ONE_WEEK = 4, + TWO_MONTHS = 5, + THREE_MONTHS = 6, + SIX_MONTHS = 7, + ONE_YEAR = 8, } // 필요 시 값 배열 등 유틸도 함께 노출 가능 @@ -10,6 +15,11 @@ export const DATE_RANGE_VALUES = [ DateRangeEnum.ALL, DateRangeEnum.ONE_MONTH, DateRangeEnum.DAY_RANGE, + DateRangeEnum.ONE_WEEK, + DateRangeEnum.TWO_MONTHS, + DateRangeEnum.THREE_MONTHS, + DateRangeEnum.SIX_MONTHS, + DateRangeEnum.ONE_YEAR, ] as const; diff --git a/app/usr/2_mychannel/page.tsx b/app/usr/2_mychannel/page.tsx index 83f08a7..4a923c1 100644 --- a/app/usr/2_mychannel/page.tsx +++ b/app/usr/2_mychannel/page.tsx @@ -70,7 +70,9 @@ export default function Page() { try { const resp = await fetch('/api/channel/mycode', { cache: 'no-store' }); const data = await resp.json(); + console.log('register_code:', data); setRegisterCode(data.registerCode); + } catch (e) { console.error('register_code 요청 에러:', e); } diff --git a/parsingServer/server.js b/parsingServer/server.js index 5f8098b..28fc662 100644 --- a/parsingServer/server.js +++ b/parsingServer/server.js @@ -345,6 +345,24 @@ const server = http.createServer((req, res) => { } // 아바타 이미지 src 추출 + let handleText = null; + // 채널 핸들(@...) 추출: 헤더 텍스트 → canonical 폴백 + try { + const handleNode = header.locator('.yt-core-attributed-string:has-text("@")').first(); + await handleNode.waitFor({ state: 'visible', timeout: 4000 }); + const txt = (await handleNode.innerText())?.trim(); + const m = txt?.match(/@[^\s•|]+/); + if (m && m[0]) handleText = m[0]; + } catch {} + if (!handleText) { + try { + const href = await page.locator('link[rel="canonical"]').getAttribute('href'); + if (href) { + const mm = href.match(/https?:\/\/www\.youtube\.com\/(%2F)?(@[^/?#]+)/); + if (mm && mm[2]) handleText = decodeURIComponent(mm[2]); + } + } catch {} + } let avatar = null; try { const img = header.locator('img[src^="https://yt3.googleusercontent.com/"]').first(); @@ -354,7 +372,7 @@ const server = http.createServer((req, res) => { await context.close(); await browser.close(); - sendJson(res, 200, { success: true, foundtext, avatar }); + sendJson(res, 200, { success: true, foundtext, avatar, handle: handleText }); } catch (e) { try { await browser?.close(); } catch {} console.error('[isCodeMatch] playwright error:', e); @@ -447,6 +465,30 @@ const server = http.createServer((req, res) => { if (t && t.startsWith('@')) handleText = t; if (handleText) console.log(`[gethandle] (${id}) channel page handle found:`, handleText); } catch {} + // 대체1: 채널 헤더 내 텍스트 기반(@...) 추출 + if (!handleText) { + try { + console.log(`[gethandle] (${id}) try channel header text selector: #page-header .yt-core-attributed-string:has-text("@")`); + const textNode = page.locator('#page-header .yt-core-attributed-string:has-text("@")').first(); + await textNode.waitFor({ state: 'visible', timeout: 4000 }); + const txt = (await textNode.innerText())?.trim(); + const m = txt?.match(/@[^\s•|]+/); + if (m && m[0]) handleText = m[0]; + if (handleText) console.log(`[gethandle] (${id}) channel header text handle found:`, handleText); + } catch {} + } + // 대체2: canonical 링크에서 @handle 파싱 + if (!handleText) { + try { + console.log(`[gethandle] (${id}) try canonical link for handle`); + const href = await page.locator('link[rel="canonical"]').getAttribute('href'); + if (href) { + const m = href.match(/https?:\/\/www\.youtube\.com\/(%2F)?(@[^/?#]+)/); + if (m && m[2]) handleText = decodeURIComponent(m[2]); + } + if (handleText) console.log(`[gethandle] (${id}) canonical handle found:`, handleText); + } catch {} + } // 채널 아바타 try { console.log(`[gethandle] (${id}) try channel page avatar selector: img[src*="yt3"]`); diff --git a/prisma/migrations/20250909002928_dev/migration.sql b/prisma/migrations/20250909002928_dev/migration.sql new file mode 100644 index 0000000..169a800 --- /dev/null +++ b/prisma/migrations/20250909002928_dev/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - You are about to drop the column `constPerView` on the `Handle` table. All the data in the column will be lost. + - You are about to drop the `contentDayView` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `Content` DROP FOREIGN KEY `Content_handleId_fkey`; + +-- DropForeignKey +ALTER TABLE `contentDayView` DROP FOREIGN KEY `contentDayView_contentId_fkey`; + +-- DropIndex +DROP INDEX `Content_handleId_fkey` ON `Content`; + +-- AlterTable +ALTER TABLE `Content` MODIFY `handleId` VARCHAR(191) NULL; + +-- AlterTable +ALTER TABLE `Handle` DROP COLUMN `constPerView`, + ADD COLUMN `costPerView` DOUBLE NOT NULL DEFAULT 1; + +-- DropTable +DROP TABLE `contentDayView`; + +-- CreateTable +CREATE TABLE `ContentDayView` ( + `id` VARCHAR(191) NOT NULL, + `contentId` VARCHAR(191) NOT NULL, + `date` DATETIME(3) NOT NULL, + `views` INTEGER NOT NULL, + `validViews` INTEGER NOT NULL, + `premiumViews` INTEGER NOT NULL, + `watchTime` INTEGER NOT NULL, + + UNIQUE INDEX `ContentDayView_contentId_date_key`(`contentId`, `date`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Content` ADD CONSTRAINT `Content_handleId_fkey` FOREIGN KEY (`handleId`) REFERENCES `Handle`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ContentDayView` ADD CONSTRAINT `ContentDayView_contentId_fkey` FOREIGN KEY (`contentId`) REFERENCES `Content`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;